From 2948c0a558c5c3c77e0a79ba6013547dd32d74df Mon Sep 17 00:00:00 2001 From: Remy Suen Date: Tue, 19 Aug 2025 15:33:37 -0400 Subject: [PATCH] Support document links for a service's env_file Signed-off-by: Remy Suen --- CHANGELOG.md | 2 + internal/compose/documentLink.go | 9 +- internal/compose/documentLink_test.go | 214 ++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd576e..6801053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to the Docker Language Server will be documented in this fil - Compose - textDocument/completion - suggest image tags for images from Docker Hub ([#375](https://github.com/docker/docker-language-server/issues/375)) + - textDocument/documentLink + - support providing links for the `env_file` attribute of a service object ([#436](https://github.com/docker/docker-language-server/issues/436)) - Bake - textDocument/completion - provide local file and folder name suggestions ([#414](https://github.com/docker/docker-language-server/issues/414)) diff --git a/internal/compose/documentLink.go b/internal/compose/documentLink.go index baece18..4e7e325 100644 --- a/internal/compose/documentLink.go +++ b/internal/compose/documentLink.go @@ -84,8 +84,8 @@ func createImageLink(serviceNode *ast.MappingValueNode) *protocol.DocumentLink { return nil } -func createLabelFileLink(folderAbsolutePath string, wslDollarSign bool, serviceNode *ast.MappingValueNode) []protocol.DocumentLink { - if resolveAnchor(serviceNode.Key).GetToken().Value == "label_file" { +func createFileLinks(folderAbsolutePath string, wslDollarSign bool, serviceNode *ast.MappingValueNode, attributeName string) []protocol.DocumentLink { + if resolveAnchor(serviceNode.Key).GetToken().Value == attributeName { if sequence, ok := resolveAnchor(serviceNode.Value).(*ast.SequenceNode); ok { links := []protocol.DocumentLink{} for _, node := range sequence.Values { @@ -203,7 +203,10 @@ func scanForLinks(folderAbsolutePath string, wslDollarSign bool, n *ast.MappingV links = append(links, *link) } - labelFileLinks := createLabelFileLink(folderAbsolutePath, wslDollarSign, serviceAttribute) + envFileLinks := createFileLinks(folderAbsolutePath, wslDollarSign, serviceAttribute, "env_file") + links = append(links, envFileLinks...) + + labelFileLinks := createFileLinks(folderAbsolutePath, wslDollarSign, serviceAttribute, "label_file") links = append(links, labelFileLinks...) } } diff --git a/internal/compose/documentLink_test.go b/internal/compose/documentLink_test.go index 807340f..f58a886 100644 --- a/internal/compose/documentLink_test.go +++ b/internal/compose/documentLink_test.go @@ -1350,6 +1350,220 @@ services: } } +func TestDocumentLink_ServiceEnvFileLinks(t *testing.T) { + testsFolder := filepath.Join(os.TempDir(), t.Name()) + composeStringURI := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(testsFolder, "compose.yaml")), "/")) + + testCases := []struct { + name string + content string + path string + linkRange protocol.Range + }{ + { + name: "string value app.labels", + content: ` +services: + test: + env_file: .env`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 14}, + End: protocol.Position{Line: 3, Character: 18}, + }, + }, + { + name: "string value ./.env", + content: ` +services: + test: + env_file: ./.env`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 14}, + End: protocol.Position{Line: 3, Character: 20}, + }, + }, + { + name: "quoted string value \"./.env\"", + content: ` +services: + test: + env_file: "./.env"`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 15}, + End: protocol.Position{Line: 3, Character: 21}, + }, + }, + { + name: "attribute value is null", + content: ` +services: + test: + env_file: null`, + }, + { + name: "array items", + content: ` +services: + test: + env_file: + - ./.env`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 8}, + End: protocol.Position{Line: 4, Character: 14}, + }, + }, + { + name: "array item is null", + content: ` +services: + test: + env_file: + - null`, + }, + { + name: "anchors and aliases to nothing", + content: ` +services: + test: + env_file: &anchor + test2: + env_file: *anchor`, + }, + { + name: "anchor on the services object itself", + content: ` +&anchor services: + test: + env_file: ./.env`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 14}, + End: protocol.Position{Line: 3, Character: 20}, + }, + }, + { + name: "anchor on the services object's value", + content: ` +services: &anchor + test: + env_file: ./.env`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 14}, + End: protocol.Position{Line: 3, Character: 20}, + }, + }, + { + name: "anchor on the service object itself", + content: ` +services: + &anchor test: + env_file: ./.env`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 14}, + End: protocol.Position{Line: 3, Character: 20}, + }, + }, + { + name: "anchor on the service object's value", + content: ` +services: + test: &anchor + env_file: ./.env`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 14}, + End: protocol.Position{Line: 3, Character: 20}, + }, + }, + { + name: "anchor on the service object's value as JSON", + content: ` +services: + test: &anchor { env_file: ./.env }`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 2, Character: 28}, + End: protocol.Position{Line: 2, Character: 34}, + }, + }, + { + name: "anchor on the env_file string attribute itself", + content: ` +services: + test: &anchor + &anchor env_file: ./.env`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 22}, + End: protocol.Position{Line: 3, Character: 28}, + }, + }, + { + name: "anchor on the env_file string attribute's value", + content: ` +services: + test: &anchor + env_file: &anchor ./.env`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 22}, + End: protocol.Position{Line: 3, Character: 28}, + }, + }, + { + name: "anchor on the env_file array attribute's value", + content: ` +services: + test: &anchor + env_file: &anchor + - ./.env`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 8}, + End: protocol.Position{Line: 4, Character: 14}, + }, + }, + { + name: "anchor on the env_file array item's value", + content: ` +services: + test: &anchor + env_file: + - &anchor ./.env`, + path: filepath.Join(testsFolder, "./.env"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 16}, + End: protocol.Position{Line: 4, Character: 22}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mgr := document.NewDocumentManager() + doc := document.NewComposeDocument(mgr, uri.URI(composeStringURI), 1, []byte(tc.content)) + links, err := DocumentLink(context.Background(), composeStringURI, doc) + require.NoError(t, err) + if tc.path == "" { + require.Equal(t, []protocol.DocumentLink{}, links) + } else { + link := protocol.DocumentLink{ + Range: tc.linkRange, + Target: types.CreateStringPointer(fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(tc.path), "/"))), + Tooltip: types.CreateStringPointer(filepath.FromSlash(tc.path)), + } + require.Equal(t, []protocol.DocumentLink{link}, links) + } + }) + } +} + func TestDocumentLink_ServiceExtendsFileLinks(t *testing.T) { testsFolder := filepath.Join(os.TempDir(), t.Name()) composeStringURI := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(testsFolder, "compose.yaml")), "/"))