Repository: docker/docker-language-server (language: go)

Issue:
Support `textDocument/documentLink` requests to jump to container registries in Compose files
We should support opening the websites for the following images.
```YAML
services:
  test:
    image: alpine
  test2:
    image: mcr.microsoft.com/powershell
  test3:
    image: ghcr.io/super-linter/super-linter
  test4:
    image: quay.io/prometheus/node-exporter
```


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0701ce4..e82ca80 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ All notable changes to the Docker Language Server will be documented in this fil
     - suggests completion items for the attributes of an object inside an array ([#95](https://github.com/docker/docker-language-server/issues/95))
   - textDocument/definition
     - support lookup of `configs`, `networks`, and `secrets` referenced inside `services` object ([#91](https://github.com/docker/docker-language-server/issues/91))
+  - textDocument/documentLink
+    - support opening a referenced image's page as a link ([#91](https://github.com/docker/docker-language-server/issues/91))
 
 ## [0.3.8] - 2025-04-24
 
diff --git a/internal/compose/documentLink.go b/internal/compose/documentLink.go
index e053fb9..cc5178e 100644
--- a/internal/compose/documentLink.go
+++ b/internal/compose/documentLink.go
@@ -12,14 +12,14 @@ import (
 	"github.com/docker/docker-language-server/internal/types"
 )
 
-func DocumentLink(ctx context.Context, documentURI protocol.URI, document document.ComposeDocument) ([]protocol.DocumentLink, error) {
+func DocumentLink(ctx context.Context, documentURI protocol.URI, doc document.ComposeDocument) ([]protocol.DocumentLink, error) {
 	url, err := url.Parse(string(documentURI))
 	if err != nil {
 		return nil, fmt.Errorf("LSP client sent invalid URI: %v", string(documentURI))
 	}
 
 	links := []protocol.DocumentLink{}
-	results, _ := DocumentSymbol(ctx, document)
+	results, _ := DocumentSymbol(ctx, doc)
 	for _, result := range results {
 		if symbol, ok := result.(*protocol.DocumentSymbol); ok && symbol.Kind == protocol.SymbolKindModule {
 			abs, err := types.AbsolutePath(url, symbol.Name)
@@ -32,5 +32,78 @@ func DocumentLink(ctx context.Context, documentURI protocol.URI, document docume
 			}
 		}
 	}
+
+	root := doc.RootNode()
+	if len(root.Content) > 0 {
+		for i := range root.Content[0].Content {
+			switch root.Content[0].Content[i].Value {
+			case "services":
+				for j := 0; j < len(root.Content[0].Content[i+1].Content); j += 2 {
+					serviceProperties := root.Content[0].Content[i+1].Content[j+1].Content
+					for k := 0; k < len(serviceProperties); k += 2 {
+						if serviceProperties[k].Value == "image" {
+							imageNode := serviceProperties[k+1]
+							linkedText, link := extractImageLink(imageNode.Value)
+							links = append(links, protocol.DocumentLink{
+								Range: protocol.Range{
+									Start: protocol.Position{
+										Line:      protocol.UInteger(imageNode.Line) - 1,
+										Character: protocol.UInteger(imageNode.Column) - 1,
+									},
+									End: protocol.Position{
+										Line:      protocol.UInteger(imageNode.Line) - 1,
+										Character: protocol.UInteger(imageNode.Column - 1 + len(linkedText)),
+									},
+								},
+								Target:  types.CreateStringPointer(link),
+								Tooltip: types.CreateStringPointer(link),
+							})
+						}
+					}
+				}
+			}
+		}
+	}
 	return links, nil
 }
+
+func extractImageLink(nodeValue string) (string, string) {
+	if strings.HasPrefix(nodeValue, "ghcr.io/") {
+		idx := strings.LastIndex(nodeValue, ":")
+		if idx == -1 {
+			return nodeValue, fmt.Sprintf("https://%v", nodeValue)
+		}
+		return nodeValue[0:idx], fmt.Sprintf("https://%v", nodeValue[0:idx])
+	}
+
+	if strings.HasPrefix(nodeValue, "mcr.microsoft.com") {
+		idx := strings.LastIndex(nodeValue, ":")
+		if idx == -1 {
+			return nodeValue, fmt.Sprintf("https://mcr.microsoft.com/artifact/mar/%v", nodeValue[18:])
+		}
+		return nodeValue[0:idx], fmt.Sprintf("https://mcr.microsoft.com/artifact/mar/%v", nodeValue[18:idx])
+	}
+
+	if strings.HasPrefix(nodeValue, "quay.io/") {
+		idx := strings.LastIndex(nodeValue, ":")
+		if idx == -1 {
+			return nodeValue, fmt.Sprintf("https://quay.io/repository/%v", nodeValue[8:])
+		}
+		return nodeValue[0:idx], fmt.Sprintf("https://quay.io/repository/%v", nodeValue[8:idx])
+	}
+
+	idx := strings.LastIndex(nodeValue, ":")
+	if idx == -1 {
+		idx := strings.Index(nodeValue, "/")
+		if idx == -1 {
+			return nodeValue, fmt.Sprintf("https://hub.docker.com/_/%v", nodeValue)
+		}
+		return nodeValue, fmt.Sprintf("https://hub.docker.com/r/%v", nodeValue)
+	}
+
+	slashIndex := strings.Index(nodeValue, "/")
+	if slashIndex == -1 {
+		return nodeValue[0:idx], fmt.Sprintf("https://hub.docker.com/_/%v", nodeValue[0:idx])
+	}
+	return nodeValue[0:idx], fmt.Sprintf("https://hub.docker.com/r/%v", nodeValue[0:idx])
+}


Test patch:
diff --git a/internal/compose/documentLink_test.go b/internal/compose/documentLink_test.go
index 4992e85..6457fbe 100644
--- a/internal/compose/documentLink_test.go
+++ b/internal/compose/documentLink_test.go
@@ -14,7 +14,7 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestDocumentLink(t *testing.T) {
+func TestDocumentLink_IncludedFiles(t *testing.T) {
 	testsFolder := filepath.Join(os.TempDir(), "composeDocumentLinkTests")
 	composeFilePath := filepath.Join(testsFolder, "docker-compose.yml")
 	referencedFilePath := filepath.Join(testsFolder, "file.yml")
@@ -52,3 +52,192 @@ func TestDocumentLink(t *testing.T) {
 		})
 	}
 }
+
+func TestDocumentLink_ImageLinks(t *testing.T) {
+	testCases := []struct {
+		name    string
+		content string
+		links   []protocol.DocumentLink
+	}{
+		{
+			name: "image: alpine",
+			content: `
+services:
+  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: "image: alpine:1.23",
+			content: `
+services:
+  test:
+    image: alpine:1.23`,
+			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: "image: grafana/grafana",
+			content: `
+services:
+  test:
+    image: grafana/grafana`,
+			links: []protocol.DocumentLink{
+				{
+					Range: protocol.Range{
+						Start: protocol.Position{Line: 3, Character: 11},
+						End:   protocol.Position{Line: 3, Character: 26},
+					},
+					Target:  types.CreateStringPointer("https://hub.docker.com/r/grafana/grafana"),
+					Tooltip: types.CreateStringPointer("https://hub.docker.com/r/grafana/grafana"),
+				},
+			},
+		},
+		{
+			name: "image: grafana/grafana:1.23",
+			content: `
+services:
+  test:
+    image: grafana/grafana:1.23`,
+			links: []protocol.DocumentLink{
+				{
+					Range: protocol.Range{
+						Start: protocol.Position{Line: 3, Character: 11},
+						End:   protocol.Position{Line: 3, Character: 26},
+					},
+					Target:  types.CreateStringPointer("https://hub.docker.com/r/grafana/grafana"),
+					Tooltip: types.CreateStringPointer("https://hub.docker.com/r/grafana/grafana"),
+				},
+			},
+		},
+		{
+			name: "image: ghcr.io/super-linter/super-linter",
+			content: `
+services:
+  test:
+    image: ghcr.io/super-linter/super-linter`,
+			links: []protocol.DocumentLink{
+				{
+					Range: protocol.Range{
+						Start: protocol.Position{Line: 3, Character: 11},
+						End:   protocol.Position{Line: 3, Character: 44},
+					},
+					Target:  types.CreateStringPointer("https://ghcr.io/super-linter/super-linter"),
+					Tooltip: types.CreateStringPointer("https://ghcr.io/super-linter/super-linter"),
+				},
+			},
+		},
+		{
+			name: "image: ghcr.io/super-linter/super-linter:1.23",
+			content: `
+services:
+  test:
+    image: ghcr.io/super-linter/super-linter:1.23`,
+			links: []protocol.DocumentLink{
+				{
+					Range: protocol.Range{
+						Start: protocol.Position{Line: 3, Character: 11},
+						End:   protocol.Position{Line: 3, Character: 44},
+					},
+					Target:  types.CreateStringPointer("https://ghcr.io/super-linter/super-linter"),
+					Tooltip: types.CreateStringPointer("https://ghcr.io/super-linter/super-linter"),
+				},
+			},
+		},
+		{
+			name: "image: mcr.microsoft.com/powershell",
+			content: `
+services:
+  test:
+    image: mcr.microsoft.com/powershell`,
+			links: []protocol.DocumentLink{
+				{
+					Range: protocol.Range{
+						Start: protocol.Position{Line: 3, Character: 11},
+						End:   protocol.Position{Line: 3, Character: 39},
+					},
+					Target:  types.CreateStringPointer("https://mcr.microsoft.com/artifact/mar/powershell"),
+					Tooltip: types.CreateStringPointer("https://mcr.microsoft.com/artifact/mar/powershell"),
+				},
+			},
+		},
+		{
+			name: "image: mcr.microsoft.com/powershell:1.23",
+			content: `
+services:
+  test:
+    image: mcr.microsoft.com/powershell:1.23`,
+			links: []protocol.DocumentLink{
+				{
+					Range: protocol.Range{
+						Start: protocol.Position{Line: 3, Character: 11},
+						End:   protocol.Position{Line: 3, Character: 39},
+					},
+					Target:  types.CreateStringPointer("https://mcr.microsoft.com/artifact/mar/powershell"),
+					Tooltip: types.CreateStringPointer("https://mcr.microsoft.com/artifact/mar/powershell"),
+				},
+			},
+		},
+		{
+			name: "image: mcr.microsoft.com/windows/servercore",
+			content: `
+services:
+  test:
+    image: mcr.microsoft.com/windows/servercore`,
+			links: []protocol.DocumentLink{
+				{
+					Range: protocol.Range{
+						Start: protocol.Position{Line: 3, Character: 11},
+						End:   protocol.Position{Line: 3, Character: 47},
+					},
+					Target:  types.CreateStringPointer("https://mcr.microsoft.com/artifact/mar/windows/servercore"),
+					Tooltip: types.CreateStringPointer("https://mcr.microsoft.com/artifact/mar/windows/servercore"),
+				},
+			},
+		},
+		{
+			name: "image: mcr.microsoft.com/windows/servercore:1.23",
+			content: `
+services:
+  test:
+    image: mcr.microsoft.com/windows/servercore:1.23`,
+			links: []protocol.DocumentLink{
+				{
+					Range: protocol.Range{
+						Start: protocol.Position{Line: 3, Character: 11},
+						End:   protocol.Position{Line: 3, Character: 47},
+					},
+					Target:  types.CreateStringPointer("https://mcr.microsoft.com/artifact/mar/windows/servercore"),
+					Tooltip: types.CreateStringPointer("https://mcr.microsoft.com/artifact/mar/windows/servercore"),
+				},
+			},
+		},
+	}
+
+	composeStringURI := 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) {
+			doc := document.NewComposeDocument("docker-compose.yml", 1, []byte(tc.content))
+			links, err := DocumentLink(context.Background(), composeStringURI, doc)
+			require.NoError(t, err)
+			require.Equal(t, tc.links, links)
+		})
+	}
+}