cloudflare/pint

Public

mirrored from https://github.com/cloudflare/pintAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.83.0

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

internal/diags/problems.go

313lines · modecode

1package diags
2
3import (
4 "cmp"
5 "fmt"
6 "slices"
7 "strings"
8
9 "github.com/cloudflare/pint/internal/output"
10)
11
12func 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.
21func 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.
48func 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
76type Kind uint8
77
78const (
79 Issue Kind = iota
80 Context
81)
82
83type 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.
92const maxLineWidth = 100
93
94type 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.
102func 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
117func 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
129func 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