From 4bdaa61521283763294806a93c39844791c397f5 Mon Sep 17 00:00:00 2001 From: Remy Suen Date: Thu, 15 May 2025 15:59:47 -0400 Subject: [PATCH] Fix code completion panic in an empty Compose file Signed-off-by: Remy Suen --- CHANGELOG.md | 3 + internal/compose/completion.go | 70 +++++++++---- internal/compose/completion_test.go | 153 +++++++++++++++++++++------- 3 files changed, 170 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 465542a..fcfdb77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ All notable changes to the Docker Language Server will be documented in this fil ### Fixed +- Compose + - textDocument/completion + - fix panic in code completion in an empty file ([#196](https://github.com/docker/docker-language-server/issues/196)) - Bake - textDocument/publishDiagnostics - stop flagging `BUILDKIT_SYNTAX` as an unrecognized `ARG` ([#187](https://github.com/docker/docker-language-server/issues/187)) diff --git a/internal/compose/completion.go b/internal/compose/completion.go index 3893399..5eb725e 100644 --- a/internal/compose/completion.go +++ b/internal/compose/completion.go @@ -108,42 +108,58 @@ func array(line string, character int) bool { return isArray } +func createTopLevelItems() []protocol.CompletionItem { + items := []protocol.CompletionItem{} + for attributeName, schema := range schemaProperties() { + item := protocol.CompletionItem{Label: attributeName} + if schema.Description != "" { + item.Documentation = schema.Description + } + items = append(items, item) + } + slices.SortFunc(items, func(a, b protocol.CompletionItem) int { + return strings.Compare(a.Label, b.Label) + }) + return items +} + +func calculateTopLevelNodeOffset(file *ast.File) int { + if len(file.Docs) == 1 { + if m, ok := file.Docs[0].Body.(*ast.MappingNode); ok { + return m.Values[0].Key.GetToken().Position.Column - 1 + } + } + return -1 +} + func Completion(ctx context.Context, params *protocol.CompletionParams, manager *document.Manager, doc document.ComposeDocument) (*protocol.CompletionList, error) { u, err := url.Parse(params.TextDocument.URI) if err != nil { return nil, fmt.Errorf("LSP client sent invalid URI: %v", params.TextDocument.URI) } - if params.Position.Character == 0 { - items := []protocol.CompletionItem{} - for attributeName, schema := range schemaProperties() { - item := protocol.CompletionItem{Label: attributeName} - if schema.Description != "" { - item.Documentation = schema.Description - } - items = append(items, item) - } - slices.SortFunc(items, func(a, b protocol.CompletionItem) int { - return strings.Compare(a.Label, b.Label) - }) - return &protocol.CompletionList{Items: items}, nil + file := doc.File() + if file == nil || len(file.Docs) == 0 { + return nil, nil } - lines := strings.Split(string(doc.Input()), "\n") lspLine := int(params.Position.Line) - if strings.HasPrefix(strings.TrimSpace(lines[lspLine]), "#") { - return nil, nil + topLevelNodeOffset := calculateTopLevelNodeOffset(file) + if topLevelNodeOffset != -1 && params.Position.Character == uint32(topLevelNodeOffset) { + return &protocol.CompletionList{Items: createTopLevelItems()}, nil } - file := doc.File() - if file == nil || len(file.Docs) == 0 { + lines := strings.Split(string(doc.Input()), "\n") + if strings.HasPrefix(strings.TrimSpace(lines[lspLine]), "#") { return nil, nil } line := int(lspLine) + 1 character := int(params.Position.Character) + 1 path := constructCompletionNodePath(file, line) - if len(path) == 1 { + if len(path) == 0 { + return &protocol.CompletionList{Items: createTopLevelItems()}, nil + } else if len(path) == 1 { return nil, nil } else if path[1].Key.GetToken().Position.Column >= character { return nil, nil @@ -452,9 +468,19 @@ func namedDependencyCompletionItems(file *ast.File, path []*ast.MappingValueNode } func constructCompletionNodePath(file *ast.File, line int) []*ast.MappingValueNode { - for _, documentNode := range file.Docs { - if mappingNode, ok := documentNode.Body.(*ast.MappingNode); ok { - return NodeStructure(line, mappingNode.Values) + for i := range len(file.Docs) { + if i+1 == len(file.Docs) { + if mappingNode, ok := file.Docs[i].Body.(*ast.MappingNode); ok { + return NodeStructure(line, mappingNode.Values) + } + } + + if m, ok := file.Docs[i].Body.(*ast.MappingNode); ok { + if n, ok := file.Docs[i+1].Body.(*ast.MappingNode); ok { + if m.Values[0].Key.GetToken().Position.Line <= line && line <= n.Values[0].Key.GetToken().Position.Line { + return NodeStructure(line, m.Values) + } + } } } return nil diff --git a/internal/compose/completion_test.go b/internal/compose/completion_test.go index f30eda9..3f4aa0b 100644 --- a/internal/compose/completion_test.go +++ b/internal/compose/completion_test.go @@ -15,6 +15,41 @@ import ( "go.lsp.dev/uri" ) +var topLevelNodes = []protocol.CompletionItem{ + { + Label: "configs", + Documentation: "Configurations that are shared among multiple services.", + }, + { + Label: "include", + Documentation: "compose sub-projects to be included.", + }, + { + Label: "name", + Documentation: "define the Compose project name, until user defines one explicitly.", + }, + { + Label: "networks", + Documentation: "Networks that are shared among multiple services.", + }, + { + Label: "secrets", + Documentation: "Secrets that are shared among multiple services.", + }, + { + Label: "services", + Documentation: "The services that will be used by your application.", + }, + { + Label: "version", + Documentation: "declared for backward compatibility, ignored. Please remove it.", + }, + { + Label: "volumes", + Documentation: "Named volumes that are shared among multiple services.", + }, +} + func serviceProperties(line, character, prefixLength protocol.UInteger) []protocol.CompletionItem { return []protocol.CompletionItem{ { @@ -934,40 +969,90 @@ configs: line: 3, character: 0, list: &protocol.CompletionList{ - Items: []protocol.CompletionItem{ - { - Label: "configs", - Documentation: "Configurations that are shared among multiple services.", - }, - { - Label: "include", - Documentation: "compose sub-projects to be included.", - }, - { - Label: "name", - Documentation: "define the Compose project name, until user defines one explicitly.", - }, - { - Label: "networks", - Documentation: "Networks that are shared among multiple services.", - }, - { - Label: "secrets", - Documentation: "Secrets that are shared among multiple services.", - }, - { - Label: "services", - Documentation: "The services that will be used by your application.", - }, - { - Label: "version", - Documentation: "declared for backward compatibility, ignored. Please remove it.", - }, - { - Label: "volumes", - Documentation: "Named volumes that are shared among multiple services.", - }, - }, + Items: topLevelNodes, + }, + }, + { + name: "top level node suggestions with a space in the front", + content: ` `, + line: 0, + character: 1, + list: &protocol.CompletionList{ + Items: topLevelNodes, + }, + }, + { + name: "top level node suggestions with indented content but code completion is unindented", + content: ` + configs: + test: +`, + line: 3, + character: 0, + list: nil, + }, + { + name: "top level node suggestions with indented content and code completion is aligned correctly", + content: ` + configs: + test: + `, + line: 3, + character: 1, + list: &protocol.CompletionList{ + Items: topLevelNodes, + }, + }, + { + name: "alignment correct with multiple documents", + content: ` +--- +--- + configs: + test: + `, + line: 5, + character: 1, + list: &protocol.CompletionList{ + Items: topLevelNodes, + }, + }, + { + name: "alignment incorrect with multiple documents", + content: ` +--- +configs: + test: +--- + configs: + test2: +`, + line: 7, + character: 0, + list: nil, + }, + { + name: "top level node suggestions with indented content and code completion is aligned correctly but in a comment", + content: ` + configs: + test: +#`, + line: 3, + character: 1, + list: nil, + }, + { + name: "top level node suggestions with multiple files", + content: ` +--- + configs: + test: +--- +`, + line: 5, + character: 0, + list: &protocol.CompletionList{ + Items: topLevelNodes, }, }, {