diff --git a/nixd/docs/editors/nvim-lsp.nix b/nixd/docs/editors/nvim-lsp.nix index e768a0d45..808fd042f 100644 --- a/nixd/docs/editors/nvim-lsp.nix +++ b/nixd/docs/editors/nvim-lsp.nix @@ -60,6 +60,10 @@ let relativenumber = true, scrolloff = 8, updatetime = 50, -- faster completion (4000ms default) + foldmethod = "expr", + foldexpr = "v:lua.vim.lsp.foldexpr()", + foldlevel = 99, + foldenable = true, } for k, v in pairs(options) do diff --git a/nixd/include/nixd/Controller/Controller.h b/nixd/include/nixd/Controller/Controller.h index 4cef02708..4690e613a 100644 --- a/nixd/include/nixd/Controller/Controller.h +++ b/nixd/include/nixd/Controller/Controller.h @@ -172,6 +172,10 @@ class Controller : public lspserver::LSPServer { const lspserver::DocumentSymbolParams &Params, lspserver::Callback> Reply); + void onFoldingRange( + const lspserver::FoldingRangeParams &Params, + lspserver::Callback> Reply); + void onSemanticTokens(const lspserver::SemanticTokensParams &Params, lspserver::Callback Reply); diff --git a/nixd/lib/Controller/FoldingRange.cpp b/nixd/lib/Controller/FoldingRange.cpp new file mode 100644 index 000000000..cc666214d --- /dev/null +++ b/nixd/lib/Controller/FoldingRange.cpp @@ -0,0 +1,154 @@ +/// \file +/// \brief Implementation of [Folding Range]. +/// [Folding Range]: +/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_foldingRange + +#include "CheckReturn.h" +#include "Convert.h" + +#include "nixd/Controller/Controller.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace nixd; +using namespace lspserver; +using namespace nixf; + +namespace { + +/// Maximum recursion depth to prevent stack overflow on deeply nested ASTs. +constexpr size_t MaxRecursionDepth = 256; + +/// Check if the range spans multiple lines (2+ lines required for folding). +bool isMultiLine(const lspserver::Range &R) { + return R.start.line < R.end.line; +} + +/// Create a FoldingRange from an LSP Range with "region" kind. +FoldingRange toFoldingRange(const lspserver::Range &R) { + FoldingRange FR; + FR.startLine = R.start.line; + FR.startCharacter = R.start.character; + FR.endLine = R.end.line; + FR.endCharacter = R.end.character; + FR.kind = FoldingRange::REGION_KIND; + return FR; +} + +/// Add a folding range if the node spans multiple lines. +void addFoldingRange(const Node &N, std::vector &Ranges, + llvm::StringRef Src) { + auto R = toLSPRange(Src, N.range()); + if (isMultiLine(R)) + Ranges.emplace_back(toFoldingRange(R)); +} + +/// Collect folding ranges from AST nodes recursively. +/// \param AST The AST node to process +/// \param Ranges Output vector to collect folding ranges +/// \param Src Source text for position conversion +/// \param Depth Current recursion depth (for stack overflow protection) +void collectFoldingRanges(const Node *AST, std::vector &Ranges, + llvm::StringRef Src, size_t Depth = 0) { + if (!AST || Depth >= MaxRecursionDepth) + return; + + switch (AST->kind()) { + case Node::NK_ExprAttrs: { + addFoldingRange(*AST, Ranges, Src); + const auto &Attrs = static_cast(*AST); + if (Attrs.binds()) { + for (const Node *Ch : Attrs.binds()->children()) + collectFoldingRanges(Ch, Ranges, Src, Depth + 1); + } + break; + } + case Node::NK_ExprList: { + addFoldingRange(*AST, Ranges, Src); + for (const Node *Ch : AST->children()) + collectFoldingRanges(Ch, Ranges, Src, Depth + 1); + break; + } + case Node::NK_ExprLambda: { + addFoldingRange(*AST, Ranges, Src); + const auto &Lambda = static_cast(*AST); + if (const auto *Body = Lambda.body()) + collectFoldingRanges(Body, Ranges, Src, Depth + 1); + break; + } + case Node::NK_ExprLet: { + addFoldingRange(*AST, Ranges, Src); + const auto &Let = static_cast(*AST); + if (const auto *Attrs = Let.attrs()) + collectFoldingRanges(Attrs, Ranges, Src, Depth + 1); + if (const auto *E = Let.expr()) + collectFoldingRanges(E, Ranges, Src, Depth + 1); + break; + } + case Node::NK_ExprWith: { + addFoldingRange(*AST, Ranges, Src); + const auto &With = static_cast(*AST); + if (const auto *W = With.with()) + collectFoldingRanges(W, Ranges, Src, Depth + 1); + if (const auto *E = With.expr()) + collectFoldingRanges(E, Ranges, Src, Depth + 1); + break; + } + case Node::NK_ExprIf: { + addFoldingRange(*AST, Ranges, Src); + const auto &If = static_cast(*AST); + if (const auto *Cond = If.cond()) + collectFoldingRanges(Cond, Ranges, Src, Depth + 1); + if (const auto *Then = If.then()) + collectFoldingRanges(Then, Ranges, Src, Depth + 1); + if (const auto *Else = If.elseExpr()) + collectFoldingRanges(Else, Ranges, Src, Depth + 1); + break; + } + case Node::NK_ExprString: + // Multiline strings are foldable regions + addFoldingRange(*AST, Ranges, Src); + break; + default: + // Other node types may contain foldable children, recurse into them + for (const Node *Ch : AST->children()) + collectFoldingRanges(Ch, Ranges, Src, Depth + 1); + break; + } +} + +} // namespace + +/// \brief Handle textDocument/foldingRange LSP request. +/// +/// Collects foldable regions from the document's AST including attribute sets, +/// lists, lambdas, let/with/if expressions, and multiline strings. +/// +/// \param Params Request parameters containing the document URI +/// \param Reply Callback to return the list of folding ranges +void Controller::onFoldingRange(const FoldingRangeParams &Params, + Callback> Reply) { + using CheckTy = std::vector; + auto Action = [Reply = std::move(Reply), URI = Params.textDocument.uri, + this]() mutable { + return Reply([&]() -> llvm::Expected { + const auto TU = CheckDefault(getTU(URI.file().str())); + const auto AST = CheckDefault(getAST(*TU)); + try { + auto Ranges = std::vector(); + collectFoldingRanges(AST.get(), Ranges, TU->src()); + return Ranges; + } catch (std::exception &E) { + elog("textDocument/foldingRange failed: {0}", E.what()); + return CheckTy{}; + } + }()); + }; + boost::asio::post(Pool, std::move(Action)); +} diff --git a/nixd/lib/Controller/LifeTime.cpp b/nixd/lib/Controller/LifeTime.cpp index 4c9b8ba20..3f0bd8454 100644 --- a/nixd/lib/Controller/LifeTime.cpp +++ b/nixd/lib/Controller/LifeTime.cpp @@ -105,6 +105,7 @@ void Controller:: {"definitionProvider", true}, {"documentLinkProvider", Object{}}, {"documentSymbolProvider", true}, + {"foldingRangeProvider", true}, {"inlayHintProvider", true}, {"completionProvider", Object{ diff --git a/nixd/lib/Controller/Support.cpp b/nixd/lib/Controller/Support.cpp index 3ee8c6665..2b04592c0 100644 --- a/nixd/lib/Controller/Support.cpp +++ b/nixd/lib/Controller/Support.cpp @@ -90,6 +90,8 @@ Controller::Controller(std::unique_ptr In, &Controller::onDefinition); Registry.addMethod("textDocument/documentSymbol", this, &Controller::onDocumentSymbol); + Registry.addMethod("textDocument/foldingRange", this, + &Controller::onFoldingRange); Registry.addMethod("textDocument/semanticTokens/full", this, &Controller::onSemanticTokens); Registry.addMethod("textDocument/inlayHint", this, &Controller::onInlayHint); diff --git a/nixd/lib/meson.build b/nixd/lib/meson.build index 701850a24..8f10b0d5c 100644 --- a/nixd/lib/meson.build +++ b/nixd/lib/meson.build @@ -16,6 +16,7 @@ libnixd_lib = library( 'Controller/DocumentHighlight.cpp', 'Controller/DocumentLink.cpp', 'Controller/DocumentSymbol.cpp', + 'Controller/FoldingRange.cpp', 'Controller/FindReferences.cpp', 'Controller/Format.cpp', 'Controller/Hover.cpp', diff --git a/nixd/tools/nixd/test/folding-range/basic.md b/nixd/tools/nixd/test/folding-range/basic.md new file mode 100644 index 000000000..0ffb224e4 --- /dev/null +++ b/nixd/tools/nixd/test/folding-range/basic.md @@ -0,0 +1,90 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test basic folding range functionality for attribute sets and lists. + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + +<-- textDocument/didOpen + +```nix file:///test.nix +{ + # Multi-line attribute set + attrs = { + x = 1; + y = 2; + }; + + # Multi-line list + list = [ + 1 + 2 + ]; +} +``` + +<-- textDocument/foldingRange(2) + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/foldingRange", + "params":{ + "textDocument":{ + "uri":"file:///test.nix" + } + } +} +``` + +We expect folding ranges for: +1. Outer attribute set (lines 0-12) +2. Inner "attrs" attribute set (lines 2-5) +3. List (lines 8-11) + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "endCharacter": 1, +CHECK-NEXT: "endLine": 12, +CHECK-NEXT: "kind": "region", +CHECK-NEXT: "startLine": 0 +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "endCharacter": 3, +CHECK-NEXT: "endLine": 5, +CHECK-NEXT: "kind": "region", +CHECK-NEXT: "startCharacter": 10, +CHECK-NEXT: "startLine": 2 +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "endCharacter": 3, +CHECK-NEXT: "endLine": 11, +CHECK-NEXT: "kind": "region", +CHECK-NEXT: "startCharacter": 9, +CHECK-NEXT: "startLine": 8 +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT:} +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/folding-range/let-lambda.md b/nixd/tools/nixd/test/folding-range/let-lambda.md new file mode 100644 index 000000000..56e0abc44 --- /dev/null +++ b/nixd/tools/nixd/test/folding-range/let-lambda.md @@ -0,0 +1,103 @@ +# RUN: nixd --lit-test < %s | FileCheck %s + +Test folding range for let expressions and lambda functions. + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + +<-- textDocument/didOpen + +```nix file:///test.nix +let + x = 1; + y = 2; +in +{ + fn = { pkgs, lib, ... }: + { + name = "test"; + }; +} +``` + +<-- textDocument/foldingRange(2) + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/foldingRange", + "params":{ + "textDocument":{ + "uri":"file:///test.nix" + } + } +} +``` + +We expect folding ranges for: +1. Let expression (lines 0-9) +2. Let bindings (lines 1-2) +3. Attribute set in let body (lines 4-9) +4. Lambda expression (lines 5-8) +5. Inner attribute set in lambda body (lines 6-8) + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "endCharacter": 1, +CHECK-NEXT: "endLine": 9, +CHECK-NEXT: "kind": "region", +CHECK-NEXT: "startLine": 0 +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "endCharacter": 8, +CHECK-NEXT: "endLine": 2, +CHECK-NEXT: "kind": "region", +CHECK-NEXT: "startCharacter": 2, +CHECK-NEXT: "startLine": 1 +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "endCharacter": 1, +CHECK-NEXT: "endLine": 9, +CHECK-NEXT: "kind": "region", +CHECK-NEXT: "startLine": 4 +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "endCharacter": 3, +CHECK-NEXT: "endLine": 8, +CHECK-NEXT: "kind": "region", +CHECK-NEXT: "startCharacter": 7, +CHECK-NEXT: "startLine": 5 +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "endCharacter": 3, +CHECK-NEXT: "endLine": 8, +CHECK-NEXT: "kind": "region", +CHECK-NEXT: "startCharacter": 2, +CHECK-NEXT: "startLine": 6 +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT:} +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` + diff --git a/nixd/tools/nixd/test/initialize.md b/nixd/tools/nixd/test/initialize.md index 23eecf413..3456773b0 100644 --- a/nixd/tools/nixd/test/initialize.md +++ b/nixd/tools/nixd/test/initialize.md @@ -43,6 +43,7 @@ CHECK-NEXT: "documentFormattingProvider": true, CHECK-NEXT: "documentHighlightProvider": true, CHECK-NEXT: "documentLinkProvider": {}, CHECK-NEXT: "documentSymbolProvider": true, +CHECK-NEXT: "foldingRangeProvider": true, CHECK-NEXT: "hoverProvider": true, CHECK-NEXT: "inlayHintProvider": true, CHECK-NEXT: "referencesProvider": true, diff --git a/nixd/tools/nixd/test/semantic-tokens/initialize.md b/nixd/tools/nixd/test/semantic-tokens/initialize.md index 5fa92e212..85554629d 100644 --- a/nixd/tools/nixd/test/semantic-tokens/initialize.md +++ b/nixd/tools/nixd/test/semantic-tokens/initialize.md @@ -43,6 +43,7 @@ CHECK-NEXT: "documentFormattingProvider": true, CHECK-NEXT: "documentHighlightProvider": true, CHECK-NEXT: "documentLinkProvider": {}, CHECK-NEXT: "documentSymbolProvider": true, +CHECK-NEXT: "foldingRangeProvider": true, CHECK-NEXT: "hoverProvider": true, CHECK-NEXT: "inlayHintProvider": true, CHECK-NEXT: "referencesProvider": true,