| 🛟 Updated. 28d5985 k33g 20h ago | 1 | package syntax |
| 2 | |
| 3 | import "strings" |
| 4 | |
| 5 | // highlightYAML colours a YAML document, one slice of spans per line. |
| 6 | // |
| 7 | // A compose file, a Kubernetes manifest and a CI workflow are all just YAML, so |
| 8 | // there is one scanner rather than one per dialect. Colouring `services:` |
| 9 | // differently from any other key would mean carrying Docker's schema here and |
| 10 | // watching it go stale every time a key is added. |
| 11 | func highlightYAML(src string) [][]Span { |
| 12 | return ScanLines(src, scanYAMLLine) |
| 13 | } |
| 14 | |
| 15 | // scanYAMLLine returns the spans of one line, and what it leaves open. |
| 16 | func scanYAMLLine(line []rune, carry yamlCarry) ([]Span, yamlCarry) { |
| 17 | s := &LineScanner{line: line} |
| 18 | |
| 19 | if carry.inBlock { |
| 20 | if continues, next := continueBlockScalar(s, carry); continues { |
| 21 | return s.spans, next |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | s.SkipSpaces() |
| 26 | if s.AtEnd() { |
| 27 | return s.spans, yamlCarry{} |
| 28 | } |
| 29 | return scanYAMLContent(s) |
| 30 | } |
| 31 | |
| 32 | // scanYAMLContent colours one ordinary line of YAML. |
| 33 | func scanYAMLContent(s *LineScanner) ([]Span, yamlCarry) { |
| 34 | switch { |
| 35 | case s.Peek(0) == '#': |
| 36 | s.TakeRest(ClassComment) |
| 37 | return s.spans, yamlCarry{} |
| 38 | case s.HasPrefix(0, "---") || s.HasPrefix(0, "..."): |
| 39 | s.TakeRest(ClassPunctuation) |
| 40 | return s.spans, yamlCarry{} |
| 41 | } |
| 42 | |
| 43 | keyIndent := s.Pos() |
| 44 | takeYAMLListMarkers(s) |
| 45 | takeYAMLKey(s) |
| 46 | |
| 47 | return scanYAMLValue(s, keyIndent) |
| 48 | } |
| 49 | |
| 50 | // takeYAMLListMarkers colours the "- " that opens a sequence entry, and any |
| 51 | // that follow it on the same line: `- - nested` is legal YAML. |
| 52 | func takeYAMLListMarkers(s *LineScanner) { |
| 53 | for s.Peek(0) == '-' && (s.Peek(1) == ' ' || s.Peek(1) == 0) { |
| 54 | s.Take(1, ClassPunctuation) |
| 55 | s.SkipSpaces() |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // scanYAMLValue colours whatever follows a key, and reports a block scalar left |
| 60 | // open at the end of the line. |
| 61 | func scanYAMLValue(s *LineScanner, keyIndent int) ([]Span, yamlCarry) { |
| 62 | if opensBlockScalar(s) { |
| 63 | s.TakeRest(ClassOperator) |
| 64 | return s.spans, yamlCarry{inBlock: true, keyIndent: keyIndent} |
| 65 | } |
| 66 | |
| 67 | for !s.AtEnd() { |
| 68 | stepYAMLValue(s) |
| 69 | } |
| 70 | return s.spans, yamlCarry{} |
| 71 | } |
| 72 | |
| 73 | // stepYAMLValue colours one thing inside a value. |
| 74 | func stepYAMLValue(s *LineScanner) { |
| 75 | switch r := s.Peek(0); { |
| 76 | case r == ' ' || r == '\t': |
| 77 | s.SkipSpaces() |
| 78 | case r == '#' && startsComment(s): |
| 79 | s.TakeRest(ClassComment) |
| 80 | case r == '"' || r == '\'': |
| 81 | TakeQuoted(s, r, ClassString) |
| 82 | case r == '&' || r == '*': |
| 83 | // An anchor and the alias that refers to it: &defaults, *defaults. |
| 84 | s.Take(1, ClassBuiltin) |
| 85 | s.TakeWhile(ClassBuiltin, isYAMLNameRune) |
| 86 | case r == '!': |
| 87 | s.TakeWhile(ClassType, func(c rune) bool { return c != ' ' }) |
| 88 | case isYAMLFlowRune(r): |
| 89 | s.Take(1, ClassPunctuation) |
| 90 | case r == ':' && endsWord(s, 1): |
| 91 | // A colon separates a flow mapping's key from its value. One with no |
| 92 | // space after it is part of the scalar — `nginx:1.27` and |
| 93 | // `http://example.com` are one value each, not a key and a value. |
| 94 | s.Take(1, ClassPunctuation) |
| 95 | case IsDigit(r) || (r == '-' && IsDigit(s.Peek(1))): |
| 96 | s.TakeWhile(ClassNumber, isYAMLNumberRune) |
| 97 | case IsLetter(r) || r == '_': |
| 98 | takeYAMLWord(s) |
| 99 | default: |
| 100 | s.Advance(1) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // startsComment reports whether a `#` begins a comment rather than sitting |
| 105 | // inside a bare value. YAML needs a space before it, so `a#b` is one scalar. |
| 106 | func startsComment(s *LineScanner) bool { |
| 107 | return s.Pos() == 0 || s.Peek(-1) == ' ' || s.Peek(-1) == '\t' |
| 108 | } |
| 109 | |
| 110 | // isYAMLNameRune reports whether a rune can appear in a bare word or an anchor. |
| 111 | func isYAMLNameRune(r rune) bool { return IsWordRune(r) || r == '-' || r == '.' } |
| 112 | |
| 113 | // yamlFlowRunes are the characters that structure a flow collection — |
| 114 | // `{a: 1, b: [2]}` — as opposed to appearing inside a scalar. |
| 115 | const yamlFlowRunes = "{}[]," |
| 116 | |
| 117 | // isYAMLFlowRune reports whether a rune is one of them. |
| 118 | func isYAMLFlowRune(r rune) bool { return strings.ContainsRune(yamlFlowRunes, r) } |
| 119 | |
| 120 | // yamlNumberRunes are the characters that may follow the first digit of an |
| 121 | // unquoted number, date or time. YAML writes all three without quotes, and |
| 122 | // `2026-09-01` and `11:30:00` are numbers to a reader whatever the spec calls |
| 123 | // them. |
| 124 | const yamlNumberRunes = ".-+:eEx" |
| 125 | |
| 126 | // isYAMLNumberRune reports whether a rune can appear in one. |
| 127 | func isYAMLNumberRune(r rune) bool { |
| 128 | return IsDigit(r) || strings.ContainsRune(yamlNumberRunes, r) |
| 129 | } |