package syntax import ( "strings" "testing" ) // dockerClassOf returns the class covering the first occurrence of a piece of // text in a Dockerfile. func dockerClassOf(t *testing.T, src, want string) Class { t.Helper() index := strings.Index(src, want) if index < 0 { t.Fatalf("%q does not appear in the source", want) } line := strings.Count(src[:index], "\n") col := index - (strings.LastIndex(src[:index], "\n") + 1) class, ok := classAt(Highlight(LanguageDockerfile, src), line, col) if !ok { t.Fatalf("no span covers %q at line %d column %d", want, line, col) } return class } func TestADockerfileIsRecognisedByItsName(t *testing.T) { // It has no extension and no shebang, which is the whole reason Filenames // exists. for _, name := range []string{ "Dockerfile", "dockerfile", "Containerfile", "Dockerfile.dev", "Dockerfile.prod", "docker/Dockerfile", "web.dockerfile", } { t.Run(name, func(t *testing.T) { if got := LanguageOf(name, ""); got != LanguageDockerfile { t.Errorf("LanguageOf(%q) = %q, want dockerfile", name, got) } }) } } func TestAnUnregisteredNameIsStillPlainText(t *testing.T) { // The stem rule must not become a wildcard: a Makefile is not a Dockerfile // merely because nothing else claims it. for _, name := range []string{"Makefile", "Justfile", "LICENSE", "dockerfiles.txt"} { t.Run(name, func(t *testing.T) { if got := LanguageOf(name, ""); got == LanguageDockerfile { t.Errorf("LanguageOf(%q) = dockerfile", name) } }) } } func TestADotfileHasNoStemToMatchOn(t *testing.T) { // ".gitignore" is all extension: its stem is empty, and an empty stem must // match nothing rather than everything. if got := LanguageOf(".gitignore", ""); got != LanguageNone { t.Errorf("LanguageOf(\".gitignore\") = %q, want none", got) } } func TestAnExtensionOutranksAName(t *testing.T) { // A file called Dockerfile.md is documentation about a Dockerfile. if got := LanguageOf("Dockerfile.md", ""); got != LanguageMarkdown { t.Errorf("LanguageOf(\"Dockerfile.md\") = %q, want markdown", got) } } func TestDockerfileColoursItsParts(t *testing.T) { const src = `# syntax=docker/dockerfile:1 FROM golang:1.26 AS builder WORKDIR /src COPY --from=cache /go/pkg /go/pkg ARG VERSION=1.0 RUN go build -o app . EXPOSE 8080 CMD ["./app"] ` tests := map[string]Class{ "# syntax=docker/dockerfile:1": ClassComment, "FROM": ClassKeyword, "AS": ClassKeyword, "WORKDIR": ClassKeyword, "--from": ClassAttribute, "8080": ClassNumber, `"./app"`: ClassString, "golang:1.26": ClassIdentifier, } for text, want := range tests { t.Run(text, func(t *testing.T) { if got := dockerClassOf(t, src, text); got != want { t.Errorf("%q is %v, want %v", text, got, want) } }) } } func TestAnInstructionIsMatchedWhateverItsCase(t *testing.T) { // Docker accepts any case and real files are inconsistent about it. for _, spelling := range []string{"FROM", "from", "From"} { t.Run(spelling, func(t *testing.T) { if got := dockerClassOf(t, spelling+" alpine\n", spelling); got != ClassKeyword { t.Errorf("%q is %v, want keyword", spelling, got) } }) } } func TestAWordThatIsNotAnInstructionIsAnArgument(t *testing.T) { // A continuation line opens with an ordinary word, and colouring it as an // instruction would put a keyword in the middle of a shell command. const src = "RUN apt-get update \\\n && apt-get install -y curl\n" if got := dockerClassOf(t, src, "apt-get"); got != ClassIdentifier { t.Errorf("apt-get is %v, want identifier", got) } } func TestTheLineContinuationIsColouredAsStructure(t *testing.T) { const src = "RUN a \\\n b\n" spans := Highlight(LanguageDockerfile, src) class, ok := classAt(spans, 0, len("RUN a ")) if !ok || class != ClassOperator { t.Errorf("the trailing backslash is %v (covered: %v), want operator", class, ok) } } func TestDockerfileColoursVariables(t *testing.T) { for _, variable := range []string{"$HOME", "${VERSION}", "${TAG:-latest}"} { t.Run(variable, func(t *testing.T) { src := "RUN echo " + variable + "\n" if got := dockerClassOf(t, src, variable); got != ClassBuiltin { t.Errorf("%q is %v, want builtin", variable, got) } }) } } func TestAVariableIsOneSpanToItsClosingBrace(t *testing.T) { const variable = "${TAG:-latest}" src := "RUN echo " + variable + " done\n" spans := Highlight(LanguageDockerfile, src) start := strings.Index(src, variable) for col := start; col < start+len(variable); col++ { class, ok := classAt(spans, 0, col) if !ok || class != ClassBuiltin { t.Fatalf("column %d of %q is %v (covered: %v); the variable was split", col-start, variable, class, ok) } } } func TestAPathOrAnImageReferenceIsOneWord(t *testing.T) { // Splitting /usr/local/bin or golang:1.26-alpine would be three colours for // one argument, which is noise rather than information. The image reference // is the case that matters: it is on the first line of nearly every file. tests := map[string]string{ "WORKDIR /usr/local/bin": "/usr/local/bin", "FROM golang:1.26-alpine": "golang:1.26-alpine", "COPY app /opt/app": "/opt/app", } for src, word := range tests { t.Run(word, func(t *testing.T) { spans := Highlight(LanguageDockerfile, src) start := strings.Index(src, word) for col := start; col < start+len(word); col++ { class, ok := classAt(spans, 0, col) if !ok || class != ClassIdentifier { t.Fatalf("column %d of %q is %v (covered: %v); the word was split", col-start, word, class, ok) } } }) } } func TestDockerfileReturnsOneEntryPerLine(t *testing.T) { tests := map[string]int{ "": 1, "FROM alpine": 1, "FROM alpine\n": 2, "FROM a\nRUN b\n": 3, } for src, want := range tests { if got := len(Highlight(LanguageDockerfile, src)); got != want { t.Errorf("Highlight(%q) returned %d lines, want %d", src, got, want) } } } func TestBrokenDockerfileIsStillColoured(t *testing.T) { for _, src := range []string{"RUN echo \"unterminated", "FROM", "${", "--", "$"} { t.Run(src, func(t *testing.T) { if got := len(Highlight(LanguageDockerfile, src)); got != 1 { t.Errorf("Highlight(%q) returned %d lines, want 1", src, got) } }) } } func TestDockerfileSpansAreOrderedAndDoNotOverlap(t *testing.T) { const src = "FROM golang:1.26 AS build\nCOPY --chown=me:me . /src\nRUN echo \"${TAG:-x}\" # note\n" for line, onLine := range Highlight(LanguageDockerfile, src) { previousEnd := 0 for _, span := range onLine { if span.Start < previousEnd { t.Errorf("line %d: span %+v starts before the previous one ended at %d", line, span, previousEnd) } previousEnd = span.End } } }