diff --git a/CHANGELOG.md b/CHANGELOG.md index 4507b44..e285f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to the Docker Language Server will be documented in this fil ### Added +- Compose + - textDocument/completion + - suggest image tags for images from Docker Hub ([#375](https://github.com/docker/docker-language-server/issues/375)) - Bake - textDocument/completion - provide local file and folder name suggestions ([#414](https://github.com/docker/docker-language-server/issues/414)) diff --git a/internal/compose/completion.go b/internal/compose/completion.go index e020af9..fc1e6eb 100644 --- a/internal/compose/completion.go +++ b/internal/compose/completion.go @@ -8,10 +8,12 @@ import ( "strings" "unicode" + "github.com/docker/docker-language-server/internal/hub" "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" "github.com/goccy/go-yaml/ast" + "github.com/goccy/go-yaml/token" "github.com/santhosh-tekuri/jsonschema/v6" ) @@ -177,7 +179,7 @@ func calculateTopLevelNodeOffset(file *ast.File) int { return -1 } -func Completion(ctx context.Context, params *protocol.CompletionParams, manager *document.Manager, doc document.ComposeDocument) (*protocol.CompletionList, error) { +func Completion(ctx context.Context, params *protocol.CompletionParams, manager *document.Manager, hub *hub.Service, doc document.ComposeDocument) (*protocol.CompletionList, error) { documentPath, err := doc.DocumentPath() if err != nil { return nil, fmt.Errorf("LSP client sent invalid URI: %v", params.TextDocument.URI) @@ -241,6 +243,10 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, manager if stop { return &protocol.CompletionList{Items: items}, nil } + items, stop = serviceImageCompletionItems(*hub, path, prefixContent) + if stop { + return &protocol.CompletionList{Items: sortItems(items)}, nil + } folderStructureItems := folderStructureCompletionItems(documentPath, path, removeQuote(prefixContent)) if len(folderStructureItems) > 0 { return processItems(folderStructureItems, whitespaceLine && arrayAttributes), nil @@ -357,10 +363,15 @@ func createSchemaItems(params *protocol.CompletionParams, nodeProps any, lines [ return items } -func processItems(items []protocol.CompletionItem, arrayPrefix bool) *protocol.CompletionList { +func sortItems(items []protocol.CompletionItem) []protocol.CompletionItem { slices.SortFunc(items, func(a, b protocol.CompletionItem) int { return strings.Compare(a.Label, b.Label) }) + return items +} + +func processItems(items []protocol.CompletionItem, arrayPrefix bool) *protocol.CompletionList { + items = sortItems(items) if arrayPrefix { for i := range items { if edit, ok := items[i].TextEdit.(protocol.TextEdit); ok { @@ -570,6 +581,45 @@ func buildTargetCompletionItems(params *protocol.CompletionParams, manager *docu return nil, false } +func hubRepositoryImage(imageValue string) (repository, image, tagPrefix string) { + idx := strings.Index(imageValue, ":") + if idx == -1 { + return "", "", "" + } + slashIndex := strings.Index(imageValue, "/") + if slashIndex != strings.LastIndex(imageValue, "/") { + return "", "", "" + } + split := strings.Split(imageValue[0:idx], "/") + if len(split) == 1 { + return "library", split[0], imageValue[idx+1:] + } + return split[0], split[1], imageValue[idx+1:] +} + +func serviceImageCompletionItems(hub hub.Service, path []*ast.MappingValueNode, prefix string) ([]protocol.CompletionItem, bool) { + if len(path) == 3 && path[0].Key.GetToken().Value == "services" && path[2].Key.GetToken().Value == "image" { + if path[2].Value.GetToken().Type == token.DoubleQuoteType || path[2].Value.GetToken().Type == token.SingleQuoteType { + prefix = prefix[1:] + } + repository, image, tagPrefix := hubRepositoryImage(prefix) + if repository != "" { + tags, _ := hub.GetTags(repository, image) + items := []protocol.CompletionItem{} + for _, tag := range tags { + if strings.HasPrefix(tag, tagPrefix) { + items = append(items, protocol.CompletionItem{ + Label: tag, + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindModule), + }) + } + } + return items, true + } + } + return nil, false +} + func createBuildStageItems(params *protocol.CompletionParams, manager *document.Manager, dockerfileURI, dockerfilePath, prefix string, prefixLength protocol.UInteger) []protocol.CompletionItem { items := []protocol.CompletionItem{} for _, itemText := range findBuildStages(manager, dockerfileURI, dockerfilePath, prefix) { diff --git a/internal/compose/completion_test.go b/internal/compose/completion_test.go index 989b735..0a149cd 100644 --- a/internal/compose/completion_test.go +++ b/internal/compose/completion_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/docker/docker-language-server/internal/hub" "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" @@ -2861,13 +2862,14 @@ services: for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { manager := document.NewDocumentManager() + hub := hub.NewService() doc := document.NewComposeDocument(manager, uri.URI(composeFileURI), 1, []byte(tc.content)) list, err := Completion(context.Background(), &protocol.CompletionParams{ TextDocumentPositionParams: protocol.TextDocumentPositionParams{ TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, Position: protocol.Position{Line: tc.line, Character: tc.character}, }, - }, manager, doc) + }, manager, &hub, doc) require.NoError(t, err) require.Equal(t, tc.list, list) }) @@ -4027,13 +4029,14 @@ models: for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { manager := document.NewDocumentManager() + hub := hub.NewService() doc := document.NewComposeDocument(manager, uri.URI(composeFileURI), 1, []byte(tc.content)) list, err := Completion(context.Background(), &protocol.CompletionParams{ TextDocumentPositionParams: protocol.TextDocumentPositionParams{ TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, Position: protocol.Position{Line: tc.line, Character: tc.character}, }, - }, nil, doc) + }, nil, &hub, doc) require.NoError(t, err) require.Equal(t, tc.list, list) }) @@ -4397,6 +4400,7 @@ services: for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { manager := document.NewDocumentManager() + hub := hub.NewService() if tc.dockerfileContent != "" { u := dockerfileURI if tc.dockerfileURI != "" { @@ -4412,7 +4416,7 @@ services: TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, Position: protocol.Position{Line: tc.line, Character: tc.character}, }, - }, manager, doc) + }, manager, &hub, doc) require.NoError(t, err) require.Equal(t, tc.list(), list) }) @@ -4525,6 +4529,7 @@ services: for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { manager := document.NewDocumentManager() + hub := hub.NewService() changed, err := manager.Write(context.Background(), uri.URI(dockerfileURI), protocol.DockerfileLanguage, 1, []byte(tc.dockerfileContent)) require.NoError(t, err) require.True(t, changed) @@ -4534,7 +4539,7 @@ services: TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, Position: protocol.Position{Line: tc.line, Character: tc.character}, }, - }, manager, doc) + }, manager, &hub, doc) require.NoError(t, err) require.Equal(t, tc.list(), list) }) @@ -4586,13 +4591,14 @@ services: for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { manager := document.NewDocumentManager() + hub := hub.NewService() doc := document.NewComposeDocument(manager, uri.URI(composeFileURI), 1, []byte(tc.content)) list, err := Completion(context.Background(), &protocol.CompletionParams{ TextDocumentPositionParams: protocol.TextDocumentPositionParams{ TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, Position: protocol.Position{Line: tc.line, Character: tc.character}, }, - }, manager, doc) + }, manager, &hub, doc) require.NoError(t, err) require.Equal(t, tc.list, list) }) @@ -4719,13 +4725,14 @@ services: for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { manager := document.NewDocumentManager() + hub := hub.NewService() doc := document.NewComposeDocument(manager, uri.URI(composeFileURI), 1, []byte(tc.content)) list, err := Completion(context.Background(), &protocol.CompletionParams{ TextDocumentPositionParams: protocol.TextDocumentPositionParams{ TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, Position: protocol.Position{Line: tc.line, Character: tc.character}, }, - }, manager, doc) + }, manager, &hub, doc) require.NoError(t, err) require.Equal(t, tc.list, list) }) @@ -4974,13 +4981,14 @@ services: for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { manager := document.NewDocumentManager() + hub := hub.NewService() doc := document.NewComposeDocument(manager, uri.URI(composeFileURI), 1, []byte(tc.content)) list, err := Completion(context.Background(), &protocol.CompletionParams{ TextDocumentPositionParams: protocol.TextDocumentPositionParams{ TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, Position: protocol.Position{Line: tc.line, Character: tc.character}, }, - }, manager, doc) + }, manager, &hub, doc) require.NoError(t, err) require.Equal(t, tc.list, list) }) @@ -5248,13 +5256,14 @@ include: for _, setup := range setups { t.Run(fmt.Sprintf("%v (%v)", tc.name, setup.description), func(t *testing.T) { manager := document.NewDocumentManager() + hub := hub.NewService() doc := document.NewComposeDocument(manager, uri.URI(composeFileURI), 1, []byte(tc.content+setup.content)) list, err := Completion(context.Background(), &protocol.CompletionParams{ TextDocumentPositionParams: protocol.TextDocumentPositionParams{ TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, Position: protocol.Position{Line: tc.line, Character: tc.character + setup.offset}, }, - }, manager, doc) + }, manager, &hub, doc) require.NoError(t, err) if tc.hideFiles { require.Equal(t, setup.folderResult, list) @@ -5330,13 +5339,126 @@ include: for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { manager := document.NewDocumentManager() + hub := hub.NewService() doc := document.NewComposeDocument(manager, uri.URI(composeFileURI), 1, []byte(tc.content)) list, err := Completion(context.Background(), &protocol.CompletionParams{ TextDocumentPositionParams: protocol.TextDocumentPositionParams{ TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, Position: protocol.Position{Line: tc.line, Character: tc.character}, }, - }, manager, doc) + }, manager, &hub, doc) + require.NoError(t, err) + require.Equal(t, tc.list, list) + }) + } +} + +func TestCompletion_ImageTags(t *testing.T) { + testCases := []struct { + name string + content string + line uint32 + character uint32 + list *protocol.CompletionList + }{ + { + name: "docker/lsp:g", + content: ` +services: + test: + image: docker/lsp:g`, + line: 3, + character: 23, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "golang", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindModule), + }, + }, + }, + }, + { + name: "'docker/lsp:g'", + content: ` +services: + test: + image: 'docker/lsp:g'`, + line: 3, + character: 24, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "golang", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindModule), + }, + }, + }, + }, + { + name: "\"docker/lsp:g\"", + content: ` +services: + test: + image: "docker/lsp:g"`, + line: 3, + character: 24, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "golang", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindModule), + }, + }, + }, + }, + { + name: "ubuntu:20.", + content: ` +services: + test: + image: ubuntu:20.`, + line: 3, + character: 21, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "20.04", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindModule), + }, + { + Label: "20.10", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindModule), + }, + }, + }, + }, + { + name: "text is correct but cursor not in the right place", + content: ` +services: + test: + image: docker/lsp:g`, + line: 3, + character: 15, + list: nil, + }, + } + + dir := createFileStructure(t) + composeFileURI := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(dir, "compose.yaml")), "/")) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + manager := document.NewDocumentManager() + hub := hub.NewService() + doc := document.NewComposeDocument(manager, uri.URI(composeFileURI), 1, []byte(tc.content)) + list, err := Completion(context.Background(), &protocol.CompletionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, + Position: protocol.Position{Line: tc.line, Character: tc.character}, + }, + }, manager, &hub, doc) require.NoError(t, err) require.Equal(t, tc.list, list) }) diff --git a/internal/hub/client.go b/internal/hub/client.go new file mode 100644 index 0000000..bedc297 --- /dev/null +++ b/internal/hub/client.go @@ -0,0 +1,94 @@ +package hub + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/docker/docker-language-server/internal/cache" + "github.com/docker/docker-language-server/internal/pkg/cli/metadata" +) + +type TagResult struct { + Name string `json:"name"` +} + +type TagsResponse struct { + Next string `json:"next"` + Results []TagResult `json:"results"` +} + +type HubClientImpl struct { + client http.Client +} + +type HubFetcherImpl struct { + hubClient *HubClientImpl +} + +const getTagsUrl = "https://hub.docker.com/v2/namespaces/%v/repositories/%v/tags?page_size=100" + +func NewHubTagsFetcher(hubClient *HubClientImpl) cache.Fetcher[[]string] { + return &HubFetcherImpl{hubClient: hubClient} +} + +func (f *HubFetcherImpl) Fetch(key cache.Key) ([]string, error) { + if k, ok := key.(HubTagsKey); ok { + return f.hubClient.GetTags(context.Background(), k.Repository, k.Image) + } + return nil, nil +} + +func NewHubClient() *HubClientImpl { + return &HubClientImpl{ + client: http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *HubClientImpl) GetTags(ctx context.Context, repository, image string) ([]string, error) { + results, err := c.GetTagsFromURL(ctx, fmt.Sprintf(getTagsUrl, repository, image)) + if err != nil { + return nil, err + } + + tags := make([]string, len(results)) + for i := range results { + tags[i] = results[i].Name + } + return tags, nil +} + +func (c *HubClientImpl) GetTagsFromURL(ctx context.Context, url string) ([]TagResult, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + err := fmt.Errorf("failed to create http request: %w", err) + return nil, err + } + + req.Header.Set("User-Agent", fmt.Sprintf("docker-language-server/v%v", metadata.Version)) + res, err := c.client.Do(req) + if err != nil { + err := fmt.Errorf("failed to send HTTP request: %w", err) + return nil, err + } + + defer res.Body.Close() + if res.StatusCode != 200 { + err := fmt.Errorf("http request failed (%v status code)", res.StatusCode) + return nil, err + } + + var tagsResponse TagsResponse + _ = json.NewDecoder(res.Body).Decode(&tagsResponse) + if tagsResponse.Next != "" { + tags, err := c.GetTagsFromURL(ctx, tagsResponse.Next) + if err == nil { + tagsResponse.Results = append(tagsResponse.Results, tags...) + } + } + return tagsResponse.Results, nil +} diff --git a/internal/hub/service.go b/internal/hub/service.go new file mode 100644 index 0000000..a7cf04e --- /dev/null +++ b/internal/hub/service.go @@ -0,0 +1,25 @@ +package hub + +import ( + "github.com/docker/docker-language-server/internal/cache" +) + +type Service interface { + GetTags(repository, image string) ([]string, error) +} + +type ServiceImpl struct { + tagsManager cache.CacheManager[[]string] +} + +func NewService() Service { + client := NewHubClient() + tf := NewHubTagsFetcher(client) + return &ServiceImpl{ + tagsManager: cache.NewManager(tf), + } +} + +func (s *ServiceImpl) GetTags(repository, image string) ([]string, error) { + return s.tagsManager.Get(HubTagsKey{Repository: repository, Image: image}) +} diff --git a/internal/hub/types.go b/internal/hub/types.go new file mode 100644 index 0000000..f85af8e --- /dev/null +++ b/internal/hub/types.go @@ -0,0 +1,12 @@ +package hub + +import "fmt" + +type HubTagsKey struct { + Repository string + Image string +} + +func (k HubTagsKey) CacheKey() string { + return fmt.Sprintf("%v-%v", k.Repository, k.Image) +} diff --git a/internal/pkg/server/completion.go b/internal/pkg/server/completion.go index bd63cbe..00917ec 100644 --- a/internal/pkg/server/completion.go +++ b/internal/pkg/server/completion.go @@ -19,7 +19,7 @@ func (s *Server) TextDocumentCompletion(ctx *glsp.Context, params *protocol.Comp if doc.LanguageIdentifier() == protocol.DockerBakeLanguage { return hcl.Completion(ctx.Context, params, s.docs, doc.(document.BakeHCLDocument)) } else if doc.LanguageIdentifier() == protocol.DockerComposeLanguage && s.composeSupport && s.composeCompletion { - return compose.Completion(ctx.Context, params, s.docs, doc.(document.ComposeDocument)) + return compose.Completion(ctx.Context, params, s.docs, s.hubService, doc.(document.ComposeDocument)) } return nil, nil } diff --git a/internal/pkg/server/server.go b/internal/pkg/server/server.go index 3065931..220bff6 100644 --- a/internal/pkg/server/server.go +++ b/internal/pkg/server/server.go @@ -12,6 +12,7 @@ import ( "github.com/docker/docker-language-server/internal/bake/hcl" "github.com/docker/docker-language-server/internal/compose" "github.com/docker/docker-language-server/internal/configuration" + "github.com/docker/docker-language-server/internal/hub" "github.com/docker/docker-language-server/internal/pkg/buildkit" "github.com/docker/docker-language-server/internal/pkg/cli/metadata" "github.com/docker/docker-language-server/internal/pkg/document" @@ -34,6 +35,7 @@ type Server struct { gs *server.Server docs *document.Manager + hubService *hub.Service scoutService scout.Service // sessionTelemetryProperties contains a map of values that should @@ -75,6 +77,7 @@ type Server struct { } func NewServer(docManager *document.Manager) *Server { + hubService := hub.NewService() scoutService := scout.NewService() handler := protocol.Handler{} sessionTelemetryProperties := make(map[string]string) @@ -88,6 +91,7 @@ func NewServer(docManager *document.Manager) *Server { gs: server.NewServer(&handler, "", false), initialized: false, telemetry: telemetry.NewClient(), + hubService: &hubService, scoutService: scoutService, sessionTelemetryProperties: sessionTelemetryProperties, composeSupport: true, diff --git a/internal/scout/languageGatewayClient.go b/internal/scout/languageGatewayClient.go index e54a707..a6bfc4b 100644 --- a/internal/scout/languageGatewayClient.go +++ b/internal/scout/languageGatewayClient.go @@ -59,7 +59,7 @@ func (c LanguageGatewayClientImpl) PostImage(ctx context.Context, jwt, image str } req.Header.Set("Authorization", "Bearer "+jwt) - req.Header.Set("User-Agent", fmt.Sprintf("dockerfile-language-server/v%v", metadata.Version)) + req.Header.Set("User-Agent", fmt.Sprintf("docker-language-server/v%v", metadata.Version)) res, err := c.client.Do(req) if err != nil { err := fmt.Errorf("failed to send HTTP request: %w", err) diff --git a/internal/telemetry/client.go b/internal/telemetry/client.go index 3056d61..b065c35 100644 --- a/internal/telemetry/client.go +++ b/internal/telemetry/client.go @@ -108,7 +108,7 @@ func (c *TelemetryClientImpl) Publish(ctx context.Context) (int, error) { } req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", fmt.Sprintf("dockerfile-language-server/v%v", metadata.Version)) + req.Header.Set("User-Agent", fmt.Sprintf("docker-language-server/v%v", metadata.Version)) req.Header.Set("x-api-key", apiKey) res, err := http.DefaultClient.Do(req) if err != nil {