Skip to content

Commit 6bef505

Browse files
committed
Show links for short-form volumes that reference local files
Signed-off-by: Remy Suen <remy.suen@docker.com>
1 parent 3ec833a commit 6bef505

File tree

3 files changed

+161
-0
lines changed

3 files changed

+161
-0
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to the Docker Language Server will be documented in this file.
44

5+
## [Unreleased]
6+
7+
### Fixed
8+
9+
- Compose
10+
- textDocument/documentLink
11+
- 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))
12+
513
## [0.18.0] - 2025-08-25
614

715
### Added

internal/compose/documentLink.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ package compose
33
import (
44
"context"
55
"fmt"
6+
"os"
7+
"path/filepath"
68
"strings"
79

10+
"github.com/compose-spec/compose-go/v2/format"
11+
composeTypes "github.com/compose-spec/compose-go/v2/types"
812
"github.com/docker/docker-language-server/internal/pkg/document"
913
"github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
1014
"github.com/docker/docker-language-server/internal/types"
@@ -104,6 +108,40 @@ func createFileLinks(folderAbsolutePath string, wslDollarSign bool, serviceNode
104108
return nil
105109
}
106110

111+
func createVolumeFileLinks(folderAbsolutePath string, wslDollarSign bool, serviceNode *ast.MappingValueNode) []protocol.DocumentLink {
112+
if resolveAnchor(serviceNode.Key).GetToken().Value == "volumes" {
113+
if sequence, ok := resolveAnchor(serviceNode.Value).(*ast.SequenceNode); ok {
114+
links := []protocol.DocumentLink{}
115+
for _, node := range sequence.Values {
116+
if s, ok := resolveAnchor(node).(*ast.StringNode); ok {
117+
config, err := format.ParseVolume(s.GetToken().Value)
118+
if err == nil && config.Type == composeTypes.VolumeTypeBind {
119+
uri, path := createLocalFileLink(folderAbsolutePath, config.Source, wslDollarSign)
120+
info, err := os.Stat(path)
121+
if err == nil && !info.IsDir() {
122+
t := volumeToken(s.GetToken())
123+
links = append(links, protocol.DocumentLink{
124+
Range: createRange(t, len(t.Value)),
125+
Target: types.CreateStringPointer(uri),
126+
Tooltip: types.CreateStringPointer(path),
127+
})
128+
}
129+
}
130+
}
131+
}
132+
return links
133+
}
134+
}
135+
return nil
136+
}
137+
138+
func createLocalFileLink(folderAbsolutePath, fsPath string, wslDollarSign bool) (uri, path string) {
139+
if filepath.IsAbs(fsPath) {
140+
return fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(fsPath), "/")), fsPath
141+
}
142+
return types.Concatenate(folderAbsolutePath, fsPath, wslDollarSign)
143+
}
144+
107145
func createObjectFileLink(folderAbsolutePath string, wslDollarSign bool, serviceNode *ast.MappingValueNode) *protocol.DocumentLink {
108146
if resolveAnchor(serviceNode.Key).GetToken().Value == "file" {
109147
return createFileLink(folderAbsolutePath, wslDollarSign, serviceNode)
@@ -216,6 +254,9 @@ func scanForLinks(folderAbsolutePath string, wslDollarSign bool, n *ast.MappingV
216254

217255
labelFileLinks := createFileLinks(folderAbsolutePath, wslDollarSign, serviceAttribute, "label_file")
218256
links = append(links, labelFileLinks...)
257+
258+
volumeFileLinks := createVolumeFileLinks(folderAbsolutePath, wslDollarSign, serviceAttribute)
259+
links = append(links, volumeFileLinks...)
219260
}
220261
}
221262
}

internal/compose/documentLink_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2035,6 +2035,118 @@ services:
20352035
}
20362036
}
20372037

