cloudflare/pint
Publicmirrored from https://github.com/cloudflare/pintAvailable
internal/diags/problems.go
313lines · modecode
| 1 | package diags |
| 2 | |
| 3 | import ( |
| 4 | "cmp" |
| 5 | "fmt" |
| 6 | "slices" |
| 7 | "strings" |
| 8 | |
| 9 | "github.com/cloudflare/pint/internal/output" |
| 10 | ) |
| 11 | |
| 12 | func countDigits(n int) (c int) { |
| 13 | for n > 0 { |
| 14 | n /= 10 |
| 15 | c++ |
| 16 | } |
| 17 | return c |
| 18 | } |
| 19 | |
| 20 | // wrapText splits text at word boundaries so that each line fits within width. |
| 21 | func wrapText(text string, width int) []string { |
| 22 | words := strings.Fields(text) |
| 23 | |
| 24 | var lines []string |
| 25 | current := words[0] |
| 26 | curWidth := len(current) |
| 27 | |
| 28 | for _, word := range words[1:] { |
| 29 | if curWidth+1+len(word) > width { |
| 30 | lines = append(lines, current) |
| 31 | current = word |
| 32 | curWidth = len(word) |
| 33 | } else { |
| 34 | current += " " + word |
| 35 | curWidth += 1 + len(word) |
| 36 | } |
| 37 | } |
| 38 | lines = append(lines, current) |
| 39 | return lines |
| 40 | } |
| 41 | |
| 42 | // writeWrappedMessage writes msg to buf, wrapping it at word boundaries. |
| 43 | // indent is the number of leading spaces to write before each line. |
| 44 | // msgWidth is the maximum width of each line. |
| 45 | // When alignRight > 0 the first line is right-aligned so its last character |
| 46 | // sits at column alignRight-1 (0-indexed); subsequent lines left-align to |
| 47 | // the same starting column as the first line. |
| 48 | func writeWrappedMessage(buf *strings.Builder, msg string, color output.Color, indent, msgWidth, alignRight int) int { |
| 49 | if len(msg) == 0 { |
| 50 | return indent |
| 51 | } |
| 52 | |
| 53 | var parts []string |
| 54 | if len(msg) <= msgWidth { |
| 55 | parts = []string{msg} |
| 56 | } else { |
| 57 | parts = wrapText(msg, msgWidth) |
| 58 | } |
| 59 | |
| 60 | for i, part := range parts { |
| 61 | pad := indent |
| 62 | if alignRight > 0 && i == 0 { |
| 63 | pad = max(indent, alignRight-len(part)) |
| 64 | } |
| 65 | // After the first line, lock indent to where the first line started. |
| 66 | if i == 0 { |
| 67 | indent = pad |
| 68 | } |
| 69 | buf.WriteString(strings.Repeat(" ", pad)) |
| 70 | buf.WriteString(output.MaybeColor(color, color == output.None, part)) |
| 71 | buf.WriteRune('\n') |
| 72 | } |
| 73 | return indent |
| 74 | } |
| 75 | |
| 76 | type Kind uint8 |
| 77 | |
| 78 | const ( |
| 79 | Issue Kind = iota |
| 80 | Context |
| 81 | ) |
| 82 | |
| 83 | type Diagnostic struct { |
| 84 | Message string |
| 85 | Pos PositionRanges `yaml:"-"` |
| 86 | FirstColumn int // 1-indexed |
| 87 | LastColumn int // 1-indexed |
| 88 | Kind Kind |
| 89 | } |
| 90 | |
| 91 | // maxLineWidth is the maximum number of characters to print for a single line. |
| 92 | const maxLineWidth = 100 |
| 93 | |
| 94 | type lineTrim struct { |
| 95 | line string // the (possibly trimmed) line text to display |
| 96 | } |
| 97 | |
| 98 | // trimLine shortens lines longer than maxLineWidth while keeping all diagnostics visible. |
| 99 | // It uses AST-driven trimming for PromQL expressions, replacing sub-expressions that do |
| 100 | // not overlap with diagnostics with "...". If the line is not valid PromQL or no nodes |
| 101 | // can be replaced, the line is left untrimmed. |
| 102 | func trimLine(line string, diags []Diagnostic, diagPositions []PositionRanges, lineNum int) lineTrim { |
| 103 | if len(line) <= maxLineWidth { |
| 104 | return lineTrim{line: line} |
| 105 | } |
| 106 | |
| 107 | // Try AST-based trimming. If it doesn't apply or the line is still |
| 108 | // too long, keep the full line — we don't use blind window-based |
| 109 | // trimming because it strips useful context. |
| 110 | if newLine, ok := astTrimLine(line, diags, diagPositions, lineNum); ok { |
| 111 | line = newLine |
| 112 | } |
| 113 | |
| 114 | return lineTrim{line: line} |
| 115 | } |
| 116 | |
| 117 | func lineCoverage(diags []Diagnostic) (lines []int) { |
| 118 | for _, diag := range diags { |
| 119 | for _, pos := range diag.Pos { |
| 120 | if !slices.Contains(lines, pos.Line) { |
| 121 | lines = append(lines, pos.Line) |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | slices.Sort(lines) |
| 126 | return lines |
| 127 | } |
| 128 | |
| 129 | func InjectDiagnostics(content string, d []Diagnostic, color output.Color) string { |
| 130 | diags := make([]Diagnostic, len(d)) |
| 131 | copy(diags, d) |
| 132 | |
| 133 | lines := lineCoverage(diags) |
| 134 | lastLine := slices.Max(lines) |
| 135 | |
| 136 | // Sort diagnostics by FirstColumn descending so that rightmost carets |
| 137 | // are printed first — this ensures inner carets don't overwrite outer ones. |
| 138 | slices.SortStableFunc(diags, func(a, b Diagnostic) int { |
| 139 | return cmp.Compare(b.FirstColumn, a.FirstColumn) |
| 140 | }) |
| 141 | |
| 142 | diagPositions := make([]PositionRanges, len(diags)) |
| 143 | for i, diag := range diags { |
| 144 | dl := diag.Pos.Len() |
| 145 | diagPositions[i] = readRange( |
| 146 | min(diag.FirstColumn, dl), |
| 147 | min(diag.LastColumn, dl), |
| 148 | diag.Pos, |
| 149 | ) |
| 150 | } |
| 151 | |
| 152 | var buf strings.Builder |
| 153 | nextLine := make([]strings.Builder, len(diags)) |
| 154 | needsNextLine := make([]bool, len(diags)) |
| 155 | msgIndent := make([]int, len(diags)) |
| 156 | |
| 157 | // When two diagnostics have the exact same range, only the first one prints |
| 158 | // underline characters. Subsequent ones skip the caret line but still print |
| 159 | // their message aligned with the first diagnostic's underline. |
| 160 | disablePoints := make([]bool, len(diags)) |
| 161 | for i, a := range diags { |
| 162 | for j := range i { |
| 163 | b := diags[j] |
| 164 | if a.FirstColumn == b.FirstColumn && a.LastColumn == b.LastColumn { |
| 165 | disablePoints[i] = true |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | digits := countDigits(lastLine) |
| 171 | nrFmt := fmt.Sprintf("%%%dd", digits) |
| 172 | |
| 173 | var lastWriteLine int |
| 174 | for lineIndex, line := range strings.Split(content, "\n") { |
| 175 | |
| 176 | if lineIndex+1 > lastLine { |
| 177 | break |
| 178 | } |
| 179 | if !slices.Contains(lines, lineIndex+1) { |
| 180 | continue |
| 181 | } |
| 182 | |
| 183 | for i := range diags { |
| 184 | needsNextLine[i] = false |
| 185 | if lineIndex+1 == diagPositions[i].Lines().Last { |
| 186 | needsNextLine[i] = true |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | trim := trimLine(line, diags, diagPositions, lineIndex+1) |
| 191 | line = trim.line |
| 192 | |
| 193 | prefix := fmt.Sprintf(nrFmt+" | ", lineIndex+1) |
| 194 | |
| 195 | if lastWriteLine > 0 && lineIndex+1-lastWriteLine > 1 { |
| 196 | buf.WriteString(output.MaybeColor(output.White, color == output.None, strings.Repeat(" ", digits))) |
| 197 | buf.WriteString(" | [...]\n") |
| 198 | } |
| 199 | lastWriteLine = lineIndex + 1 |
| 200 | |
| 201 | buf.WriteString(output.MaybeColor(output.White, color == output.None, prefix)) |
| 202 | for i, ok := range needsNextLine { |
| 203 | if ok { |
| 204 | nextLine[i].WriteString(strings.Repeat(" ", digits+3)) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | for columnIndex, r := range line { |
| 209 | buf.WriteRune(r) |
| 210 | |
| 211 | for i, ok := range needsNextLine { |
| 212 | if !ok { |
| 213 | continue |
| 214 | } |
| 215 | for _, pos := range diagPositions[i] { |
| 216 | if pos.Line != lineIndex+1 { |
| 217 | continue |
| 218 | } |
| 219 | before := pos.FirstColumn > columnIndex+1 |
| 220 | inside := pos.FirstColumn <= columnIndex+1 && pos.LastColumn >= columnIndex+1 |
| 221 | switch { |
| 222 | case before: |
| 223 | nextLine[i].WriteRune(' ') |
| 224 | case inside && disablePoints[i]: |
| 225 | nextLine[i].WriteRune(' ') |
| 226 | case inside && !disablePoints[i]: |
| 227 | nextLine[i].WriteRune('^') |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 | buf.WriteRune('\n') |
| 233 | |
| 234 | for i, ok := range needsNextLine { |
| 235 | if ok { |
| 236 | caretLine := nextLine[i].String() |
| 237 | |
| 238 | // indent is the absolute column (0-indexed from the start of the |
| 239 | // output line) of the first '^'; it includes the gutter width. |
| 240 | indent := strings.IndexFunc(caretLine, func(r rune) bool { return r != ' ' }) |
| 241 | if indent < 0 { |
| 242 | // For disabled-point diagnostics the caret line is all spaces, |
| 243 | // so compute indent from the diagnostic's first column. |
| 244 | indent = 0 |
| 245 | for _, pos := range diagPositions[i] { |
| 246 | if pos.Line == lineIndex+1 && pos.FirstColumn > 0 { |
| 247 | indent = digits + 3 + pos.FirstColumn - 1 |
| 248 | break |
| 249 | } |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | gutter := digits + 3 |
| 254 | lastCaret := strings.LastIndex(caretLine, "^") |
| 255 | if lastCaret < 0 { |
| 256 | lastCaret = indent |
| 257 | } |
| 258 | |
| 259 | // Inline the message on the caret line when it fits in one |
| 260 | // piece and there are at least 20 columns after the last ^. |
| 261 | msg := diags[i].Message |
| 262 | spaceAfterCaret := maxLineWidth + gutter - lastCaret - 1 |
| 263 | if !disablePoints[i] && len(msg) > 0 && len(msg)+1 <= spaceAfterCaret && spaceAfterCaret >= 20 { |
| 264 | buf.WriteString(output.MaybeColor(color, color == output.None, caretLine)) |
| 265 | buf.WriteRune(' ') |
| 266 | buf.WriteString(output.MaybeColor(color, color == output.None, msg)) |
| 267 | buf.WriteRune('\n') |
| 268 | msgIndent[i] = lastCaret + 2 |
| 269 | } else { |
| 270 | if !disablePoints[i] { |
| 271 | buf.WriteString(output.MaybeColor(color, color == output.None, caretLine)) |
| 272 | buf.WriteRune('\n') |
| 273 | } |
| 274 | |
| 275 | // Place the message on whichever side of the caret has more |
| 276 | // horizontal space. |
| 277 | // Right side: message starts at firstCaret, extends to maxLineWidth. |
| 278 | // Left side: message block ends at lastCaret, width up to maxLineWidth, |
| 279 | // indent is pushed left so the block right-edge aligns with ^. |
| 280 | firstCaret := indent |
| 281 | rightSpace := maxLineWidth + gutter - firstCaret |
| 282 | leftSpace := lastCaret + 1 - gutter |
| 283 | |
| 284 | var msgWidth, alignRight int |
| 285 | if rightSpace >= leftSpace { |
| 286 | msgWidth = max(20, rightSpace) |
| 287 | } else { |
| 288 | indent = max(gutter, lastCaret+1-max(20, leftSpace)) |
| 289 | msgWidth = max(20, lastCaret+1-indent) |
| 290 | alignRight = lastCaret + 1 |
| 291 | } |
| 292 | |
| 293 | // For diagnostics that share a caret range with a previous |
| 294 | // one, align to the same column as that diagnostic's message. |
| 295 | if disablePoints[i] { |
| 296 | for j := range i { |
| 297 | if diags[j].FirstColumn == diags[i].FirstColumn && diags[j].LastColumn == diags[i].LastColumn { |
| 298 | indent = msgIndent[j] |
| 299 | alignRight = 0 |
| 300 | msgWidth = max(20, maxLineWidth+gutter-indent) |
| 301 | break |
| 302 | } |
| 303 | } |
| 304 | } |
| 305 | msgIndent[i] = writeWrappedMessage(&buf, msg, color, indent, msgWidth, alignRight) |
| 306 | } |
| 307 | nextLine[i].Reset() |
| 308 | } |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | return buf.String() |
| 313 | } |
| 314 | |