From 864f10265f009241fe241592d81b5771238e8a2e Mon Sep 17 00:00:00 2001 From: Remy Suen Date: Thu, 10 Jul 2025 09:32:29 -0400 Subject: [PATCH] Resolve anchors for all supported document links Signed-off-by: Remy Suen --- CHANGELOG.md | 6 + internal/compose/documentLink.go | 26 +- internal/compose/documentLink_test.go | 358 ++++++++++++++++++++++++++ 3 files changed, 377 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da8f8f..d5060f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to the Docker Language Server will be documented in this fil ## [Unreleased] +### Added + +- Compose + - textDocument/documentLink + - add anchor resolution for all supported document links ([#348](https://github.com/docker/docker-language-server/issues/348)) + ### Fixed - Compose diff --git a/internal/compose/documentLink.go b/internal/compose/documentLink.go index 48a41dc..a1bda52 100644 --- a/internal/compose/documentLink.go +++ b/internal/compose/documentLink.go @@ -61,10 +61,10 @@ func stringNode(value ast.Node) *ast.StringNode { } func createdNestedLink(u *url.URL, serviceNode *ast.MappingValueNode, parent, child string) *protocol.DocumentLink { - if serviceNode.Key.GetToken().Value == parent { + if resolveAnchor(serviceNode.Key).GetToken().Value == parent { if mappingNode, ok := resolveAnchor(serviceNode.Value).(*ast.MappingNode); ok { for _, buildAttribute := range mappingNode.Values { - if buildAttribute.Key.GetToken().Value == child { + if resolveAnchor(buildAttribute.Key).GetToken().Value == child { return createFileLink(u, buildAttribute) } } @@ -74,7 +74,7 @@ func createdNestedLink(u *url.URL, serviceNode *ast.MappingValueNode, parent, ch } func createImageLink(serviceNode *ast.MappingValueNode) *protocol.DocumentLink { - if serviceNode.Key.GetToken().Value == "image" { + if resolveAnchor(serviceNode.Key).GetToken().Value == "image" { service := stringNode(serviceNode.Value) if service != nil { linkedText, link := extractImageLink(service.Value) @@ -91,7 +91,7 @@ func createImageLink(serviceNode *ast.MappingValueNode) *protocol.DocumentLink { } func createObjectFileLink(u *url.URL, serviceNode *ast.MappingValueNode) *protocol.DocumentLink { - if serviceNode.Key.GetToken().Value == "file" { + if resolveAnchor(serviceNode.Key).GetToken().Value == "file" { return createFileLink(u, serviceNode) } return nil @@ -100,23 +100,23 @@ func createObjectFileLink(u *url.URL, serviceNode *ast.MappingValueNode) *protoc func includedFiles(nodes []ast.Node) []*token.Token { tokens := []*token.Token{} for _, entry := range nodes { - if mappingNode, ok := entry.(*ast.MappingNode); ok { + if mappingNode, ok := resolveAnchor(entry).(*ast.MappingNode); ok { for _, value := range mappingNode.Values { - if value.Key.GetToken().Value == "path" { - if paths, ok := value.Value.(*ast.SequenceNode); ok { + if resolveAnchor(value.Key).GetToken().Value == "path" { + if paths, ok := resolveAnchor(value.Value).(*ast.SequenceNode); ok { // include: // - path: // - ../commons/compose.yaml // - ./commons-override.yaml for _, path := range paths.Values { - tokens = append(tokens, path.GetToken()) + tokens = append(tokens, resolveAnchor(path).GetToken()) } } else { // include: // - path: ../commons/compose.yaml // project_directory: .. // env_file: ../another/.env - tokens = append(tokens, value.Value.GetToken()) + tokens = append(tokens, resolveAnchor(value.Value).GetToken()) } } } @@ -139,7 +139,7 @@ func scanForLinks(u *url.URL, n *ast.MappingValueNode) []protocol.DocumentLink { links := []protocol.DocumentLink{} switch s.Value { case "include": - if sequence, ok := n.Value.(*ast.SequenceNode); ok { + if sequence, ok := resolveAnchor(n.Value).(*ast.SequenceNode); ok { for _, token := range includedFiles(sequence.Values) { link := createLink(u, token) if link != nil { @@ -148,7 +148,7 @@ func scanForLinks(u *url.URL, n *ast.MappingValueNode) []protocol.DocumentLink { } } case "services": - if mappingNode, ok := n.Value.(*ast.MappingNode); ok { + if mappingNode, ok := resolveAnchor(n.Value).(*ast.MappingNode); ok { for _, node := range mappingNode.Values { if serviceAttributes, ok := resolveAnchor(node.Value).(*ast.MappingNode); ok { for _, serviceAttribute := range serviceAttributes.Values { @@ -171,7 +171,7 @@ func scanForLinks(u *url.URL, n *ast.MappingValueNode) []protocol.DocumentLink { } } case "configs": - if mappingNode, ok := n.Value.(*ast.MappingNode); ok { + if mappingNode, ok := resolveAnchor(n.Value).(*ast.MappingNode); ok { for _, node := range mappingNode.Values { if configAttributes, ok := resolveAnchor(node.Value).(*ast.MappingNode); ok { for _, configAttribute := range configAttributes.Values { @@ -184,7 +184,7 @@ func scanForLinks(u *url.URL, n *ast.MappingValueNode) []protocol.DocumentLink { } } case "secrets": - if mappingNode, ok := n.Value.(*ast.MappingNode); ok { + if mappingNode, ok := resolveAnchor(n.Value).(*ast.MappingNode); ok { for _, node := range mappingNode.Values { if configAttributes, ok := resolveAnchor(node.Value).(*ast.MappingNode); ok { for _, configAttribute := range configAttributes.Values { diff --git a/internal/compose/documentLink_test.go b/internal/compose/documentLink_test.go index edea6d4..bd891eb 100644 --- a/internal/compose/documentLink_test.go +++ b/internal/compose/documentLink_test.go @@ -98,6 +98,41 @@ func TestDocumentLink_IncludedFiles(t *testing.T) { name: "included files, mixed paths", content: ` include: + - file.yml + - path: file2.yml + - path: + - file3.yml`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 2, Character: 4}, + End: protocol.Position{Line: 2, Character: 12}, + }, + Target: documentLinkTarget(testsFolder, "file.yml"), + Tooltip: documentLinkTooltip(testsFolder, "file.yml"), + }, + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 10}, + End: protocol.Position{Line: 3, Character: 19}, + }, + Target: documentLinkTarget(testsFolder, "file2.yml"), + Tooltip: documentLinkTooltip(testsFolder, "file2.yml"), + }, + { + Range: protocol.Range{ + Start: protocol.Position{Line: 5, Character: 6}, + End: protocol.Position{Line: 5, Character: 15}, + }, + Target: documentLinkTarget(testsFolder, "file3.yml"), + Tooltip: documentLinkTooltip(testsFolder, "file3.yml"), + }, + }, + }, + { + name: "anchor on the include object's attribute", + content: ` +include: &anchor - file.yml - path: file2.yml - path: @@ -179,6 +214,105 @@ include: }, }, }, + { + name: "anchor on the path's string attribute", + content: ` +include: + - path: &anchor file2.yml`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 2, Character: 18}, + End: protocol.Position{Line: 2, Character: 27}, + }, + Target: documentLinkTarget(testsFolder, "file2.yml"), + Tooltip: documentLinkTooltip(testsFolder, "file2.yml"), + }, + }, + }, + { + name: "anchor on the path's string attribute name", + content: ` +include: + - &anchor path: file2.yml`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 2, Character: 18}, + End: protocol.Position{Line: 2, Character: 27}, + }, + Target: documentLinkTarget(testsFolder, "file2.yml"), + Tooltip: documentLinkTooltip(testsFolder, "file2.yml"), + }, + }, + }, + { + name: "anchor on the path object's value", + content: ` +include: + - path: &anchor + - file2.yml`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 6}, + End: protocol.Position{Line: 3, Character: 15}, + }, + Target: documentLinkTarget(testsFolder, "file2.yml"), + Tooltip: documentLinkTooltip(testsFolder, "file2.yml"), + }, + }, + }, + { + name: "anchor on the path array item's string value", + content: ` +include: + - path: + - &anchor file2.yml`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 14}, + End: protocol.Position{Line: 3, Character: 23}, + }, + Target: documentLinkTarget(testsFolder, "file2.yml"), + Tooltip: documentLinkTooltip(testsFolder, "file2.yml"), + }, + }, + }, + { + name: "anchor on the include array item itself", + content: ` +include: + - &anchor { path: file2.yml }`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 2, Character: 20}, + End: protocol.Position{Line: 2, Character: 29}, + }, + Target: documentLinkTarget(testsFolder, "file2.yml"), + Tooltip: documentLinkTooltip(testsFolder, "file2.yml"), + }, + }, + }, + { + name: "anchor on the path object", + content: ` +include: + - &anchor path: + - file2.yml`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 6}, + End: protocol.Position{Line: 3, Character: 15}, + }, + Target: documentLinkTarget(testsFolder, "file2.yml"), + Tooltip: documentLinkTooltip(testsFolder, "file2.yml"), + }, + }, + }, } for _, tc := range testCases { @@ -544,6 +678,56 @@ services: }, }, }, + { + name: "anchor on the services object", + content: ` +services: &anchor + test: + image: alpine`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 11}, + End: protocol.Position{Line: 3, Character: 17}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/_/alpine"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/_/alpine"), + }, + }, + }, + { + name: "anchor on the service object itself", + content: ` +services: + test: &anchor { image: alpine }`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 2, Character: 25}, + End: protocol.Position{Line: 2, Character: 31}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/_/alpine"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/_/alpine"), + }, + }, + }, + { + name: "anchor on the image attribute", + content: ` +services: + test: + &anchor image: alpine`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 19}, + End: protocol.Position{Line: 3, Character: 25}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/_/alpine"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/_/alpine"), + }, + }, + }, { name: "anchor on the service object", content: ` @@ -675,6 +859,56 @@ services: End: protocol.Position{Line: 4, Character: 37}, }, }, + { + name: "anchor on the services object", + content: ` +services: &anchor + test: + build: + dockerfile: ./Dockerfile2`, + path: filepath.Join(testsFolder, "Dockerfile2"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 18}, + End: protocol.Position{Line: 4, Character: 31}, + }, + }, + { + name: "anchor on the service JSON object", + content: ` +services: + test: &anchor { build: { dockerfile: ./Dockerfile2 } }`, + path: filepath.Join(testsFolder, "Dockerfile2"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 2, Character: 39}, + End: protocol.Position{Line: 2, Character: 52}, + }, + }, + { + name: "anchor on the service object", + content: ` +services: + test: &anchor + build: + dockerfile: ./Dockerfile2`, + path: filepath.Join(testsFolder, "Dockerfile2"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 18}, + End: protocol.Position{Line: 4, Character: 31}, + }, + }, + { + name: "anchor on the build attribute inside a JSON object", + content: ` +services: + backend: { + &anchor build: { dockerfile: Dockerfile2 } + }`, + path: filepath.Join(testsFolder, "Dockerfile2"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 35}, + End: protocol.Position{Line: 3, Character: 46}, + }, + }, { name: "anchor on the build object", content: ` @@ -688,6 +922,19 @@ services: End: protocol.Position{Line: 4, Character: 31}, }, }, + { + name: "anchor on the dockerfile attribute", + content: ` +services: + test: + build: + &anchor dockerfile: ./Dockerfile2`, + path: filepath.Join(testsFolder, "Dockerfile2"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 26}, + End: protocol.Position{Line: 4, Character: 39}, + }, + }, } for _, tc := range testCases { @@ -773,6 +1020,56 @@ services: End: protocol.Position{Line: 4, Character: 54}, }, }, + { + name: "anchor on the services object", + content: ` +services: &anchor + test: + credential_spec: + file: ./credential-spec.json`, + path: filepath.Join(testsFolder, "credential-spec.json"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 12}, + End: protocol.Position{Line: 4, Character: 34}, + }, + }, + { + name: "anchor on the service JSON object", + content: ` +services: + test: &anchor { credential_spec: { file: ./credential-spec.json } }`, + path: filepath.Join(testsFolder, "credential-spec.json"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 2, Character: 43}, + End: protocol.Position{Line: 2, Character: 65}, + }, + }, + { + name: "anchor on the service object", + content: ` +services: + test: &anchor + credential_spec: + file: ./credential-spec.json`, + path: filepath.Join(testsFolder, "credential-spec.json"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 12}, + End: protocol.Position{Line: 4, Character: 34}, + }, + }, + { + name: "anchor on the credential_spec attribute inside a JSON object", + content: ` +services: + backend: { + &anchor credential_spec: { file: ./credential-spec.json } + }`, + path: filepath.Join(testsFolder, "credential-spec.json"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 39}, + End: protocol.Position{Line: 3, Character: 61}, + }, + }, { name: "anchor on the credential_spec object", content: ` @@ -786,6 +1083,19 @@ services: End: protocol.Position{Line: 4, Character: 34}, }, }, + { + name: "anchor on the file attribute", + content: ` +services: + test: + credential_spec: + &anchor file: ./credential-spec.json`, + path: filepath.Join(testsFolder, "credential-spec.json"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 20}, + End: protocol.Position{Line: 4, Character: 42}, + }, + }, } for _, tc := range testCases { @@ -864,6 +1174,18 @@ configs: End: protocol.Position{Line: 3, Character: 34}, }, }, + { + name: "anchor on the configs object", + content: ` +configs: &anchor + test: + file: ./httpd.conf`, + path: filepath.Join(testsFolder, "httpd.conf"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 10}, + End: protocol.Position{Line: 3, Character: 22}, + }, + }, { name: "anchor on the config object", content: ` @@ -876,6 +1198,18 @@ configs: End: protocol.Position{Line: 3, Character: 22}, }, }, + { + name: "anchor on the file attribute", + content: ` +configs: + test: + &anchor file: ./httpd.conf`, + path: filepath.Join(testsFolder, "httpd.conf"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 18}, + End: protocol.Position{Line: 3, Character: 30}, + }, + }, } for _, tc := range testCases { @@ -955,6 +1289,18 @@ secrets: End: protocol.Position{Line: 3, Character: 35}, }, }, + { + name: "anchor on the secrets object", + content: ` +secrets: &anchor + test: + file: ./server.cert`, + path: filepath.Join(testsFolder, "server.cert"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 10}, + End: protocol.Position{Line: 3, Character: 23}, + }, + }, { name: "anchor on the secret object", content: ` @@ -967,6 +1313,18 @@ secrets: End: protocol.Position{Line: 3, Character: 23}, }, }, + { + name: "anchor on the file attribute", + content: ` +secrets: + test: + &anchor file: ./server.cert`, + path: filepath.Join(testsFolder, "server.cert"), + linkRange: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 18}, + End: protocol.Position{Line: 3, Character: 31}, + }, + }, } for _, tc := range testCases {