Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
22 changes: 3 additions & 19 deletions internal/compose/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
}
Expand Down
48 changes: 48 additions & 0 deletions internal/dockerfile/inlayHint.go
Original file line number Diff line number Diff line change
@@ -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
}
112 changes: 112 additions & 0 deletions internal/dockerfile/inlayHint_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
24 changes: 8 additions & 16 deletions internal/hub/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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) {
Expand Down
11 changes: 5 additions & 6 deletions internal/hub/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
}
3 changes: 3 additions & 0 deletions internal/pkg/server/inlayHint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
22 changes: 22 additions & 0 deletions internal/types/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:]
}
Loading