diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f8edb..092a427 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 + - provide local file and folder name suggestions when modifying simple strings for service volumes ([#376](https://github.com/docker/docker-language-server/issues/376)) + ### Fixed - Compose diff --git a/e2e-tests/initialize_test.go b/e2e-tests/initialize_test.go index c30232c..5e237d4 100644 --- a/e2e-tests/initialize_test.go +++ b/e2e-tests/initialize_test.go @@ -96,8 +96,10 @@ func createGuaranteedInitializeResult() protocol.InitializeResult { syncKind := protocol.TextDocumentSyncKindFull return protocol.InitializeResult{ Capabilities: protocol.ServerCapabilities{ - CodeActionProvider: protocol.CodeActionOptions{}, - CompletionProvider: &protocol.CompletionOptions{}, + CodeActionProvider: protocol.CodeActionOptions{}, + CompletionProvider: &protocol.CompletionOptions{ + TriggerCharacters: []string{"/"}, + }, DefinitionProvider: protocol.DefinitionOptions{}, DocumentHighlightProvider: &protocol.DocumentHighlightOptions{}, DocumentLinkProvider: &protocol.DocumentLinkOptions{}, diff --git a/internal/compose/completion.go b/internal/compose/completion.go index b03471e..608e2eb 100644 --- a/internal/compose/completion.go +++ b/internal/compose/completion.go @@ -3,6 +3,7 @@ package compose import ( "context" "fmt" + "os" "path/filepath" "slices" "strings" @@ -213,7 +214,8 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, manager whitespaceLine := currentLineTrimmed == "" line := int(lspLine) + 1 path := constructCompletionNodePath(file, line) - wordPrefix := protocol.UInteger(len(prefix(lines[lspLine], character-1))) + prefixContent := prefix(lines[lspLine], character-1) + prefixLength := protocol.UInteger(len(prefixContent)) if len(path) == 0 { if topLevelNodeOffset != -1 && params.Position.Character != uint32(topLevelNodeOffset) { return nil, nil @@ -222,7 +224,7 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, manager } 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, documentPath, path) + items := createSchemaItems(params, schema.Ref.OneOf[1].Properties, lines, lspLine, whitespaceLine, prefixLength, file, manager, documentPath, path) return processItems(items, whitespaceLine), nil } return nil, nil @@ -231,23 +233,27 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, manager } path, nodeProps, arrayAttributes := nodeProperties(path, line, character) - dependencies := dependencyCompletionItems(file, documentPath, path, params, wordPrefix) + dependencies := dependencyCompletionItems(file, documentPath, path, params, prefixLength) if len(dependencies) > 0 { return &protocol.CompletionList{Items: dependencies}, nil } - items, stop := buildTargetCompletionItems(params, manager, path, documentPath, wordPrefix) + items, stop := buildTargetCompletionItems(params, manager, path, documentPath, prefixLength) if stop { return &protocol.CompletionList{Items: items}, nil } + folderStructureItems := folderStructureCompletionItems(documentPath, path, removeQuote(prefixContent)) + if len(folderStructureItems) > 0 { + return processItems(folderStructureItems, whitespaceLine && arrayAttributes), nil + } - items = namedDependencyCompletionItems(file, path, "configs", "configs", params, wordPrefix) + items = namedDependencyCompletionItems(file, path, "configs", "configs", params, prefixLength) if len(items) == 0 { - items = namedDependencyCompletionItems(file, path, "secrets", "secrets", params, wordPrefix) + items = namedDependencyCompletionItems(file, path, "secrets", "secrets", params, prefixLength) } if len(items) == 0 { - items = volumeDependencyCompletionItems(file, path, params, wordPrefix) + items = volumeDependencyCompletionItems(file, path, params, prefixLength) } - schemaItems := createSchemaItems(params, nodeProps, lines, lspLine, whitespaceLine && arrayAttributes, wordPrefix, file, manager, documentPath, path) + schemaItems := createSchemaItems(params, nodeProps, lines, lspLine, whitespaceLine && arrayAttributes, prefixLength, file, manager, documentPath, path) items = append(items, schemaItems...) if len(items) == 0 { return nil, nil @@ -255,6 +261,13 @@ func Completion(ctx context.Context, params *protocol.CompletionParams, manager return processItems(items, whitespaceLine && arrayAttributes), nil } +func removeQuote(prefix string) string { + if len(prefix) > 0 && (prefix[0] == 34 || prefix[0] == 39) { + return prefix[1:] + } + return prefix +} + func createEnumItems(schema *jsonschema.Schema, params *protocol.CompletionParams, wordPrefixLength protocol.UInteger) []protocol.CompletionItem { items := []protocol.CompletionItem{} for _, value := range schema.Enum.Values { @@ -382,6 +395,54 @@ func modifyTextEdit(file *ast.File, manager *document.Manager, documentPath docu return edit } +func folderStructureCompletionItems(documentPath document.DocumentPath, path []*ast.MappingValueNode, prefix string) []protocol.CompletionItem { + folder := directoryForPrefix(documentPath, path, prefix) + if folder != "" { + items := []protocol.CompletionItem{} + entries, _ := os.ReadDir(folder) + for _, entry := range entries { + item := protocol.CompletionItem{Label: entry.Name()} + if entry.IsDir() { + item.Kind = types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder) + } else { + item.Kind = types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFile) + } + items = append(items, item) + } + return items + } + return nil +} + +func directoryForPrefix(documentPath document.DocumentPath, path []*ast.MappingValueNode, prefix string) string { + if len(path) == 3 && path[2].Key.GetToken().Value == "volumes" { + if strings.HasPrefix(prefix, "./") { + _, folder := types.Concatenate(documentPath.Folder, prefix[0:strings.LastIndex(prefix, "/")], documentPath.WSLDollarSignHost) + return folder + } + } else if len(path) == 4 && path[2].Key.GetToken().Value == "volumes" && path[3].Key.GetToken().Value == "source" { + if volumes, ok := path[2].Value.(*ast.SequenceNode); ok { + for _, node := range volumes.Values { + if volume, ok := node.(*ast.MappingNode); ok { + if slices.Contains(volume.Values, path[3]) { + for _, property := range volume.Values { + if property.Key.GetToken().Value == "type" && property.Value.GetToken().Value == "bind" { + if strings.HasPrefix(prefix, "./") { + _, folder := types.Concatenate(documentPath.Folder, prefix[0:strings.LastIndex(prefix, "/")], documentPath.WSLDollarSignHost) + return folder + } + return documentPath.Folder + } + } + return "" + } + } + } + } + } + return "" +} + func findDependencies(file *ast.File, dependencyType string) []string { services := []string{} for _, documentNode := range file.Docs { diff --git a/internal/compose/completion_test.go b/internal/compose/completion_test.go index c0854e4..ee113b5 100644 --- a/internal/compose/completion_test.go +++ b/internal/compose/completion_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/docker/docker-language-server/internal/pkg/document" "github.com/docker/docker-language-server/internal/tliron/glsp/protocol" @@ -4577,6 +4578,300 @@ services: } } +func TestCompletion_VolumeFolderListing(t *testing.T) { + dir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("%v-%v", t.Name(), time.Now().UnixMilli())) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(dir)) + }) + + fileStructure := []struct { + name string + isDir bool + }{ + {name: "a.txt", isDir: false}, + {name: "b", isDir: true}, + {name: "folder", isDir: true}, + {name: "folder/subfile.txt", isDir: false}, + } + for _, entry := range fileStructure { + if entry.isDir { + require.NoError(t, os.Mkdir(filepath.Join(dir, entry.name), 0755)) + } else { + f, err := os.Create(filepath.Join(dir, entry.name)) + require.NoError(t, err) + require.NoError(t, f.Close()) + } + } + + testCases := []struct { + name string + content string + line uint32 + character uint32 + list *protocol.CompletionList + }{ + { + name: "./ prefix ignored on other attributes", + content: ` +services: + test: + depends_on: + - ./`, + line: 4, + character: 10, + list: nil, + }, + { + name: "suggests nothing for source attribute with no type specified", + content: ` +services: + test: + volumes: + - source: `, + line: 4, + character: 16, + list: nil, + }, + { + name: "suggests nothing for source attribute when type is not bind", + content: ` +services: + test: + volumes: + - type: volume + source: `, + line: 5, + character: 16, + list: nil, + }, + { + name: "suggests nothing for multiple volumes on a non-bind one", + content: ` +services: + test: + volumes: + - type: bind + - source: ./`, + line: 5, + character: 18, + list: nil, + }, + { + name: "suggest file structure with a ./ prefix", + content: ` +services: + test: + volumes: + - ./`, + line: 4, + character: 10, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "a.txt", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFile), + }, + { + Label: "b", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder), + }, + { + Label: "folder", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder), + }, + }, + }, + }, + { + name: "suggest file structure with a quoted \"./\" prefix", + content: ` +services: + test: + volumes: + - "./"`, + line: 4, + character: 11, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "a.txt", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFile), + }, + { + Label: "b", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder), + }, + { + Label: "folder", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder), + }, + }, + }, + }, + { + name: "suggest file structure with a quoted './' prefix", + content: ` +services: + test: + volumes: + - './'`, + line: 4, + character: 11, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "a.txt", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFile), + }, + { + Label: "b", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder), + }, + { + Label: "folder", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder), + }, + }, + }, + }, + { + name: "suggest file structure with a ./a prefix", + content: ` +services: + test: + volumes: + - ./a`, + line: 4, + character: 11, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "a.txt", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFile), + }, + { + Label: "b", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder), + }, + { + Label: "folder", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder), + }, + }, + }, + }, + { + name: "suggest file structure with a ./folder/ prefix", + content: ` +services: + test: + volumes: + - ./folder/`, + line: 4, + character: 17, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "subfile.txt", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFile), + }, + }, + }, + }, + { + name: "suggests file structure for source attribute when type is bind with no content", + content: ` +services: + test: + volumes: + - type: bind + source: `, + line: 5, + character: 16, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "a.txt", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFile), + }, + { + Label: "b", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder), + }, + { + Label: "folder", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder), + }, + }, + }, + }, + { + name: "suggests file structure for source attribute when type is bind with a ./ prefix", + content: ` +services: + test: + volumes: + - type: bind + source: ./`, + line: 5, + character: 18, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "a.txt", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFile), + }, + { + Label: "b", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder), + }, + { + Label: "folder", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFolder), + }, + }, + }, + }, + { + name: "suggests inner file structure for source attribute when type is bind with a ./folder/ prefix", + content: ` +services: + test: + volumes: + - type: bind + source: ./folder/`, + line: 5, + character: 25, + list: &protocol.CompletionList{ + Items: []protocol.CompletionItem{ + { + Label: "subfile.txt", + Kind: types.CreateCompletionItemKindPointer(protocol.CompletionItemKindFile), + }, + }, + }, + }, + } + + composeFileURI := fmt.Sprintf("file:///%v", strings.TrimPrefix(filepath.ToSlash(filepath.Join(dir, "compose.yaml")), "/")) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + manager := document.NewDocumentManager() + doc := document.NewComposeDocument(manager, uri.URI(composeFileURI), 1, []byte(tc.content)) + list, err := Completion(context.Background(), &protocol.CompletionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: composeFileURI}, + Position: protocol.Position{Line: tc.line, Character: tc.character}, + }, + }, manager, doc) + require.NoError(t, err) + require.Equal(t, tc.list, list) + }) + } +} + func textEdit(newText string, line, character, prefixLength protocol.UInteger) protocol.TextEdit { return protocol.TextEdit{ NewText: newText, diff --git a/internal/pkg/server/initialize.go b/internal/pkg/server/initialize.go index e6e7c7e..ceadf08 100644 --- a/internal/pkg/server/initialize.go +++ b/internal/pkg/server/initialize.go @@ -128,9 +128,11 @@ func (s *Server) Initialize(ctx *glsp.Context, params *protocol.InitializeParams syncKind := protocol.TextDocumentSyncKindFull result := protocol.InitializeResult{ Capabilities: protocol.ServerCapabilities{ - CodeActionProvider: protocol.CodeActionOptions{}, - CodeLensProvider: codeLensProvider, - CompletionProvider: &protocol.CompletionOptions{}, + CodeActionProvider: protocol.CodeActionOptions{}, + CodeLensProvider: codeLensProvider, + CompletionProvider: &protocol.CompletionOptions{ + TriggerCharacters: []string{"/"}, + }, DefinitionProvider: protocol.DefinitionOptions{}, DocumentHighlightProvider: protocol.DocumentHighlightOptions{}, DocumentLinkProvider: &protocol.DocumentLinkOptions{},