diff --git a/CHANGELOG.md b/CHANGELOG.md index c7eaa1a..bd2c6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the Docker Language Server will be documented in this file. +## [Unreleased] + +### Added + +- Compose + - textDocument/completion + - support build stage names for the `target` attribute ([#173](https://github.com/docker/docker-language-server/issues/173)) + ## [0.6.0] - 2025-05-07 ### Added diff --git a/internal/bake/hcl/completion.go b/internal/bake/hcl/completion.go index 526e547..c555f61 100644 --- a/internal/bake/hcl/completion.go +++ b/internal/bake/hcl/completion.go @@ -15,11 +15,11 @@ import ( "github.com/hashicorp/hcl/v2/hclsyntax" ) -func Completion(ctx context.Context, params *protocol.CompletionParams, manager *document.Manager, document document.BakeHCLDocument) (*protocol.CompletionList, error) { +func Completion(ctx context.Context, params *protocol.CompletionParams, manager *document.Manager, bakeDocument document.BakeHCLDocument) (*protocol.CompletionList, error) { filename := string(params.TextDocument.URI) - hclPos := parser.ConvertToHCLPosition(string(document.Input()), int(params.Position.Line), int(params.Position.Character)) - candidates, err := document.Decoder().CompletionAtPos(ctx, filename, hclPos) + hclPos := parser.ConvertToHCLPosition(string(bakeDocument.Input()), int(params.Position.Line), int(params.Position.Character)) + candidates, err := bakeDocument.Decoder().CompletionAtPos(ctx, filename, hclPos) if err != nil { var rangeErr *decoder.PosOutOfRangeError if errors.As(err, &rangeErr) { @@ -30,7 +30,7 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, manager return nil, fmt.Errorf("textDocument/completion encountered an error: %w", err) } } - body, ok := document.File().Body.(*hclsyntax.Body) + body, ok := bakeDocument.File().Body.(*hclsyntax.Body) if !ok { return nil, errors.New("unrecognized body in HCL document") } @@ -68,12 +68,12 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, manager } } - dockerfilePath, err := document.DockerfileForTarget(b) + dockerfilePath, err := bakeDocument.DockerfileForTarget(b) if dockerfilePath == "" || err != nil { continue } - _, nodes := OpenDockerfile(ctx, manager, dockerfilePath) + _, nodes := document.OpenDockerfile(ctx, manager, dockerfilePath) if nodes != nil { if attribute, ok := attributes["target"]; ok && isInsideRange(attribute.Expr.Range(), params.Position) { if _, ok := attributes["dockerfile-inline"]; ok { diff --git a/internal/bake/hcl/definition.go b/internal/bake/hcl/definition.go index cfdcefb..af6003f 100644 --- a/internal/bake/hcl/definition.go +++ b/internal/bake/hcl/definition.go @@ -1,12 +1,9 @@ package hcl import ( - "bytes" "context" "errors" "fmt" - "net/url" - "os" "path/filepath" "strings" @@ -15,14 +12,9 @@ import ( "github.com/docker/docker-language-server/internal/types" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/moby/buildkit/frontend/dockerfile/parser" "go.lsp.dev/uri" ) -func LocalDockerfile(u *url.URL) (string, error) { - return types.AbsolutePath(u, "Dockerfile") -} - func Definition(ctx context.Context, definitionLinkSupport bool, manager *document.Manager, documentURI uri.URI, doc document.BakeHCLDocument, position protocol.Position) (any, error) { body, ok := doc.File().Body.(*hclsyntax.Body) if !ok { @@ -130,7 +122,7 @@ func ResolveExpression(ctx context.Context, definitionLinkSupport bool, manager value, _ := literalValueExpr.Value(&hcl.EvalContext{}) target := value.AsString() - bytes, nodes := OpenDockerfile(ctx, manager, dockerfilePath) + bytes, nodes := document.OpenDockerfile(ctx, manager, dockerfilePath) lines := strings.Split(string(bytes), "\n") for _, child := range nodes { if strings.EqualFold(child.Value, "FROM") { @@ -191,7 +183,7 @@ func ResolveExpression(ctx context.Context, definitionLinkSupport bool, manager end-- } arg := string(doc.Input()[start:end]) - bytes, nodes := OpenDockerfile(ctx, manager, dockerfilePath) + bytes, nodes := document.OpenDockerfile(ctx, manager, dockerfilePath) lines := strings.Split(string(bytes), "\n") for _, child := range nodes { if strings.EqualFold(child.Value, "ARG") { @@ -455,26 +447,3 @@ func CalculateBlockLocation(definitionLinkSupport bool, input []byte, body *hcls } return nil } - -func ParseDockerfile(dockerfilePath string) ([]byte, *parser.Result, error) { - dockerfileBytes, err := os.ReadFile(dockerfilePath) - if err != nil { - return nil, nil, err - } - result, err := parser.Parse(bytes.NewReader(dockerfileBytes)) - return dockerfileBytes, result, err -} - -func OpenDockerfile(ctx context.Context, manager *document.Manager, path string) ([]byte, []*parser.Node) { - doc := manager.Get(ctx, uri.URI(fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(path), "/")))) - if doc != nil { - if dockerfile, ok := doc.(document.DockerfileDocument); ok { - return dockerfile.Input(), dockerfile.Nodes() - } - } - dockerfileBytes, result, err := ParseDockerfile(path) - if err != nil { - return nil, nil - } - return dockerfileBytes, result.AST.Children -} diff --git a/internal/bake/hcl/definition_test.go b/internal/bake/hcl/definition_test.go index 853645d..95e7423 100644 --- a/internal/bake/hcl/definition_test.go +++ b/internal/bake/hcl/definition_test.go @@ -12,6 +12,7 @@ import ( "github.com/docker/docker-language-server/internal/pkg/document" "github.com/docker/docker-language-server/internal/tliron/glsp/protocol" + "github.com/docker/docker-language-server/internal/types" "github.com/stretchr/testify/require" "go.lsp.dev/uri" ) @@ -24,7 +25,7 @@ func TestLocalDockerfileForNonWindows(t *testing.T) { u, err := url.Parse("file:///home/unix/docker-bake.hcl") require.NoError(t, err) - path, err := LocalDockerfile(u) + path, err := types.LocalDockerfile(u) require.NoError(t, err) require.Equal(t, "/home/unix/Dockerfile", path) } @@ -37,7 +38,7 @@ func TestLocalDockerfileForWindows(t *testing.T) { u, err := url.Parse("file:///c%3A/Users/windows/docker-bake.hcl") require.NoError(t, err) - path, err := LocalDockerfile(u) + path, err := types.LocalDockerfile(u) require.NoError(t, err) require.Equal(t, "c:\\Users\\windows\\Dockerfile", path) } diff --git a/internal/bake/hcl/diagnosticsCollector.go b/internal/bake/hcl/diagnosticsCollector.go index 50bbac5..2949004 100644 --- a/internal/bake/hcl/diagnosticsCollector.go +++ b/internal/bake/hcl/diagnosticsCollector.go @@ -234,7 +234,7 @@ func (c *BakeHCLDiagnosticsCollector) CollectDiagnostics(source, workspaceFolder // checkTargetArgs examines the args attribute of a target block. func (c *BakeHCLDiagnosticsCollector) checkTargetArgs(dockerfilePath string, input []byte, expr *hclsyntax.ObjectConsExpr, source string) []protocol.Diagnostic { - _, nodes := OpenDockerfile(context.Background(), c.docs, dockerfilePath) + _, nodes := document.OpenDockerfile(context.Background(), c.docs, dockerfilePath) args := []string{} for _, child := range nodes { if strings.EqualFold(child.Value, "ARG") { @@ -283,7 +283,7 @@ func (c *BakeHCLDiagnosticsCollector) checkTargetTarget(dockerfilePath string, e value, _ := literalValueExpr.Value(&hcl.EvalContext{}) target := value.AsString() - _, nodes := OpenDockerfile(context.Background(), c.docs, dockerfilePath) + _, nodes := document.OpenDockerfile(context.Background(), c.docs, dockerfilePath) found := false for _, child := range nodes { if strings.EqualFold(child.Value, "FROM") { diff --git a/internal/bake/hcl/inlayHint.go b/internal/bake/hcl/inlayHint.go index 917ee19..bd99de2 100644 --- a/internal/bake/hcl/inlayHint.go +++ b/internal/bake/hcl/inlayHint.go @@ -27,7 +27,7 @@ func InlayHint(docs *document.Manager, doc document.BakeHCLDocument, rng protoco if expr, ok := attribute.Expr.(*hclsyntax.ObjectConsExpr); ok && len(expr.Items) > 0 { dockerfilePath, err := doc.DockerfileForTarget(block) if dockerfilePath != "" && err == nil { - _, nodes := OpenDockerfile(context.Background(), docs, dockerfilePath) + _, nodes := document.OpenDockerfile(context.Background(), docs, dockerfilePath) args := map[string]string{} for _, child := range nodes { if strings.EqualFold(child.Value, "ARG") { diff --git a/internal/bake/hcl/inlineCompletion.go b/internal/bake/hcl/inlineCompletion.go index af71d9f..d82aaf6 100644 --- a/internal/bake/hcl/inlineCompletion.go +++ b/internal/bake/hcl/inlineCompletion.go @@ -11,6 +11,7 @@ import ( "github.com/docker/docker-language-server/internal/pkg/document" "github.com/docker/docker-language-server/internal/tliron/glsp/protocol" + "github.com/docker/docker-language-server/internal/types" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" @@ -42,23 +43,23 @@ func shouldSuggest(content []byte, body *hclsyntax.Body, position protocol.Posit return strings.TrimSpace(lines[position.Line]) != "}" } -func InlineCompletion(ctx context.Context, params *protocol.InlineCompletionParams, manager *document.Manager, document document.BakeHCLDocument) ([]protocol.InlineCompletionItem, error) { +func InlineCompletion(ctx context.Context, params *protocol.InlineCompletionParams, manager *document.Manager, bakeDocument document.BakeHCLDocument) ([]protocol.InlineCompletionItem, error) { url, err := url.Parse(params.TextDocument.URI) if err != nil { return nil, fmt.Errorf("LSP client sent invalid URI: %v", params.TextDocument.URI) } - body, ok := document.File().Body.(*hclsyntax.Body) + body, ok := bakeDocument.File().Body.(*hclsyntax.Body) if !ok { return nil, errors.New("unrecognized body in HCL document") } - dockerfilePath, err := LocalDockerfile(url) + dockerfilePath, err := types.LocalDockerfile(url) if err != nil { return nil, fmt.Errorf("invalid document path: %v", url.Path) } - if !shouldSuggest(document.Input(), body, params.Position) { + if !shouldSuggest(bakeDocument.Input(), body, params.Position) { return nil, nil } @@ -86,7 +87,7 @@ func InlineCompletion(ctx context.Context, params *protocol.InlineCompletionPara argNames := []string{} args := map[string]string{} targets := []string{} - _, nodes := OpenDockerfile(ctx, manager, dockerfilePath) + _, nodes := document.OpenDockerfile(ctx, manager, dockerfilePath) before := true for _, child := range nodes { if strings.EqualFold(child.Value, "ARG") && before { @@ -113,7 +114,7 @@ func InlineCompletion(ctx context.Context, params *protocol.InlineCompletionPara if len(targets) > 0 { items := []protocol.InlineCompletionItem{} - lines := strings.Split(string(document.Input()), "\n") + lines := strings.Split(string(bakeDocument.Input()), "\n") for _, target := range targets { sb := strings.Builder{} sb.WriteString(fmt.Sprintf("target \"%v\" {\n", target)) diff --git a/internal/compose/completion.go b/internal/compose/completion.go index 411ce58..94b8614 100644 --- a/internal/compose/completion.go +++ b/internal/compose/completion.go @@ -3,6 +3,7 @@ package compose import ( "context" "fmt" + "net/url" "slices" "strings" "unicode" @@ -40,7 +41,12 @@ func array(line string, character int) bool { return isArray } -func Completion(ctx context.Context, params *protocol.CompletionParams, doc document.ComposeDocument) (*protocol.CompletionList, error) { +func Completion(ctx context.Context, params *protocol.CompletionParams, manager *document.Manager, doc document.ComposeDocument) (*protocol.CompletionList, error) { + u, err := url.Parse(params.TextDocument.URI) + if err != nil { + return nil, fmt.Errorf("LSP client sent invalid URI: %v", params.TextDocument.URI) + } + if params.Position.Character == 0 { items := []protocol.CompletionItem{} for attributeName, schema := range schemaProperties() { @@ -81,8 +87,12 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, doc docu if len(dependencies) > 0 { return &protocol.CompletionList{Items: dependencies}, nil } + items, stop := buildTargetCompletionItems(params, manager, path, u, protocol.UInteger(len(wordPrefix))) + if stop { + return &protocol.CompletionList{Items: items}, nil + } - items := volumeDependencyCompletionItems(file, path, params, protocol.UInteger(len(wordPrefix))) + items = volumeDependencyCompletionItems(file, path, params, protocol.UInteger(len(wordPrefix))) if len(items) > 0 { return &protocol.CompletionList{Items: items}, nil } @@ -201,6 +211,50 @@ func findDependencies(file *ast.File, dependencyType string) []string { return services } +func findBuildStages(params *protocol.CompletionParams, manager *document.Manager, dockerfilePath, prefix string, prefixLength protocol.UInteger) []protocol.CompletionItem { + _, nodes := document.OpenDockerfile(context.Background(), manager, dockerfilePath) + items := []protocol.CompletionItem{} + for _, child := range nodes { + if strings.EqualFold(child.Value, "FROM") { + if child.Next != nil && child.Next.Next != nil && strings.EqualFold(child.Next.Next.Value, "AS") && child.Next.Next.Next != nil { + buildStage := child.Next.Next.Next.Value + if strings.HasPrefix(buildStage, prefix) { + items = append(items, protocol.CompletionItem{ + Label: buildStage, + Documentation: child.Next.Value, + TextEdit: protocol.TextEdit{ + NewText: buildStage, + Range: protocol.Range{ + Start: protocol.Position{ + Line: params.Position.Line, + Character: params.Position.Character - prefixLength, + }, + End: params.Position, + }, + }, + }) + } + } + } + } + return items +} + +func buildTargetCompletionItems(params *protocol.CompletionParams, manager *document.Manager, path []*ast.MappingValueNode, u *url.URL, prefixLength protocol.UInteger) ([]protocol.CompletionItem, bool) { + if len(path) == 4 && path[2].Key.GetToken().Value == "build" && path[3].Key.GetToken().Value == "target" { + dockerfilePath, err := types.LocalDockerfile(u) + if err == nil { + if _, ok := path[3].Value.(*ast.NullNode); ok { + return findBuildStages(params, manager, dockerfilePath, "", prefixLength), true + } else if prefix, ok := path[3].Value.(*ast.StringNode); ok { + offset := int(params.Position.Character) - path[3].Value.GetToken().Position.Column + 1 + return findBuildStages(params, manager, dockerfilePath, prefix.Value[0:offset], prefixLength), true + } + } + } + return nil, false +} + func dependencyCompletionItems(file *ast.File, path []*ast.MappingValueNode, params *protocol.CompletionParams, prefixLength protocol.UInteger) []protocol.CompletionItem { dependency := map[string]string{ "depends_on": "services", diff --git a/internal/compose/completion_test.go b/internal/compose/completion_test.go index 32e4ebe..14844ba 100644 --- a/internal/compose/completion_test.go +++ b/internal/compose/completion_test.go @@ -2171,7 +2171,7 @@ services: TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, Position: protocol.Position{Line: tc.line, Character: tc.character}, }, - }, doc) + }, nil, doc) require.NoError(t, err) require.Equal(t, tc.list, list) }) @@ -2808,7 +2808,199 @@ secrets: TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, Position: protocol.Position{Line: tc.line, Character: tc.character}, }, - }, doc) + }, nil, doc) + require.NoError(t, err) + if tc.list == nil { + require.Nil(t, list) + } else { + require.Equal(t, tc.list, list) + } + }) + } +} + +func TestCompletion_ExternalFileLookups(t *testing.T) { + testCases := []struct { + name string + dockerfileContent string + content string + line uint32 + character uint32 + list *protocol.CompletionList + }{ + { + name: "target attribute finds nothing", + dockerfileContent: "FROM scratch", + content: ` +services: + postgres: + build: + target: `, + line: 4, + character: 14, + list: &protocol.CompletionList{Items: []protocol.CompletionItem{}}, + }, + { + name: "target attribute finds nothing", + dockerfileContent: "FROM scratch AS", + content: ` +services: + postgres: + build: + target: `, + line: 4, + character: 14, + list: &protocol.CompletionList{Items: []protocol.CompletionItem{}}, + }, + { + name: "target attribute ignores target with an invalid AS", + dockerfileContent: "FROM scratch ABC base", + content: ` +services: + postgres: + build: + target: `, + line: 4, + character: 14, + list: &protocol.CompletionList{Items: []protocol.CompletionItem{}}, + }, + { + name: "target attribute finds a target with uppercase AS", + dockerfileContent: "FROM scratch AS base", + content: ` +services: + postgres: + build: + target: `, + line: 4, + character: 14, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "base", + Documentation: "scratch", + TextEdit: textEdit("base", 4, 14, 0), + }, + }, + }, + }, + { + name: "target attribute finds a target with lowercase AS", + dockerfileContent: "FROM scratch as base", + content: ` +services: + postgres: + build: + target: `, + line: 4, + character: 14, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "base", + Documentation: "scratch", + TextEdit: textEdit("base", 4, 14, 0), + }, + }, + }, + }, + { + name: "target attribute finds two build stages", + dockerfileContent: "FROM busybox as base\nFROM alpine as base2", + content: ` +services: + postgres: + build: + target: `, + line: 4, + character: 14, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "base", + Documentation: "busybox", + TextEdit: textEdit("base", 4, 14, 0), + }, + { + Label: "base2", + Documentation: "alpine", + TextEdit: textEdit("base2", 4, 14, 0), + }, + }, + }, + }, + { + name: "build stage suggested by prefix", + dockerfileContent: "FROM busybox as bstage\nFROM alpine as astage", + content: ` +services: + postgres: + build: + target: a`, + line: 4, + character: 15, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "astage", + Documentation: "alpine", + TextEdit: textEdit("astage", 4, 15, 1), + }, + }, + }, + }, + { + name: "invalid prefix with a space is ignored", + dockerfileContent: "FROM busybox as bstage\nFROM alpine as astage", + content: ` +services: + postgres: + build: + target: a a`, + line: 4, + character: 17, + list: &protocol.CompletionList{Items: []protocol.CompletionItem{}}, + }, + { + name: "completion in the middle with a valid prefix", + dockerfileContent: "FROM busybox as bstage\nFROM alpine as astage", + content: ` +services: + postgres: + build: + target: ab`, + line: 4, + character: 15, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "astage", + Documentation: "alpine", + TextEdit: textEdit("astage", 4, 15, 1), + }, + }, + }, + }, + } + + dockerfileURI := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(os.TempDir(), "Dockerfile")), "/")) + composeFileURI := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(os.TempDir(), "compose.yaml")), "/")) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + manager := document.NewDocumentManager() + if tc.dockerfileContent != "" { + changed, err := manager.Write(context.Background(), uri.URI(dockerfileURI), protocol.DockerfileLanguage, 1, []byte(tc.dockerfileContent)) + require.NoError(t, err) + require.True(t, changed) + } + doc := document.NewComposeDocument(uri.URI(composeFileURI), 1, []byte(tc.content)) + list, err := Completion(context.Background(), &protocol.CompletionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, + Position: protocol.Position{Line: tc.line, Character: tc.character}, + }, + }, manager, doc) require.NoError(t, err) if tc.list == nil { require.Nil(t, list) diff --git a/internal/pkg/document/manager.go b/internal/pkg/document/manager.go index 3ddc3ae..a474e78 100644 --- a/internal/pkg/document/manager.go +++ b/internal/pkg/document/manager.go @@ -1,16 +1,19 @@ package document import ( + "bytes" "context" "errors" "fmt" "os" + "path/filepath" "strings" "sync" "time" "github.com/bep/debounce" "github.com/docker/docker-language-server/internal/tliron/glsp/protocol" + "github.com/moby/buildkit/frontend/dockerfile/parser" "go.lsp.dev/uri" ) @@ -32,6 +35,29 @@ type documentLock struct { queue func(func()) } +func parseDockerfile(dockerfilePath string) ([]byte, *parser.Result, error) { + dockerfileBytes, err := os.ReadFile(dockerfilePath) + if err != nil { + return nil, nil, err + } + result, err := parser.Parse(bytes.NewReader(dockerfileBytes)) + return dockerfileBytes, result, err +} + +func OpenDockerfile(ctx context.Context, manager *Manager, path string) ([]byte, []*parser.Node) { + doc := manager.Get(ctx, uri.URI(fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(path), "/")))) + if doc != nil { + if dockerfile, ok := doc.(DockerfileDocument); ok { + return dockerfile.Input(), dockerfile.Nodes() + } + } + dockerfileBytes, result, err := parseDockerfile(path) + if err != nil { + return nil, nil + } + return dockerfileBytes, result.AST.Children +} + func NewDocumentManager(opts ...ManagerOpt) *Manager { m := Manager{ docs: make(DocumentMap), diff --git a/internal/pkg/server/completion.go b/internal/pkg/server/completion.go index 542ae68..bc61549 100644 --- a/internal/pkg/server/completion.go +++ b/internal/pkg/server/completion.go @@ -19,7 +19,7 @@ func (s *Server) TextDocumentCompletion(ctx *glsp.Context, params *protocol.Comp if doc.LanguageIdentifier() == protocol.DockerBakeLanguage { return hcl.Completion(ctx.Context, params, s.docs, doc.(document.BakeHCLDocument)) } else if doc.LanguageIdentifier() == protocol.DockerComposeLanguage && s.composeCompletion { - return compose.Completion(ctx.Context, params, doc.(document.ComposeDocument)) + return compose.Completion(ctx.Context, params, s.docs, doc.(document.ComposeDocument)) } return nil, nil } diff --git a/internal/types/common.go b/internal/types/common.go index 9d6d3aa..4b30cae 100644 --- a/internal/types/common.go +++ b/internal/types/common.go @@ -77,6 +77,10 @@ func StripLeadingSlash(folder string) string { return folder } +func LocalDockerfile(u *url.URL) (string, error) { + return AbsolutePath(u, "Dockerfile") +} + func AbsolutePath(documentURL *url.URL, path string) (string, error) { documentPath := documentURL.Path if runtime.GOOS == "windows" {