| 🛟 Updated. 28d5985 k33g 4h ago | 1 | package syntax |
| 2 | |
| 3 | import "strings" |
| 4 | |
| 5 | // highlightDockerfile colours a Dockerfile, one slice of spans per line. |
| 6 | // |
| 7 | // Nothing in a Dockerfile crosses a line break as far as colouring is |
| 8 | // concerned. A backslash continuation joins two lines for Docker, but each half |
| 9 | // still reads as shell and each is coloured on its own — which is what a reader |
| 10 | // wants, since the halves are usually a command and its arguments. |
| 11 | func highlightDockerfile(src string) [][]Span { |
| 12 | return ScanLines(src, scanDockerfileLine) |
| 13 | } |
| 14 | |
| 15 | // scanDockerfileLine colours one line. Dockerfiles need no carried state, so |
| 16 | // the state parameter is the empty struct. |
| 17 | func scanDockerfileLine(line []rune, carry struct{}) ([]Span, struct{}) { |
| 18 | s := &LineScanner{line: line} |
| 19 | |
| 20 | s.SkipSpaces() |
| 21 | switch { |
| 22 | case s.AtEnd(): |
| 23 | return s.spans, carry |
| 24 | case s.Peek(0) == '#': |
| 25 | // This covers the parser directives too — `# syntax=docker/…` and |
| 26 | // `# escape=\` are comments to everything but Docker's own front end, |
| 27 | // and colouring them as instructions would suggest they are ones. |
| 28 | s.TakeRest(ClassComment) |
| 29 | return s.spans, carry |
| 30 | } |
| 31 | |
| 32 | takeDockerfileInstruction(s) |
| 33 | for !s.AtEnd() { |
| 34 | stepDockerfileArgument(s) |
| 35 | } |
| 36 | return s.spans, carry |
| 37 | } |
| 38 | |
| 39 | // takeDockerfileInstruction colours the instruction a line opens with. |
| 40 | // |
| 41 | // Instructions are matched case-insensitively because Docker accepts any case |
| 42 | // and real files are inconsistent about it, though the convention is capitals. |
| 43 | // A first word that is not an instruction is left as plain text rather than |
| 44 | // guessed at: a continuation line's first word is an ordinary argument. |
| 45 | func takeDockerfileInstruction(s *LineScanner) { |
| 46 | start := s.Pos() |
| 47 | for !s.AtEnd() && IsLetter(s.Peek(0)) { |
| 48 | s.Advance(1) |
| 49 | } |
| 50 | if s.Pos() == start { |
| 51 | return |
| 52 | } |
| 53 | |
| 54 | if !dockerfileInstructions[strings.ToUpper(string(s.line[start:s.Pos()]))] { |
| 55 | // Not an instruction. Put the position back so the word is coloured by |
| 56 | // the argument rules, which is where a continuation line's first word |
| 57 | // belongs. |
| 58 | s.pos = start |
| 59 | return |
| 60 | } |
| 61 | s.Emit(start, s.Pos(), ClassKeyword) |
| 62 | } |
| 63 | |
| 64 | // stepDockerfileArgument colours one thing after the instruction. |
| 65 | func stepDockerfileArgument(s *LineScanner) { |
| 66 | switch r := s.Peek(0); { |
| 67 | case r == ' ' || r == '\t': |
| 68 | s.SkipSpaces() |
| 69 | case r == '#': |
| 70 | s.TakeRest(ClassComment) |
| 71 | case r == '"' || r == '\'': |
| 72 | TakeQuoted(s, r, ClassString) |
| 73 | case r == '$': |
| 74 | takeDockerfileVariable(s) |
| 75 | case s.HasPrefix(0, "--"): |
| 76 | // A flag such as --from=builder or --chown=me:me. The name is the part |
| 77 | // that carries the meaning, so the value after `=` is coloured as one. |
| 78 | s.TakeWhile(ClassAttribute, func(c rune) bool { return c != '=' && c != ' ' }) |
| 79 | case r == '\\' && s.Peek(1) == 0: |
| 80 | // The line continuation, which is structure rather than an argument. |
| 81 | s.Take(1, ClassOperator) |
| 82 | case IsDigit(r): |
| 83 | s.TakeWhile(ClassNumber, func(c rune) bool { return IsDigit(c) || c == '.' }) |
| 84 | case IsLetter(r) || r == '_' || r == '/' || r == '.': |
| 85 | takeDockerfileWord(s) |
| 86 | case IsPunctuationRune(r) || r == '=' || r == ':': |
| 87 | s.Take(1, ClassPunctuation) |
| 88 | default: |
| 89 | s.Advance(1) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // takeDockerfileVariable colours $NAME and ${NAME:-default}. |
| 94 | func takeDockerfileVariable(s *LineScanner) { |
| 95 | start := s.Pos() |
| 96 | s.Advance(1) |
| 97 | |
| 98 | if s.Peek(0) == '{' { |
| 99 | for !s.AtEnd() && s.Peek(0) != '}' { |
| 100 | s.Advance(1) |
| 101 | } |
| 102 | s.Advance(1) // the closing brace |
| 103 | s.Emit(start, s.Pos(), ClassBuiltin) |
| 104 | return |
| 105 | } |
| 106 | |
| 107 | for !s.AtEnd() && IsWordRune(s.Peek(0)) { |
| 108 | s.Advance(1) |
| 109 | } |
| 110 | s.Emit(start, s.Pos(), ClassBuiltin) |
| 111 | } |
| 112 | |
| 113 | // takeDockerfileWord colours a bare word: `AS`, `as` and the stage name after |
| 114 | // them are what a reader looks for in a multi-stage build. |
| 115 | func takeDockerfileWord(s *LineScanner) { |
| 116 | start := s.Pos() |
| 117 | for !s.AtEnd() && isDockerfileWordRune(s.Peek(0)) { |
| 118 | s.Advance(1) |
| 119 | } |
| 120 | |
| 121 | class := ClassIdentifier |
| 122 | if dockerfileModifiers[strings.ToUpper(string(s.line[start:s.Pos()]))] { |
| 123 | class = ClassKeyword |
| 124 | } |
| 125 | s.Emit(start, s.Pos(), class) |
| 126 | } |
| 127 | |
| 128 | // isDockerfileWordRune reports whether a rune belongs to a bare word. |
| 129 | // |
| 130 | // Paths and image references are words: `/usr/local/bin` and |
| 131 | // `golang:1.26-alpine` each read as one thing, and splitting them would be |
| 132 | // three colours for one argument. The colon is in the list for the image |
| 133 | // reference's sake, which is the same reasoning the YAML scanner uses for |
| 134 | // `image: nginx:1.27`. |
| 135 | func isDockerfileWordRune(r rune) bool { |
| 136 | return IsWordRune(r) || strings.ContainsRune(dockerfileWordRunes, r) |
| 137 | } |
| 138 | |
| 139 | // dockerfileWordRunes are the characters that join a bare word beyond the |
| 140 | // letters, digits and underscores every language shares. |
| 141 | const dockerfileWordRunes = "-./@:" |
| 142 | |
| 143 | // dockerfileInstructions are the words that may open a line. The list is the |
| 144 | // whole of the Dockerfile language: there is no user-extensible instruction, so |
| 145 | // a word that is not here is an argument. |
| 146 | var dockerfileInstructions = wordSet( |
| 147 | "ADD", "ARG", "CMD", "COPY", "ENTRYPOINT", "ENV", "EXPOSE", "FROM", |
| 148 | "HEALTHCHECK", "LABEL", "MAINTAINER", "ONBUILD", "RUN", "SHELL", |
| 149 | "STOPSIGNAL", "USER", "VOLUME", "WORKDIR", |
| 150 | ) |
| 151 | |
| 152 | // dockerfileModifiers are words that carry meaning inside an instruction rather |
| 153 | // than opening one. |
| 154 | var dockerfileModifiers = wordSet("AS", "NONE") |
| 155 | |
| 156 | // wordSet builds a lookup from a list of words. |
| 157 | func wordSet(words ...string) map[string]bool { |
| 158 | set := make(map[string]bool, len(words)) |
| 159 | for _, word := range words { |
| 160 | set[word] = true |
| 161 | } |
| 162 | return set |
| 163 | } |