diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 293e2d461..a0bfab1cb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -21,4 +21,5 @@ # Code Actions /nixd/lib/Controller/CodeAction.cpp @takeokunn +/nixd/lib/Controller/CodeActions/ @takeokunn /nixd/tools/nixd/test/code-action/ @takeokunn diff --git a/nixd/lib/Controller/CodeAction.cpp b/nixd/lib/Controller/CodeAction.cpp index 0a66bd079..1c508ffa5 100644 --- a/nixd/lib/Controller/CodeAction.cpp +++ b/nixd/lib/Controller/CodeAction.cpp @@ -6,777 +6,22 @@ #include "CheckReturn.h" #include "Convert.h" -#include "nixd/Controller/Controller.h" +#include "CodeActions/AttrName.h" +#include "CodeActions/FlattenAttrs.h" +#include "CodeActions/JsonToNix.h" +#include "CodeActions/NoogleDoc.h" +#include "CodeActions/PackAttrs.h" -#include -#include -#include -#include +#include "nixd/Controller/Controller.h" #include #include -#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 Escape special characters for Nix double-quoted string literals. -/// Escapes: " \ ${ \n \r \t (per Nix Reference Manual) -std::string escapeNixString(llvm::StringRef S) { - std::string Result; - Result.reserve(S.size() + S.size() / 4 + 2); - for (size_t I = 0; I < S.size(); ++I) { - char C = S[I]; - switch (C) { - case '"': - Result += "\\\""; - break; - case '\\': - Result += "\\\\"; - break; - case '\n': - Result += "\\n"; - break; - case '\r': - Result += "\\r"; - break; - case '\t': - Result += "\\t"; - break; - case '$': - // Only escape ${ to prevent interpolation - if (I + 1 < S.size() && S[I + 1] == '{') { - Result += "\\${"; - ++I; // Skip the '{' - } else { - Result += C; - } - break; - default: - Result += C; - } - } - return Result; -} - -/// \brief Quote and escape a Nix attribute key if necessary. -/// Returns the key as-is if it's a valid identifier, otherwise quotes and -/// escapes special characters using escapeNixString(). -std::string quoteNixAttrKey(const std::string &Key) { - if (isValidNixIdentifier(Key)) - return Key; - - return "\"" + escapeNixString(Key) + "\""; -} - -/// \brief Maximum recursion depth for JSON to Nix conversion. -constexpr size_t MaxJsonDepth = 100; - -/// \brief Maximum array/object width for JSON to Nix conversion. -constexpr size_t MaxJsonWidth = 10000; - -/// \brief Convert a JSON value to Nix expression syntax. -/// \param V The JSON value to convert -/// \param Indent Current indentation level (for pretty-printing) -/// \param Depth Current recursion depth (safety limit) -/// \return Nix expression string, or empty string on error -std::string jsonToNix(const llvm::json::Value &V, size_t Indent = 0, - size_t Depth = 0) { - if (Depth > MaxJsonDepth) - return ""; // Safety limit exceeded - - std::string IndentStr(Indent * 2, ' '); - std::string NextIndent((Indent + 1) * 2, ' '); - std::string Out; - - if (V.kind() == llvm::json::Value::Null) { - Out = "null"; - } else if (auto B = V.getAsBoolean()) { - Out = *B ? "true" : "false"; - } else if (auto I = V.getAsInteger()) { - Out = std::to_string(*I); - } else if (auto D = V.getAsNumber()) { - // Format floating point with enough precision - std::ostringstream SS; - SS << std::setprecision(17) << *D; - Out = SS.str(); - } else if (auto S = V.getAsString()) { - Out = "\"" + escapeNixString(*S) + "\""; - } else if (const auto *A = V.getAsArray()) { - if (A->size() > MaxJsonWidth) - return ""; // Width limit exceeded - if (A->empty()) { - Out = "[ ]"; - } else { - // Pre-allocate memory to reduce reallocations - // Estimate: opening + closing + elements * (indent + value_estimate + - // newline) - size_t EstimatedSize = 4 + A->size() * ((Indent + 1) * 2 + 20); - Out.reserve(EstimatedSize); - Out = "[\n"; - for (size_t I = 0; I < A->size(); ++I) { - std::string Elem = jsonToNix((*A)[I], Indent + 1, Depth + 1); - if (Elem.empty()) - return ""; // Propagate error - Out += NextIndent + Elem; - if (I + 1 < A->size()) - Out += "\n"; - } - Out += "\n" + IndentStr + "]"; - } - } else if (const auto *O = V.getAsObject()) { - if (O->size() > MaxJsonWidth) - return ""; // Width limit exceeded - if (O->empty()) { - Out = "{ }"; - } else { - // Pre-allocate memory to reduce reallocations - // Estimate: braces + elements * (indent + key + " = " + value_estimate + - // ";\n") - size_t EstimatedSize = 4 + O->size() * ((Indent + 1) * 2 + 30); - Out.reserve(EstimatedSize); - Out = "{\n"; - size_t I = 0; - for (const auto &KV : *O) { - std::string Key = quoteNixAttrKey(KV.first.str()); - std::string Val = jsonToNix(KV.second, Indent + 1, Depth + 1); - if (Val.empty()) - return ""; // Propagate error - Out += NextIndent + Key + " = " + Val + ";"; - if (I + 1 < O->size()) - Out += "\n"; - ++I; - } - Out += "\n" + IndentStr + "}"; - } - } - return Out; -} - -/// \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 Get the number of sibling bindings sharing the same first path -/// segment. Uses SemaAttrs to count nested attributes for the first segment. -/// Returns 0 if the segment is not found or has conflicts (non-path binding). -size_t getSiblingCount(const nixf::Binding &Bind, - const nixf::ExprAttrs &ParentAttrs) { - const auto &Names = Bind.path().names(); - if (Names.empty() || !Names[0]->isStatic()) - return 0; - - const std::string &FirstSeg = Names[0]->staticName(); - const nixf::SemaAttrs &SA = ParentAttrs.sema(); - - auto It = SA.staticAttrs().find(FirstSeg); - if (It == SA.staticAttrs().end()) - return 0; - - const nixf::Attribute &Attr = It->second; - - // Check if value is a nested ExprAttrs (path was desugared) - if (!Attr.value() || Attr.value()->kind() != nixf::Node::NK_ExprAttrs) - return 0; // Non-ExprAttrs value = conflict with non-path binding - - const auto &NestedAttrs = static_cast(*Attr.value()); - const nixf::SemaAttrs &NestedSA = NestedAttrs.sema(); - - // Return 0 if there are dynamic attrs (can't safely pack) - if (!NestedSA.dynamicAttrs().empty()) - return 0; - - return NestedSA.staticAttrs().size(); -} - -/// \brief Maximum recursion depth for nested text generation. -/// Prevents stack overflow on maliciously crafted deeply nested inputs. -constexpr size_t MaxNestedDepth = 100; - -/// \brief Recursively generate nested attribute set text from SemaAttrs. -/// This produces the fully packed/nested form of attributes. -/// \param Depth Current recursion depth (for safety limit) -void generateNestedText(const nixf::SemaAttrs &SA, llvm::StringRef Src, - std::string &Out, size_t Depth = 0) { - // Safety check: prevent stack overflow from deeply nested structures - if (Depth > MaxNestedDepth) { - Out += "{ /* max depth exceeded */ }"; - return; - } - - Out += "{ "; - bool First = true; - for (const auto &[Key, Attr] : SA.staticAttrs()) { - if (!First) - Out += " "; - First = false; - - // Output the key, quoting if necessary - Out += quoteNixAttrKey(Key); - Out += " = "; - - // Check if value is a nested ExprAttrs that needs recursive generation - if (Attr.value() && Attr.value()->kind() == nixf::Node::NK_ExprAttrs) { - const auto &NestedAttrs = - static_cast(*Attr.value()); - const nixf::SemaAttrs &NestedSA = NestedAttrs.sema(); - - // If all nested attrs come from dotted paths (no inherit, no dynamic), - // we can generate recursively - if (NestedSA.dynamicAttrs().empty() && !Attr.fromInherit()) { - generateNestedText(NestedSA, Src, Out, Depth + 1); - } else { - // Use original source text - Out += Attr.value()->src(Src); - } - } else if (Attr.value()) { - Out += Attr.value()->src(Src); - } - Out += ";"; - } - Out += " }"; -} - -/// \brief Generate shallow nested attribute set text from original bindings. -/// Unlike generateNestedText, this only expands one level and preserves -/// remaining dotted paths as-is by extracting from original source. -/// \param Binds The original Binds node containing all bindings -/// \param FirstSeg The first path segment to match (e.g., "foo" for "foo.bar") -/// \param Src The source text -/// \param Out Output string to append to -void generateShallowNestedText(const nixf::Binds &Binds, - const std::string &FirstSeg, llvm::StringRef Src, - std::string &Out) { - Out += "{ "; - bool First = true; - - for (const auto &Child : Binds.bindings()) { - if (Child->kind() != nixf::Node::NK_Binding) - continue; - - const auto &SibBind = static_cast(*Child); - const auto &Names = SibBind.path().names(); - - // Only process bindings that match the first segment - if (Names.empty() || !Names[0]->isStatic() || - Names[0]->staticName() != FirstSeg) - continue; - - if (!First) - Out += " "; - First = false; - - if (Names.size() == 1) { - // Single segment path (e.g., just "foo") - shouldn't happen in bulk pack - // but handle it gracefully by using value directly - Out += quoteNixAttrKey(Names[0]->staticName()); - Out += " = "; - if (SibBind.value()) { - Out += SibBind.value()->src(Src); - } - } else { - // Multi-segment path - extract rest of path from source - // e.g., "foo.bar.x = 1" -> "bar.x = 1" - const nixf::LexerCursor RestStart = Names[1]->range().lCur(); - const nixf::LexerCursor PathEnd = SibBind.path().range().rCur(); - - if (PathEnd.offset() >= RestStart.offset()) { - std::string_view RestPath = Src.substr( - RestStart.offset(), PathEnd.offset() - RestStart.offset()); - Out += RestPath; - Out += " = "; - if (SibBind.value()) { - Out += SibBind.value()->src(Src); - } - } - } - Out += ";"; - } - Out += " }"; -} - -/// \brief Find all sibling bindings that share the same first path segment. -/// Returns the range covering all such bindings, or nullopt if not applicable. -std::optional -findSiblingBindingsRange(const nixf::Binding &Bind, const nixf::Binds &Binds, - const std::string &FirstSeg) { - nixf::LexerCursor Start = Bind.range().lCur(); - nixf::LexerCursor End = Bind.range().rCur(); - - for (const auto &Sibling : Binds.bindings()) { - if (Sibling->kind() != nixf::Node::NK_Binding) - continue; - - const auto &SibBind = static_cast(*Sibling); - const auto &SibNames = SibBind.path().names(); - - if (SibNames.empty() || !SibNames[0]->isStatic()) - continue; - - if (SibNames[0]->staticName() == FirstSeg) { - // Expand range to include this sibling - if (SibBind.range().lCur().offset() < Start.offset()) - Start = SibBind.range().lCur(); - if (SibBind.range().rCur().offset() > End.offset()) - End = SibBind.range().rCur(); - } - } - - return nixf::LexerCursorRange{Start, End}; -} - -/// \brief Add pack action for dotted attribute paths. -/// Transforms: { foo.bar = 1; } -> { foo = { bar = 1; }; } -/// Also offers bulk pack when siblings share a prefix: -/// { foo.bar = 1; foo.baz = 2; } -> { foo = { bar = 1; baz = 2; }; } -void addPackAttrsAction(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); - const auto &Names = Bind.path().names(); - - // Must have at least 2 path segments (e.g., foo.bar) - if (Names.size() < 2) - return; - - // All path segments must be static - for (const auto &Name : Names) { - if (!Name->isStatic()) - return; - } - - // Check parent ExprAttrs is not recursive - const nixf::Node *BindsNode = PM.query(Bind); - if (!BindsNode || BindsNode->kind() != nixf::Node::NK_Binds) - return; - - const nixf::Node *AttrsNode = PM.query(*BindsNode); - if (!AttrsNode || AttrsNode->kind() != nixf::Node::NK_ExprAttrs) - return; - - const auto &ParentAttrs = static_cast(*AttrsNode); - if (ParentAttrs.isRecursive()) - return; - - const std::string &FirstSeg = Names[0]->staticName(); - size_t SiblingCount = getSiblingCount(Bind, ParentAttrs); - - if (SiblingCount == 0) - return; // Can't pack (dynamic attrs or other conflicts) - - // Helper lambda to generate Pack One action text - auto GeneratePackOneText = [&]() -> std::string { - std::string NewText; - const std::string_view FirstName = Names[0]->src(Src); - - size_t ValueSize = Bind.value() ? Bind.value()->src(Src).size() : 0; - NewText.reserve(FirstName.size() + Bind.path().src(Src).size() + ValueSize + - 15); - NewText += FirstName; - NewText += " = { "; - - const nixf::LexerCursor RestStart = Names[1]->range().lCur(); - const nixf::LexerCursor RestEnd = Bind.path().range().rCur(); - - // Safety check: ensure valid range to prevent integer underflow - if (RestEnd.offset() < RestStart.offset()) - return ""; - - std::string_view RestPath = - Src.substr(RestStart.offset(), RestEnd.offset() - RestStart.offset()); - - NewText += RestPath; - NewText += " = "; - - if (Bind.value()) { - NewText += Bind.value()->src(Src); - } - NewText += "; };"; - return NewText; - }; - - if (SiblingCount == 1) { - // Single binding - offer simple pack action - std::string NewText = GeneratePackOneText(); - if (NewText.empty()) - return; - - Actions.emplace_back(createSingleEditAction( - "Pack dotted path to nested set", CodeAction::REFACTOR_REWRITE_KIND, - FileURI, toLSPRange(Src, Bind.range()), std::move(NewText))); - } else { - // Multiple siblings share the prefix - offer Pack One and bulk pack actions - const nixf::SemaAttrs &SA = ParentAttrs.sema(); - auto It = SA.staticAttrs().find(FirstSeg); - if (It == SA.staticAttrs().end()) - return; - - const nixf::Attribute &Attr = It->second; - if (!Attr.value() || Attr.value()->kind() != nixf::Node::NK_ExprAttrs) - return; - - const auto &NestedAttrs = - static_cast(*Attr.value()); - - // Find the range covering all sibling bindings (needed for bulk actions) - const auto &ParentBinds = static_cast(*BindsNode); - auto BulkRange = findSiblingBindingsRange(Bind, ParentBinds, FirstSeg); - if (!BulkRange) - return; - - // Action 1: Pack One - pack only the current binding - std::string PackOneText = GeneratePackOneText(); - if (!PackOneText.empty()) { - Actions.emplace_back(createSingleEditAction( - "Pack dotted path to nested set", CodeAction::REFACTOR_REWRITE_KIND, - FileURI, toLSPRange(Src, Bind.range()), std::move(PackOneText))); - } - - // Action 2: Shallow Pack All - pack all siblings but only one level deep - std::string ShallowText; - ShallowText += quoteNixAttrKey(FirstSeg); - ShallowText += " = "; - generateShallowNestedText(ParentBinds, FirstSeg, Src, ShallowText); - ShallowText += ";"; - - Actions.emplace_back(createSingleEditAction( - "Pack all '" + FirstSeg + "' bindings to nested set", - CodeAction::REFACTOR_REWRITE_KIND, FileURI, toLSPRange(Src, *BulkRange), - std::move(ShallowText))); - - // Action 3: Recursive Pack All - fully nest all sibling bindings - std::string RecursiveText; - RecursiveText += quoteNixAttrKey(FirstSeg); - RecursiveText += " = "; - generateNestedText(NestedAttrs.sema(), Src, RecursiveText); - RecursiveText += ";"; - - Actions.emplace_back(createSingleEditAction( - "Recursively pack all '" + FirstSeg + "' bindings to nested set", - CodeAction::REFACTOR_REWRITE_KIND, FileURI, toLSPRange(Src, *BulkRange), - std::move(RecursiveText))); - } -} - -/// \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; - - 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)); - } - } -} - -/// \brief Construct noogle.dev URL for a lib.* function path. -/// Examples: -/// - {"lib", "optionalString"} -> "https://noogle.dev/f/lib/optionalString" -/// - {"lib", "strings", "optionalString"} -> -/// "https://noogle.dev/f/lib/strings/optionalString" -std::string buildNoogleUrl(const std::vector &Path) { - std::string Url = "https://noogle.dev/f"; - for (const auto &Segment : Path) { - Url += "/"; - Url += Segment; - } - return Url; -} - -/// \brief Add a code action to open noogle.dev documentation for lib.* -/// functions. -/// -/// This action is offered when the cursor is on an ExprSelect with: -/// - Base expression is ExprVar with name "lib" -/// - Path contains at least one static attribute name -/// -/// Examples that trigger: -/// - lib.optionalString -/// - lib.strings.optionalString -/// - lib.attrsets.mapAttrs -/// -/// Examples that do NOT trigger: -/// - lib (just the variable, no selection) -/// - lib.${x} (dynamic attribute) -/// - pkgs.hello (not lib.*) -void addNoogleDocAction(const nixf::Node &N, const nixf::ParentMapAnalysis &PM, - std::vector &Actions) { - // Find if we're inside an ExprSelect - const nixf::Node *SelectNode = PM.upTo(N, nixf::Node::NK_ExprSelect); - if (!SelectNode) - return; - - const auto &Sel = static_cast(*SelectNode); - - // Check base expression is ExprVar with name "lib" - if (Sel.expr().kind() != nixf::Node::NK_ExprVar) - return; - - const auto &Var = static_cast(Sel.expr()); - if (Var.id().name() != "lib") - return; - - // Check path exists and has at least one attribute - if (!Sel.path()) - return; - - const nixf::AttrPath &Path = *Sel.path(); - if (Path.names().empty()) - return; - - // Build the function path, checking all names are static - std::vector FunctionPath; - FunctionPath.reserve(Path.names().size() + 1); - FunctionPath.emplace_back("lib"); - - for (const auto &Name : Path.names()) { - if (!Name->isStatic()) - return; // Dynamic attribute, can't construct URL - FunctionPath.emplace_back(Name->staticName()); - } - - // Construct the noogle.dev URL - std::string NoogleUrl = buildNoogleUrl(FunctionPath); - - // Create a code action that will open the URL - // Note: The actual URL opening is handled by the client via - // window/showDocument - Actions.emplace_back(CodeAction{ - .title = "Open Noogle documentation for " + FunctionPath.back(), - .kind = std::string(CodeAction::REFACTOR_KIND), - .data = Object{{"noogleUrl", NoogleUrl}}, - }); -} - -/// \brief Add JSON to Nix conversion action for selected JSON text. -/// This is a selection-based action that works on arbitrary text, not AST -/// nodes. -void addJsonToNixAction(llvm::StringRef Src, const lspserver::Range &Range, - const std::string &FileURI, - std::vector &Actions) { - // Convert LSP positions to byte offsets - llvm::Expected StartOffset = positionToOffset(Src, Range.start); - llvm::Expected EndOffset = positionToOffset(Src, Range.end); - - if (!StartOffset || !EndOffset) { - if (!StartOffset) - llvm::consumeError(StartOffset.takeError()); - if (!EndOffset) - llvm::consumeError(EndOffset.takeError()); - return; - } - - // Validate range - if (*StartOffset >= *EndOffset || *EndOffset > Src.size()) - return; - - // Extract selected text - llvm::StringRef SelectedText = - Src.substr(*StartOffset, *EndOffset - *StartOffset); - - // Skip if selection is too short (minimum valid JSON is "{}" or "[]") - if (SelectedText.size() < 2) - return; - - // Skip if first character is not { or [ (quick rejection) - char First = SelectedText.front(); - if (First != '{' && First != '[') - return; - - // Try to parse as JSON - llvm::Expected JsonVal = llvm::json::parse(SelectedText); - if (!JsonVal) { - llvm::consumeError(JsonVal.takeError()); - return; - } - - // Skip empty JSON structures - already valid Nix - if (const auto *A = JsonVal->getAsArray()) { - if (A->empty()) - return; - } else if (const auto *O = JsonVal->getAsObject()) { - if (O->empty()) - return; - } - - // Convert JSON to Nix - std::string NixText = jsonToNix(*JsonVal); - if (NixText.empty()) - return; - - Actions.emplace_back(createSingleEditAction( - "Convert JSON to Nix", CodeAction::REFACTOR_REWRITE_KIND, FileURI, Range, - std::move(NixText))); -} -} // namespace - void Controller::onCodeAction(const lspserver::CodeActionParams &Params, Callback> Reply) { using CheckTy = std::vector; diff --git a/nixd/lib/Controller/CodeActions/AttrName.cpp b/nixd/lib/Controller/CodeActions/AttrName.cpp new file mode 100644 index 000000000..fa768620f --- /dev/null +++ b/nixd/lib/Controller/CodeActions/AttrName.cpp @@ -0,0 +1,41 @@ +/// \file +/// \brief Implementation of attribute name quote/unquote code actions. + +#include "AttrName.h" +#include "Utils.h" + +#include "../Convert.h" + +#include + +namespace nixd { + +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; + + 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", lspserver::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", + lspserver::CodeAction::REFACTOR_REWRITE_KIND, + FileURI, toLSPRange(Src, AN.range()), Name)); + } + } +} + +} // namespace nixd diff --git a/nixd/lib/Controller/CodeActions/AttrName.h b/nixd/lib/Controller/CodeActions/AttrName.h new file mode 100644 index 000000000..4948a90ce --- /dev/null +++ b/nixd/lib/Controller/CodeActions/AttrName.h @@ -0,0 +1,29 @@ +/// \file +/// \brief Code action for quoting/unquoting attribute names. +/// +/// Offers to quote an unquoted attribute name (foo -> "foo") or +/// unquote a quoted attribute name if it's a valid identifier ("foo" -> foo). + +#pragma once + +#include + +#include + +#include + +#include +#include + +namespace nixf { +class Node; +} // namespace nixf + +namespace nixd { + +/// \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); + +} // namespace nixd diff --git a/nixd/lib/Controller/CodeActions/FlattenAttrs.cpp b/nixd/lib/Controller/CodeActions/FlattenAttrs.cpp new file mode 100644 index 000000000..2b295bd3d --- /dev/null +++ b/nixd/lib/Controller/CodeActions/FlattenAttrs.cpp @@ -0,0 +1,110 @@ +/// \file +/// \brief Implementation of flatten nested attribute sets code action. + +#include "FlattenAttrs.h" +#include "Utils.h" + +#include "../Convert.h" + +#include + +namespace nixd { + +namespace { + +/// \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; +} + +} // namespace + +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", + lspserver::CodeAction::REFACTOR_REWRITE_KIND, FileURI, + toLSPRange(Src, Bind.range()), std::move(NewText))); +} + +} // namespace nixd diff --git a/nixd/lib/Controller/CodeActions/FlattenAttrs.h b/nixd/lib/Controller/CodeActions/FlattenAttrs.h new file mode 100644 index 000000000..53ca8dc48 --- /dev/null +++ b/nixd/lib/Controller/CodeActions/FlattenAttrs.h @@ -0,0 +1,33 @@ +/// \file +/// \brief Code action for flattening nested attribute sets. +/// +/// Transforms: { foo = { bar = 1; }; } -> { foo.bar = 1; } + +#pragma once + +#include + +#include + +#include + +#include +#include + +namespace nixf { +class Node; +} // namespace nixf + +namespace nixd { + +/// \brief Add flatten action for nested attribute sets. +/// +/// This action is offered when the cursor is on a binding whose value is +/// a non-recursive attribute set. It flattens all nested bindings into +/// dotted paths. +void addFlattenAttrsAction(const nixf::Node &N, + const nixf::ParentMapAnalysis &PM, + const std::string &FileURI, llvm::StringRef Src, + std::vector &Actions); + +} // namespace nixd diff --git a/nixd/lib/Controller/CodeActions/JsonToNix.cpp b/nixd/lib/Controller/CodeActions/JsonToNix.cpp new file mode 100644 index 000000000..71ad4e3bc --- /dev/null +++ b/nixd/lib/Controller/CodeActions/JsonToNix.cpp @@ -0,0 +1,157 @@ +/// \file +/// \brief Implementation of JSON to Nix conversion code action. + +#include "JsonToNix.h" +#include "Utils.h" + +#include +#include + +#include +#include + +namespace nixd { + +namespace { + +/// \brief Convert a JSON value to Nix expression syntax. +/// \param V The JSON value to convert +/// \param Indent Current indentation level (for pretty-printing) +/// \param Depth Current recursion depth (safety limit) +/// \return Nix expression string, or empty string on error +std::string jsonToNix(const llvm::json::Value &V, size_t Indent = 0, + size_t Depth = 0) { + if (Depth > MaxJsonDepth) + return ""; // Safety limit exceeded + + std::string IndentStr(Indent * 2, ' '); + std::string NextIndent((Indent + 1) * 2, ' '); + std::string Out; + + if (V.kind() == llvm::json::Value::Null) { + Out = "null"; + } else if (auto B = V.getAsBoolean()) { + Out = *B ? "true" : "false"; + } else if (auto I = V.getAsInteger()) { + Out = std::to_string(*I); + } else if (auto D = V.getAsNumber()) { + // Format floating point with enough precision + std::ostringstream SS; + SS << std::setprecision(17) << *D; + Out = SS.str(); + } else if (auto S = V.getAsString()) { + Out = "\"" + escapeNixString(*S) + "\""; + } else if (const auto *A = V.getAsArray()) { + if (A->size() > MaxJsonWidth) + return ""; // Width limit exceeded + if (A->empty()) { + Out = "[ ]"; + } else { + // Pre-allocate memory to reduce reallocations + // Estimate: opening + closing + elements * (indent + value_estimate + + // newline) + size_t EstimatedSize = 4 + A->size() * ((Indent + 1) * 2 + 20); + Out.reserve(EstimatedSize); + Out = "[\n"; + for (size_t I = 0; I < A->size(); ++I) { + std::string Elem = jsonToNix((*A)[I], Indent + 1, Depth + 1); + if (Elem.empty()) + return ""; // Propagate error + Out += NextIndent + Elem; + if (I + 1 < A->size()) + Out += "\n"; + } + Out += "\n" + IndentStr + "]"; + } + } else if (const auto *O = V.getAsObject()) { + if (O->size() > MaxJsonWidth) + return ""; // Width limit exceeded + if (O->empty()) { + Out = "{ }"; + } else { + // Pre-allocate memory to reduce reallocations + // Estimate: braces + elements * (indent + key + " = " + value_estimate + + // ";\n") + size_t EstimatedSize = 4 + O->size() * ((Indent + 1) * 2 + 30); + Out.reserve(EstimatedSize); + Out = "{\n"; + size_t I = 0; + for (const auto &KV : *O) { + std::string Key = quoteNixAttrKey(KV.first.str()); + std::string Val = jsonToNix(KV.second, Indent + 1, Depth + 1); + if (Val.empty()) + return ""; // Propagate error + Out += NextIndent + Key + " = " + Val + ";"; + if (I + 1 < O->size()) + Out += "\n"; + ++I; + } + Out += "\n" + IndentStr + "}"; + } + } + return Out; +} + +} // namespace + +void addJsonToNixAction(llvm::StringRef Src, const lspserver::Range &Range, + const std::string &FileURI, + std::vector &Actions) { + // Convert LSP positions to byte offsets + llvm::Expected StartOffset = + lspserver::positionToOffset(Src, Range.start); + llvm::Expected EndOffset = + lspserver::positionToOffset(Src, Range.end); + + if (!StartOffset || !EndOffset) { + if (!StartOffset) + llvm::consumeError(StartOffset.takeError()); + if (!EndOffset) + llvm::consumeError(EndOffset.takeError()); + return; + } + + // Validate range + if (*StartOffset >= *EndOffset || *EndOffset > Src.size()) + return; + + // Extract selected text + llvm::StringRef SelectedText = + Src.substr(*StartOffset, *EndOffset - *StartOffset); + + // Skip if selection is too short (minimum valid JSON is "{}" or "[]") + if (SelectedText.size() < 2) + return; + + // Skip if first character is not { or [ (quick rejection) + char First = SelectedText.front(); + if (First != '{' && First != '[') + return; + + // Try to parse as JSON + llvm::Expected JsonVal = llvm::json::parse(SelectedText); + if (!JsonVal) { + llvm::consumeError(JsonVal.takeError()); + return; + } + + // Skip empty JSON structures - already valid Nix + if (const auto *A = JsonVal->getAsArray()) { + if (A->empty()) + return; + } else if (const auto *O = JsonVal->getAsObject()) { + if (O->empty()) + return; + } + + // Convert JSON to Nix + std::string NixText = jsonToNix(*JsonVal); + if (NixText.empty()) + return; + + Actions.emplace_back(createSingleEditAction( + "Convert JSON to Nix", lspserver::CodeAction::REFACTOR_REWRITE_KIND, + FileURI, Range, std::move(NixText))); +} + +} // namespace nixd diff --git a/nixd/lib/Controller/CodeActions/JsonToNix.h b/nixd/lib/Controller/CodeActions/JsonToNix.h new file mode 100644 index 000000000..77bc325ec --- /dev/null +++ b/nixd/lib/Controller/CodeActions/JsonToNix.h @@ -0,0 +1,26 @@ +/// \file +/// \brief Code action for converting JSON to Nix expressions. +/// +/// Provides a selection-based code action that converts valid JSON text +/// to equivalent Nix expression syntax. + +#pragma once + +#include + +#include + +#include +#include + +namespace nixd { + +/// \brief Add JSON to Nix conversion action for selected JSON text. +/// +/// This is a selection-based action that works on arbitrary text, not AST +/// nodes. It parses the selected text as JSON and converts it to Nix syntax. +void addJsonToNixAction(llvm::StringRef Src, const lspserver::Range &Range, + const std::string &FileURI, + std::vector &Actions); + +} // namespace nixd diff --git a/nixd/lib/Controller/CodeActions/NoogleDoc.cpp b/nixd/lib/Controller/CodeActions/NoogleDoc.cpp new file mode 100644 index 000000000..2669d4251 --- /dev/null +++ b/nixd/lib/Controller/CodeActions/NoogleDoc.cpp @@ -0,0 +1,79 @@ +/// \file +/// \brief Implementation of noogle.dev documentation code action. + +#include "NoogleDoc.h" + +#include + +#include + +namespace nixd { + +namespace { + +/// \brief Construct noogle.dev URL for a lib.* function path. +/// Examples: +/// - {"lib", "optionalString"} -> "https://noogle.dev/f/lib/optionalString" +/// - {"lib", "strings", "optionalString"} -> +/// "https://noogle.dev/f/lib/strings/optionalString" +std::string buildNoogleUrl(const std::vector &Path) { + std::string Url = "https://noogle.dev/f"; + for (const auto &Segment : Path) { + Url += "/"; + Url += Segment; + } + return Url; +} + +} // namespace + +void addNoogleDocAction(const nixf::Node &N, const nixf::ParentMapAnalysis &PM, + std::vector &Actions) { + // Find if we're inside an ExprSelect + const nixf::Node *SelectNode = PM.upTo(N, nixf::Node::NK_ExprSelect); + if (!SelectNode) + return; + + const auto &Sel = static_cast(*SelectNode); + + // Check base expression is ExprVar with name "lib" + if (Sel.expr().kind() != nixf::Node::NK_ExprVar) + return; + + const auto &Var = static_cast(Sel.expr()); + if (Var.id().name() != "lib") + return; + + // Check path exists and has at least one attribute + if (!Sel.path()) + return; + + const nixf::AttrPath &Path = *Sel.path(); + if (Path.names().empty()) + return; + + // Build the function path, checking all names are static + std::vector FunctionPath; + FunctionPath.reserve(Path.names().size() + 1); + FunctionPath.emplace_back("lib"); + + for (const auto &Name : Path.names()) { + if (!Name->isStatic()) + return; // Dynamic attribute, can't construct URL + FunctionPath.emplace_back(Name->staticName()); + } + + // Construct the noogle.dev URL + std::string NoogleUrl = buildNoogleUrl(FunctionPath); + + // Create a code action that will open the URL + // Note: The actual URL opening is handled by the client via + // window/showDocument + Actions.emplace_back(lspserver::CodeAction{ + .title = "Open Noogle documentation for " + FunctionPath.back(), + .kind = std::string(lspserver::CodeAction::REFACTOR_KIND), + .data = llvm::json::Object{{"noogleUrl", NoogleUrl}}, + }); +} + +} // namespace nixd diff --git a/nixd/lib/Controller/CodeActions/NoogleDoc.h b/nixd/lib/Controller/CodeActions/NoogleDoc.h new file mode 100644 index 000000000..02df73ed7 --- /dev/null +++ b/nixd/lib/Controller/CodeActions/NoogleDoc.h @@ -0,0 +1,40 @@ +/// \file +/// \brief Code action for opening noogle.dev documentation. +/// +/// Provides a code action to open noogle.dev documentation for lib.* functions. + +#pragma once + +#include + +#include + +#include +#include + +namespace nixf { +class Node; +} // namespace nixf + +namespace nixd { + +/// \brief Add a code action to open noogle.dev documentation for lib.* +/// functions. +/// +/// This action is offered when the cursor is on an ExprSelect with: +/// - Base expression is ExprVar with name "lib" +/// - Path contains at least one static attribute name +/// +/// Examples that trigger: +/// - lib.optionalString +/// - lib.strings.optionalString +/// - lib.attrsets.mapAttrs +/// +/// Examples that do NOT trigger: +/// - lib (just the variable, no selection) +/// - lib.${x} (dynamic attribute) +/// - pkgs.hello (not lib.*) +void addNoogleDocAction(const nixf::Node &N, const nixf::ParentMapAnalysis &PM, + std::vector &Actions); + +} // namespace nixd diff --git a/nixd/lib/Controller/CodeActions/PackAttrs.cpp b/nixd/lib/Controller/CodeActions/PackAttrs.cpp new file mode 100644 index 000000000..2aec25fe1 --- /dev/null +++ b/nixd/lib/Controller/CodeActions/PackAttrs.cpp @@ -0,0 +1,323 @@ +/// \file +/// \brief Implementation of pack dotted paths code action. + +#include "PackAttrs.h" +#include "Utils.h" + +#include "../Convert.h" + +#include + +#include + +namespace nixd { + +namespace { + +/// \brief Maximum recursion depth for nested text generation. +/// Prevents stack overflow on maliciously crafted deeply nested inputs. +constexpr size_t MaxNestedDepth = 100; + +/// \brief Get the number of sibling bindings sharing the same first path +/// segment. Uses SemaAttrs to count nested attributes for the first segment. +/// Returns 0 if the segment is not found or has conflicts (non-path binding). +size_t getSiblingCount(const nixf::Binding &Bind, + const nixf::ExprAttrs &ParentAttrs) { + const auto &Names = Bind.path().names(); + if (Names.empty() || !Names[0]->isStatic()) + return 0; + + const std::string &FirstSeg = Names[0]->staticName(); + const nixf::SemaAttrs &SA = ParentAttrs.sema(); + + auto It = SA.staticAttrs().find(FirstSeg); + if (It == SA.staticAttrs().end()) + return 0; + + const nixf::Attribute &Attr = It->second; + + // Check if value is a nested ExprAttrs (path was desugared) + if (!Attr.value() || Attr.value()->kind() != nixf::Node::NK_ExprAttrs) + return 0; // Non-ExprAttrs value = conflict with non-path binding + + const auto &NestedAttrs = static_cast(*Attr.value()); + const nixf::SemaAttrs &NestedSA = NestedAttrs.sema(); + + // Return 0 if there are dynamic attrs (can't safely pack) + if (!NestedSA.dynamicAttrs().empty()) + return 0; + + return NestedSA.staticAttrs().size(); +} + +/// \brief Recursively generate nested attribute set text from SemaAttrs. +/// This produces the fully packed/nested form of attributes. +/// \param Depth Current recursion depth (for safety limit) +void generateNestedText(const nixf::SemaAttrs &SA, llvm::StringRef Src, + std::string &Out, size_t Depth = 0) { + // Safety check: prevent stack overflow from deeply nested structures + if (Depth > MaxNestedDepth) { + Out += "{ /* max depth exceeded */ }"; + return; + } + + Out += "{ "; + bool First = true; + for (const auto &[Key, Attr] : SA.staticAttrs()) { + if (!First) + Out += " "; + First = false; + + // Output the key, quoting if necessary + Out += quoteNixAttrKey(Key); + Out += " = "; + + // Check if value is a nested ExprAttrs that needs recursive generation + if (Attr.value() && Attr.value()->kind() == nixf::Node::NK_ExprAttrs) { + const auto &NestedAttrs = + static_cast(*Attr.value()); + const nixf::SemaAttrs &NestedSA = NestedAttrs.sema(); + + // If all nested attrs come from dotted paths (no inherit, no dynamic), + // we can generate recursively + if (NestedSA.dynamicAttrs().empty() && !Attr.fromInherit()) { + generateNestedText(NestedSA, Src, Out, Depth + 1); + } else { + // Use original source text + Out += Attr.value()->src(Src); + } + } else if (Attr.value()) { + Out += Attr.value()->src(Src); + } + Out += ";"; + } + Out += " }"; +} + +/// \brief Generate shallow nested attribute set text from original bindings. +/// Unlike generateNestedText, this only expands one level and preserves +/// remaining dotted paths as-is by extracting from original source. +/// \param Binds The original Binds node containing all bindings +/// \param FirstSeg The first path segment to match (e.g., "foo" for "foo.bar") +/// \param Src The source text +/// \param Out Output string to append to +void generateShallowNestedText(const nixf::Binds &Binds, + const std::string &FirstSeg, llvm::StringRef Src, + std::string &Out) { + Out += "{ "; + bool First = true; + + for (const auto &Child : Binds.bindings()) { + if (Child->kind() != nixf::Node::NK_Binding) + continue; + + const auto &SibBind = static_cast(*Child); + const auto &Names = SibBind.path().names(); + + // Only process bindings that match the first segment + if (Names.empty() || !Names[0]->isStatic() || + Names[0]->staticName() != FirstSeg) + continue; + + if (!First) + Out += " "; + First = false; + + if (Names.size() == 1) { + // Single segment path (e.g., just "foo") - shouldn't happen in bulk pack + // but handle it gracefully by using value directly + Out += quoteNixAttrKey(Names[0]->staticName()); + Out += " = "; + if (SibBind.value()) { + Out += SibBind.value()->src(Src); + } + } else { + // Multi-segment path - extract rest of path from source + // e.g., "foo.bar.x = 1" -> "bar.x = 1" + const nixf::LexerCursor RestStart = Names[1]->range().lCur(); + const nixf::LexerCursor PathEnd = SibBind.path().range().rCur(); + + if (PathEnd.offset() >= RestStart.offset()) { + std::string_view RestPath = Src.substr( + RestStart.offset(), PathEnd.offset() - RestStart.offset()); + Out += RestPath; + Out += " = "; + if (SibBind.value()) { + Out += SibBind.value()->src(Src); + } + } + } + Out += ";"; + } + Out += " }"; +} + +/// \brief Find all sibling bindings that share the same first path segment. +/// Returns the range covering all such bindings, or nullopt if not applicable. +std::optional +findSiblingBindingsRange(const nixf::Binding &Bind, const nixf::Binds &Binds, + const std::string &FirstSeg) { + nixf::LexerCursor Start = Bind.range().lCur(); + nixf::LexerCursor End = Bind.range().rCur(); + + for (const auto &Sibling : Binds.bindings()) { + if (Sibling->kind() != nixf::Node::NK_Binding) + continue; + + const auto &SibBind = static_cast(*Sibling); + const auto &SibNames = SibBind.path().names(); + + if (SibNames.empty() || !SibNames[0]->isStatic()) + continue; + + if (SibNames[0]->staticName() == FirstSeg) { + // Expand range to include this sibling + if (SibBind.range().lCur().offset() < Start.offset()) + Start = SibBind.range().lCur(); + if (SibBind.range().rCur().offset() > End.offset()) + End = SibBind.range().rCur(); + } + } + + return nixf::LexerCursorRange{Start, End}; +} + +} // namespace + +void addPackAttrsAction(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); + const auto &Names = Bind.path().names(); + + // Must have at least 2 path segments (e.g., foo.bar) + if (Names.size() < 2) + return; + + // All path segments must be static + for (const auto &Name : Names) { + if (!Name->isStatic()) + return; + } + + // Check parent ExprAttrs is not recursive + const nixf::Node *BindsNode = PM.query(Bind); + if (!BindsNode || BindsNode->kind() != nixf::Node::NK_Binds) + return; + + const nixf::Node *AttrsNode = PM.query(*BindsNode); + if (!AttrsNode || AttrsNode->kind() != nixf::Node::NK_ExprAttrs) + return; + + const auto &ParentAttrs = static_cast(*AttrsNode); + if (ParentAttrs.isRecursive()) + return; + + const std::string &FirstSeg = Names[0]->staticName(); + size_t SiblingCount = getSiblingCount(Bind, ParentAttrs); + + if (SiblingCount == 0) + return; // Can't pack (dynamic attrs or other conflicts) + + // Helper lambda to generate Pack One action text + auto GeneratePackOneText = [&]() -> std::string { + std::string NewText; + const std::string_view FirstName = Names[0]->src(Src); + + size_t ValueSize = Bind.value() ? Bind.value()->src(Src).size() : 0; + NewText.reserve(FirstName.size() + Bind.path().src(Src).size() + ValueSize + + 15); + NewText += FirstName; + NewText += " = { "; + + const nixf::LexerCursor RestStart = Names[1]->range().lCur(); + const nixf::LexerCursor RestEnd = Bind.path().range().rCur(); + + // Safety check: ensure valid range to prevent integer underflow + if (RestEnd.offset() < RestStart.offset()) + return ""; + + std::string_view RestPath = + Src.substr(RestStart.offset(), RestEnd.offset() - RestStart.offset()); + + NewText += RestPath; + NewText += " = "; + + if (Bind.value()) { + NewText += Bind.value()->src(Src); + } + NewText += "; };"; + return NewText; + }; + + if (SiblingCount == 1) { + // Single binding - offer simple pack action + std::string NewText = GeneratePackOneText(); + if (NewText.empty()) + return; + + Actions.emplace_back(createSingleEditAction( + "Pack dotted path to nested set", + lspserver::CodeAction::REFACTOR_REWRITE_KIND, FileURI, + toLSPRange(Src, Bind.range()), std::move(NewText))); + } else { + // Multiple siblings share the prefix - offer Pack One and bulk pack actions + const nixf::SemaAttrs &SA = ParentAttrs.sema(); + auto It = SA.staticAttrs().find(FirstSeg); + if (It == SA.staticAttrs().end()) + return; + + const nixf::Attribute &Attr = It->second; + if (!Attr.value() || Attr.value()->kind() != nixf::Node::NK_ExprAttrs) + return; + + const auto &NestedAttrs = + static_cast(*Attr.value()); + + // Find the range covering all sibling bindings (needed for bulk actions) + const auto &ParentBinds = static_cast(*BindsNode); + auto BulkRange = findSiblingBindingsRange(Bind, ParentBinds, FirstSeg); + if (!BulkRange) + return; + + // Action 1: Pack One - pack only the current binding + std::string PackOneText = GeneratePackOneText(); + if (!PackOneText.empty()) { + Actions.emplace_back(createSingleEditAction( + "Pack dotted path to nested set", + lspserver::CodeAction::REFACTOR_REWRITE_KIND, FileURI, + toLSPRange(Src, Bind.range()), std::move(PackOneText))); + } + + // Action 2: Shallow Pack All - pack all siblings but only one level deep + std::string ShallowText; + ShallowText += quoteNixAttrKey(FirstSeg); + ShallowText += " = "; + generateShallowNestedText(ParentBinds, FirstSeg, Src, ShallowText); + ShallowText += ";"; + + Actions.emplace_back(createSingleEditAction( + "Pack all '" + FirstSeg + "' bindings to nested set", + lspserver::CodeAction::REFACTOR_REWRITE_KIND, FileURI, + toLSPRange(Src, *BulkRange), std::move(ShallowText))); + + // Action 3: Recursive Pack All - fully nest all sibling bindings + std::string RecursiveText; + RecursiveText += quoteNixAttrKey(FirstSeg); + RecursiveText += " = "; + generateNestedText(NestedAttrs.sema(), Src, RecursiveText); + RecursiveText += ";"; + + Actions.emplace_back(createSingleEditAction( + "Recursively pack all '" + FirstSeg + "' bindings to nested set", + lspserver::CodeAction::REFACTOR_REWRITE_KIND, FileURI, + toLSPRange(Src, *BulkRange), std::move(RecursiveText))); + } +} + +} // namespace nixd diff --git a/nixd/lib/Controller/CodeActions/PackAttrs.h b/nixd/lib/Controller/CodeActions/PackAttrs.h new file mode 100644 index 000000000..59891c3b3 --- /dev/null +++ b/nixd/lib/Controller/CodeActions/PackAttrs.h @@ -0,0 +1,36 @@ +/// \file +/// \brief Code action for packing dotted attribute paths into nested sets. +/// +/// Transforms: { foo.bar = 1; } -> { foo = { bar = 1; }; } +/// Also offers bulk pack when siblings share a prefix: +/// { foo.bar = 1; foo.baz = 2; } -> { foo = { bar = 1; baz = 2; }; } + +#pragma once + +#include + +#include + +#include + +#include +#include + +namespace nixf { +class Node; +} // namespace nixf + +namespace nixd { + +/// \brief Add pack action for dotted attribute paths. +/// +/// This action is offered when the cursor is on a binding with a dotted path +/// (e.g., foo.bar = 1). It offers three variants: +/// - Pack One: pack only the current binding +/// - Shallow Pack All: pack all siblings sharing the same first segment +/// - Recursive Pack All: fully nest all siblings +void addPackAttrsAction(const nixf::Node &N, const nixf::ParentMapAnalysis &PM, + const std::string &FileURI, llvm::StringRef Src, + std::vector &Actions); + +} // namespace nixd diff --git a/nixd/lib/Controller/CodeActions/Utils.cpp b/nixd/lib/Controller/CodeActions/Utils.cpp new file mode 100644 index 000000000..4f9346f00 --- /dev/null +++ b/nixd/lib/Controller/CodeActions/Utils.cpp @@ -0,0 +1,96 @@ +/// \file +/// \brief Implementation of shared utilities for code actions. + +#include "Utils.h" + +#include + +namespace nixd { + +lspserver::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( + lspserver::TextEdit{.range = EditRange, .newText = std::move(NewText)}); + using Changes = std::map>; + lspserver::WorkspaceEdit WE{.changes = Changes{{FileURI, std::move(Edits)}}}; + return lspserver::CodeAction{ + .title = Title, + .kind = std::string(Kind), + .edit = std::move(WE), + }; +} + +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(); +} + +std::string escapeNixString(llvm::StringRef S) { + std::string Result; + Result.reserve(S.size() + S.size() / 4 + 2); + for (size_t I = 0; I < S.size(); ++I) { + char C = S[I]; + switch (C) { + case '"': + Result += "\\\""; + break; + case '\\': + Result += "\\\\"; + break; + case '\n': + Result += "\\n"; + break; + case '\r': + Result += "\\r"; + break; + case '\t': + Result += "\\t"; + break; + case '$': + // Only escape ${ to prevent interpolation + if (I + 1 < S.size() && S[I + 1] == '{') { + Result += "\\${"; + ++I; // Skip the '{' + } else { + Result += C; + } + break; + default: + Result += C; + } + } + return Result; +} + +std::string quoteNixAttrKey(const std::string &Key) { + if (isValidNixIdentifier(Key)) + return Key; + + return "\"" + escapeNixString(Key) + "\""; +} + +} // namespace nixd diff --git a/nixd/lib/Controller/CodeActions/Utils.h b/nixd/lib/Controller/CodeActions/Utils.h new file mode 100644 index 000000000..18c1c61ef --- /dev/null +++ b/nixd/lib/Controller/CodeActions/Utils.h @@ -0,0 +1,49 @@ +/// \file +/// \brief Shared utilities for code actions. +/// +/// This header provides common functions used across multiple code action +/// implementations, including text manipulation, Nix identifier validation, +/// and CodeAction construction helpers. + +#pragma once + +#include + +#include + +#include +#include + +namespace nixd { + +/// \brief Create a CodeAction with a single text edit. +lspserver::CodeAction createSingleEditAction(const std::string &Title, + llvm::StringLiteral Kind, + const std::string &FileURI, + const lspserver::Range &EditRange, + std::string NewText); + +/// \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); + +/// \brief Escape special characters for Nix double-quoted string literals. +/// +/// Escapes: " \ ${ \n \r \t (per Nix Reference Manual) +std::string escapeNixString(llvm::StringRef S); + +/// \brief Quote and escape a Nix attribute key if necessary. +/// +/// Returns the key as-is if it's a valid identifier, otherwise quotes and +/// escapes special characters using escapeNixString(). +std::string quoteNixAttrKey(const std::string &Key); + +/// \brief Maximum recursion depth for JSON to Nix conversion. +constexpr size_t MaxJsonDepth = 100; + +/// \brief Maximum array/object width for JSON to Nix conversion. +constexpr size_t MaxJsonWidth = 10000; + +} // namespace nixd diff --git a/nixd/lib/meson.build b/nixd/lib/meson.build index 8f10b0d5c..1d291284d 100644 --- a/nixd/lib/meson.build +++ b/nixd/lib/meson.build @@ -8,6 +8,12 @@ libnixd_lib = library( 'CommandLine/Options.cpp', 'Controller/AST.cpp', 'Controller/CodeAction.cpp', + 'Controller/CodeActions/AttrName.cpp', + 'Controller/CodeActions/FlattenAttrs.cpp', + 'Controller/CodeActions/JsonToNix.cpp', + 'Controller/CodeActions/NoogleDoc.cpp', + 'Controller/CodeActions/PackAttrs.cpp', + 'Controller/CodeActions/Utils.cpp', 'Controller/Completion.cpp', 'Controller/Configuration.cpp', 'Controller/Convert.cpp', diff --git a/nixd/tools/nixd/test/code-action/keywords-comprehensive.md b/nixd/tools/nixd/test/code-action/attr-name/keywords-comprehensive.md similarity index 100% rename from nixd/tools/nixd/test/code-action/keywords-comprehensive.md rename to nixd/tools/nixd/test/code-action/attr-name/keywords-comprehensive.md diff --git a/nixd/tools/nixd/test/code-action/quote-attr.md b/nixd/tools/nixd/test/code-action/attr-name/quote-attr.md similarity index 100% rename from nixd/tools/nixd/test/code-action/quote-attr.md rename to nixd/tools/nixd/test/code-action/attr-name/quote-attr.md diff --git a/nixd/tools/nixd/test/code-action/quote-let.md b/nixd/tools/nixd/test/code-action/attr-name/quote-let.md similarity index 100% rename from nixd/tools/nixd/test/code-action/quote-let.md rename to nixd/tools/nixd/test/code-action/attr-name/quote-let.md diff --git a/nixd/tools/nixd/test/code-action/unquote-attr.md b/nixd/tools/nixd/test/code-action/attr-name/unquote-attr.md similarity index 100% rename from nixd/tools/nixd/test/code-action/unquote-attr.md rename to nixd/tools/nixd/test/code-action/attr-name/unquote-attr.md diff --git a/nixd/tools/nixd/test/code-action/unquote-empty.md b/nixd/tools/nixd/test/code-action/attr-name/unquote-empty.md similarity index 100% rename from nixd/tools/nixd/test/code-action/unquote-empty.md rename to nixd/tools/nixd/test/code-action/attr-name/unquote-empty.md diff --git a/nixd/tools/nixd/test/code-action/unquote-hyphen.md b/nixd/tools/nixd/test/code-action/attr-name/unquote-hyphen.md similarity index 100% rename from nixd/tools/nixd/test/code-action/unquote-hyphen.md rename to nixd/tools/nixd/test/code-action/attr-name/unquote-hyphen.md diff --git a/nixd/tools/nixd/test/code-action/unquote-invalid-chars.md b/nixd/tools/nixd/test/code-action/attr-name/unquote-invalid-chars.md similarity index 100% rename from nixd/tools/nixd/test/code-action/unquote-invalid-chars.md rename to nixd/tools/nixd/test/code-action/attr-name/unquote-invalid-chars.md diff --git a/nixd/tools/nixd/test/code-action/unquote-invalid.md b/nixd/tools/nixd/test/code-action/attr-name/unquote-invalid.md similarity index 100% rename from nixd/tools/nixd/test/code-action/unquote-invalid.md rename to nixd/tools/nixd/test/code-action/attr-name/unquote-invalid.md diff --git a/nixd/tools/nixd/test/code-action/unquote-keyword.md b/nixd/tools/nixd/test/code-action/attr-name/unquote-keyword.md similarity index 100% rename from nixd/tools/nixd/test/code-action/unquote-keyword.md rename to nixd/tools/nixd/test/code-action/attr-name/unquote-keyword.md diff --git a/nixd/tools/nixd/test/code-action/unquote-let-invalid.md b/nixd/tools/nixd/test/code-action/attr-name/unquote-let-invalid.md similarity index 100% rename from nixd/tools/nixd/test/code-action/unquote-let-invalid.md rename to nixd/tools/nixd/test/code-action/attr-name/unquote-let-invalid.md diff --git a/nixd/tools/nixd/test/code-action/unquote-let-keyword.md b/nixd/tools/nixd/test/code-action/attr-name/unquote-let-keyword.md similarity index 100% rename from nixd/tools/nixd/test/code-action/unquote-let-keyword.md rename to nixd/tools/nixd/test/code-action/attr-name/unquote-let-keyword.md diff --git a/nixd/tools/nixd/test/code-action/unquote-let.md b/nixd/tools/nixd/test/code-action/attr-name/unquote-let.md similarity index 100% rename from nixd/tools/nixd/test/code-action/unquote-let.md rename to nixd/tools/nixd/test/code-action/attr-name/unquote-let.md diff --git a/nixd/tools/nixd/test/code-action/unquote-quote-char.md b/nixd/tools/nixd/test/code-action/attr-name/unquote-quote-char.md similarity index 100% rename from nixd/tools/nixd/test/code-action/unquote-quote-char.md rename to nixd/tools/nixd/test/code-action/attr-name/unquote-quote-char.md diff --git a/nixd/tools/nixd/test/code-action/unquote-underscore.md b/nixd/tools/nixd/test/code-action/attr-name/unquote-underscore.md similarity index 100% rename from nixd/tools/nixd/test/code-action/unquote-underscore.md rename to nixd/tools/nixd/test/code-action/attr-name/unquote-underscore.md diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs-deep.md b/nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs-deep.md similarity index 100% rename from nixd/tools/nixd/test/code-action/flatten-attrs-deep.md rename to nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs-deep.md diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs-dynamic.md b/nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs-dynamic.md similarity index 100% rename from nixd/tools/nixd/test/code-action/flatten-attrs-dynamic.md rename to nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs-dynamic.md diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs-empty.md b/nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs-empty.md similarity index 100% rename from nixd/tools/nixd/test/code-action/flatten-attrs-empty.md rename to nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs-empty.md diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs-inherit.md b/nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs-inherit.md similarity index 100% rename from nixd/tools/nixd/test/code-action/flatten-attrs-inherit.md rename to nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs-inherit.md diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs-multi.md b/nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs-multi.md similarity index 100% rename from nixd/tools/nixd/test/code-action/flatten-attrs-multi.md rename to nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs-multi.md diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs-rec.md b/nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs-rec.md similarity index 100% rename from nixd/tools/nixd/test/code-action/flatten-attrs-rec.md rename to nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs-rec.md diff --git a/nixd/tools/nixd/test/code-action/flatten-attrs.md b/nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs.md similarity index 100% rename from nixd/tools/nixd/test/code-action/flatten-attrs.md rename to nixd/tools/nixd/test/code-action/flatten-attrs/flatten-attrs.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix-array.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-array.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix-array.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-array.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix-depth-limit.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-depth-limit.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix-depth-limit.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-depth-limit.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix-empty-array.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-empty-array.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix-empty-array.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-empty-array.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix-empty.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-empty.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix-empty.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-empty.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix-escaping.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-escaping.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix-escaping.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-escaping.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix-interpolation.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-interpolation.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix-interpolation.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-interpolation.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix-invalid.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-invalid.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix-invalid.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-invalid.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix-nested.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-nested.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix-nested.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-nested.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix-numeric.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-numeric.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix-numeric.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-numeric.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix-special-keys.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-special-keys.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix-special-keys.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-special-keys.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix-types.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-types.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix-types.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-types.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix-width-limit.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-width-limit.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix-width-limit.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix-width-limit.md diff --git a/nixd/tools/nixd/test/code-action/json-to-nix.md b/nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix.md similarity index 100% rename from nixd/tools/nixd/test/code-action/json-to-nix.md rename to nixd/tools/nixd/test/code-action/json-to-nix/json-to-nix.md diff --git a/nixd/tools/nixd/test/code-action/noogle-lib-nested.md b/nixd/tools/nixd/test/code-action/noogle-doc/noogle-lib-nested.md similarity index 100% rename from nixd/tools/nixd/test/code-action/noogle-lib-nested.md rename to nixd/tools/nixd/test/code-action/noogle-doc/noogle-lib-nested.md diff --git a/nixd/tools/nixd/test/code-action/noogle-lib-simple.md b/nixd/tools/nixd/test/code-action/noogle-doc/noogle-lib-simple.md similarity index 100% rename from nixd/tools/nixd/test/code-action/noogle-lib-simple.md rename to nixd/tools/nixd/test/code-action/noogle-doc/noogle-lib-simple.md diff --git a/nixd/tools/nixd/test/code-action/noogle-lib-var-only.md b/nixd/tools/nixd/test/code-action/noogle-doc/noogle-lib-var-only.md similarity index 100% rename from nixd/tools/nixd/test/code-action/noogle-lib-var-only.md rename to nixd/tools/nixd/test/code-action/noogle-doc/noogle-lib-var-only.md diff --git a/nixd/tools/nixd/test/code-action/noogle-not-lib.md b/nixd/tools/nixd/test/code-action/noogle-doc/noogle-not-lib.md similarity index 100% rename from nixd/tools/nixd/test/code-action/noogle-not-lib.md rename to nixd/tools/nixd/test/code-action/noogle-doc/noogle-not-lib.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-bulk-multiline.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-bulk-multiline.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-bulk-multiline.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-bulk-multiline.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-bulk-non-contiguous.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-bulk-non-contiguous.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-bulk-non-contiguous.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-bulk-non-contiguous.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-bulk-quoted.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-bulk-quoted.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-bulk-quoted.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-bulk-quoted.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-conflict.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-conflict.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-conflict.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-conflict.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-deep.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-deep.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-deep.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-deep.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-dynamic-first.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-dynamic-first.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-dynamic-first.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-dynamic-first.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-dynamic.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-dynamic.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-dynamic.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-dynamic.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-escape.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-escape.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-escape.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-escape.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-inherit-conflict.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-inherit-conflict.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-inherit-conflict.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-inherit-conflict.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-let.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-let.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-let.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-let.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-multi-actions.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-multi-actions.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-multi-actions.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-multi-actions.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-multiple-prefixes-foo.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-multiple-prefixes-foo.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-multiple-prefixes-foo.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-multiple-prefixes-foo.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-quoted-inner.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-quoted-inner.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-quoted-inner.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-quoted-inner.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-quoted.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-quoted.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-quoted.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-quoted.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-rec.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-rec.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-rec.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-rec.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-recursive-bulk.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-recursive-bulk.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-recursive-bulk.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-recursive-bulk.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-shallow-bulk.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-shallow-bulk.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-shallow-bulk.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-shallow-bulk.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-sibling-different-prefix.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-sibling-different-prefix.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-sibling-different-prefix.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-sibling-different-prefix.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-sibling-inherit.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-sibling-inherit.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-sibling-inherit.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-sibling-inherit.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-single.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-single.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-single.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-single.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-values-complex.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-values-complex.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-values-complex.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-values-complex.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-values-empty.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-values-empty.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-values-empty.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-values-empty.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs-values-nested.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-values-nested.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs-values-nested.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs-values-nested.md diff --git a/nixd/tools/nixd/test/code-action/pack-attrs.md b/nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs.md similarity index 100% rename from nixd/tools/nixd/test/code-action/pack-attrs.md rename to nixd/tools/nixd/test/code-action/pack-attrs/pack-attrs.md diff --git a/nixd/tools/nixd/test/code-action/quick-fix.md b/nixd/tools/nixd/test/code-action/quick-fix/quick-fix.md similarity index 100% rename from nixd/tools/nixd/test/code-action/quick-fix.md rename to nixd/tools/nixd/test/code-action/quick-fix/quick-fix.md