| 📦 Turbo JS 91999d1 k33g 11h ago | 1 | package jslang |
| 2 | |
| 3 | // JSON: the language every Node project's manifest is written in, and one |
| 4 | // turbo-core does not colour. A scanner for it is thirty lines, because the |
| 5 | // language is: strings, numbers, three words, six punctuation marks. |
| 6 | |
| 7 | import "rickub.com/turbo-editors/turbo-core/syntax" |
| 8 | |
| 9 | // jsonCarry is what a line of JSON leaves open: a block comment, which strict |
| 10 | // JSON does not have and tsconfig.json, .jsonc files and every editor's own |
| 11 | // settings file do. Tolerating comments costs nothing in a file without them |
| 12 | // and colours the ones that have them as what they are. |
| 13 | type jsonCarry struct { |
| 14 | inComment bool |
| 15 | } |
| 16 | |
| 17 | // HighlightJSON colours a JSON document. |
| 18 | // |
| 19 | // A key is coloured as an **attribute** and a value as a **string**, so that |
| 20 | // the two sides of a colon read apart — which is the whole of what a reader |
| 21 | // wants from a coloured package.json. Which side a string is on is decided by |
| 22 | // what follows it: a string followed by a colon is a key. |
| 23 | func HighlightJSON(src string) [][]syntax.Span { |
| 24 | return syntax.ScanLines(src, scanJSONLine) |
| 25 | } |
| 26 | |
| 27 | // scanJSONLine colours one line and returns what it leaves open. |
| 28 | func scanJSONLine(line []rune, open jsonCarry) ([]syntax.Span, jsonCarry) { |
| 29 | s := syntax.NewLineScanner(line) |
| 30 | |
| 31 | if open.inComment { |
| 32 | if !syntax.FinishBlockComment(s, "*/", syntax.ClassComment) { |
| 33 | return s.Spans(), open |
| 34 | } |
| 35 | open.inComment = false |
| 36 | } |
| 37 | |
| 38 | for !s.AtEnd() { |
| 39 | scanJSONToken(s, &open) |
| 40 | } |
| 41 | return s.Spans(), open |
| 42 | } |
| 43 | |
| 44 | // scanJSONToken colours whatever starts at the scanner's position. |
| 45 | func scanJSONToken(s *syntax.LineScanner, open *jsonCarry) { |
| 46 | r := s.Peek(0) |
| 47 | |
| 48 | switch { |
| 49 | case r == ' ' || r == '\t': |
| 50 | s.SkipSpaces() |
| 51 | case s.HasPrefix(0, "//"): |
| 52 | s.TakeRest(syntax.ClassComment) |
| 53 | case s.HasPrefix(0, "/*"): |
| 54 | if !syntax.OpenBlockComment(s, "/*", "*/", syntax.ClassComment) { |
| 55 | open.inComment = true |
| 56 | } |
| 57 | case r == '"': |
| 58 | takeJSONString(s) |
| 59 | case syntax.IsDigit(r) || (r == '-' && syntax.IsDigit(s.Peek(1))): |
| 60 | takeJSONNumber(s) |
| 61 | case syntax.IsLetter(r): |
| 62 | takeJSONWord(s) |
| 63 | case syntax.IsPunctuationRune(r) || r == ':': |
| 64 | // The colon is an operator rune to the library and structure here: |
| 65 | // it separates a key from its value the way a comma separates two. |
| 66 | s.Take(1, syntax.ClassPunctuation) |
| 67 | default: |
| 68 | s.Advance(1) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // takeJSONString colours a quoted string as a key when a colon follows it and |
| 73 | // as a value otherwise. An unterminated one is coloured to the end of the |
| 74 | // line, as a value: a key with no colon is not a key yet. |
| 75 | func takeJSONString(s *syntax.LineScanner) { |
| 76 | start := s.Pos() |
| 77 | s.Advance(1) |
| 78 | for !s.AtEnd() { |
| 79 | switch s.Peek(0) { |
| 80 | case '\\': |
| 81 | s.Advance(2) |
| 82 | continue |
| 83 | case '"': |
| 84 | s.Advance(1) |
| 85 | s.Emit(start, s.Pos(), jsonStringClass(s)) |
| 86 | return |
| 87 | } |
| 88 | s.Advance(1) |
| 89 | } |
| 90 | s.Emit(start, s.Pos(), syntax.ClassString) |
| 91 | } |
| 92 | |
| 93 | // jsonStringClass says what a string that has just closed is, by looking past |
| 94 | // the spaces after it for a colon. |
| 95 | func jsonStringClass(s *syntax.LineScanner) syntax.Class { |
| 96 | for at := 0; s.Pos()+at < s.Len(); at++ { |
| 97 | switch s.Peek(at) { |
| 98 | case ' ', '\t': |
| 99 | case ':': |
| 100 | return syntax.ClassAttribute |
| 101 | default: |
| 102 | return syntax.ClassString |
| 103 | } |
| 104 | } |
| 105 | return syntax.ClassString |
| 106 | } |
| 107 | |
| 108 | // takeJSONNumber colours a number, its sign, fraction and exponent included. |
| 109 | func takeJSONNumber(s *syntax.LineScanner) { |
| 110 | start := s.Pos() |
| 111 | s.Advance(1) |
| 112 | for !s.AtEnd() { |
| 113 | r := s.Peek(0) |
| 114 | switch { |
| 115 | case syntax.IsDigit(r) || r == '.' || r == 'e' || r == 'E': |
| 116 | s.Advance(1) |
| 117 | case (r == '+' || r == '-') && isExponent(s.Peek(-1)): |
| 118 | s.Advance(1) |
| 119 | default: |
| 120 | s.Emit(start, s.Pos(), syntax.ClassNumber) |
| 121 | return |
| 122 | } |
| 123 | } |
| 124 | s.Emit(start, s.Pos(), syntax.ClassNumber) |
| 125 | } |
| 126 | |
| 127 | // takeJSONWord colours true, false and null as the constants they are, and any |
| 128 | // other bare word as an identifier — which JSON has none of, so the colour is |
| 129 | // the scanner saying "this is not JSON" without giving up on the line. |
| 130 | func takeJSONWord(s *syntax.LineScanner) { |
| 131 | start := s.Pos() |
| 132 | for !s.AtEnd() && syntax.IsWordRune(s.Peek(0)) { |
| 133 | s.Advance(1) |
| 134 | } |
| 135 | class := syntax.ClassIdentifier |
| 136 | if jsonConstants[wordAt(s, start)] { |
| 137 | class = syntax.ClassConstant |
| 138 | } |
| 139 | s.Emit(start, s.Pos(), class) |
| 140 | } |
| 141 | |
| 142 | // jsonConstants are the three words JSON has. |
| 143 | var jsonConstants = map[string]bool{"true": true, "false": true, "null": true} |