diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 29083558..580bd33a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: go-version: [1.21.5] - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-2022] runs-on: ${{ matrix.os }} steps: @@ -27,30 +27,3 @@ jobs: - name: Run tests run: make test - - tests-windows: - name: tests - strategy: - matrix: - go-version: [1.21.5] - os: [windows-2019] - runs-on: ${{ matrix.os }} - steps: - - - name: Install Go - uses: actions/setup-go@v4 - with: - go-version: ${{ matrix.go-version }} - - - name: Set up MinGW - uses: egor-tensin/setup-mingw@v2 - with: - version: 12.2.0 - - - name: Checkout code - uses: actions/checkout@v4 - - - name: Run tests - run: make test - env: - CC: "cc" diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..83e55fdc --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "testdata/charts"] + path = testdata/charts + url = https://github.com/qvalentin/charts.git diff --git a/Makefile b/Makefile index 6cdf201d..f8a23ccc 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,16 @@ install-metalinter: @$(GO) get -v github.com/golangci/golangci-lint/cmd/golangci-lint@v1.53.2 @$(GO) install -v github.com/golangci/golangci-lint/cmd/golangci-lint@v1.53.2 +install-yamlls: + npm install --global yaml-language-server + +integration-test-deps: + @YAMLLS_BIN=$$(command -v yaml-language-server) || { echo "yaml-language-server command not found! Installing..." && $(MAKE) install-yamlls; }; + git submodule init + git submodule update --depth 1 + test: + $(MAKE) integration-test-deps @$(GO) test ./... -v -race coverage: diff --git a/internal/adapter/yamlls/diagnostics.go b/internal/adapter/yamlls/diagnostics.go index 29af3a1d..01bb695e 100644 --- a/internal/adapter/yamlls/diagnostics.go +++ b/internal/adapter/yamlls/diagnostics.go @@ -54,20 +54,26 @@ func diagnisticIsRelevant(diagnostic lsp.Diagnostic, node *sitter.Node) bool { switch diagnostic.Message { case "Map keys must be unique": return !lsplocal.IsInElseBranch(node) - case "All mapping items must start at the same column", - "Implicit map keys need to be followed by map values", - "Implicit keys need to be on a single line", - "A block sequence may not be used as an implicit map key": - // TODO: could add a check if is is caused by includes + case "All mapping items must start at the same column": + // unknown what exactly this is, only causes one error in bitnami/charts return false + case "Implicit map keys need to be followed by map values", "A block sequence may not be used as an implicit map key", "Implicit keys need to be on a single line": + // still breaks with + // params: + // {{- range $key, $value := .params }} + // {{ $key }}: + // {{- range $value }} + // - {{ . | quote }} + // {{- end }} + // {{- end }} + return false && !lsplocal.IsInElseBranch(node) case "Block scalars with more-indented leading empty lines must use an explicit indentation indicator": - return false // TODO: check if this is a false positive, probably requires parsing the yaml with tree-sitter injections // smtp-password: | // {{- if not .Values.existingSecret }} // test: dsa // {{- end }} - + return false default: return true } diff --git a/internal/adapter/yamlls/diagnostics_integration_test.go b/internal/adapter/yamlls/diagnostics_integration_test.go new file mode 100644 index 00000000..3426d82e --- /dev/null +++ b/internal/adapter/yamlls/diagnostics_integration_test.go @@ -0,0 +1,164 @@ +package yamlls + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + lsplocal "github.com/mrjosh/helm-ls/internal/lsp" + "github.com/mrjosh/helm-ls/internal/util" + "github.com/stretchr/testify/assert" + "go.lsp.dev/jsonrpc2" + lsp "go.lsp.dev/protocol" + "go.lsp.dev/uri" +) + +// must be relative to this file +var TEST_DATA_DIR = "../../../testdata/charts/bitnami/" + +type jsonRpcDiagnostics struct { + Params lsp.PublishDiagnosticsParams `json:"params"` + Jsonrpc string `json:"jsonrpc"` + Method string `json:"method"` +} + +type readWriteCloseMock struct { + diagnosticsChan chan lsp.PublishDiagnosticsParams +} + +func (proc readWriteCloseMock) Read(p []byte) (int, error) { + return 1, nil +} + +func (proc readWriteCloseMock) Write(p []byte) (int, error) { + if strings.HasPrefix(string(p), "Content-Length: ") { + return 1, nil + } + var diagnostics jsonRpcDiagnostics + json.NewDecoder(strings.NewReader(string(p))).Decode(&diagnostics) + + proc.diagnosticsChan <- diagnostics.Params + return 1, nil +} + +func (proc readWriteCloseMock) Close() error { + return nil +} + +func readTestFiles(dir string, channel chan<- lsp.DidOpenTextDocumentParams, doneChan chan<- int) { + libRegEx, e := regexp.Compile(".*(/|\\\\)templates(/|\\\\).*\\.ya?ml") + if e != nil { + log.Fatal(e) + return + } + if _, err := os.Stat(dir); os.IsNotExist(err) { + log.Fatal(err) + return + } + + count := 0 + filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if d.Type().IsRegular() && libRegEx.MatchString(path) { + contentBytes, _ := os.ReadFile(path) + count++ + channel <- lsp.DidOpenTextDocumentParams{ + TextDocument: lsp.TextDocumentItem{ + URI: uri.File(path), + LanguageID: "", + Version: 0, + Text: string(contentBytes), + }, + } + } + return nil + }) + doneChan <- count +} + +func sendTestFilesToYamlls(documents *lsplocal.DocumentStore, yamllsConnector *Connector, + doneReadingFilesChan <-chan int, + doneSendingFilesChan chan<- int, + filesChan <-chan lsp.DidOpenTextDocumentParams, +) { + ownCount := 0 + for { + select { + case d := <-filesChan: + documents.DidOpen(d, util.DefaultConfig) + tree := lsplocal.ParseAst(nil, d.TextDocument.Text) + yamllsConnector.DocumentDidOpen(tree, d) + ownCount++ + case count := <-doneReadingFilesChan: + if count != ownCount { + log.Fatal("Count mismatch: ", count, " != ", ownCount) + } + doneSendingFilesChan <- count + return + } + } +} + +func TestYamllsDiagnosticsIntegration(t *testing.T) { + diagnosticsChan := make(chan lsp.PublishDiagnosticsParams) + doneReadingFilesChan := make(chan int) + doneSendingFilesChan := make(chan int) + + dir := t.TempDir() + documents := lsplocal.NewDocumentStore() + con := jsonrpc2.NewConn(jsonrpc2.NewStream(readWriteCloseMock{diagnosticsChan})) + config := util.DefaultConfig.YamllsConfiguration + + yamllsSettings := util.DefaultYamllsSettings + // disabling yamlls schema store improves performance and + // removes all schema diagnostics that are not caused by the yaml trimming + yamllsSettings.Schemas = make(map[string]string) + yamllsSettings.YamllsSchemaStoreSettings = util.YamllsSchemaStoreSettings{ + Enable: false, + } + config.YamllsSettings = yamllsSettings + yamllsConnector := NewConnector(config, con, documents) + + if yamllsConnector.Conn == nil { + t.Fatal("Could not connect to yaml-language-server") + } + + yamllsConnector.CallInitialize(uri.File(dir)) + + didOpenChan := make(chan lsp.DidOpenTextDocumentParams) + go readTestFiles(TEST_DATA_DIR, didOpenChan, doneReadingFilesChan) + go sendTestFilesToYamlls(documents, + yamllsConnector, doneReadingFilesChan, doneSendingFilesChan, didOpenChan) + + sentCount, diagnosticsCount := 0, 0 + receivedDiagnostics := make(map[uri.URI]lsp.PublishDiagnosticsParams) + + afterCh := time.After(600 * time.Second) + for { + if sentCount != 0 && len(receivedDiagnostics) == sentCount { + fmt.Println("All files checked") + break + } + select { + case d := <-diagnosticsChan: + receivedDiagnostics[d.URI] = d + if len(d.Diagnostics) > 0 { + diagnosticsCount++ + fmt.Printf("Got diagnostic in %s diagnostics: %v \n", d.URI.Filename(), d.Diagnostics) + } + case <-afterCh: + t.Fatal("Timed out waiting for diagnostics") + case count := <-doneSendingFilesChan: + sentCount = count + } + } + + fmt.Printf("Checked %d files, found %d diagnostics\n", sentCount, diagnosticsCount) + assert.LessOrEqual(t, diagnosticsCount, 23) + assert.Equal(t, 2368, sentCount, "Count of files in test data not equal to actual count") +} diff --git a/internal/adapter/yamlls/documentSync.go b/internal/adapter/yamlls/documentSync.go index c32f3756..167d0f6c 100644 --- a/internal/adapter/yamlls/documentSync.go +++ b/internal/adapter/yamlls/documentSync.go @@ -24,11 +24,11 @@ func (yamllsConnector Connector) InitiallySyncOpenDocuments(docs []*lsplocal.Doc } func (yamllsConnector Connector) DocumentDidOpen(ast *sitter.Tree, params lsp.DidOpenTextDocumentParams) { - logger.Println("YamllsConnector DocumentDidOpen", params.TextDocument.URI) + logger.Debug("YamllsConnector DocumentDidOpen", params.TextDocument.URI) if yamllsConnector.Conn == nil { return } - params.TextDocument.Text = trimTemplateForYamllsFromAst(ast, params.TextDocument.Text) + params.TextDocument.Text = lsplocal.TrimTemplate(ast, params.TextDocument.Text) err := (*yamllsConnector.Conn).Notify(context.Background(), lsp.MethodTextDocumentDidOpen, params) if err != nil { @@ -40,16 +40,17 @@ func (yamllsConnector Connector) DocumentDidSave(doc *lsplocal.Document, params if yamllsConnector.Conn == nil { return } - params.Text = trimTemplateForYamllsFromAst(doc.Ast, doc.Content) + params.Text = lsplocal.TrimTemplate(doc.Ast, doc.Content) err := (*yamllsConnector.Conn).Notify(context.Background(), lsp.MethodTextDocumentDidSave, params) if err != nil { logger.Error("Error calling yamlls for didSave", err) } - yamllsConnector.DocumentDidChangeFullSync(doc, lsp.DidChangeTextDocumentParams{TextDocument: lsp.VersionedTextDocumentIdentifier{ - TextDocumentIdentifier: params.TextDocument, - }, + yamllsConnector.DocumentDidChangeFullSync(doc, lsp.DidChangeTextDocumentParams{ + TextDocument: lsp.VersionedTextDocumentIdentifier{ + TextDocumentIdentifier: params.TextDocument, + }, }) } @@ -57,7 +58,7 @@ func (yamllsConnector Connector) DocumentDidChange(doc *lsplocal.Document, param if yamllsConnector.Conn == nil { return } - var trimmedText = trimTemplateForYamllsFromAst(doc.Ast, doc.Content) + trimmedText := lsplocal.TrimTemplate(doc.Ast, doc.Content) logger.Debug("Sending DocumentDidChange previous", params) for i, change := range params.ContentChanges { @@ -88,7 +89,7 @@ func (yamllsConnector Connector) DocumentDidChangeFullSync(doc *lsplocal.Documen } logger.Println("Sending DocumentDidChange with full sync, current content:", doc.Content) - var trimmedText = trimTemplateForYamllsFromAst(doc.Ast.Copy(), doc.Content) + trimmedText := lsplocal.TrimTemplate(doc.Ast.Copy(), doc.Content) params.ContentChanges = []lsp.TextDocumentContentChangeEvent{ { diff --git a/internal/adapter/yamlls/trimTemplate.go b/internal/adapter/yamlls/trimTemplate.go deleted file mode 100644 index 9f5beb47..00000000 --- a/internal/adapter/yamlls/trimTemplate.go +++ /dev/null @@ -1,124 +0,0 @@ -package yamlls - -import ( - "github.com/mrjosh/helm-ls/internal/tree-sitter/gotemplate" - sitter "github.com/smacker/go-tree-sitter" -) - -func trimTemplateForYamllsFromAst(ast *sitter.Tree, text string) string { - result := []byte(text) - prettyPrintNode(ast.RootNode(), []byte(text), result) - return string(result) -} - -func prettyPrintNode(node *sitter.Node, previous []byte, result []byte) { - childCount := node.ChildCount() - - switch node.Type() { - case gotemplate.NodeTypeIfAction: - trimIfAction(node, previous, result) - case gotemplate.NodeTypeBlockAction, gotemplate.NodeTypeWithAction, gotemplate.NodeTypeRangeAction: - trimAction(childCount, node, previous, result) - case gotemplate.NodeTypeDefineAction: - earaseTemplate(node, previous, result) - case gotemplate.NodeTypeFunctionCall: - trimFunctionCall(node, previous, result) - case gotemplate.NodeTypeComment, gotemplate.NodeTypeVariableDefinition, gotemplate.NodeTypeAssignment: - earaseTemplateAndSiblings(node, previous, result) - default: - for i := 0; i < int(childCount); i++ { - prettyPrintNode(node.Child(i), previous, result) - } - } -} - -func trimAction(childCount uint32, node *sitter.Node, previous []byte, result []byte) { - for i := 0; i < int(childCount); i++ { - child := node.Child(i) - switch child.Type() { - case - gotemplate.NodeTypeAssignment, - gotemplate.NodeTypeIf, - gotemplate.NodeTypeSelectorExpression, - gotemplate.NodeTypeElse, - gotemplate.NodeTypeRange, - gotemplate.NodeTypeFunctionCall, - gotemplate.NodeTypeWith, - gotemplate.NodeTypeDefine, - gotemplate.NodeTypeOpenBraces, - gotemplate.NodeTypeOpenBracesDash, - gotemplate.NodeTypeCloseBraces, - gotemplate.NodeTypeCloseBracesDash, - gotemplate.NodeTypeEnd, - gotemplate.NodeTypeInterpretedStringLiteral, - gotemplate.NodeTypeBlock, - gotemplate.NodeTypeVariableDefinition, - gotemplate.NodeTypeVariable, - gotemplate.NodeTypeRangeVariableDefinition: - earaseTemplate(child, previous, result) - default: - prettyPrintNode(child, previous, result) - } - } -} - -func trimIfAction(node *sitter.Node, previous []byte, result []byte) { - if node.StartPoint().Row == node.EndPoint().Row { - earaseTemplate(node, previous, result) - return - } - - curser := sitter.NewTreeCursor(node) - curser.GoToFirstChild() - for curser.GoToNextSibling() { - if curser.CurrentFieldName() == gotemplate.FieldNameCondition { - earaseTemplate(curser.CurrentNode(), previous, result) - earaseTemplate(curser.CurrentNode().NextSibling(), previous, result) - continue - } - switch curser.CurrentNode().Type() { - case gotemplate.NodeTypeIf, gotemplate.NodeTypeElseIf: - earaseTemplate(curser.CurrentNode(), previous, result) - earaseTemplate(curser.CurrentNode().PrevSibling(), previous, result) - case gotemplate.NodeTypeEnd, gotemplate.NodeTypeElse: - earaseTemplateAndSiblings(curser.CurrentNode(), previous, result) - default: - prettyPrintNode(curser.CurrentNode(), previous, result) - } - } - curser.Close() -} - -func trimFunctionCall(node *sitter.Node, previous []byte, result []byte) { - functionName := node.ChildByFieldName(gotemplate.FieldNameFunction) - if functionName.Content(previous) == "include" { - parent := node.Parent() - if parent != nil && parent.Type() == gotemplate.NodeTypeChainedPipeline { - earaseTemplateAndSiblings(parent, previous, result) - } - } -} - -func earaseTemplateAndSiblings(node *sitter.Node, previous []byte, result []byte) { - earaseTemplate(node, previous, result) - prevSibling, nextSibling := node.PrevSibling(), node.NextSibling() - if prevSibling != nil { - earaseTemplate(prevSibling, previous, result) - } - if nextSibling != nil { - earaseTemplate(nextSibling, previous, result) - } -} - -func earaseTemplate(node *sitter.Node, previous []byte, result []byte) { - if node == nil { - return - } - logger.Debug("Content that is erased", node.Content(previous)) - for i := range []byte(node.Content(previous)) { - index := int(node.StartByte()) + i - if result[index] != '\n' && result[index] != '\r' { - result[index] = byte(' ') - } - } -} diff --git a/internal/adapter/yamlls/trimTemplate_test.go b/internal/adapter/yamlls/trimTemplate_test.go deleted file mode 100644 index cc86f1bc..00000000 --- a/internal/adapter/yamlls/trimTemplate_test.go +++ /dev/null @@ -1,400 +0,0 @@ -package yamlls - -import ( - "fmt" - "testing" - - lsplocal "github.com/mrjosh/helm-ls/internal/lsp" - "github.com/stretchr/testify/assert" -) - -type TrimTemplateTestData struct { - documentText string - trimmedText string -} - -var testTrimTemplateTestData = []TrimTemplateTestData{ - { - documentText: ` -{{ .Values.global. }} -yaml: test - -{{block "name"}} T1 {{end}} -`, - trimmedText: ` -{{ .Values.global. }} -yaml: test - - T1 -`, - }, - { - documentText: ` -{{ .Values.global. }} -yaml: test - -{{block "name"}} T1 {{end}} -`, - - trimmedText: ` -{{ .Values.global. }} -yaml: test - - T1 -`, - }, - { - documentText: ` -{{ if eq .Values.service.myParameter "true" }} -apiVersion: keda.sh/v1alpha1 -kind: ScaledObject -metadata: - name: prometheus-scaledobject - namespace: default -spec: - scaleTargetRef: - name: hasd - triggers: - - type: prometheus - metadata: - serverAdress: http://:9090 - metricName: http_requests_total # DEPRECATED: This parameter is deprecated as of KEDA v2.10 and will be removed in version 2.12 - threshold: '100' - query: sum(rate(http_requests_total{deployment="my-deployment"}[2m])) -# yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/keda.sh/scaledobject_v1alpha1.json -{{ end }} -`, - trimmedText: ` - -apiVersion: keda.sh/v1alpha1 -kind: ScaledObject -metadata: - name: prometheus-scaledobject - namespace: default -spec: - scaleTargetRef: - name: hasd - triggers: - - type: prometheus - metadata: - serverAdress: http://:9090 - metricName: http_requests_total # DEPRECATED: This parameter is deprecated as of KEDA v2.10 and will be removed in version 2.12 - threshold: '100' - query: sum(rate(http_requests_total{deployment="my-deployment"}[2m])) -# yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/keda.sh/scaledobject_v1alpha1.json - -`, - }, - { - documentText: ` -{{ if eq .Values.service.myParameter "true" }} -{{ if eq .Values.service.second "true" }} -apiVersion: keda.sh/v1alpha1 -{{ end }} -{{ end }} -`, - trimmedText: ` - - -apiVersion: keda.sh/v1alpha1 - - -`, - }, - { - documentText: ` -{{- if .Values.ingress.enabled }} -apiVersion: apps/v1 -kind: Ingress -{{- end }} -`, - - trimmedText: ` - -apiVersion: apps/v1 -kind: Ingress - -`, - }, - { - documentText: ` -{{- if .Values.ingress.enabled }} -apiVersion: apps/v1 -{{- else }} -apiVersion: apps/v2 -{{- end }} -`, - - trimmedText: ` - -apiVersion: apps/v1 - -apiVersion: apps/v2 - -`, - }, - { - documentText: ` -apiVersion: {{ include "common.capabilities.ingress.apiVersion" . }} -kind: Ingress -metadata: - name: {{ include "common.names.fullname" . }} - namespace: {{ .Release.Namespace | quote }} - labels: {{- include "common.labels.standard" . | nindent 4 }} - {{- if .Values.commonLabels }} - {{- include "common.tplvalues.render" ( dict "value" .Values.commonLabels "context" $ ) | nindent 4 }} - {{- end }} - app.kubernetes.io/component: grafana - annotations: - {{- if .Values.ingress.certManager }} - kubernetes.io/tls-acme: "true" - {{- end }} - {{- if .Values.ingress.annotations }} - {{- include "common.tplvalues.render" (dict "value" .Values.ingress.annotations "context" $) | nindent 4 }} - {{- end }} - {{- if .Values.commonAnnotations }} - {{- include "common.tplvalues.render" ( dict "value" .Values.commonAnnotations "context" $ ) | nindent 4 }} - {{- end }} - `, - trimmedText: ` -apiVersion: {{ include "common.capabilities.ingress.apiVersion" . }} -kind: Ingress -metadata: - name: {{ include "common.names.fullname" . }} - namespace: {{ .Release.Namespace | quote }} - labels: - - - - app.kubernetes.io/component: grafana - annotations: - - kubernetes.io/tls-acme: "true" - - - - - - - - `, - }, - {documentText: `{{- $x := "test" -}}`, trimmedText: ` `}, - {documentText: `{{ $x := "test" }}`, trimmedText: ` `}, - {documentText: `{{ /* comment */ }}`, trimmedText: ` `}, - {documentText: `{{define "name"}} T1 {{end}}`, trimmedText: ` `}, - { - documentText: ` - {{- if .Values.controller.customStartupProbe }} - startupProbe: {} - {{- else if .Values.controller.startupProbe.enabled }} - startupProbe: - httpGet: - path: /healthz - port: {{ .Values.controller.containerPorts.controller }} - {{- end }} - `, - trimmedText: ` - - startupProbe: {} - - startupProbe: - httpGet: - path: /healthz - port: {{ .Values.controller.containerPorts.controller }} - - `, - }, - { - documentText: ` - {{ if eq .Values.replicaCout 1 }} - {{- $kube := "" -}} - apiVersion: v1 - kind: Service - bka: dsa - metadata: - name: {{ include "hello-world.fullname" . }} - labels: - {{- include "hello-world.labels" . | nindent 4 }} - spec: - type: {{ .Values.service.type }} - ports: - - port: {{ .Values.service.port }} - targetPort: http - {{ end }} - `, - trimmedText: ` - - - apiVersion: v1 - kind: Service - bka: dsa - metadata: - name: {{ include "hello-world.fullname" . }} - labels: - - spec: - type: {{ .Values.service.type }} - ports: - - port: {{ .Values.service.port }} - targetPort: http - - `, - }, - { - documentText: `{{ if }}{{- end -}}`, - trimmedText: ` `, - }, - { - // todo: Handle this case better - documentText: ` -{{ if }} - -{{- end -}}`, - trimmedText: ` - }} - - `, - }, - { - documentText: `{{- $shards := $.Values.shards | int }}`, - trimmedText: ` `, - }, - { - documentText: ` -{{- if $.Values.externalAccess.enabled }} -{{- $shards := $.Values.shards | int }} -{{- $replicas := $.Values.replicaCount | int }} -{{- $totalNodes := mul $shards $replicas }} -{{- range $shard, $e := until $shards }} -{{- range $i, $_e := until $replicas }} -{{- $targetPod := printf "%s-shard%d-%d" (include "common.names.fullname" $) $shard $i }} -{{- end }} -{{- end }} -{{- end }} - `, - trimmedText: ` - - - - - - - - - - - `, - }, - { - documentText: ` -data: - pod_template.yaml: |- - {{- if .Values.worker.podTemplate }} - {{- include "common.tplvalues.render" (dict "value" .Values.worker.podTemplate "context" $) | nindent 4 }} - {{- else }} - apiVersion: v1 - kind: Pod - {{ end }} -`, - trimmedText: ` -data: - pod_template.yaml: |- - - - - apiVersion: v1 - kind: Pod - -`, - }, - { - documentText: ` -{{- /* -Copyright Some Company, Inc. -SPDX-License-Identifier: APACHE-2.0 -*/}} -`, - trimmedText: ` - - - - -`, - }, - { - documentText: ` -{{- $namespaces := list .Release.Namespace }} -{{- $namespaces = .Values.controller.workflowNamespaces }} -`, - trimmedText: ` - - -`, - }, - { - documentText: ` -{{- range $namespaces }} -{{- end }} -`, - trimmedText: ` - - -`, - }, - { - documentText: ` -list: - - value: {{ join "," .Values.initialCluster | quote }} - - name: some -`, - trimmedText: ` -list: - - value: {{ join "," .Values.initialCluster | quote }} - - name: some -`, - }, - { - documentText: ` - - name: ELASTICSEARCH_NODE_ROLES - value: {{ join "," $roles | quote }} - - name: ELASTICSEARCH_TRANSPORT_PORT_NUMBER - value: {{ .Values.containerPorts.transport | quote }} - - name: ELASTICSEARCH_HTTP_PORT_NUMBER - value: {{ .Values.containerPorts.restAPI | quote }} -`, - trimmedText: ` - - name: ELASTICSEARCH_NODE_ROLES - value: {{ join "," $roles | quote }} - - name: ELASTICSEARCH_TRANSPORT_PORT_NUMBER - value: {{ .Values.containerPorts.transport | quote }} - - name: ELASTICSEARCH_HTTP_PORT_NUMBER - value: {{ .Values.containerPorts.restAPI | quote }} -`, - }, - { - documentText: ` -apiVersion: {{ if .Values.useStatefulSet }}{{ include "common.capabilities.statefulset.apiVersion" . }}{{- else }}{{ include "common.capabilities.deployment.apiVersion" . }}{{- end }} - `, - trimmedText: ` -apiVersion: - `, - }, -} - -func TestTrimTemplate(t *testing.T) { - for _, testData := range testTrimTemplateTestData { - testTrimTemplateWithTestData(t, testData) - } -} - -func testTrimTemplateWithTestData(t *testing.T, testData TrimTemplateTestData) { - doc := &lsplocal.Document{ - Content: testData.documentText, - Ast: lsplocal.ParseAst(nil, testData.documentText), - } - - trimmed := trimTemplateForYamllsFromAst(doc.Ast, testData.documentText) - - assert.Equal(t, testData.trimmedText, trimmed, fmt.Sprintf("AST was: %v", doc.Ast.RootNode().String())) -} diff --git a/internal/lsp/document.go b/internal/lsp/document.go index ba5f94a0..0d9c1a7b 100644 --- a/internal/lsp/document.go +++ b/internal/lsp/document.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "strings" + "sync" "github.com/mrjosh/helm-ls/internal/util" sitter "github.com/smacker/go-tree-sitter" @@ -13,20 +14,21 @@ import ( // documentStore holds opened documents. type DocumentStore struct { - documents map[string]*Document + documents sync.Map } func NewDocumentStore() *DocumentStore { return &DocumentStore{ - documents: map[string]*Document{}, + documents: sync.Map{}, } } func (s *DocumentStore) GetAllDocs() []*Document { var docs []*Document - for _, doc := range s.documents { - docs = append(docs, doc) - } + s.documents.Range(func(_, v interface{}) bool { + docs = append(docs, v.(*Document)) + return true + }) return docs } @@ -42,14 +44,17 @@ func (s *DocumentStore) DidOpen(params lsp.DidOpenTextDocumentParams, helmlsConf Ast: ParseAst(nil, params.TextDocument.Text), DiagnosticsCache: NewDiagnosticsCache(helmlsConfig), } - s.documents[path] = doc + s.documents.Store(path, doc) return doc, nil } func (s *DocumentStore) Get(docuri uri.URI) (*Document, bool) { path := docuri.Filename() - d, ok := s.documents[path] - return d, ok + d, ok := s.documents.Load(path) + if !ok { + return nil, false + } + return d.(*Document), ok } // Document represents an opened file. diff --git a/internal/lsp/yaml_ast.go b/internal/lsp/yaml_ast.go new file mode 100644 index 00000000..974ce90b --- /dev/null +++ b/internal/lsp/yaml_ast.go @@ -0,0 +1,62 @@ +package lsp + +import ( + "context" + + "github.com/mrjosh/helm-ls/internal/tree-sitter/gotemplate" + sitter "github.com/smacker/go-tree-sitter" + "github.com/smacker/go-tree-sitter/yaml" +) + +func getRangeForNode(node *sitter.Node) sitter.Range { + return sitter.Range{ + StartPoint: node.StartPoint(), + EndPoint: node.EndPoint(), + StartByte: node.StartByte(), + EndByte: node.EndByte(), + } +} + +func getTextNodeRanges(gotemplateNode *sitter.Node) []sitter.Range { + textNodes := []sitter.Range{} + + for i := 0; i < int(gotemplateNode.ChildCount()); i++ { + child := gotemplateNode.Child(i) + if child.Type() == gotemplate.NodeTypeText { + textNodes = append(textNodes, getRangeForNode(child)) + } else { + textNodes = append(textNodes, getTextNodeRanges(child)...) + } + } + return textNodes +} + +func ParseYamlAst(gotemplateTree *sitter.Tree, content string) *sitter.Tree { + parser := sitter.NewParser() + parser.SetLanguage(yaml.GetLanguage()) + parser.SetIncludedRanges(getTextNodeRanges(gotemplateTree.RootNode())) + + tree, _ := parser.ParseCtx(context.Background(), nil, []byte(content)) + return tree +} + +// TrimTemplate removes all template nodes. +// This is done by keeping only the text nodes +// which is easier then removing the template nodes +// since template nodes could contain other nodes +func TrimTemplate(gotemplateTree *sitter.Tree, content string) string { + ranges := getTextNodeRanges(gotemplateTree.RootNode()) + result := make([]byte, len(content)) + for i := range result { + if content[i] == '\n' || content[i] == '\r' { + result[i] = content[i] + continue + } + result[i] = byte(' ') + } + for _, yamlRange := range ranges { + copy(result[yamlRange.StartByte:yamlRange.EndByte], + content[yamlRange.StartByte:yamlRange.EndByte]) + } + return string(result) +} diff --git a/internal/lsp/yaml_ast_test.go b/internal/lsp/yaml_ast_test.go new file mode 100644 index 00000000..fc8b8eab --- /dev/null +++ b/internal/lsp/yaml_ast_test.go @@ -0,0 +1,162 @@ +package lsp + +import ( + "testing" + + sitter "github.com/smacker/go-tree-sitter" + "github.com/stretchr/testify/assert" +) + +func Test_getTextNodeRanges(t *testing.T) { + type args struct { + gotemplateString string + } + tests := []struct { + name string + args args + want []sitter.Range + }{ + { + name: "no text nodes", + args: args{ + "{{ with .Values }}{{ .test }}{{ end }}", + }, + want: []sitter.Range{}, + }, + { + name: "simple text node", + args: args{ + "a: {{ .test }}", + }, + want: []sitter.Range{ + { + StartPoint: sitter.Point{Row: 0, Column: 0}, + EndPoint: sitter.Point{Row: 0, Column: 2}, + StartByte: 0, + EndByte: 2, + }, + }, + }, + { + name: "to simple text nodes", + args: args{ + ` +a: {{ .test }} +b: not`, + }, + want: []sitter.Range{ + {StartPoint: sitter.Point{ + Row: 0x1, Column: 0x0, + }, EndPoint: sitter.Point{ + Row: 0x1, Column: 0x2, + }, StartByte: 0x1, EndByte: 0x3}, + { + StartPoint: sitter.Point{ + Row: 0x2, + Column: 0x0, + }, + EndPoint: sitter.Point{ + Row: 0x2, + Column: 0x2, + }, + StartByte: 0x10, + EndByte: 0x12, + }, + { + StartPoint: sitter.Point{ + Row: 0x2, + Column: 0x2, + }, EndPoint: sitter.Point{ + Row: 0x2, + Column: 0x6, + }, + StartByte: 0x12, + EndByte: 0x16, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getTextNodeRanges(ParseAst(nil, tt.args.gotemplateString).RootNode()) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseYamlAst(t *testing.T) { + type args struct { + content string + } + tests := []struct { + name string + args args + wantSexpr string + }{ + { + name: "simple template node", + args: args{ + "a: {{ .test }}", + }, + wantSexpr: "(stream (document (block_node (block_mapping (block_mapping_pair key: (flow_node (plain_scalar (string_scalar))))))))", + }, + { + name: "key value", + args: args{ + "a: value", + }, + wantSexpr: "(stream (document (block_node (block_mapping (block_mapping_pair key: (flow_node (plain_scalar (string_scalar))) value: (flow_node (plain_scalar (string_scalar))))))))", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotemplateTree := ParseAst(nil, tt.args.content) + got := ParseYamlAst(gotemplateTree, tt.args.content) + assert.Equal(t, tt.wantSexpr, got.RootNode().String()) + }) + } +} + +func TestTrimTemplate(t *testing.T) { + tests := []struct { + documentText string + trimmedText string + }{ + { + documentText: ` +{{ .Values.global. }} +yaml: test + +{{block "name"}} T1 {{end}} +`, + trimmedText: ` + +yaml: test + + T1 +`, + }, + { + documentText: ` +{{ .Values.global. }} +yaml: test + +{{block "name"}} T1 {{end}} +`, + + trimmedText: ` + +yaml: test + + T1 +`, + }, + } + for _, tt := range tests { + t.Run(tt.documentText, func(t *testing.T) { + gotemplateTree := ParseAst(nil, tt.documentText) + got := TrimTemplate(gotemplateTree, tt.documentText) + assert.Equal(t, tt.trimmedText, got) + }) + } +} diff --git a/internal/util/config.go b/internal/util/config.go index 55972196..b71bd94a 100644 --- a/internal/util/config.go +++ b/internal/util/config.go @@ -40,14 +40,22 @@ var DefaultConfig = HelmlsConfiguration{ }, } +type YamllsSchemaStoreSettings struct { + Enable bool `json:"enable"` +} + type YamllsSettings struct { - Schemas map[string]string `json:"schemas"` - Completion bool `json:"completion"` - Hover bool `json:"hover"` + Schemas map[string]string `json:"schemas"` + Completion bool `json:"completion"` + Hover bool `json:"hover"` + YamllsSchemaStoreSettings YamllsSchemaStoreSettings `json:"schemaStore"` } var DefaultYamllsSettings = YamllsSettings{ Schemas: map[string]string{"kubernetes": "templates/**"}, Completion: true, Hover: true, + YamllsSchemaStoreSettings: YamllsSchemaStoreSettings{ + Enable: true, + }, } diff --git a/testdata/charts b/testdata/charts new file mode 160000 index 00000000..3b84d7dc --- /dev/null +++ b/testdata/charts @@ -0,0 +1 @@ +Subproject commit 3b84d7dc269f76064e18ba4e21937cc78e77deda