| 🛟 Updated. 28d5985 k33g 4h ago | 1 | package syntax |
| 2 | |
| 3 | // bashKeywords are the words that structure a shell script, and the builtins |
| 4 | // worth telling apart from a command on the PATH. |
| 5 | // |
| 6 | // The set is what sh, bash and zsh share: a script is coloured the same |
| 7 | // whichever of them it names in its shebang, because the differences between |
| 8 | // them are not what a highlighter can usefully show. |
| 9 | var bashKeywords = map[string]Class{ |
| 10 | "if": ClassKeyword, "then": ClassKeyword, "elif": ClassKeyword, |
| 11 | "else": ClassKeyword, "fi": ClassKeyword, "for": ClassKeyword, |
| 12 | "while": ClassKeyword, "until": ClassKeyword, "do": ClassKeyword, |
| 13 | "done": ClassKeyword, "case": ClassKeyword, "esac": ClassKeyword, |
| 14 | "in": ClassKeyword, "function": ClassKeyword, "select": ClassKeyword, |
| 15 | "time": ClassKeyword, "return": ClassKeyword, "break": ClassKeyword, |
| 16 | "continue": ClassKeyword, "exit": ClassKeyword, |
| 17 | |
| 18 | "true": ClassConstant, "false": ClassConstant, |
| 19 | |
| 20 | "echo": ClassBuiltin, "printf": ClassBuiltin, "read": ClassBuiltin, |
| 21 | "cd": ClassBuiltin, "export": ClassBuiltin, "local": ClassBuiltin, |
| 22 | "declare": ClassBuiltin, "readonly": ClassBuiltin, "unset": ClassBuiltin, |
| 23 | "shift": ClassBuiltin, "set": ClassBuiltin, "test": ClassBuiltin, |
| 24 | "source": ClassBuiltin, "eval": ClassBuiltin, "exec": ClassBuiltin, |
| 25 | "trap": ClassBuiltin, "wait": ClassBuiltin, "command": ClassBuiltin, |
| 26 | } |
| 27 | |
| 28 | // highlightBash colours a shell script, one slice of spans per line. |
| 29 | // |
| 30 | // Heredocs are deliberately not recognised. Following `<<EOF` to its closing |
| 31 | // delimiter means carrying an arbitrary word across lines and dealing with the |
| 32 | // `<<-` and quoted-delimiter spellings, which is a good deal of machinery for |
| 33 | // a construct that is usually a few lines of plain text. |
| 34 | // |
| 35 | // Nothing here spans lines, so the scanner carries no state — the empty struct |
| 36 | // is what says so. |
| 37 | func highlightBash(src string) [][]Span { |
| 38 | return ScanLines(src, scanBashLine) |
| 39 | } |
| 40 | |
| 41 | // bashScanner walks one line of shell, remembering whether it has already |
| 42 | // seen a word. |
| 43 | // |
| 44 | // That flag is the whole of the command rule: **the first bare word on a line |
| 45 | // is the command being run; every later one is a plain argument.** Following |
| 46 | // the real rule — a command also starts after ";", "|", "&&" and after "then" |
| 47 | // or "do" — would colour a little more and cost a table of what resets it, |
| 48 | // and getting it wrong colours an argument as a command, which reads as a |
| 49 | // mistake in the script rather than in the highlighter. |
| 50 | type bashScanner struct { |
| 51 | LineScanner |
| 52 | seenWord bool |
| 53 | } |
| 54 | |
| 55 | // scanBashLine returns the spans of one line. Nothing is ever left open. |
| 56 | func scanBashLine(line []rune, _ struct{}) ([]Span, struct{}) { |
| 57 | s := &bashScanner{LineScanner: LineScanner{line: line}} |
| 58 | for !s.AtEnd() { |
| 59 | s.step() |
| 60 | } |
| 61 | return s.spans, struct{}{} |
| 62 | } |
| 63 | |
| 64 | // step recognises whatever starts at the current position. |
| 65 | func (s *bashScanner) step() { |
| 66 | switch { |
| 67 | case s.line[s.pos] == ' ' || s.line[s.pos] == '\t': |
| 68 | s.pos++ |
| 69 | case s.line[s.pos] == '#': |
| 70 | s.TakeRest(ClassComment) |
| 71 | case s.line[s.pos] == '\'': |
| 72 | // A single-quoted string has no escapes and no expansion: everything |
| 73 | // up to the next quote is literal, backslashes included. |
| 74 | takeLiteralQuoted(&s.LineScanner) |
| 75 | case s.line[s.pos] == '"': |
| 76 | takeDoubleQuoted(&s.LineScanner) |
| 77 | case s.line[s.pos] == '$': |
| 78 | takeExpansion(&s.LineScanner) |
| 79 | case IsDigit(s.line[s.pos]): |
| 80 | s.TakeWhile(ClassNumber, IsDigit) |
| 81 | case IsWordRune(s.line[s.pos]) || s.startsAnOption(): |
| 82 | s.takeWord() |
| 83 | case IsPunctuationRune(s.line[s.pos]): |
| 84 | s.Take(1, ClassPunctuation) |
| 85 | case IsOperatorRune(s.line[s.pos]): |
| 86 | s.TakeWhile(ClassOperator, IsOperatorRune) |
| 87 | default: |
| 88 | s.pos++ |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // startsAnOption reports whether a hyphen here begins a flag rather than an |
| 93 | // operator. |
| 94 | // |
| 95 | // Without this, "-eu" is a minus followed by a word, and every option in every |
| 96 | // script is coloured as arithmetic. A hyphen followed by a letter, a digit or |
| 97 | // another hyphen is an option; one followed by a space is not. |
| 98 | func (s *bashScanner) startsAnOption() bool { |
| 99 | return s.line[s.pos] == '-' && (IsWordRune(s.Peek(1)) || s.Peek(1) == '-') |
| 100 | } |
| 101 | |
| 102 | // takeLiteralQuoted colours '…', where nothing is escaped or expanded. |
| 103 | func takeLiteralQuoted(s *LineScanner) { |
| 104 | start := s.pos |
| 105 | s.pos++ |
| 106 | |
| 107 | for !s.AtEnd() { |
| 108 | if s.line[s.pos] == '\'' { |
| 109 | s.pos++ |
| 110 | s.Emit(start, s.pos, ClassString) |
| 111 | return |
| 112 | } |
| 113 | s.pos++ |
| 114 | } |
| 115 | s.Emit(start, len(s.line), ClassString) |
| 116 | } |
| 117 | |
| 118 | // takeDoubleQuoted colours "…", with the expansions inside it coloured as |
| 119 | // expansions rather than as part of the string. |
| 120 | // |
| 121 | // That is the point of the double-quoted form: "$HOME/bin" is a path with a |
| 122 | // variable in it, and seeing which part is the variable is most of what a |
| 123 | // reader wants from it. |
| 124 | func takeDoubleQuoted(s *LineScanner) { |
| 125 | start := s.pos |
| 126 | s.pos++ // the opening quote |
| 127 | |
| 128 | for !s.AtEnd() { |
| 129 | switch { |
| 130 | case s.line[s.pos] == '\\' && s.pos+1 < len(s.line): |
| 131 | s.pos += 2 |
| 132 | case s.line[s.pos] == '$': |
| 133 | s.Emit(start, s.pos, ClassString) |
| 134 | takeExpansion(s) |
| 135 | start = s.pos |
| 136 | case s.line[s.pos] == '"': |
| 137 | s.pos++ |
| 138 | s.Emit(start, s.pos, ClassString) |
| 139 | return |
| 140 | default: |
| 141 | s.pos++ |
| 142 | } |
| 143 | } |
| 144 | s.Emit(start, len(s.line), ClassString) |
| 145 | } |
| 146 | |
| 147 | // takeExpansion colours $NAME, ${…}, $(…) and the one-character forms such as |
| 148 | // $1, $?, $@ and $#. |
| 149 | func takeExpansion(s *LineScanner) { |
| 150 | switch s.Peek(1) { |
| 151 | case '{': |
| 152 | takeBracketed(s, '{', '}') |
| 153 | case '(': |
| 154 | takeBracketed(s, '(', ')') |
| 155 | default: |
| 156 | takePlainExpansion(s) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // takePlainExpansion colours $NAME and the single-character variables. |
| 161 | func takePlainExpansion(s *LineScanner) { |
| 162 | start := s.pos |
| 163 | s.pos++ // the dollar |
| 164 | |
| 165 | if !s.AtEnd() && IsWordRune(s.line[s.pos]) { |
| 166 | for !s.AtEnd() && IsWordRune(s.line[s.pos]) { |
| 167 | s.pos++ |
| 168 | } |
| 169 | s.Emit(start, s.pos, ClassBuiltin) |
| 170 | return |
| 171 | } |
| 172 | // $? $@ $# $* $$ $! and friends are one character each. |
| 173 | if !s.AtEnd() { |
| 174 | s.pos++ |
| 175 | } |
| 176 | s.Emit(start, s.pos, ClassBuiltin) |
| 177 | } |
| 178 | |
| 179 | // takeBracketed colours ${…} and $(…) up to the matching close, counting |
| 180 | // nesting so that $(a $(b) c) is one span. |
| 181 | // |
| 182 | // An unclosed one is coloured to the end of the line, which is what it looks |
| 183 | // like while it is being typed. |
| 184 | func takeBracketed(s *LineScanner, open, close rune) { |
| 185 | start := s.pos |
| 186 | s.pos += 2 // the dollar and the bracket |
| 187 | |
| 188 | depth := 1 |
| 189 | for !s.AtEnd() && depth > 0 { |
| 190 | switch s.line[s.pos] { |
| 191 | case open: |
| 192 | depth++ |
| 193 | case close: |
| 194 | depth-- |
| 195 | } |
| 196 | s.pos++ |
| 197 | } |
| 198 | s.Emit(start, s.pos, ClassBuiltin) |
| 199 | } |
| 200 | |
| 201 | // takeWord colours a bare word: a keyword, a builtin, a variable being |
| 202 | // assigned, the command being run, or one of its arguments. |
| 203 | func (s *bashScanner) takeWord() { |
| 204 | start := s.pos |
| 205 | for !s.AtEnd() && (IsWordRune(s.line[s.pos]) || s.line[s.pos] == '-') { |
| 206 | s.pos++ |
| 207 | } |
| 208 | |
| 209 | class := s.classOf(string(s.line[start:s.pos])) |
| 210 | s.seenWord = true |
| 211 | s.Emit(start, s.pos, class) |
| 212 | } |
| 213 | |
| 214 | // classOf decides what a bare word is. |
| 215 | func (s *bashScanner) classOf(word string) Class { |
| 216 | if class, ok := bashKeywords[word]; ok { |
| 217 | return class |
| 218 | } |
| 219 | if !s.AtEnd() && s.line[s.pos] == '=' { |
| 220 | return ClassIdentifier // NAME=value |
| 221 | } |
| 222 | if s.seenWord { |
| 223 | return ClassIdentifier // an argument |
| 224 | } |
| 225 | return ClassFunction // the command being run |
| 226 | } |