diff --git a/CHANGELOG.md b/CHANGELOG.md index e492bbd..8127a31 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] + +### Fixed + +- Compose + - textDocument/documentLink + - return document links for files referenced in the short-form `volumes` attribute of a service object ([#460](https://github.com/docker/docker-language-server/issues/460)) + ## [0.18.0] - 2025-08-25 ### Added diff --git a/internal/compose/documentLink.go b/internal/compose/documentLink.go index 02c3070..7d85f98 100644 --- a/internal/compose/documentLink.go +++ b/internal/compose/documentLink.go @@ -3,8 +3,12 @@ package compose import ( "context" "fmt" + "os" + "path/filepath" "strings" + "github.com/compose-spec/compose-go/v2/format" + composeTypes "github.com/compose-spec/compose-go/v2/types" "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" @@ -104,6 +108,40 @@ func createFileLinks(folderAbsolutePath string, wslDollarSign bool, serviceNode return nil } +func createVolumeFileLinks(folderAbsolutePath string, wslDollarSign bool, serviceNode *ast.MappingValueNode) []protocol.DocumentLink { + if resolveAnchor(serviceNode.Key).GetToken().Value == "volumes" { + if sequence, ok := resolveAnchor(serviceNode.Value).(*ast.SequenceNode); ok { + links := []protocol.DocumentLink{} + for _, node := range sequence.Values { + if s, ok := resolveAnchor(node).(*ast.StringNode); ok { + t := s.GetToken() + config, err := format.ParseVolume(t.Value) + if err == nil && config.Type == composeTypes.VolumeTypeBind { + uri, path := createLocalFileLink(folderAbsolutePath, config.Source, wslDollarSign) + info, err := os.Stat(path) + if err == nil && !info.IsDir() { + links = append(links, protocol.DocumentLink{ + Range: createRange(t, len(config.Source)), + Target: types.CreateStringPointer(uri), + Tooltip: types.CreateStringPointer(path), + }) + } + } + } + } + return links + } + } + return nil +} + +func createLocalFileLink(folderAbsolutePath, fsPath string, wslDollarSign bool) (uri, path string) { + if filepath.IsAbs(fsPath) { + return fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(fsPath), "/")), fsPath + } + return types.Concatenate(folderAbsolutePath, fsPath, wslDollarSign) +} + func createObjectFileLink(folderAbsolutePath string, wslDollarSign bool, serviceNode *ast.MappingValueNode) *protocol.DocumentLink { if resolveAnchor(serviceNode.Key).GetToken().Value == "file" { return createFileLink(folderAbsolutePath, wslDollarSign, serviceNode) @@ -216,6 +254,9 @@ func scanForLinks(folderAbsolutePath string, wslDollarSign bool, n *ast.MappingV labelFileLinks := createFileLinks(folderAbsolutePath, wslDollarSign, serviceAttribute, "label_file") links = append(links, labelFileLinks...) + + volumeFileLinks := createVolumeFileLinks(folderAbsolutePath, wslDollarSign, serviceAttribute) + links = append(links, volumeFileLinks...) } } } diff --git a/internal/compose/documentLink_test.go b/internal/compose/documentLink_test.go index 9930b12..1a35661 100644 --- a/internal/compose/documentLink_test.go +++ b/internal/compose/documentLink_test.go @@ -2035,6 +2035,118 @@ services: } } +func TestDocumentLink_VolumeFileLinks(t *testing.T) { + tempDir := os.TempDir() + tempFile := filepath.Join(tempDir, "tempFile.txt") + f, err := os.Create(tempFile) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, f.Close()) + }) + testsFolder := createFileStructure(t) + 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: "mount local file", + content: ` +services: + test: + volumes: + - ./a.txt:/mount/a.txt`, + path: filepath.Join(testsFolder, "./a.txt"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 8}, + End: protocol.Position{Line: 4, Character: 15}, + }, + }, + { + name: "mount local file (string is anchored)", + content: ` +services: + test: + volumes: + - &anchor ./a.txt:/mount/a.txt`, + path: filepath.Join(testsFolder, "./a.txt"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 16}, + End: protocol.Position{Line: 4, Character: 23}, + }, + }, + { + name: "mount local file (volumes attribute name is anchored)", + content: ` +services: + test: + &anchor volumes: + - ./a.txt:/mount/a.txt`, + path: filepath.Join(testsFolder, "./a.txt"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 8}, + End: protocol.Position{Line: 4, Character: 15}, + }, + }, + { + name: "mount local file (volumes attribute value is anchored)", + content: ` +services: + test: + volumes: &anchor + - ./a.txt:/mount/a.txt`, + path: filepath.Join(testsFolder, "./a.txt"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 8}, + End: protocol.Position{Line: 4, Character: 15}, + }, + }, + { + name: "mount local folder", + content: ` +services: + test: + volumes: + - ./folder:/mount/folder`, + }, + { + name: "absolute path", + content: fmt.Sprintf(` +services: + test: + volumes: + - %v:/mount/file.txt`, tempFile), + path: tempFile, + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 8}, + End: protocol.Position{Line: 4, Character: protocol.UInteger(8 + len(tempFile))}, + }, + }, + } + + 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_ConfigFileLinks(t *testing.T) { testsFolder := filepath.Join(os.TempDir(), t.Name()) composeStringURI := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(testsFolder, "compose.yaml")), "/"))