├── 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 | --------------------------------------------------------------------------------