diff --git a/nixd/lib/Controller/CodeAction.cpp b/nixd/lib/Controller/CodeAction.cpp index 584cb8095..2a576cd16 100644 --- a/nixd/lib/Controller/CodeAction.cpp +++ b/nixd/lib/Controller/CodeAction.cpp @@ -68,6 +68,100 @@ bool isValidNixIdentifier(const std::string &S) { return Keywords.find(S) == Keywords.end(); } +/// \brief Check if an ExprAttrs can be flattened (no rec, inherit, dynamic). +/// Returns the Binds node if flattenable, nullptr otherwise. +const nixf::Binds *getFlattenableBinds(const nixf::ExprAttrs &Attrs) { + // Block if recursive attribute set + if (Attrs.isRecursive()) + return nullptr; + + const nixf::Binds *B = Attrs.binds(); + if (!B || B->bindings().empty()) + return nullptr; + + // Check all bindings: must be plain Binding nodes (no Inherit) + // and all attribute names must be static (no dynamic ${} interpolation) + for (const auto &Child : B->bindings()) { + if (Child->kind() != nixf::Node::NK_Binding) + return nullptr; // Inherit node found + + const auto &Bind = static_cast(*Child); + for (const auto &Name : Bind.path().names()) { + if (!Name->isStatic()) + return nullptr; // Dynamic attribute name + } + } + + return B; +} + +/// \brief Add flatten action for nested attribute sets. +/// Transforms: { foo = { bar = 1; }; } -> { foo.bar = 1; } +void addFlattenAttrsAction(const nixf::Node &N, + const nixf::ParentMapAnalysis &PM, + const std::string &FileURI, llvm::StringRef Src, + std::vector &Actions) { + // Find if we're inside a Binding + const nixf::Node *BindingNode = PM.upTo(N, nixf::Node::NK_Binding); + if (!BindingNode) + return; + + const auto &Bind = static_cast(*BindingNode); + + // Check if the binding's value is an ExprAttrs + if (!Bind.value() || Bind.value()->kind() != nixf::Node::NK_ExprAttrs) + return; + + const auto &NestedAttrs = static_cast(*Bind.value()); + + // Check if flattenable + const nixf::Binds *NestedBinds = getFlattenableBinds(NestedAttrs); + if (!NestedBinds) + return; + + // Check outer path is static too + for (const auto &Name : Bind.path().names()) { + if (!Name->isStatic()) + return; + } + + // Build the flattened text + std::string NewText; + const auto &NestedBindings = NestedBinds->bindings(); + + // Pre-allocate to reduce reallocations. The +40 accounts for inner path, + // " = ", value text, and ";". May under-allocate for complex expressions + // but still reduces reallocations significantly. + const std::string_view OuterPath = Bind.path().src(Src); + size_t EstimatedSize = NestedBindings.size() * (OuterPath.size() + 40); + NewText.reserve(EstimatedSize); + + for (size_t I = 0; I < NestedBindings.size(); ++I) { + const auto &InnerBind = + static_cast(*NestedBindings[I]); + + // Build path: outer.inner + const std::string_view InnerPath = InnerBind.path().src(Src); + + NewText += OuterPath; + NewText += "."; + NewText += InnerPath; + NewText += " = "; + + if (InnerBind.value()) { + NewText += InnerBind.value()->src(Src); + } + NewText += ";"; + + if (I + 1 < NestedBindings.size()) + NewText += " "; + } + + Actions.emplace_back(createSingleEditAction( + "Flatten nested attribute set", CodeAction::REFACTOR_REWRITE_KIND, + FileURI, toLSPRange(Src, Bind.range()), std::move(NewText))); +} + /// \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, @@ -144,6 +238,8 @@ void Controller::onCodeAction(const lspserver::CodeActionParams &Params, nixf::PositionRange NixfRange = toNixfRange(Range); if (const nixf::Node *N = TU->ast()->descend(NixfRange)) { addAttrNameActions(*N, *TU->parentMap(), FileURI, TU->src(), Actions); + addFlattenAttrsAction(*N, *TU->parentMap(), FileURI, TU->src(), + Actions); } } diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs-deep.md b/nixd/tools/nixd/test/code-action/flatten-attrs-deep.md new file mode 100644 index 000000000..7d45ae6c7 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/flatten-attrs-deep.md @@ -0,0 +1,93 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that flatten action works with already-dotted outer paths. + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///flatten-attrs-deep.nix +{ a.b = { c = 1; }; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///flatten-attrs-deep.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":5 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +No Quote action since "a.b" is a path, not a simple identifier. + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///flatten-attrs-deep.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "a.b.c = 1;", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 19, +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": "Flatten nested attribute set" +CHECK-NEXT: } +CHECK-NEXT: ] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs-dynamic.md b/nixd/tools/nixd/test/code-action/flatten-attrs-dynamic.md new file mode 100644 index 000000000..0cb670620 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/flatten-attrs-dynamic.md @@ -0,0 +1,69 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that flatten action is NOT offered when nested attrset contains dynamic attribute names. + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///flatten-attrs-dynamic.nix +{ foo = { ${x} = 1; }; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///flatten-attrs-dynamic.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":5 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Dynamic attrsets should not offer flatten action. + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", + CHECK-NOT: "Flatten nested attribute set" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs-empty.md b/nixd/tools/nixd/test/code-action/flatten-attrs-empty.md new file mode 100644 index 000000000..b142816ad --- /dev/null +++ b/nixd/tools/nixd/test/code-action/flatten-attrs-empty.md @@ -0,0 +1,69 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that flatten action is NOT offered for empty nested attribute sets. + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///flatten-attrs-empty.nix +{ foo = { }; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///flatten-attrs-empty.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":5 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Empty attrsets should not offer flatten action. + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", + CHECK-NOT: "Flatten nested attribute set" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs-inherit.md b/nixd/tools/nixd/test/code-action/flatten-attrs-inherit.md new file mode 100644 index 000000000..c413a5392 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/flatten-attrs-inherit.md @@ -0,0 +1,69 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that flatten action is NOT offered when nested attrset contains inherit. + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///flatten-attrs-inherit.nix +{ foo = { inherit bar; }; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///flatten-attrs-inherit.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":5 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Attrsets with inherit should not offer flatten action. + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", + CHECK-NOT: "Flatten nested attribute set" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs-multi.md b/nixd/tools/nixd/test/code-action/flatten-attrs-multi.md new file mode 100644 index 000000000..b2843f882 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/flatten-attrs-multi.md @@ -0,0 +1,87 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that flatten action works for multiple nested bindings. + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///flatten-attrs-multi.nix +{ foo = { bar = 1; baz = 2; }; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///flatten-attrs-multi.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":5 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Verify that flatten action is offered for multiple nested bindings. + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", + CHECK: "newText": "foo.bar = 1; foo.baz = 2;", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 30, +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: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Flatten nested attribute set" +CHECK-NEXT: } +CHECK-NEXT: ] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs-rec.md b/nixd/tools/nixd/test/code-action/flatten-attrs-rec.md new file mode 100644 index 000000000..1d89ded8d --- /dev/null +++ b/nixd/tools/nixd/test/code-action/flatten-attrs-rec.md @@ -0,0 +1,75 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that flatten action is NOT offered for recursive (rec) attribute sets. + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///flatten-attrs-rec.nix +{ foo = rec { bar = 1; }; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///flatten-attrs-rec.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":5 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +Recursive attrsets should not offer flatten (only quote action available) + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///flatten-attrs-rec.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "\"foo\"", +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs.md b/nixd/tools/nixd/test/code-action/flatten-attrs.md new file mode 100644 index 000000000..6d4fc234f --- /dev/null +++ b/nixd/tools/nixd/test/code-action/flatten-attrs.md @@ -0,0 +1,116 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test that "Flatten nested attribute set" action is offered for simple nested attrsets. + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///flatten-attrs.nix +{ foo = { bar = 1; }; } +``` + +<-- textDocument/codeAction(2) + + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///flatten-attrs.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":5 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +The Quote action appears first, then Flatten action second. + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///flatten-attrs.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: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///flatten-attrs.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "foo.bar = 1;", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 21, +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": "Flatten nested attribute set" +CHECK-NEXT: } +CHECK-NEXT: ] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +```