diff --git a/CHANGELOG.md b/CHANGELOG.md index ccbd193..62e5656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to the Docker Language Server will be documented in this fil ## [Unreleased] +### Added + +- Compose + - textDocument/completion + - add support for suggesting `include` properties ([#316](https://github.com/docker/docker-language-server/issues/316)) + ### Fixed - Compose diff --git a/internal/compose/completion.go b/internal/compose/completion.go index 0784533..96ce96f 100644 --- a/internal/compose/completion.go +++ b/internal/compose/completion.go @@ -126,6 +126,7 @@ var textEditModifiers = []textEditModifier{buildTargetModifier, serviceSuggestio func prefix(line string, character int) string { sb := strings.Builder{} + sb.Grow(character) for i := range character { if unicode.IsSpace(rune(line[i])) { sb.Reset() @@ -136,13 +137,14 @@ func prefix(line string, character int) string { return sb.String() } -func createSpacing(line string, whitespaceLine, arrayAttributes bool) string { - if whitespaceLine && arrayAttributes { +func createSpacing(line string, character int, arrayAttributes bool) string { + if arrayAttributes { // 2 more for the attribute, then 2 more for the array offset = 4 total - return strings.Repeat(" ", len(line)+4) + return strings.Repeat(" ", character+4) } sb := strings.Builder{} - for i := range line { + sb.Grow(character + 2) + for i := range character { if unicode.IsSpace(rune(line[i])) || line[i] == '-' { sb.WriteString(" ") } @@ -201,75 +203,87 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, manager return nil, nil } + character := int(params.Position.Character) + 1 + if len(lines[lspLine]) < character-1 { + return nil, nil + } whitespaceLine := currentLineTrimmed == "" line := int(lspLine) + 1 - character := int(params.Position.Character) + 1 path := constructCompletionNodePath(file, line) + wordPrefix := protocol.UInteger(len(prefix(lines[lspLine], character-1))) if len(path) == 0 { if topLevelNodeOffset != -1 && params.Position.Character != uint32(topLevelNodeOffset) { return nil, nil } return &protocol.CompletionList{Items: createTopLevelItems()}, nil } else if len(path) == 1 { + if path[0].Key.GetToken().Value == "include" { + schema := schemaProperties()["include"].Items.(*jsonschema.Schema) + items := createSchemaItems(params, schema.Ref.OneOf[1].Properties, lines, lspLine, whitespaceLine, wordPrefix, file, manager, u, path) + return processItems(items, whitespaceLine), nil + } return nil, nil } else if path[1].Key.GetToken().Position.Column >= character { return nil, nil - } else if len(lines[lspLine]) < character-1 { - return nil, nil } - wordPrefix := prefix(lines[lspLine], character-1) path, nodeProps, arrayAttributes := nodeProperties(path, line, character) - dependencies := dependencyCompletionItems(file, u, path, params, protocol.UInteger(len(wordPrefix))) + dependencies := dependencyCompletionItems(file, u, path, params, wordPrefix) if len(dependencies) > 0 { return &protocol.CompletionList{Items: dependencies}, nil } - items, stop := buildTargetCompletionItems(params, manager, path, u, protocol.UInteger(len(wordPrefix))) + items, stop := buildTargetCompletionItems(params, manager, path, u, wordPrefix) if stop { return &protocol.CompletionList{Items: items}, nil } - items = namedDependencyCompletionItems(file, path, "configs", "configs", params, protocol.UInteger(len(wordPrefix))) + items = namedDependencyCompletionItems(file, path, "configs", "configs", params, wordPrefix) if len(items) == 0 { - items = namedDependencyCompletionItems(file, path, "secrets", "secrets", params, protocol.UInteger(len(wordPrefix))) + items = namedDependencyCompletionItems(file, path, "secrets", "secrets", params, wordPrefix) } if len(items) == 0 { - items = volumeDependencyCompletionItems(file, path, params, protocol.UInteger(len(wordPrefix))) + items = volumeDependencyCompletionItems(file, path, params, wordPrefix) } + schemaItems := createSchemaItems(params, nodeProps, lines, lspLine, whitespaceLine && arrayAttributes, wordPrefix, file, manager, u, path) + items = append(items, schemaItems...) + if len(items) == 0 { + return nil, nil + } + return processItems(items, whitespaceLine && arrayAttributes), nil +} + +func createEnumItems(schema *jsonschema.Schema, params *protocol.CompletionParams, wordPrefixLength protocol.UInteger) []protocol.CompletionItem { + items := []protocol.CompletionItem{} + for _, value := range schema.Enum.Values { + enumValue := value.(string) + item := protocol.CompletionItem{ + Label: enumValue, + Documentation: schema.Description, + Detail: extractDetail(schema), + TextEdit: protocol.TextEdit{ + NewText: enumValue, + Range: protocol.Range{ + Start: protocol.Position{ + Line: params.Position.Line, + Character: params.Position.Character - wordPrefixLength, + }, + End: params.Position, + }, + }, + } + items = append(items, item) + } + return items +} + +func createSchemaItems(params *protocol.CompletionParams, nodeProps any, lines []string, lspLine int, whitespacePrefixedArrayAttribute bool, wordPrefixLength protocol.UInteger, file *ast.File, manager *document.Manager, u *url.URL, path []*ast.MappingValueNode) []protocol.CompletionItem { + items := []protocol.CompletionItem{} if schema, ok := nodeProps.(*jsonschema.Schema); ok { if schema.Enum != nil { - for _, value := range schema.Enum.Values { - enumValue := value.(string) - item := protocol.CompletionItem{ - Label: enumValue, - Documentation: schema.Description, - Detail: extractDetail(schema), - TextEdit: protocol.TextEdit{ - NewText: enumValue, - Range: protocol.Range{ - Start: protocol.Position{ - Line: params.Position.Line, - Character: params.Position.Character - protocol.UInteger(len(wordPrefix)), - }, - End: params.Position, - }, - }, - } - items = append(items, item) - } + return createEnumItems(schema, params, wordPrefixLength) } } else if properties, ok := nodeProps.(map[string]*jsonschema.Schema); ok { - sb := strings.Builder{} - for i := range lines[lspLine] { - if unicode.IsSpace(rune(lines[lspLine][i])) || lines[lspLine][i] == '-' { - sb.WriteString(" ") - } - } - sb.WriteString(" ") - if whitespaceLine && arrayAttributes { - sb.WriteString(" ") - } - spacing := createSpacing(lines[lspLine], whitespaceLine, arrayAttributes) + spacing := createSpacing(lines[lspLine], int(params.Position.Character), whitespacePrefixedArrayAttribute) for attributeName, schema := range properties { item := protocol.CompletionItem{ Detail: extractDetail(schema), @@ -279,7 +293,7 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, manager Range: protocol.Range{ Start: protocol.Position{ Line: params.Position.Line, - Character: params.Position.Character - protocol.UInteger(len(wordPrefix)), + Character: params.Position.Character - protocol.UInteger(wordPrefixLength), }, End: params.Position, }, @@ -314,7 +328,7 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, manager Range: protocol.Range{ Start: protocol.Position{ Line: params.Position.Line, - Character: params.Position.Character - protocol.UInteger(len(wordPrefix)), + Character: params.Position.Character - protocol.UInteger(wordPrefixLength), }, End: params.Position, }, @@ -324,13 +338,14 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, manager items = append(items, item) } } - if len(items) == 0 { - return nil, nil - } + return items +} + +func processItems(items []protocol.CompletionItem, arrayPrefix bool) *protocol.CompletionList { slices.SortFunc(items, func(a, b protocol.CompletionItem) int { return strings.Compare(a.Label, b.Label) }) - if whitespaceLine && arrayAttributes { + if arrayPrefix { for i := range items { edit := items[i].TextEdit.(protocol.TextEdit) items[i].TextEdit = protocol.TextEdit{ @@ -339,7 +354,7 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, manager } } } - return &protocol.CompletionList{Items: items}, nil + return &protocol.CompletionList{Items: items} } func createChoiceSnippetText(itemTexts []completionItemText) string { diff --git a/internal/compose/completion_test.go b/internal/compose/completion_test.go index 778ff62..86090bd 100644 --- a/internal/compose/completion_test.go +++ b/internal/compose/completion_test.go @@ -1985,6 +1985,93 @@ services: }, }, }, + { + name: "properties of an embedded object with a custom name with extra trailing whitespace", + content: ` +services: + test: + networks: + abc: + `, + line: 5, + character: 8, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "aliases", + Detail: types.CreateStringPointer("array"), + Documentation: "A list of unique string values.", + TextEdit: textEdit("aliases:\n - ", 5, 8, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "driver_opts", + Detail: types.CreateStringPointer("object"), + Documentation: "Driver options for this network.", + TextEdit: textEdit("driver_opts:\n ", 5, 8, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "gw_priority", + Detail: types.CreateStringPointer("number"), + Documentation: "Specify the gateway priority for the network connection.", + TextEdit: textEdit("gw_priority: ", 5, 8, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "interface_name", + Detail: types.CreateStringPointer("string"), + Documentation: "Interface network name used to connect to network", + TextEdit: textEdit("interface_name: ", 5, 8, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "ipv4_address", + Detail: types.CreateStringPointer("string"), + Documentation: "Specify a static IPv4 address for this service on this network.", + TextEdit: textEdit("ipv4_address: ", 5, 8, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "ipv6_address", + Detail: types.CreateStringPointer("string"), + Documentation: "Specify a static IPv6 address for this service on this network.", + TextEdit: textEdit("ipv6_address: ", 5, 8, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "link_local_ips", + Detail: types.CreateStringPointer("array"), + Documentation: "A list of unique string values.", + TextEdit: textEdit("link_local_ips:\n - ", 5, 8, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "mac_address", + Detail: types.CreateStringPointer("string"), + Documentation: "Specify a MAC address for this service on this network.", + TextEdit: textEdit("mac_address: ", 5, 8, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "priority", + Detail: types.CreateStringPointer("number"), + Documentation: "Specify the priority for the network connection.", + TextEdit: textEdit("priority: ", 5, 8, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + }, + }, + }, { name: "oneOf results of a service object's networks attribute", content: ` @@ -2573,6 +2660,108 @@ services: character: 1, list: nil, }, + { + name: "include properties with no array hyphen present", + content: "include:\n ", + line: 1, + character: 2, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "env_file", + Detail: types.CreateStringPointer("array or string"), + Documentation: "Either a single string or a list of strings.", + TextEdit: textEdit("- env_file:", 1, 2, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "path", + Detail: types.CreateStringPointer("array or string"), + Documentation: "Either a single string or a list of strings.", + TextEdit: textEdit("- path:", 1, 2, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "project_directory", + Detail: types.CreateStringPointer("string"), + Documentation: "Path to resolve relative paths set in the Compose file", + TextEdit: textEdit("- project_directory: ", 1, 2, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + }, + }, + }, + { + name: "include properties with array hyphen present", + content: "include:\n - ", + line: 1, + character: 4, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "env_file", + Detail: types.CreateStringPointer("array or string"), + Documentation: "Either a single string or a list of strings.", + TextEdit: textEdit("env_file:", 1, 4, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "path", + Detail: types.CreateStringPointer("array or string"), + Documentation: "Either a single string or a list of strings.", + TextEdit: textEdit("path:", 1, 4, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "project_directory", + Detail: types.CreateStringPointer("string"), + Documentation: "Path to resolve relative paths set in the Compose file", + TextEdit: textEdit("project_directory: ", 1, 4, 0), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + }, + }, + }, + { + name: "include properties with a prefix", + content: "include:\n - e", + line: 1, + character: 5, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "env_file", + Detail: types.CreateStringPointer("array or string"), + Documentation: "Either a single string or a list of strings.", + TextEdit: textEdit("env_file:", 1, 5, 1), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "path", + Detail: types.CreateStringPointer("array or string"), + Documentation: "Either a single string or a list of strings.", + TextEdit: textEdit("path:", 1, 5, 1), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + { + Label: "project_directory", + Detail: types.CreateStringPointer("string"), + Documentation: "Path to resolve relative paths set in the Compose file", + TextEdit: textEdit("project_directory: ", 1, 5, 1), + InsertTextMode: types.CreateInsertTextModePointer(protocol.InsertTextModeAsIs), + InsertTextFormat: types.CreateInsertTextFormatPointer(protocol.InsertTextFormatSnippet), + }, + }, + }, + }, } composeFileURI := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(os.TempDir(), "compose.yaml")), "/"))