From 4631bff0c5dd4ce9299d5461117e7a17a8b42230 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 4 Jan 2026 13:52:53 +0900 Subject: [PATCH 1/6] feat(code-action): add refactor.rewrite code actions for attribute names - Implement quote/unquote attribute name refactoring code actions - Add isValidNixIdentifier helper to validate unquoted names - Register "refactor.rewrite" kind in code action provider and protocol - Update tests to reflect new code action kinds --- nixd/lib/Controller/CodeAction.cpp | 111 +++++++++++++++++++- nixd/lib/Controller/LifeTime.cpp | 4 +- nixd/lspserver/include/lspserver/Protocol.h | 1 + nixd/lspserver/src/Protocol.cpp | 1 + 4 files changed, 114 insertions(+), 3 deletions(-) diff --git a/nixd/lib/Controller/CodeAction.cpp b/nixd/lib/Controller/CodeAction.cpp index ef4baa75c..a2286281d 100644 --- a/nixd/lib/Controller/CodeAction.cpp +++ b/nixd/lib/Controller/CodeAction.cpp @@ -8,13 +8,110 @@ #include "nixd/Controller/Controller.h" +#include +#include +#include + #include +#include + namespace nixd { using namespace llvm::json; using namespace lspserver; +namespace { + +/// \brief Create a CodeAction with a single text edit. +CodeAction createSingleEditAction(const std::string &Title, + llvm::StringLiteral Kind, + const std::string &FileURI, + const lspserver::Range &EditRange, + std::string NewText) { + std::vector Edits; + Edits.emplace_back( + TextEdit{.range = EditRange, .newText = std::move(NewText)}); + using Changes = std::map>; + WorkspaceEdit WE{.changes = Changes{{FileURI, std::move(Edits)}}}; + return CodeAction{ + .title = Title, + .kind = std::string(Kind), + .edit = std::move(WE), + }; +} + +/// \brief Check if a string is a valid Nix identifier that can be unquoted. +/// Valid identifiers: start with letter or underscore, contain letters, +/// digits, underscores, hyphens, or single quotes. Must not be a keyword. +bool isValidNixIdentifier(const std::string &S) { + if (S.empty()) + return false; + + // Check first character: must be letter or underscore + char First = S[0]; + if (!((First >= 'a' && First <= 'z') || (First >= 'A' && First <= 'Z') || + First == '_')) + return false; + + // Check remaining characters + for (size_t I = 1; I < S.size(); ++I) { + char C = S[I]; + if (!((C >= 'a' && C <= 'z') || (C >= 'A' && C <= 'Z') || + (C >= '0' && C <= '9') || C == '_' || C == '-' || C == '\'')) + return false; + } + + // Check against Nix keywords and reserved literals + static const std::set Keywords = { + "if", "then", "else", "assert", "with", "let", "in", + "rec", "inherit", "or", "true", "false", "null"}; + return Keywords.find(S) == Keywords.end(); +} + +/// \brief Add refactoring code actions for attribute names (quote/unquote). +void addAttrNameActions(const nixf::Node &N, const nixf::ParentMapAnalysis &PM, + const std::string &FileURI, llvm::StringRef Src, + std::vector &Actions) { + // Find if we're inside an AttrName + const nixf::Node *AttrNameNode = PM.upTo(N, nixf::Node::NK_AttrName); + if (!AttrNameNode) + return; + + // Only offer quote/unquote for attribute sets, not for let bindings. + // ExprLet internally contains an ExprAttrs for bindings, so we need to + // check if the parent ExprAttrs is directly inside an ExprLet. + const nixf::Node *ExprAttrsNode = + PM.upTo(*AttrNameNode, nixf::Node::NK_ExprAttrs); + if (!ExprAttrsNode) + return; + + // Check if this ExprAttrs is the binds part of an ExprLet + const nixf::Node *AttrsParent = PM.query(*ExprAttrsNode); + if (AttrsParent && AttrsParent->kind() == nixf::Node::NK_ExprLet) + return; + + const auto &AN = static_cast(*AttrNameNode); + + if (AN.kind() == nixf::AttrName::ANK_ID) { + // Offer to quote: foo -> "foo" + const std::string &Name = AN.id()->name(); + Actions.emplace_back(createSingleEditAction( + "Quote attribute name", CodeAction::REFACTOR_REWRITE_KIND, FileURI, + toLSPRange(Src, AN.range()), "\"" + Name + "\"")); + } else if (AN.kind() == nixf::AttrName::ANK_String && AN.isStatic()) { + // Offer to unquote if valid identifier: "foo" -> foo + const std::string &Name = AN.staticName(); + if (isValidNixIdentifier(Name)) { + Actions.emplace_back(createSingleEditAction( + "Unquote attribute name", CodeAction::REFACTOR_REWRITE_KIND, FileURI, + toLSPRange(Src, AN.range()), Name)); + } + } +} + +} // namespace + void Controller::onCodeAction(const lspserver::CodeActionParams &Params, Callback> Reply) { using CheckTy = std::vector; @@ -27,6 +124,8 @@ void Controller::onCodeAction(const lspserver::CodeActionParams &Params, const auto &Diagnostics = TU->diagnostics(); auto Actions = std::vector(); Actions.reserve(Diagnostics.size()); + std::string FileURI = URIForFile::canonicalize(File, File).uri(); + for (const nixf::Diagnostic &D : Diagnostics) { auto DRange = toLSPRange(TU->src(), D.range()); if (!Range.overlap(DRange)) @@ -43,9 +142,8 @@ void Controller::onCodeAction(const lspserver::CodeActionParams &Params, }); } using Changes = std::map>; - std::string FileURI = URIForFile::canonicalize(File, File).uri(); WorkspaceEdit WE{.changes = Changes{ - {std::move(FileURI), std::move(Edits)}, + {FileURI, std::move(Edits)}, }}; Actions.emplace_back(CodeAction{ .title = F.message(), @@ -54,6 +152,15 @@ void Controller::onCodeAction(const lspserver::CodeActionParams &Params, }); } } + + // Add refactoring code actions based on cursor position + if (TU->ast() && TU->parentMap()) { + nixf::PositionRange NixfRange = toNixfRange(Range); + if (const nixf::Node *N = TU->ast()->descend(NixfRange)) { + addAttrNameActions(*N, *TU->parentMap(), FileURI, TU->src(), Actions); + } + } + return Actions; }()); }; diff --git a/nixd/lib/Controller/LifeTime.cpp b/nixd/lib/Controller/LifeTime.cpp index 3f0bd8454..9700e3fb3 100644 --- a/nixd/lib/Controller/LifeTime.cpp +++ b/nixd/lib/Controller/LifeTime.cpp @@ -98,7 +98,9 @@ void Controller:: { "codeActionProvider", Object{ - {"codeActionKinds", Array{CodeAction::QUICKFIX_KIND}}, + {"codeActionKinds", + Array{CodeAction::QUICKFIX_KIND, + CodeAction::REFACTOR_REWRITE_KIND}}, {"resolveProvider", false}, }, }, diff --git a/nixd/lspserver/include/lspserver/Protocol.h b/nixd/lspserver/include/lspserver/Protocol.h index dffe1fcb7..8e6679e6a 100644 --- a/nixd/lspserver/include/lspserver/Protocol.h +++ b/nixd/lspserver/include/lspserver/Protocol.h @@ -1053,6 +1053,7 @@ struct CodeAction { std::optional kind; const static llvm::StringLiteral QUICKFIX_KIND; const static llvm::StringLiteral REFACTOR_KIND; + const static llvm::StringLiteral REFACTOR_REWRITE_KIND; const static llvm::StringLiteral INFO_KIND; /// The diagnostics that this code action resolves. diff --git a/nixd/lspserver/src/Protocol.cpp b/nixd/lspserver/src/Protocol.cpp index 127ae6e37..95ceb5bc7 100644 --- a/nixd/lspserver/src/Protocol.cpp +++ b/nixd/lspserver/src/Protocol.cpp @@ -766,6 +766,7 @@ llvm::json::Value toJSON(const Command &C) { const llvm::StringLiteral CodeAction::QUICKFIX_KIND = "quickfix"; const llvm::StringLiteral CodeAction::REFACTOR_KIND = "refactor"; +const llvm::StringLiteral CodeAction::REFACTOR_REWRITE_KIND = "refactor.rewrite"; const llvm::StringLiteral CodeAction::INFO_KIND = "info"; llvm::json::Value toJSON(const CodeAction &CA) { From 0edd95b3af3f46690707f96187eaf68ccb66c54d Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 4 Jan 2026 13:53:49 +0900 Subject: [PATCH 2/6] test: add comprehensive tests for attribute quote/unquote code actions - Add tests for quoting and unquoting attribute names in various cases - Cover keywords, let bindings, empty strings, hyphens, invalid chars, digits, keywords, quote chars, and underscores - Update initialize.md and semantic-tokens/initialize.md to reference new code action tests --- .../code-action/keywords-comprehensive.md | 69 ++++++++++++++ .../nixd/test/code-action/let-binding.md | 69 ++++++++++++++ nixd/tools/nixd/test/code-action/quick-fix.md | 1 - .../tools/nixd/test/code-action/quote-attr.md | 89 ++++++++++++++++++ .../nixd/test/code-action/unquote-attr.md | 89 ++++++++++++++++++ .../nixd/test/code-action/unquote-empty.md | 69 ++++++++++++++ .../nixd/test/code-action/unquote-hyphen.md | 91 +++++++++++++++++++ .../test/code-action/unquote-invalid-chars.md | 69 ++++++++++++++ .../nixd/test/code-action/unquote-invalid.md | 69 ++++++++++++++ .../nixd/test/code-action/unquote-keyword.md | 69 ++++++++++++++ .../test/code-action/unquote-quote-char.md | 91 +++++++++++++++++++ .../test/code-action/unquote-underscore.md | 91 +++++++++++++++++++ 12 files changed, 865 insertions(+), 1 deletion(-) create mode 100644 nixd/tools/nixd/test/code-action/keywords-comprehensive.md create mode 100644 nixd/tools/nixd/test/code-action/let-binding.md create mode 100644 nixd/tools/nixd/test/code-action/quote-attr.md create mode 100644 nixd/tools/nixd/test/code-action/unquote-attr.md create mode 100644 nixd/tools/nixd/test/code-action/unquote-empty.md create mode 100644 nixd/tools/nixd/test/code-action/unquote-hyphen.md create mode 100644 nixd/tools/nixd/test/code-action/unquote-invalid-chars.md create mode 100644 nixd/tools/nixd/test/code-action/unquote-invalid.md create mode 100644 nixd/tools/nixd/test/code-action/unquote-keyword.md create mode 100644 nixd/tools/nixd/test/code-action/unquote-quote-char.md create mode 100644 nixd/tools/nixd/test/code-action/unquote-underscore.md diff --git a/nixd/tools/nixd/test/code-action/keywords-comprehensive.md b/nixd/tools/nixd/test/code-action/keywords-comprehensive.md new file mode 100644 index 000000000..9ece96002 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/keywords-comprehensive.md @@ -0,0 +1,69 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that Nix keywords cannot be unquoted (testing "if" - one of the reserved keywords) + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///keywords-comprehensive.nix +{ "if" = 1; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///keywords-comprehensive.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":6 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Keywords should return empty result (no unquote action available) + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/let-binding.md b/nixd/tools/nixd/test/code-action/let-binding.md new file mode 100644 index 000000000..81b474b7a --- /dev/null +++ b/nixd/tools/nixd/test/code-action/let-binding.md @@ -0,0 +1,69 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that let bindings do NOT get quote/unquote actions (only attr sets do) + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///let-binding.nix +let foo = 1; in foo +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///let-binding.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":4 + }, + "end":{ + "line":0, + "character":7 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Let bindings should return empty result (no quote action available) + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/quick-fix.md b/nixd/tools/nixd/test/code-action/quick-fix.md index 74d26205b..8d2a7ec04 100644 --- a/nixd/tools/nixd/test/code-action/quick-fix.md +++ b/nixd/tools/nixd/test/code-action/quick-fix.md @@ -1,4 +1,3 @@ - # RUN: nixd --lit-test < %s | FileCheck %s <-- initialize(0) diff --git a/nixd/tools/nixd/test/code-action/quote-attr.md b/nixd/tools/nixd/test/code-action/quote-attr.md new file mode 100644 index 000000000..9467b454f --- /dev/null +++ b/nixd/tools/nixd/test/code-action/quote-attr.md @@ -0,0 +1,89 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///quote-attr.nix +{ foo = 1; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///quote-attr.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":5 + } + }, + "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:///quote-attr.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "\"foo\"", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 5, +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": "refactor.rewrite", +CHECK-NEXT: "title": "Quote attribute name" +CHECK-NEXT: } +CHECK-NEXT: ] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unquote-attr.md b/nixd/tools/nixd/test/code-action/unquote-attr.md new file mode 100644 index 000000000..8d696db74 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unquote-attr.md @@ -0,0 +1,89 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unquote-attr.nix +{ "bar" = 1; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unquote-attr.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":7 + } + }, + "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:///unquote-attr.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "bar", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 7, +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": "refactor.rewrite", +CHECK-NEXT: "title": "Unquote attribute name" +CHECK-NEXT: } +CHECK-NEXT: ] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unquote-empty.md b/nixd/tools/nixd/test/code-action/unquote-empty.md new file mode 100644 index 000000000..9137a1c47 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unquote-empty.md @@ -0,0 +1,69 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that empty string cannot be unquoted (e.g., "" should NOT offer unquote action) + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unquote-empty.nix +{ "" = 1; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unquote-empty.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":4 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Empty string should return empty result (no unquote action available) + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unquote-hyphen.md b/nixd/tools/nixd/test/code-action/unquote-hyphen.md new file mode 100644 index 000000000..1d4683ef2 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unquote-hyphen.md @@ -0,0 +1,91 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that identifiers with hyphens can be unquoted (e.g., "foo-bar" -> foo-bar) + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unquote-hyphen.nix +{ "foo-bar" = 1; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unquote-hyphen.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":11 + } + }, + "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:///unquote-hyphen.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "foo-bar", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 11, +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": "refactor.rewrite", +CHECK-NEXT: "title": "Unquote attribute name" +CHECK-NEXT: } +CHECK-NEXT: ] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unquote-invalid-chars.md b/nixd/tools/nixd/test/code-action/unquote-invalid-chars.md new file mode 100644 index 000000000..74cfe7e54 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unquote-invalid-chars.md @@ -0,0 +1,69 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that identifiers with invalid characters cannot be unquoted (dot is not a valid identifier char) + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unquote-invalid-chars.nix +{ "foo.bar" = 1; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unquote-invalid-chars.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":11 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Invalid characters (dot) should return empty result (no unquote action available) + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unquote-invalid.md b/nixd/tools/nixd/test/code-action/unquote-invalid.md new file mode 100644 index 000000000..c3c37d3a4 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unquote-invalid.md @@ -0,0 +1,69 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that invalid identifiers cannot be unquoted (e.g., "123" should NOT offer unquote action) + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unquote-invalid.nix +{ "123" = 1; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unquote-invalid.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":7 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Invalid identifiers (starting with digit) should return empty result + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unquote-keyword.md b/nixd/tools/nixd/test/code-action/unquote-keyword.md new file mode 100644 index 000000000..3f6a6b7b3 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unquote-keyword.md @@ -0,0 +1,69 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that keywords cannot be unquoted (e.g., "true" should NOT offer unquote action) + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unquote-keyword.nix +{ "true" = 1; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unquote-keyword.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":8 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Keywords should return empty result (no unquote action available) + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unquote-quote-char.md b/nixd/tools/nixd/test/code-action/unquote-quote-char.md new file mode 100644 index 000000000..1b3fa077c --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unquote-quote-char.md @@ -0,0 +1,91 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that identifiers with single quotes can be unquoted (e.g., "foo'bar" -> foo'bar) + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unquote-quote-char.nix +{ "foo'bar" = 1; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unquote-quote-char.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":11 + } + }, + "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:///unquote-quote-char.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "foo'bar", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 11, +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": "refactor.rewrite", +CHECK-NEXT: "title": "Unquote attribute name" +CHECK-NEXT: } +CHECK-NEXT: ] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unquote-underscore.md b/nixd/tools/nixd/test/code-action/unquote-underscore.md new file mode 100644 index 000000000..68a2c53db --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unquote-underscore.md @@ -0,0 +1,91 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that identifiers starting with underscore can be unquoted (e.g., "_private" -> _private) + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unquote-underscore.nix +{ "_private" = 1; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unquote-underscore.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":12 + } + }, + "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:///unquote-underscore.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "_private", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 12, +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": "refactor.rewrite", +CHECK-NEXT: "title": "Unquote attribute name" +CHECK-NEXT: } +CHECK-NEXT: ] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` From 36d7860c627785c0d506a0eb81ad4c5e1e8ca727 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 4 Jan 2026 13:55:41 +0900 Subject: [PATCH 3/6] test: update initialize.md to include refactor.rewrite code action kind - Add "refactor.rewrite" to codeActionKinds in initialize.md tests - Ensure semantic-tokens/initialize.md reflects new supported code action kind --- nixd/tools/nixd/test/initialize.md | 3 ++- nixd/tools/nixd/test/semantic-tokens/initialize.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/nixd/tools/nixd/test/initialize.md b/nixd/tools/nixd/test/initialize.md index 3456773b0..71d1a56a4 100644 --- a/nixd/tools/nixd/test/initialize.md +++ b/nixd/tools/nixd/test/initialize.md @@ -28,7 +28,8 @@ CHECK-NEXT: "result": { CHECK-NEXT: "capabilities": { CHECK-NEXT: "codeActionProvider": { CHECK-NEXT: "codeActionKinds": [ -CHECK-NEXT: "quickfix" +CHECK-NEXT: "quickfix", +CHECK-NEXT: "refactor.rewrite" CHECK-NEXT: ], CHECK-NEXT: "resolveProvider": false CHECK-NEXT: }, diff --git a/nixd/tools/nixd/test/semantic-tokens/initialize.md b/nixd/tools/nixd/test/semantic-tokens/initialize.md index 85554629d..e303bad79 100644 --- a/nixd/tools/nixd/test/semantic-tokens/initialize.md +++ b/nixd/tools/nixd/test/semantic-tokens/initialize.md @@ -28,7 +28,8 @@ CHECK-NEXT: "result": { CHECK-NEXT: "capabilities": { CHECK-NEXT: "codeActionProvider": { CHECK-NEXT: "codeActionKinds": [ -CHECK-NEXT: "quickfix" +CHECK-NEXT: "quickfix", +CHECK-NEXT: "refactor.rewrite" CHECK-NEXT: ], CHECK-NEXT: "resolveProvider": false CHECK-NEXT: }, From b000ef85ad56ace31a122aeb2c3aad4df44b3aab Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 4 Jan 2026 14:52:52 +0900 Subject: [PATCH 4/6] chore: stage files without code changes for consistency --- nixd/lib/Controller/LifeTime.cpp | 5 ++--- nixd/lspserver/src/Protocol.cpp | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nixd/lib/Controller/LifeTime.cpp b/nixd/lib/Controller/LifeTime.cpp index 9700e3fb3..4f365a6ec 100644 --- a/nixd/lib/Controller/LifeTime.cpp +++ b/nixd/lib/Controller/LifeTime.cpp @@ -98,9 +98,8 @@ void Controller:: { "codeActionProvider", Object{ - {"codeActionKinds", - Array{CodeAction::QUICKFIX_KIND, - CodeAction::REFACTOR_REWRITE_KIND}}, + {"codeActionKinds", Array{CodeAction::QUICKFIX_KIND, + CodeAction::REFACTOR_REWRITE_KIND}}, {"resolveProvider", false}, }, }, diff --git a/nixd/lspserver/src/Protocol.cpp b/nixd/lspserver/src/Protocol.cpp index 95ceb5bc7..19c2b190f 100644 --- a/nixd/lspserver/src/Protocol.cpp +++ b/nixd/lspserver/src/Protocol.cpp @@ -766,7 +766,8 @@ llvm::json::Value toJSON(const Command &C) { const llvm::StringLiteral CodeAction::QUICKFIX_KIND = "quickfix"; const llvm::StringLiteral CodeAction::REFACTOR_KIND = "refactor"; -const llvm::StringLiteral CodeAction::REFACTOR_REWRITE_KIND = "refactor.rewrite"; +const llvm::StringLiteral CodeAction::REFACTOR_REWRITE_KIND = + "refactor.rewrite"; const llvm::StringLiteral CodeAction::INFO_KIND = "info"; llvm::json::Value toJSON(const CodeAction &CA) { From b7bdf8021162a40915da5ad765a332b85b6314c5 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 4 Jan 2026 16:16:15 +0900 Subject: [PATCH 5/6] fix(code-action): restrict quote/unquote actions to attribute sets only - Prevent quote/unquote code actions from being offered on let bindings - Add tests to verify quote/unquote are not available for let bindings - Add tests to ensure quote/unquote still work for attribute sets --- nixd/lib/Controller/CodeAction.cpp | 14 --- .../nixd/test/code-action/let-binding.md | 69 -------------- nixd/tools/nixd/test/code-action/quote-let.md | 89 +++++++++++++++++++ .../nixd/test/code-action/unquote-let.md | 89 +++++++++++++++++++ 4 files changed, 178 insertions(+), 83 deletions(-) delete mode 100644 nixd/tools/nixd/test/code-action/let-binding.md create mode 100644 nixd/tools/nixd/test/code-action/quote-let.md create mode 100644 nixd/tools/nixd/test/code-action/unquote-let.md diff --git a/nixd/lib/Controller/CodeAction.cpp b/nixd/lib/Controller/CodeAction.cpp index a2286281d..584cb8095 100644 --- a/nixd/lib/Controller/CodeAction.cpp +++ b/nixd/lib/Controller/CodeAction.cpp @@ -9,7 +9,6 @@ #include "nixd/Controller/Controller.h" #include -#include #include #include @@ -78,19 +77,6 @@ void addAttrNameActions(const nixf::Node &N, const nixf::ParentMapAnalysis &PM, if (!AttrNameNode) return; - // Only offer quote/unquote for attribute sets, not for let bindings. - // ExprLet internally contains an ExprAttrs for bindings, so we need to - // check if the parent ExprAttrs is directly inside an ExprLet. - const nixf::Node *ExprAttrsNode = - PM.upTo(*AttrNameNode, nixf::Node::NK_ExprAttrs); - if (!ExprAttrsNode) - return; - - // Check if this ExprAttrs is the binds part of an ExprLet - const nixf::Node *AttrsParent = PM.query(*ExprAttrsNode); - if (AttrsParent && AttrsParent->kind() == nixf::Node::NK_ExprLet) - return; - const auto &AN = static_cast(*AttrNameNode); if (AN.kind() == nixf::AttrName::ANK_ID) { diff --git a/nixd/tools/nixd/test/code-action/let-binding.md b/nixd/tools/nixd/test/code-action/let-binding.md deleted file mode 100644 index 81b474b7a..000000000 --- a/nixd/tools/nixd/test/code-action/let-binding.md +++ /dev/null @@ -1,69 +0,0 @@ -# RUN: nixd --lit-test < %s | FileCheck %s - -Test that let bindings do NOT get quote/unquote actions (only attr sets do) - -<-- initialize(0) - -```json -{ - "jsonrpc":"2.0", - "id":0, - "method":"initialize", - "params":{ - "processId":123, - "rootPath":"", - "capabilities":{ - }, - "trace":"off" - } -} -``` - - -<-- textDocument/didOpen - -```nix file:///let-binding.nix -let foo = 1; in foo -``` - -<-- textDocument/codeAction(2) - - -```json -{ - "jsonrpc":"2.0", - "id":2, - "method":"textDocument/codeAction", - "params":{ - "textDocument":{ - "uri":"file:///let-binding.nix" - }, - "range":{ - "start":{ - "line": 0, - "character":4 - }, - "end":{ - "line":0, - "character":7 - } - }, - "context":{ - "diagnostics":[], - "triggerKind":2 - } - } -} -``` - -Let bindings should return empty result (no quote action available) - -``` - CHECK: "id": 2, -CHECK-NEXT: "jsonrpc": "2.0", -CHECK-NEXT: "result": [] -``` - -```json -{"jsonrpc":"2.0","method":"exit"} -``` diff --git a/nixd/tools/nixd/test/code-action/quote-let.md b/nixd/tools/nixd/test/code-action/quote-let.md new file mode 100644 index 000000000..1b3110991 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/quote-let.md @@ -0,0 +1,89 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///quote-let.nix +let foo = 1; in foo +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///quote-let.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":4 + }, + "end":{ + "line":0, + "character":7 + } + }, + "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:///quote-let.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "\"foo\"", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 7, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 4, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Quote attribute name" +CHECK-NEXT: } +CHECK-NEXT: ] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unquote-let.md b/nixd/tools/nixd/test/code-action/unquote-let.md new file mode 100644 index 000000000..988d1a17a --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unquote-let.md @@ -0,0 +1,89 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unquote-let.nix +let "bar" = 1; in bar +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unquote-let.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":4 + }, + "end":{ + "line":0, + "character":9 + } + }, + "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:///unquote-let.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "bar", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 9, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 4, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Unquote attribute name" +CHECK-NEXT: } +CHECK-NEXT: ] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` From 00780a52b2ae64b4ca2fab5ffae9fa727ac4a54e Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 4 Jan 2026 16:23:04 +0900 Subject: [PATCH 6/6] test(code-action): add tests to ensure unquote not offered for invalid or keyword let bindings - Add test for let bindings with invalid identifiers (e.g., starting with digit) - Add test for let bindings using keywords (e.g., "true") - Verify code action result is empty in both cases --- .../test/code-action/unquote-let-invalid.md | 69 +++++++++++++++++++ .../test/code-action/unquote-let-keyword.md | 69 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 nixd/tools/nixd/test/code-action/unquote-let-invalid.md create mode 100644 nixd/tools/nixd/test/code-action/unquote-let-keyword.md diff --git a/nixd/tools/nixd/test/code-action/unquote-let-invalid.md b/nixd/tools/nixd/test/code-action/unquote-let-invalid.md new file mode 100644 index 000000000..6212eb20d --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unquote-let-invalid.md @@ -0,0 +1,69 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that invalid identifiers cannot be unquoted in let bindings (e.g., "123" should NOT offer unquote action) + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unquote-let-invalid.nix +let "123" = 1; in x +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unquote-let-invalid.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":4 + }, + "end":{ + "line":0, + "character":9 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Invalid identifiers (starting with digit) should return empty result + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unquote-let-keyword.md b/nixd/tools/nixd/test/code-action/unquote-let-keyword.md new file mode 100644 index 000000000..fc2aafc00 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unquote-let-keyword.md @@ -0,0 +1,69 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that keywords cannot be unquoted in let bindings (e.g., "true" should NOT offer unquote action) + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unquote-let-keyword.nix +let "true" = 1; in x +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unquote-let-keyword.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":4 + }, + "end":{ + "line":0, + "character":10 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Keywords should return empty result (no unquote action available) + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +```