From 76203129927fb251a6f02073bf3e68364f6cd387 Mon Sep 17 00:00:00 2001 From: Yingchi Long Date: Mon, 22 Jan 2024 22:31:41 +0800 Subject: [PATCH] nixd-next: add diagnostic quick fix (code action) (#310) --- nixd-next/tools/nixd/src/Controller.cpp | 107 ++++++++++++++++-- .../tools/nixd/test/code-action-quick-fix.md | 101 +++++++++++++++++ nixd-next/tools/nixd/test/diagnostic.md | 2 +- nixd-next/tools/nixd/test/initialize.md | 6 + 4 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 nixd-next/tools/nixd/test/code-action-quick-fix.md diff --git a/nixd-next/tools/nixd/src/Controller.cpp b/nixd-next/tools/nixd/src/Controller.cpp index 4c0bd3e58..98001b74a 100644 --- a/nixd-next/tools/nixd/src/Controller.cpp +++ b/nixd-next/tools/nixd/src/Controller.cpp @@ -39,29 +39,67 @@ int getLSPSeverity(nixf::Diagnostic::DiagnosticKind Kind) { assert(false && "Invalid severity"); } +/// Holds analyzed information about a document. +/// +/// TU stands for "Translation Unit". +class NixTU { + std::vector Diagnostics; + std::shared_ptr AST; + +public: + NixTU() = default; + NixTU(std::vector Diagnostics, + std::shared_ptr AST) + : Diagnostics(std::move(Diagnostics)), AST(std::move(AST)) {} + + [[nodiscard]] const std::vector &diagnostics() const { + return Diagnostics; + } + + [[nodiscard]] const std::shared_ptr &ast() const { return AST; } +}; + class Controller : public LSPServer { DraftStore Store; llvm::unique_function PublishDiagnostic; + llvm::StringMap TUs; + /// Action right after a document is added (including updates). void actOnDocumentAdd(PathRef File, std::optional Version) { auto Draft = Store.getDraft(File); assert(Draft && "Added document is not in the store?"); std::vector Diagnostics; - nixf::parse(*Draft->Contents, Diagnostics); - + std::shared_ptr AST = + nixf::parse(*Draft->Contents, Diagnostics); std::vector LSPDiags; LSPDiags.reserve(Diagnostics.size()); for (const nixf::Diagnostic &D : Diagnostics) { + // Format the message. + std::string Message = D.format(); + + // Add fix information. + if (!D.fixes().empty()) { + Message += " ("; + if (D.fixes().size() == 1) { + Message += "fix available"; + } else { + Message += std::to_string(D.fixes().size()); + Message += " fixes available"; + } + Message += ")"; + } + LSPDiags.emplace_back(Diagnostic{ .range = toLSPRange(D.range()), .severity = getLSPSeverity(D.kind()), .code = D.sname(), .source = "nixf", - .message = D.format(), + .message = Message, }); + for (const nixf::Note &N : D.notes()) { LSPDiags.emplace_back(Diagnostic{ .range = toLSPRange(N.range()), @@ -76,6 +114,7 @@ class Controller : public LSPServer { .diagnostics = std::move(LSPDiags), .version = Version, }); + TUs[File] = NixTU{std::move(Diagnostics), std::move(AST)}; } void removeDocument(PathRef File) { Store.removeDraft(File); } @@ -84,14 +123,19 @@ class Controller : public LSPServer { [[maybe_unused]] const InitializeParams &Params, Callback Reply) { Object ServerCaps{ - { - {"textDocumentSync", - llvm::json::Object{ - {"openClose", true}, - {"change", (int)TextDocumentSyncKind::Incremental}, - {"save", true}, - }}, - }, + {{"textDocumentSync", + llvm::json::Object{ + {"openClose", true}, + {"change", (int)TextDocumentSyncKind::Incremental}, + {"save", true}, + }}, + { + "codeActionProvider", + Object{ + {"codeActionKinds", Array{CodeAction::QUICKFIX_KIND}}, + {"resolveProvider", false}, + }, + }}, }; Object Result{{ @@ -146,6 +190,43 @@ class Controller : public LSPServer { removeDocument(File); } + void onCodeAction(const CodeActionParams &Params, + Callback> Reply) { + PathRef File = Params.textDocument.uri.file(); + Range Range = Params.range; + const std::vector &Diagnostics = TUs[File].diagnostics(); + std::vector Actions; + Actions.reserve(Diagnostics.size()); + for (const nixf::Diagnostic &D : Diagnostics) { + // Skip diagnostics that are not in the range. + if (!Range.contains(toLSPRange(D.range()))) + continue; + + // Add fixes. + for (const nixf::Fix &F : D.fixes()) { + std::vector Edits; + Edits.reserve(F.edits().size()); + for (const nixf::TextEdit &TE : F.edits()) { + Edits.emplace_back(TextEdit{ + .range = toLSPRange(TE.oldRange()), + .newText = std::string(TE.newText()), + }); + } + using Changes = std::map>; + std::string FileURI = URIForFile::canonicalize(File, File).uri(); + WorkspaceEdit WE{.changes = Changes{ + {std::move(FileURI), std::move(Edits)}, + }}; + Actions.emplace_back(CodeAction{ + .title = F.message(), + .kind = std::string(CodeAction::QUICKFIX_KIND), + .edit = std::move(WE), + }); + } + } + Reply(std::move(Actions)); + } + public: Controller(std::unique_ptr In, std::unique_ptr Out) : LSPServer(std::move(In), std::move(Out)) { @@ -162,6 +243,10 @@ class Controller : public LSPServer { Registry.addNotification("textDocument/didClose", this, &Controller::onDocumentDidClose); + + // Language Features + Registry.addMethod("textDocument/codeAction", this, + &Controller::onCodeAction); } }; diff --git a/nixd-next/tools/nixd/test/code-action-quick-fix.md b/nixd-next/tools/nixd/test/code-action-quick-fix.md new file mode 100644 index 000000000..78e13b854 --- /dev/null +++ b/nixd-next/tools/nixd/test/code-action-quick-fix.md @@ -0,0 +1,101 @@ + +# RUN: nixd-next --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```json +{ + "jsonrpc":"2.0", + "method":"textDocument/didOpen", + "params":{ + "textDocument":{ + "uri":"file:///basic.nix", + "languageId":"nix", + "version":1, + "text":"/*" + } + } +} +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///basic.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":0 + }, + "end":{ + "line":0, + "character":2 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///basic.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "*/", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 2, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 2, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: "kind": "quickfix", +CHECK-NEXT: "title": "insert */" +CHECK-NEXT: } +CHECK-NEXT: ] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd-next/tools/nixd/test/diagnostic.md b/nixd-next/tools/nixd/test/diagnostic.md index 545e9f94c..f699b48cf 100644 --- a/nixd-next/tools/nixd/test/diagnostic.md +++ b/nixd-next/tools/nixd/test/diagnostic.md @@ -41,7 +41,7 @@ CHECK-NEXT: "params": { CHECK-NEXT: "diagnostics": [ CHECK-NEXT: { CHECK-NEXT: "code": "lex-unterminated-bcomment", -CHECK-NEXT: "message": "unterminated /* comment", +CHECK-NEXT: "message": "unterminated /* comment (fix available)", CHECK-NEXT: "range": { CHECK-NEXT: "end": { CHECK-NEXT: "character": 2, diff --git a/nixd-next/tools/nixd/test/initialize.md b/nixd-next/tools/nixd/test/initialize.md index e748bdf40..94e23cfc3 100644 --- a/nixd-next/tools/nixd/test/initialize.md +++ b/nixd-next/tools/nixd/test/initialize.md @@ -26,6 +26,12 @@ CHECK-NEXT: "id": 0, CHECK-NEXT: "jsonrpc": "2.0", CHECK-NEXT: "result": { CHECK-NEXT: "capabilities": { +CHECK-NEXT: "codeActionProvider": { +CHECK-NEXT: "codeActionKinds": [ +CHECK-NEXT: "quickfix" +CHECK-NEXT: ], +CHECK-NEXT: "resolveProvider": false +CHECK-NEXT: }, CHECK-NEXT: "textDocumentSync": { CHECK-NEXT: "change": 2, CHECK-NEXT: "openClose": true,