| 🛟 Updated. 28d5985 k33g 18h ago | 1 | package syntax |
| 2 | |
| 3 | import "strings" |
| 4 | |
| 5 | // highlightTOML returns the spans to colour for a TOML document, one slice per |
| 6 | // line. |
| 7 | // |
| 8 | // TOML is scanned a line at a time rather than as one stream, because a span |
| 9 | // may not straddle a line break and every TOML construct but one fits on a |
| 10 | // line. The exception is the multi-line string, which is what carry says. |
| 11 | func highlightTOML(src string) [][]Span { |
| 12 | return ScanLines(src, scanTOMLLine) |
| 13 | } |
| 14 | |
| 15 | // multiline says which kind of multi-line string, if any, is still open at the |
| 16 | // start of a line. |
| 17 | type multiline uint8 |
| 18 | |
| 19 | const ( |
| 20 | noMultiline multiline = iota |
| 21 | basicMultiline // opened with """ |
| 22 | literalMultiline // opened with ''' |
| 23 | ) |
| 24 | |
| 25 | // delimiter returns the three characters that close an open multi-line string. |
| 26 | func (m multiline) delimiter() string { |
| 27 | if m == basicMultiline { |
| 28 | return `"""` |
| 29 | } |
| 30 | return `'''` |
| 31 | } |
| 32 | |
| 33 | // tomlScanner walks one line of TOML, emitting spans as it recognises things. |
| 34 | type tomlScanner struct { |
| 35 | LineScanner |
| 36 | carry multiline |
| 37 | } |
| 38 | |
| 39 | // scanTOMLLine returns the spans of one line, and what is left open at its end. |
| 40 | func scanTOMLLine(line []rune, carry multiline) ([]Span, multiline) { |
| 41 | s := &tomlScanner{LineScanner: LineScanner{line: line}, carry: carry} |
| 42 | |
| 43 | if carry != noMultiline { |
| 44 | s.finishMultiline(carry) |
| 45 | } |
| 46 | for s.pos < len(s.line) { |
| 47 | s.step() |
| 48 | } |
| 49 | return s.spans, s.carry |
| 50 | } |
| 51 | |
| 52 | // step recognises whatever starts at the current position. |
| 53 | func (s *tomlScanner) step() { |
| 54 | switch c := s.line[s.pos]; { |
| 55 | case c == ' ' || c == '\t': |
| 56 | s.pos++ |
| 57 | case c == '#': |
| 58 | s.Emit(s.pos, len(s.line), ClassComment) |
| 59 | s.pos = len(s.line) |
| 60 | case isTOMLPunctuation(c): |
| 61 | s.Emit(s.pos, s.pos+1, ClassPunctuation) |
| 62 | s.pos++ |
| 63 | case c == '=': |
| 64 | s.Emit(s.pos, s.pos+1, ClassOperator) |
| 65 | s.pos++ |
| 66 | case c == '"' || c == '\'': |
| 67 | s.scanString(c) |
| 68 | case isTOMLBareRune(c): |
| 69 | s.scanWord() |
| 70 | default: |
| 71 | s.pos++ |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | // scanString reads a quoted string, of any of TOML's four kinds. |
| 76 | func (s *tomlScanner) scanString(quote rune) { |
| 77 | if kind, ok := s.openingMultiline(quote); ok { |
| 78 | start := s.pos |
| 79 | s.pos += 3 // past the opening delimiter, so it is not found as the closing one |
| 80 | s.finishMultilineFrom(start, kind) |
| 81 | return |
| 82 | } |
| 83 | |
| 84 | start := s.pos |
| 85 | s.pos++ // the opening quote |
| 86 | for s.pos < len(s.line) { |
| 87 | // Only a basic string has escapes; in a literal string a backslash is |
| 88 | // a backslash, which is the point of literal strings. |
| 89 | if quote == '"' && s.line[s.pos] == '\\' && s.pos+1 < len(s.line) { |
| 90 | s.pos += 2 |
| 91 | continue |
| 92 | } |
| 93 | if s.line[s.pos] == quote { |
| 94 | s.pos++ |
| 95 | s.Emit(start, s.pos, ClassString) |
| 96 | return |
| 97 | } |
| 98 | s.pos++ |
| 99 | } |
| 100 | |
| 101 | // Unterminated: colour to the end of the line rather than giving up, so a |
| 102 | // string being typed stays coloured while it is still half-written. |
| 103 | s.Emit(start, len(s.line), ClassString) |
| 104 | } |
| 105 | |
| 106 | // openingMultiline reports whether a triple quote starts here. |
| 107 | func (s *tomlScanner) openingMultiline(quote rune) (multiline, bool) { |
| 108 | if s.pos+2 >= len(s.line) || s.line[s.pos+1] != quote || s.line[s.pos+2] != quote { |
| 109 | return noMultiline, false |
| 110 | } |
| 111 | if quote == '"' { |
| 112 | return basicMultiline, true |
| 113 | } |
| 114 | return literalMultiline, true |
| 115 | } |
| 116 | |
| 117 | // finishMultiline colours the continuation of a multi-line string opened on an |
| 118 | // earlier line, which starts at the very beginning of this one. |
| 119 | func (s *tomlScanner) finishMultiline(kind multiline) { |
| 120 | s.finishMultilineFrom(s.pos, kind) |
| 121 | } |
| 122 | |
| 123 | // finishMultilineFrom colours from start to the end of an open multi-line |
| 124 | // string, or to the end of the line when it does not close here. |
| 125 | // |
| 126 | // The span starts at start but the search for the closing delimiter starts at |
| 127 | // the current position, which is what keeps an opening """ from being found as |
| 128 | // its own closing one. start is a parameter rather than something patched onto |
| 129 | // the span afterwards because emit drops empty spans — an opening delimiter at |
| 130 | // the very end of a line emits nothing, and patching would then have rewritten |
| 131 | // whatever span came before it. |
| 132 | func (s *tomlScanner) finishMultilineFrom(start int, kind multiline) { |
| 133 | closing := []rune(kind.delimiter()) |
| 134 | |
| 135 | for at := s.pos; at+len(closing) <= len(s.line); at++ { |
| 136 | if hasRunes(s.line, at, closing) { |
| 137 | s.pos = at + len(closing) |
| 138 | s.Emit(start, s.pos, ClassString) |
| 139 | s.carry = noMultiline |
| 140 | return |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | s.pos = len(s.line) |
| 145 | s.Emit(start, s.pos, ClassString) |
| 146 | s.carry = kind |
| 147 | } |
| 148 | |
| 149 | // scanWord reads a bare word: a key, a table name, a boolean, a number or a |
| 150 | // date, told apart by what follows it. |
| 151 | func (s *tomlScanner) scanWord() { |
| 152 | start := s.pos |
| 153 | for s.pos < len(s.line) && isTOMLBareRune(s.line[s.pos]) { |
| 154 | s.pos++ |
| 155 | } |
| 156 | |
| 157 | word := string(s.line[start:s.pos]) |
| 158 | s.Emit(start, s.pos, tomlWordClass(word, s.followedByEquals(), s.insideTableHeader(start))) |
| 159 | } |
| 160 | |
| 161 | // followedByEquals reports whether the next thing on the line is an "=", which |
| 162 | // is what makes a bare word a key rather than a value. |
| 163 | func (s *tomlScanner) followedByEquals() bool { |
| 164 | for at := s.pos; at < len(s.line); at++ { |
| 165 | switch s.line[at] { |
| 166 | case ' ', '\t': |
| 167 | case '.': |
| 168 | return true // a dotted key: every part of it is still a key |
| 169 | case '=': |
| 170 | return true |
| 171 | default: |
| 172 | return false |
| 173 | } |
| 174 | } |
| 175 | return false |
| 176 | } |
| 177 | |
| 178 | // insideTableHeader reports whether the word starting at an offset is part of |
| 179 | // a [table] or [[array of tables]] header. |
| 180 | // |
| 181 | // It looks at what comes before rather than tracking a state, because a header |
| 182 | // is always the first thing on its line. |
| 183 | func (s *tomlScanner) insideTableHeader(start int) bool { |
| 184 | for at := range start { |
| 185 | switch s.line[at] { |
| 186 | case ' ', '\t', '[': |
| 187 | default: |
| 188 | return false |
| 189 | } |
| 190 | } |
| 191 | return start > 0 && s.line[start-1] == '[' |
| 192 | } |
| 193 | |
| 194 | // tomlWordClass decides what a bare word is. |
| 195 | // |
| 196 | // The classes are the ones the Go highlighter already uses, so a theme colours |
| 197 | // TOML without naming a single new key: a table header reads as a type, a key |
| 198 | // as an identifier, and true and false as the constants they are. |
| 199 | func tomlWordClass(word string, key, header bool) Class { |
| 200 | if header { |
| 201 | return ClassType |
| 202 | } |
| 203 | if key { |
| 204 | return ClassIdentifier |
| 205 | } |
| 206 | return tomlValueClass(word) |
| 207 | } |
| 208 | |
| 209 | // tomlValueClass decides what a bare word on the value side of an "=" is. |
| 210 | func tomlValueClass(word string) Class { |
| 211 | switch { |
| 212 | case word == "true" || word == "false": |
| 213 | return ClassConstant |
| 214 | case word == "inf" || word == "nan", startsLikeANumber(word): |
| 215 | return ClassNumber |
| 216 | default: |
| 217 | return ClassIdentifier |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // startsLikeANumber reports whether a word is a number, a date or a time. |
| 222 | // |
| 223 | // TOML's dates and times start with a digit and carry on through the same |
| 224 | // characters a number may hold, so one test covers all of them. |
| 225 | func startsLikeANumber(word string) bool { |
| 226 | if word == "" { |
| 227 | return false |
| 228 | } |
| 229 | first := rune(word[0]) |
| 230 | if first == '+' || first == '-' { |
| 231 | return len(word) > 1 && IsDigit(rune(word[1])) |
| 232 | } |
| 233 | return IsDigit(first) |
| 234 | } |
| 235 | |
| 236 | // tomlPunctuation are the characters that structure a document rather than |
| 237 | // carry a value: table brackets, array brackets, inline-table braces, the |
| 238 | // separator, and the dot of a dotted key. |
| 239 | const tomlPunctuation = "[]{},." |
| 240 | |
| 241 | // isTOMLPunctuation reports whether a rune is one of them. |
| 242 | func isTOMLPunctuation(r rune) bool { return strings.ContainsRune(tomlPunctuation, r) } |
| 243 | |
| 244 | // bareExtras are the characters that may appear in a bare word besides letters |
| 245 | // and digits: key separators, number signs, and the colon of a time. |
| 246 | const bareExtras = "_-+:" |
| 247 | |
| 248 | // isTOMLBareRune reports whether a rune may appear in a bare key, a number, a |
| 249 | // date or a boolean — everything that is neither punctuation nor a string. |
| 250 | func isTOMLBareRune(r rune) bool { |
| 251 | return IsLetter(r) || IsDigit(r) || strings.ContainsRune(bareExtras, r) |
| 252 | } |
| 253 | |
| 254 | // hasRunes reports whether want appears in line at offset at. |
| 255 | func hasRunes(line []rune, at int, want []rune) bool { |
| 256 | if at+len(want) > len(line) { |
| 257 | return false |
| 258 | } |
| 259 | for i, r := range want { |
| 260 | if line[at+i] != r { |
| 261 | return false |
| 262 | } |
| 263 | } |
| 264 | return true |
| 265 | } |