From d8646bf977fb3a792f9e1fe7dabbfa40448a8089 Mon Sep 17 00:00:00 2001 From: Remy Suen Date: Mon, 14 Jul 2025 11:57:32 -0400 Subject: [PATCH] Provide document links for referenced models Added support for opening a model's page on Docker Hub or Hugging Face. Signed-off-by: Remy Suen --- CHANGELOG.md | 1 + internal/compose/documentLink.go | 58 +++++ internal/compose/documentLink_test.go | 320 ++++++++++++++++++++++++++ 3 files changed, 379 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2548c32..bd0db77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to the Docker Language Server will be documented in this fil - textDocument/documentLink - add anchor resolution for all supported document links ([#348](https://github.com/docker/docker-language-server/issues/348)) - return document links for the `file` attribute of a service object's `extends` attribute object ([#172](https://github.com/docker/docker-language-server/issues/172)) + - provide document links for models on Docker Hub and Hugging Face ([#356](https://github.com/docker/docker-language-server/issues/356)) - textDocument/hover - support hovering over referenced models ([#343](https://github.com/docker/docker-language-server/issues/343)) diff --git a/internal/compose/documentLink.go b/internal/compose/documentLink.go index f1427fc..f7b1bbd 100644 --- a/internal/compose/documentLink.go +++ b/internal/compose/documentLink.go @@ -97,6 +97,23 @@ func createObjectFileLink(u *url.URL, serviceNode *ast.MappingValueNode) *protoc return nil } +func createModelLink(serviceNode *ast.MappingValueNode) *protocol.DocumentLink { + if resolveAnchor(serviceNode.Key).GetToken().Value == "model" { + service := stringNode(serviceNode.Value) + if service != nil { + linkedText, link := extractModelLink(service.Value) + if linkedText != "" { + return &protocol.DocumentLink{ + Range: createRange(service.GetToken(), len(linkedText)), + Target: types.CreateStringPointer(link), + Tooltip: types.CreateStringPointer(link), + } + } + } + } + return nil +} + func includedFiles(nodes []ast.Node) []*token.Token { tokens := []*token.Token{} for _, entry := range nodes { @@ -201,6 +218,19 @@ func scanForLinks(u *url.URL, n *ast.MappingValueNode) []protocol.DocumentLink { } } } + case "models": + 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 { + link := createModelLink(serviceAttribute) + if link != nil { + links = append(links, *link) + } + } + } + } + } } return links } @@ -278,3 +308,31 @@ func extractImageLink(nodeValue string) (string, string) { } return nodeValue[0:idx], fmt.Sprintf("https://hub.docker.com/r/%v", nodeValue[0:idx]) } + +func extractModelLink(nodeValue string) (string, string) { + if strings.HasPrefix(nodeValue, "hf.co") { + if len(nodeValue) <= 6 { + return "", "" + } + 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]) + } + + 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]) +} diff --git a/internal/compose/documentLink_test.go b/internal/compose/documentLink_test.go index ad62b96..d09eeae 100644 --- a/internal/compose/documentLink_test.go +++ b/internal/compose/documentLink_test.go @@ -1520,3 +1520,323 @@ secrets: }) } } + +func TestDocumentLink_ModelsModelLinks(t *testing.T) { + testCases := []struct { + name string + content string + links []protocol.DocumentLink + }{ + { + name: "ai/llama3.3", + content: ` +models: + modelA: + model: ai/llama3.3`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 11}, + End: protocol.Position{Line: 3, Character: 22}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/r/ai/llama3.3"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/r/ai/llama3.3"), + }, + }, + }, + { + name: "ai/llama3.3:latest", + content: ` +models: + modelA: + model: ai/llama3.3:latest`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 11}, + End: protocol.Position{Line: 3, Character: 22}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/r/ai/llama3.3"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/r/ai/llama3.3"), + }, + }, + }, + { + name: "\"ai/llama3.3\"", + content: ` +models: + modelA: + model: "ai/llama3.3"`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 12}, + End: protocol.Position{Line: 3, Character: 23}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/r/ai/llama3.3"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/r/ai/llama3.3"), + }, + }, + }, + { + name: "\"ai/llama3.3:\"", + content: ` +models: + modelA: + model: "ai/llama3.3:"`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 12}, + End: protocol.Position{Line: 3, Character: 23}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/r/ai/llama3.3"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/r/ai/llama3.3"), + }, + }, + }, + { + name: "hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF", + content: ` +models: + modelA: + model: hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 11}, + End: protocol.Position{Line: 3, Character: 53}, + }, + Target: types.CreateStringPointer("https://hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF"), + Tooltip: types.CreateStringPointer("https://hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF"), + }, + }, + }, + { + name: "hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF:latest", + content: ` +models: + modelA: + model: hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF:latest`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 11}, + End: protocol.Position{Line: 3, Character: 53}, + }, + Target: types.CreateStringPointer("https://hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF"), + Tooltip: types.CreateStringPointer("https://hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF"), + }, + }, + }, + { + name: "\"hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF\"", + content: ` +models: + modelA: + model: "hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF"`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 12}, + End: protocol.Position{Line: 3, Character: 54}, + }, + Target: types.CreateStringPointer("https://hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF"), + Tooltip: types.CreateStringPointer("https://hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF"), + }, + }, + }, + { + name: "\"hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF:\"", + content: ` +models: + modelA: + model: "hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF:"`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 12}, + End: protocol.Position{Line: 3, Character: 54}, + }, + Target: types.CreateStringPointer("https://hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF"), + Tooltip: types.CreateStringPointer("https://hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF"), + }, + }, + }, + { + name: "hf.co", + content: ` +models: + modelA: + model: hf.co`, + links: []protocol.DocumentLink{}, + }, + { + name: "\"hf.co:\"", + content: ` +models: + modelA: + model: "hf.co:"`, + links: []protocol.DocumentLink{}, + }, + { + name: "anchors and aliases to nothing", + content: ` +models: + model1: + model: &aiModelHello + model2: + model: *aiModelHello`, + links: []protocol.DocumentLink{}, + }, + { + name: "anchor on the models object's value", + content: ` +models: &anchor + model1: + model: ai/qwen3`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 11}, + End: protocol.Position{Line: 3, Character: 19}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/r/ai/qwen3"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/r/ai/qwen3"), + }, + }, + }, + { + name: "anchor on the model object itself", + content: ` +models: + &anchor model1: + model: ai/qwen3`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 11}, + End: protocol.Position{Line: 3, Character: 19}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/r/ai/qwen3"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/r/ai/qwen3"), + }, + }, + }, + { + name: "anchor on the model object's value", + content: ` +models: + model1: &anchor + model: ai/qwen3`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 11}, + End: protocol.Position{Line: 3, Character: 19}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/r/ai/qwen3"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/r/ai/qwen3"), + }, + }, + }, + { + name: "anchor on the model object's value as JSON", + content: ` +models: + model1: &anchor { model: ai/qwen3 }`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 2, Character: 27}, + End: protocol.Position{Line: 2, Character: 35}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/r/ai/qwen3"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/r/ai/qwen3"), + }, + }, + }, + { + name: "anchor on the model attribute's value", + content: ` +models: + model1: + &anchor model: ai/qwen3`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 19}, + End: protocol.Position{Line: 3, Character: 27}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/r/ai/qwen3"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/r/ai/qwen3"), + }, + }, + }, + { + name: "anchor on the model attribute's value", + content: ` +models: + model1: + model: &anchor ai/qwen3`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 3, Character: 19}, + End: protocol.Position{Line: 3, Character: 27}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/r/ai/qwen3"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/r/ai/qwen3"), + }, + }, + }, + { + name: "invalid models", + content: ` +models: + - `, + links: []protocol.DocumentLink{}, + }, + { + name: "two documents", + content: ` +--- +models: + model1: + model: ai/smollm2 +--- +models: + model2: + model: ai/smollm3`, + links: []protocol.DocumentLink{ + { + Range: protocol.Range{ + Start: protocol.Position{Line: 4, Character: 11}, + End: protocol.Position{Line: 4, Character: 21}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/r/ai/smollm2"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/r/ai/smollm2"), + }, + { + Range: protocol.Range{ + Start: protocol.Position{Line: 8, Character: 11}, + End: protocol.Position{Line: 8, Character: 21}, + }, + Target: types.CreateStringPointer("https://hub.docker.com/r/ai/smollm3"), + Tooltip: types.CreateStringPointer("https://hub.docker.com/r/ai/smollm3"), + }, + }, + }, + } + + 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) { + mgr := document.NewDocumentManager() + doc := document.NewComposeDocument(mgr, "docker-compose.yml", 1, []byte(tc.content)) + links, err := DocumentLink(context.Background(), composeStringURI, doc) + require.NoError(t, err) + require.Equal(t, tc.links, links) + }) + } +}