diff --git a/CHANGELOG.md b/CHANGELOG.md index e285f8b..ecd576e 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 +- Dockerfile + - textDocument/inlayHint + - show when an image was last pushed ([#431](https://github.com/docker/docker-language-server/issues/431)) - Compose - textDocument/completion - suggest image tags for images from Docker Hub ([#375](https://github.com/docker/docker-language-server/issues/375)) diff --git a/internal/compose/completion.go b/internal/compose/completion.go index fc1e6eb..fb520c9 100644 --- a/internal/compose/completion.go +++ b/internal/compose/completion.go @@ -581,35 +581,19 @@ 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) + repository, image, tagPrefix := types.HubRepositoryImage(prefix) if repository != "" { tags, _ := hub.GetTags(repository, image) items := []protocol.CompletionItem{} for _, tag := range tags { - if strings.HasPrefix(tag, tagPrefix) { + if strings.HasPrefix(tag.Name, tagPrefix) { items = append(items, protocol.CompletionItem{ - Label: tag, + Label: tag.Name, Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindModule), }) } diff --git a/internal/dockerfile/inlayHint.go b/internal/dockerfile/inlayHint.go new file mode 100644 index 0000000..c0e0ff9 --- /dev/null +++ b/internal/dockerfile/inlayHint.go @@ -0,0 +1,48 @@ +package dockerfile + +import ( + "fmt" + "strings" + "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" +) + +func InlayHint(hubService hub.Service, doc document.DockerfileDocument, rng protocol.Range) ([]protocol.InlayHint, error) { + content := doc.Input() + lines := strings.Split(string(content), "\n") + nodes := doc.Nodes() + hints := []protocol.InlayHint{} + for _, node := range nodes { + line := protocol.UInteger(node.StartLine) - 1 + if rng.Start.Line <= line && line <= rng.End.Line { + if strings.EqualFold(node.Value, "FROM") && node.Next != nil { + repository, image, tag := types.HubRepositoryImage(node.Next.Value) + if repository != "" && image != "" && tag != "" { + tags, err := hubService.GetTags(repository, image) + if err == nil { + for _, t := range tags { + if t.Name == tag { + if t.TagLastPushed != "" { + parsed, err := time.Parse(time.RFC3339Nano, t.TagLastPushed) + if err == nil { + hints = append(hints, protocol.InlayHint{ + Label: fmt.Sprintf("(last pushed on %v)", parsed.Format(time.DateOnly)), + PaddingLeft: types.CreateBoolPointer(true), + Position: protocol.Position{Line: line, Character: protocol.UInteger(len(lines[node.StartLine-1]))}, + }) + } + } + break + } + } + } + } + } + } + } + return hints, nil +} diff --git a/internal/dockerfile/inlayHint_test.go b/internal/dockerfile/inlayHint_test.go new file mode 100644 index 0000000..8e811dd --- /dev/null +++ b/internal/dockerfile/inlayHint_test.go @@ -0,0 +1,112 @@ +package dockerfile + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "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/stretchr/testify/require" + "go.lsp.dev/uri" +) + +func TestInlayHint(t *testing.T) { + testCases := []struct { + name string + content string + rng protocol.Range + inlayHints []protocol.InlayHint + }{ + { + name: "alpine", + content: "FROM alpine", + rng: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: protocol.Position{Line: 0, Character: 11}, + }, + inlayHints: []protocol.InlayHint{}, + }, + { + name: "alpine:3.16", + content: "FROM alpine:3.16", + rng: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: protocol.Position{Line: 0, Character: 16}, + }, + inlayHints: []protocol.InlayHint{ + { + Label: "(last pushed on 2024-01-27)", + PaddingLeft: types.CreateBoolPointer(true), + Position: protocol.Position{Line: 0, Character: 16}, + }, + }, + }, + { + name: "alpine@sha256:72af6266bafde8c78d5f20a2a85d0576533ce1ecd6ed8bcf7baf62a743f3b24d", + content: "FROM alpine@sha256:72af6266bafde8c78d5f20a2a85d0576533ce1ecd6ed8bcf7baf62a743f3b24d", + rng: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: protocol.Position{Line: 0, Character: 16}, + }, + inlayHints: []protocol.InlayHint{}, + }, + { + name: "alpine:3.16@sha256:72af6266bafde8c78d5f20a2a85d0576533ce1ecd6ed8bcf7baf62a743f3b24d", + content: "FROM alpine:3.16@sha256:72af6266bafde8c78d5f20a2a85d0576533ce1ecd6ed8bcf7baf62a743f3b24d", + rng: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: protocol.Position{Line: 0, Character: 16}, + }, + inlayHints: []protocol.InlayHint{}, + }, + { + name: "prom/prometheus", + content: "FROM prom/prometheus", + rng: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: protocol.Position{Line: 0, Character: 16}, + }, + inlayHints: []protocol.InlayHint{}, + }, + { + name: "prom/prometheus:v3.1.0", + content: "FROM prom/prometheus:v3.1.0", + rng: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: protocol.Position{Line: 0, Character: 27}, + }, + inlayHints: []protocol.InlayHint{ + { + Label: "(last pushed on 2025-01-02)", + PaddingLeft: types.CreateBoolPointer(true), + Position: protocol.Position{Line: 0, Character: 27}, + }, + }, + }, + { + name: "content outside range should not return anything", + content: "\n\nFROM alpine:3.16", + rng: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: protocol.Position{Line: 1, Character: 0}, + }, + inlayHints: []protocol.InlayHint{}, + }, + } + + dockerfileURI := uri.URI(fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(os.TempDir(), "Dockerfile")), "/"))) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + hubService := hub.NewService() + doc := document.NewDockerfileDocument(dockerfileURI, 1, []byte(tc.content)) + inlayHints, err := InlayHint(hubService, doc, tc.rng) + require.NoError(t, err) + require.Equal(t, tc.inlayHints, inlayHints) + }) + } +} diff --git a/internal/hub/client.go b/internal/hub/client.go index bedc297..c313a58 100644 --- a/internal/hub/client.go +++ b/internal/hub/client.go @@ -12,7 +12,8 @@ import ( ) type TagResult struct { - Name string `json:"name"` + Name string `json:"name"` + TagLastPushed string `json:"tag_last_pushed"` } type TagsResponse struct { @@ -24,17 +25,17 @@ type HubClientImpl struct { client http.Client } -type HubFetcherImpl struct { +type HubTagResultsFetcherImpl 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 NewHubTagResultsFetcher(hubClient *HubClientImpl) cache.Fetcher[[]TagResult] { + return &HubTagResultsFetcherImpl{hubClient: hubClient} } -func (f *HubFetcherImpl) Fetch(key cache.Key) ([]string, error) { +func (f *HubTagResultsFetcherImpl) Fetch(key cache.Key) ([]TagResult, error) { if k, ok := key.(HubTagsKey); ok { return f.hubClient.GetTags(context.Background(), k.Repository, k.Image) } @@ -49,17 +50,8 @@ func NewHubClient() *HubClientImpl { } } -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) GetTags(ctx context.Context, repository, image string) ([]TagResult, error) { + return c.GetTagsFromURL(ctx, fmt.Sprintf(getTagsUrl, repository, image)) } func (c *HubClientImpl) GetTagsFromURL(ctx context.Context, url string) ([]TagResult, error) { diff --git a/internal/hub/service.go b/internal/hub/service.go index a7cf04e..5169f31 100644 --- a/internal/hub/service.go +++ b/internal/hub/service.go @@ -5,21 +5,20 @@ import ( ) type Service interface { - GetTags(repository, image string) ([]string, error) + GetTags(repository, image string) ([]TagResult, error) } type ServiceImpl struct { - tagsManager cache.CacheManager[[]string] + tagResultManager cache.CacheManager[[]TagResult] } func NewService() Service { client := NewHubClient() - tf := NewHubTagsFetcher(client) return &ServiceImpl{ - tagsManager: cache.NewManager(tf), + tagResultManager: cache.NewManager(NewHubTagResultsFetcher(client)), } } -func (s *ServiceImpl) GetTags(repository, image string) ([]string, error) { - return s.tagsManager.Get(HubTagsKey{Repository: repository, Image: image}) +func (s *ServiceImpl) GetTags(repository, image string) ([]TagResult, error) { + return s.tagResultManager.Get(HubTagsKey{Repository: repository, Image: image}) } diff --git a/internal/pkg/server/inlayHint.go b/internal/pkg/server/inlayHint.go index 3fc13c9..e660c5d 100644 --- a/internal/pkg/server/inlayHint.go +++ b/internal/pkg/server/inlayHint.go @@ -3,6 +3,7 @@ package server 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/dockerfile" "github.com/docker/docker-language-server/internal/pkg/document" "github.com/docker/docker-language-server/internal/tliron/glsp" "github.com/docker/docker-language-server/internal/tliron/glsp/protocol" @@ -19,6 +20,8 @@ func (s *Server) TextDocumentInlayHint(ctx *glsp.Context, params *protocol.Inlay return compose.InlayHint(doc.(document.ComposeDocument), params.Range) } else if doc.LanguageIdentifier() == protocol.DockerBakeLanguage { return hcl.InlayHint(s.docs, doc.(document.BakeHCLDocument), params.Range) + } else if doc.LanguageIdentifier() == protocol.DockerfileLanguage { + return dockerfile.InlayHint(*s.hubService, doc.(document.DockerfileDocument), params.Range) } return nil, nil } diff --git a/internal/types/common.go b/internal/types/common.go index 4ef5f93..20c7f22 100644 --- a/internal/types/common.go +++ b/internal/types/common.go @@ -134,3 +134,25 @@ func FileStructureCompletionItems(folder string, hideFiles bool) []protocol.Comp } return nil } + +func HubRepositoryImage(imageValue string) (repository, image, tag string) { + // ignore images with a SHA digest + if strings.Contains(imageValue, "@") { + return "", "", "" + } + // ignore images in another repository + slashIndex := strings.Index(imageValue, "/") + if slashIndex != strings.LastIndex(imageValue, "/") { + return "", "", "" + } + // ignore images without an explicit tag + idx := strings.Index(imageValue, ":") + if idx == -1 { + 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:] +}