package syntax import "strings" // highlightDockerfile colours a Dockerfile, one slice of spans per line. // // Nothing in a Dockerfile crosses a line break as far as colouring is // concerned. A backslash continuation joins two lines for Docker, but each half // still reads as shell and each is coloured on its own — which is what a reader // wants, since the halves are usually a command and its arguments. func highlightDockerfile(src string) [][]Span { return ScanLines(src, scanDockerfileLine) } // scanDockerfileLine colours one line. Dockerfiles need no carried state, so // the state parameter is the empty struct. func scanDockerfileLine(line []rune, carry struct{}) ([]Span, struct{}) { s := &LineScanner{line: line} s.SkipSpaces() switch { case s.AtEnd(): return s.spans, carry case s.Peek(0) == '#': // This covers the parser directives too — `# syntax=docker/…` and // `# escape=\` are comments to everything but Docker's own front end, // and colouring them as instructions would suggest they are ones. s.TakeRest(ClassComment) return s.spans, carry } takeDockerfileInstruction(s) for !s.AtEnd() { stepDockerfileArgument(s) } return s.spans, carry } // takeDockerfileInstruction colours the instruction a line opens with. // // Instructions are matched case-insensitively because Docker accepts any case // and real files are inconsistent about it, though the convention is capitals. // A first word that is not an instruction is left as plain text rather than // guessed at: a continuation line's first word is an ordinary argument. func takeDockerfileInstruction(s *LineScanner) { start := s.Pos() for !s.AtEnd() && IsLetter(s.Peek(0)) { s.Advance(1) } if s.Pos() == start { return } if !dockerfileInstructions[strings.ToUpper(string(s.line[start:s.Pos()]))] { // Not an instruction. Put the position back so the word is coloured by // the argument rules, which is where a continuation line's first word // belongs. s.pos = start return } s.Emit(start, s.Pos(), ClassKeyword) } // stepDockerfileArgument colours one thing after the instruction. func stepDockerfileArgument(s *LineScanner) { switch r := s.Peek(0); { case r == ' ' || r == '\t': s.SkipSpaces() case r == '#': s.TakeRest(ClassComment) case r == '"' || r == '\'': TakeQuoted(s, r, ClassString) case r == '$': takeDockerfileVariable(s) case s.HasPrefix(0, "--"): // A flag such as --from=builder or --chown=me:me. The name is the part // that carries the meaning, so the value after `=` is coloured as one. s.TakeWhile(ClassAttribute, func(c rune) bool { return c != '=' && c != ' ' }) case r == '\\' && s.Peek(1) == 0: // The line continuation, which is structure rather than an argument. s.Take(1, ClassOperator) case IsDigit(r): s.TakeWhile(ClassNumber, func(c rune) bool { return IsDigit(c) || c == '.' }) case IsLetter(r) || r == '_' || r == '/' || r == '.': takeDockerfileWord(s) case IsPunctuationRune(r) || r == '=' || r == ':': s.Take(1, ClassPunctuation) default: s.Advance(1) } } // takeDockerfileVariable colours $NAME and ${NAME:-default}. func takeDockerfileVariable(s *LineScanner) { start := s.Pos() s.Advance(1) if s.Peek(0) == '{' { for !s.AtEnd() && s.Peek(0) != '}' { s.Advance(1) } s.Advance(1) // the closing brace s.Emit(start, s.Pos(), ClassBuiltin) return } for !s.AtEnd() && IsWordRune(s.Peek(0)) { s.Advance(1) } s.Emit(start, s.Pos(), ClassBuiltin) } // takeDockerfileWord colours a bare word: `AS`, `as` and the stage name after // them are what a reader looks for in a multi-stage build. func takeDockerfileWord(s *LineScanner) { start := s.Pos() for !s.AtEnd() && isDockerfileWordRune(s.Peek(0)) { s.Advance(1) } class := ClassIdentifier if dockerfileModifiers[strings.ToUpper(string(s.line[start:s.Pos()]))] { class = ClassKeyword } s.Emit(start, s.Pos(), class) } // isDockerfileWordRune reports whether a rune belongs to a bare word. // // Paths and image references are words: `/usr/local/bin` and // `golang:1.26-alpine` each read as one thing, and splitting them would be // three colours for one argument. The colon is in the list for the image // reference's sake, which is the same reasoning the YAML scanner uses for // `image: nginx:1.27`. func isDockerfileWordRune(r rune) bool { return IsWordRune(r) || strings.ContainsRune(dockerfileWordRunes, r) } // dockerfileWordRunes are the characters that join a bare word beyond the // letters, digits and underscores every language shares. const dockerfileWordRunes = "-./@:" // dockerfileInstructions are the words that may open a line. The list is the // whole of the Dockerfile language: there is no user-extensible instruction, so // a word that is not here is an argument. var dockerfileInstructions = wordSet( "ADD", "ARG", "CMD", "COPY", "ENTRYPOINT", "ENV", "EXPOSE", "FROM", "HEALTHCHECK", "LABEL", "MAINTAINER", "ONBUILD", "RUN", "SHELL", "STOPSIGNAL", "USER", "VOLUME", "WORKDIR", ) // dockerfileModifiers are words that carry meaning inside an instruction rather // than opening one. var dockerfileModifiers = wordSet("AS", "NONE") // wordSet builds a lookup from a list of words. func wordSet(words ...string) map[string]bool { set := make(map[string]bool, len(words)) for _, word := range words { set[word] = true } return set }