├── testdata
├── diagnostics
│ ├── Dockerfile
│ ├── Dockerfile2
│ └── backend
│ │ └── Dockerfile
├── completion
│ ├── backend
│ │ └── Dockerfile
│ └── Dockerfile
├── definition
│ ├── backend
│ │ └── Dockerfile
│ └── Dockerfile
├── inlayHint
│ └── backend
│ │ └── Dockerfile
├── README.md
└── releaser
│ └── RELEASE.expected.md
├── .gitignore
├── .idea
├── vcs.xml
├── .gitignore
├── modules.xml
├── docker-language-server.iml
└── runConfigurations
│ └── Debug_Server__49201_.xml
├── internal
├── tliron
│ └── glsp
│ │ ├── protocol
│ │ ├── telemetry.go
│ │ ├── trace.go
│ │ ├── client.go
│ │ └── base-protocol.go
│ │ ├── README.md
│ │ ├── server
│ │ ├── logging.go
│ │ ├── serve.go
│ │ ├── run-nodejs.go
│ │ ├── run-stdio.go
│ │ ├── listener.go
│ │ ├── run-tcp.go
│ │ ├── connections.go
│ │ ├── server.go
│ │ └── handler.go
│ │ └── common.go
├── hub
│ ├── types.go
│ ├── service.go
│ └── client.go
├── bake
│ └── hcl
│ │ ├── parser
│ │ └── schema_test.go
│ │ ├── documentLink.go
│ │ ├── documentSymbol.go
│ │ ├── codeLens.go
│ │ ├── inlayHint.go
│ │ ├── documentSymbol_test.go
│ │ ├── hover.go
│ │ ├── documentHighlight.go
│ │ ├── codeLens_test.go
│ │ ├── inlayHint_test.go
│ │ └── inlineCompletion.go
├── pkg
│ ├── cli
│ │ ├── metadata
│ │ │ └── metadata.go
│ │ ├── cli.go
│ │ └── start.go
│ ├── lsp
│ │ └── textdocument
│ │ │ └── publishDiagnostics.go
│ ├── server
│ │ ├── executeCommand.go
│ │ ├── rename.go
│ │ ├── prepareRename.go
│ │ ├── inlineCompletion.go
│ │ ├── codeLens.go
│ │ ├── semanticTokens.go
│ │ ├── formatting.go
│ │ ├── documentHighlight.go
│ │ ├── documentSymbol.go
│ │ ├── completion.go
│ │ ├── documentLink.go
│ │ ├── definition.go
│ │ ├── inlayHint.go
│ │ ├── didChangeConfiguration.go
│ │ ├── hover.go
│ │ ├── codeAction.go
│ │ └── notifier.go
│ └── document
│ │ ├── dockerfileDocument_test.go
│ │ ├── dockerfileDocument.go
│ │ ├── manager_test.go
│ │ ├── document.go
│ │ ├── dockerComposeDocument_test.go
│ │ └── dockerComposeDocument.go
├── compose
│ ├── prepareRename.go
│ ├── rename.go
│ ├── diagnosticsCollector_test.go
│ ├── diagnosticsCollector.go
│ ├── definition.go
│ ├── documentSymbol.go
│ ├── inlayHint.go
│ ├── formatting.go
│ └── schema.go
├── telemetry
│ ├── types.go
│ ├── client_test.go
│ └── client.go
├── cache
│ └── manager.go
├── types
│ ├── types.go
│ └── common_test.go
├── dockerfile
│ ├── inlayHint.go
│ └── inlayHint_test.go
├── scout
│ ├── languageGatewayClient.go
│ ├── types.go
│ └── languageGatewayClient_test.go
└── configuration
│ └── configuration.go
├── docker-bake.hcl
├── .golangci.yml
├── .vscode
└── launch.json
├── cmd
└── docker-language-server
│ └── main.go
├── e2e-tests
├── json_test.go
├── documentSymbol_test.go
├── formatting_test.go
├── prepareRename_test.go
├── documentHighlight_test.go
├── inlayHint_test.go
├── rename_test.go
├── inlineCompletion_test.go
├── semanticTokens_test.go
└── documentLink_test.go
├── CLIENTS.md
├── Makefile
├── Dockerfile
├── SECURITY.md
├── releaser
└── main_test.go
├── .github
└── workflows
│ └── prepare-release.yml
├── TELEMETRY.md
└── CONTRIBUTING.md
/testdata/diagnostics/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM scratch
2 | ARG valid=value
--------------------------------------------------------------------------------
/testdata/completion/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM scratch AS nested
2 |
--------------------------------------------------------------------------------
/testdata/diagnostics/Dockerfile2:
--------------------------------------------------------------------------------
1 | ARG other=value
2 | FROM scratch AS build
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /docker-language-server*
2 | /testdata/releaser/CHANGELOG.result.md
--------------------------------------------------------------------------------
/testdata/definition/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG NESTED_VAR
2 | FROM scratch AS stage
3 |
--------------------------------------------------------------------------------
/testdata/inlayHint/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG BACKEND_VAR=backend_value
2 | FROM scratch
3 |
--------------------------------------------------------------------------------
/testdata/diagnostics/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG BACKEND_VAR=backend_value
2 | FROM scratch
3 |
--------------------------------------------------------------------------------
/testdata/definition/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM scratch AS stage
2 | ARG var
3 | ARG defined=default
4 | FROM scratch AS hyphenated-stage
--------------------------------------------------------------------------------
/testdata/README.md:
--------------------------------------------------------------------------------
1 | # Tests
2 |
3 | This folder is used for the tests. Create files in this folder and its subfolders at your own risk. They may be deleted or overwritten by a test.
4 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 | # Datasource local storage ignored files
7 | /dataSources/
8 | /dataSources.local.xml
9 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/protocol/telemetry.go:
--------------------------------------------------------------------------------
1 | package protocol
2 |
3 | // https://microsoft.github.io/language-server-protocol/specifications/specification-3-16#telemetry_event
4 |
5 | const ServerTelemetryEvent = Method("telemetry/event")
6 |
--------------------------------------------------------------------------------
/internal/hub/types.go:
--------------------------------------------------------------------------------
1 | package hub
2 |
3 | import "fmt"
4 |
5 | type HubTagsKey struct {
6 | Repository string
7 | Image string
8 | }
9 |
10 | func (k HubTagsKey) CacheKey() string {
11 | return fmt.Sprintf("%v-%v", k.Repository, k.Image)
12 | }
13 |
--------------------------------------------------------------------------------
/testdata/releaser/RELEASE.expected.md:
--------------------------------------------------------------------------------
1 | ### Added
2 |
3 | - Compose
4 | - updated Compose schema to the latest version
5 |
6 | ### Fixed
7 |
8 | - Bake
9 | - textDocument/hover
10 | - fix error when hovering inside a comment ([#410](https://github.com/docker/docker-language-server/issues/410))
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/README.md:
--------------------------------------------------------------------------------
1 | The code in this folder was forked over from `tliron/glsp` on GitHub. The repository can be found [here](https://github.com/tliron/glsp) and the Git commit that the source of the fork came from was [`d7cfc1c7abca7d5d8addb034e99b4cb7c75e5c1e`](https://github.com/tliron/glsp/commit/d7cfc1c7abca7d5d8addb034e99b4cb7c75e5c1e).
2 |
--------------------------------------------------------------------------------
/internal/bake/hcl/parser/schema_test.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestConvertToHCLPosition(t *testing.T) {
10 | pos := ConvertToHCLPosition("", 0, 0)
11 | require.Equal(t, 1, pos.Line)
12 | require.Equal(t, 1, pos.Column)
13 | require.Equal(t, 0, pos.Byte)
14 | }
15 |
--------------------------------------------------------------------------------
/.idea/docker-language-server.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/server/logging.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/tliron/commonlog"
7 | )
8 |
9 | type JSONRPCLogger struct {
10 | log commonlog.Logger
11 | }
12 |
13 | // ([jsonrpc2.Logger] interface)
14 | func (self *JSONRPCLogger) Printf(format string, v ...any) {
15 | self.log.Debugf(strings.TrimSuffix(format, "\n"), v...)
16 | }
17 |
--------------------------------------------------------------------------------
/testdata/completion/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG TARGETOS TARGETARCH
2 |
3 | FROM
4 | FROM scratch
5 | # introduce a parsing error on purpose
6 | FROM scratch AS
7 |
8 | FROM scratch AS base
9 | FROM scratch AS tests
10 | FROM scratch AS release
11 |
12 | # this ENV line validates the FROM argument parsing
13 | ENV a=b c=d e=f
14 |
15 | ARG argOne
16 | ARG argTwo
17 |
18 | ARG argOnePredefined=v1
19 |
--------------------------------------------------------------------------------
/internal/pkg/cli/metadata/metadata.go:
--------------------------------------------------------------------------------
1 | package metadata
2 |
3 | // Version will be set dynamically by the build.sh script.
4 | var Version = "0.0.0"
5 |
6 | // BugSnagAPIKey will be set dynamically by the build.sh script.
7 | var BugSnagAPIKey = ""
8 |
9 | // TelemetryEndpoint will be set dynamically by the build.sh script.
10 | var TelemetryEndpoint = ""
11 |
12 | // TelemetryKey will be set dynamically by the build.sh script.
13 | var TelemetryKey = ""
14 |
--------------------------------------------------------------------------------
/internal/pkg/lsp/textdocument/publishDiagnostics.go:
--------------------------------------------------------------------------------
1 | package textdocument
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/pkg/document"
5 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
6 | )
7 |
8 | type DiagnosticsCollector interface {
9 | CollectDiagnostics(source, workspaceFolder string, doc document.Document, text string) []protocol.Diagnostic
10 | SupportsLanguageIdentifier(languageIdentifier protocol.LanguageIdentifier) bool
11 | }
12 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/server/serve.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "io"
5 |
6 | "github.com/tliron/commonlog"
7 | )
8 |
9 | // See: https://github.com/sourcegraph/go-langserver/blob/master/main.go#L179
10 |
11 | func (self *Server) ServeStream(stream io.ReadWriteCloser, log commonlog.Logger) {
12 | if log == nil {
13 | log = self.Log
14 | }
15 | log.Info("new stream connection")
16 | <-self.newStreamConnection(stream).DisconnectNotify()
17 | log.Info("stream connection closed")
18 | }
19 |
--------------------------------------------------------------------------------
/docker-bake.hcl:
--------------------------------------------------------------------------------
1 | variable "GO_VERSION" {
2 | default = null
3 | }
4 |
5 | target "_common" {
6 | args = {
7 | GO_VERSION = GO_VERSION
8 | BUILDKIT_CONTEXT_KEEP_GIT_DIR = 1
9 | }
10 | }
11 |
12 | group "validate" {
13 | targets = ["vendor-validate"]
14 | }
15 |
16 | target "vendor-validate" {
17 | inherits = ["_common"]
18 | target = "vendor-validate"
19 | output = ["type=cacheonly"]
20 | }
21 |
22 | target "vendor" {
23 | inherits = ["_common"]
24 | target = "vendor-update"
25 | output = ["."]
26 | }
27 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | linters:
3 | # Defines a set of rules to ignore issues.
4 | # It does not skip the analysis, and so does not ignore "typecheck" errors.
5 | exclusions:
6 | # Excluding configuration per-path, per-linter, per-text and per-source.
7 | rules:
8 | - path: internal/tliron
9 | linters:
10 | - errcheck
11 | - staticcheck
12 | - path: releaser
13 | linters:
14 | - errcheck
15 | - path: e2e-tests
16 | linters:
17 | - errcheck
18 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/common.go:
--------------------------------------------------------------------------------
1 | package glsp
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | )
7 |
8 | type NotifyFunc func(ctx context.Context, method string, params any)
9 | type CallFunc func(ctx context.Context, method string, params any, result any)
10 |
11 | type Context struct {
12 | Method string
13 | Params json.RawMessage
14 | Notify NotifyFunc
15 | Call CallFunc
16 | Context context.Context // can be nil
17 | }
18 |
19 | type Handler interface {
20 | Handle(context *Context) (result any, validMethod bool, validParams bool, err error)
21 | }
22 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/server/run-nodejs.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "errors"
5 | "os"
6 | "strconv"
7 | )
8 |
9 | func (self *Server) RunNodeJs() error {
10 | nodeChannelFd := os.Getenv("NODE_CHANNEL_FD")
11 | if len(nodeChannelFd) == 0 {
12 | return errors.New("NODE_CHANNEL_FD not in environment")
13 | }
14 | nodeChannelFdInt, err := strconv.Atoi(nodeChannelFd)
15 | if err != nil {
16 | return err
17 | }
18 | file := os.NewFile(uintptr(nodeChannelFdInt), "/glsp/NODE_CHANNEL_FD")
19 |
20 | self.Log.Notice("listening for Node.js IPC connections")
21 | self.ServeStream(file, nil)
22 | return nil
23 | }
24 |
--------------------------------------------------------------------------------
/internal/hub/service.go:
--------------------------------------------------------------------------------
1 | package hub
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/cache"
5 | )
6 |
7 | type Service interface {
8 | GetTags(repository, image string) ([]TagResult, error)
9 | }
10 |
11 | type ServiceImpl struct {
12 | tagResultManager cache.CacheManager[[]TagResult]
13 | }
14 |
15 | func NewService() Service {
16 | client := NewHubClient()
17 | return &ServiceImpl{
18 | tagResultManager: cache.NewManager(NewHubTagResultsFetcher(client)),
19 | }
20 | }
21 |
22 | func (s *ServiceImpl) GetTags(repository, image string) ([]TagResult, error) {
23 | return s.tagResultManager.Get(HubTagsKey{Repository: repository, Image: image})
24 | }
25 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Debug_Server__49201_.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/server/run-stdio.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "errors"
5 | "os"
6 | )
7 |
8 | func (self *Server) RunStdio() error {
9 | self.Log.Notice("reading from stdin, writing to stdout")
10 | self.ServeStream(Stdio{}, nil)
11 | return nil
12 | }
13 |
14 | type Stdio struct{}
15 |
16 | // ([io.Reader] interface)
17 | func (Stdio) Read(p []byte) (int, error) {
18 | return os.Stdin.Read(p)
19 | }
20 |
21 | // ([io.Writer] interface)
22 | func (Stdio) Write(p []byte) (int, error) {
23 | return os.Stdout.Write(p)
24 | }
25 |
26 | // ([io.Closer] interface)
27 | func (Stdio) Close() error {
28 | return errors.Join(os.Stdin.Close(), os.Stdout.Close())
29 | }
30 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Run Server (49201)",
9 | "type": "go",
10 | "request": "launch",
11 | "mode": "auto",
12 | "program": "${workspaceFolder}/cmd/docker-language-server/main.go",
13 | "args": [
14 | "start",
15 | "--address",
16 | ":49201"
17 | ]
18 | }
19 | ]
20 | }
--------------------------------------------------------------------------------
/internal/compose/prepareRename.go:
--------------------------------------------------------------------------------
1 | package compose
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/pkg/document"
5 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
6 | )
7 |
8 | func PrepareRename(doc document.ComposeDocument, params *protocol.PrepareRenameParams) (*protocol.Range, error) {
9 | highlights, err := DocumentHighlight(doc, params.Position)
10 | if err != nil || len(highlights) == 0 {
11 | return nil, err
12 | }
13 |
14 | for _, highlight := range highlights {
15 | if insideRange(highlight.Range, params.Position.Line, params.Position.Character) {
16 | return &highlight.Range, nil
17 | }
18 | }
19 | return nil, nil
20 | }
21 |
--------------------------------------------------------------------------------
/internal/pkg/server/executeCommand.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/tliron/glsp"
5 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
6 | "github.com/docker/docker-language-server/internal/types"
7 | )
8 |
9 | func (s *Server) WorkspaceExecuteCommand(context *glsp.Context, params *protocol.ExecuteCommandParams) (any, error) {
10 | if params.Command == types.TelemetryCallbackCommandId && len(params.Arguments) == 2 {
11 | if event, ok := params.Arguments[0].(string); ok {
12 | if properties, ok := params.Arguments[1].(map[string]any); ok {
13 | s.Enqueue(event, properties)
14 | }
15 | }
16 | }
17 | return nil, nil
18 | }
19 |
--------------------------------------------------------------------------------
/cmd/docker-language-server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 | "os"
6 |
7 | "github.com/bugsnag/bugsnag-go"
8 | "github.com/docker/docker-language-server/internal/pkg/cli"
9 | "github.com/docker/docker-language-server/internal/pkg/cli/metadata"
10 | )
11 |
12 | func main() {
13 | bugsnag.Configure(bugsnag.Configuration{
14 | APIKey: metadata.BugSnagAPIKey,
15 | AppType: "languageServer",
16 | AppVersion: metadata.Version,
17 | // if it is the empty string it will not be set
18 | Hostname: "REDACTED",
19 | Logger: log.New(os.Stderr, "", log.LstdFlags),
20 | ProjectPackages: []string{"main", "github.com/docker/docker-language-server/**"},
21 | ReleaseStage: "production",
22 | })
23 |
24 | cli.Execute()
25 | }
26 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/server/listener.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "crypto/tls"
5 | "net"
6 | "os"
7 | )
8 |
9 | func (self *Server) newNetworkListener(network string, address string) (*net.Listener, error) {
10 | listener, err := net.Listen(network, address)
11 | if err != nil {
12 | self.Log.Criticalf("could not bind to address %s: %v", address, err)
13 | return nil, err
14 | }
15 |
16 | cert := os.Getenv("TLS_CERT")
17 | key := os.Getenv("TLS_KEY")
18 | if (cert != "") && (key != "") {
19 | cert, err := tls.X509KeyPair([]byte(cert), []byte(key))
20 | if err != nil {
21 | return nil, err
22 | }
23 | listener = tls.NewListener(listener, &tls.Config{
24 | Certificates: []tls.Certificate{cert},
25 | })
26 | }
27 |
28 | return &listener, nil
29 | }
30 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/server/run-tcp.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/tliron/commonlog"
5 | )
6 |
7 | func (self *Server) RunTCP(address string) error {
8 | listener, err := self.newNetworkListener("tcp", address)
9 | if err != nil {
10 | return err
11 | }
12 |
13 | log := commonlog.NewKeyValueLogger(self.Log, "address", address)
14 | defer commonlog.CallAndLogError((*listener).Close, "listener.Close", log)
15 | log.Notice("listening for TCP connections")
16 |
17 | var connectionCount uint64
18 |
19 | for {
20 | connection, err := (*listener).Accept()
21 | if err != nil {
22 | return err
23 | }
24 |
25 | connectionCount++
26 | connectionLog := commonlog.NewKeyValueLogger(log, "id", connectionCount)
27 |
28 | go self.ServeStream(connection, connectionLog)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/internal/pkg/server/rename.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/compose"
5 | "github.com/docker/docker-language-server/internal/pkg/document"
6 | "github.com/docker/docker-language-server/internal/tliron/glsp"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
8 | "go.lsp.dev/uri"
9 | )
10 |
11 | func (s *Server) TextDocumentRename(ctx *glsp.Context, params *protocol.RenameParams) (*protocol.WorkspaceEdit, error) {
12 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
13 | if err != nil {
14 | return nil, err
15 | }
16 | defer doc.Close()
17 | if doc.LanguageIdentifier() == protocol.DockerComposeLanguage && s.composeSupport {
18 | return compose.Rename(doc.(document.ComposeDocument), params)
19 | }
20 | return nil, nil
21 | }
22 |
--------------------------------------------------------------------------------
/internal/compose/rename.go:
--------------------------------------------------------------------------------
1 | package compose
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/pkg/document"
5 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
6 | )
7 |
8 | func Rename(doc document.ComposeDocument, params *protocol.RenameParams) (*protocol.WorkspaceEdit, error) {
9 | highlights, err := DocumentHighlight(doc, params.Position)
10 | if err != nil || len(highlights) == 0 {
11 | return nil, err
12 | }
13 |
14 | edits := []protocol.TextEdit{}
15 | for _, highlight := range highlights {
16 | edits = append(edits, protocol.TextEdit{
17 | NewText: params.NewName,
18 | Range: highlight.Range,
19 | })
20 | }
21 | return &protocol.WorkspaceEdit{
22 | Changes: map[protocol.DocumentUri][]protocol.TextEdit{
23 | params.TextDocument.URI: edits,
24 | },
25 | }, nil
26 | }
27 |
--------------------------------------------------------------------------------
/internal/pkg/server/prepareRename.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/compose"
5 | "github.com/docker/docker-language-server/internal/pkg/document"
6 | "github.com/docker/docker-language-server/internal/tliron/glsp"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
8 | "go.lsp.dev/uri"
9 | )
10 |
11 | func (s *Server) TextDocumentPrepareRename(ctx *glsp.Context, params *protocol.PrepareRenameParams) (any, error) {
12 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
13 | if err != nil {
14 | return nil, err
15 | }
16 | defer doc.Close()
17 | if doc.LanguageIdentifier() == protocol.DockerComposeLanguage && s.composeSupport {
18 | return compose.PrepareRename(doc.(document.ComposeDocument), params)
19 | }
20 | return nil, nil
21 | }
22 |
--------------------------------------------------------------------------------
/internal/pkg/server/inlineCompletion.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/bake/hcl"
5 | "github.com/docker/docker-language-server/internal/pkg/document"
6 | "github.com/docker/docker-language-server/internal/tliron/glsp"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
8 | "go.lsp.dev/uri"
9 | )
10 |
11 | func (s *Server) TextDocumentInlineCompletion(ctx *glsp.Context, params *protocol.InlineCompletionParams) (any, error) {
12 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
13 | if err != nil {
14 | return nil, err
15 | }
16 | defer doc.Close()
17 | if doc.LanguageIdentifier() == protocol.DockerBakeLanguage {
18 | return hcl.InlineCompletion(ctx.Context, params, s.docs, doc.(document.BakeHCLDocument))
19 | }
20 | return nil, nil
21 | }
22 |
--------------------------------------------------------------------------------
/internal/pkg/server/codeLens.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/bake/hcl"
5 | "github.com/docker/docker-language-server/internal/pkg/document"
6 | "github.com/docker/docker-language-server/internal/tliron/glsp"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
8 | "go.lsp.dev/uri"
9 | )
10 |
11 | func (s *Server) TextDocumentCodeLens(ctx *glsp.Context, params *protocol.CodeLensParams) ([]protocol.CodeLens, error) {
12 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
13 | if err != nil {
14 | return nil, err
15 | }
16 | defer doc.Close()
17 |
18 | if doc.LanguageIdentifier() == protocol.DockerBakeLanguage {
19 | return hcl.CodeLens(ctx.Context, string(params.TextDocument.URI), doc.(document.BakeHCLDocument))
20 | }
21 | return nil, nil
22 | }
23 |
--------------------------------------------------------------------------------
/internal/telemetry/types.go:
--------------------------------------------------------------------------------
1 | package telemetry
2 |
3 | // Event names should use underscores because they will be ingested into
4 | // Snowflake and then snakeCase becomes SNAKECASE which makes it a
5 | // little hard to read.
6 | const EventServerHeartbeat = "server_heartbeat"
7 | const EventServerUserAction = "server_user_action"
8 |
9 | const ServerHeartbeatTypeInitialized = "initialized"
10 | const ServerHeartbeatTypePanic = "panic"
11 |
12 | const ServerUserActionTypeCommandExecuted = "commandExecuted"
13 | const ServerUserActionTypeFileAnalyzed = "fileAnalyzed"
14 |
15 | type TelemetryPayload struct {
16 | Records []Record `json:"records"`
17 | }
18 |
19 | type Record struct {
20 | Event string `json:"event"`
21 | Source string `json:"source"`
22 | Timestamp int64 `json:"event_timestamp"`
23 | Properties map[string]any `json:"properties"`
24 | }
25 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/server/connections.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | contextpkg "context"
5 | "io"
6 |
7 | "github.com/sourcegraph/jsonrpc2"
8 | "github.com/tliron/commonlog"
9 | )
10 |
11 | func (self *Server) newStreamConnection(stream io.ReadWriteCloser) *jsonrpc2.Conn {
12 | handler := self.newHandler()
13 | connectionOptions := self.newConnectionOptions()
14 |
15 | context, cancel := contextpkg.WithTimeout(contextpkg.Background(), self.StreamTimeout)
16 | defer cancel()
17 |
18 | return jsonrpc2.NewConn(context, jsonrpc2.NewBufferedStream(stream, jsonrpc2.VSCodeObjectCodec{}), handler, connectionOptions...)
19 | }
20 |
21 | func (self *Server) newConnectionOptions() []jsonrpc2.ConnOpt {
22 | if self.Debug {
23 | log := commonlog.NewScopeLogger(self.Log, "rpc")
24 | return []jsonrpc2.ConnOpt{jsonrpc2.LogMessages(&JSONRPCLogger{log})}
25 | } else {
26 | return nil
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/internal/pkg/document/dockerfileDocument_test.go:
--------------------------------------------------------------------------------
1 | package document
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestParse(t *testing.T) {
10 | testCases := []struct {
11 | name string
12 | content string
13 | newContent string
14 | result bool
15 | }{
16 | {
17 | name: "flag added",
18 | content: "FROM scratch",
19 | newContent: "FROM --platform=abc scratch",
20 | result: true,
21 | },
22 | {
23 | name: "flag value changed",
24 | content: "FROM --platform=abc scratch",
25 | newContent: "FROM --platform=abcd scratch",
26 | result: true,
27 | },
28 | }
29 |
30 | for _, tc := range testCases {
31 | t.Run(tc.name, func(t *testing.T) {
32 | doc := NewDockerfileDocument("file:///tmp/Dockerfile", 1, []byte(tc.content))
33 | result := doc.Update(2, []byte(tc.newContent))
34 | require.Equal(t, tc.result, result)
35 | })
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/docker/docker-language-server/internal/tliron/glsp"
7 | "github.com/tliron/commonlog"
8 | )
9 |
10 | var DefaultTimeout = time.Minute
11 |
12 | //
13 | // Server
14 | //
15 |
16 | type Server struct {
17 | Handler glsp.Handler
18 | LogBaseName string
19 | Debug bool
20 |
21 | Log commonlog.Logger
22 | Timeout time.Duration
23 | ReadTimeout time.Duration
24 | WriteTimeout time.Duration
25 | StreamTimeout time.Duration
26 | }
27 |
28 | func NewServer(handler glsp.Handler, logName string, debug bool) *Server {
29 | return &Server{
30 | Handler: handler,
31 | LogBaseName: logName,
32 | Debug: debug,
33 | Log: commonlog.GetLogger(logName),
34 | Timeout: DefaultTimeout,
35 | ReadTimeout: DefaultTimeout,
36 | WriteTimeout: DefaultTimeout,
37 | StreamTimeout: DefaultTimeout,
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/e2e-tests/json_test.go:
--------------------------------------------------------------------------------
1 | package server_test
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | // requireJsonEqual simplifies comparing two objects after marshaling them to JSON.
11 | //
12 | // In particular, this simplifies comparisons across some of the protocol types,
13 | // which were primarily designed for one-way marshaling, and so use
14 | // `any` for some fields that can (presumably?) accept multiple types,
15 | // which means the unmarshalled versions won't compare properly to hand-crafted
16 | // test objects.
17 | func requireJsonEqual(t testing.TB, expected, actual any) {
18 | t.Helper()
19 |
20 | expectedJSON, err := json.Marshal(expected)
21 | require.NoError(t, err, "Could not marshal expected object")
22 |
23 | actualJSON, err := json.Marshal(actual)
24 | require.NoError(t, err, "Could not marshal actual object")
25 |
26 | require.JSONEq(t, string(expectedJSON), string(actualJSON))
27 | }
28 |
--------------------------------------------------------------------------------
/internal/pkg/server/semanticTokens.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/bake/hcl"
5 | "github.com/docker/docker-language-server/internal/pkg/document"
6 | "github.com/docker/docker-language-server/internal/tliron/glsp"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
8 | "go.lsp.dev/uri"
9 | )
10 |
11 | func (s *Server) TextDocumentSemanticTokensFull(ctx *glsp.Context, params *protocol.SemanticTokensParams) (*protocol.SemanticTokens, error) {
12 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
13 | if err != nil {
14 | return nil, err
15 | }
16 | defer doc.Close()
17 | if doc.LanguageIdentifier() == protocol.DockerBakeLanguage {
18 | result, err := hcl.SemanticTokensFull(ctx.Context, doc.(document.BakeHCLDocument), string(params.TextDocument.URI))
19 | if err != nil {
20 | return nil, err
21 | }
22 | return &protocol.SemanticTokens{Data: result.Data}, nil
23 | }
24 |
25 | return nil, nil
26 | }
27 |
--------------------------------------------------------------------------------
/CLIENTS.md:
--------------------------------------------------------------------------------
1 | # Language Clients
2 |
3 | The Docker Language Server can be plugged into any editor that supports the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/). If you have successfully configured the Docker Language Server with an editor that is not mentioned here, please document the steps and then open a pull request. We would be happy to review your steps and add it to the list below!
4 |
5 | ## Maintained Clients
6 |
7 | ### Visual Studio Code
8 |
9 | The Docker Language Server maintains and develops the Docker DX ([Visual Studio Code extension](https://marketplace.visualstudio.com/items?itemName=docker.docker)).
10 |
11 | ## Other Clients
12 |
13 | ### JetBrains
14 |
15 | To connect a JetBrains IDE with the Docker Language Server, you can use the [LSP4IJ](https://plugins.jetbrains.com/plugin/23257-lsp4ij) plugin from Red Hat. It is an open source LSP client for JetBrains IDEs. Follow the documentation [here](https://github.com/redhat-developer/lsp4ij/blob/main/docs/user-defined-ls/docker-language-server.md) to get started.
16 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build
2 | build:
3 | ./build.sh
4 |
5 | .PHONY: test
6 | test:
7 | gotestsum -- $$(go list ./... | grep -v e2e-tests) -timeout 30s
8 | go test $$(go list ./... | grep e2e-tests) -timeout 240s
9 |
10 | .PHONY: build-docker-test
11 | build-docker-test:
12 | docker build -t docker/lsp:test --target test .
13 |
14 | .PHONY: test-docker
15 | test-docker: build-docker-test
16 | docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker/lsp:test make test
17 |
18 | .PHONY: test-docker-disconnected
19 | test-docker-disconnected: build-docker-test
20 | docker run -e DOCKER_NETWORK_NONE=true --rm -v /var/run/docker.sock:/var/run/docker.sock --network none docker/lsp:test make test
21 |
22 | .PHONY: lint
23 | lint:
24 | golangci-lint run
25 |
26 | .PHONY: install
27 | install:
28 | go install ./cmd/docker-language-server
29 |
30 | .PHONY: vendor
31 | vendor:
32 | docker buildx bake vendor
33 |
34 | .PHONY: validate-vendor
35 | validate-vendor:
36 | docker buildx bake vendor-validate
37 |
38 | .PHONY: validate-all
39 | validate-all: lint validate-vendor
40 |
--------------------------------------------------------------------------------
/internal/pkg/server/formatting.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/bake/hcl"
5 | "github.com/docker/docker-language-server/internal/compose"
6 | "github.com/docker/docker-language-server/internal/pkg/document"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp"
8 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
9 | "go.lsp.dev/uri"
10 | )
11 |
12 | func (s *Server) TextDocumentFormatting(ctx *glsp.Context, params *protocol.DocumentFormattingParams) ([]protocol.TextEdit, error) {
13 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
14 | if err != nil {
15 | return nil, err
16 | }
17 | defer doc.Close()
18 | if doc.LanguageIdentifier() == protocol.DockerComposeLanguage && s.composeSupport {
19 | return compose.Formatting(doc.(document.ComposeDocument), params.Options)
20 | } else if doc.LanguageIdentifier() == protocol.DockerBakeLanguage {
21 | return hcl.Formatting(doc.(document.BakeHCLDocument), params.Options)
22 | }
23 | return nil, nil
24 | }
25 |
--------------------------------------------------------------------------------
/internal/pkg/server/documentHighlight.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/bake/hcl"
5 | "github.com/docker/docker-language-server/internal/compose"
6 | "github.com/docker/docker-language-server/internal/pkg/document"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp"
8 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
9 | "go.lsp.dev/uri"
10 | )
11 |
12 | func (s *Server) TextDocumentDocumentHighlight(ctx *glsp.Context, params *protocol.DocumentHighlightParams) ([]protocol.DocumentHighlight, error) {
13 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
14 | if err != nil {
15 | return nil, err
16 | }
17 | defer doc.Close()
18 | if doc.LanguageIdentifier() == protocol.DockerBakeLanguage {
19 | return hcl.DocumentHighlight(doc.(document.BakeHCLDocument), params.Position)
20 | } else if doc.LanguageIdentifier() == protocol.DockerComposeLanguage && s.composeSupport {
21 | return compose.DocumentHighlight(doc.(document.ComposeDocument), params.Position)
22 | }
23 | return nil, nil
24 | }
25 |
--------------------------------------------------------------------------------
/internal/pkg/server/documentSymbol.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/bake/hcl"
5 | "github.com/docker/docker-language-server/internal/compose"
6 | "github.com/docker/docker-language-server/internal/pkg/document"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp"
8 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
9 | "go.lsp.dev/uri"
10 | )
11 |
12 | func (s *Server) TextDocumentDocumentSymbol(ctx *glsp.Context, params *protocol.DocumentSymbolParams) (any, error) {
13 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
14 | if err != nil {
15 | return nil, err
16 | }
17 | defer doc.Close()
18 | language := doc.LanguageIdentifier()
19 | if language == protocol.DockerBakeLanguage {
20 | return hcl.DocumentSymbol(ctx.Context, string(params.TextDocument.URI), doc.(document.BakeHCLDocument))
21 | } else if language == protocol.DockerComposeLanguage && s.composeSupport {
22 | return compose.DocumentSymbol(ctx.Context, doc.(document.ComposeDocument))
23 | }
24 |
25 | return nil, nil
26 | }
27 |
--------------------------------------------------------------------------------
/internal/pkg/server/completion.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/bake/hcl"
5 | "github.com/docker/docker-language-server/internal/compose"
6 | "github.com/docker/docker-language-server/internal/pkg/document"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp"
8 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
9 | "go.lsp.dev/uri"
10 | )
11 |
12 | func (s *Server) TextDocumentCompletion(ctx *glsp.Context, params *protocol.CompletionParams) (any, error) {
13 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
14 | if err != nil {
15 | return nil, err
16 | }
17 | defer doc.Close()
18 |
19 | if doc.LanguageIdentifier() == protocol.DockerBakeLanguage {
20 | return hcl.Completion(ctx.Context, params, s.docs, doc.(document.BakeHCLDocument))
21 | } else if doc.LanguageIdentifier() == protocol.DockerComposeLanguage && s.composeSupport && s.composeCompletion {
22 | return compose.Completion(ctx.Context, params, s.docs, s.hubService, doc.(document.ComposeDocument))
23 | }
24 | return nil, nil
25 | }
26 |
--------------------------------------------------------------------------------
/internal/pkg/server/documentLink.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/bake/hcl"
5 | "github.com/docker/docker-language-server/internal/compose"
6 | "github.com/docker/docker-language-server/internal/pkg/document"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp"
8 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
9 | "go.lsp.dev/uri"
10 | )
11 |
12 | func (s *Server) TextDocumentDocumentLink(ctx *glsp.Context, params *protocol.DocumentLinkParams) ([]protocol.DocumentLink, error) {
13 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
14 | if err != nil {
15 | return nil, err
16 | }
17 | defer doc.Close()
18 | language := doc.LanguageIdentifier()
19 | if language == protocol.DockerBakeLanguage {
20 | return hcl.DocumentLink(ctx.Context, params.TextDocument.URI, doc.(document.BakeHCLDocument))
21 | } else if language == protocol.DockerComposeLanguage && s.composeSupport {
22 | return compose.DocumentLink(ctx.Context, params.TextDocument.URI, doc.(document.ComposeDocument))
23 | }
24 | return nil, nil
25 | }
26 |
--------------------------------------------------------------------------------
/internal/pkg/server/definition.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/bake/hcl"
5 | "github.com/docker/docker-language-server/internal/compose"
6 | "github.com/docker/docker-language-server/internal/pkg/document"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp"
8 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
9 | "go.lsp.dev/uri"
10 | )
11 |
12 | func (s *Server) TextDocumentDefinition(ctx *glsp.Context, params *protocol.DefinitionParams) (any, error) {
13 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
14 | if err != nil {
15 | return nil, err
16 | }
17 | defer doc.Close()
18 |
19 | if doc.LanguageIdentifier() == protocol.DockerBakeLanguage {
20 | return hcl.Definition(ctx.Context, s.definitionLinkSupport, s.docs, uri.URI(params.TextDocument.URI), doc.(document.BakeHCLDocument), params.Position)
21 | } else if doc.LanguageIdentifier() == protocol.DockerComposeLanguage && s.composeSupport {
22 | return compose.Definition(ctx.Context, s.definitionLinkSupport, doc.(document.ComposeDocument), params)
23 | }
24 | return nil, nil
25 | }
26 |
--------------------------------------------------------------------------------
/internal/cache/manager.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import (
4 | "sync"
5 | "time"
6 | )
7 |
8 | type Key interface {
9 | CacheKey() string
10 | }
11 |
12 | type Fetcher[T any] interface {
13 | Fetch(key Key) (T, error)
14 | }
15 |
16 | type CacheManager[T any] interface {
17 | Get(key Key) (T, error)
18 | }
19 |
20 | type CacheManagerImpl[T any] struct {
21 | mutex sync.Mutex
22 | cache map[string]T
23 | fetcher Fetcher[T]
24 | }
25 |
26 | func NewManager[T any](fetcher Fetcher[T]) CacheManager[T] {
27 | return &CacheManagerImpl[T]{
28 | cache: make(map[string]T),
29 | fetcher: fetcher,
30 | }
31 | }
32 |
33 | func (c *CacheManagerImpl[T]) Get(key Key) (T, error) {
34 | c.mutex.Lock()
35 | defer c.mutex.Unlock()
36 |
37 | cacheKey := key.CacheKey()
38 | if val, exists := c.cache[cacheKey]; exists {
39 | return val, nil
40 | }
41 |
42 | fetched, err := c.fetcher.Fetch(key)
43 | if err == nil {
44 | c.cache[cacheKey] = fetched
45 | // auto-expire after one hour
46 | time.AfterFunc(1*time.Hour, func() {
47 | c.mutex.Lock()
48 | defer c.mutex.Unlock()
49 | delete(c.cache, cacheKey)
50 | })
51 | }
52 | return fetched, err
53 | }
54 |
--------------------------------------------------------------------------------
/internal/types/types.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
4 |
5 | type NamedEdit struct {
6 | Title string `json:"title"`
7 | Edit string `json:"edit"`
8 | Range *protocol.Range `json:"range,omitempty"`
9 | }
10 |
11 | func CreateDiagnosticSeverityPointer(ds protocol.DiagnosticSeverity) *protocol.DiagnosticSeverity {
12 | return &ds
13 | }
14 |
15 | func CreateAnyPointer(a any) *any {
16 | return &a
17 | }
18 |
19 | func CreateBoolPointer(b bool) *bool {
20 | return &b
21 | }
22 |
23 | func CreateInt32Pointer(i int32) *int32 {
24 | return &i
25 | }
26 |
27 | func CreateStringPointer(s string) *string {
28 | return &s
29 | }
30 |
31 | func CreateDocumentHighlightKindPointer(k protocol.DocumentHighlightKind) *protocol.DocumentHighlightKind {
32 | return &k
33 | }
34 |
35 | func CreateCompletionItemKindPointer(k protocol.CompletionItemKind) *protocol.CompletionItemKind {
36 | return &k
37 | }
38 |
39 | func CreateInsertTextFormatPointer(f protocol.InsertTextFormat) *protocol.InsertTextFormat {
40 | return &f
41 | }
42 |
43 | func CreateInsertTextModePointer(m protocol.InsertTextMode) *protocol.InsertTextMode {
44 | return &m
45 | }
46 |
--------------------------------------------------------------------------------
/internal/pkg/server/inlayHint.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/bake/hcl"
5 | "github.com/docker/docker-language-server/internal/compose"
6 | "github.com/docker/docker-language-server/internal/dockerfile"
7 | "github.com/docker/docker-language-server/internal/pkg/document"
8 | "github.com/docker/docker-language-server/internal/tliron/glsp"
9 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
10 | "go.lsp.dev/uri"
11 | )
12 |
13 | func (s *Server) TextDocumentInlayHint(ctx *glsp.Context, params *protocol.InlayHintParams) (any, error) {
14 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
15 | if err != nil {
16 | return nil, err
17 | }
18 | defer doc.Close()
19 | if doc.LanguageIdentifier() == protocol.DockerComposeLanguage && s.composeSupport {
20 | return compose.InlayHint(doc.(document.ComposeDocument), params.Range)
21 | } else if doc.LanguageIdentifier() == protocol.DockerBakeLanguage {
22 | return hcl.InlayHint(s.docs, doc.(document.BakeHCLDocument), params.Range)
23 | } else if doc.LanguageIdentifier() == protocol.DockerfileLanguage {
24 | return dockerfile.InlayHint(*s.hubService, doc.(document.DockerfileDocument), params.Range)
25 | }
26 | return nil, nil
27 | }
28 |
--------------------------------------------------------------------------------
/internal/pkg/server/didChangeConfiguration.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/configuration"
5 | "github.com/docker/docker-language-server/internal/tliron/glsp"
6 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
7 | )
8 |
9 | func (s *Server) WorkspaceDidChangeConfiguration(ctx *glsp.Context, params *protocol.DidChangeConfigurationParams) error {
10 | changedSettings, _ := params.Settings.([]any)
11 | scoutConfigurationChanged := false
12 | for _, setting := range changedSettings {
13 | config := setting.(string)
14 | switch config {
15 | case configuration.ConfigTelemetry:
16 | go s.FetchUnscopedConfiguration()
17 | case configuration.ConfigExperimentalVulnerabilityScanning:
18 | fallthrough
19 | case configuration.ConfigExperimentalScoutCriticalHighVulnerabilities:
20 | fallthrough
21 | case configuration.ConfigExperimentalScoutNotPinnedDigest:
22 | fallthrough
23 | case configuration.ConfigExperimentalScoutRecommendedTag:
24 | fallthrough
25 | case configuration.ConfigExperimentalScoutVulnerabilities:
26 | scoutConfigurationChanged = true
27 | }
28 | }
29 |
30 | if scoutConfigurationChanged {
31 | scopes := configuration.Documents()
32 | if len(scopes) > 0 {
33 | go func() {
34 | defer s.handlePanic("WorkspaceDidChangeConfiguration")
35 |
36 | s.FetchConfigurations(scopes)
37 | s.recomputeDiagnostics()
38 | }()
39 | }
40 | }
41 | return nil
42 | }
43 |
--------------------------------------------------------------------------------
/internal/pkg/server/hover.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "errors"
5 | "strings"
6 |
7 | "github.com/docker/docker-language-server/internal/bake/hcl"
8 | "github.com/docker/docker-language-server/internal/compose"
9 | "github.com/docker/docker-language-server/internal/pkg/document"
10 | "github.com/docker/docker-language-server/internal/tliron/glsp"
11 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
12 | "go.lsp.dev/uri"
13 | )
14 |
15 | func (s *Server) TextDocumentHover(ctx *glsp.Context, params *protocol.HoverParams) (*protocol.Hover, error) {
16 | doc, err := s.docs.Read(ctx.Context, uri.URI(params.TextDocument.URI))
17 | if err != nil {
18 | return nil, err
19 | }
20 | defer doc.Close()
21 | switch doc.LanguageIdentifier() {
22 | case protocol.DockerBakeLanguage:
23 | return hcl.Hover(ctx.Context, params, doc.(document.BakeHCLDocument))
24 | case protocol.DockerComposeLanguage:
25 | if s.composeSupport {
26 | return compose.Hover(ctx.Context, params, doc.(document.ComposeDocument))
27 | }
28 | return nil, nil
29 | case protocol.DockerfileLanguage:
30 | instruction := doc.(document.DockerfileDocument).Instruction(params.Position)
31 | if instruction != nil && strings.EqualFold(instruction.Value, "FROM") && instruction.Next != nil {
32 | return s.scoutService.Hover(ctx.Context, params.TextDocument.URI, instruction.Next.Value)
33 | }
34 | return nil, nil
35 | }
36 | return nil, errors.New("URI did not map to a recognized document")
37 | }
38 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax=docker/dockerfile:1
2 |
3 | ARG GO_VERSION="1.25"
4 | ARG ALPINE_VERSION="3.22"
5 |
6 | FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS base
7 | ENV CGO_ENABLED=0
8 | RUN apk add --no-cache file git rsync make
9 | WORKDIR /src
10 |
11 | FROM base AS build-base
12 | COPY go.* .
13 | RUN --mount=type=cache,target=/go/pkg/mod \
14 | --mount=type=cache,target=/root/.cache/go-build \
15 | go mod download
16 |
17 | FROM build-base AS vendored
18 | RUN --mount=type=bind,target=.,rw \
19 | --mount=type=cache,target=/go/pkg/mod \
20 | go mod tidy && mkdir /out && cp go.mod go.sum /out
21 |
22 | FROM scratch AS vendor-update
23 | COPY --from=vendored /out /
24 |
25 | FROM vendored AS vendor-validate
26 | RUN --mount=type=bind,target=.,rw <&2 'ERROR: Vendor result differs. Please vendor your package with "make vendor"'
33 | echo "$diff"
34 | exit 1
35 | fi
36 | EOT
37 |
38 | FROM base AS test
39 | RUN < 0 {
22 | name = blockSymbol.Labels[0]
23 | }
24 | kind := protocol.SymbolKindFunction
25 | if blockSymbol.Type == "variable" {
26 | kind = protocol.SymbolKindVariable
27 | }
28 | result = append(result, createSymbol(name, kind, symbolRange))
29 | } else if symbol, ok := symbol.(*decoder.AttributeSymbol); ok {
30 | result = append(result, createSymbol(symbol.AttrName, protocol.SymbolKindProperty, symbolRange))
31 | }
32 | }
33 | return result, nil
34 | }
35 |
36 | func createSymbol(name string, kind protocol.SymbolKind, rng hcl.Range) *protocol.DocumentSymbol {
37 | return &protocol.DocumentSymbol{
38 | Name: name,
39 | Kind: kind,
40 | Range: protocol.Range{
41 | Start: protocol.Position{
42 | Line: uint32(rng.Start.Line - 1),
43 | Character: 0,
44 | },
45 | End: protocol.Position{
46 | Line: uint32(rng.Start.Line - 1),
47 | Character: 0,
48 | },
49 | },
50 | SelectionRange: protocol.Range{
51 | Start: protocol.Position{
52 | Line: uint32(rng.Start.Line - 1),
53 | Character: 0,
54 | },
55 | End: protocol.Position{
56 | Line: uint32(rng.Start.Line - 1),
57 | Character: 0,
58 | },
59 | },
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | The maintainers of the Docker Language Server project take security seriously.
4 | If you discover a security issue, please bring it to their attention right away!
5 |
6 | ## Reporting a Vulnerability
7 |
8 | Please **DO NOT** file a public issue, instead send your report privately
9 | to [security@docker.com](mailto:security@docker.com).
10 |
11 | Reporter(s) can expect a response within 72 hours, acknowledging the issue was
12 | received.
13 |
14 | ## Review Process
15 |
16 | After receiving the report, an initial triage and technical analysis is
17 | performed to confirm the report and determine its scope. We may request
18 | additional information in this stage of the process.
19 |
20 | Once a reviewer has confirmed the relevance of the report, a draft security
21 | advisory will be created on GitHub. The draft advisory will be used to discuss
22 | the issue with maintainers, the reporter(s), and where applicable, other
23 | affected parties under embargo.
24 |
25 | If the vulnerability is accepted, a timeline for developing a patch, public
26 | disclosure, and patch release will be determined. If there is an embargo period
27 | on public disclosure before the patch release, the reporter(s) are expected to
28 | participate in the discussion of the timeline and abide by agreed upon dates
29 | for public disclosure.
30 |
31 | ## Accreditation
32 |
33 | Security reports are greatly appreciated and we will publicly thank you,
34 | although we will keep your name confidential if you request it. We also like to
35 | send gifts - if you're into swag, make sure to let us know. We do not currently
36 | offer a paid security bounty program at this time.
37 |
38 | ## Further Information
39 |
40 | Should anything in this document be unclear or if you are looking for additional
41 | information about how Docker reviews and responds to security vulnerabilities,
42 | please take a look at Docker's
43 | [Vulnerability Disclosure Policy](https://www.docker.com/trust/vulnerability-disclosure-policy/).
44 |
--------------------------------------------------------------------------------
/internal/pkg/server/codeAction.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "encoding/json"
5 |
6 | "github.com/docker/docker-language-server/internal/telemetry"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp"
8 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
9 | "github.com/docker/docker-language-server/internal/types"
10 | )
11 |
12 | func (s *Server) TextDocumentCodeAction(ctx *glsp.Context, params *protocol.CodeActionParams) (any, error) {
13 | actions := []protocol.CodeAction{}
14 | for _, diagnostic := range params.Context.Diagnostics {
15 | bytes, _ := json.Marshal(diagnostic.Data)
16 | edits := []*types.NamedEdit{}
17 | _ = json.Unmarshal(bytes, &edits)
18 | if len(edits) > 0 {
19 | for _, edit := range edits {
20 | editRange := protocol.Range{
21 | Start: protocol.Position{
22 | Line: diagnostic.Range.Start.Line,
23 | Character: diagnostic.Range.Start.Character,
24 | },
25 | End: protocol.Position{
26 | Line: diagnostic.Range.End.Line,
27 | Character: diagnostic.Range.End.Character,
28 | },
29 | }
30 | if edit.Range != nil {
31 | editRange = *edit.Range
32 | }
33 | action := protocol.CodeAction{
34 | Title: edit.Title,
35 | Edit: &protocol.WorkspaceEdit{
36 | Changes: map[string][]protocol.TextEdit{
37 | params.TextDocument.URI: {
38 | protocol.TextEdit{
39 | NewText: edit.Edit,
40 | Range: editRange,
41 | },
42 | },
43 | },
44 | },
45 | Command: &protocol.Command{
46 | Command: types.TelemetryCallbackCommandId,
47 | Arguments: []any{
48 | telemetry.EventServerUserAction,
49 | map[string]any{
50 | "action": telemetry.ServerUserActionTypeCommandExecuted,
51 | "action_id": types.CodeActionDiagnosticCommandId,
52 | "diagnostic": diagnostic.Code,
53 | },
54 | },
55 | },
56 | }
57 | actions = append(actions, action)
58 | }
59 | }
60 | }
61 |
62 | return actions, nil
63 | }
64 |
--------------------------------------------------------------------------------
/releaser/main_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "testing"
7 | "time"
8 |
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestUpdateChangelog(t *testing.T) {
13 | // relative to releaser directory, going up to repository root)
14 | inputPath := "../testdata/releaser/CHANGELOG.md"
15 | resultPath := "../testdata/releaser/CHANGELOG.result.md"
16 | expectedPath := "../testdata/releaser/CHANGELOG.expected.md"
17 |
18 | err := updateChangelog(inputPath, resultPath, "minor")
19 | require.NoError(t, err, "updateChangeLog failed")
20 |
21 | resultLines, err := readFileLines(resultPath)
22 | require.NoError(t, err, "failed to read expected file: %v", resultPath)
23 |
24 | expectedLines, err := readFileLines(expectedPath)
25 | require.NoError(t, err, "failed to read expected file: %v", expectedPath)
26 |
27 | today := time.Now().Format("2006-01-02")
28 | expectedVersionLine := fmt.Sprintf("## [0.16.0] - %s", today)
29 |
30 | for i := range resultLines {
31 | // Special handling for the version header line with date
32 | if strings.HasPrefix(expectedLines[i], "## [0.16.0] - 2025-08-08") {
33 | require.Equal(t, expectedVersionLine, resultLines[i])
34 | } else {
35 | require.Equal(t, expectedLines[i], resultLines[i])
36 | }
37 | }
38 |
39 | require.Equal(t, len(expectedLines), len(resultLines), "files have different number of lines")
40 | }
41 |
42 | func TestGenerateReleaseNotes(t *testing.T) {
43 | // relative to releaser directory, going up to repository root)
44 | changelogPath := "../testdata/releaser/CHANGELOG.md"
45 | expectedPath := "../testdata/releaser/RELEASE.expected.md"
46 |
47 | expectedLines, err := readFileLines(expectedPath)
48 | require.NoError(t, err, "failed to read expected file: %v", expectedPath)
49 |
50 | result, err := generateReleaseNotes(changelogPath)
51 | require.NoError(t, err, "generateReleaseNotes failed")
52 |
53 | resultLines := strings.Split(result, "\n")
54 | for i := range resultLines {
55 | require.Equal(t, expectedLines[i], resultLines[i])
56 | }
57 | require.Equal(t, len(expectedLines), len(resultLines), "files have different number of lines")
58 | }
59 |
--------------------------------------------------------------------------------
/e2e-tests/documentSymbol_test.go:
--------------------------------------------------------------------------------
1 | package server_test
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "os"
7 | "testing"
8 |
9 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
10 | "github.com/sourcegraph/jsonrpc2"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func TestDocumentSymbol(t *testing.T) {
15 | s := startServer()
16 |
17 | client := bytes.NewBuffer(make([]byte, 0, 1024))
18 | server := bytes.NewBuffer(make([]byte, 0, 1024))
19 | serverStream := &TestStream{incoming: server, outgoing: client, closed: false}
20 | defer serverStream.Close()
21 | go s.ServeStream(serverStream)
22 |
23 | clientStream := jsonrpc2.NewBufferedStream(&TestStream{incoming: client, outgoing: server, closed: false}, jsonrpc2.VSCodeObjectCodec{})
24 | defer clientStream.Close()
25 | conn := jsonrpc2.NewConn(context.Background(), clientStream, &ConfigurationHandler{t: t})
26 | initialize(t, conn, protocol.InitializeParams{})
27 |
28 | homedir, err := os.UserHomeDir()
29 | require.NoError(t, err)
30 |
31 | testCases := []struct {
32 | name string
33 | content string
34 | symbols []*protocol.DocumentSymbol
35 | }{
36 | {
37 | name: "target \"api\" {}",
38 | content: "target \"api\" {}",
39 | symbols: []*protocol.DocumentSymbol{
40 | {
41 | Name: "api",
42 | Kind: protocol.SymbolKindFunction,
43 | Range: protocol.Range{
44 | Start: protocol.Position{Line: 0, Character: 0},
45 | End: protocol.Position{Line: 0, Character: 0},
46 | },
47 | },
48 | },
49 | },
50 | }
51 |
52 | for _, tc := range testCases {
53 | t.Run(tc.name, func(t *testing.T) {
54 | didOpen := createDidOpenTextDocumentParams(homedir, t.Name()+".hcl", tc.content, "dockerbake")
55 | err := conn.Notify(context.Background(), protocol.MethodTextDocumentDidOpen, didOpen)
56 | require.NoError(t, err)
57 |
58 | var symbols []*protocol.DocumentSymbol
59 | err = conn.Call(context.Background(), protocol.MethodTextDocumentDocumentSymbol, protocol.DocumentSymbolParams{
60 | TextDocument: protocol.TextDocumentIdentifier{URI: didOpen.TextDocument.URI},
61 | }, &symbols)
62 | require.NoError(t, err)
63 | require.Equal(t, tc.symbols, symbols)
64 | })
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/e2e-tests/formatting_test.go:
--------------------------------------------------------------------------------
1 | package server_test
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "os"
7 | "testing"
8 |
9 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
10 | "github.com/sourcegraph/jsonrpc2"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func TestFormatting(t *testing.T) {
15 | s := startServer()
16 |
17 | client := bytes.NewBuffer(make([]byte, 0, 1024))
18 | server := bytes.NewBuffer(make([]byte, 0, 1024))
19 | serverStream := &TestStream{incoming: server, outgoing: client, closed: false}
20 | defer serverStream.Close()
21 | go s.ServeStream(serverStream)
22 |
23 | clientStream := jsonrpc2.NewBufferedStream(&TestStream{incoming: client, outgoing: server, closed: false}, jsonrpc2.VSCodeObjectCodec{})
24 | defer clientStream.Close()
25 | conn := jsonrpc2.NewConn(context.Background(), clientStream, &ConfigurationHandler{t: t})
26 | initialize(t, conn, protocol.InitializeParams{})
27 |
28 | homedir, err := os.UserHomeDir()
29 | require.NoError(t, err)
30 |
31 | testCases := []struct {
32 | name string
33 | content string
34 | edits []protocol.TextEdit
35 | }{
36 | {
37 | name: "truncate whitespace before a block type",
38 | content: " target t {\n}",
39 | edits: []protocol.TextEdit{
40 | {
41 | NewText: "",
42 | Range: protocol.Range{
43 | Start: protocol.Position{Line: 0, Character: 0},
44 | End: protocol.Position{Line: 0, Character: 1},
45 | },
46 | },
47 | },
48 | },
49 | }
50 |
51 | for _, tc := range testCases {
52 | t.Run(tc.name, func(t *testing.T) {
53 | didOpen := createDidOpenTextDocumentParams(homedir, t.Name()+".hcl", tc.content, "dockerbake")
54 | err := conn.Notify(context.Background(), protocol.MethodTextDocumentDidOpen, didOpen)
55 | require.NoError(t, err)
56 |
57 | var edits []protocol.TextEdit
58 | err = conn.Call(context.Background(), protocol.MethodTextDocumentFormatting, protocol.HoverParams{
59 | TextDocumentPositionParams: protocol.TextDocumentPositionParams{
60 | TextDocument: protocol.TextDocumentIdentifier{URI: didOpen.TextDocument.URI},
61 | Position: protocol.Position{Line: 0, Character: 3},
62 | },
63 | }, &edits)
64 | require.NoError(t, err)
65 | require.Equal(t, tc.edits, edits)
66 | })
67 | }
68 |
69 | }
70 |
--------------------------------------------------------------------------------
/internal/bake/hcl/codeLens.go:
--------------------------------------------------------------------------------
1 | package hcl
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 |
8 | "github.com/docker/docker-language-server/internal/pkg/document"
9 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
10 | "github.com/docker/docker-language-server/internal/types"
11 | "github.com/hashicorp/hcl/v2/hclsyntax"
12 | )
13 |
14 | func CodeLens(ctx context.Context, documentURI string, doc document.BakeHCLDocument) ([]protocol.CodeLens, error) {
15 | body, ok := doc.File().Body.(*hclsyntax.Body)
16 | if !ok {
17 | return nil, errors.New("unrecognized body in HCL document")
18 | }
19 |
20 | dp, err := doc.DocumentPath()
21 | if err != nil {
22 | return nil, fmt.Errorf("could not parse URI (%v): %w", documentURI, err)
23 | }
24 |
25 | _, cwd := types.Concatenate(dp.Folder, ".", dp.WSLDollarSignHost)
26 | result := []protocol.CodeLens{}
27 | for _, block := range body.Blocks {
28 | if len(block.Labels) > 0 {
29 | switch block.Type {
30 | case "group":
31 | rng := protocol.Range{
32 | Start: protocol.Position{Line: uint32(block.Range().Start.Line - 1)},
33 | End: protocol.Position{Line: uint32(block.Range().Start.Line - 1)},
34 | }
35 | result = append(result, createCodeLens("Build", cwd, "build", block.Labels[0], rng))
36 | result = append(result, createCodeLens("Check", cwd, "check", block.Labels[0], rng))
37 | result = append(result, createCodeLens("Print", cwd, "print", block.Labels[0], rng))
38 | case "target":
39 | rng := protocol.Range{
40 | Start: protocol.Position{Line: uint32(block.Range().Start.Line - 1)},
41 | End: protocol.Position{Line: uint32(block.Range().Start.Line - 1)},
42 | }
43 | result = append(result, createCodeLens("Build", cwd, "build", block.Labels[0], rng))
44 | result = append(result, createCodeLens("Check", cwd, "check", block.Labels[0], rng))
45 | result = append(result, createCodeLens("Print", cwd, "print", block.Labels[0], rng))
46 | }
47 | }
48 | }
49 | return result, nil
50 | }
51 |
52 | func createCodeLens(title, cwd, call, target string, rng protocol.Range) protocol.CodeLens {
53 | return protocol.CodeLens{
54 | Range: rng,
55 | Command: &protocol.Command{
56 | Title: title,
57 | Command: types.BakeBuildCommandId,
58 | Arguments: []any{
59 | map[string]string{
60 | "call": call,
61 | "target": target,
62 | "cwd": cwd,
63 | },
64 | },
65 | },
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/internal/pkg/cli/cli.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "os"
7 | "os/signal"
8 | "strings"
9 |
10 | "github.com/spf13/cobra"
11 |
12 | "github.com/docker/docker-language-server/internal/pkg/cli/metadata"
13 | )
14 |
15 | var logLevel slog.LevelVar
16 |
17 | type RootCmd struct {
18 | *cobra.Command
19 | debug bool
20 | verbose bool
21 | }
22 |
23 | // Creates a new RootCmd
24 | // params:
25 | //
26 | // commandName: what to call the base command in examples (e.g., "docker-language-server")
27 | func NewRootCmd(commandName string) *RootCmd {
28 | cmd := RootCmd{
29 | Command: &cobra.Command{
30 | Use: commandName,
31 | Short: "Language server for Docker",
32 | Version: metadata.Version,
33 | },
34 | }
35 |
36 | cmd.PersistentFlags().BoolVar(&cmd.debug, "debug", false, "Enable debug logging")
37 | cmd.PersistentFlags().BoolVar(&cmd.verbose, "verbose", false, "Enable verbose logging")
38 |
39 | cmd.PersistentPreRun = func(cc *cobra.Command, args []string) {
40 | if cmd.debug {
41 | logLevel.Set(slog.LevelDebug)
42 | } else if cmd.verbose {
43 | logLevel.Set(slog.LevelInfo)
44 | }
45 | }
46 |
47 | cmd.AddCommand(newStartCmd(commandName).Command)
48 |
49 | return &cmd
50 | }
51 |
52 | func Execute() {
53 | logLevel.Set(slog.LevelError)
54 |
55 | logger := slog.New(slog.NewJSONHandler(
56 | os.Stderr,
57 | &slog.HandlerOptions{
58 | AddSource: true,
59 | Level: &logLevel,
60 | },
61 | ))
62 | ctx, cancel := context.WithCancel(context.Background())
63 | defer cancel()
64 | setupSignalHandler(cancel)
65 |
66 | err := NewRootCmd("docker-language-server").ExecuteContext(ctx)
67 | if err != nil {
68 | if !isCobraError(err) {
69 | logger.Error("fatal error", "error", err)
70 | }
71 | os.Exit(1)
72 | }
73 | }
74 |
75 | func setupSignalHandler(cancel context.CancelFunc) {
76 | c := make(chan os.Signal, 1)
77 | signal.Notify(c, os.Interrupt)
78 | go func() {
79 | for sig := range c {
80 | if sig == os.Interrupt {
81 | // TODO(milas): give open conns a grace period to close gracefully
82 | cancel()
83 | os.Exit(0)
84 | }
85 | }
86 | }()
87 | }
88 |
89 | func isCobraError(err error) bool {
90 | // Cobra doesn't give us a good way to distinguish between Cobra errors
91 | // (e.g. invalid command/args) and app errors, so ignore them manually
92 | // to avoid logging out scary stack traces for benign invocation issues
93 | return strings.Contains(err.Error(), "unknown flag")
94 | }
95 |
--------------------------------------------------------------------------------
/.github/workflows/prepare-release.yml:
--------------------------------------------------------------------------------
1 | name: Prepare release
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | version_type:
7 | description: Version type
8 | required: true
9 | default: minor
10 | type: choice
11 | options:
12 | - patch
13 | - minor
14 | - major
15 |
16 | permissions:
17 | contents: write
18 | pull-requests: write
19 |
20 | jobs:
21 | prepare-release:
22 | runs-on: ubuntu-latest
23 |
24 | steps:
25 | - uses: actions/checkout@v4
26 |
27 | - uses: actions/setup-go@v5
28 | with:
29 | go-version: "1.24"
30 |
31 | - name: Configure Git
32 | run: |
33 | git config --local user.email "${{ github.actor }}@users.noreply.github.com"
34 | git config --local user.name "${{ github.actor }}"
35 |
36 | - name: Update changelog to the next ${{ github.event.inputs.version_type }} version
37 | run: |
38 | go run ./releaser/main.go update-changelog ${{ github.event.inputs.version_type }}
39 |
40 | - name: Extract new version from changelog
41 | id: version
42 | # Extract the version from the first ## header in CHANGELOG.md
43 | run: |
44 | NEW_VERSION=$(grep -m 1 "^## \[" CHANGELOG.md | sed 's/^## \[\([^]]*\)\].*/\1/')
45 | echo "New version (${{ github.event.inputs.version_type }}): $NEW_VERSION"
46 | echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
47 |
48 | - name: Create pull request
49 | # https://github.com/peter-evans/create-pull-request/releases/tag/v7.0.8
50 | uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
51 | with:
52 | token: ${{ secrets.GITHUB_TOKEN }}
53 | base: main
54 | branch: bot/prepare-release-v${{ steps.version.outputs.new_version }}
55 | commit-message: |
56 | Prepare for the v${{ steps.version.outputs.new_version }} release
57 |
58 | This commit was generated by a GitHub Action workflow. It was triggered
59 | by ${{ github.actor }}.
60 | signoff: true
61 | delete-branch: true
62 | title: "Prepare for the v${{ steps.version.outputs.new_version }} release (triggered by ${{ github.actor }})"
63 | body: |
64 | This pull request was generated by `./github/workflows/prepare-release.yml`.
65 | draft: false
66 | add-paths: CHANGELOG.md
67 | assignees: ${{ github.actor }}
68 | reviewers: ${{ github.actor }}
69 |
--------------------------------------------------------------------------------
/TELEMETRY.md:
--------------------------------------------------------------------------------
1 | # Telemetry
2 |
3 | The Docker Language Server collects telemetry and telemetry collection is **enabled** by default. We collect this telemetry so that we can improve the language server by understanding usage patterns and catching crashes and errors for diagnostic purposes.
4 |
5 | ## Configuring Telemetry Collection
6 |
7 | There are three different telemetry settings in the Docker Language Server.
8 |
9 | - `"all"` - all telemetry will be sent
10 | - `"error"` - send only errors and crash information
11 | - `"off"` - do not send any telemetry
12 |
13 | This configuration can be set in two different ways.
14 |
15 | ### Initialization
16 |
17 | You can include the desired telemetry setting for the language server when sending the initialize `initialize` request from the client to the server. If you include a `telemetry` property inside the `initializationOptions` object in your `initialize` request then the language server will be initialized as such.
18 |
19 | ```JSONC
20 | {
21 | "clientInfo": {
22 | "name": "clientName",
23 | "version": "1.2.3"
24 | },
25 | "initializationOptions": {
26 | // you can send enable all telemetry, only send errors, or disable it completely
27 | "telemetry": "all" | "error" | "off"
28 | }
29 | }
30 | ```
31 |
32 | ### Dynamic Configuration
33 |
34 | If you want to allow your users to configure their telemetry settings whenever they want, then the client needs to send a `workspace/didChangeConfiguration` to the server notifying it that the `docker.lsp.telemetry` configuration has changed. The server will then send a `workspace/configuration` request to the client asking the client to respond back with what setting (`"all" | "error" | "off"`) should be used.
35 |
36 | ```mermaid
37 | sequenceDiagram
38 | client->>server: workspace/didChangeConfiguration notification
39 | server->>client: workspace/configuration request
40 | client->>server: workspace/configuration response
41 | ```
42 |
43 | ## Telemetry Data Collected
44 |
45 | - name and version of the client
46 | - action id that was executed for resolving a diagnostic
47 | - name of the function that triggered a crash
48 | - stack trace of a crash
49 | - hash of the Git remote of modified files
50 | - hash of the path of modified files
51 | - language identifier of modified files
52 |
53 | ## BugSnag
54 |
55 | Errors will be captured and sent to BugSnag. This can be turned off by disabling telemetry.
56 |
57 | ## Privacy Policy
58 |
59 | Read our [privacy policy](https://www.docker.com/legal/docker-privacy-policy/) to learn more about how the information is collected and used.
60 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contribute to the Docker Language Server Project
2 |
3 | ## Sign your work
4 |
5 | The sign-off is a simple line at the end of the explanation for the patch. Your
6 | signature certifies that you wrote the patch or otherwise have the right to pass
7 | it on as an open-source patch. The rules are pretty simple: if you can certify
8 | the below (from [developercertificate.org](http://developercertificate.org/)):
9 |
10 | ```
11 | Developer Certificate of Origin
12 | Version 1.1
13 |
14 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
15 | 1 Letterman Drive
16 | Suite D4700
17 | San Francisco, CA, 94129
18 |
19 | Everyone is permitted to copy and distribute verbatim copies of this
20 | license document, but changing it is not allowed.
21 |
22 | Developer's Certificate of Origin 1.1
23 |
24 | By making a contribution to this project, I certify that:
25 |
26 | (a) The contribution was created in whole or in part by me and I
27 | have the right to submit it under the open source license
28 | indicated in the file; or
29 |
30 | (b) The contribution is based upon previous work that, to the best
31 | of my knowledge, is covered under an appropriate open source
32 | license and I have the right under that license to submit that
33 | work with modifications, whether created in whole or in part
34 | by me, under the same open source license (unless I am
35 | permitted to submit under a different license), as indicated
36 | in the file; or
37 |
38 | (c) The contribution was provided directly to me by some other
39 | person who certified (a), (b) or (c) and I have not modified
40 | it.
41 |
42 | (d) I understand and agree that this project and the contribution
43 | are public and that a record of the contribution (including all
44 | personal information I submit with it, including my sign-off) is
45 | maintained indefinitely and may be redistributed consistent with
46 | this project or the open source license(s) involved.
47 | ```
48 |
49 | Then you just add a line to every git commit message:
50 |
51 | Signed-off-by: Joe Smith
52 |
53 | Use your real name (sorry, no pseudonyms or anonymous contributions.)
54 |
55 | If you set your `user.name` and `user.email` git configs, you can sign your
56 | commit automatically with `git commit -s`.
57 |
58 | ### Run the helper commands
59 |
60 | To validate PRs before submitting them you should run:
61 |
62 | ```
63 | $ make validate-all
64 | ```
65 |
66 | To generate and align vendored files with go modules run:
67 |
68 | ```
69 | $ make vendor
70 | ```
71 |
--------------------------------------------------------------------------------
/internal/hub/client.go:
--------------------------------------------------------------------------------
1 | package hub
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "time"
9 |
10 | "github.com/docker/docker-language-server/internal/cache"
11 | "github.com/docker/docker-language-server/internal/pkg/cli/metadata"
12 | )
13 |
14 | type TagResult struct {
15 | Name string `json:"name"`
16 | TagLastPushed string `json:"tag_last_pushed"`
17 | }
18 |
19 | type TagsResponse struct {
20 | Next string `json:"next"`
21 | Results []TagResult `json:"results"`
22 | }
23 |
24 | type HubClientImpl struct {
25 | client http.Client
26 | }
27 |
28 | type HubTagResultsFetcherImpl struct {
29 | hubClient *HubClientImpl
30 | }
31 |
32 | const getTagsUrl = "https://hub.docker.com/v2/namespaces/%v/repositories/%v/tags?page_size=100"
33 |
34 | func NewHubTagResultsFetcher(hubClient *HubClientImpl) cache.Fetcher[[]TagResult] {
35 | return &HubTagResultsFetcherImpl{hubClient: hubClient}
36 | }
37 |
38 | func (f *HubTagResultsFetcherImpl) Fetch(key cache.Key) ([]TagResult, error) {
39 | if k, ok := key.(HubTagsKey); ok {
40 | return f.hubClient.GetTags(context.Background(), k.Repository, k.Image)
41 | }
42 | return nil, nil
43 | }
44 |
45 | func NewHubClient() *HubClientImpl {
46 | return &HubClientImpl{
47 | client: http.Client{
48 | Timeout: 30 * time.Second,
49 | },
50 | }
51 | }
52 |
53 | func (c *HubClientImpl) GetTags(ctx context.Context, repository, image string) ([]TagResult, error) {
54 | return c.GetTagsFromURL(ctx, fmt.Sprintf(getTagsUrl, repository, image))
55 | }
56 |
57 | func (c *HubClientImpl) GetTagsFromURL(ctx context.Context, url string) ([]TagResult, error) {
58 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
59 | if err != nil {
60 | err := fmt.Errorf("failed to create http request: %w", err)
61 | return nil, err
62 | }
63 |
64 | req.Header.Set("User-Agent", fmt.Sprintf("docker-language-server/v%v", metadata.Version))
65 | res, err := c.client.Do(req)
66 | if err != nil {
67 | err := fmt.Errorf("failed to send HTTP request: %w", err)
68 | return nil, err
69 | }
70 |
71 | defer func() { _ = res.Body.Close() }()
72 | if res.StatusCode != 200 {
73 | err := fmt.Errorf("http request failed (%v status code)", res.StatusCode)
74 | return nil, err
75 | }
76 |
77 | var tagsResponse TagsResponse
78 | _ = json.NewDecoder(res.Body).Decode(&tagsResponse)
79 | if tagsResponse.Next != "" {
80 | tags, err := c.GetTagsFromURL(ctx, tagsResponse.Next)
81 | if err == nil {
82 | tagsResponse.Results = append(tagsResponse.Results, tags...)
83 | }
84 | }
85 | return tagsResponse.Results, nil
86 | }
87 |
--------------------------------------------------------------------------------
/internal/pkg/server/notifier.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/docker/docker-language-server/internal/configuration"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp"
8 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
9 | )
10 |
11 | // LanguageClient exposes JSON-RPC notifications and requests that are
12 | // sent from the server to the client. Notifications can be sent freely
13 | // but requests should be done in its own goroutine. Requests must not
14 | // be sent from within an existing JSON-RPC notification or request
15 | // handler. This is because the handling code is single-threaded and is
16 | // expected to return to handle future messages. If a request is sent
17 | // then the handler would never be able to process the response.
18 | type LanguageClient struct {
19 | call glsp.CallFunc
20 | notify glsp.NotifyFunc
21 | }
22 |
23 | func (c *LanguageClient) ShowMessage(ctx context.Context, params protocol.ShowMessageParams) {
24 | c.notify(ctx, protocol.ServerWindowShowMessage, params)
25 | }
26 |
27 | func (c *LanguageClient) ShowDocumentRequest(ctx context.Context, params protocol.ShowDocumentParams, result *protocol.ShowDocumentResult) {
28 | c.call(ctx, protocol.ServerWindowShowDocument, params, result)
29 | }
30 |
31 | func (c *LanguageClient) ShowMessageRequest(ctx context.Context, params protocol.ShowMessageRequestParams, result *protocol.MessageActionItem) {
32 | c.call(ctx, protocol.ServerWindowShowMessageRequest, params, &result)
33 | }
34 |
35 | func (c *LanguageClient) WorkspaceCodeLensRefresh(ctx context.Context) {
36 | c.call(ctx, protocol.ServerWorkspaceCodeLensRefresh, nil, nil)
37 | }
38 |
39 | func (c *LanguageClient) WorkspaceSemanticTokensRefresh(ctx context.Context) {
40 | c.call(ctx, protocol.MethodWorkspaceSemanticTokensRefresh, nil, nil)
41 | }
42 |
43 | func (c *LanguageClient) PublishDiagnostics(ctx context.Context, params protocol.PublishDiagnosticsParams) {
44 | c.notify(ctx, protocol.ServerTextDocumentPublishDiagnostics, params)
45 | }
46 |
47 | func (c *LanguageClient) WorkspaceConfiguration(ctx context.Context, params protocol.ConfigurationParams, configurations *[]configuration.Configuration) {
48 | c.call(ctx, protocol.ServerWorkspaceConfiguration, params, &configurations)
49 | }
50 |
51 | // RegisterCapability sends the client/registerCapability request from
52 | // the server to the client to dynamically register capabilities.
53 | func (c *LanguageClient) RegisterCapability(ctx context.Context, params protocol.RegistrationParams) {
54 | c.call(ctx, protocol.ServerClientRegisterCapability, params, nil)
55 | }
56 |
--------------------------------------------------------------------------------
/internal/scout/languageGatewayClient.go:
--------------------------------------------------------------------------------
1 | package scout
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "errors"
8 | "fmt"
9 | "net/http"
10 | "time"
11 |
12 | "github.com/docker/docker-language-server/internal/cache"
13 | "github.com/docker/docker-language-server/internal/pkg/cli/metadata"
14 | )
15 |
16 | type LanguageGatewayClient interface {
17 | PostImage(ctx context.Context, jwt, image string) (ImageResponse, error)
18 | Fetch(key cache.Key) (ImageResponse, error)
19 | }
20 |
21 | type LanguageGatewayClientImpl struct {
22 | client http.Client
23 | }
24 |
25 | const languageGatewayImageUrl = "https://api.scout.docker.com/v1/language-gateway/image"
26 |
27 | func NewLanguageGatewayClient() LanguageGatewayClient {
28 | return &LanguageGatewayClientImpl{
29 | client: http.Client{
30 | Timeout: 30 * time.Second,
31 | },
32 | }
33 | }
34 |
35 | func (c LanguageGatewayClientImpl) Fetch(key cache.Key) (ImageResponse, error) {
36 | scoutKey, ok := key.(*ScoutImageKey)
37 | if ok {
38 | return c.PostImage(context.Background(), "", scoutKey.Image)
39 | }
40 | return ImageResponse{}, errors.New("unrecognized key provided")
41 | }
42 |
43 | // PostImage sends an HTTP POST request to /v1/language-gateway/image to
44 | // retrieve infromation from the Scout Language Gateway. This
45 | // information can be used for providing diagnostics about the given
46 | // image.
47 | func (c LanguageGatewayClientImpl) PostImage(ctx context.Context, jwt, image string) (ImageResponse, error) {
48 | imageRequestBody := &ImageRequest{Image: image}
49 | b, err := json.Marshal(imageRequestBody)
50 | if err != nil {
51 | err := fmt.Errorf("failed to marshal image request: %w", err)
52 | return ImageResponse{}, err
53 | }
54 |
55 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, languageGatewayImageUrl, bytes.NewBuffer(b))
56 | if err != nil {
57 | err := fmt.Errorf("failed to create http request: %w", err)
58 | return ImageResponse{}, err
59 | }
60 |
61 | req.Header.Set("Authorization", "Bearer "+jwt)
62 | req.Header.Set("User-Agent", fmt.Sprintf("docker-language-server/v%v", metadata.Version))
63 | res, err := c.client.Do(req)
64 | if err != nil {
65 | err := fmt.Errorf("failed to send HTTP request: %w", err)
66 | return ImageResponse{}, err
67 | }
68 |
69 | defer func() { _ = res.Body.Close() }()
70 | if res.StatusCode >= 500 {
71 | err := fmt.Errorf("http request failed (%v status code)", res.StatusCode)
72 | return ImageResponse{}, err
73 | } else if res.StatusCode >= 400 {
74 | return ImageResponse{}, nil
75 | }
76 |
77 | var imageResponse ImageResponse
78 | _ = json.NewDecoder(res.Body).Decode(&imageResponse)
79 | return imageResponse, nil
80 | }
81 |
--------------------------------------------------------------------------------
/internal/pkg/cli/start.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "text/template"
7 |
8 | "github.com/spf13/cobra"
9 |
10 | "github.com/docker/docker-language-server/internal/pkg/document"
11 | "github.com/docker/docker-language-server/internal/pkg/server"
12 | )
13 |
14 | type startCmd struct {
15 | *cobra.Command
16 | address string
17 | stdio bool
18 | }
19 |
20 | var exampleTemplate = template.Must(template.New("example").Parse(`
21 | # Launch in stdio mode with extra logging
22 | {{.BaseCommandName}} start --stdio --verbose
23 |
24 | # Listen on all interfaces on port 8765
25 | {{.BaseCommandName}} start --address=":8765"`))
26 |
27 | type exampleTemplateParams struct {
28 | BaseCommandName string
29 | }
30 |
31 | // creates a new startCmd
32 | // params:
33 | //
34 | // commandName: what to call the base command in examples (e.g., "docker-language-server")
35 | func newStartCmd(baseCommandName string) *startCmd {
36 | cmd := startCmd{
37 | Command: &cobra.Command{
38 | Use: "start",
39 | Short: "Start the Docker LSP server",
40 | Long: `Start the Docker LSP server.
41 |
42 | By default, the server will run in stdio mode: requests should be written to
43 | stdin and responses will be written to stdout. (All logging is _always_ done
44 | to stderr.)
45 |
46 | For socket mode, pass the --address option.
47 | `,
48 | },
49 | }
50 |
51 | var example bytes.Buffer
52 | p := exampleTemplateParams{
53 | BaseCommandName: baseCommandName,
54 | }
55 | err := exampleTemplate.Execute(&example, p)
56 | if err != nil {
57 | panic(err)
58 | }
59 | cmd.Example = example.String()
60 |
61 | cmd.RunE = func(cc *cobra.Command, args []string) error {
62 | ctx := cc.Context()
63 | if cmd.address != "" {
64 | err = runSocketServer(ctx, cmd.address)
65 | } else {
66 | err = runStdioServer(ctx)
67 | }
68 | if err == context.Canceled {
69 | err = nil
70 | }
71 | return err
72 | }
73 |
74 | cmd.Flags().StringVar(&cmd.address, "address", "",
75 | "Address (hostname:port) to listen on")
76 | cmd.Flags().BoolVar(&cmd.stdio, "stdio", false,
77 | "Stdio (use stdin and stdout to communicate)")
78 | cmd.MarkFlagsMutuallyExclusive("address", "stdio")
79 | cmd.MarkFlagsOneRequired("address", "stdio")
80 |
81 | return &cmd
82 | }
83 |
84 | func runStdioServer(_ context.Context) error {
85 | docManager := document.NewDocumentManager()
86 | s := server.NewServer(docManager)
87 | s.StartBackgrondProcesses(context.Background())
88 | return s.RunStdio()
89 | }
90 |
91 | func runSocketServer(_ context.Context, addr string) error {
92 | docManager := document.NewDocumentManager()
93 | s := server.NewServer(docManager)
94 | s.StartBackgrondProcesses(context.Background())
95 | return s.RunTCP(addr)
96 | }
97 |
--------------------------------------------------------------------------------
/e2e-tests/prepareRename_test.go:
--------------------------------------------------------------------------------
1 | package server_test
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "os"
8 | "testing"
9 |
10 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
11 | "github.com/sourcegraph/jsonrpc2"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | func TestPrepareRename(t *testing.T) {
16 | testPrepareRename(t, true)
17 | testPrepareRename(t, false)
18 | }
19 |
20 | func testPrepareRename(t *testing.T, composeSupport bool) {
21 | s := startServer()
22 |
23 | client := bytes.NewBuffer(make([]byte, 0, 1024))
24 | server := bytes.NewBuffer(make([]byte, 0, 1024))
25 | serverStream := &TestStream{incoming: server, outgoing: client, closed: false}
26 | defer serverStream.Close()
27 | go s.ServeStream(serverStream)
28 |
29 | clientStream := jsonrpc2.NewBufferedStream(&TestStream{incoming: client, outgoing: server, closed: false}, jsonrpc2.VSCodeObjectCodec{})
30 | defer clientStream.Close()
31 | conn := jsonrpc2.NewConn(context.Background(), clientStream, &ConfigurationHandler{t: t})
32 | initialize(t, conn, protocol.InitializeParams{
33 | InitializationOptions: map[string]any{
34 | "dockercomposeExperimental": map[string]bool{"composeSupport": composeSupport},
35 | },
36 | })
37 |
38 | homedir, err := os.UserHomeDir()
39 | require.NoError(t, err)
40 |
41 | testCases := []struct {
42 | name string
43 | content string
44 | position protocol.Position
45 | result *protocol.Range
46 | }{
47 | {
48 | name: "rename dependent service",
49 | content: `
50 | services:
51 | test:
52 | depends_on:
53 | - test2
54 | test2:`,
55 | position: protocol.Position{Line: 4, Character: 11},
56 | result: &protocol.Range{
57 | Start: protocol.Position{Line: 4, Character: 8},
58 | End: protocol.Position{Line: 4, Character: 13},
59 | },
60 | },
61 | }
62 |
63 | for _, tc := range testCases {
64 | t.Run(fmt.Sprintf("%v (composeSupport=%v)", tc.name, composeSupport), func(t *testing.T) {
65 | didOpen := createDidOpenTextDocumentParams(homedir, t.Name()+".yaml", tc.content, protocol.DockerComposeLanguage)
66 | err := conn.Notify(context.Background(), protocol.MethodTextDocumentDidOpen, didOpen)
67 | require.NoError(t, err)
68 |
69 | var result *protocol.Range
70 | err = conn.Call(context.Background(), protocol.MethodTextDocumentPrepareRename, protocol.PrepareRenameParams{
71 | TextDocumentPositionParams: protocol.TextDocumentPositionParams{
72 | TextDocument: protocol.TextDocumentIdentifier{URI: didOpen.TextDocument.URI},
73 | Position: tc.position,
74 | },
75 | }, &result)
76 | require.NoError(t, err)
77 | if composeSupport {
78 | require.Equal(t, tc.result, result)
79 | } else {
80 | require.Nil(t, result)
81 | }
82 | })
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/internal/scout/types.go:
--------------------------------------------------------------------------------
1 | package scout
2 |
3 | import (
4 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
5 | "github.com/docker/docker-language-server/internal/types"
6 | )
7 |
8 | type ImageRequest struct {
9 | Image string `json:"image"`
10 | Organization string `json:"organization"`
11 | }
12 |
13 | type Image struct {
14 | Short string `json:"short"`
15 | Registry string `json:"registry"`
16 | Repository string `json:"repository"`
17 | Tag string `json:"tag"`
18 | Digest string `json:"digest"`
19 | }
20 |
21 | type Link struct {
22 | Href string `json:"href"`
23 | Title string `json:"title"`
24 | }
25 |
26 | type Diagnostic struct {
27 | Kind string `json:"kind"`
28 | Message string `json:"message"`
29 | Severity string `json:"severity"`
30 | Link Link `json:"link"`
31 | }
32 |
33 | type Edit struct {
34 | Title string `json:"title"`
35 | Edit string `json:"edit"`
36 | Diagnostic string `json:"diagnostic"`
37 | }
38 |
39 | type Description struct {
40 | Plaintext string `json:"plaintext"`
41 | Markdown string `json:"markdown"`
42 | }
43 |
44 | type Info struct {
45 | Kind string `json:"kind"`
46 | Description Description `json:"description"`
47 | }
48 |
49 | type ImageResponse struct {
50 | Image Image `json:"image"`
51 | Diagnostics []Diagnostic `json:"diagnostics"`
52 | Edits []Edit `json:"edits"`
53 | Infos []Info `json:"infos"`
54 | }
55 |
56 | type ScoutImageKey struct {
57 | Image string
58 | }
59 |
60 | func (k *ScoutImageKey) CacheKey() string {
61 | return k.Image
62 | }
63 |
64 | func ConvertDiagnostic(diagnostic Diagnostic, source string, rng protocol.Range, edits []types.NamedEdit) protocol.Diagnostic {
65 | lspDiagnostic := protocol.Diagnostic{}
66 | lspDiagnostic.Code = &protocol.IntegerOrString{Value: diagnostic.Kind}
67 | lspDiagnostic.Message = diagnostic.Message
68 | lspDiagnostic.Source = types.CreateStringPointer(source)
69 | if diagnostic.Link.Href != "" {
70 | lspDiagnostic.CodeDescription = &protocol.CodeDescription{
71 | HRef: diagnostic.Link.Href,
72 | }
73 | }
74 | lspDiagnostic.Range = rng
75 |
76 | switch diagnostic.Severity {
77 | case "error":
78 | lspDiagnostic.Severity = types.CreateDiagnosticSeverityPointer(protocol.DiagnosticSeverityError)
79 | case "info":
80 | lspDiagnostic.Severity = types.CreateDiagnosticSeverityPointer(protocol.DiagnosticSeverityInformation)
81 | case "warn":
82 | lspDiagnostic.Severity = types.CreateDiagnosticSeverityPointer(protocol.DiagnosticSeverityWarning)
83 | case "hint":
84 | lspDiagnostic.Severity = types.CreateDiagnosticSeverityPointer(protocol.DiagnosticSeverityHint)
85 | }
86 |
87 | if len(edits) > 0 {
88 | lspDiagnostic.Data = edits
89 | }
90 | return lspDiagnostic
91 | }
92 |
--------------------------------------------------------------------------------
/internal/compose/definition.go:
--------------------------------------------------------------------------------
1 | package compose
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/docker/docker-language-server/internal/pkg/document"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
8 | "github.com/docker/docker-language-server/internal/types"
9 | "github.com/goccy/go-yaml/ast"
10 | )
11 |
12 | func insideRange(rng protocol.Range, line, character protocol.UInteger) bool {
13 | return rng.Start.Line == line && rng.Start.Character <= character && character <= rng.End.Character
14 | }
15 |
16 | func Definition(ctx context.Context, definitionLinkSupport bool, doc document.ComposeDocument, params *protocol.DefinitionParams) (any, error) {
17 | name, dependency := DocumentHighlights(doc, params.Position)
18 | if len(dependency.documentHighlights) == 0 {
19 | return nil, nil
20 | }
21 |
22 | targetURI := params.TextDocument.URI
23 | var sourceRange *protocol.Range
24 | var definitionRange *protocol.Range
25 | for _, highlight := range dependency.documentHighlights {
26 | if *highlight.Kind == protocol.DocumentHighlightKindWrite {
27 | definitionRange = &highlight.Range
28 | if insideRange(highlight.Range, params.Position.Line, params.Position.Character) {
29 | sourceRange = &highlight.Range
30 | break
31 | }
32 | }
33 | }
34 |
35 | if definitionRange == nil {
36 | node, u := dependencyLookup(doc, dependency.dependencyType, name)
37 | if node != nil {
38 | r := createRange(node.Key.GetToken(), len(node.Key.GetToken().Value))
39 | definitionRange = &r
40 | targetURI = u
41 | }
42 | }
43 |
44 | for _, highlight := range dependency.documentHighlights {
45 | if *highlight.Kind == protocol.DocumentHighlightKindRead {
46 | if insideRange(highlight.Range, params.Position.Line, params.Position.Character) {
47 | sourceRange = &highlight.Range
48 | break
49 | }
50 | }
51 | }
52 |
53 | if definitionRange != nil {
54 | return types.CreateDefinitionResult(
55 | definitionLinkSupport,
56 | *definitionRange,
57 | sourceRange,
58 | targetURI,
59 | ), nil
60 | }
61 | return nil, nil
62 | }
63 |
64 | func dependencyLookup(doc document.ComposeDocument, dependencyType, name string) (*ast.MappingValueNode, string) {
65 | files, _ := doc.IncludedFiles()
66 | for u, file := range files {
67 | for _, doc := range file.Docs {
68 | if mappingNode, ok := doc.Body.(*ast.MappingNode); ok {
69 | for _, node := range mappingNode.Values {
70 | if s, ok := node.Key.(*ast.StringNode); ok && s.Value == dependencyType {
71 | if m, ok := node.Value.(*ast.MappingNode); ok {
72 | for _, service := range m.Values {
73 | if s, ok := service.Key.(*ast.StringNode); ok && s.Value == name {
74 | return service, u
75 | }
76 | }
77 | }
78 | }
79 | }
80 | }
81 | }
82 | }
83 | return nil, ""
84 | }
85 |
--------------------------------------------------------------------------------
/e2e-tests/documentHighlight_test.go:
--------------------------------------------------------------------------------
1 | package server_test
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "os"
7 | "testing"
8 |
9 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
10 | "github.com/docker/docker-language-server/internal/types"
11 | "github.com/sourcegraph/jsonrpc2"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | func TestDocumentHighlight(t *testing.T) {
16 | s := startServer()
17 |
18 | client := bytes.NewBuffer(make([]byte, 0, 1024))
19 | server := bytes.NewBuffer(make([]byte, 0, 1024))
20 | serverStream := &TestStream{incoming: server, outgoing: client, closed: false}
21 | defer serverStream.Close()
22 | go s.ServeStream(serverStream)
23 |
24 | clientStream := jsonrpc2.NewBufferedStream(&TestStream{incoming: client, outgoing: server, closed: false}, jsonrpc2.VSCodeObjectCodec{})
25 | defer clientStream.Close()
26 | conn := jsonrpc2.NewConn(context.Background(), clientStream, &ConfigurationHandler{t: t})
27 | initialize(t, conn, protocol.InitializeParams{})
28 |
29 | homedir, err := os.UserHomeDir()
30 | require.NoError(t, err)
31 |
32 | testCases := []struct {
33 | name string
34 | content string
35 | position protocol.Position
36 | ranges []*protocol.DocumentHighlight
37 | }{
38 | {
39 | name: "cursor in group's block targets attribute pointing at unquoted target",
40 | content: "group g { targets = [\"build\"] }\ntarget build {}\ntarget irrelevant {}",
41 | position: protocol.Position{Line: 0, Character: 25},
42 | ranges: []*protocol.DocumentHighlight{
43 | {
44 | Kind: types.CreateDocumentHighlightKindPointer(protocol.DocumentHighlightKindRead),
45 | Range: protocol.Range{
46 | Start: protocol.Position{Line: 0, Character: 22},
47 | End: protocol.Position{Line: 0, Character: 27},
48 | },
49 | },
50 | {
51 | Kind: types.CreateDocumentHighlightKindPointer(protocol.DocumentHighlightKindWrite),
52 | Range: protocol.Range{
53 | Start: protocol.Position{Line: 1, Character: 7},
54 | End: protocol.Position{Line: 1, Character: 12},
55 | },
56 | },
57 | },
58 | },
59 | }
60 |
61 | for _, tc := range testCases {
62 | t.Run(tc.name, func(t *testing.T) {
63 | didOpen := createDidOpenTextDocumentParams(homedir, t.Name()+".hcl", tc.content, "dockerbake")
64 | err := conn.Notify(context.Background(), protocol.MethodTextDocumentDidOpen, didOpen)
65 | require.NoError(t, err)
66 |
67 | var ranges []*protocol.DocumentHighlight
68 | err = conn.Call(context.Background(), protocol.MethodTextDocumentDocumentHighlight, protocol.DocumentHighlightParams{
69 | TextDocumentPositionParams: protocol.TextDocumentPositionParams{
70 | TextDocument: protocol.TextDocumentIdentifier{URI: didOpen.TextDocument.URI},
71 | Position: tc.position,
72 | },
73 | }, &ranges)
74 | require.NoError(t, err)
75 | require.Equal(t, tc.ranges, ranges)
76 | })
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/internal/configuration/configuration.go:
--------------------------------------------------------------------------------
1 | package configuration
2 |
3 | import (
4 | "sync"
5 |
6 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
7 | )
8 |
9 | const (
10 | ConfigTelemetry = "docker.lsp.telemetry"
11 |
12 | ConfigExperimentalVulnerabilityScanning = "docker.lsp.experimental.vulnerabilityScanning"
13 |
14 | ConfigExperimentalScoutCriticalHighVulnerabilities = "docker.lsp.experimental.scout.criticalHighVulnerabilities"
15 | ConfigExperimentalScoutNotPinnedDigest = "docker.lsp.experimental.scout.notPinnedDigest"
16 | ConfigExperimentalScoutRecommendedTag = "docker.lsp.experimental.scout.recommendedTag"
17 | ConfigExperimentalScoutVulnerabilities = "docker.lsp.experimental.scout.vulnerabilities"
18 | )
19 |
20 | type TelemetrySetting string
21 |
22 | const (
23 | TelemetrySettingOff TelemetrySetting = "off"
24 | TelemetrySettingError TelemetrySetting = "error"
25 | TelemetrySettingAll TelemetrySetting = "all"
26 | )
27 |
28 | type Configuration struct {
29 | // docker.lsp.telemetry
30 | Telemetry TelemetrySetting `json:"telemetry,omitempty"`
31 | Experimental Experimental `json:"experimental"`
32 | }
33 |
34 | type Experimental struct {
35 | // docker.lsp.experimental.vulnerabilityScanning
36 | VulnerabilityScanning bool `json:"vulnerabilityScanning"`
37 | // docker.lsp.experimental.scout
38 | Scout Scout `json:"scout"`
39 | }
40 |
41 | type Scout struct {
42 | CriticalHighVulnerabilities bool `json:"criticalHighVulnerabilities"`
43 | NotPinnedDigest bool `json:"notPinnedDigest"`
44 | RecommendedTag bool `json:"recommendedTag"`
45 | Vulnerabilites bool `json:"vulnerabilites"`
46 | }
47 |
48 | var configurations = make(map[protocol.DocumentUri]Configuration)
49 | var lock = sync.RWMutex{}
50 | var defaultConfiguration = Configuration{
51 | Telemetry: TelemetrySettingAll,
52 | Experimental: Experimental{
53 | VulnerabilityScanning: true,
54 | Scout: Scout{
55 | CriticalHighVulnerabilities: true,
56 | NotPinnedDigest: false,
57 | RecommendedTag: false,
58 | Vulnerabilites: true,
59 | },
60 | },
61 | }
62 |
63 | func Documents() []protocol.DocumentUri {
64 | lock.RLock()
65 | defer lock.RUnlock()
66 |
67 | documents := []protocol.DocumentUri{}
68 | for document := range configurations {
69 | documents = append(documents, document)
70 | }
71 | return documents
72 | }
73 |
74 | func Get(document protocol.DocumentUri) Configuration {
75 | lock.RLock()
76 | defer lock.RUnlock()
77 |
78 | if config, ok := configurations[document]; ok {
79 | return config
80 | }
81 | return defaultConfiguration
82 | }
83 |
84 | func Store(document protocol.DocumentUri, configuration Configuration) {
85 | lock.Lock()
86 | defer lock.Unlock()
87 | configurations[document] = configuration
88 | }
89 |
90 | func Remove(document protocol.DocumentUri) {
91 | lock.Lock()
92 | defer lock.Unlock()
93 | delete(configurations, document)
94 | }
95 |
--------------------------------------------------------------------------------
/internal/bake/hcl/inlayHint.go:
--------------------------------------------------------------------------------
1 | package hcl
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "strings"
8 |
9 | "github.com/docker/docker-language-server/internal/pkg/document"
10 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
11 | "github.com/docker/docker-language-server/internal/types"
12 | "github.com/hashicorp/hcl/v2"
13 | "github.com/hashicorp/hcl/v2/hclsyntax"
14 | )
15 |
16 | func InlayHint(docs *document.Manager, doc document.BakeHCLDocument, rng protocol.Range) ([]protocol.InlayHint, error) {
17 | body, ok := doc.File().Body.(*hclsyntax.Body)
18 | if !ok {
19 | return nil, errors.New("unrecognized body in HCL document")
20 | }
21 |
22 | input := doc.Input()
23 | hints := []protocol.InlayHint{}
24 | for _, block := range body.Blocks {
25 | if block.Type == "target" && len(block.Labels) > 0 {
26 | if attribute, ok := block.Body.Attributes["args"]; ok {
27 | if expr, ok := attribute.Expr.(*hclsyntax.ObjectConsExpr); ok && len(expr.Items) > 0 {
28 | dockerfileURI, dockerfilePath, err := doc.DockerfileForTarget(block)
29 | if dockerfilePath != "" && err == nil {
30 | _, nodes := document.OpenDockerfile(context.Background(), docs, dockerfileURI, dockerfilePath)
31 | args := map[string]string{}
32 | for _, child := range nodes {
33 | if strings.EqualFold(child.Value, "ARG") {
34 | child = child.Next
35 | for child != nil {
36 | value := child.Value
37 | idx := strings.Index(value, "=")
38 | if idx != -1 {
39 | defaultValue := value[idx+1:]
40 | if defaultValue != "" {
41 | args[value[:idx]] = defaultValue
42 | }
43 | }
44 | child = child.Next
45 | }
46 | }
47 | }
48 |
49 | lines := strings.Split(string(input), "\n")
50 | for _, item := range expr.Items {
51 | itemRange := item.KeyExpr.Range()
52 | if insideProtocol(rng, itemRange.Start) || insideProtocol(rng, itemRange.End) {
53 | if value, ok := args[string(input[itemRange.Start.Byte:itemRange.End.Byte])]; ok {
54 | hints = append(hints, protocol.InlayHint{
55 | Label: fmt.Sprintf("(default value: %v)", value),
56 | PaddingLeft: types.CreateBoolPointer(true),
57 | Position: protocol.Position{
58 | Line: uint32(itemRange.Start.Line) - 1,
59 | Character: uint32(len(lines[itemRange.Start.Line-1])),
60 | },
61 | })
62 | }
63 | }
64 | }
65 | }
66 | }
67 | }
68 | }
69 | }
70 | return hints, nil
71 | }
72 |
73 | func insideProtocol(rng protocol.Range, position hcl.Pos) bool {
74 | line := uint32(position.Line - 1)
75 | character := uint32(position.Column - 1)
76 | if rng.Start.Line < line {
77 | if line < rng.End.Line {
78 | return true
79 | } else if line == rng.End.Line {
80 | return character <= rng.End.Character
81 | }
82 | return false
83 | } else if rng.Start.Line == line {
84 | if line < rng.End.Line {
85 | return rng.Start.Character <= character
86 | }
87 | return rng.Start.Character <= character && character <= rng.End.Character
88 | }
89 | return false
90 | }
91 |
--------------------------------------------------------------------------------
/internal/pkg/document/dockerfileDocument.go:
--------------------------------------------------------------------------------
1 | package document
2 |
3 | import (
4 | "bytes"
5 | "strings"
6 | "sync"
7 |
8 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
9 | "github.com/moby/buildkit/frontend/dockerfile/parser"
10 | "go.lsp.dev/uri"
11 | )
12 |
13 | type DockerfileDocument interface {
14 | Document
15 | Instruction(p protocol.Position) *parser.Node
16 | Nodes() []*parser.Node
17 | }
18 |
19 | func NewDockerfileDocument(u uri.URI, version int32, input []byte) DockerfileDocument {
20 | doc := &dockerfileDocument{
21 | document: document{
22 | uri: u,
23 | identifier: protocol.DockerfileLanguage,
24 | version: version,
25 | input: input,
26 | },
27 | }
28 | doc.copyFn = doc.copy
29 | doc.parseFn = doc.parse
30 | doc.parseFn(true)
31 | return doc
32 | }
33 |
34 | type dockerfileDocument struct {
35 | document
36 | mutex sync.Mutex
37 | result *parser.Result
38 | }
39 |
40 | func (d *dockerfileDocument) Nodes() []*parser.Node {
41 | d.parseFn(false)
42 | if d.result == nil {
43 | return nil
44 | }
45 |
46 | return d.result.AST.Children
47 | }
48 |
49 | func (d *dockerfileDocument) parse(force bool) bool {
50 | d.mutex.Lock()
51 | defer d.mutex.Unlock()
52 |
53 | if d.result == nil || force {
54 | result, _ := parser.Parse(bytes.NewReader(d.input))
55 | if result == nil {
56 | // we have no AST information to compare to so we assume
57 | // something has changed
58 | d.result = nil
59 | return true
60 | } else if d.result == nil {
61 | d.result = result
62 | return true
63 | }
64 |
65 | children := d.result.AST.Children
66 | newChildren := result.AST.Children
67 | d.result = result
68 |
69 | if len(children) != len(newChildren) {
70 | return true
71 | }
72 |
73 | for i := range children {
74 | node := children[i]
75 | newNode := newChildren[i]
76 | if compareNodes(node, newNode) {
77 | return true
78 | }
79 | }
80 |
81 | if children[0].StartLine != 1 {
82 | lines := strings.Split(string(d.input), "\n")
83 | for i := range children[0].StartLine {
84 | if strings.Contains(lines[i], "check=") {
85 | return true
86 | }
87 | }
88 | }
89 | }
90 | return false
91 | }
92 |
93 | func compareNodes(n1, n2 *parser.Node) bool {
94 | if len(n1.Flags) != len(n2.Flags) {
95 | return true
96 | }
97 |
98 | for i := range n1.Flags {
99 | if n1.Flags[i] != n2.Flags[i] {
100 | return true
101 | }
102 | }
103 |
104 | for {
105 | if n1 == nil {
106 | return n2 != nil
107 | } else if n2 == nil {
108 | return true
109 | }
110 |
111 | if n1.StartLine != n2.StartLine || n1.EndLine != n2.EndLine {
112 | return true
113 | }
114 |
115 | if n1.Value != n2.Value {
116 | return true
117 | }
118 |
119 | n1 = n1.Next
120 | n2 = n2.Next
121 | }
122 | }
123 |
124 | func (d *dockerfileDocument) Instruction(p protocol.Position) *parser.Node {
125 | d.parseFn(false)
126 | if d.result != nil {
127 | for _, instruction := range d.result.AST.Children {
128 | if instruction.StartLine <= int(p.Line+1) && int(p.Line+1) <= instruction.EndLine {
129 | return instruction
130 | }
131 | }
132 | }
133 | return nil
134 | }
135 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/server/handler.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | contextpkg "context"
5 | "errors"
6 | "fmt"
7 |
8 | "github.com/docker/docker-language-server/internal/tliron/glsp"
9 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
10 | "github.com/sourcegraph/jsonrpc2"
11 | )
12 |
13 | // AsynchronousRequestHandler will selectively process some JSON-RPC
14 | // messages asynchronously.
15 | type AsynchronousRequestHandler struct {
16 | handler jsonrpc2.Handler
17 | }
18 |
19 | func asynchronousRequest(req *jsonrpc2.Request) bool {
20 | // handle textDocument/inlayHint requests asynchronously
21 | return !req.Notif && req.Method == protocol.MethodTextDocumentInlayHint
22 | }
23 |
24 | func (h *AsynchronousRequestHandler) Handle(ctx contextpkg.Context, conn *jsonrpc2.Conn, req *jsonrpc2.Request) {
25 | if asynchronousRequest(req) {
26 | go h.handler.Handle(ctx, conn, req)
27 | } else {
28 | // handle notifications synchronously so that the document changes do not overlap each other
29 | h.handler.Handle(ctx, conn, req)
30 | }
31 | }
32 |
33 | // See: https://github.com/sourcegraph/go-langserver/blob/master/langserver/handler.go#L206
34 |
35 | func (self *Server) newHandler() jsonrpc2.Handler {
36 | return &AsynchronousRequestHandler{handler: jsonrpc2.HandlerWithError(self.handle)}
37 | }
38 |
39 | func (self *Server) handle(context contextpkg.Context, connection *jsonrpc2.Conn, request *jsonrpc2.Request) (any, error) {
40 | glspContext := glsp.Context{
41 | Method: request.Method,
42 | Notify: func(ctx contextpkg.Context, method string, params any) {
43 | if err := connection.Notify(ctx, method, params); err != nil {
44 | self.Log.Error(err.Error())
45 | }
46 | },
47 | Call: func(ctx contextpkg.Context, method string, params any, result any) {
48 | if err := connection.Call(ctx, method, params, result); err != nil {
49 | self.Log.Error(err.Error())
50 | }
51 | },
52 | Context: context,
53 | }
54 |
55 | if request.Params != nil {
56 | glspContext.Params = *request.Params
57 | }
58 |
59 | switch request.Method {
60 | case "exit":
61 | // We're giving the attached handler a chance to handle it first, but we'll ignore any result
62 | self.Handler.Handle(&glspContext)
63 | err := connection.Close()
64 | return nil, err
65 |
66 | default:
67 | // Note: jsonrpc2 will not even call this function if reqest.Params is invalid JSON,
68 | // so we don't need to handle jsonrpc2.CodeParseError here
69 | result, validMethod, validParams, err := self.Handler.Handle(&glspContext)
70 | if !validMethod {
71 | return nil, &jsonrpc2.Error{
72 | Code: jsonrpc2.CodeMethodNotFound,
73 | Message: fmt.Sprintf("method not supported: %s", request.Method),
74 | }
75 | } else if !validParams {
76 | if err == nil {
77 | return nil, &jsonrpc2.Error{
78 | Code: jsonrpc2.CodeInvalidParams,
79 | }
80 | } else {
81 | return nil, &jsonrpc2.Error{
82 | Code: jsonrpc2.CodeInvalidParams,
83 | Message: err.Error(),
84 | }
85 | }
86 | } else if err != nil {
87 | var jsonsrpcErr *jsonrpc2.Error
88 | if errors.As(err, &jsonsrpcErr) {
89 | return nil, jsonsrpcErr
90 | }
91 |
92 | return nil, &jsonrpc2.Error{
93 | Code: jsonrpc2.CodeInvalidRequest,
94 | Message: err.Error(),
95 | }
96 | } else {
97 | return result, nil
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/e2e-tests/inlayHint_test.go:
--------------------------------------------------------------------------------
1 | package server_test
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 | "testing"
11 |
12 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
13 | "github.com/docker/docker-language-server/internal/types"
14 | "github.com/sourcegraph/jsonrpc2"
15 | "github.com/stretchr/testify/require"
16 | )
17 |
18 | func TestInlayHint(t *testing.T) {
19 | s := startServer()
20 |
21 | client := bytes.NewBuffer(make([]byte, 0, 1024))
22 | server := bytes.NewBuffer(make([]byte, 0, 1024))
23 | serverStream := &TestStream{incoming: server, outgoing: client, closed: false}
24 | defer serverStream.Close()
25 | go s.ServeStream(serverStream)
26 |
27 | clientStream := jsonrpc2.NewBufferedStream(&TestStream{incoming: client, outgoing: server, closed: false}, jsonrpc2.VSCodeObjectCodec{})
28 | defer clientStream.Close()
29 | conn := jsonrpc2.NewConn(context.Background(), clientStream, &ConfigurationHandler{t: t})
30 | initialize(t, conn, protocol.InitializeParams{})
31 |
32 | testCases := []struct {
33 | name string
34 | content string
35 | dockerfileContent string
36 | rng protocol.Range
37 | hints []protocol.InlayHint
38 | }{
39 | {
40 | name: "args lookup",
41 | content: "target t1 {\n args = {\n undefined = \"test\"\n empty = \"test\"\n defined = \"test\"\n}\n}",
42 | dockerfileContent: "FROM scratch\nARG undefined\nARG empty=\nARG defined=value\n",
43 | rng: protocol.Range{
44 | Start: protocol.Position{Line: 0, Character: 0},
45 | End: protocol.Position{Line: 5, Character: 0},
46 | },
47 | hints: []protocol.InlayHint{
48 | {
49 | Label: "(default value: value)",
50 | PaddingLeft: types.CreateBoolPointer(true),
51 | Position: protocol.Position{Line: 4, Character: 20},
52 | },
53 | },
54 | },
55 | }
56 |
57 | temporaryDockerfile := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(os.TempDir(), "Dockerfile")), "/"))
58 | temporaryBakeFile := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(os.TempDir(), "docker-bake.hcl")), "/"))
59 |
60 | for _, tc := range testCases {
61 | t.Run(tc.name, func(t *testing.T) {
62 | didOpenDockerfile := protocol.DidOpenTextDocumentParams{
63 | TextDocument: protocol.TextDocumentItem{
64 | URI: temporaryDockerfile,
65 | Text: tc.dockerfileContent,
66 | LanguageID: "dockerfile",
67 | Version: 1,
68 | },
69 | }
70 | err := conn.Notify(context.Background(), protocol.MethodTextDocumentDidOpen, didOpenDockerfile)
71 | require.NoError(t, err)
72 |
73 | didOpenBakeFile := protocol.DidOpenTextDocumentParams{
74 | TextDocument: protocol.TextDocumentItem{
75 | URI: temporaryBakeFile,
76 | Text: tc.content,
77 | LanguageID: "dockerbake",
78 | Version: 1,
79 | },
80 | }
81 | err = conn.Notify(context.Background(), protocol.MethodTextDocumentDidOpen, didOpenBakeFile)
82 | require.NoError(t, err)
83 |
84 | var hints []protocol.InlayHint
85 | err = conn.Call(context.Background(), protocol.MethodTextDocumentInlayHint, protocol.InlayHintParams{
86 | TextDocument: protocol.TextDocumentIdentifier{URI: didOpenBakeFile.TextDocument.URI},
87 | Range: tc.rng,
88 | }, &hints)
89 | require.NoError(t, err)
90 | require.Equal(t, tc.hints, hints)
91 | })
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/e2e-tests/rename_test.go:
--------------------------------------------------------------------------------
1 | package server_test
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "os"
8 | "testing"
9 |
10 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
11 | "github.com/sourcegraph/jsonrpc2"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | func TestRename(t *testing.T) {
16 | testRename(t, true)
17 | testRename(t, false)
18 | }
19 |
20 | func testRename(t *testing.T, composeSupport bool) {
21 | s := startServer()
22 |
23 | client := bytes.NewBuffer(make([]byte, 0, 1024))
24 | server := bytes.NewBuffer(make([]byte, 0, 1024))
25 | serverStream := &TestStream{incoming: server, outgoing: client, closed: false}
26 | defer serverStream.Close()
27 | go s.ServeStream(serverStream)
28 |
29 | clientStream := jsonrpc2.NewBufferedStream(&TestStream{incoming: client, outgoing: server, closed: false}, jsonrpc2.VSCodeObjectCodec{})
30 | defer clientStream.Close()
31 | conn := jsonrpc2.NewConn(context.Background(), clientStream, &ConfigurationHandler{t: t})
32 | initialize(t, conn, protocol.InitializeParams{
33 | InitializationOptions: map[string]any{
34 | "dockercomposeExperimental": map[string]bool{"composeSupport": composeSupport},
35 | },
36 | })
37 |
38 | homedir, err := os.UserHomeDir()
39 | require.NoError(t, err)
40 |
41 | testCases := []struct {
42 | name string
43 | content string
44 | position protocol.Position
45 | workspaceEdit func(protocol.DocumentUri) *protocol.WorkspaceEdit
46 | }{
47 | {
48 | name: "rename dependent service",
49 | content: `
50 | services:
51 | test:
52 | depends_on:
53 | - test2
54 | test2:`,
55 | position: protocol.Position{Line: 4, Character: 11},
56 | workspaceEdit: func(u protocol.DocumentUri) *protocol.WorkspaceEdit {
57 | return &protocol.WorkspaceEdit{
58 | Changes: map[protocol.DocumentUri][]protocol.TextEdit{
59 | u: {
60 | {
61 | NewText: "newName",
62 | Range: protocol.Range{
63 | Start: protocol.Position{Line: 4, Character: 8},
64 | End: protocol.Position{Line: 4, Character: 13},
65 | },
66 | },
67 | {
68 | NewText: "newName",
69 | Range: protocol.Range{
70 | Start: protocol.Position{Line: 5, Character: 2},
71 | End: protocol.Position{Line: 5, Character: 7},
72 | },
73 | },
74 | },
75 | },
76 | }
77 | },
78 | },
79 | }
80 |
81 | for _, tc := range testCases {
82 | t.Run(fmt.Sprintf("%v (composeSupport=%v)", tc.name, composeSupport), func(t *testing.T) {
83 | didOpen := createDidOpenTextDocumentParams(homedir, t.Name()+".yaml", tc.content, protocol.DockerComposeLanguage)
84 | err := conn.Notify(context.Background(), protocol.MethodTextDocumentDidOpen, didOpen)
85 | require.NoError(t, err)
86 |
87 | var workspaceEdit *protocol.WorkspaceEdit
88 | err = conn.Call(context.Background(), protocol.MethodTextDocumentRename, protocol.RenameParams{
89 | TextDocumentPositionParams: protocol.TextDocumentPositionParams{
90 | TextDocument: protocol.TextDocumentIdentifier{URI: didOpen.TextDocument.URI},
91 | Position: tc.position,
92 | },
93 | NewName: "newName",
94 | }, &workspaceEdit)
95 | require.NoError(t, err)
96 | if composeSupport {
97 | require.Equal(t, tc.workspaceEdit(didOpen.TextDocument.URI), workspaceEdit)
98 | } else {
99 | require.Nil(t, workspaceEdit)
100 | }
101 | })
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/internal/tliron/glsp/protocol/base-protocol.go:
--------------------------------------------------------------------------------
1 | package protocol
2 |
3 | import (
4 | "encoding/json"
5 | "strconv"
6 |
7 | "github.com/docker/docker-language-server/internal/tliron/glsp"
8 | )
9 |
10 | var True bool = true
11 | var False bool = false
12 |
13 | type Method = string
14 |
15 | // https://microsoft.github.io/language-server-protocol/specifications/specification-3-16#number
16 |
17 | /**
18 | * Defines an integer number in the range of -2^31 to 2^31 - 1.
19 | */
20 | type Integer = int32
21 |
22 | /**
23 | * Defines an unsigned integer number in the range of 0 to 2^31 - 1.
24 | */
25 | type UInteger = uint32
26 |
27 | /**
28 | * Defines a decimal number. Since decimal numbers are very
29 | * rare in the language server specification we denote the
30 | * exact range with every decimal using the mathematics
31 | * interval notation (e.g. [0, 1] denotes all decimals d with
32 | * 0 <= d <= 1.
33 | */
34 | type Decimal = float32
35 |
36 | type IntegerOrString struct {
37 | Value any // Integer | string
38 | }
39 |
40 | // ([json.Marshaler] interface)
41 | func (self *IntegerOrString) MarshalJSON() ([]byte, error) {
42 | return json.Marshal(self.Value)
43 | }
44 |
45 | // ([json.Unmarshaler] interface)
46 | func (self *IntegerOrString) UnmarshalJSON(data []byte) error {
47 | var value Integer
48 | if err := json.Unmarshal(data, &value); err == nil {
49 | self.Value = value
50 | return nil
51 | } else {
52 | var value string
53 | if err := json.Unmarshal(data, &value); err == nil {
54 | self.Value = value
55 | return nil
56 | } else {
57 | return err
58 | }
59 | }
60 | }
61 |
62 | type BoolOrString struct {
63 | Value any // bool | string
64 | }
65 |
66 | // ([json.Marshaler] interface)
67 | func (self BoolOrString) MarshalJSON() ([]byte, error) {
68 | return json.Marshal(self.Value)
69 | }
70 |
71 | // ([json.Unmarshaler] interface)
72 | func (self BoolOrString) UnmarshalJSON(data []byte) error {
73 | var value bool
74 | if err := json.Unmarshal(data, &value); err == nil {
75 | self.Value = value
76 | return nil
77 | } else {
78 | var value string
79 | if err := json.Unmarshal(data, &value); err == nil {
80 | self.Value = value
81 | return nil
82 | } else {
83 | return err
84 | }
85 | }
86 | }
87 |
88 | // ([fmt.Stringer] interface)
89 | func (self BoolOrString) String() string {
90 | if value, ok := self.Value.(bool); ok {
91 | return strconv.FormatBool(value)
92 | } else {
93 | return self.Value.(string)
94 | }
95 | }
96 |
97 | // https://microsoft.github.io/language-server-protocol/specifications/specification-3-16#cancelRequest
98 |
99 | const MethodCancelRequest = Method("$/cancelRequest")
100 |
101 | type CancelRequestFunc func(context *glsp.Context, params *CancelParams) error
102 |
103 | type CancelParams struct {
104 | /**
105 | * The request id to cancel.
106 | */
107 | ID IntegerOrString `json:"id"`
108 | }
109 |
110 | // https://microsoft.github.io/language-server-protocol/specifications/specification-3-16#progress
111 |
112 | const MethodProgress = Method("$/progress")
113 |
114 | type ProgressFunc func(context *glsp.Context, params *ProgressParams) error
115 |
116 | type ProgressParams struct {
117 | /**
118 | * The progress token provided by the client or server.
119 | */
120 | Token ProgressToken `json:"token"`
121 |
122 | /**
123 | * The progress data.
124 | */
125 | Value any `json:"value"`
126 | }
127 |
128 | type ProgressToken = IntegerOrString
129 |
--------------------------------------------------------------------------------
/internal/types/common_test.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestGitRepository(t *testing.T) {
10 | testCases := []struct {
11 | url string
12 | expected string
13 | }{
14 | {url: "ssh://user@host.xz/path/to/repo.git/"},
15 | {url: "ssh://host.xz/path/to/repo.git/"},
16 | {url: "ssh://user@host.xz/path/to/repo.git/"},
17 | {url: "ssh://host.xz/path/to/repo.git/"},
18 | {url: "rsync://host.xz/path/to/repo.git/"},
19 | {url: "git://host.xz/path/to/repo.git/"},
20 | {url: "http://host.xz/path/to/repo.git/"},
21 | {url: "https://host.xz/path/to/repo.git/"},
22 | {
23 | url: "ssh://user@host.xz:8888/path/to/repo.git/",
24 | expected: "host.xz:8888/path/to/repo.git",
25 | },
26 | {
27 | url: "user@host.xz:path/to/repo.git",
28 | expected: "host.xz/path/to/repo.git",
29 | },
30 | {
31 | url: "host.xz:/path/to/repo.git/",
32 | expected: "host.xz/path/to/repo.git",
33 | },
34 | {
35 | url: "host.xz:/path/to/repo.git/",
36 | expected: "host.xz/path/to/repo.git",
37 | },
38 | {
39 | url: "host.xz:path/to/repo.git",
40 | expected: "host.xz/path/to/repo.git",
41 | },
42 | {
43 | url: "ssh://host.xz:8888/path/to/repo.git/",
44 | expected: "host.xz:8888/path/to/repo.git",
45 | },
46 | {
47 | url: "user@host.xz:/path/to/repo.git/",
48 | },
49 | }
50 |
51 | for _, tc := range testCases {
52 | t.Run(tc.url, func(t *testing.T) {
53 | repository := GitRepository(tc.url)
54 | if tc.expected == "" {
55 | require.Equal(t, "host.xz/path/to/repo.git", repository)
56 | } else {
57 | require.Equal(t, tc.expected, repository)
58 | }
59 | })
60 | }
61 | }
62 |
63 | func TestWorkspaceFolders(t *testing.T) {
64 | testCases := []struct {
65 | name string
66 | uri string
67 | workspaceFolders []string
68 | folder string
69 | absolutePath string
70 | relativePath string
71 | }{
72 | {
73 | name: "simple prefix, folder without trailing slash",
74 | uri: "file:///a/b/c/Dockerfile",
75 | workspaceFolders: []string{"/a/b/c"},
76 | folder: "/a/b/c",
77 | absolutePath: "/a/b/c/Dockerfile",
78 | relativePath: "Dockerfile",
79 | },
80 | {
81 | name: "simple prefix, folder with trailing slash",
82 | uri: "file:///a/b/c/Dockerfile",
83 | workspaceFolders: []string{"/a/b/c/"},
84 | folder: "/a/b/c/",
85 | absolutePath: "/a/b/c/Dockerfile",
86 | relativePath: "Dockerfile",
87 | },
88 | {
89 | name: "shared prefix",
90 | uri: "file:///a/b/c2/Dockerfile",
91 | workspaceFolders: []string{"/a/b/c", "/a/b/c2"},
92 | folder: "/a/b/c2",
93 | absolutePath: "/a/b/c2/Dockerfile",
94 | relativePath: "Dockerfile",
95 | },
96 | {
97 | name: "subfolder",
98 | uri: "file:///a/b/c/d/Dockerfile",
99 | workspaceFolders: []string{"/a/b/c"},
100 | folder: "/a/b/c",
101 | absolutePath: "/a/b/c/d/Dockerfile",
102 | relativePath: "d/Dockerfile",
103 | },
104 | }
105 |
106 | for _, tc := range testCases {
107 | t.Run(tc.name, func(t *testing.T) {
108 | folder, absolutePath, relativePath := WorkspaceFolder(tc.uri, tc.workspaceFolders)
109 | require.Equal(t, tc.folder, folder)
110 | require.Equal(t, tc.absolutePath, absolutePath)
111 | require.Equal(t, tc.relativePath, relativePath)
112 | })
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/internal/telemetry/client.go:
--------------------------------------------------------------------------------
1 | package telemetry
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "net/http"
9 | "os"
10 | "sync"
11 | "time"
12 |
13 | "github.com/docker/docker-language-server/internal/configuration"
14 | "github.com/docker/docker-language-server/internal/pkg/cli/metadata"
15 | )
16 |
17 | type TelemetryClient interface {
18 | Enqueue(event string, properties map[string]any)
19 | Publish(ctx context.Context) (int, error)
20 | UpdateTelemetrySetting(value string)
21 | }
22 |
23 | type TelemetryClientImpl struct {
24 | mutex sync.Mutex
25 | telemetry configuration.TelemetrySetting
26 | records []Record
27 | }
28 |
29 | func NewClient() TelemetryClient {
30 | return &TelemetryClientImpl{telemetry: configuration.TelemetrySettingAll}
31 | }
32 |
33 | func (c *TelemetryClientImpl) UpdateTelemetrySetting(value string) {
34 | switch value {
35 | case "all":
36 | c.telemetry = configuration.TelemetrySettingAll
37 | case "error":
38 | c.telemetry = configuration.TelemetrySettingError
39 | case "off":
40 | c.telemetry = configuration.TelemetrySettingOff
41 | default:
42 | c.telemetry = configuration.TelemetrySettingAll
43 | }
44 | }
45 |
46 | func (c *TelemetryClientImpl) allow(err bool) bool {
47 | if c.telemetry == configuration.TelemetrySettingAll {
48 | return true
49 | }
50 |
51 | return c.telemetry == configuration.TelemetrySettingError && err
52 | }
53 |
54 | func (c *TelemetryClientImpl) Enqueue(event string, properties map[string]any) {
55 | c.mutex.Lock()
56 | defer c.mutex.Unlock()
57 |
58 | value, ok := properties["type"].(string)
59 | if c.allow(ok && event == EventServerHeartbeat && value == ServerHeartbeatTypePanic) {
60 | c.records = append(c.records, Record{
61 | Event: event,
62 | Source: "editor_integration",
63 | Properties: properties,
64 | Timestamp: int64(time.Now().UnixMilli()),
65 | })
66 | }
67 | }
68 |
69 | func (c *TelemetryClientImpl) trimRecords() []Record {
70 | c.mutex.Lock()
71 | defer c.mutex.Unlock()
72 |
73 | records := c.records
74 | if len(c.records) > 500 {
75 | records = c.records[0:500]
76 | c.records = c.records[500:]
77 | } else {
78 | c.records = nil
79 | }
80 | return records
81 | }
82 |
83 | func (c *TelemetryClientImpl) Publish(ctx context.Context) (int, error) {
84 | if os.Getenv("DOCKER_LANGUAGE_SERVER_TELEMETRY") == "false" || metadata.TelemetryEndpoint == "" || metadata.TelemetryKey == "" {
85 | c.records = nil
86 | return 0, nil
87 | }
88 |
89 | if len(c.records) == 0 {
90 | return 0, nil
91 | }
92 |
93 | records := c.trimRecords()
94 |
95 | payload := &TelemetryPayload{Records: records}
96 | b, err := json.Marshal(payload)
97 | if err != nil {
98 | return 0, fmt.Errorf("failed to marshal telemetry payload: %w", err)
99 | }
100 |
101 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, metadata.TelemetryEndpoint, bytes.NewBuffer(b))
102 | if err != nil {
103 | return 0, fmt.Errorf("failed to create telemetry request: %w", err)
104 | }
105 |
106 | req.Header.Set("Content-Type", "application/json")
107 | req.Header.Set("User-Agent", fmt.Sprintf("docker-language-server/v%v", metadata.Version))
108 | req.Header.Set("x-api-key", metadata.TelemetryKey)
109 | res, err := http.DefaultClient.Do(req)
110 | if err != nil {
111 | return 0, fmt.Errorf("failed to send telemetry request: %w", err)
112 | }
113 |
114 | defer func() { _ = res.Body.Close() }()
115 | if res.StatusCode >= 400 {
116 | return 0, fmt.Errorf("telemetry http request failed (%v status code)", res.StatusCode)
117 | }
118 | return len(records), nil
119 | }
120 |
--------------------------------------------------------------------------------
/internal/scout/languageGatewayClient_test.go:
--------------------------------------------------------------------------------
1 | package scout
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net"
7 | "os"
8 | "testing"
9 |
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestPostImage(t *testing.T) {
14 | testCases := []struct {
15 | image string
16 | err error
17 | response ImageResponse
18 | }{
19 | {
20 | // this triggers a 400 which is ignored
21 | image: "alpine:3.16.1::",
22 | err: nil,
23 | },
24 | {
25 | image: "alpine:3.16.1",
26 | err: nil,
27 | response: ImageResponse{
28 | Image: Image{
29 | Registry: "docker.io",
30 | Repository: "library/alpine",
31 | Short: "alpine:3.16.1",
32 | Tag: "3.16.1",
33 | },
34 | Diagnostics: []Diagnostic{
35 | {
36 | Kind: "not_pinned_digest",
37 | Message: "The image can be pinned to a digest",
38 | Severity: "hint",
39 | },
40 | },
41 | Edits: []Edit{
42 | {
43 | Title: "Pin the base image digest",
44 | Edit: "alpine:3.16.1@sha256:7580ece7963bfa863801466c0a488f11c86f85d9988051a9f9c68cb27f6b7872",
45 | Diagnostic: "not_pinned_digest",
46 | },
47 | },
48 | Infos: []Info{
49 | {
50 | Description: Description{Plaintext: "Current image vulnerabilities: 1C 3H 9M 0L"},
51 | Kind: "critical_high_vulnerabilities",
52 | },
53 | },
54 | },
55 | },
56 | }
57 |
58 | c := NewLanguageGatewayClient()
59 | for _, tc := range testCases {
60 | response, err := c.PostImage(context.Background(), "jwt", tc.image)
61 | if os.Getenv("DOCKER_NETWORK_NONE") == "true" {
62 | var dns *net.DNSError
63 | require.True(t, errors.As(err, &dns))
64 | continue
65 | }
66 |
67 | require.Equal(t, tc.err, err)
68 | if tc.err != nil {
69 | require.Len(t, tc.response.Diagnostics, 0)
70 | require.Len(t, tc.response.Edits, 0)
71 | require.Len(t, tc.response.Infos, 0)
72 | continue
73 | }
74 |
75 | require.Equal(t, tc.response.Image.Registry, response.Image.Registry)
76 | require.Equal(t, tc.response.Image.Repository, response.Image.Repository)
77 | require.Equal(t, tc.response.Image.Short, response.Image.Short)
78 | require.Equal(t, tc.response.Image.Tag, response.Image.Tag)
79 |
80 | for _, expectedDiagnostic := range tc.response.Diagnostics {
81 | found := false
82 | for _, diagnostic := range response.Diagnostics {
83 | if expectedDiagnostic.Kind == diagnostic.Kind {
84 | require.Equal(t, expectedDiagnostic.Message, diagnostic.Message)
85 | require.Equal(t, expectedDiagnostic.Severity, diagnostic.Severity)
86 | found = true
87 | break
88 | }
89 | }
90 |
91 | if !found {
92 | t.Errorf("expected diagnostic kind not found: %v", expectedDiagnostic.Kind)
93 | }
94 | }
95 |
96 | for _, expectedEdit := range tc.response.Edits {
97 | found := false
98 | for _, edit := range response.Edits {
99 | if expectedEdit.Edit == edit.Edit {
100 | require.Equal(t, expectedEdit.Diagnostic, edit.Diagnostic)
101 | require.Equal(t, expectedEdit.Title, edit.Title)
102 | found = true
103 | break
104 | }
105 | }
106 |
107 | if !found {
108 | t.Errorf("expected edit not found: %v", expectedEdit.Edit)
109 | }
110 | }
111 |
112 | for _, expectedInfo := range tc.response.Infos {
113 | found := false
114 | for _, info := range response.Infos {
115 | if expectedInfo.Kind == info.Kind {
116 | require.Equal(t, expectedInfo.Description.Plaintext, info.Description.Plaintext)
117 | found = true
118 | break
119 | }
120 | }
121 |
122 | if !found {
123 | t.Errorf("expected info kind not found: %v", expectedInfo.Kind)
124 | }
125 | }
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/internal/compose/documentSymbol.go:
--------------------------------------------------------------------------------
1 | package compose
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/docker/docker-language-server/internal/pkg/document"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
8 | "github.com/goccy/go-yaml/ast"
9 | "github.com/goccy/go-yaml/token"
10 | )
11 |
12 | var symbolKinds = map[string]protocol.SymbolKind{
13 | "services": protocol.SymbolKindClass,
14 | "networks": protocol.SymbolKindInterface,
15 | "volumes": protocol.SymbolKindFile,
16 | "configs": protocol.SymbolKindVariable,
17 | "secrets": protocol.SymbolKindKey,
18 | "models": protocol.SymbolKindModule,
19 | }
20 |
21 | func findSymbols(value string, n *ast.MappingValueNode, mapping map[string]protocol.SymbolKind) (result []any) {
22 | if kind, ok := mapping[value]; ok {
23 | if mappingNode, ok := n.Value.(*ast.MappingNode); ok {
24 | for _, service := range mappingNode.Values {
25 | result = append(result, createSymbol(service.Key.GetToken(), kind))
26 | }
27 | } else if n, ok := n.Value.(*ast.MappingValueNode); ok {
28 | result = append(result, createSymbol(n.Key.GetToken(), kind))
29 | }
30 | } else if value == "include" {
31 | if sequenceNode, ok := n.Value.(*ast.SequenceNode); ok {
32 | for _, token := range includedFiles(sequenceNode.Values) {
33 | result = append(result, createSymbol(token, protocol.SymbolKindModule))
34 | }
35 | }
36 | }
37 | return result
38 | }
39 |
40 | func includedFiles(nodes []ast.Node) []*token.Token {
41 | tokens := []*token.Token{}
42 | for _, entry := range nodes {
43 | if mappingNode, ok := resolveAnchor(entry).(*ast.MappingNode); ok {
44 | for _, value := range mappingNode.Values {
45 | if resolveAnchor(value.Key).GetToken().Value == "path" {
46 | if paths, ok := resolveAnchor(value.Value).(*ast.SequenceNode); ok {
47 | // include:
48 | // - path:
49 | // - ../commons/compose.yaml
50 | // - ./commons-override.yaml
51 | for _, path := range paths.Values {
52 | tokens = append(tokens, resolveAnchor(path).GetToken())
53 | }
54 | } else {
55 | // include:
56 | // - path: ../commons/compose.yaml
57 | // project_directory: ..
58 | // env_file: ../another/.env
59 | tokens = append(tokens, resolveAnchor(value.Value).GetToken())
60 | }
61 | }
62 | }
63 | } else {
64 | // include:
65 | // - abc.yml
66 | // - def.yml
67 | stringNode := stringNode(entry)
68 | if stringNode != nil {
69 | tokens = append(tokens, stringNode.GetToken())
70 | }
71 |
72 | }
73 | }
74 | return tokens
75 | }
76 |
77 | func DocumentSymbol(ctx context.Context, doc document.ComposeDocument) (result []any, err error) {
78 | file := doc.File()
79 | if file == nil || len(file.Docs) == 0 {
80 | return nil, nil
81 | }
82 |
83 | for _, documentNode := range file.Docs {
84 | if mappingNode, ok := documentNode.Body.(*ast.MappingNode); ok {
85 | for _, n := range mappingNode.Values {
86 | if s, ok := n.Key.(*ast.StringNode); ok {
87 | result = append(result, findSymbols(s.Value, n, symbolKinds)...)
88 | }
89 | }
90 | }
91 | }
92 | return result, nil
93 | }
94 |
95 | func createSymbol(t *token.Token, kind protocol.SymbolKind) *protocol.DocumentSymbol {
96 | rng := protocol.Range{
97 | Start: protocol.Position{
98 | Line: uint32(t.Position.Line - 1),
99 | Character: uint32(t.Position.Column - 1),
100 | },
101 | End: protocol.Position{
102 | Line: uint32(t.Position.Line - 1),
103 | Character: uint32(t.Position.Column - 1 + len(t.Value)),
104 | },
105 | }
106 | return &protocol.DocumentSymbol{
107 | Name: t.Value,
108 | Kind: kind,
109 | Range: rng,
110 | SelectionRange: rng,
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/e2e-tests/inlineCompletion_test.go:
--------------------------------------------------------------------------------
1 | package server_test
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 | "testing"
11 |
12 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
13 | "github.com/sourcegraph/jsonrpc2"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | func TestInlineCompletion(t *testing.T) {
18 | s := startServer()
19 |
20 | client := bytes.NewBuffer(make([]byte, 0, 1024))
21 | server := bytes.NewBuffer(make([]byte, 0, 1024))
22 | serverStream := &TestStream{incoming: server, outgoing: client, closed: false}
23 | defer serverStream.Close()
24 | go s.ServeStream(serverStream)
25 |
26 | clientStream := jsonrpc2.NewBufferedStream(&TestStream{incoming: client, outgoing: server, closed: false}, jsonrpc2.VSCodeObjectCodec{})
27 | defer clientStream.Close()
28 | conn := jsonrpc2.NewConn(context.Background(), clientStream, &ConfigurationHandler{t: t})
29 | initialize(t, conn, protocol.InitializeParams{})
30 |
31 | testCases := []struct {
32 | name string
33 | content string
34 | line protocol.UInteger
35 | character protocol.UInteger
36 | dockerfileContent string
37 | items []protocol.InlineCompletionItem
38 | }{
39 | {
40 | name: "one build stage",
41 | content: "",
42 | line: 0,
43 | character: 0,
44 | dockerfileContent: "FROM scratch AS simple",
45 | items: []protocol.InlineCompletionItem{
46 | {
47 | InsertText: "target \"simple\" {\n target = \"simple\"\n}\n",
48 | Range: &protocol.Range{
49 | Start: protocol.Position{Line: 0, Character: 0},
50 | End: protocol.Position{Line: 0, Character: 0},
51 | },
52 | },
53 | },
54 | },
55 | {
56 | name: "outside document bounds",
57 | content: "",
58 | line: 1,
59 | character: 0,
60 | dockerfileContent: "FROM scratch AS simple",
61 | items: nil,
62 | },
63 | }
64 |
65 | temporaryDockerfile := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(os.TempDir(), "Dockerfile")), "/"))
66 | temporaryBakeFile := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(os.TempDir(), "docker-bake.hcl")), "/"))
67 |
68 | for _, tc := range testCases {
69 | t.Run(tc.name, func(t *testing.T) {
70 | didOpenDockerfile := protocol.DidOpenTextDocumentParams{
71 | TextDocument: protocol.TextDocumentItem{
72 | URI: temporaryDockerfile,
73 | Text: tc.dockerfileContent,
74 | LanguageID: "dockerfile",
75 | Version: 1,
76 | },
77 | }
78 | err := conn.Notify(context.Background(), protocol.MethodTextDocumentDidOpen, didOpenDockerfile)
79 | require.NoError(t, err)
80 |
81 | didOpenBakeFile := protocol.DidOpenTextDocumentParams{
82 | TextDocument: protocol.TextDocumentItem{
83 | URI: temporaryBakeFile,
84 | Text: tc.content,
85 | LanguageID: "dockerbake",
86 | Version: 1,
87 | },
88 | }
89 | err = conn.Notify(context.Background(), protocol.MethodTextDocumentDidOpen, didOpenBakeFile)
90 | require.NoError(t, err)
91 |
92 | var result []protocol.InlineCompletionItem
93 | err = conn.Call(context.Background(), protocol.MethodTextDocumentInlineCompletion, protocol.InlineCompletionParams{
94 | TextDocumentPositionParams: protocol.TextDocumentPositionParams{
95 | TextDocument: protocol.TextDocumentIdentifier{URI: didOpenBakeFile.TextDocument.URI},
96 | Position: protocol.Position{Line: tc.line, Character: tc.character},
97 | },
98 | }, &result)
99 | require.NoError(t, err)
100 | require.Equal(t, tc.items, result)
101 | })
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/internal/pkg/document/manager_test.go:
--------------------------------------------------------------------------------
1 | package document
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 | "testing"
10 |
11 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
12 | "github.com/stretchr/testify/assert"
13 | "github.com/stretchr/testify/require"
14 | "go.lsp.dev/uri"
15 | )
16 |
17 | func TestReadDocument(t *testing.T) {
18 | testFile := filepath.Join(os.TempDir(), "TestReadDocument")
19 | err := os.WriteFile(testFile, []byte("hello world"), 0644)
20 | require.NoError(t, err)
21 |
22 | contents, err := ReadDocument(uri.URI(fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(testFile), "/"))))
23 | require.NoError(t, err)
24 | require.Equal(t, "hello world", string(contents))
25 | }
26 |
27 | func TestURIfilename(t *testing.T) {
28 | file := filepath.Join(os.TempDir(), "mod")
29 | var fn string
30 | var err error
31 | fn, err = filename(uri.URI(fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(file), "/"))))
32 | require.NoError(t, err)
33 | assert.Equal(t, file, fn)
34 | fn, err = filename(uri.URI("ext://mod"))
35 | require.Error(t, err)
36 | assert.Equal(t, "", fn)
37 | assert.Equal(t, "only file URIs are supported, got ext", err.Error())
38 | }
39 |
40 | func TestWrite(t *testing.T) {
41 | testCases := []struct {
42 | name string
43 | content string
44 | newContent string
45 | changed bool
46 | }{
47 | {
48 | name: "FROM instruction has changed",
49 | content: "FROM alpine:3.16.1",
50 | newContent: "FROM alpine:3.16.2",
51 | changed: true,
52 | },
53 | {
54 | name: "whitespace added",
55 | content: "FROM alpine:3.16.1",
56 | newContent: "FROM alpine:3.16.1 ",
57 | changed: false,
58 | },
59 | {
60 | name: "newline moves content",
61 | content: "FROM alpine:3.16.1",
62 | newContent: "\nFROM alpine:3.16.1",
63 | changed: true, // line numbers have changed
64 | },
65 | {
66 | name: "newline at the end",
67 | content: "FROM alpine:3.16.1 AS base",
68 | newContent: "FROM alpine:3.16.1 \\\nAS base",
69 | changed: true, // FROM now spans two lines
70 | },
71 | {
72 | name: "comments are not considered a change",
73 | content: "FROM scratch",
74 | newContent: "FROM scratch\n# comment",
75 | changed: false,
76 | },
77 | {
78 | name: "syntax parser directive changed",
79 | content: "#escape=\\\nFROM alpine:3.16.1 \\\nAS base",
80 | newContent: "#escape=`\nFROM alpine:3.16.1 \\\nAS base",
81 | changed: true,
82 | },
83 | {
84 | name: "check parser directive changed",
85 | content: "#\nFROM scratch",
86 | newContent: "#check=skip=JSONArgsRecommended\nFROM scratch",
87 | changed: true,
88 | },
89 | {
90 | name: "add non check parser",
91 | content: "FROM scratch",
92 | newContent: "FROM scratch\n#check=skip=JSONArgsRecommended",
93 | changed: false,
94 | },
95 | }
96 |
97 | for _, tc := range testCases {
98 | t.Run(tc.name, func(t *testing.T) {
99 | manager := NewDocumentManager()
100 | defer manager.Remove("Dockerfile")
101 |
102 | changed, err := manager.Write(context.Background(), "Dockerfile", protocol.DockerfileLanguage, 1, []byte(tc.content))
103 | require.NoError(t, err)
104 | require.True(t, changed)
105 | version, err := manager.Version(context.Background(), "Dockerfile")
106 | require.NoError(t, err)
107 | require.Equal(t, int32(1), version)
108 |
109 | changed, err = manager.Write(context.Background(), "Dockerfile", protocol.DockerfileLanguage, 2, []byte(tc.newContent))
110 | require.NoError(t, err)
111 | require.Equal(t, tc.changed, changed)
112 | version, err = manager.Version(context.Background(), "Dockerfile")
113 | require.NoError(t, err)
114 | require.Equal(t, int32(2), version)
115 | })
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/internal/bake/hcl/documentSymbol_test.go:
--------------------------------------------------------------------------------
1 | package hcl
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 | "testing"
10 |
11 | "github.com/docker/docker-language-server/internal/pkg/document"
12 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
13 | "github.com/stretchr/testify/require"
14 | "go.lsp.dev/uri"
15 | )
16 |
17 | func TestDocumentSymbol(t *testing.T) {
18 | testCases := []struct {
19 | name string
20 | content string
21 | symbols []*protocol.DocumentSymbol
22 | }{
23 | {
24 | name: "targets block",
25 | content: "target \"webapp\" {\n}\ntarget \"api\" {\n}\n",
26 | symbols: []*protocol.DocumentSymbol{
27 | {
28 | Name: "webapp",
29 | Kind: protocol.SymbolKindFunction,
30 | Range: protocol.Range{
31 | Start: protocol.Position{Line: 0, Character: 0},
32 | End: protocol.Position{Line: 0, Character: 0},
33 | },
34 | SelectionRange: protocol.Range{
35 | Start: protocol.Position{Line: 0, Character: 0},
36 | End: protocol.Position{Line: 0, Character: 0},
37 | },
38 | },
39 | {
40 | Name: "api",
41 | Kind: protocol.SymbolKindFunction,
42 | Range: protocol.Range{
43 | Start: protocol.Position{Line: 2, Character: 0},
44 | End: protocol.Position{Line: 2, Character: 0},
45 | },
46 | SelectionRange: protocol.Range{
47 | Start: protocol.Position{Line: 2, Character: 0},
48 | End: protocol.Position{Line: 2, Character: 0},
49 | },
50 | },
51 | },
52 | },
53 | {
54 | name: "target block without name",
55 | content: "target{}",
56 | symbols: []*protocol.DocumentSymbol{
57 | {
58 | Name: "target",
59 | Kind: protocol.SymbolKindFunction,
60 | Range: protocol.Range{
61 | Start: protocol.Position{Line: 0, Character: 0},
62 | End: protocol.Position{Line: 0, Character: 0},
63 | },
64 | SelectionRange: protocol.Range{
65 | Start: protocol.Position{Line: 0, Character: 0},
66 | End: protocol.Position{Line: 0, Character: 0},
67 | },
68 | },
69 | },
70 | },
71 | {
72 | name: "variable block without name",
73 | content: "variable{}",
74 | symbols: []*protocol.DocumentSymbol{
75 | {
76 | Name: "variable",
77 | Kind: protocol.SymbolKindVariable,
78 | Range: protocol.Range{
79 | Start: protocol.Position{Line: 0, Character: 0},
80 | End: protocol.Position{Line: 0, Character: 0},
81 | },
82 | SelectionRange: protocol.Range{
83 | Start: protocol.Position{Line: 0, Character: 0},
84 | End: protocol.Position{Line: 0, Character: 0},
85 | },
86 | },
87 | },
88 | },
89 | {
90 | name: "attribute with a value",
91 | content: "attribute = \"value\"",
92 | symbols: []*protocol.DocumentSymbol{
93 | {
94 | Name: "attribute",
95 | Kind: protocol.SymbolKindProperty,
96 | Range: protocol.Range{
97 | Start: protocol.Position{Line: 0, Character: 0},
98 | End: protocol.Position{Line: 0, Character: 0},
99 | },
100 | SelectionRange: protocol.Range{
101 | Start: protocol.Position{Line: 0, Character: 0},
102 | End: protocol.Position{Line: 0, Character: 0},
103 | },
104 | },
105 | },
106 | },
107 | }
108 |
109 | temporaryBakeFile := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(os.TempDir(), "docker-bake.hcl")), "/"))
110 | for _, tc := range testCases {
111 | t.Run(tc.name, func(t *testing.T) {
112 | doc := document.NewBakeHCLDocument(uri.URI(temporaryBakeFile), 1, []byte(tc.content))
113 | symbols, err := DocumentSymbol(context.Background(), temporaryBakeFile, doc)
114 | require.NoError(t, err)
115 | var result []any
116 | for _, symbol := range tc.symbols {
117 | result = append(result, symbol)
118 | }
119 | require.Equal(t, result, symbols)
120 | })
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/internal/pkg/document/document.go:
--------------------------------------------------------------------------------
1 | package document
2 |
3 | import (
4 | "fmt"
5 | "net/url"
6 | "strings"
7 | "unicode"
8 |
9 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
10 | "github.com/docker/docker-language-server/internal/types"
11 | "go.lsp.dev/uri"
12 | )
13 |
14 | type DocumentPath struct {
15 | Folder string
16 | FileName string
17 | WSLDollarSignHost bool
18 | }
19 |
20 | type Document interface {
21 | URI() uri.URI
22 | DocumentPath() (DocumentPath, error)
23 | Copy() Document
24 | Input() []byte
25 | Version() int32
26 | Update(version int32, input []byte) bool
27 | Close()
28 | LanguageIdentifier() protocol.LanguageIdentifier
29 | }
30 |
31 | type NewDocumentFunc func(mgr *Manager, u uri.URI, identifier protocol.LanguageIdentifier, version int32, input []byte) Document
32 |
33 | func NewDocument(mgr *Manager, u uri.URI, identifier protocol.LanguageIdentifier, version int32, input []byte) Document {
34 | switch identifier {
35 | case protocol.DockerBakeLanguage:
36 | return NewBakeHCLDocument(u, version, input)
37 | case protocol.DockerComposeLanguage:
38 | return NewComposeDocument(mgr, u, version, input)
39 | }
40 | return NewDockerfileDocument(u, version, input)
41 | }
42 |
43 | // DirectoryForPrefix returns the parent directory to be used given the
44 | // document's path and the prefix string that has been inserted into the
45 | // document thus far.
46 | //
47 | // prefixRequired is true if prefix can just be a name without any
48 | // slashes or backslashes.
49 | func DirectoryForPrefix(documentPath DocumentPath, prefix, defaultValue string, prefixRequired bool) string {
50 | idx := strings.LastIndex(prefix, "/")
51 | if idx == -1 {
52 | if prefixRequired {
53 | if len(prefix) > 2 && unicode.IsLetter(rune(prefix[0])) && prefix[1] == ':' {
54 | backslashIdx := strings.LastIndex(prefix, "\\")
55 | if backslashIdx != -1 {
56 | return prefix[0 : backslashIdx+1]
57 | }
58 | }
59 | return defaultValue
60 | }
61 | return documentPath.Folder
62 | } else if prefix[0] == '/' {
63 | return prefix[0 : idx+1]
64 | }
65 | _, folder := types.Concatenate(documentPath.Folder, prefix[0:idx], documentPath.WSLDollarSignHost)
66 | return folder
67 | }
68 |
69 | type document struct {
70 | uri uri.URI
71 | identifier protocol.LanguageIdentifier
72 | version int32
73 | // input is the file as it exists in the editor buffer.
74 | input []byte
75 | parseFn func(force bool) bool
76 | copyFn func() Document
77 | }
78 |
79 | var _ Document = &document{}
80 |
81 | func (d *document) Update(version int32, input []byte) bool {
82 | d.version = version
83 | d.input = input
84 | return d.parseFn(true)
85 | }
86 |
87 | func (d *document) Version() int32 {
88 | return d.version
89 | }
90 |
91 | func (d *document) Input() []byte {
92 | return d.input
93 | }
94 |
95 | func (d *document) URI() uri.URI {
96 | return d.uri
97 | }
98 |
99 | func (d *document) DocumentPath() (DocumentPath, error) {
100 | uriString := string(d.uri)
101 | url, err := url.Parse(uriString)
102 | if err != nil {
103 | if strings.HasPrefix(uriString, "file://wsl%24/") {
104 | path := uriString[len("file://wsl%24"):]
105 | idx := strings.LastIndex(path, "/")
106 | return DocumentPath{Folder: path[0:idx], FileName: path[idx+1:], WSLDollarSignHost: true}, nil
107 | }
108 | return DocumentPath{}, fmt.Errorf("invalid URI: %v", uriString)
109 | }
110 | folder, err := types.AbsoluteFolder(url)
111 | idx := strings.LastIndex(uriString, "/")
112 | return DocumentPath{Folder: folder, FileName: uriString[idx+1:]}, err
113 | }
114 |
115 | func (d *document) LanguageIdentifier() protocol.LanguageIdentifier {
116 | return d.identifier
117 | }
118 |
119 | func (d *document) Close() {
120 | }
121 |
122 | // Copy creates a shallow copy of the Document.
123 | //
124 | // The Contents byte slice is returned as-is.
125 | // A shallow copy of the Tree is made, as Tree-sitter trees are not thread-safe.
126 | func (d *document) Copy() Document {
127 | return d.copyFn()
128 | }
129 |
--------------------------------------------------------------------------------
/internal/pkg/document/dockerComposeDocument_test.go:
--------------------------------------------------------------------------------
1 | package document
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 | "testing"
10 |
11 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
12 | "github.com/stretchr/testify/require"
13 | "go.lsp.dev/uri"
14 | )
15 |
16 | func TestIncludedPaths(t *testing.T) {
17 | testCases := []struct {
18 | name string
19 | content string
20 | paths []string
21 | }{
22 | {
23 | name: "handle all path types",
24 | content: `
25 | include:
26 | - arrayItem.yaml
27 | - path: pathObject.yaml
28 | - path:
29 | - pathArrayItem.yaml`,
30 | paths: []string{"arrayItem.yaml", "pathObject.yaml", "pathArrayItem.yaml"},
31 | },
32 | }
33 |
34 | for _, tc := range testCases {
35 | t.Run(tc.name, func(t *testing.T) {
36 | opts := WithReadDocumentFunc(func(uri.URI) ([]byte, error) {
37 | return []byte(tc.content), nil
38 | })
39 | mgr := NewDocumentManager(opts)
40 | w, err := mgr.Write(context.Background(), "", protocol.DockerComposeLanguage, 1, []byte(tc.content))
41 | require.NoError(t, err)
42 | require.True(t, w)
43 | doc := mgr.Get(context.Background(), "")
44 | require.Equal(t, tc.paths, doc.(*composeDocument).includedPaths())
45 | })
46 | }
47 | }
48 |
49 | func TestIncludedFiles(t *testing.T) {
50 | testCases := []struct {
51 | name string
52 | content string
53 | resolved bool
54 | externalContent map[string]string
55 | }{
56 | {
57 | name: "ignore unrecognized URIs",
58 | content: `
59 | include:
60 | - arrayItem.yaml`,
61 | resolved: true,
62 | externalContent: map[string]string{
63 | "arrayItem.yaml": `name: arrayItem`,
64 | },
65 | },
66 | {
67 | name: "ignore unrecognized URIs",
68 | content: `
69 | include:
70 | - oci://www.docker.com/
71 | - https://www.docker.com/`,
72 | resolved: true,
73 | externalContent: map[string]string{},
74 | },
75 | {
76 | name: "recurse for more files",
77 | content: `
78 | include:
79 | - first.yaml`,
80 | resolved: true,
81 | externalContent: map[string]string{
82 | "first.yaml": `
83 | include:
84 | - second.yaml`,
85 | "second.yaml": "name: second",
86 | },
87 | },
88 | {
89 | name: "two-way self recursion",
90 | content: `
91 | include:
92 | - first.yaml`,
93 | resolved: false,
94 | externalContent: map[string]string{
95 | "first.yaml": `
96 | include:
97 | - compose.yaml`,
98 | },
99 | },
100 | {
101 | name: "three-way self recursion",
102 | content: `
103 | include:
104 | - first.yaml`,
105 | resolved: false,
106 | externalContent: map[string]string{
107 | "first.yaml": `
108 | include:
109 | - second.yaml`,
110 | "second.yaml": `
111 | include:
112 | - compose.yaml`,
113 | },
114 | },
115 | {
116 | name: "include node is invalid",
117 | content: "include:",
118 | resolved: true,
119 | externalContent: map[string]string{},
120 | },
121 | }
122 |
123 | folder := os.TempDir()
124 | temporaryComposeFileURI := fileURI(folder, "compose.yaml")
125 | for _, tc := range testCases {
126 | t.Run(tc.name, func(t *testing.T) {
127 | for path, content := range tc.externalContent {
128 | err := os.WriteFile(filepath.Join(folder, path), []byte(content), 0644)
129 | require.NoError(t, err)
130 | }
131 | mgr := NewDocumentManager()
132 | u := uri.URI(temporaryComposeFileURI)
133 | w, err := mgr.Write(context.Background(), u, protocol.DockerComposeLanguage, 1, []byte(tc.content))
134 | require.NoError(t, err)
135 | require.True(t, w)
136 | doc := mgr.Get(context.Background(), u)
137 | includedFiles, resolved := doc.(ComposeDocument).IncludedFiles()
138 | require.Equal(t, tc.resolved, resolved)
139 | if resolved {
140 | require.Len(t, includedFiles, len(tc.externalContent))
141 | } else {
142 | require.Len(t, includedFiles, 0)
143 | }
144 | })
145 | }
146 | }
147 |
148 | func fileURI(folder, name string) string {
149 | return fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(folder, name)), "/"))
150 | }
151 |
--------------------------------------------------------------------------------
/internal/compose/inlayHint.go:
--------------------------------------------------------------------------------
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, walked []string, chain []map[string]ast.Node) []map[string]ast.Node {
32 | if !slices.Contains(walked, service) {
33 | walked = append(walked, service)
34 | } else {
35 | return nil
36 | }
37 | if extends, ok := serviceProps[service]["extends"]; ok {
38 | if s, ok := extends.(*ast.StringNode); ok {
39 | // block self-referencing recursions
40 | if s.Value != service {
41 | chain = append(chain, hierarchyProperties(s.Value, serviceProps, walked, chain)...)
42 | }
43 | } else if mappingNode, ok := extends.(*ast.MappingNode); ok {
44 | external := false
45 | for _, value := range mappingNode.Values {
46 | if value.Key.GetToken().Value == "file" {
47 | external = true
48 | break
49 | }
50 | }
51 |
52 | if !external {
53 | for _, value := range mappingNode.Values {
54 | if value.Key.GetToken().Value == "service" {
55 | chain = append(chain, hierarchyProperties(value.Value.GetToken().Value, serviceProps, walked, chain)...)
56 | }
57 | }
58 | }
59 | }
60 | }
61 | chain = append(chain, serviceProps[service])
62 | return chain
63 | }
64 |
65 | func InlayHint(doc document.ComposeDocument, rng protocol.Range) ([]protocol.InlayHint, error) {
66 | file := doc.File()
67 | if file == nil || len(file.Docs) == 0 {
68 | return nil, nil
69 | }
70 |
71 | hints := []protocol.InlayHint{}
72 | for _, docNode := range file.Docs {
73 | if mappingNode, ok := docNode.Body.(*ast.MappingNode); ok {
74 | for _, node := range mappingNode.Values {
75 | if s, ok := node.Key.(*ast.StringNode); ok && s.Value == "services" {
76 | serviceProps := allServiceProperties(node.Value)
77 | for service, props := range serviceProps {
78 | chain := hierarchyProperties(service, serviceProps, []string{}, []map[string]ast.Node{})
79 | if len(chain) == 1 {
80 | continue
81 | }
82 | slices.Reverse(chain)
83 | chain = chain[1:]
84 | for name, value := range props {
85 | if name == "extends" {
86 | continue
87 | }
88 | // skip object attributes for now
89 | if _, ok := value.(*ast.MappingNode); ok {
90 | continue
91 | }
92 |
93 | for _, parentProps := range chain {
94 | if parentProp, ok := parentProps[name]; ok {
95 | if _, ok := parentProp.(*ast.MappingNode); !ok {
96 | length := len(value.GetToken().Value)
97 | if value.GetToken().Type == token.DoubleQuoteType {
98 | length += 2
99 | }
100 | hints = append(hints, protocol.InlayHint{
101 | Label: fmt.Sprintf("(parent value: %v)", parentProp.GetToken().Value),
102 | PaddingLeft: types.CreateBoolPointer(true),
103 | Position: protocol.Position{
104 | Line: uint32(value.GetToken().Position.Line) - 1,
105 | Character: uint32(value.GetToken().Position.Column + length - 1),
106 | },
107 | })
108 | break
109 | }
110 | }
111 | }
112 | }
113 | }
114 | break
115 | }
116 | }
117 | }
118 | }
119 | return hints, nil
120 | }
121 |
--------------------------------------------------------------------------------
/internal/bake/hcl/hover.go:
--------------------------------------------------------------------------------
1 | package hcl
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 |
8 | "github.com/docker/docker-language-server/internal/bake/hcl/parser"
9 | "github.com/docker/docker-language-server/internal/pkg/document"
10 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
11 | "github.com/hashicorp/hcl-lang/decoder"
12 | "github.com/hashicorp/hcl/v2/hclsyntax"
13 | )
14 |
15 | func Hover(ctx context.Context, params *protocol.HoverParams, document document.BakeHCLDocument) (*protocol.Hover, error) {
16 | body, ok := document.File().Body.(*hclsyntax.Body)
17 | if !ok {
18 | return nil, errors.New("unrecognized body in HCL document")
19 | }
20 |
21 | input := document.Input()
22 | variable := hoveredVariableName(input, body.Blocks, params.Position)
23 | if variable != "" {
24 | for _, block := range body.Blocks {
25 | if block.Type == "variable" && len(block.Labels) > 0 && block.Labels[0] == variable {
26 | if attribute, ok := block.Body.Attributes["default"]; ok {
27 | value := string(input[attribute.Expr.Range().Start.Byte:attribute.Expr.Range().End.Byte])
28 | return &protocol.Hover{
29 | Contents: protocol.MarkupContent{
30 | Kind: "markdown",
31 | Value: value,
32 | },
33 | }, nil
34 | }
35 | }
36 | }
37 | }
38 |
39 | filename := string(params.TextDocument.URI)
40 | hclPos := parser.ConvertToHCLPosition(string(document.Input()), int(params.Position.Line), int(params.Position.Character))
41 | hover, err := document.Decoder().HoverAtPos(ctx, filename, hclPos)
42 | if err != nil {
43 | var positionalError *decoder.PositionalError
44 | if !errors.As(err, &positionalError) {
45 | var posOutOfRangeError *decoder.PosOutOfRangeError
46 | if !errors.As(err, &posOutOfRangeError) {
47 | return nil, fmt.Errorf("hover analysis encountered an error: %w", err)
48 | }
49 | }
50 | return nil, nil
51 | }
52 | if hover == nil {
53 | return nil, nil
54 | }
55 | return &protocol.Hover{
56 | Contents: protocol.MarkupContent{
57 | Kind: "markdown",
58 | Value: hover.Content.Value,
59 | },
60 | }, nil
61 | }
62 |
63 | func hoveredVariableName(input []byte, blocks hclsyntax.Blocks, position protocol.Position) string {
64 | for _, block := range blocks {
65 | if isInsideBodyRangeLines(block.Body, int(position.Line+1)) {
66 | if block.Type == "variable" && len(block.LabelRanges) > 0 && isInsideRange(block.LabelRanges[0], position) {
67 | label := string(input[block.LabelRanges[0].Start.Byte:block.LabelRanges[0].End.Byte])
68 | if Quoted(label) {
69 | if block.LabelRanges[0].Start.Column == int(position.Character+1) || block.LabelRanges[0].End.Column == int(position.Character+1) {
70 | return ""
71 | }
72 | }
73 | return block.Labels[0]
74 | }
75 |
76 | for _, attribute := range block.Body.Attributes {
77 | if isInsideRange(attribute.Expr.Range(), position) {
78 | name := extractVariableName(input, attribute.Expr)
79 | if name != "" {
80 | return name
81 | }
82 | }
83 | }
84 | }
85 | }
86 | return ""
87 | }
88 |
89 | func extractVariableName(input []byte, expression hclsyntax.Expression) string {
90 | if tupleCons, ok := expression.(*hclsyntax.TupleConsExpr); ok {
91 | for _, expr := range tupleCons.Exprs {
92 | name := extractVariableName(input, expr)
93 | if name != "" {
94 | return name
95 | }
96 | }
97 | }
98 |
99 | if scope, ok := expression.(*hclsyntax.ScopeTraversalExpr); ok {
100 | return string(input[scope.SrcRange.Start.Byte:scope.SrcRange.End.Byte])
101 | }
102 |
103 | if wrap, ok := expression.(*hclsyntax.TemplateWrapExpr); ok {
104 | return extractVariableName(input, wrap.Wrapped)
105 | }
106 |
107 | if template, ok := expression.(*hclsyntax.TemplateExpr); ok {
108 | for _, part := range template.Parts {
109 | name := extractVariableName(input, part)
110 | if name != "" {
111 | return name
112 | }
113 | }
114 | }
115 |
116 | if objectCons, ok := expression.(*hclsyntax.ObjectConsExpr); ok {
117 | for _, item := range objectCons.Items {
118 | name := extractVariableName(input, item.ValueExpr)
119 | if name != "" {
120 | return name
121 | }
122 | }
123 | }
124 |
125 | return ""
126 | }
127 |
--------------------------------------------------------------------------------
/internal/dockerfile/inlayHint_test.go:
--------------------------------------------------------------------------------
1 | package dockerfile
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "path/filepath"
7 | "strings"
8 | "testing"
9 |
10 | "github.com/docker/docker-language-server/internal/hub"
11 | "github.com/docker/docker-language-server/internal/pkg/document"
12 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
13 | "github.com/docker/docker-language-server/internal/types"
14 | "github.com/stretchr/testify/require"
15 | "go.lsp.dev/uri"
16 | )
17 |
18 | func TestInlayHint(t *testing.T) {
19 | // set timezone to UTC for this test so the locale is consistent
20 | origTZ := os.Getenv("TZ")
21 | require.NoError(t, os.Setenv("TZ", "UTC"))
22 | t.Cleanup(func() {
23 | require.NoError(t, os.Setenv("TZ", origTZ))
24 | })
25 |
26 | testCases := []struct {
27 | name string
28 | content string
29 | rng protocol.Range
30 | inlayHints []protocol.InlayHint
31 | }{
32 | {
33 | name: "alpine",
34 | content: "FROM alpine",
35 | rng: protocol.Range{
36 | Start: protocol.Position{Line: 0, Character: 0},
37 | End: protocol.Position{Line: 0, Character: 11},
38 | },
39 | inlayHints: []protocol.InlayHint{},
40 | },
41 | {
42 | name: "alpine:3.16",
43 | content: "FROM alpine:3.16",
44 | rng: protocol.Range{
45 | Start: protocol.Position{Line: 0, Character: 0},
46 | End: protocol.Position{Line: 0, Character: 16},
47 | },
48 | inlayHints: []protocol.InlayHint{
49 | {
50 | Label: "(last pushed 1 year ago)",
51 | PaddingLeft: types.CreateBoolPointer(true),
52 | Position: protocol.Position{Line: 0, Character: 16},
53 | Tooltip: types.CreateAnyPointer("2024-01-27 00:47:58 UTC"),
54 | },
55 | },
56 | },
57 | {
58 | name: "alpine@sha256:72af6266bafde8c78d5f20a2a85d0576533ce1ecd6ed8bcf7baf62a743f3b24d",
59 | content: "FROM alpine@sha256:72af6266bafde8c78d5f20a2a85d0576533ce1ecd6ed8bcf7baf62a743f3b24d",
60 | rng: protocol.Range{
61 | Start: protocol.Position{Line: 0, Character: 0},
62 | End: protocol.Position{Line: 0, Character: 16},
63 | },
64 | inlayHints: []protocol.InlayHint{},
65 | },
66 | {
67 | name: "alpine:3.16@sha256:72af6266bafde8c78d5f20a2a85d0576533ce1ecd6ed8bcf7baf62a743f3b24d",
68 | content: "FROM alpine:3.16@sha256:72af6266bafde8c78d5f20a2a85d0576533ce1ecd6ed8bcf7baf62a743f3b24d",
69 | rng: protocol.Range{
70 | Start: protocol.Position{Line: 0, Character: 0},
71 | End: protocol.Position{Line: 0, Character: 16},
72 | },
73 | inlayHints: []protocol.InlayHint{},
74 | },
75 | {
76 | name: "prom/prometheus",
77 | content: "FROM prom/prometheus",
78 | rng: protocol.Range{
79 | Start: protocol.Position{Line: 0, Character: 0},
80 | End: protocol.Position{Line: 0, Character: 16},
81 | },
82 | inlayHints: []protocol.InlayHint{},
83 | },
84 | {
85 | name: "prom/prometheus:v2.6.1",
86 | content: "FROM prom/prometheus:v2.6.1",
87 | rng: protocol.Range{
88 | Start: protocol.Position{Line: 0, Character: 0},
89 | End: protocol.Position{Line: 0, Character: 27},
90 | },
91 | inlayHints: []protocol.InlayHint{
92 | {
93 | Label: "(last pushed 6 years ago)",
94 | PaddingLeft: types.CreateBoolPointer(true),
95 | Position: protocol.Position{Line: 0, Character: 27},
96 | Tooltip: types.CreateAnyPointer("2019-01-15 20:13:35 UTC"),
97 | },
98 | },
99 | },
100 | {
101 | name: "content outside range should not return anything",
102 | content: "\n\nFROM alpine:3.16",
103 | rng: protocol.Range{
104 | Start: protocol.Position{Line: 0, Character: 0},
105 | End: protocol.Position{Line: 1, Character: 0},
106 | },
107 | inlayHints: []protocol.InlayHint{},
108 | },
109 | }
110 |
111 | dockerfileURI := uri.URI(fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(os.TempDir(), "Dockerfile")), "/")))
112 | for _, tc := range testCases {
113 | t.Run(tc.name, func(t *testing.T) {
114 | hubService := hub.NewService()
115 | doc := document.NewDockerfileDocument(dockerfileURI, 1, []byte(tc.content))
116 | inlayHints, err := InlayHint(hubService, doc, tc.rng)
117 | require.NoError(t, err)
118 | require.Equal(t, tc.inlayHints, inlayHints)
119 | })
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/internal/bake/hcl/documentHighlight.go:
--------------------------------------------------------------------------------
1 | package hcl
2 |
3 | import (
4 | "errors"
5 | "strings"
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/hashicorp/hcl/v2"
11 | "github.com/hashicorp/hcl/v2/hclsyntax"
12 | )
13 |
14 | func DocumentHighlight(document document.BakeHCLDocument, position protocol.Position) ([]protocol.DocumentHighlight, error) {
15 | body, ok := document.File().Body.(*hclsyntax.Body)
16 | if !ok {
17 | return nil, errors.New("unrecognized body in HCL document")
18 | }
19 |
20 | bytes := document.Input()
21 | target := ""
22 | for _, block := range body.Blocks {
23 | if block.Type == "group" {
24 | if targets, ok := block.Body.Attributes["targets"]; ok {
25 | if expr, ok := targets.Expr.(*hclsyntax.TupleConsExpr); ok {
26 | for _, item := range expr.Exprs {
27 | if template, ok := item.(*hclsyntax.TemplateExpr); ok && len(template.Parts) == 1 && isInsideRange(template.Parts[0].Range(), position) {
28 | value, _ := template.Parts[0].Value(&hcl.EvalContext{})
29 | target = value.AsString()
30 | break
31 | }
32 | }
33 | }
34 | }
35 | } else if block.Type == "target" && len(block.LabelRanges) > 0 && isInsideRange(block.LabelRanges[0], position) {
36 | label := string(bytes[block.LabelRanges[0].Start.Byte:block.LabelRanges[0].End.Byte])
37 | if Quoted(label) {
38 | unquotedRange := hcl.Range{
39 | Start: hcl.Pos{
40 | Line: block.LabelRanges[0].Start.Line,
41 | Column: block.LabelRanges[0].Start.Column + 1,
42 | },
43 | End: hcl.Pos{
44 | Line: block.LabelRanges[0].End.Line,
45 | Column: block.LabelRanges[0].End.Column - 1,
46 | },
47 | }
48 | if isInsideRange(unquotedRange, position) {
49 | target = label[1 : len(label)-1]
50 | }
51 | } else {
52 | target = label
53 | }
54 | }
55 | }
56 |
57 | if target != "" {
58 | ranges := []protocol.DocumentHighlight{}
59 | for _, block := range body.Blocks {
60 | if block.Type == "group" {
61 | if targets, ok := block.Body.Attributes["targets"]; ok {
62 | if expr, ok := targets.Expr.(*hclsyntax.TupleConsExpr); ok {
63 | for _, item := range expr.Exprs {
64 | if template, ok := item.(*hclsyntax.TemplateExpr); ok && len(template.Parts) == 1 {
65 | value, _ := template.Parts[0].Value(&hcl.EvalContext{})
66 | if target == value.AsString() {
67 | ranges = append(ranges, protocol.DocumentHighlight{
68 | Kind: types.CreateDocumentHighlightKindPointer(protocol.DocumentHighlightKindRead),
69 | Range: createProtocolRange(template.Parts[0].Range(), false),
70 | })
71 | }
72 | }
73 | }
74 | }
75 | }
76 | } else if block.Type == "target" && len(block.LabelRanges) > 0 {
77 | label := string(bytes[block.LabelRanges[0].Start.Byte:block.LabelRanges[0].End.Byte])
78 | quoted := Quoted(label)
79 | label = strings.TrimPrefix(label, "\"")
80 | label = strings.TrimSuffix(label, "\"")
81 |
82 | if target == label {
83 | ranges = append(ranges, protocol.DocumentHighlight{
84 | Kind: types.CreateDocumentHighlightKindPointer(protocol.DocumentHighlightKindWrite),
85 | Range: createProtocolRange(block.LabelRanges[0], quoted),
86 | })
87 | }
88 | }
89 | }
90 | return ranges, nil
91 | }
92 | return nil, nil
93 | }
94 |
95 | func Quoted(s string) bool {
96 | return s[0] == 34 && s[len(s)-1] == 34
97 | }
98 |
99 | func createProtocolRange(hclRange hcl.Range, quoted bool) protocol.Range {
100 | if quoted {
101 | return protocol.Range{
102 | Start: protocol.Position{
103 | Line: uint32(hclRange.Start.Line - 1),
104 | Character: uint32(hclRange.Start.Column),
105 | },
106 | End: protocol.Position{
107 | Line: uint32(hclRange.End.Line - 1),
108 | Character: uint32(hclRange.End.Column - 2),
109 | },
110 | }
111 | }
112 |
113 | return protocol.Range{
114 | Start: protocol.Position{
115 | Line: uint32(hclRange.Start.Line - 1),
116 | Character: uint32(hclRange.Start.Column - 1),
117 | },
118 | End: protocol.Position{
119 | Line: uint32(hclRange.End.Line - 1),
120 | Character: uint32(hclRange.End.Column - 1),
121 | },
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/e2e-tests/semanticTokens_test.go:
--------------------------------------------------------------------------------
1 | package server_test
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "os"
7 | "testing"
8 |
9 | "github.com/docker/docker-language-server/internal/bake/hcl"
10 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
11 | "github.com/sourcegraph/jsonrpc2"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | func TestSemanticTokensFull(t *testing.T) {
16 | s := startServer()
17 |
18 | client := bytes.NewBuffer(make([]byte, 0, 1024))
19 | server := bytes.NewBuffer(make([]byte, 0, 1024))
20 | serverStream := &TestStream{incoming: server, outgoing: client, closed: false}
21 | defer serverStream.Close()
22 | go s.ServeStream(serverStream)
23 |
24 | clientStream := jsonrpc2.NewBufferedStream(&TestStream{incoming: client, outgoing: server, closed: false}, jsonrpc2.VSCodeObjectCodec{})
25 | defer clientStream.Close()
26 | conn := jsonrpc2.NewConn(context.Background(), clientStream, &ConfigurationHandler{t: t})
27 | initialize(t, conn, protocol.InitializeParams{})
28 |
29 | homedir, err := os.UserHomeDir()
30 | require.NoError(t, err)
31 |
32 | testCases := []struct {
33 | name string
34 | languageIdentifier protocol.LanguageIdentifier
35 | content string
36 | result *protocol.SemanticTokens
37 | }{
38 | {
39 | name: "target {}",
40 | languageIdentifier: protocol.DockerBakeLanguage,
41 | content: "target {}",
42 | result: &protocol.SemanticTokens{Data: []uint32{0, 0, 6, hcl.SemanticTokenTypeIndex(hcl.TokenType_Type), 0}},
43 | },
44 | {
45 | name: "single line comment after content with no newlines after it",
46 | languageIdentifier: protocol.DockerBakeLanguage,
47 | content: "variable \"port\" {default = true} # hello",
48 | result: &protocol.SemanticTokens{
49 | Data: []uint32{
50 | 0, 0, 8, hcl.SemanticTokenTypeIndex(hcl.TokenType_Type), 0,
51 | 0, 9, 6, hcl.SemanticTokenTypeIndex(hcl.TokenType_Class), 0,
52 | 0, 8, 7, hcl.SemanticTokenTypeIndex(hcl.TokenType_Property), 0,
53 | 0, 10, 4, hcl.SemanticTokenTypeIndex(hcl.TokenType_Keyword), 0,
54 | 0, 6, 7, hcl.SemanticTokenTypeIndex(hcl.TokenType_Comment), 0,
55 | },
56 | },
57 | },
58 | {
59 | name: "single line comment after content followed by LF",
60 | languageIdentifier: protocol.DockerBakeLanguage,
61 | content: "variable \"port\" {default = true} # hello\n",
62 | result: &protocol.SemanticTokens{
63 | Data: []uint32{
64 | 0, 0, 8, hcl.SemanticTokenTypeIndex(hcl.TokenType_Type), 0,
65 | 0, 9, 6, hcl.SemanticTokenTypeIndex(hcl.TokenType_Class), 0,
66 | 0, 8, 7, hcl.SemanticTokenTypeIndex(hcl.TokenType_Property), 0,
67 | 0, 10, 4, hcl.SemanticTokenTypeIndex(hcl.TokenType_Keyword), 0,
68 | 0, 6, 7, hcl.SemanticTokenTypeIndex(hcl.TokenType_Comment), 0,
69 | },
70 | },
71 | },
72 | {
73 | name: "single line comment after content followed by CRLF",
74 | languageIdentifier: protocol.DockerBakeLanguage,
75 | content: "variable \"port\" {default = true} # hello\r\n",
76 | result: &protocol.SemanticTokens{
77 | Data: []uint32{
78 | 0, 0, 8, hcl.SemanticTokenTypeIndex(hcl.TokenType_Type), 0,
79 | 0, 9, 6, hcl.SemanticTokenTypeIndex(hcl.TokenType_Class), 0,
80 | 0, 8, 7, hcl.SemanticTokenTypeIndex(hcl.TokenType_Property), 0,
81 | 0, 10, 4, hcl.SemanticTokenTypeIndex(hcl.TokenType_Keyword), 0,
82 | 0, 6, 7, hcl.SemanticTokenTypeIndex(hcl.TokenType_Comment), 0,
83 | },
84 | },
85 | },
86 | {
87 | name: "open dockerfile.hcl (issue 84)",
88 | languageIdentifier: protocol.DockerfileLanguage,
89 | content: "FROM scratch",
90 | result: nil,
91 | },
92 | }
93 |
94 | for _, tc := range testCases {
95 | t.Run(tc.name, func(t *testing.T) {
96 | didOpen := createDidOpenTextDocumentParams(homedir, t.Name()+".hcl", tc.content, tc.languageIdentifier)
97 | err := conn.Notify(context.Background(), protocol.MethodTextDocumentDidOpen, didOpen)
98 | require.NoError(t, err)
99 |
100 | var result *protocol.SemanticTokens
101 | err = conn.Call(context.Background(), protocol.MethodTextDocumentSemanticTokensFull, protocol.SemanticTokensParams{
102 | TextDocument: protocol.TextDocumentIdentifier{URI: didOpen.TextDocument.URI},
103 | }, &result)
104 | require.NoError(t, err)
105 | require.Equal(t, tc.result, result)
106 | })
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/e2e-tests/documentLink_test.go:
--------------------------------------------------------------------------------
1 | package server_test
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 | "testing"
11 |
12 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
13 | "github.com/docker/docker-language-server/internal/types"
14 | "github.com/sourcegraph/jsonrpc2"
15 | "github.com/stretchr/testify/require"
16 | )
17 |
18 | func TestDocumentLink(t *testing.T) {
19 | s := startServer()
20 |
21 | client := bytes.NewBuffer(make([]byte, 0, 1024))
22 | server := bytes.NewBuffer(make([]byte, 0, 1024))
23 | serverStream := &TestStream{incoming: server, outgoing: client, closed: false}
24 | defer serverStream.Close()
25 | go s.ServeStream(serverStream)
26 |
27 | clientStream := jsonrpc2.NewBufferedStream(&TestStream{incoming: client, outgoing: server, closed: false}, jsonrpc2.VSCodeObjectCodec{})
28 | defer clientStream.Close()
29 | conn := jsonrpc2.NewConn(context.Background(), clientStream, &ConfigurationHandler{t: t})
30 | initialize(t, conn, protocol.InitializeParams{})
31 |
32 | homedir, err := os.UserHomeDir()
33 | require.NoError(t, err)
34 | testFolder := filepath.Join(homedir, t.Name())
35 |
36 | testCases := []struct {
37 | name string
38 | content string
39 | linkRange protocol.Range
40 | path string
41 | links []protocol.DocumentLink
42 | }{
43 | {
44 | name: "dockerfile attribute in targets block",
45 | content: "target \"api\" {\n dockerfile = \"Dockerfile.api\"\n}",
46 | path: filepath.Join(homedir, "TestDocumentLink", "Dockerfile.api"),
47 | linkRange: protocol.Range{
48 | Start: protocol.Position{Line: 1, Character: 16},
49 | End: protocol.Position{Line: 1, Character: 30},
50 | },
51 | },
52 | {
53 | name: "./dockerfile attribute in targets block",
54 | content: "target \"api\" {\n dockerfile = \"./Dockerfile.api\"\n}",
55 | path: filepath.Join(homedir, "TestDocumentLink", "Dockerfile.api"),
56 | linkRange: protocol.Range{
57 | Start: protocol.Position{Line: 1, Character: 16},
58 | End: protocol.Position{Line: 1, Character: 32},
59 | },
60 | },
61 | {
62 | name: "../dockerfile attribute in targets block",
63 | content: "target \"api\" {\n dockerfile = \"../Dockerfile.api\"\n}",
64 | path: filepath.Join(homedir, "Dockerfile.api"),
65 | linkRange: protocol.Range{
66 | Start: protocol.Position{Line: 1, Character: 16},
67 | End: protocol.Position{Line: 1, Character: 33},
68 | },
69 | },
70 | {
71 | name: "folder/dockerfile attribute in targets block",
72 | content: "target \"api\" {\n dockerfile = \"folder/Dockerfile.api\"\n}",
73 | path: filepath.Join(homedir, "TestDocumentLink", "folder", "Dockerfile.api"),
74 | linkRange: protocol.Range{
75 | Start: protocol.Position{Line: 1, Character: 16},
76 | End: protocol.Position{Line: 1, Character: 37},
77 | },
78 | },
79 | {
80 | name: "../folder/dockerfile attribute in targets block",
81 | content: "target \"api\" {\n dockerfile = \"../folder/Dockerfile.api\"\n}",
82 | path: filepath.Join(homedir, "folder/Dockerfile.api"),
83 | linkRange: protocol.Range{
84 | Start: protocol.Position{Line: 1, Character: 16},
85 | End: protocol.Position{Line: 1, Character: 40},
86 | },
87 | },
88 | }
89 |
90 | didOpen := createDidOpenTextDocumentParams(testFolder, "DocumentLink.hcl", "", "dockerbake")
91 | err = conn.Notify(context.Background(), protocol.MethodTextDocumentDidOpen, didOpen)
92 | require.NoError(t, err)
93 |
94 | version := int32(2)
95 | for _, tc := range testCases {
96 | t.Run(tc.name, func(t *testing.T) {
97 | didChange := createDidChangeTextDocumentParams(testFolder, "DocumentLink.hcl", tc.content, version)
98 | version++
99 | err = conn.Notify(context.Background(), protocol.MethodTextDocumentDidChange, didChange)
100 | require.NoError(t, err)
101 |
102 | var result []protocol.DocumentLink
103 | err = conn.Call(context.Background(), protocol.MethodTextDocumentDocumentLink, protocol.DocumentLinkParams{
104 | TextDocument: protocol.TextDocumentIdentifier{URI: didOpen.TextDocument.URI},
105 | }, &result)
106 | require.NoError(t, err)
107 | links := []protocol.DocumentLink{
108 | {
109 | Range: tc.linkRange,
110 | Target: types.CreateStringPointer(fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(tc.path), "/"))),
111 | Tooltip: types.CreateStringPointer(tc.path),
112 | },
113 | }
114 | require.Equal(t, links, result)
115 | })
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/internal/bake/hcl/codeLens_test.go:
--------------------------------------------------------------------------------
1 | package hcl
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 | "path/filepath"
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 TestCodeLens(t *testing.T) {
18 | testsFolder := filepath.Join(os.TempDir(), "codeLensTests")
19 | bakeFilePath := filepath.Join(testsFolder, "docker-bake.hcl")
20 | uriString := fmt.Sprintf("file:///%v", filepath.ToSlash(bakeFilePath))
21 | testCodeLens(t, testsFolder, uriString)
22 | }
23 |
24 | func TestCodeLens_WSL(t *testing.T) {
25 | testCodeLens(t, "\\\\wsl$\\docker-desktop\\tmp", "file://wsl%24/docker-desktop/tmp/docker-bake.hcl")
26 | }
27 |
28 | func testCodeLens(t *testing.T, testsFolder, uriString string) {
29 | testCases := []struct {
30 | name string
31 | content string
32 | codeLens []protocol.CodeLens
33 | }{
34 | {
35 | name: "empty file",
36 | content: "",
37 | codeLens: []protocol.CodeLens{},
38 | },
39 | {
40 | name: "target block with no label",
41 | content: "target {}",
42 | codeLens: []protocol.CodeLens{},
43 | },
44 | {
45 | name: "target block",
46 | content: "target \"first\" { target = \"abc\"\n}",
47 | codeLens: []protocol.CodeLens{
48 | {
49 | Command: &protocol.Command{
50 | Title: "Build",
51 | Command: types.BakeBuildCommandId,
52 | Arguments: []any{
53 | map[string]string{
54 | "call": "build",
55 | "target": "first",
56 | "cwd": testsFolder,
57 | },
58 | },
59 | },
60 | Range: protocol.Range{
61 | Start: protocol.Position{Line: 0},
62 | End: protocol.Position{Line: 0},
63 | },
64 | },
65 | {
66 | Command: &protocol.Command{
67 | Title: "Check",
68 | Command: types.BakeBuildCommandId,
69 | Arguments: []any{
70 | map[string]string{
71 | "call": "check",
72 | "target": "first",
73 | "cwd": testsFolder,
74 | },
75 | },
76 | },
77 | Range: protocol.Range{
78 | Start: protocol.Position{Line: 0},
79 | End: protocol.Position{Line: 0},
80 | },
81 | },
82 | {
83 | Command: &protocol.Command{
84 | Title: "Print",
85 | Command: types.BakeBuildCommandId,
86 | Arguments: []any{
87 | map[string]string{
88 | "call": "print",
89 | "target": "first",
90 | "cwd": testsFolder,
91 | },
92 | },
93 | },
94 | Range: protocol.Range{
95 | Start: protocol.Position{Line: 0},
96 | End: protocol.Position{Line: 0},
97 | },
98 | },
99 | },
100 | },
101 | {
102 | name: "group block",
103 | content: "group \"g1\" {}",
104 | codeLens: []protocol.CodeLens{
105 | {
106 | Command: &protocol.Command{
107 | Title: "Build",
108 | Command: types.BakeBuildCommandId,
109 | Arguments: []any{
110 | map[string]string{
111 | "call": "build",
112 | "target": "g1",
113 | "cwd": testsFolder,
114 | },
115 | },
116 | },
117 | Range: protocol.Range{
118 | Start: protocol.Position{Line: 0},
119 | End: protocol.Position{Line: 0},
120 | },
121 | },
122 | {
123 | Command: &protocol.Command{
124 | Title: "Check",
125 | Command: types.BakeBuildCommandId,
126 | Arguments: []any{
127 | map[string]string{
128 | "call": "check",
129 | "target": "g1",
130 | "cwd": testsFolder,
131 | },
132 | },
133 | },
134 | Range: protocol.Range{
135 | Start: protocol.Position{Line: 0},
136 | End: protocol.Position{Line: 0},
137 | },
138 | },
139 | {
140 | Command: &protocol.Command{
141 | Title: "Print",
142 | Command: types.BakeBuildCommandId,
143 | Arguments: []any{
144 | map[string]string{
145 | "call": "print",
146 | "target": "g1",
147 | "cwd": testsFolder,
148 | },
149 | },
150 | },
151 | Range: protocol.Range{
152 | Start: protocol.Position{Line: 0},
153 | End: protocol.Position{Line: 0},
154 | },
155 | },
156 | },
157 | },
158 | }
159 |
160 | for _, tc := range testCases {
161 | t.Run(tc.name, func(t *testing.T) {
162 | doc := document.NewBakeHCLDocument(uri.URI(uriString), 1, []byte(tc.content))
163 | codeLens, err := CodeLens(context.Background(), uriString, doc)
164 | require.NoError(t, err)
165 | require.Equal(t, tc.codeLens, codeLens)
166 | })
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/internal/compose/formatting.go:
--------------------------------------------------------------------------------
1 | package compose
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/docker/docker-language-server/internal/pkg/document"
7 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
8 | "github.com/sourcegraph/jsonrpc2"
9 | )
10 |
11 | type indentation struct {
12 | original int
13 | desired int
14 | }
15 |
16 | type comment struct {
17 | line int
18 | whitespace int
19 | }
20 |
21 | func formattingOptionTabSize(options protocol.FormattingOptions) (int, error) {
22 | if tabSize, ok := options[protocol.FormattingOptionTabSize].(float64); ok && tabSize > 0 {
23 | return int(tabSize), nil
24 | }
25 | return -1, &jsonrpc2.Error{Code: jsonrpc2.CodeInvalidParams, Message: "tabSize is not a positive integer"}
26 | }
27 |
28 | func indent(indentation int) string {
29 | sb := strings.Builder{}
30 | for range indentation {
31 | sb.WriteString(" ")
32 | }
33 | return sb.String()
34 | }
35 |
36 | func Formatting(doc document.ComposeDocument, options protocol.FormattingOptions) ([]protocol.TextEdit, error) {
37 | file := doc.File()
38 | if file == nil || len(file.Docs) == 0 {
39 | return nil, nil
40 | }
41 | tabSize, err := formattingOptionTabSize(options)
42 | if err != nil {
43 | return nil, err
44 | }
45 |
46 | edits := []protocol.TextEdit{}
47 | indentations := []indentation{}
48 | comments := []comment{}
49 | topLevelNodeDetected := false
50 | lines := strings.Split(string(doc.Input()), "\n")
51 | lineCheck:
52 | for lineNumber, line := range lines {
53 | lineIndentation := 0
54 | stop := 0
55 | isComment := false
56 | empty := true
57 | for i := range len(line) {
58 | if line[i] == 32 {
59 | lineIndentation++
60 | } else if line[i] == '#' {
61 | empty = false
62 | isComment = true
63 | comments = append(comments, comment{line: lineNumber, whitespace: i})
64 | break
65 | } else {
66 | empty = false
67 | if strings.HasPrefix(lines[lineNumber], "---") {
68 | edits = append(edits, formatComments(comments, 0)...)
69 | comments = nil
70 | indentations = nil
71 | topLevelNodeDetected = false
72 | continue lineCheck
73 | }
74 |
75 | if !topLevelNodeDetected {
76 | topLevelNodeDetected = true
77 | if lineIndentation > 0 {
78 | newIndentation, _ := updateIndentation(indentations, lineIndentation, 0)
79 | indentations = append(indentations, newIndentation)
80 | }
81 | }
82 | break
83 | }
84 | stop++
85 | }
86 |
87 | if isComment {
88 | continue
89 | }
90 |
91 | if lineIndentation != 0 {
92 | newIndentation, resetIndex := updateIndentation(indentations, lineIndentation, tabSize)
93 | if resetIndex == -1 {
94 | indentations = append(indentations, newIndentation)
95 | } else {
96 | indentations = indentations[:resetIndex+1]
97 | }
98 | edits = append(edits, formatComments(comments, newIndentation.desired)...)
99 | comments = nil
100 | if lineIndentation != newIndentation.desired {
101 | edits = append(edits, protocol.TextEdit{
102 | NewText: indent(newIndentation.desired),
103 | Range: protocol.Range{
104 | Start: protocol.Position{Line: protocol.UInteger(lineNumber), Character: 0},
105 | End: protocol.Position{Line: protocol.UInteger(lineNumber), Character: protocol.UInteger(stop)},
106 | },
107 | })
108 | }
109 | } else if !empty {
110 | edits = append(edits, formatComments(comments, 0)...)
111 | comments = nil
112 | indentations = nil
113 | }
114 | }
115 | return edits, nil
116 | }
117 |
118 | // formatComments goes over the list of comments and corrects its
119 | // indentation to the desired indentation only if it differs. Any
120 | // comment that needs to have its indentation changed will have a
121 | // TextEdit created for it and included in the returned result.
122 | func formatComments(comments []comment, desired int) []protocol.TextEdit {
123 | edits := []protocol.TextEdit{}
124 | for _, c := range comments {
125 | if desired != c.whitespace {
126 | edits = append(edits, protocol.TextEdit{
127 | NewText: indent(desired),
128 | Range: protocol.Range{
129 | Start: protocol.Position{Line: protocol.UInteger(c.line), Character: 0},
130 | End: protocol.Position{Line: protocol.UInteger(c.line), Character: protocol.UInteger(c.whitespace)},
131 | },
132 | })
133 | }
134 | }
135 | return edits
136 | }
137 |
138 | func updateIndentation(indentations []indentation, original, tabSpacing int) (indentation, int) {
139 | last := tabSpacing
140 | for i := range indentations {
141 | if indentations[i].original == original {
142 | return indentations[i], i
143 | }
144 | last = indentations[i].desired + tabSpacing
145 | }
146 | return indentation{
147 | original: original,
148 | desired: last,
149 | }, -1
150 | }
151 |
--------------------------------------------------------------------------------
/internal/pkg/document/dockerComposeDocument.go:
--------------------------------------------------------------------------------
1 | package document
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "path/filepath"
7 | "slices"
8 | "strings"
9 | "sync"
10 |
11 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
12 | "github.com/goccy/go-yaml/ast"
13 | "github.com/goccy/go-yaml/parser"
14 | "go.lsp.dev/uri"
15 | )
16 |
17 | type ComposeDocument interface {
18 | Document
19 | File() *ast.File
20 | ParsingError() error
21 | IncludedFiles() (map[string]*ast.File, bool)
22 | }
23 |
24 | type composeDocument struct {
25 | document
26 | mutex sync.Mutex
27 | mgr *Manager
28 | file *ast.File
29 | parsingError error
30 | }
31 |
32 | func NewComposeDocument(mgr *Manager, u uri.URI, version int32, input []byte) ComposeDocument {
33 | doc := &composeDocument{
34 | document: document{
35 | uri: u,
36 | identifier: protocol.DockerComposeLanguage,
37 | version: version,
38 | // change all CRLF to LFs
39 | // https://github.com/goccy/go-yaml/issues/560
40 | // https://github.com/docker/docker-language-server/issues/205
41 | input: []byte(strings.ReplaceAll(string(input), "\r\n", "\n")),
42 | },
43 | mgr: mgr,
44 | }
45 | doc.copyFn = doc.copy
46 | doc.parseFn = doc.parse
47 | doc.parseFn(true)
48 | return doc
49 | }
50 |
51 | func (d *composeDocument) parse(_ bool) bool {
52 | d.mutex.Lock()
53 | defer d.mutex.Unlock()
54 |
55 | d.file, d.parsingError = parser.ParseBytes(d.input, parser.ParseComments)
56 | return true
57 | }
58 |
59 | func (d *composeDocument) copy() Document {
60 | return NewComposeDocument(d.mgr, d.uri, d.version, d.input)
61 | }
62 |
63 | func (d *composeDocument) File() *ast.File {
64 | return d.file
65 | }
66 |
67 | func (d *composeDocument) ParsingError() error {
68 | return d.parsingError
69 | }
70 |
71 | func isPath(path string) bool {
72 | prefixes := []string{"git://", "http://", "https://", "oci://"}
73 | for _, prefix := range prefixes {
74 | if strings.HasPrefix(path, prefix) {
75 | return false
76 | }
77 | }
78 | return true
79 | }
80 |
81 | func searchForIncludedFiles(searched []uri.URI, d *composeDocument) (map[string]*ast.File, bool) {
82 | documentPath, err := d.DocumentPath()
83 | if err != nil {
84 | return nil, true
85 | }
86 |
87 | files := map[string]*ast.File{}
88 | for _, path := range d.includedPaths() {
89 | if isPath(path) {
90 | includedPath := filepath.Join(documentPath.Folder, path)
91 | uriString := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(includedPath), "/"))
92 | pathURI := uri.URI(uriString)
93 | if slices.Contains(searched, pathURI) {
94 | return nil, false
95 | }
96 | doc, err := d.mgr.tryReading(context.Background(), pathURI, false)
97 | if err == nil {
98 | if c, ok := doc.(*composeDocument); ok && c.file != nil {
99 | searched = append(searched, pathURI)
100 | next, resolvable := searchForIncludedFiles(searched, c)
101 | if !resolvable {
102 | return nil, false
103 | }
104 | files[uriString] = c.file
105 | for u, f := range next {
106 | files[u] = f
107 | }
108 | }
109 | }
110 | }
111 | }
112 | return files, true
113 | }
114 |
115 | func (d *composeDocument) IncludedFiles() (map[string]*ast.File, bool) {
116 | return searchForIncludedFiles([]uri.URI{d.uri}, d)
117 | }
118 |
119 | func (d *composeDocument) includedPaths() []string {
120 | if d.file == nil || len(d.file.Docs) == 0 {
121 | return nil
122 | }
123 |
124 | paths := []string{}
125 | for _, doc := range d.file.Docs {
126 | if node, ok := doc.Body.(*ast.MappingNode); ok {
127 | for _, topNode := range node.Values {
128 | if topNode.Key.GetToken().Value == "include" {
129 | if sequenceNode, ok := topNode.Value.(*ast.SequenceNode); ok {
130 | for _, sequenceValue := range sequenceNode.Values {
131 | if stringArrayItem, ok := sequenceValue.(*ast.StringNode); ok {
132 | paths = append(paths, stringArrayItem.Value)
133 | } else if includeObject, ok := sequenceValue.(*ast.MappingNode); ok {
134 | for _, includeValues := range includeObject.Values {
135 | if includeValues.Key.GetToken().Value == "path" {
136 | if pathArrayItem, ok := includeValues.Value.(*ast.StringNode); ok {
137 | paths = append(paths, pathArrayItem.Value)
138 | } else if pathSequence, ok := includeValues.Value.(*ast.SequenceNode); ok {
139 | for _, sequenceValue := range pathSequence.Values {
140 | if s, ok := sequenceValue.(*ast.StringNode); ok {
141 | paths = append(paths, s.Value)
142 | }
143 | }
144 | }
145 | break
146 | }
147 | }
148 | }
149 | }
150 | }
151 | break
152 | }
153 | }
154 | }
155 | }
156 | return paths
157 | }
158 |
--------------------------------------------------------------------------------
/internal/bake/hcl/inlayHint_test.go:
--------------------------------------------------------------------------------
1 | package hcl
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 | "testing"
10 |
11 | "github.com/docker/docker-language-server/internal/pkg/document"
12 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
13 | "github.com/docker/docker-language-server/internal/types"
14 | "github.com/stretchr/testify/require"
15 | "go.lsp.dev/uri"
16 | )
17 |
18 | var testCases = []struct {
19 | name string
20 | content string
21 | dockerfileContent string
22 | rng protocol.Range
23 | items []protocol.InlayHint
24 | }{
25 | {
26 | name: "args lookup",
27 | content: "target t1 {\n args = {\n undefined = \"test\"\n empty = \"test\"\n defined = \"test\"\n}\n}",
28 | dockerfileContent: "FROM scratch\nARG undefined\nARG empty=\nARG defined=value\n",
29 | rng: protocol.Range{
30 | Start: protocol.Position{Line: 0, Character: 0},
31 | End: protocol.Position{Line: 5, Character: 0},
32 | },
33 | items: []protocol.InlayHint{
34 | {
35 | Label: "(default value: value)",
36 | PaddingLeft: types.CreateBoolPointer(true),
37 | Position: protocol.Position{Line: 4, Character: 20},
38 | },
39 | },
40 | },
41 | {
42 | name: "args lookup outside the range",
43 | content: "target t1 {\n args = {\n undefined = \"test\"\n empty = \"test\"\n defined = \"test\"\n}\n}\n\n\n\n",
44 | dockerfileContent: "FROM scratch\nARG undefined\nARG empty=\nARG defined=value\n",
45 | rng: protocol.Range{
46 | Start: protocol.Position{Line: 8, Character: 0},
47 | End: protocol.Position{Line: 8, Character: 0},
48 | },
49 | items: []protocol.InlayHint{},
50 | },
51 | }
52 |
53 | func TestInlayHint(t *testing.T) {
54 | tempDir := os.TempDir()
55 | dockerfileURI := uri.URI(fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(tempDir, "Dockerfile")), "/")))
56 | bakeFileURI := uri.URI(fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(tempDir, "docker-bake.hcl")), "/")))
57 |
58 | for _, tc := range testCases {
59 | t.Run(tc.name, func(t *testing.T) {
60 | manager := document.NewDocumentManager()
61 | changed, err := manager.Write(context.Background(), dockerfileURI, protocol.DockerfileLanguage, 1, []byte(tc.dockerfileContent))
62 | require.NoError(t, err)
63 | require.True(t, changed)
64 |
65 | doc := document.NewBakeHCLDocument(bakeFileURI, 1, []byte(tc.content))
66 | items, err := InlayHint(manager, doc, tc.rng)
67 | require.NoError(t, err)
68 | require.Equal(t, tc.items, items)
69 | })
70 | }
71 | }
72 |
73 | func TestInlayHint_WSL(t *testing.T) {
74 | dockerfileURI := uri.URI("file://wsl%24/docker-desktop/tmp/Dockerfile")
75 | bakeFileURI := uri.URI("file://wsl%24/docker-desktop/tmp/docker-bake.hcl")
76 |
77 | for _, tc := range testCases {
78 | t.Run(tc.name, func(t *testing.T) {
79 | manager := document.NewDocumentManager()
80 | changed, err := manager.Write(context.Background(), dockerfileURI, protocol.DockerfileLanguage, 1, []byte(tc.dockerfileContent))
81 | require.NoError(t, err)
82 | require.True(t, changed)
83 |
84 | doc := document.NewBakeHCLDocument(bakeFileURI, 1, []byte(tc.content))
85 | items, err := InlayHint(manager, doc, tc.rng)
86 | require.NoError(t, err)
87 | require.Equal(t, tc.items, items)
88 | })
89 | }
90 | }
91 |
92 | func TestInlayHint_TestFiles(t *testing.T) {
93 | testCases := []struct {
94 | name string
95 | content string
96 | rng protocol.Range
97 | items []protocol.InlayHint
98 | }{
99 | {
100 | name: "args lookup to a different context folder",
101 | content: "target \"backend\" {\n context = \"./backend\"\n args = {\n BACKEND_VAR=\"changed\"\n }\n}",
102 | rng: protocol.Range{
103 | Start: protocol.Position{Line: 0, Character: 0},
104 | End: protocol.Position{Line: 4, Character: 0},
105 | },
106 | items: []protocol.InlayHint{
107 | {
108 | Label: "(default value: backend_value)",
109 | PaddingLeft: types.CreateBoolPointer(true),
110 | Position: protocol.Position{Line: 3, Character: 25},
111 | },
112 | },
113 | },
114 | }
115 |
116 | wd, err := os.Getwd()
117 | require.NoError(t, err)
118 | projectRoot := filepath.Dir(filepath.Dir(filepath.Dir(wd)))
119 | inlayHintTestFolderPath := filepath.Join(projectRoot, "testdata", "inlayHint")
120 | bakeFilePath := filepath.Join(inlayHintTestFolderPath, "docker-bake.hcl")
121 | bakeFileURI := uri.URI(fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(bakeFilePath), "/")))
122 |
123 | for _, tc := range testCases {
124 | t.Run(tc.name, func(t *testing.T) {
125 | manager := document.NewDocumentManager()
126 | doc := document.NewBakeHCLDocument(bakeFileURI, 1, []byte(tc.content))
127 | items, err := InlayHint(manager, doc, tc.rng)
128 | require.NoError(t, err)
129 | require.Equal(t, tc.items, items)
130 | })
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/internal/compose/schema.go:
--------------------------------------------------------------------------------
1 | package compose
2 |
3 | import (
4 | "bytes"
5 | _ "embed"
6 | "slices"
7 |
8 | "github.com/goccy/go-yaml/ast"
9 | "github.com/santhosh-tekuri/jsonschema/v6"
10 | )
11 |
12 | //go:embed compose-spec.json
13 | var schemaData []byte
14 |
15 | var composeSchema *jsonschema.Schema
16 |
17 | func init() {
18 | schema, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaData))
19 | if err != nil {
20 | return
21 | }
22 |
23 | compiler := jsonschema.NewCompiler()
24 | if err := compiler.AddResource("schema.json", schema); err != nil {
25 | return
26 | }
27 | compiled, err := compiler.Compile("schema.json")
28 | if err != nil {
29 | return
30 | }
31 | composeSchema = compiled
32 | }
33 |
34 | func schemaProperties() map[string]*jsonschema.Schema {
35 | return composeSchema.Properties
36 | }
37 |
38 | func nodeProperties(nodes []*ast.MappingValueNode, line, column int) ([]*ast.MappingValueNode, any, bool) {
39 | if composeSchema != nil && slices.Contains(composeSchema.Types.ToStrings(), "object") && composeSchema.Properties != nil {
40 | if prop, ok := composeSchema.Properties[nodes[0].Key.GetToken().Value]; ok {
41 | for regexp, property := range prop.PatternProperties {
42 | if regexp.MatchString(nodes[1].Key.GetToken().Value) {
43 | if property.Ref != nil {
44 | return recurseNodeProperties(nodes, line, column, 2, property.Ref.Properties, false)
45 | }
46 | }
47 | }
48 | }
49 | }
50 | return nodes, nil, false
51 | }
52 |
53 | func recurseNodeProperties(nodes []*ast.MappingValueNode, line, column, nodeOffset int, properties map[string]*jsonschema.Schema, arrayAttributes bool) ([]*ast.MappingValueNode, any, bool) {
54 | if len(nodes) == nodeOffset {
55 | if nodes[len(nodes)-1].Key.GetToken().Position.Column >= column {
56 | return nodes, nil, false
57 | }
58 | return nodes, properties, arrayAttributes
59 | }
60 | if nodes[nodeOffset].Key.GetToken().Position.Column == column {
61 | return nodes[0:nodeOffset], properties, false
62 | }
63 |
64 | value := nodes[nodeOffset].Key.GetToken().Value
65 | if prop, ok := properties[value]; ok {
66 | if prop.Ref != nil {
67 | if len(prop.Ref.Properties) > 0 {
68 | return recurseNodeProperties(nodes, line, column, nodeOffset+1, prop.Ref.Properties, false)
69 | }
70 | // try to match the child node to patternProperties
71 | if len(nodes) > nodeOffset+1 {
72 | for regexp, property := range prop.Ref.PatternProperties {
73 | nextValue := nodes[nodeOffset+1].Key.GetToken().Value
74 | if regexp.MatchString(nextValue) {
75 | for _, nested := range property.OneOf {
76 | if slices.Contains(nested.Types.ToStrings(), "object") {
77 | return recurseNodeProperties(nodes, line, column, nodeOffset+2, nested.Properties, false)
78 | }
79 | }
80 | }
81 | }
82 | }
83 | if schema, ok := prop.Ref.Items.(*jsonschema.Schema); ok {
84 | for _, nested := range schema.OneOf {
85 | if nested.Types != nil && slices.Contains(nested.Types.ToStrings(), "object") {
86 | if len(nested.Properties) > 0 {
87 | return recurseNodeProperties(nodes, line, column, nodeOffset+1, nested.Properties, true)
88 | }
89 | }
90 | }
91 | }
92 | }
93 |
94 | for _, schema := range prop.OneOf {
95 | if schema.Types != nil && slices.Contains(schema.Types.ToStrings(), "object") {
96 | if len(schema.Properties) > 0 {
97 | return recurseNodeProperties(nodes, line, column, nodeOffset+1, schema.Properties, false)
98 | }
99 |
100 | for regexp, property := range schema.PatternProperties {
101 | if len(nodes) == nodeOffset+1 {
102 | return nodes, nil, false
103 | }
104 |
105 | nextValue := nodes[nodeOffset+1].Key.GetToken().Value
106 | if regexp.MatchString(nextValue) {
107 | for _, nested := range property.OneOf {
108 | if slices.Contains(nested.Types.ToStrings(), "object") {
109 | return recurseNodeProperties(nodes, line, column, nodeOffset+2, nested.Properties, false)
110 | }
111 | }
112 | return recurseNodeProperties(nodes, line, column, nodeOffset+1, property.Properties, false)
113 | }
114 | }
115 | }
116 | }
117 |
118 | if schema, ok := prop.Items.(*jsonschema.Schema); ok {
119 | for _, nested := range schema.OneOf {
120 | if nested.Types != nil && slices.Contains(nested.Types.ToStrings(), "object") {
121 | if len(nested.Properties) > 0 {
122 | return recurseNodeProperties(nodes, line, column, nodeOffset+1, nested.Properties, true)
123 | }
124 | }
125 | }
126 | return recurseNodeProperties(nodes, line, column, nodeOffset+1, schema.Properties, true)
127 | }
128 |
129 | if nodes[nodeOffset].Key.GetToken().Position.Column < column {
130 | if nodes[nodeOffset].Key.GetToken().Position.Line == line {
131 | return nodes, prop, false
132 | }
133 | return recurseNodeProperties(nodes, line, column, nodeOffset+1, prop.Properties, false)
134 | }
135 | return nodes, prop.Properties, false
136 | }
137 | return nodes, properties, false
138 | }
139 |
--------------------------------------------------------------------------------
/internal/bake/hcl/inlineCompletion.go:
--------------------------------------------------------------------------------
1 | package hcl
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "slices"
8 | "strings"
9 | "unicode"
10 |
11 | "github.com/docker/docker-language-server/internal/pkg/document"
12 | "github.com/docker/docker-language-server/internal/tliron/glsp/protocol"
13 | "github.com/docker/docker-language-server/internal/types"
14 | "github.com/hashicorp/hcl/v2"
15 | "github.com/hashicorp/hcl/v2/hclsyntax"
16 | "github.com/zclconf/go-cty/cty"
17 | )
18 |
19 | func shouldSuggest(content []byte, body *hclsyntax.Body, position protocol.Position) bool {
20 | for _, block := range body.Blocks {
21 | if isInsideRange(block.Range(), position) {
22 | return false
23 | }
24 | }
25 |
26 | for _, attribute := range body.Attributes {
27 | if isInsideRange(attribute.Range(), position) {
28 | return false
29 | }
30 | }
31 |
32 | rawTokens, _ := hclsyntax.LexConfig(content, "", hcl.InitialPos)
33 | for _, rawToken := range rawTokens {
34 | if rawToken.Type == hclsyntax.TokenComment {
35 | if isInsideRange(rawToken.Range, position) {
36 | return false
37 | }
38 | }
39 | }
40 |
41 | lines := strings.Split(string(content), "\n")
42 | if len(lines) <= int(position.Line) {
43 | return false
44 | }
45 | return strings.TrimSpace(lines[position.Line]) != "}"
46 | }
47 |
48 | func InlineCompletion(ctx context.Context, params *protocol.InlineCompletionParams, manager *document.Manager, bakeDocument document.BakeHCLDocument) ([]protocol.InlineCompletionItem, error) {
49 | documentPath, err := bakeDocument.DocumentPath()
50 | if err != nil {
51 | return nil, fmt.Errorf("LSP client sent invalid URI: %v", params.TextDocument.URI)
52 | }
53 |
54 | body, ok := bakeDocument.File().Body.(*hclsyntax.Body)
55 | if !ok {
56 | return nil, errors.New("unrecognized body in HCL document")
57 | }
58 |
59 | documentURI, dockerfilePath := types.Concatenate(documentPath.Folder, "Dockerfile", documentPath.WSLDollarSignHost)
60 | if !shouldSuggest(bakeDocument.Input(), body, params.Position) {
61 | return nil, nil
62 | }
63 |
64 | preexistingTargets := []string{}
65 | for _, block := range body.Blocks {
66 | if block.Type == "target" && len(block.Labels) > 0 {
67 | preexistingTargets = append(preexistingTargets, block.Labels[0])
68 | }
69 |
70 | if attribute, ok := block.Body.Attributes["target"]; ok {
71 | if templateExpr, ok := attribute.Expr.(*hclsyntax.TemplateExpr); ok {
72 | if len(templateExpr.Parts) == 1 {
73 | if literalValueExpr, ok := templateExpr.Parts[0].(*hclsyntax.LiteralValueExpr); ok {
74 | value, _ := literalValueExpr.Value(&hcl.EvalContext{})
75 | if value.Type() == cty.String {
76 | target := value.AsString()
77 | preexistingTargets = append(preexistingTargets, target)
78 | }
79 | }
80 | }
81 | }
82 | }
83 | }
84 |
85 | argNames := []string{}
86 | args := map[string]string{}
87 | targets := []string{}
88 | _, nodes := document.OpenDockerfile(ctx, manager, documentURI, dockerfilePath)
89 | before := true
90 | for _, child := range nodes {
91 | if strings.EqualFold(child.Value, "ARG") && before {
92 | if child.Next != nil {
93 | arg := child.Next.Value
94 | idx := strings.Index(arg, "=")
95 | if idx == -1 {
96 | args[arg] = ""
97 | argNames = append(argNames, arg)
98 | } else {
99 | args[arg[:idx]] = arg[idx+1:]
100 | argNames = append(argNames, arg[:idx])
101 | }
102 | }
103 | } else if strings.EqualFold(child.Value, "FROM") {
104 | before = false
105 | if child.Next != nil && child.Next.Next != nil && strings.EqualFold(child.Next.Next.Value, "AS") && child.Next.Next.Next != nil {
106 | if !slices.Contains(preexistingTargets, child.Next.Next.Next.Value) {
107 | targets = append(targets, child.Next.Next.Next.Value)
108 | }
109 | }
110 | }
111 | }
112 |
113 | if len(targets) > 0 {
114 | items := []protocol.InlineCompletionItem{}
115 | lines := strings.Split(string(bakeDocument.Input()), "\n")
116 | for _, target := range targets {
117 | sb := strings.Builder{}
118 | sb.WriteString(fmt.Sprintf("target \"%v\" {\n", target))
119 | sb.WriteString(fmt.Sprintf(" target = \"%v\"\n", target))
120 |
121 | if len(args) > 0 {
122 | sb.WriteString(" args = {\n")
123 | for _, argName := range argNames {
124 | sb.WriteString(fmt.Sprintf(" %v = \"%v\"\n", argName, args[argName]))
125 | }
126 | sb.WriteString(" }\n")
127 | }
128 |
129 | sb.WriteString("}\n")
130 |
131 | content := strings.TrimLeftFunc(lines[params.Position.Line], unicode.IsSpace)
132 | if strings.HasPrefix(sb.String(), content) {
133 | items = append(items, protocol.InlineCompletionItem{
134 | Range: &protocol.Range{
135 | Start: protocol.Position{
136 | Line: params.Position.Line,
137 | Character: params.Position.Character - uint32(len(content)),
138 | },
139 | End: params.Position,
140 | },
141 | InsertText: sb.String(),
142 | })
143 | }
144 |
145 | }
146 |
147 | return items, nil
148 | }
149 | return nil, nil
150 | }
151 |
--------------------------------------------------------------------------------