2038+
func TestDocumentLink_VolumeFileLinks(t *testing.T) {
2039+
tempDir := os.TempDir()
2040+
tempFile := filepath.Join(tempDir, "tempFile.txt")
2041+
f, err := os.Create(tempFile)
2042+
require.NoError(t, err)
2043+
t.Cleanup(func() {
2044+
require.NoError(t, f.Close())
2045+
})
2046+
testsFolder := createFileStructure(t)
2047+
composeStringURI := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(testsFolder, "compose.yaml")), "/"))
2048+
2049+
testCases := []struct {
2050+
name string
2051+
content string
2052+
path string
2053+
linkRange protocol.Range
2054+
}{
2055+
{
2056+
name: "mount local file",
2057+
content: `
2058+
services:
2059+
test:
2060+
volumes:
2061+
- ./a.txt:/mount/a.txt`,
2062+
path: filepath.Join(testsFolder, "./a.txt"),
2063+
linkRange: protocol.Range{
2064+
Start: protocol.Position{Line: 4, Character: 8},
2065+
End: protocol.Position{Line: 4, Character: 15},
2066+
},
2067+
},
2068+
{
2069+
name: "mount local file (string is anchored)",
2070+
content: `
2071+
services:
2072+
test:
2073+
volumes:
2074+
- &anchor ./a.txt:/mount/a.txt`,
2075+
path: filepath.Join(testsFolder, "./a.txt"),
2076+
linkRange: protocol.Range{
2077+
Start: protocol.Position{Line: 4, Character: 16},
2078+
End: protocol.Position{Line: 4, Character: 23},
2079+
},
2080+
},
2081+
{
2082+
name: "mount local file (volumes attribute name is anchored)",
2083+
content: `
2084+
services:
2085+
test:
2086+
&anchor volumes:
2087+
- ./a.txt:/mount/a.txt`,
2088+
path: filepath.Join(testsFolder, "./a.txt"),
2089+
linkRange: protocol.Range{
2090+
Start: protocol.Position{Line: 4, Character: 8},
2091+
End: protocol.Position{Line: 4, Character: 15},
2092+
},
2093+
},
2094+
{
2095+
name: "mount local file (volumes attribute value is anchored)",
2096+
content: `
2097+
services:
2098+
test:
2099+
volumes: &anchor
2100+
- ./a.txt:/mount/a.txt`,
2101+
path: filepath.Join(testsFolder, "./a.txt"),
2102+
linkRange: protocol.Range{
2103+
Start: protocol.Position{Line: 4, Character: 8},
2104+
End: protocol.Position{Line: 4, Character: 15},
2105+
},
2106+
},
2107+
{
2108+
name: "mount local folder",
2109+
content: `
2110+
services:
2111+
test:
2112+
volumes:
2113+
- ./folder:/mount/folder`,
2114+
},
2115+
{
2116+
name: "absolute path",
2117+
content: fmt.Sprintf(`
2118+
services:
2119+
test:
2120+
volumes:
2121+
- %v:/mount/file.txt`, tempFile),
2122+
path: tempFile,
2123+
linkRange: protocol.Range{
2124+
Start: protocol.Position{Line: 4, Character: 8},
2125+
End: protocol.Position{Line: 4, Character: protocol.UInteger(8 + len(tempFile))},
2126+
},
2127+
},
2128+
}
2129+
2130+
for _, tc := range testCases {
2131+
t.Run(tc.name, func(t *testing.T) {
2132+
mgr := document.NewDocumentManager()
2133+
doc := document.NewComposeDocument(mgr, uri.URI(composeStringURI), 1, []byte(tc.content))
2134+
links, err := DocumentLink(context.Background(), composeStringURI, doc)
2135+
require.NoError(t, err)
2136+
if tc.path == "" {
2137+
require.Equal(t, []protocol.DocumentLink{}, links)
2138+
} else {
2139+
link := protocol.DocumentLink{
2140+
Range: tc.linkRange,
2141+
Target: types.CreateStringPointer(fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(tc.path), "/"))),
2142+
Tooltip: types.CreateStringPointer(filepath.FromSlash(tc.path)),
2143+
}
2144+
require.Equal(t, []protocol.DocumentLink{link}, links)
2145+
}
2146+
})
2147+
}
2148+
}
2149+
20382150
func TestDocumentLink_ConfigFileLinks(t *testing.T) {
20392151
testsFolder := filepath.Join(os.TempDir(), t.Name())
20402152
composeStringURI := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(testsFolder, "compose.yaml")), "/"))

0 commit comments

Comments
 (0)