Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/modules/structured.go

c0427d5fc7c0fcf8606c546071085e8af231fb81 · 10.1 KB · 364 lines raw

1 package modules
2
3 import (
4 "bytes"
5 "encoding/json"
6 "html/template"
7 "regexp"
8 "strconv"
9 "strings"
10
11 "gopkg.in/yaml.v3"
12 )
13
14 // ParseNote reports why a JSON/YAML blob failed to parse, so the blob view
15 // can point at the offending line instead of leaving a reader to guess.
16 // Line is 0 when the underlying error can't be pinned to one.
17 type ParseNote struct {
18 Line int
19 Msg string
20 }
21
22 // Anchor is a YAML anchor declared on a line, along with how many alias
23 // sites in the file point back at it.
24 type Anchor struct {
25 Name string
26 Uses int
27 }
28
29 // StructRef is a resolved YAML alias: which anchor it names, the line that
30 // anchor is declared on, and the value it stands for rendered back to YAML.
31 // One per line -- a flow sequence like [*a, *b] resolves only its first.
32 type StructRef struct {
33 Name string
34 Line int
35 Preview string
36 }
37
38 // maxStructuredSize bounds the work one file may cost the structured view:
39 // a full parse on the server, and a pass over every row in the browser at
40 // load. It matches maxHighlightSize on purpose -- past that point the file
41 // is already served as plain, unhighlighted text with no indent guides, and
42 // folding a file that has neither would be a strange half-feature. Half a
43 // megabyte is far above any hand-written config and below the size where a
44 // modest laptop notices the per-row work.
45 const maxStructuredSize = maxHighlightSize
46
47 // structuredLang returns "json" or "yaml" for a recognised config
48 // extension, "" otherwise. The blob view only spends the extra parse and
49 // indent-guide pass on formats where nesting depth is the whole point.
50 func structuredLang(path string) string {
51 p := strings.ToLower(path)
52 switch {
53 case strings.HasSuffix(p, ".json"):
54 return "json"
55 case strings.HasSuffix(p, ".yaml"), strings.HasSuffix(p, ".yml"):
56 return "yaml"
57 default:
58 return ""
59 }
60 }
61
62 func validateStructured(lang string, raw []byte) *ParseNote {
63 var err error
64 switch lang {
65 case "json":
66 err = json.Unmarshal(raw, new(any))
67 case "yaml":
68 err = yaml.Unmarshal(raw, new(any))
69 default:
70 return nil
71 }
72 if err == nil {
73 return nil
74 }
75 return &ParseNote{Line: parseErrorLine(lang, raw, err), Msg: err.Error()}
76 }
77
78 var yamlLineRe = regexp.MustCompile(`line (\d+)`)
79
80 func parseErrorLine(lang string, raw []byte, err error) int {
81 if lang == "json" {
82 switch e := err.(type) {
83 case *json.SyntaxError:
84 return bytes.Count(raw[:e.Offset], []byte("\n")) + 1
85 case *json.UnmarshalTypeError:
86 return bytes.Count(raw[:e.Offset], []byte("\n")) + 1
87 }
88 return 0
89 }
90 // yaml.v3 errors are plain strings of the form "yaml: line N: ...", or
91 // for TypeError one such string per bad field -- take the first.
92 if m := yamlLineRe.FindStringSubmatch(err.Error()); m != nil {
93 n, _ := strconv.Atoi(m[1])
94 return n
95 }
96 return 0
97 }
98
99 // indentUnit finds the smallest non-zero run of leading spaces across
100 // non-blank lines, so a 4-space file gets one guide per level and a
101 // 2-space file doesn't get a spurious extra one.
102 func indentUnit(raw []byte) int {
103 unit := 0
104 for _, line := range bytes.Split(raw, []byte("\n")) {
105 n := 0
106 for n < len(line) && line[n] == ' ' {
107 n++
108 }
109 if n == 0 || n == len(line) {
110 continue // blank or all-whitespace
111 }
112 if unit == 0 || n < unit {
113 unit = n
114 }
115 }
116 if unit == 0 {
117 return 2
118 }
119 return unit
120 }
121
122 // annotateStructure fills in the per-line structure the blob view needs to
123 // fold, filter and cross-reference a config file: nesting depth, the extent
124 // of the block each line opens, the mapping key it declares, and (YAML only)
125 // the anchors and aliases tying parts of the file together.
126 //
127 // It works off raw rather than the highlighted HTML: indentation is the one
128 // signal that means the same thing in both languages, and it survives
129 // minified or oddly-formatted input by simply finding nothing to fold.
130 func annotateStructure(lines []Line, raw []byte, lang string) {
131 src := bytes.Split(raw, []byte("\n"))
132 unit := indentUnit(raw)
133
134 ind := make([]int, len(src))
135 for i, l := range src {
136 ind[i] = lineIndent(l)
137 }
138 ends := foldEnds(ind)
139
140 keyRe := jsonKeyRe
141 if lang == "yaml" {
142 keyRe = yamlKeyRe
143 }
144 for i := range src {
145 if i >= len(lines) {
146 break
147 }
148 if ind[i] >= 0 {
149 lines[i].Depth = ind[i] / unit
150 if e := ends[i]; e > 0 && e <= len(lines) {
151 lines[i].FoldTo = e
152 }
153 }
154 if m := keyRe.FindSubmatch(src[i]); m != nil {
155 for _, g := range m[1:] {
156 if len(g) > 0 {
157 lines[i].Key = string(g)
158 break
159 }
160 }
161 }
162 }
163 if lang != "yaml" {
164 return
165 }
166 anchors, refs := yamlAnchors(src)
167 for n, a := range anchors {
168 if n >= 1 && n <= len(lines) {
169 lines[n-1].Anchor = a
170 }
171 }
172 for n, r := range refs {
173 if n >= 1 && n <= len(lines) {
174 lines[n-1].Ref = r
175 }
176 }
177 }
178
179 // lineIndent counts leading spaces, or returns -1 for a line that carries no
180 // structure (blank or whitespace-only) so folding steps over it rather than
181 // treating column 0 as a dedent.
182 func lineIndent(line []byte) int {
183 n := 0
184 for n < len(line) && line[n] == ' ' {
185 n++
186 }
187 if n == len(bytes.TrimRight(line, " \t\r")) {
188 return -1
189 }
190 return n
191 }
192
193 // foldEnds returns, per line, the 1-based number of the last line belonging
194 // to the block that line opens -- 0 when it opens nothing. A line opens a
195 // block when the next line carrying structure is indented further, and the
196 // block runs until indentation returns to that line's own level or less.
197 // Blank lines between siblings are absorbed; blank lines trailing a block
198 // are not.
199 func foldEnds(ind []int) []int {
200 ends := make([]int, len(ind))
201 type frame struct {
202 ind, idx int
203 child bool
204 }
205 var stack []frame
206 prev := -1 // last line carrying structure
207 for i, in := range ind {
208 if in < 0 {
209 continue
210 }
211 for len(stack) > 0 && stack[len(stack)-1].ind >= in {
212 if t := stack[len(stack)-1]; t.child {
213 ends[t.idx] = prev + 1
214 }
215 stack = stack[:len(stack)-1]
216 }
217 if len(stack) > 0 {
218 stack[len(stack)-1].child = true
219 }
220 stack = append(stack, frame{ind: in, idx: i})
221 prev = i
222 }
223 for _, t := range stack {
224 if t.child {
225 ends[t.idx] = prev + 1
226 }
227 }
228 return ends
229 }
230
231 var (
232 // The key a line declares, used to scope search to keys rather than
233 // whole lines. Both are deliberately shallow: a miss costs a narrower
234 // search, not a wrong render.
235 yamlKeyRe = regexp.MustCompile(`^\s*(?:-\s+)?(?:"([^"]*)"|'([^']*)'|([^\s#"'][^:#]*?))\s*:(?:\s|$)`)
236 jsonKeyRe = regexp.MustCompile(`^\s*"((?:[^"\\]|\\.)*)"\s*:`)
237 )
238
239 // yamlAnchors resolves every alias in the file to the line its anchor is
240 // declared on, keyed by 1-based line number. Anchors are per-document, so
241 // the whole stream is decoded document by document -- a k8s manifest that
242 // defines its anchors in the second document would otherwise come back
243 // empty. A malformed document ends the walk with whatever was found first;
244 // the parse-error banner already reports the failure itself.
245 func yamlAnchors(src [][]byte) (map[int]*Anchor, map[int]*StructRef) {
246 anchors := map[int]*Anchor{}
247 refs := map[int]*StructRef{}
248 dec := yaml.NewDecoder(bytes.NewReader(bytes.Join(src, []byte("\n"))))
249 for {
250 var doc yaml.Node
251 if err := dec.Decode(&doc); err != nil {
252 break
253 }
254 walkYAML(&doc, src, anchors, refs)
255 }
256 return anchors, refs
257 }
258
259 func walkYAML(n *yaml.Node, src [][]byte, anchors map[int]*Anchor, refs map[int]*StructRef) {
260 if n.Anchor != "" {
261 if line := anchorLine(src, n.Anchor, n.Line); line > 0 {
262 if _, ok := anchors[line]; !ok {
263 anchors[line] = &Anchor{Name: n.Anchor}
264 }
265 }
266 }
267 if n.Kind == yaml.AliasNode && n.Alias != nil {
268 target := anchorLine(src, n.Value, n.Alias.Line)
269 if target > 0 {
270 if _, ok := refs[n.Line]; !ok {
271 refs[n.Line] = &StructRef{Name: n.Value, Line: target, Preview: nodePreview(n.Alias)}
272 }
273 if a, ok := anchors[target]; ok {
274 a.Uses++
275 }
276 }
277 }
278 for _, c := range n.Content {
279 walkYAML(c, src, anchors, refs)
280 }
281 }
282
283 // anchorLine locates the line the "&name" token sits on. A node's own Line
284 // is where its *value* begins, which for a block mapping is the line after
285 // the key the anchor was written on -- so scan back from there for the
286 // token itself and point at that instead.
287 func anchorLine(src [][]byte, name string, at int) int {
288 tok := []byte("&" + name)
289 if at > len(src) {
290 at = len(src)
291 }
292 for i := at - 1; i >= 0 && i > at-64; i-- {
293 j := bytes.Index(src[i], tok)
294 if j < 0 {
295 continue
296 }
297 if k := j + len(tok); k < len(src[i]) && isAnchorChar(src[i][k]) {
298 continue // &namelonger
299 }
300 return i + 1
301 }
302 return 0
303 }
304
305 func isAnchorChar(b byte) bool {
306 return b == '-' || b == '_' || b == '.' ||
307 (b >= '0' && b <= '9') || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
308 }
309
310 // nodePreview renders what an alias actually stands for, short enough to sit
311 // in a tooltip. The anchor name is stripped from the copy so the preview
312 // reads as the value, not as its declaration.
313 func nodePreview(n *yaml.Node) string {
314 c := *n
315 c.Anchor = ""
316 var buf bytes.Buffer
317 enc := yaml.NewEncoder(&buf)
318 enc.SetIndent(2) // yaml.Marshal's default of 4 doesn't match most files
319 if err := enc.Encode(&c); err != nil {
320 return ""
321 }
322 enc.Close()
323 s := strings.TrimRight(buf.String(), "\n")
324 if ls := strings.Split(s, "\n"); len(ls) > 12 {
325 s = strings.Join(append(ls[:12], "…"), "\n")
326 }
327 if len(s) > 480 {
328 s = strings.ToValidUTF8(s[:480], "") + "…"
329 }
330 return s
331 }
332
333 // withIndentGuides prefixes each line's leading spaces with one guide span
334 // per nesting level, so deeply nested JSON/YAML reads like a tree instead
335 // of a wall of spaces. Leading whitespace is always plain, unwrapped text
336 // at this point (see highlightLines' Text/TextWhitespace handling), so
337 // trimming it back off the rendered HTML here is safe.
338 func withIndentGuides(lines []template.HTML, unit int) []template.HTML {
339 out := make([]template.HTML, len(lines))
340 for i, l := range lines {
341 s := string(l)
342 n := 0
343 for n < len(s) && s[n] == ' ' {
344 n++
345 }
346 if n < unit {
347 out[i] = l
348 continue
349 }
350 var b strings.Builder
351 levels, rem := n/unit, n%unit
352 for j := 0; j < levels; j++ {
353 b.WriteString(`<span class="ind" style="width:`)
354 b.WriteString(strconv.Itoa(unit))
355 b.WriteString(`ch"></span>`)
356 }
357 if rem > 0 {
358 b.WriteString(strings.Repeat(" ", rem))
359 }
360 b.WriteString(s[n:])
361 out[i] = template.HTML(b.String())
362 }
363 return out
364 }