diff --git a/internal/context/context.go b/internal/context/context.go index 07c20d8c0..8e08f8644 100644 --- a/internal/context/context.go +++ b/internal/context/context.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-ls/internal/filesystem" "github.com/hashicorp/terraform-ls/internal/terraform/rootmodule" "github.com/hashicorp/terraform-ls/internal/watcher" + "github.com/hashicorp/terraform-ls/langserver/diagnostics" "github.com/sourcegraph/go-lsp" ) @@ -33,6 +34,7 @@ var ( ctxRootModuleWalker = &contextKey{"root module walker"} ctxRootModuleLoader = &contextKey{"root module loader"} ctxRootDir = &contextKey{"root directory"} + ctxDiags = &contextKey{"diagnostics"} ) func missingContextErr(ctxKey *contextKey) *MissingContextErr { @@ -211,3 +213,16 @@ func RootModuleLoader(ctx context.Context) (rootmodule.RootModuleLoader, error) } return w, nil } + +func WithDiagnostics(ctx context.Context, diags *diagnostics.Notifier) context.Context { + return context.WithValue(ctx, ctxDiags, diags) +} + +func Diagnostics(ctx context.Context) (*diagnostics.Notifier, error) { + diags, ok := ctx.Value(ctxDiags).(*diagnostics.Notifier) + if !ok { + return nil, missingContextErr(ctxDiags) + } + + return diags, nil +} diff --git a/internal/lsp/diagnostics.go b/internal/lsp/diagnostics.go new file mode 100644 index 000000000..7eff360d9 --- /dev/null +++ b/internal/lsp/diagnostics.go @@ -0,0 +1,19 @@ +package lsp + +import ( + "github.com/hashicorp/hcl/v2" + lsp "github.com/sourcegraph/go-lsp" +) + +func HCLSeverityToLSP(severity hcl.DiagnosticSeverity) lsp.DiagnosticSeverity { + var sev lsp.DiagnosticSeverity + switch severity { + case hcl.DiagError: + sev = lsp.Error + case hcl.DiagWarning: + sev = lsp.Warning + case hcl.DiagInvalid: + panic("invalid diagnostic") + } + return sev +} diff --git a/internal/lsp/file_change.go b/internal/lsp/file_change.go index 34e60a0ac..78468332d 100644 --- a/internal/lsp/file_change.go +++ b/internal/lsp/file_change.go @@ -54,37 +54,6 @@ func TextEdits(changes filesystem.DocumentChanges) []lsp.TextEdit { return edits } -func HCLRangeToLSP(hclRng hcl.Range) lsp.Range { - return lsp.Range{ - Start: lsp.Position{ - Character: hclRng.Start.Column - 1, - Line: hclRng.Start.Line - 1, - }, - End: lsp.Position{ - Character: hclRng.End.Column - 1, - Line: hclRng.End.Line - 1, - }, - } -} - -func lspRangeToHCL(lspRng lsp.Range, f File) (*hcl.Range, error) { - startPos, err := lspPositionToHCL(f.Lines(), lspRng.Start) - if err != nil { - return nil, err - } - - endPos, err := lspPositionToHCL(f.Lines(), lspRng.End) - if err != nil { - return nil, err - } - - return &hcl.Range{ - Filename: f.Filename(), - Start: startPos, - End: endPos, - }, nil -} - func (fc *contentChange) Text() string { return fc.text } diff --git a/internal/lsp/range.go b/internal/lsp/range.go new file mode 100644 index 000000000..0615f124e --- /dev/null +++ b/internal/lsp/range.go @@ -0,0 +1,37 @@ +package lsp + +import ( + "github.com/hashicorp/hcl/v2" + lsp "github.com/sourcegraph/go-lsp" +) + +func HCLRangeToLSP(hclRng hcl.Range) lsp.Range { + return lsp.Range{ + Start: lsp.Position{ + Character: hclRng.Start.Column - 1, + Line: hclRng.Start.Line - 1, + }, + End: lsp.Position{ + Character: hclRng.End.Column - 1, + Line: hclRng.End.Line - 1, + }, + } +} + +func lspRangeToHCL(lspRng lsp.Range, f File) (*hcl.Range, error) { + startPos, err := lspPositionToHCL(f.Lines(), lspRng.Start) + if err != nil { + return nil, err + } + + endPos, err := lspPositionToHCL(f.Lines(), lspRng.End) + if err != nil { + return nil, err + } + + return &hcl.Range{ + Filename: f.Filename(), + Start: startPos, + End: endPos, + }, nil +} diff --git a/internal/lsp/file_change_test.go b/internal/lsp/range_test.go similarity index 100% rename from internal/lsp/file_change_test.go rename to internal/lsp/range_test.go diff --git a/langserver/diagnostics/diagnostics.go b/langserver/diagnostics/diagnostics.go new file mode 100644 index 000000000..67c0113c3 --- /dev/null +++ b/langserver/diagnostics/diagnostics.go @@ -0,0 +1,82 @@ +package diagnostics + +import ( + "context" + "sync" + + "github.com/creachadair/jrpc2" + "github.com/hashicorp/hcl/v2/hclparse" + ilsp "github.com/hashicorp/terraform-ls/internal/lsp" + "github.com/sourcegraph/go-lsp" +) + +// documentContext encapsulates the data needed to diagnose the file and push diagnostics to the client +type documentContext struct { + ctx context.Context + uri lsp.DocumentURI + text []byte +} + +// Notifier is a type responsible for processing documents and pushing diagnostics to the client +type Notifier struct { + sessCtx context.Context + hclDocs chan documentContext + closeHclDocsOnce sync.Once +} + +func NewNotifier(sessCtx context.Context) *Notifier { + hclDocs := make(chan documentContext, 10) + go hclDiags(hclDocs) + return &Notifier{hclDocs: hclDocs, sessCtx: sessCtx} +} + +// DiagnoseHCL enqueues the document for HCL parsing. Documents will be parsed and notifications delivered in order that +// they are enqueued. Files that are actively changing should be enqueued in order, so that diagnostics remain insync with +// the current content of the file. This is the responsibility of the caller. +func (n *Notifier) DiagnoseHCL(ctx context.Context, uri lsp.DocumentURI, text []byte) { + select { + case <-n.sessCtx.Done(): + n.closeHclDocsOnce.Do(func() { + close(n.hclDocs) + }) + return + default: + } + n.hclDocs <- documentContext{ctx: ctx, uri: uri, text: text} +} + +func hclParse(doc documentContext) []lsp.Diagnostic { + diags := []lsp.Diagnostic{} + + _, hclDiags := hclparse.NewParser().ParseHCL(doc.text, string(doc.uri)) + for _, hclDiag := range hclDiags { + // only process diagnostics with an attributable spot in the code + if hclDiag.Subject != nil { + msg := hclDiag.Summary + if hclDiag.Detail != "" { + msg += ": " + hclDiag.Detail + } + diags = append(diags, lsp.Diagnostic{ + Range: ilsp.HCLRangeToLSP(*hclDiag.Subject), + Severity: ilsp.HCLSeverityToLSP(hclDiag.Severity), + Source: "HCL", + Message: msg, + }) + } + } + return diags +} + +func hclDiags(docs <-chan documentContext) { + for doc := range docs { + // always push diagnostics, even if the slice is empty, this is how previous diagnostics are cleared + // any push error will result in a panic since this is executing in its own thread and we can't bubble + // an error to a jrpc response + if err := jrpc2.PushNotify(doc.ctx, "textDocument/publishDiagnostics", lsp.PublishDiagnosticsParams{ + URI: doc.uri, + Diagnostics: hclParse(doc), + }); err != nil { + panic(err) + } + } +} diff --git a/langserver/diagnostics/diagnostics_test.go b/langserver/diagnostics/diagnostics_test.go new file mode 100644 index 000000000..d377cd14a --- /dev/null +++ b/langserver/diagnostics/diagnostics_test.go @@ -0,0 +1,50 @@ +package diagnostics + +import ( + "context" + "testing" +) + +func TestDiagnoseHCL_Closes(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + n := NewNotifier(ctx) + cancel() + n.DiagnoseHCL(context.Background(), "", []byte{}) + if _, open := <-n.hclDocs; open { + t.Fatal("documents channel should be closed") + } +} + +func TestDiagnoseHCL_DoesNotSendAfterClose(t *testing.T) { + defer func() { + if err := recover(); err != nil { + t.Fatal(err) + } + }() + ctx, cancel := context.WithCancel(context.Background()) + n := NewNotifier(ctx) + cancel() + n.DiagnoseHCL(context.Background(), "", []byte{}) + n.DiagnoseHCL(context.Background(), "", []byte{}) +} + +func TestHCLParse_ReturnsEmptySliceWhenValid(t *testing.T) { + diags := hclParse(documentContext{ctx: context.Background(), uri: "test", text: hcl(`provider "test" {}`)}) + if diags == nil { + t.Fatal("slice needs to be initialized") + } + if len(diags) > 0 { + t.Fatalf("valid hcl should return an empty slice: %v", diags) + } +} + +func TestHCLParse_ReturnsDiagsWhenInvalid(t *testing.T) { + diags := hclParse(documentContext{ctx: context.Background(), uri: "test", text: hcl(`provider test" {}`)}) + if len(diags) == 0 { + t.Fatal("invalid hcl should return diags") + } +} + +func hcl(text string) []byte { + return append([]byte(text), '\n') +} diff --git a/langserver/handlers/did_change.go b/langserver/handlers/did_change.go index daf10466e..6309e885e 100644 --- a/langserver/handlers/did_change.go +++ b/langserver/handlers/did_change.go @@ -49,6 +49,17 @@ func TextDocumentDidChange(ctx context.Context, params DidChangeTextDocumentPara return err } + // now that the file content has been sync'd run diagnostics + diags, err := lsctx.Diagnostics(ctx) + if err != nil { + return err + } + text, err := f.Text() + if err != nil { + return err + } + diags.DiagnoseHCL(ctx, params.TextDocument.URI, text) + cf, err := lsctx.RootModuleCandidateFinder(ctx) if err != nil { return err diff --git a/langserver/handlers/did_open.go b/langserver/handlers/did_open.go index d7fd4029a..12ce06410 100644 --- a/langserver/handlers/did_open.go +++ b/langserver/handlers/did_open.go @@ -14,6 +14,13 @@ import ( ) func (lh *logHandler) TextDocumentDidOpen(ctx context.Context, params lsp.DidOpenTextDocumentParams) error { + + diags, err := lsctx.Diagnostics(ctx) + if err != nil { + return err + } + diags.DiagnoseHCL(ctx, params.TextDocument.URI, []byte(params.TextDocument.Text)) + fs, err := lsctx.DocumentStorage(ctx) if err != nil { return err diff --git a/langserver/handlers/service.go b/langserver/handlers/service.go index f5c7e2130..bc8dfc1d1 100644 --- a/langserver/handlers/service.go +++ b/langserver/handlers/service.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform-ls/internal/filesystem" "github.com/hashicorp/terraform-ls/internal/terraform/rootmodule" "github.com/hashicorp/terraform-ls/internal/watcher" + "github.com/hashicorp/terraform-ls/langserver/diagnostics" "github.com/hashicorp/terraform-ls/langserver/session" "github.com/sourcegraph/go-lsp" ) @@ -141,6 +142,7 @@ func (svc *service) Assigner() (jrpc2.Assigner, error) { } rmLoader := rootmodule.NewRootModuleLoader(svc.sessCtx, svc.modMgr) + diags := diagnostics.NewNotifier(svc.sessCtx) rootDir := "" @@ -173,6 +175,7 @@ func (svc *service) Assigner() (jrpc2.Assigner, error) { if err != nil { return nil, err } + ctx = lsctx.WithDiagnostics(ctx, diags) ctx = lsctx.WithDocumentStorage(ctx, fs) ctx = lsctx.WithRootModuleCandidateFinder(ctx, svc.modMgr) return handle(ctx, req, TextDocumentDidChange) @@ -182,6 +185,7 @@ func (svc *service) Assigner() (jrpc2.Assigner, error) { if err != nil { return nil, err } + ctx = lsctx.WithDiagnostics(ctx, diags) ctx = lsctx.WithDocumentStorage(ctx, fs) ctx = lsctx.WithRootDirectory(ctx, &rootDir) ctx = lsctx.WithRootModuleCandidateFinder(ctx, svc.modMgr)