From 0b7d8438534a5052013c677bd3f36339f691d131 Mon Sep 17 00:00:00 2001 From: qvalentin Date: Thu, 18 Apr 2024 09:07:06 +0200 Subject: [PATCH] feat(completion): tests and refactoring --- internal/documentation/godocs/gotemplate.go | 127 +++++++++--------- internal/handler/completion.go | 20 +-- internal/handler/completion_main_test.go | 100 +++++++++++++- .../generic_template_context.go | 6 +- .../language_features/template_context.go | 37 +++++ internal/lsp/symbol_table.go | 9 +- internal/lsp/symbol_table_test.go | 8 ++ internal/protocol/completion.go | 27 ++++ 8 files changed, 246 insertions(+), 88 deletions(-) create mode 100644 internal/protocol/completion.go diff --git a/internal/documentation/godocs/gotemplate.go b/internal/documentation/godocs/gotemplate.go index 79121e0c..13f3d442 100644 --- a/internal/documentation/godocs/gotemplate.go +++ b/internal/documentation/godocs/gotemplate.go @@ -37,70 +37,67 @@ type GoTemplateSnippet struct { Detail string Doc string Snippet string - Filter string } -var ( - TextSnippets = []GoTemplateSnippet{ - { - Name: "comment", - Detail: "{{- /* a comment with white space trimmed from preceding and following text */ -}}", - Doc: "A comment; discarded. May contain newlines. Comments do not nest and must start and end at the delimiters, as shown here.", - Snippet: "{{- /* $1 */ -}}", - }, - { - Name: "{{ }}", - Detail: "template", - Doc: "", - Snippet: "{{- $0 }}", - }, - { - Name: "if", - Detail: "{{if pipeline}} T1 {{end}}", - Doc: "If the value of the pipeline is empty, no output is generated; otherwise, T1 is executed. The empty values are false, 0, any nil pointer or interface value, and any array, slice, map, or string of length zero. Dot is unaffected.", - Snippet: "{{- if $1 }}\n $0 \n{{- end }}", - }, - { - Name: "if else", - Detail: "{{if pipeline}} T1 {{else}} T0 {{end}}", - Doc: "If the value of the pipeline is empty, T0 is executed; otherwise, T1 is executed. Dot is unaffected.", - Snippet: "{{- if $1 }}\n $2 \n{{- else }}\n $0 \n{{- end }}", - }, - { - Name: "if else if", - Detail: "{{if pipeline}} T1 {{else if pipeline}} T0 {{end}}", - Doc: "To simplify the appearance of if-else chains, the else action of an if may include another if directly; the effect is exactly the same as writing {{if pipeline}} T1 {{else}}{{if pipeline}} T0 {{end}}{{end}}", - Snippet: "{{- if $1 }}\n $2 \n{{- else if $4 }}\n $0 \n{{- end }}", - }, - { - Name: "range", - Detail: "{{range pipeline}} T1 {{end}}", - Doc: "The value of the pipeline must be an array, slice, map, or channel. If the value of the pipeline has length zero, nothing is output; otherwise, dot is set to the successive elements of the array, slice, or map and T1 is executed. If the value is a map and the keys are of basic type with a defined order, the elements will be visited in sorted key order.", - Snippet: "{{- range $1 }}\n $0 \n{{- end }}", - }, - { - Name: "range else", - Detail: "{{range pipeline}} T1 {{else}} T0 {{end}}", - Doc: "The value of the pipeline must be an array, slice, map, or channel. If the value of the pipeline has length zero, dot is unaffected and T0 is executed; otherwise, dot is set to the successive elements of the array, slice, or map and T1 is executed.", - Snippet: "{{- range $1 }}\n $2 {{- else }}\n $0 \n{{- end }}", - }, - { - Name: "block", - Detail: "{{block \"name\" pipeline}} T1 {{end}}", - Doc: "A block is shorthand for defining a template {{define \"name\"}} T1 {{end}} and then executing it in place {{template \"name\" pipeline}} The typical use is to define a set of root templates that are then customized by redefining the block templates within.", - Snippet: "{{- block $1 }}\n $0 \n{{- end }}", - }, - { - Name: "with", - Detail: "{{with pipeline}} T1 {{end}}", - Doc: "If the value of the pipeline is empty, no output is generated; otherwise, dot is set to the value of the pipeline and T1 is executed.", - Snippet: "{{- with $1 }}\n $0 \n{{- end }}", - }, - { - Name: "with else", - Detail: "{{with pipeline}} T1 {{else}} T0 {{end}}", - Doc: "If the value of the pipeline is empty, dot is unaffected and T0 is executed; otherwise, dot is set to the value of the pipeline and T1 is executed", - Snippet: "{{- with $1 }}\n $2 {{- else }}\n $0 \n{{- end }}", - }, - } -) +var TextSnippets = []GoTemplateSnippet{ + { + Name: "comment", + Detail: "{{- /* a comment with white space trimmed from preceding and following text */ -}}", + Doc: "A comment; discarded. May contain newlines. Comments do not nest and must start and end at the delimiters, as shown here.", + Snippet: "{{- /* $1 */ -}}", + }, + { + Name: "{{ }}", + Detail: "template", + Doc: "", + Snippet: "{{- $0 }}", + }, + { + Name: "if", + Detail: "{{if pipeline}} T1 {{end}}", + Doc: "If the value of the pipeline is empty, no output is generated; otherwise, T1 is executed. The empty values are false, 0, any nil pointer or interface value, and any array, slice, map, or string of length zero. Dot is unaffected.", + Snippet: "{{- if $1 }}\n $0 \n{{- end }}", + }, + { + Name: "if else", + Detail: "{{if pipeline}} T1 {{else}} T0 {{end}}", + Doc: "If the value of the pipeline is empty, T0 is executed; otherwise, T1 is executed. Dot is unaffected.", + Snippet: "{{- if $1 }}\n $2 \n{{- else }}\n $0 \n{{- end }}", + }, + { + Name: "if else if", + Detail: "{{if pipeline}} T1 {{else if pipeline}} T0 {{end}}", + Doc: "To simplify the appearance of if-else chains, the else action of an if may include another if directly; the effect is exactly the same as writing {{if pipeline}} T1 {{else}}{{if pipeline}} T0 {{end}}{{end}}", + Snippet: "{{- if $1 }}\n $2 \n{{- else if $4 }}\n $0 \n{{- end }}", + }, + { + Name: "range", + Detail: "{{range pipeline}} T1 {{end}}", + Doc: "The value of the pipeline must be an array, slice, map, or channel. If the value of the pipeline has length zero, nothing is output; otherwise, dot is set to the successive elements of the array, slice, or map and T1 is executed. If the value is a map and the keys are of basic type with a defined order, the elements will be visited in sorted key order.", + Snippet: "{{- range $1 }}\n $0 \n{{- end }}", + }, + { + Name: "range else", + Detail: "{{range pipeline}} T1 {{else}} T0 {{end}}", + Doc: "The value of the pipeline must be an array, slice, map, or channel. If the value of the pipeline has length zero, dot is unaffected and T0 is executed; otherwise, dot is set to the successive elements of the array, slice, or map and T1 is executed.", + Snippet: "{{- range $1 }}\n $2 {{- else }}\n $0 \n{{- end }}", + }, + { + Name: "block", + Detail: "{{block \"name\" pipeline}} T1 {{end}}", + Doc: "A block is shorthand for defining a template {{define \"name\"}} T1 {{end}} and then executing it in place {{template \"name\" pipeline}} The typical use is to define a set of root templates that are then customized by redefining the block templates within.", + Snippet: "{{- block $1 }}\n $0 \n{{- end }}", + }, + { + Name: "with", + Detail: "{{with pipeline}} T1 {{end}}", + Doc: "If the value of the pipeline is empty, no output is generated; otherwise, dot is set to the value of the pipeline and T1 is executed.", + Snippet: "{{- with $1 }}\n $0 \n{{- end }}", + }, + { + Name: "with else", + Detail: "{{with pipeline}} T1 {{else}} T0 {{end}}", + Doc: "If the value of the pipeline is empty, dot is unaffected and T0 is executed; otherwise, dot is set to the value of the pipeline and T1 is executed", + Snippet: "{{- with $1 }}\n $2 {{- else }}\n $0 \n{{- end }}", + }, +} diff --git a/internal/handler/completion.go b/internal/handler/completion.go index dc4dddde..3d46e0f5 100644 --- a/internal/handler/completion.go +++ b/internal/handler/completion.go @@ -35,6 +35,7 @@ func init() { } func (h *langHandler) Completion(ctx context.Context, params *lsp.CompletionParams) (result *lsp.CompletionList, err error) { + logger.Debug("Running completion with params", params) doc, ok := h.documents.Get(params.TextDocument.URI) if !ok { return nil, errors.New("Could not get document: " + params.TextDocument.URI.Filename()) @@ -50,6 +51,7 @@ func (h *langHandler) Completion(ctx context.Context, params *lsp.CompletionPara result := make([]lsp.CompletionItem, 0) result = append(result, textCompletionsItems...) result = append(result, yamllsCompletions(ctx, h, params)...) + logger.Debug("Sending completions ", result) return &protocol.CompletionList{IsIncomplete: false, Items: result}, err } @@ -69,7 +71,6 @@ func (h *langHandler) Completion(ctx context.Context, params *lsp.CompletionPara } logger.Println(fmt.Sprintf("Word found for completions is < %s >", word)) - items = make([]lsp.CompletionItem, 0) for _, v := range helmdocs.BuiltInObjects { items = append(items, lsp.CompletionItem{ @@ -132,19 +133,20 @@ func completionAstParsing(doc *lsplocal.Document, position lsp.Position) (string logger.Println("currentNode", currentNode) logger.Println("relevantChildNode", relevantChildNode) - switch relevantChildNode.Type() { + nodeType := relevantChildNode.Type() + switch nodeType { case gotemplate.NodeTypeIdentifier: word = relevantChildNode.Content([]byte(doc.Content)) case gotemplate.NodeTypeDot: logger.Debug("TraverseIdentifierPathUp for dot node") word = lsplocal.TraverseIdentifierPathUp(relevantChildNode, doc) - case gotemplate.NodeTypeDotSymbol: + case gotemplate.NodeTypeDotSymbol, gotemplate.NodeTypeFieldIdentifier: logger.Debug("GetFieldIdentifierPath") word = lsplocal.GetFieldIdentifierPath(relevantChildNode, doc) case gotemplate.NodeTypeText, gotemplate.NodeTypeTemplate: return word, true } - logger.Println("word", word) + logger.Debug("word", word) return word, false } @@ -285,14 +287,14 @@ func getTextCompletionItems(gotemplateSnippet []godocs.GoTemplateSnippet) (resul func textCompletionItem(gotemplateSnippet godocs.GoTemplateSnippet) lsp.CompletionItem { return lsp.CompletionItem{ Label: gotemplateSnippet.Name, - TextEdit: &lsp.TextEdit{ - Range: lsp.Range{}, - NewText: gotemplateSnippet.Snippet, - }, + // TextEdit: &lsp.TextEdit{ + // // Range: lsp.Range{}, // TODO: range must contain the requested range + // NewText: gotemplateSnippet.Snippet, + // }, + InsertText: gotemplateSnippet.Snippet, Detail: gotemplateSnippet.Detail, Documentation: gotemplateSnippet.Doc, Kind: lsp.CompletionItemKindText, InsertTextFormat: lsp.InsertTextFormatSnippet, - FilterText: gotemplateSnippet.Filter, } } diff --git a/internal/handler/completion_main_test.go b/internal/handler/completion_main_test.go index bfd40eef..6d848fb9 100644 --- a/internal/handler/completion_main_test.go +++ b/internal/handler/completion_main_test.go @@ -7,6 +7,7 @@ import ( "github.com/mrjosh/helm-ls/internal/adapter/yamlls" "github.com/mrjosh/helm-ls/internal/charts" + helmdocs "github.com/mrjosh/helm-ls/internal/documentation/helm" lsplocal "github.com/mrjosh/helm-ls/internal/lsp" "github.com/mrjosh/helm-ls/internal/util" "github.com/stretchr/testify/assert" @@ -16,20 +17,101 @@ import ( func TestCompletionMain(t *testing.T) { testCases := []struct { - desc string - position lsp.Position - expectedInsertText string - expectedError error + desc string + position lsp.Position + expectedInsertText string + notExpectedInsertTexts []string + expectedError error }{ { - desc: "Test completion on .Values.re", + desc: "Test completion on {{ . }}", + position: lsp.Position{ + Line: 6, + Character: 4, + }, + expectedInsertText: "Chart", + notExpectedInsertTexts: []string{ + helmdocs.HelmFuncs[0].Name, + "replicaCount", + "toYaml", + }, + expectedError: nil, + }, + { + desc: "Test completion on .Values.", position: lsp.Position{ Line: 0, + Character: 11, + }, + expectedInsertText: "replicaCount", + notExpectedInsertTexts: []string{ + helmdocs.HelmFuncs[0].Name, + }, + expectedError: nil, + }, + { + desc: "Test completion on .Values.re", + position: lsp.Position{ + Line: 1, Character: 13, }, - expectedInsertText: "not $x\n\nnegate the boolean value of $x", - expectedError: nil, + expectedInsertText: "replicaCount", + notExpectedInsertTexts: []string{ + helmdocs.HelmFuncs[0].Name, + }, + expectedError: nil, + }, + { + desc: "Test completion on {{ toY }}", + position: lsp.Position{ + Line: 3, + Character: 6, + }, + expectedInsertText: "toYaml", + notExpectedInsertTexts: []string{ + "replicaCount", + }, + expectedError: nil, + }, + { + desc: "Test completion on text", + position: lsp.Position{ + Line: 4, + Character: 0, + }, + expectedInsertText: "{{- if $1 }}\n $2 \n{{- else }}\n $0 \n{{- end }}", + notExpectedInsertTexts: []string{ + "replicaCount", + "toYaml", + }, + expectedError: nil, }, + { + desc: "Test completion on .Chart.N", + position: lsp.Position{ + Line: 5, + Character: 11, + }, + expectedInsertText: "Name", + notExpectedInsertTexts: []string{ + helmdocs.HelmFuncs[0].Name, + "replicaCount", + "toYaml", + }, + expectedError: nil, + }, + // { + // desc: "Test completion on {{ }}", + // position: lsp.Position{ + // Line: 4, + // Character: 3, + // }, + // expectedInsertText: "toYaml", + // notExpectedInsertTexts: []string{ + // "replicaCount", + // }, + // expectedError: nil, + // }, } for _, tt := range testCases { t.Run(tt.desc, func(t *testing.T) { @@ -71,6 +153,10 @@ func TestCompletionMain(t *testing.T) { insertTexts = append(insertTexts, item.InsertText) } assert.Contains(t, insertTexts, tt.expectedInsertText) + + for _, notExpectedInsertText := range tt.notExpectedInsertTexts { + assert.NotContains(t, insertTexts, notExpectedInsertText) + } }) } } diff --git a/internal/language_features/generic_template_context.go b/internal/language_features/generic_template_context.go index fcb4c225..13bf565b 100644 --- a/internal/language_features/generic_template_context.go +++ b/internal/language_features/generic_template_context.go @@ -14,11 +14,7 @@ type GenericTemplateContextFeature struct { } func (f *GenericTemplateContextFeature) getTemplateContext() (lsplocal.TemplateContext, error) { - templateContext := f.GenericDocumentUseCase.Document.SymbolTable.GetTemplateContext(lsplocal.GetRangeForNode(f.Node)) - if len(templateContext) == 0 || templateContext == nil { - return lsplocal.TemplateContext{}, fmt.Errorf("no template context found") - } - return templateContext, nil + return f.GenericDocumentUseCase.Document.SymbolTable.GetTemplateContext(lsplocal.GetRangeForNode(f.Node)) } func (f *GenericTemplateContextFeature) getReferencesFromSymbolTable(templateContext lsplocal.TemplateContext) []lsp.Location { diff --git a/internal/language_features/template_context.go b/internal/language_features/template_context.go index 8d5799aa..287516bf 100644 --- a/internal/language_features/template_context.go +++ b/internal/language_features/template_context.go @@ -9,6 +9,7 @@ import ( helmdocs "github.com/mrjosh/helm-ls/internal/documentation/helm" lsplocal "github.com/mrjosh/helm-ls/internal/lsp" + "github.com/mrjosh/helm-ls/internal/protocol" "github.com/mrjosh/helm-ls/internal/tree-sitter/gotemplate" "github.com/mrjosh/helm-ls/internal/util" "github.com/mrjosh/helm-ls/pkg/chart" @@ -131,3 +132,39 @@ func (f *TemplateContextFeature) getTableOrValueForSelector(values chartutil.Val } return values.YAML() } + +func (f *TemplateContextFeature) Completion() (result *lsp.CompletionList, err error) { + templateContext, err := f.getTemplateContext() + if err != nil { + return nil, err + } + + if len(templateContext) == 0 { + result := helmdocs.BuiltInObjects + return protocol.NewCompletionResults(result).ToLSP(), nil + } + + if len(templateContext) == 1 { + result, ok := helmdocs.BuiltInOjectVals[templateContext[0]] + if !ok { + result := helmdocs.BuiltInObjects + } + return nil, nil + } + + m := make(map[string]lsp.CompletionItem) + for _, queriedValuesFiles := range f.Chart.ResolveValueFiles(templateContext.Tail(), f.ChartStore) { + for _, valuesFile := range queriedValuesFiles.ValuesFiles.AllValuesFiles() { + for _, item := range h.getValue(valuesFile.Values, queriedValuesFiles.Selector) { + m[item.InsertText] = item + } + } + } + + for _, item := range m { + result = append(result, item) + } + + return result + return nil, nil +} diff --git a/internal/lsp/symbol_table.go b/internal/lsp/symbol_table.go index c9b5e5c3..2f3d6719 100644 --- a/internal/lsp/symbol_table.go +++ b/internal/lsp/symbol_table.go @@ -1,6 +1,7 @@ package lsp import ( + "fmt" "strings" sitter "github.com/smacker/go-tree-sitter" @@ -45,8 +46,12 @@ func (s *SymbolTable) GetTemplateContextRanges(templateContext TemplateContext) return s.contexts[templateContext.Format()] } -func (s *SymbolTable) GetTemplateContext(pointRange sitter.Range) TemplateContext { - return s.contextsReversed[pointRange] +func (s *SymbolTable) GetTemplateContext(pointRange sitter.Range) (TemplateContext, error) { + result, ok := s.contextsReversed[pointRange] + if !ok { + return result, fmt.Errorf("no template context found") + } + return result, nil } func (s *SymbolTable) AddIncludeDefinition(symbol string, pointRange sitter.Range) { diff --git a/internal/lsp/symbol_table_test.go b/internal/lsp/symbol_table_test.go index de977495..62ab6fe4 100644 --- a/internal/lsp/symbol_table_test.go +++ b/internal/lsp/symbol_table_test.go @@ -61,6 +61,7 @@ func TestSymbolTableForValues(t *testing.T) { {{ end }} {{ .Test }} +{{ . }} ` ast := ParseAst(nil, content) @@ -156,6 +157,13 @@ func TestSymbolTableForValues(t *testing.T) { Column: 6, }, }, + { + path: []string{}, + startPoint: sitter.Point{ + Row: 21, + Column: 3, + }, + }, } for _, v := range expected { diff --git a/internal/protocol/completion.go b/internal/protocol/completion.go new file mode 100644 index 00000000..757df8c6 --- /dev/null +++ b/internal/protocol/completion.go @@ -0,0 +1,27 @@ +package protocol + +import ( + helmdocs "github.com/mrjosh/helm-ls/internal/documentation/helm" + lsp "go.lsp.dev/protocol" +) + +type CompletionResults []CompletionResult + +func NewCompletionResults(docs []helmdocs.HelmDocumentation) *CompletionResults { + result := CompletionResults{} + + for _, doc := range docs { + result = append(result, CompletionResult{doc}) + } + + return &result +} + +type CompletionResult struct { + Documentation helmdocs.HelmDocumentation +} + +func (*CompletionResults) ToLSP() (result *lsp.CompletionList) { + // TODO: implement this + return nil +}