Skip to content

Commit 0fd39da

Browse files
authored
Merge pull request #163 from docker/compose-simple-inlay-hints
Add inlay hints support for non-object attributes
2 parents a2ca425 + 48672de commit 0fd39da

File tree

4 files changed

+262
-3
lines changed

4 files changed

+262
-3
lines changed

CHANGELOG.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ All notable changes to the Docker Language Server will be documented in this fil
77
### Added
88

99
- Compose
10-
- improve code completion by automatically including required attributes in completion items ([#155](https://github.com/docker/docker-language-server/issues/155))
10+
- textDocument/completion
11+
- improve code completion by automatically including required attributes in completion items ([#155](https://github.com/docker/docker-language-server/issues/155))
12+
- textDocument/inlayHint
13+
- show the parent service's value if it is being overridden and they are not object attributes ([#156](https://github.com/docker/docker-language-server/issues/156))
1114

1215
### Fixed
1316

@@ -104,7 +107,7 @@ All notable changes to the Docker Language Server will be documented in this fil
104107
- Bake
105108
- textDocument/publishDiagnostics
106109
- consider the context attribute when determining which Dockerfile the Bake target is for ([#57](https://github.com/docker/docker-language-server/issues/57))
107-
- textDocument/inlayHints
110+
- textDocument/inlayHint
108111
- consider the context attribute when determining which Dockerfile to use for inlaying default values of `ARG` variables ([#60](https://github.com/docker/docker-language-server/pull/60))
109112
- textDocument/completion
110113
- consider the context attribute when determining which Dockerfile to use for looking up build stages ([#61](https://github.com/docker/docker-language-server/pull/61))

internal/compose/inlayHint.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package compose
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
7+
"github.com/docker/docker-language-server/internal/pkg/document"
8+
"github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
9+
"github.com/docker/docker-language-server/internal/types"
10+
"github.com/goccy/go-yaml/ast"
11+
"github.com/goccy/go-yaml/token"
12+
)
13+
14+
func allServiceProperties(node ast.Node) map[string]map[string]ast.Node {
15+
if servicesNode, ok := node.(*ast.MappingNode); ok {
16+
services := map[string]map[string]ast.Node{}
17+
for _, serviceNode := range servicesNode.Values {
18+
if properties, ok := serviceNode.Value.(*ast.MappingNode); ok {
19+
serviceProperties := map[string]ast.Node{}
20+
for _, property := range properties.Values {
21+
serviceProperties[property.Key.GetToken().Value] = property.Value
22+
}
23+
services[serviceNode.Key.GetToken().Value] = serviceProperties
24+
}
25+
}
26+
return services
27+
}
28+
return nil
29+
}
30+
31+
func hierarchyProperties(service string, serviceProps map[string]map[string]ast.Node, chain []map[string]ast.Node) []map[string]ast.Node {
32+
if extends, ok := serviceProps[service]["extends"]; ok {
33+
if s, ok := extends.(*ast.StringNode); ok {
34+
chain = append(chain, hierarchyProperties(s.Value, serviceProps, chain)...)
35+
} else if mappingNode, ok := extends.(*ast.MappingNode); ok {
36+
external := false
37+
for _, value := range mappingNode.Values {
38+
if value.Key.GetToken().Value == "file" {
39+
external = true
40+
break
41+
}
42+
}
43+
44+
if !external {
45+
for _, value := range mappingNode.Values {
46+
if value.Key.GetToken().Value == "service" {
47+
chain = append(chain, hierarchyProperties(value.Value.GetToken().Value, serviceProps, chain)...)
48+
}
49+
}
50+
}
51+
}
52+
}
53+
chain = append(chain, serviceProps[service])
54+
return chain
55+
}
56+
57+
func InlayHint(doc document.ComposeDocument, rng protocol.Range) ([]protocol.InlayHint, error) {
58+
file := doc.File()
59+
if file == nil || len(file.Docs) == 0 {
60+
return nil, nil
61+
}
62+
63+
hints := []protocol.InlayHint{}
64+
for _, docNode := range file.Docs {
65+
if mappingNode, ok := docNode.Body.(*ast.MappingNode); ok {
66+
for _, node := range mappingNode.Values {
67+
if s, ok := node.Key.(*ast.StringNode); ok && s.Value == "services" {
68+
serviceProps := allServiceProperties(node.Value)
69+
for service, props := range serviceProps {
70+
chain := hierarchyProperties(service, serviceProps, []map[string]ast.Node{})
71+
if len(chain) == 1 {
72+
continue
73+
}
74+
slices.Reverse(chain)
75+
chain = chain[1:]
76+
for name, value := range props {
77+
if name == "extends" {
78+
continue
79+
}
80+
81+
for _, parentProps := range chain {
82+
if parentProp, ok := parentProps[name]; ok {
83+
if _, ok := parentProp.(*ast.MappingNode); !ok {
84+
length := len(value.GetToken().Value)
85+
if value.GetToken().Type == token.DoubleQuoteType {
86+
length += 2
87+
}
88+
hints = append(hints, protocol.InlayHint{
89+
Label: fmt.Sprintf("(parent value: %v)", parentProp.GetToken().Value),
90+
PaddingLeft: types.CreateBoolPointer(true),
91+
Position: protocol.Position{
92+
Line: uint32(value.GetToken().Position.Line) - 1,
93+
Character: uint32(value.GetToken().Position.Column + length - 1),
94+
},
95+
})
96+
break
97+
}
98+
}
99+
}
100+
}
101+
}
102+
break
103+
}
104+
}
105+
}
106+
}
107+
return hints, nil
108+
}

internal/compose/inlayHint_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package compose
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/docker/docker-language-server/internal/pkg/document"
11+
"github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
12+
"github.com/docker/docker-language-server/internal/types"
13+
"github.com/stretchr/testify/require"
14+
"go.lsp.dev/uri"
15+
)
16+
17+
func TestInlayHint(t *testing.T) {
18+
composeFileURI := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(os.TempDir(), "compose.yaml")), "/"))
19+
20+
testCases := []struct {
21+
name string
22+
content string
23+
inlayHints []protocol.InlayHint
24+
}{
25+
{
26+
name: "extends is ignored",
27+
content: `
28+
services:
29+
web:
30+
attach: true
31+
web2:
32+
extends: web
33+
web3:
34+
extends: web2`,
35+
inlayHints: []protocol.InlayHint{},
36+
},
37+
{
38+
name: "single line attribute has a hint",
39+
content: `
40+
services:
41+
web:
42+
attach: true
43+
web2:
44+
extends: web
45+
attach: false`,
46+
inlayHints: []protocol.InlayHint{
47+
{
48+
Label: "(parent value: true)",
49+
PaddingLeft: types.CreateBoolPointer(true),
50+
Position: protocol.Position{Line: 6, Character: 17},
51+
},
52+
},
53+
},
54+
{
55+
name: "attribute recurses upwards",
56+
content: `
57+
services:
58+
web:
59+
attach: true
60+
web2:
61+
extends: web
62+
web3:
63+
extends: web
64+
attach: false`,
65+
inlayHints: []protocol.InlayHint{
66+
{
67+
Label: "(parent value: true)",
68+
PaddingLeft: types.CreateBoolPointer(true),
69+
Position: protocol.Position{Line: 8, Character: 17},
70+
},
71+
},
72+
},
73+
{
74+
name: "extends as an object but without a file attribute",
75+
content: `
76+
services:
77+
web:
78+
attach: true
79+
web2:
80+
extends:
81+
service: web
82+
attach: false`,
83+
inlayHints: []protocol.InlayHint{
84+
{
85+
Label: "(parent value: true)",
86+
PaddingLeft: types.CreateBoolPointer(true),
87+
Position: protocol.Position{Line: 7, Character: 17},
88+
},
89+
},
90+
},
91+
{
92+
name: "extends as an object pointing to a locally named service but points to a bad file",
93+
content: `
94+
services:
95+
web:
96+
attach: true
97+
web2:
98+
extends:
99+
service: web
100+
file: non-existent.yaml
101+
attach: false`,
102+
inlayHints: []protocol.InlayHint{},
103+
},
104+
{
105+
name: "quoted string value has the correct position",
106+
content: `
107+
services:
108+
web:
109+
hostname: "hostname1"
110+
web2:
111+
hostname: "hostname2"
112+
extends: web`,
113+
inlayHints: []protocol.InlayHint{
114+
{
115+
Label: "(parent value: hostname1)",
116+
PaddingLeft: types.CreateBoolPointer(true),
117+
Position: protocol.Position{Line: 5, Character: 25},
118+
},
119+
},
120+
},
121+
{
122+
name: "sub-attributes unsupported",
123+
content: `
124+
services:
125+
web:
126+
build:
127+
context: c1
128+
web2:
129+
build:
130+
context: c2
131+
extends: web`,
132+
inlayHints: []protocol.InlayHint{},
133+
},
134+
}
135+
136+
for _, tc := range testCases {
137+
u := uri.URI(composeFileURI)
138+
t.Run(tc.name, func(t *testing.T) {
139+
doc := document.NewComposeDocument(u, 1, []byte(tc.content))
140+
inlayHints, err := InlayHint(doc, protocol.Range{})
141+
require.NoError(t, err)
142+
require.Equal(t, tc.inlayHints, inlayHints)
143+
})
144+
}
145+
}

internal/pkg/server/inlayHint.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package server
22

33
import (
44
"github.com/docker/docker-language-server/internal/bake/hcl"
5+
"github.com/docker/docker-language-server/internal/compose"
56
"github.com/docker/docker-language-server/internal/pkg/document"
67
"github.com/docker/docker-language-server/internal/tliron/glsp"
78
"github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
@@ -14,7 +15,9 @@ func (s *Server) TextDocumentInlayHint(ctx *glsp.Context, params *protocol.Inlay
1415
return nil, err
1516
}
1617
defer doc.Close()
17-
if doc.LanguageIdentifier() == protocol.DockerBakeLanguage {
18+
if doc.LanguageIdentifier() == protocol.DockerComposeLanguage {
19+
return compose.InlayHint(doc.(document.ComposeDocument), params.Range)
20+
} else if doc.LanguageIdentifier() == protocol.DockerBakeLanguage {
1821
return hcl.InlayHint(s.docs, doc.(document.BakeHCLDocument), params.Range)
1922
}
2023
return nil, nil

0 commit comments

Comments
 (0)