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

internal/modules/structured.go

d658fcc4ea9f3bb562fd00dce8f863934560c56c · 14.7 KB · 516 lines raw

1 package modules
2
3 import (
4 "bytes"
5 "encoding/json"
6 "html/template"
7 "regexp"
8 "sort"
9 "strconv"
10 "strings"
11
12 "gopkg.in/yaml.v3"
13 )
14
15 // ParseNote reports why a JSON/YAML blob failed to parse, so the blob view
16 // can point at the offending line instead of leaving a reader to guess.
17 // Line is 0 when the underlying error can't be pinned to one.
18 type ParseNote struct {
19 Line int
20 Msg string
21 }
22
23 // Anchor is a YAML anchor declared on a line, along with how many alias
24 // sites in the file point back at it.
25 type Anchor struct {
26 Name string
27 Uses int
28 }
29
30 // StructRef is a resolved YAML alias: which anchor it names, the line that
31 // anchor is declared on, and the value it stands for rendered back to YAML.
32 // One per line -- a flow sequence like [*a, *b] resolves only its first.
33 type StructRef struct {
34 Name string
35 Line int
36 Preview string
37 }
38
39 // maxStructuredSize bounds the work one file may cost the structured view:
40 // a full parse on the server, and a pass over every row in the browser at
41 // load. It matches maxHighlightSize on purpose -- past that point the file
42 // is already served as plain, unhighlighted text with no indent guides, and
43 // folding a file that has neither would be a strange half-feature. Half a
44 // megabyte is far above any hand-written config and below the size where a
45 // modest laptop notices the per-row work.
46 const maxStructuredSize = maxHighlightSize
47
48 // structuredLang returns "json" or "yaml" for a recognised config
49 // extension, "" otherwise. The blob view only spends the extra parse and
50 // indent-guide pass on formats where nesting depth is the whole point.
51 func structuredLang(path string) string {
52 p := strings.ToLower(path)
53 switch {
54 case strings.HasSuffix(p, ".json"):
55 return "json"
56 case strings.HasSuffix(p, ".yaml"), strings.HasSuffix(p, ".yml"):
57 return "yaml"
58 default:
59 return ""
60 }
61 }
62
63 func validateStructured(lang string, raw []byte) *ParseNote {
64 var err error
65 switch lang {
66 case "json":
67 err = json.Unmarshal(raw, new(any))
68 case "yaml":
69 err = yaml.Unmarshal(raw, new(any))
70 default:
71 return nil
72 }
73 if err == nil {
74 return nil
75 }
76 return &ParseNote{Line: parseErrorLine(lang, raw, err), Msg: err.Error()}
77 }
78
79 var yamlLineRe = regexp.MustCompile(`line (\d+)`)
80
81 func parseErrorLine(lang string, raw []byte, err error) int {
82 if lang == "json" {
83 switch e := err.(type) {
84 case *json.SyntaxError:
85 return bytes.Count(raw[:e.Offset], []byte("\n")) + 1
86 case *json.UnmarshalTypeError:
87 return bytes.Count(raw[:e.Offset], []byte("\n")) + 1
88 }
89 return 0
90 }
91 // yaml.v3 errors are plain strings of the form "yaml: line N: ...", or
92 // for TypeError one such string per bad field -- take the first.
93 if m := yamlLineRe.FindStringSubmatch(err.Error()); m != nil {
94 n, _ := strconv.Atoi(m[1])
95 return n
96 }
97 return 0
98 }
99
100 // indentUnit finds the smallest non-zero run of leading spaces across
101 // non-blank lines, so a 4-space file gets one guide per level and a
102 // 2-space file doesn't get a spurious extra one.
103 func indentUnit(raw []byte) int {
104 unit := 0
105 for _, line := range bytes.Split(raw, []byte("\n")) {
106 n := 0
107 for n < len(line) && line[n] == ' ' {
108 n++
109 }
110 if n == 0 || n == len(line) {
111 continue // blank or all-whitespace
112 }
113 if unit == 0 || n < unit {
114 unit = n
115 }
116 }
117 if unit == 0 {
118 return 2
119 }
120 return unit
121 }
122
123 // annotateStructure fills in the per-line structure the blob view needs to
124 // fold, filter and cross-reference a config file: nesting depth, the extent
125 // of the block each line opens, the mapping key it declares, and (YAML only)
126 // the anchors and aliases tying parts of the file together.
127 //
128 // It works off raw rather than the highlighted HTML: indentation is the one
129 // signal that means the same thing in both languages, and it survives
130 // minified or oddly-formatted input by simply finding nothing to fold.
131 func annotateStructure(lines []Line, raw []byte, lang string) {
132 src := bytes.Split(raw, []byte("\n"))
133 unit := indentUnit(raw)
134
135 ind := make([]int, len(src))
136 for i, l := range src {
137 ind[i] = lineIndent(l)
138 }
139 ends := foldEnds(ind)
140
141 keyRe := jsonKeyRe
142 if lang == "yaml" {
143 keyRe = yamlKeyRe
144 }
145 for i := range src {
146 if i >= len(lines) {
147 break
148 }
149 if ind[i] >= 0 {
150 lines[i].Depth = ind[i] / unit
151 if e := ends[i]; e > 0 && e <= len(lines) {
152 lines[i].FoldTo = e
153 }
154 }
155 if m := keyRe.FindSubmatch(src[i]); m != nil {
156 for _, g := range m[1:] {
157 if len(g) > 0 {
158 lines[i].Key = string(g)
159 break
160 }
161 }
162 }
163 }
164 if lang != "yaml" {
165 annotateJSONPaths(lines, raw)
166 return
167 }
168 anchors, refs, paths := yamlAnnotations(src)
169 for n, a := range anchors {
170 if n >= 1 && n <= len(lines) {
171 lines[n-1].Anchor = a
172 }
173 }
174 for n, r := range refs {
175 if n >= 1 && n <= len(lines) {
176 lines[n-1].Ref = r
177 }
178 }
179 for n, p := range paths {
180 if n >= 1 && n <= len(lines) {
181 lines[n-1].Path = p
182 }
183 }
184 }
185
186 // lineIndent counts leading spaces, or returns -1 for a line that carries no
187 // structure (blank or whitespace-only) so folding steps over it rather than
188 // treating column 0 as a dedent.
189 func lineIndent(line []byte) int {
190 n := 0
191 for n < len(line) && line[n] == ' ' {
192 n++
193 }
194 if n == len(bytes.TrimRight(line, " \t\r")) {
195 return -1
196 }
197 return n
198 }
199
200 // foldEnds returns, per line, the 1-based number of the last line belonging
201 // to the block that line opens -- 0 when it opens nothing. A line opens a
202 // block when the next line carrying structure is indented further, and the
203 // block runs until indentation returns to that line's own level or less.
204 // Blank lines between siblings are absorbed; blank lines trailing a block
205 // are not.
206 func foldEnds(ind []int) []int {
207 ends := make([]int, len(ind))
208 type frame struct {
209 ind, idx int
210 child bool
211 }
212 var stack []frame
213 prev := -1 // last line carrying structure
214 for i, in := range ind {
215 if in < 0 {
216 continue
217 }
218 for len(stack) > 0 && stack[len(stack)-1].ind >= in {
219 if t := stack[len(stack)-1]; t.child {
220 ends[t.idx] = prev + 1
221 }
222 stack = stack[:len(stack)-1]
223 }
224 if len(stack) > 0 {
225 stack[len(stack)-1].child = true
226 }
227 stack = append(stack, frame{ind: in, idx: i})
228 prev = i
229 }
230 for _, t := range stack {
231 if t.child {
232 ends[t.idx] = prev + 1
233 }
234 }
235 return ends
236 }
237
238 var (
239 // The key a line declares, used to scope search to keys rather than
240 // whole lines. Both are deliberately shallow: a miss costs a narrower
241 // search, not a wrong render.
242 yamlKeyRe = regexp.MustCompile(`^\s*(?:-\s+)?(?:"([^"]*)"|'([^']*)'|([^\s#"'][^:#]*?))\s*:(?:\s|$)`)
243 jsonKeyRe = regexp.MustCompile(`^\s*"((?:[^"\\]|\\.)*)"\s*:`)
244 )
245
246 // yamlAnnotations resolves every alias in the file to the line its anchor is
247 // declared on, keyed by 1-based line number, and records each line's
248 // canonical value path. Anchors are per-document, so the whole stream is
249 // decoded document by document -- a k8s manifest that defines its anchors in
250 // the second document would otherwise come back empty. A malformed document
251 // ends the walk with whatever was found first; the parse-error banner
252 // already reports the failure itself.
253 func yamlAnnotations(src [][]byte) (map[int]*Anchor, map[int]*StructRef, map[int]string) {
254 anchors := map[int]*Anchor{}
255 refs := map[int]*StructRef{}
256 paths := map[int]string{}
257 dec := yaml.NewDecoder(bytes.NewReader(bytes.Join(src, []byte("\n"))))
258 for {
259 var doc yaml.Node
260 if err := dec.Decode(&doc); err != nil {
261 break
262 }
263 walkYAML(&doc, src, anchors, refs, paths, "")
264 }
265 return anchors, refs, paths
266 }
267
268 func walkYAML(n *yaml.Node, src [][]byte, anchors map[int]*Anchor, refs map[int]*StructRef, paths map[int]string, path string) {
269 if n.Anchor != "" {
270 if line := anchorLine(src, n.Anchor, n.Line); line > 0 {
271 if _, ok := anchors[line]; !ok {
272 anchors[line] = &Anchor{Name: n.Anchor}
273 }
274 }
275 }
276 if n.Kind == yaml.AliasNode && n.Alias != nil {
277 target := anchorLine(src, n.Value, n.Alias.Line)
278 if target > 0 {
279 if _, ok := refs[n.Line]; !ok {
280 refs[n.Line] = &StructRef{Name: n.Value, Line: target, Preview: nodePreview(n.Alias)}
281 }
282 if a, ok := anchors[target]; ok {
283 a.Uses++
284 }
285 }
286 // An alias displays only *name here; the aliased value's lines are
287 // wherever the anchor was declared. Recursing into n.Alias would
288 // emit paths pointing at those other lines, so record this line's
289 // path and stop.
290 recordPath(paths, n.Line, path)
291 return
292 }
293
294 switch n.Kind {
295 case yaml.DocumentNode:
296 for _, c := range n.Content {
297 walkYAML(c, src, anchors, refs, paths, path)
298 }
299 case yaml.MappingNode:
300 for i := 0; i+1 < len(n.Content); i += 2 {
301 k, v := n.Content[i], n.Content[i+1]
302 child := joinPath(path, k.Value)
303 if k.Value == "<<" {
304 // A merge key is not a property of the mapping, and the
305 // rendered text never expands it, so a `<<` segment would
306 // name something the reader cannot see. Keep the parent.
307 child = path
308 }
309 // Walk the key too (anchors may be declared on one), but record
310 // the value's path last so it wins for the shared line.
311 walkYAML(k, src, anchors, refs, paths, path)
312 recordPath(paths, k.Line, child)
313 walkYAML(v, src, anchors, refs, paths, child)
314 }
315 case yaml.SequenceNode:
316 for i, c := range n.Content {
317 child := joinIndex(path, i)
318 recordPath(paths, c.Line, child)
319 walkYAML(c, src, anchors, refs, paths, child)
320 }
321 default:
322 recordPath(paths, n.Line, path)
323 }
324 }
325
326 // anchorLine locates the line the "&name" token sits on. A node's own Line
327 // is where its *value* begins, which for a block mapping is the line after
328 // the key the anchor was written on -- so scan back from there for the
329 // token itself and point at that instead.
330 func anchorLine(src [][]byte, name string, at int) int {
331 tok := []byte("&" + name)
332 if at > len(src) {
333 at = len(src)
334 }
335 for i := at - 1; i >= 0 && i > at-64; i-- {
336 j := bytes.Index(src[i], tok)
337 if j < 0 {
338 continue
339 }
340 if k := j + len(tok); k < len(src[i]) && isAnchorChar(src[i][k]) {
341 continue // &namelonger
342 }
343 return i + 1
344 }
345 return 0
346 }
347
348 func isAnchorChar(b byte) bool {
349 return b == '-' || b == '_' || b == '.' ||
350 (b >= '0' && b <= '9') || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
351 }
352
353 // nodePreview renders what an alias actually stands for, short enough to sit
354 // in a tooltip. The anchor name is stripped from the copy so the preview
355 // reads as the value, not as its declaration.
356 func nodePreview(n *yaml.Node) string {
357 c := *n
358 c.Anchor = ""
359 var buf bytes.Buffer
360 enc := yaml.NewEncoder(&buf)
361 enc.SetIndent(2) // yaml.Marshal's default of 4 doesn't match most files
362 if err := enc.Encode(&c); err != nil {
363 return ""
364 }
365 enc.Close()
366 s := strings.TrimRight(buf.String(), "\n")
367 if ls := strings.Split(s, "\n"); len(ls) > 12 {
368 s = strings.Join(append(ls[:12], "…"), "\n")
369 }
370 if len(s) > 480 {
371 s = strings.ToValidUTF8(s[:480], "") + "…"
372 }
373 return s
374 }
375
376 // withIndentGuides prefixes each line's leading spaces with one guide span
377 // per nesting level, so deeply nested JSON/YAML reads like a tree instead
378 // of a wall of spaces. Leading whitespace is always plain, unwrapped text
379 // at this point (see highlightLines' Text/TextWhitespace handling), so
380 // trimming it back off the rendered HTML here is safe.
381 func withIndentGuides(lines []template.HTML, unit int) []template.HTML {
382 out := make([]template.HTML, len(lines))
383 for i, l := range lines {
384 s := string(l)
385 n := 0
386 for n < len(s) && s[n] == ' ' {
387 n++
388 }
389 if n < unit {
390 out[i] = l
391 continue
392 }
393 var b strings.Builder
394 levels, rem := n/unit, n%unit
395 for j := 0; j < levels; j++ {
396 b.WriteString(`<span class="ind" style="width:`)
397 b.WriteString(strconv.Itoa(unit))
398 b.WriteString(`ch"></span>`)
399 }
400 if rem > 0 {
401 b.WriteString(strings.Repeat(" ", rem))
402 }
403 b.WriteString(s[n:])
404 out[i] = template.HTML(b.String())
405 }
406 return out
407 }
408
409 // --------------------------------------------------------------- paths
410
411 // A path query names a value the way obj1.property3.items[4].val does.
412 // Both the server (here) and static/js/structured.js write the same
413 // canonical form so a query can be compared as a string: simple keys are
414 // dotted, indices attach directly, and anything else is bracket-quoted.
415 var (
416 simplePathKey = regexp.MustCompile(`^[A-Za-z0-9_$-]+$`)
417 pathEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`)
418 )
419
420 func joinPath(parent, key string) string {
421 if simplePathKey.MatchString(key) {
422 if parent == "" {
423 return key
424 }
425 return parent + "." + key
426 }
427 return parent + `["` + pathEscaper.Replace(key) + `"]`
428 }
429
430 func joinIndex(parent string, i int) string {
431 return parent + "[" + strconv.Itoa(i) + "]"
432 }
433
434 // recordPath keeps the last path recorded for a line. A line can carry more
435 // than one path (JSON/YAML flow style puts several values on one row), and
436 // the last one parsed is the deepest -- the one a reader naming a full path
437 // is most likely to mean. A container query still reaches its children by
438 // the boundary-aware prefix match in structured.js, so dropping the shallow
439 // path costs nothing there.
440 func recordPath(paths map[int]string, line int, path string) {
441 if path == "" || line <= 0 {
442 return
443 }
444 paths[line] = path
445 }
446
447 // annotateJSONPaths records each line's canonical value path. It reuses
448 // encoding/json's own decoder rather than a second parser, so the offsets it
449 // sees are exactly the ones the format defines.
450 func annotateJSONPaths(lines []Line, raw []byte) {
451 paths := jsonPaths(raw)
452 for i := range lines {
453 if p, ok := paths[lines[i].N]; ok {
454 lines[i].Path = p
455 }
456 }
457 }
458
459 func jsonPaths(raw []byte) map[int]string {
460 paths := map[int]string{}
461 lineOf := newLineIndex(raw)
462 dec := json.NewDecoder(bytes.NewReader(raw))
463
464 // walk consumes one JSON value at path. Objects record the path on the
465 // key's line and recurse into the value; arrays record it on each
466 // element's line. A malformed document simply stops -- the parse-error
467 // banner owns reporting that.
468 var walk func(path string)
469 walk = func(path string) {
470 tok, err := dec.Token()
471 if err != nil {
472 return
473 }
474 recordPath(paths, lineOf(dec.InputOffset()), path)
475 d, ok := tok.(json.Delim)
476 if !ok {
477 return
478 }
479 switch d {
480 case '{':
481 for dec.More() {
482 kt, err := dec.Token()
483 if err != nil {
484 return
485 }
486 key, _ := kt.(string)
487 child := joinPath(path, key)
488 recordPath(paths, lineOf(dec.InputOffset()), child)
489 walk(child)
490 }
491 dec.Token() // consume '}'
492 case '[':
493 for i := 0; dec.More(); i++ {
494 walk(joinIndex(path, i))
495 }
496 dec.Token() // consume ']'
497 }
498 }
499 walk("")
500 return paths
501 }
502
503 // newLineIndex maps a byte offset to a 1-based line number. Newline
504 // positions are found once and binary-searched per token, so indexing a
505 // whole file stays O(n log n) rather than counting from zero each time.
506 func newLineIndex(raw []byte) func(int64) int {
507 var newlines []int64
508 for i, b := range raw {
509 if b == '\n' {
510 newlines = append(newlines, int64(i))
511 }
512 }
513 return func(off int64) int {
514 return sort.Search(len(newlines), func(i int) bool { return newlines[i] >= off }) + 1
515 }
516 }