diff --git a/libnixf/src/Sema/VariableLookup.cpp b/libnixf/src/Sema/VariableLookup.cpp index 814167369..31ec2e4b2 100644 --- a/libnixf/src/Sema/VariableLookup.cpp +++ b/libnixf/src/Sema/VariableLookup.cpp @@ -4,6 +4,7 @@ #include "nixf/Basic/Nodes/Expr.h" #include "nixf/Basic/Nodes/Lambda.h" #include "nixf/Sema/PrimOpInfo.h" +#include "nixf/Sema/SemaActions.h" #include @@ -177,15 +178,63 @@ void VariableLookupAnalysis::lookupVar(const ExprVar &Var, {&Var, LookupResult{LookupResultKind::Undefined, nullptr}}); break; } - case PrimopLookupResult::NotFound: + case PrimopLookupResult::NotFound: { // Otherwise, this variable is undefined. Results.insert( {&Var, LookupResult{LookupResultKind::Undefined, nullptr}}); Diagnostic &Diag = Diags.emplace_back(Diagnostic::DK_UndefinedVariable, Var.range()); Diag << Var.id().name(); + + // Try to find the nearest enclosing lambda with formals to offer a fix + for (const auto *E = Env.get(); E; E = E->parent()) { + if (!E->syntax()) + continue; + if (E->syntax()->kind() != Node::NK_ExprLambda) + continue; + + const auto &Lambda = static_cast(*E->syntax()); + if (!Lambda.arg()) + continue; + + const auto *Formals = Lambda.arg()->formals(); + if (!Formals) + continue; + + // Found a lambda with formals - add a fix to insert this variable + const auto &FV = Formals->members(); + if (FV.empty()) { + // Empty formals like { }: - insert after opening brace + Diag.fix("add `" + Name + "` to formals") + .edit(TextEdit::mkInsertion(Formals->range().lCur(), Name)); + } else { + // Has existing formals - insert after the last one with comma + const auto &LastFormal = FV.back(); + // If last formal is ellipsis, insert before it + if (LastFormal->isEllipsis()) { + if (FV.size() > 1) { + // Insert before ellipsis: { a, ... } -> { a, x, ... } + const auto &BeforeEllipsis = FV[FV.size() - 2]; + Diag.fix("add `" + Name + "` to formals") + .edit(TextEdit::mkInsertion(BeforeEllipsis->rCur(), + ", " + Name)); + } else { + // Only ellipsis: { ... } -> { x, ... } + Diag.fix("add `" + Name + "` to formals") + .edit(TextEdit::mkInsertion(LastFormal->range().lCur(), + Name + ", ")); + } + } else { + // Normal case: insert after last formal + Diag.fix("add `" + Name + "` to formals") + .edit(TextEdit::mkInsertion(LastFormal->rCur(), ", " + Name)); + } + } + break; // Only offer fix for nearest enclosing lambda + } break; } + } } } @@ -252,7 +301,38 @@ void VariableLookupAnalysis::dfs(const ExprLambda &Lambda, dfs(*Lambda.body(), NewEnv); + // Store the number of diagnostics before emitEnvLivenessWarning + const std::size_t DiagsBefore = Diags.size(); + emitEnvLivenessWarning(NewEnv); + + // Add fixes for unused lambda formals that were just diagnosed + if (Arg.formals()) { + const Formals::FormalVector &FV = Arg.formals()->members(); + + // Iterate through diagnostics that were just added + for (std::size_t i = DiagsBefore; i < Diags.size(); ++i) { + auto &D = Diags[i]; + + // Only process unused formal diagnostics + if (D.kind() != Diagnostic::DK_UnusedDefLambdaNoArg_Formal && + D.kind() != Diagnostic::DK_UnusedDefLambdaWithArg_Formal) { + continue; + } + + // Find the matching formal + for (auto It = FV.begin(); It != FV.end(); ++It) { + const auto &Formal = *It; + if (D.range().lCur() == Formal->id()->range().lCur() && + D.range().rCur() == Formal->id()->range().rCur()) { + // Add a fix to remove this formal + Fix &F = D.fix("remove unused formal"); + Sema::removeFormal(F, It, FV); + break; + } + } + } + } } void VariableLookupAnalysis::dfsDynamicAttrs( @@ -348,7 +428,52 @@ void VariableLookupAnalysis::dfs(const ExprLet &Let, if (Let.expr()) dfs(*Let.expr(), LetEnv); + + // Store the number of diagnostics before emitEnvLivenessWarning + const std::size_t DiagsBefore = Diags.size(); + emitEnvLivenessWarning(LetEnv); + + // Add fixes for unused let bindings that were just diagnosed + if (Let.binds()) { + const auto &Bindings = Let.binds()->bindings(); + + // Iterate through diagnostics that were just added + for (std::size_t i = DiagsBefore; i < Diags.size(); ++i) { + auto &D = Diags[i]; + + // Only process unused let definition diagnostics + if (D.kind() != Diagnostic::DK_UnusedDefLet) { + continue; + } + + // Find the matching binding by checking if the diagnostic range + // is contained within the binding's range + for (const auto &BindNode : Bindings) { + if (BindNode->kind() != Node::NK_Binding) + continue; + + const auto &Bind = static_cast(*BindNode); + // The diagnostic is on the attrname, which is inside the binding + // Check if the diagnostic range matches the first attrname in the path + const auto &PathNames = Bind.path().names(); + if (PathNames.empty()) + continue; + + const auto &FirstName = PathNames[0]; + if (!FirstName) + continue; + + if (D.range().lCur() == FirstName->range().lCur() && + D.range().rCur() == FirstName->range().rCur()) { + // Add a fix to remove this binding + D.fix("remove unused binding") + .edit(TextEdit::mkRemoval(Bind.range())); + break; + } + } + } + } } void VariableLookupAnalysis::trivialDispatch( diff --git a/nixd/lib/Controller/CodeAction.cpp b/nixd/lib/Controller/CodeAction.cpp index ef4baa75c..00312a1d9 100644 --- a/nixd/lib/Controller/CodeAction.cpp +++ b/nixd/lib/Controller/CodeAction.cpp @@ -3,46 +3,1075 @@ /// [Code Action]: /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_codeAction +#include "AST.h" #include "CheckReturn.h" #include "Convert.h" #include "nixd/Controller/Controller.h" +#include +#include +#include +#include +#include + #include +#include +#include + +#include +#include +#include namespace nixd { using namespace llvm::json; using namespace lspserver; +namespace { + +/// \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 + static const std::set Keywords = { + "if", "then", "else", "let", "in", + "with", "rec", "inherit", "assert", "or"}; + return Keywords.find(S) == Keywords.end(); +} + +/// \brief Percent-encode a string for use in URL path segments. +/// Encodes all characters except unreserved characters (RFC 3986): +/// ALPHA / DIGIT / "-" / "." / "_" / "~" +std::string percentEncode(const std::string &S) { + std::ostringstream Encoded; + Encoded << std::hex << std::uppercase; + + for (unsigned char C : S) { + // Unreserved characters (RFC 3986 section 2.3) + if ((C >= 'A' && C <= 'Z') || (C >= 'a' && C <= 'z') || + (C >= '0' && C <= '9') || C == '-' || C == '.' || C == '_' || + C == '~') { + Encoded << C; + } else { + // Percent-encode everything else + Encoded << '%' << std::setw(2) << std::setfill('0') + << static_cast(C); + } + } + + return Encoded.str(); +} + +/// \brief Create a WorkspaceEdit with a single file's text edits. +/// This helper reduces boilerplate when creating code actions. +WorkspaceEdit createWorkspaceEdit(const std::string &FileURI, + std::vector Edits) { + using Changes = std::map>; + return WorkspaceEdit{.changes = Changes{{FileURI, std::move(Edits)}}}; +} + +/// \brief Create a single TextEdit for a range replacement. +lspserver::TextEdit createTextEdit(llvm::StringRef Src, + const nixf::LexerCursorRange &Range, + std::string NewText) { + return lspserver::TextEdit{ + .range = toLSPRange(Src, Range), + .newText = std::move(NewText), + }; +} + +/// \brief Add refactoring code actions for attribute names (quote/unquote). +void addAttrNameActions(const nixf::Node &N, const nixf::ParentMapAnalysis &PM, + const URIForFile &URI, llvm::StringRef Src, + std::vector &Actions) { + // Find if we're inside an AttrName + const nixf::Node *AttrNameNode = PM.upTo(N, nixf::Node::NK_AttrName); + if (!AttrNameNode) + return; + + // Only offer quote/unquote for attribute sets, not for let bindings. + // ExprLet internally contains an ExprAttrs for bindings, so we need to + // check if the parent ExprAttrs is directly inside an ExprLet. + const nixf::Node *ExprAttrsNode = + PM.upTo(*AttrNameNode, nixf::Node::NK_ExprAttrs); + if (!ExprAttrsNode) + return; + + // Check if this ExprAttrs is the binds part of an ExprLet + const nixf::Node *AttrsParent = PM.query(*ExprAttrsNode); + if (AttrsParent && AttrsParent->kind() == nixf::Node::NK_ExprLet) + return; + + const auto &AN = static_cast(*AttrNameNode); + + std::string FileURI = URI.uri(); + + if (AN.kind() == nixf::AttrName::ANK_ID) { + // Offer to quote: foo -> "foo" + const std::string &Name = AN.id()->name(); + Actions.emplace_back(CodeAction{ + .title = "Quote attribute name", + .kind = std::string(CodeAction::REFACTOR_REWRITE_KIND), + .edit = createWorkspaceEdit( + FileURI, {createTextEdit(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(CodeAction{ + .title = "Unquote attribute name", + .kind = std::string(CodeAction::REFACTOR_REWRITE_KIND), + .edit = createWorkspaceEdit(FileURI, + {createTextEdit(Src, AN.range(), Name)}), + }); + } + } +} + +/// \brief Extract source text from a node's range. +std::string getSourceText(llvm::StringRef Src, const nixf::Node &N) { + size_t Start = N.range().lCur().offset(); + size_t End = N.range().rCur().offset(); + + // Validate bounds to prevent undefined behavior + if (Start >= Src.size() || End > Src.size() || Start >= End) + return ""; + + return std::string(Src.substr(Start, End - Start)); +} + +/// \brief Check if a binding can be flattened (value is a simple attrset). +/// Returns the nested ExprAttrs if flattenable, nullptr otherwise. +const nixf::ExprAttrs *getFlattenableNestedAttrs(const nixf::Binding &B) { + if (!B.value()) + return nullptr; + + // Value must be an ExprAttrs + const nixf::Expr *Value = B.value().get(); + if (Value->kind() != nixf::Node::NK_ExprAttrs) + return nullptr; + + const auto *NestedAttrs = static_cast(Value); + + // Don't flatten recursive attribute sets + if (NestedAttrs->isRecursive()) + return nullptr; + + // Must have bindings + if (!NestedAttrs->binds()) + return nullptr; + + const auto &Bindings = NestedAttrs->binds()->bindings(); + if (Bindings.empty()) + return nullptr; + + // All bindings must be regular Binding nodes (not Inherit) + for (const auto &BindNode : Bindings) { + if (BindNode->kind() != nixf::Node::NK_Binding) + return nullptr; + + const auto &Bind = static_cast(*BindNode); + // Path must be static (no dynamic attributes) + for (const auto &Name : Bind.path().names()) { + if (!Name->isStatic()) + return nullptr; + } + } + + return NestedAttrs; +} + +/// \brief Add flatten code action for nested attribute sets. +/// Example: { foo = { bar = 1; }; } -> { foo.bar = 1; } +void addFlattenAction(const nixf::Node &N, const nixf::ParentMapAnalysis &PM, + const URIForFile &URI, 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); + + // Outer path must be static (no dynamic attributes) + for (const auto &Name : Bind.path().names()) { + if (!Name->isStatic()) + return; + } + + // Check if binding value is a flattenable nested attrset + const nixf::ExprAttrs *NestedAttrs = getFlattenableNestedAttrs(Bind); + if (!NestedAttrs) + return; + + // Build the flattened bindings + std::ostringstream NewText; + const auto &OuterNames = Bind.path().names(); + const auto &NestedBindings = NestedAttrs->binds()->bindings(); + + for (size_t I = 0; I < NestedBindings.size(); ++I) { + const auto &NestedBind = + static_cast(*NestedBindings[I]); + + // Build the combined path: outer.inner + for (const auto &Name : OuterNames) { + NewText << getSourceText(Src, *Name); + NewText << "."; + } + + // Add the nested path and value + NewText << getSourceText(Src, NestedBind.path()); + NewText << " = "; + if (NestedBind.value()) { + NewText << getSourceText(Src, *NestedBind.value()); + } + NewText << ";"; + + if (I + 1 < NestedBindings.size()) + NewText << " "; + } + + std::string FileURI = URI.uri(); + std::vector Edits; + Edits.emplace_back(lspserver::TextEdit{ + .range = toLSPRange(Src, Bind.range()), + .newText = NewText.str(), + }); + + using Changes = std::map>; + WorkspaceEdit WE{.changes = Changes{{FileURI, std::move(Edits)}}}; + + Actions.emplace_back(CodeAction{ + .title = "Flatten attribute set", + .kind = std::string(CodeAction::REFACTOR_REWRITE_KIND), + .edit = std::move(WE), + }); +} + +/// \brief Add pack code action for dotted attribute paths. +/// Example: { foo.bar = 1; } -> { foo = { bar = 1; }; } +void addPackAction(const nixf::Node &N, const nixf::ParentMapAnalysis &PM, + const URIForFile &URI, 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); + + // Path must have more than one name to be packable + const auto &Names = Bind.path().names(); + if (Names.size() < 2) + return; + + // All names must be static + for (const auto &Name : Names) { + if (!Name->isStatic()) + return; + } + + // Build the packed binding: first_name = { rest.of.path = value; }; + std::ostringstream NewText; + + // First name + NewText << getSourceText(Src, *Names[0]); + NewText << " = { "; + + // Rest of path + for (size_t I = 1; I < Names.size(); ++I) { + NewText << getSourceText(Src, *Names[I]); + if (I + 1 < Names.size()) + NewText << "."; + } + + // Value + NewText << " = "; + if (Bind.value()) { + NewText << getSourceText(Src, *Bind.value()); + } + NewText << "; };"; + + std::string FileURI = URI.uri(); + std::vector Edits; + Edits.emplace_back(lspserver::TextEdit{ + .range = toLSPRange(Src, Bind.range()), + .newText = NewText.str(), + }); + + using Changes = std::map>; + WorkspaceEdit WE{.changes = Changes{{FileURI, std::move(Edits)}}}; + + Actions.emplace_back(CodeAction{ + .title = "Pack attribute set", + .kind = std::string(CodeAction::REFACTOR_REWRITE_KIND), + .edit = std::move(WE), + }); +} + +/// \brief Convert a JSON value to Nix expression syntax. +std::string jsonToNix(const llvm::json::Value &V, int Indent = 0) { + std::ostringstream Out; + std::string IndentStr(Indent * 2, ' '); + std::string NextIndent((Indent + 1) * 2, ' '); + + 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 << *I; + } else if (auto D = V.getAsNumber()) { + Out << *D; + } else if (auto S = V.getAsString()) { + // Escape the string for Nix + Out << "\""; + for (char C : *S) { + switch (C) { + case '\\': + Out << "\\\\"; + break; + case '"': + Out << "\\\""; + break; + case '\n': + Out << "\\n"; + break; + case '\r': + Out << "\\r"; + break; + case '\t': + Out << "\\t"; + break; + case '$': + Out << "\\$"; + break; + default: + Out << C; + } + } + Out << "\""; + } else if (const auto *A = V.getAsArray()) { + if (A->empty()) { + Out << "[ ]"; + } else { + Out << "[\n"; + for (size_t I = 0; I < A->size(); ++I) { + Out << NextIndent << jsonToNix((*A)[I], Indent + 1); + if (I + 1 < A->size()) + Out << "\n"; + } + Out << "\n" << IndentStr << "]"; + } + } else if (const auto *O = V.getAsObject()) { + if (O->empty()) { + Out << "{ }"; + } else { + Out << "{\n"; + size_t I = 0; + for (const auto &KV : *O) { + std::string Key = KV.first.str(); + // Quote the key if it's not a valid identifier + if (isValidNixIdentifier(Key)) { + Out << NextIndent << Key; + } else { + Out << NextIndent << "\"" << Key << "\""; + } + Out << " = " << jsonToNix(KV.second, Indent + 1) << ";"; + if (I + 1 < O->size()) + Out << "\n"; + ++I; + } + Out << "\n" << IndentStr << "}"; + } + } + return Out.str(); +} + +/// \brief Add JSON to Nix conversion code action for selected JSON text. +void addJsonToNixAction(llvm::StringRef Src, lspserver::Range SelectionRange, + const URIForFile &URI, + std::vector &Actions) { + // Use lspserver's positionToOffset for proper UTF-16 handling + llvm::Expected StartOffsetExp = + positionToOffset(Src, SelectionRange.start); + llvm::Expected EndOffsetExp = + positionToOffset(Src, SelectionRange.end); + + if (!StartOffsetExp || !EndOffsetExp) { + if (!StartOffsetExp) + llvm::consumeError(StartOffsetExp.takeError()); + if (!EndOffsetExp) + llvm::consumeError(EndOffsetExp.takeError()); + return; + } + + size_t StartOffset = *StartOffsetExp; + size_t EndOffset = *EndOffsetExp; + + if (StartOffset >= EndOffset) + return; + + std::string SelectedText = + std::string(Src.substr(StartOffset, EndOffset - StartOffset)); + + // Skip if selection is empty or too short + if (SelectedText.empty() || SelectedText.size() < 2) + return; + + // Try to parse as JSON + llvm::Expected Parsed = llvm::json::parse(SelectedText); + if (!Parsed) { + llvm::consumeError(Parsed.takeError()); + return; + } + + // Convert to Nix + std::string NixText = jsonToNix(*Parsed); + + std::string FileURI = URI.uri(); + std::vector Edits; + Edits.emplace_back(lspserver::TextEdit{ + .range = SelectionRange, + .newText = NixText, + }); + + using Changes = std::map>; + WorkspaceEdit WE{.changes = Changes{{FileURI, std::move(Edits)}}}; + + Actions.emplace_back(CodeAction{ + .title = "Convert JSON to Nix", + .kind = std::string(CodeAction::REFACTOR_REWRITE_KIND), + .edit = std::move(WE), + }); +} + +/// \brief Add "Open in noogle.dev" action for lib function references. +/// Example: lib.mapAttrs -> opens https://noogle.dev/f/lib/mapAttrs +void addNoogleAction(const nixf::Node &N, const nixf::ParentMapAnalysis &PM, + const nixf::VariableLookupAnalysis &VLA, + std::vector &Actions) { + // Find if we're inside an ExprSelect (e.g., lib.mapAttrs) + const nixf::Node *SelectNode = PM.upTo(N, nixf::Node::NK_ExprSelect); + if (!SelectNode) + return; + + const auto &Select = static_cast(*SelectNode); + + // Try to build a selector using the idiom system + try { + Selector Sel = idioms::mkSelector(Select, VLA, PM); + + // Check if it starts with "lib" (nixpkgs lib functions) + // Need at least "lib" + function name (e.g., "lib.mapAttrs") + if (Sel.size() < 2 || Sel[0] != idioms::Lib) + return; + + // Build the noogle.dev URL with percent-encoding + // Format: https://noogle.dev/f/lib/ + std::ostringstream Url; + Url << "https://noogle.dev/f/lib"; + for (size_t I = 1; I < Sel.size(); ++I) { + Url << "/" << percentEncode(Sel[I]); + } + + // Build the function name for display + std::ostringstream FuncName; + FuncName << "lib"; + for (size_t I = 1; I < Sel.size(); ++I) { + FuncName << "." << Sel[I]; + } + + // Create a command-based code action + // The editor/client is responsible for executing this command + Command Cmd; + Cmd.command = "nixd.openNoogle"; + Cmd.title = "Open " + FuncName.str() + " in noogle.dev"; + Cmd.argument = Url.str(); + + Actions.emplace_back(CodeAction{ + .title = "Open " + FuncName.str() + " in noogle.dev", + .kind = std::string(CodeAction::INFO_KIND), + .command = std::move(Cmd), + }); + } catch (const idioms::IdiomSelectorException &) { + // Not a recognized idiom, skip + } +} + +/// \brief Add "Extract expression to file" action for selected expressions. +/// Creates a command that the client should handle to: +/// 1. Prompt the user for a file path +/// 2. Create the new file with the expression +/// 3. Replace the selection with an import statement +void addExtractToFileAction(const nixf::Node &N, const URIForFile &URI, + llvm::StringRef Src, + std::vector &Actions) { + // Only offer extract for substantial expression types + // Skip for simple nodes like identifiers, keywords, literals + switch (N.kind()) { + case nixf::Node::NK_ExprAttrs: + case nixf::Node::NK_ExprList: + case nixf::Node::NK_ExprLambda: + case nixf::Node::NK_ExprCall: + case nixf::Node::NK_ExprLet: + case nixf::Node::NK_ExprIf: + case nixf::Node::NK_ExprWith: + case nixf::Node::NK_ExprSelect: + break; + default: + return; + } + + // Get the expression's source text + std::string ExprText = getSourceText(Src, N); + + // Skip if expression is too short + if (ExprText.size() < 5) + return; + + // Create a command for the client to handle + // The command includes the expression text and source file URI + Command Cmd; + Cmd.command = "nixd.extractToFile"; + Cmd.title = "Extract expression to file"; + + // Build a JSON object with the necessary info + // Use toLSPRange for proper UTF-16 offset conversion + lspserver::Range LspRange = toLSPRange(Src, N.range()); + llvm::json::Object Args; + Args["expression"] = ExprText; + Args["uri"] = URI.uri(); + Args["range"] = llvm::json::Object{ + {"start", llvm::json::Object{{"line", LspRange.start.line}, + {"character", LspRange.start.character}}}, + {"end", llvm::json::Object{{"line", LspRange.end.line}, + {"character", LspRange.end.character}}}}; + Cmd.argument = llvm::json::Value(std::move(Args)); + + Actions.emplace_back(CodeAction{ + .title = "Extract expression to file", + .kind = std::string(CodeAction::REFACTOR_EXTRACT_KIND), + .command = std::move(Cmd), + }); +} + +/// \brief Escape a string for double-quoted Nix string literal. +std::string escapeForDoubleQuoted(llvm::StringRef S) { + std::string Result; + Result.reserve(S.size()); + for (char C : S) { + switch (C) { + case '\\': + Result += "\\\\"; + break; + case '"': + Result += "\\\""; + break; + case '\n': + Result += "\\n"; + break; + case '\r': + Result += "\\r"; + break; + case '\t': + Result += "\\t"; + break; + case '$': + Result += "\\$"; + break; + default: + Result += C; + } + } + return Result; +} + +/// \brief Escape a string for indented (multi-line) Nix string literal. +std::string escapeForIndented(llvm::StringRef S) { + std::string Result; + Result.reserve(S.size()); + for (size_t I = 0; I < S.size(); ++I) { + char C = S[I]; + // In indented strings, '' followed by ' needs escaping as ''' + // and ${ needs escaping as ''${ + if (C == '$' && I + 1 < S.size() && S[I + 1] == '{') { + Result += "''${"; + ++I; // Skip the { + } else if (C == '\'' && I + 1 < S.size() && S[I + 1] == '\'') { + Result += "'''"; + ++I; // Skip the second ' + } else { + Result += C; + } + } + return Result; +} + +/// \brief Check if a string starts with '' (indented string literal). +bool isIndentedString(llvm::StringRef Src, const nixf::ExprString &Str) { + size_t Start = Str.range().lCur().offset(); + if (Start + 1 >= Src.size()) + return false; + return Src[Start] == '\'' && Src[Start + 1] == '\''; +} + +/// \brief Add "Rewrite string" action to convert between string styles. +/// Example: "foo\nbar" -> ''foo +/// bar'' +/// Example: ''foo +/// bar'' -> "foo\nbar" +void addRewriteStringAction(const nixf::Node &N, + const nixf::ParentMapAnalysis &PM, + const URIForFile &URI, llvm::StringRef Src, + std::vector &Actions) { + // Find if we're inside an ExprString + const nixf::Node *StringNode = PM.upTo(N, nixf::Node::NK_ExprString); + if (!StringNode) + return; + + const auto &Str = static_cast(*StringNode); + + // Only work with literal strings (no interpolation) + if (!Str.isLiteral()) + return; + + const std::string &Content = Str.literal(); + std::string FileURI = URI.uri(); + std::string NewText; + std::string ActionTitle; + + if (isIndentedString(Src, Str)) { + // Convert indented string to double-quoted + NewText = "\"" + escapeForDoubleQuoted(Content) + "\""; + ActionTitle = "Convert to double-quoted string"; + } else { + // Convert double-quoted string to indented + NewText = "''" + escapeForIndented(Content) + "''"; + ActionTitle = "Convert to indented string"; + } + + std::vector Edits; + Edits.emplace_back(lspserver::TextEdit{ + .range = toLSPRange(Src, Str.range()), + .newText = NewText, + }); + + using Changes = std::map>; + WorkspaceEdit WE{.changes = Changes{{FileURI, std::move(Edits)}}}; + + Actions.emplace_back(CodeAction{ + .title = ActionTitle, + .kind = std::string(CodeAction::REFACTOR_REWRITE_KIND), + .edit = std::move(WE), + }); +} + +/// \brief Add "Convert to inherit" action for simple self-assignments. +/// Example: { x = x; } -> { inherit x; } +/// Example: { a = b.a; } -> { inherit (b) a; } +void addConvertToInheritAction(const nixf::Node &N, + const nixf::ParentMapAnalysis &PM, + const URIForFile &URI, 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); + + // Path must be a single static name + const auto &Names = Bind.path().names(); + if (Names.size() != 1 || !Names[0]->isStatic()) + return; + + const std::string &AttrName = Names[0]->staticName(); + + // Check if value exists + if (!Bind.value()) + return; + + const nixf::Expr *Value = Bind.value().get(); + + std::string FileURI = URI.uri(); + + // Case 1: Simple self-assignment { x = x; } -> { inherit x; } + if (Value->kind() == nixf::Node::NK_ExprVar) { + const auto &Var = static_cast(*Value); + if (Var.id().name() == AttrName) { + std::string NewText = "inherit " + AttrName + ";"; + + std::vector Edits; + Edits.emplace_back(lspserver::TextEdit{ + .range = toLSPRange(Src, Bind.range()), + .newText = NewText, + }); + + using Changes = std::map>; + WorkspaceEdit WE{.changes = Changes{{FileURI, std::move(Edits)}}}; + + Actions.emplace_back(CodeAction{ + .title = "Convert to inherit", + .kind = std::string(CodeAction::REFACTOR_REWRITE_KIND), + .edit = std::move(WE), + }); + } + return; + } + + // Case 2: Select expression { a = b.a; } -> { inherit (b) a; } + if (Value->kind() == nixf::Node::NK_ExprSelect) { + const auto &Select = static_cast(*Value); + + // Must have a path with at least one element + if (!Select.path() || Select.path()->names().empty()) + return; + + // Last element of path must match the attribute name + const auto &PathNames = Select.path()->names(); + const auto &LastName = PathNames.back(); + if (!LastName->isStatic() || LastName->staticName() != AttrName) + return; + + // Must not have a default expression + if (Select.defaultExpr()) + return; + + // Build the inherit expression + // Get the base expression (everything before the last selector) + std::ostringstream NewText; + NewText << "inherit ("; + + // Get source of the base expression + if (PathNames.size() == 1) { + // Simple case: { a = b.a; } where Select.expr() is 'b' + NewText << getSourceText(Src, Select.expr()); + } else { + // Complex case: { a = b.c.a; } - need to build b.c + NewText << getSourceText(Src, Select.expr()); + for (size_t I = 0; I + 1 < PathNames.size(); ++I) { + NewText << "." << getSourceText(Src, *PathNames[I]); + } + } + NewText << ") " << AttrName << ";"; + + std::vector Edits; + Edits.emplace_back(lspserver::TextEdit{ + .range = toLSPRange(Src, Bind.range()), + .newText = NewText.str(), + }); + + using Changes = std::map>; + WorkspaceEdit WE{.changes = Changes{{FileURI, std::move(Edits)}}}; + + Actions.emplace_back(CodeAction{ + .title = "Convert to inherit", + .kind = std::string(CodeAction::REFACTOR_REWRITE_KIND), + .edit = std::move(WE), + }); + } +} + +/// \brief Add "Convert inherit to binding" action. +/// Example: { inherit x; } -> { x = x; } +/// Example: { inherit (b) a; } -> { a = b.a; } +void addInheritToBindingAction(const nixf::Node &N, + const nixf::ParentMapAnalysis &PM, + const URIForFile &URI, llvm::StringRef Src, + std::vector &Actions) { + // Find if we're inside an Inherit + const nixf::Node *InheritNode = PM.upTo(N, nixf::Node::NK_Inherit); + if (!InheritNode) + return; + + const auto &Inherit = static_cast(*InheritNode); + + // Must have at least one name + const auto &Names = Inherit.names(); + if (Names.empty()) + return; + + // Find which name the cursor is on + const nixf::AttrName *TargetName = nullptr; + for (const auto &Name : Names) { + if (Name->range().lCur().offset() <= N.range().lCur().offset() && + N.range().rCur().offset() <= Name->range().rCur().offset()) { + TargetName = Name.get(); + break; + } + } + + // If not on a specific name, use the first one + if (!TargetName && !Names.empty()) + TargetName = Names[0].get(); + + if (!TargetName || !TargetName->isStatic()) + return; + + const std::string &AttrName = TargetName->staticName(); + std::string FileURI = URI.uri(); + + std::ostringstream NewText; + NewText << AttrName << " = "; + + if (Inherit.expr()) { + // inherit (expr) name; -> name = expr.name; + NewText << getSourceText(Src, *Inherit.expr()) << "." << AttrName; + } else { + // inherit name; -> name = name; + NewText << AttrName; + } + NewText << ";"; + + // If this is the only name in inherit, replace the whole inherit + // Otherwise, we'd need more complex handling (not implemented for simplicity) + if (Names.size() != 1) + return; + + std::vector Edits; + Edits.emplace_back(lspserver::TextEdit{ + .range = toLSPRange(Src, Inherit.range()), + .newText = NewText.str(), + }); + + using Changes = std::map>; + WorkspaceEdit WE{.changes = Changes{{FileURI, std::move(Edits)}}}; + + Actions.emplace_back(CodeAction{ + .title = "Convert to explicit binding", + .kind = std::string(CodeAction::REFACTOR_REWRITE_KIND), + .edit = std::move(WE), + }); +} + +/// \brief Check if an expression contains a nested `with` expression. +bool hasNestedWith(const nixf::Node &N) { + if (N.kind() == nixf::Node::NK_ExprWith) + return true; + + for (const nixf::Node *Child : N.children()) { + if (Child && hasNestedWith(*Child)) + return true; + } + return false; +} + +/// \brief Collect all variable names that come from a `with` expression. +void collectWithVariables(const nixf::Node &N, + const nixf::VariableLookupAnalysis &VLA, + std::set &Names) { + // If this is a variable, check if it comes from `with` + if (N.kind() == nixf::Node::NK_ExprVar) { + const auto &Var = static_cast(N); + auto Result = VLA.query(Var); + if (Result.Kind == + nixf::VariableLookupAnalysis::LookupResultKind::FromWith) { + Names.insert(Var.id().name()); + } + } + + // Recursively process children + for (const nixf::Node *Child : N.children()) { + if (Child) + collectWithVariables(*Child, VLA, Names); + } +} + +/// \brief Add "Convert with to let/inherit" action for with expressions. +/// Example: with lib; { x = foo; } -> let inherit (lib) foo; in { x = foo; } +void addWithToLetAction(const nixf::Node &N, const nixf::ParentMapAnalysis &PM, + const nixf::VariableLookupAnalysis &VLA, + const URIForFile &URI, llvm::StringRef Src, + std::vector &Actions) { + // Find if we're inside an ExprWith + const nixf::Node *WithNode = PM.upTo(N, nixf::Node::NK_ExprWith); + if (!WithNode) + return; + + const auto &With = static_cast(*WithNode); + + // Need both the with expression and body + if (!With.with() || !With.expr()) + return; + + // Don't offer conversion for nested `with` expressions + // because we can't reliably determine which `with` each variable comes from + if (hasNestedWith(*With.expr())) + return; + + // Collect all variables that come from this `with` + std::set VarNames; + collectWithVariables(*With.expr(), VLA, VarNames); + + // Skip if no variables from with + if (VarNames.empty()) + return; + + // Build the replacement text + std::ostringstream NewText; + NewText << "let inherit (" << getSourceText(Src, *With.with()) << ")"; + for (const auto &Name : VarNames) { + NewText << " " << Name; + } + NewText << "; in " << getSourceText(Src, *With.expr()); + + std::string FileURI = URI.uri(); + std::vector Edits; + Edits.emplace_back(lspserver::TextEdit{ + .range = toLSPRange(Src, With.range()), + .newText = NewText.str(), + }); + + using Changes = std::map>; + WorkspaceEdit WE{.changes = Changes{{FileURI, std::move(Edits)}}}; + + Actions.emplace_back(CodeAction{ + .title = "Convert with to let/inherit", + .kind = std::string(CodeAction::REFACTOR_REWRITE_KIND), + .edit = std::move(WE), + }); +} + +/// \brief Check if two ranges overlap (for conflict detection). +bool rangesOverlap(const lspserver::Range &A, const lspserver::Range &B) { + // A ends before B starts, or B ends before A starts + if (A.end.line < B.start.line || + (A.end.line == B.start.line && A.end.character <= B.start.character)) + return false; + if (B.end.line < A.start.line || + (B.end.line == A.start.line && B.end.character <= A.start.character)) + return false; + return true; +} + +/// \brief Add source.fixAll action that applies all quickfixes. +void addFixAllAction(const std::vector &Diagnostics, + llvm::StringRef Src, const std::string &FileURI, + std::vector &Actions) { + std::vector AllEdits; + std::vector UsedRanges; + + // Collect all fixes from all diagnostics + for (const nixf::Diagnostic &D : Diagnostics) { + for (const nixf::Fix &F : D.fixes()) { + bool HasConflict = false; + + // Check for conflicts with already collected edits + for (const nixf::TextEdit &TE : F.edits()) { + lspserver::Range EditRange = toLSPRange(Src, TE.oldRange()); + for (const auto &UsedRange : UsedRanges) { + if (rangesOverlap(EditRange, UsedRange)) { + HasConflict = true; + break; + } + } + if (HasConflict) + break; + } + + if (HasConflict) + continue; + + // Add all edits from this fix + for (const nixf::TextEdit &TE : F.edits()) { + lspserver::Range EditRange = toLSPRange(Src, TE.oldRange()); + UsedRanges.push_back(EditRange); + AllEdits.emplace_back(lspserver::TextEdit{ + .range = EditRange, + .newText = std::string(TE.newText()), + }); + } + } + } + + // Only add fixAll action if there are edits to apply + if (AllEdits.empty()) + return; + + using Changes = std::map>; + WorkspaceEdit WE{.changes = Changes{{FileURI, std::move(AllEdits)}}}; + + Actions.emplace_back(CodeAction{ + .title = "Fix all auto-fixable problems", + .kind = std::string(CodeAction::SOURCE_FIXALL_KIND), + .edit = std::move(WE), + }); +} + +} // namespace + void Controller::onCodeAction(const lspserver::CodeActionParams &Params, Callback> Reply) { using CheckTy = std::vector; std::string File(Params.textDocument.uri.file()); Range Range = Params.range; - auto Action = [Reply = std::move(Reply), File, Range, this]() mutable { + URIForFile URI = Params.textDocument.uri; + auto Action = [Reply = std::move(Reply), File, Range, URI, this]() mutable { return Reply([&]() -> llvm::Expected { const auto TU = CheckDefault(getTU(File)); const auto &Diagnostics = TU->diagnostics(); auto Actions = std::vector(); Actions.reserve(Diagnostics.size()); + + // Add diagnostic-based quick fixes for (const nixf::Diagnostic &D : Diagnostics) { auto DRange = toLSPRange(TU->src(), D.range()); if (!Range.overlap(DRange)) continue; + // Determine if this diagnostic's fixes should be marked as preferred + bool IsPreferred = false; + switch (D.kind()) { + case nixf::Diagnostic::DK_UndefinedVariable: + case nixf::Diagnostic::DK_UnusedDefLet: + case nixf::Diagnostic::DK_UnusedDefLambdaNoArg_Formal: + case nixf::Diagnostic::DK_UnusedDefLambdaWithArg_Formal: + case nixf::Diagnostic::DK_UnusedDefLambdaWithArg_Arg: + case nixf::Diagnostic::DK_ExtraRecursive: + case nixf::Diagnostic::DK_ExtraWith: + case nixf::Diagnostic::DK_PrimOpNeedsPrefix: + case nixf::Diagnostic::DK_EmptyInherit: + case nixf::Diagnostic::DK_RedundantParen: + IsPreferred = true; + break; + default: + break; + } + // Add fixes. for (const nixf::Fix &F : D.fixes()) { - std::vector Edits; + std::vector Edits; Edits.reserve(F.edits().size()); for (const nixf::TextEdit &TE : F.edits()) { - Edits.emplace_back(TextEdit{ + Edits.emplace_back(lspserver::TextEdit{ .range = toLSPRange(TU->src(), TE.oldRange()), .newText = std::string(TE.newText()), }); } - using Changes = std::map>; + using Changes = + std::map>; std::string FileURI = URIForFile::canonicalize(File, File).uri(); WorkspaceEdit WE{.changes = Changes{ {std::move(FileURI), std::move(Edits)}, @@ -50,10 +1079,41 @@ void Controller::onCodeAction(const lspserver::CodeActionParams &Params, Actions.emplace_back(CodeAction{ .title = F.message(), .kind = std::string(CodeAction::QUICKFIX_KIND), + .isPreferred = IsPreferred, .edit = std::move(WE), }); } } + + // Add refactoring actions + if (TU->ast() && TU->parentMap()) { + nixf::Position Pos = toNixfPosition(Range.start); + if (const nixf::Node *N = TU->ast()->descend({Pos, Pos})) { + addAttrNameActions(*N, *TU->parentMap(), URI, TU->src(), Actions); + addFlattenAction(*N, *TU->parentMap(), URI, TU->src(), Actions); + addPackAction(*N, *TU->parentMap(), URI, TU->src(), Actions); + addConvertToInheritAction(*N, *TU->parentMap(), URI, TU->src(), + Actions); + addInheritToBindingAction(*N, *TU->parentMap(), URI, TU->src(), + Actions); + addRewriteStringAction(*N, *TU->parentMap(), URI, TU->src(), Actions); + if (TU->variableLookup()) { + addNoogleAction(*N, *TU->parentMap(), *TU->variableLookup(), + Actions); + addWithToLetAction(*N, *TU->parentMap(), *TU->variableLookup(), URI, + TU->src(), Actions); + } + addExtractToFileAction(*N, URI, TU->src(), Actions); + } + } + + // Add JSON to Nix conversion action (selection-based) + addJsonToNixAction(TU->src(), Range, URI, Actions); + + // Add source.fixAll action + std::string FileURI = URIForFile::canonicalize(File, File).uri(); + addFixAllAction(Diagnostics, TU->src(), FileURI, Actions); + return Actions; }()); }; diff --git a/nixd/lib/Controller/LifeTime.cpp b/nixd/lib/Controller/LifeTime.cpp index 3f0bd8454..baf602d69 100644 --- a/nixd/lib/Controller/LifeTime.cpp +++ b/nixd/lib/Controller/LifeTime.cpp @@ -98,7 +98,11 @@ void Controller:: { "codeActionProvider", Object{ - {"codeActionKinds", Array{CodeAction::QUICKFIX_KIND}}, + {"codeActionKinds", + Array{CodeAction::QUICKFIX_KIND, CodeAction::REFACTOR_KIND, + CodeAction::REFACTOR_EXTRACT_KIND, + CodeAction::REFACTOR_REWRITE_KIND, + CodeAction::SOURCE_FIXALL_KIND}}, {"resolveProvider", false}, }, }, diff --git a/nixd/lspserver/include/lspserver/Protocol.h b/nixd/lspserver/include/lspserver/Protocol.h index dffe1fcb7..5d3f89e1a 100644 --- a/nixd/lspserver/include/lspserver/Protocol.h +++ b/nixd/lspserver/include/lspserver/Protocol.h @@ -1053,6 +1053,11 @@ struct CodeAction { std::optional kind; const static llvm::StringLiteral QUICKFIX_KIND; const static llvm::StringLiteral REFACTOR_KIND; + const static llvm::StringLiteral REFACTOR_EXTRACT_KIND; + const static llvm::StringLiteral REFACTOR_INLINE_KIND; + const static llvm::StringLiteral REFACTOR_REWRITE_KIND; + const static llvm::StringLiteral SOURCE_KIND; + const static llvm::StringLiteral SOURCE_FIXALL_KIND; const static llvm::StringLiteral INFO_KIND; /// The diagnostics that this code action resolves. diff --git a/nixd/lspserver/src/Protocol.cpp b/nixd/lspserver/src/Protocol.cpp index 127ae6e37..2dd5172c2 100644 --- a/nixd/lspserver/src/Protocol.cpp +++ b/nixd/lspserver/src/Protocol.cpp @@ -766,6 +766,13 @@ llvm::json::Value toJSON(const Command &C) { const llvm::StringLiteral CodeAction::QUICKFIX_KIND = "quickfix"; const llvm::StringLiteral CodeAction::REFACTOR_KIND = "refactor"; +const llvm::StringLiteral CodeAction::REFACTOR_EXTRACT_KIND = + "refactor.extract"; +const llvm::StringLiteral CodeAction::REFACTOR_INLINE_KIND = "refactor.inline"; +const llvm::StringLiteral CodeAction::REFACTOR_REWRITE_KIND = + "refactor.rewrite"; +const llvm::StringLiteral CodeAction::SOURCE_KIND = "source"; +const llvm::StringLiteral CodeAction::SOURCE_FIXALL_KIND = "source.fixAll"; const llvm::StringLiteral CodeAction::INFO_KIND = "info"; llvm::json::Value toJSON(const CodeAction &CA) { diff --git a/nixd/tools/nixd/test/code-action/convert-to-inherit.md b/nixd/tools/nixd/test/code-action/convert-to-inherit.md new file mode 100644 index 000000000..201b91a99 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/convert-to-inherit.md @@ -0,0 +1,249 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + +## Test 1: Basic self-assignment { x = x; } -> { inherit x; } + +<-- textDocument/didOpen + +```nix file:///convert-to-inherit-basic.nix +{ x = x; } +``` + +<-- textDocument/codeAction(1) + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///convert-to-inherit-basic.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":6 + }, + "end":{ + "line":0, + "character":7 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ + CHECK: "newText": "inherit x;", + CHECK: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Convert to inherit" +``` + +## Test 2: Simple select expression { a = b.a; } -> { inherit (b) a; } + +<-- textDocument/didOpen + +```nix file:///convert-to-inherit-select.nix +{ a = b.a; } +``` + +<-- textDocument/codeAction(2) + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///convert-to-inherit-select.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":6 + }, + "end":{ + "line":0, + "character":9 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ + CHECK: "newText": "inherit (b) a;", + CHECK: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Convert to inherit" +``` + +## Test 3: Complex selector { a = b.c.a; } -> { inherit (b.c) a; } + +<-- textDocument/didOpen + +```nix file:///convert-to-inherit-complex.nix +{ a = b.c.a; } +``` + +<-- textDocument/codeAction(3) + +```json +{ + "jsonrpc":"2.0", + "id":3, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///convert-to-inherit-complex.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":6 + }, + "end":{ + "line":0, + "character":11 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 3, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ + CHECK: "newText": "inherit (b.c) a;", + CHECK: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Convert to inherit" +``` + +## Test 4: Negative case - non-matching names { x = y; } + +<-- textDocument/didOpen + +```nix file:///convert-to-inherit-negative.nix +{ x = y; } +``` + +<-- textDocument/codeAction(4) + +```json +{ + "jsonrpc":"2.0", + "id":4, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///convert-to-inherit-negative.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":3 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 4, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NOT: "title": "Convert to inherit" +``` + +## Test 5: Negative case - select expression with non-matching name { x = y.z; } + +<-- textDocument/didOpen + +```nix file:///convert-to-inherit-negative-select.nix +{ x = y.z; } +``` + +<-- textDocument/codeAction(5) + +```json +{ + "jsonrpc":"2.0", + "id":5, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///convert-to-inherit-negative-select.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":3 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 5, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NOT: "title": "Convert to inherit" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/empty-inherit.md b/nixd/tools/nixd/test/code-action/empty-inherit.md new file mode 100644 index 000000000..aebd8d80e --- /dev/null +++ b/nixd/tools/nixd/test/code-action/empty-inherit.md @@ -0,0 +1,103 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///empty-inherit.nix +{ inherit; } +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///empty-inherit.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":9 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///empty-inherit.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 9, +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: "newText": "", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 10, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 9, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: "isPreferred": true, +CHECK-NEXT: "kind": "quickfix", +CHECK-NEXT: "title": "remove `inherit` keyword" +CHECK-NEXT: } +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/extra-rec.md b/nixd/tools/nixd/test/code-action/extra-rec.md new file mode 100644 index 000000000..eaef1b85a --- /dev/null +++ b/nixd/tools/nixd/test/code-action/extra-rec.md @@ -0,0 +1,90 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///extra-rec.nix +rec { x = 1; } +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///extra-rec.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":0 + }, + "end":{ + "line":0, + "character":3 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///extra-rec.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 3, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 0, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: "isPreferred": true, +CHECK-NEXT: "kind": "quickfix", +CHECK-NEXT: "title": "remove `rec` keyword" +CHECK-NEXT: } +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/extra-with.md b/nixd/tools/nixd/test/code-action/extra-with.md new file mode 100644 index 000000000..728855cfd --- /dev/null +++ b/nixd/tools/nixd/test/code-action/extra-with.md @@ -0,0 +1,116 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///extra-with.nix +with {}; 1 +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///extra-with.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":0 + }, + "end":{ + "line":0, + "character":4 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///extra-with.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 4, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 0, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "newText": "", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 8, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 7, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "newText": "", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 7, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 5, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: "isPreferred": true, +CHECK-NEXT: "kind": "quickfix", +CHECK-NEXT: "title": "remove `with` expression" +CHECK-NEXT: } +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/extract-to-file.md b/nixd/tools/nixd/test/code-action/extract-to-file.md new file mode 100644 index 000000000..08d687052 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/extract-to-file.md @@ -0,0 +1,73 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///extract-to-file.nix +{ foo = { bar = 1; baz = 2; }; } +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///extract-to-file.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":8 + }, + "end":{ + "line":0, + "character":28 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK: "command": { +CHECK: "arguments": [ +CHECK: "expression": +CHECK: "range": +CHECK: "uri": "file:///extract-to-file.nix" +CHECK: "command": "nixd.extractToFile", +CHECK: "title": "Extract expression to file" +CHECK: "title": "Extract expression to file" +``` + +```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..5f1178c7b --- /dev/null +++ b/nixd/tools/nixd/test/code-action/flatten-attrs.md @@ -0,0 +1,114 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///flatten-attrs.nix +{ foo = { bar = 1; }; } +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "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 + } + } +} +``` + +``` + CHECK: "id": 1, +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 attribute set" +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/inherit-to-binding.md b/nixd/tools/nixd/test/code-action/inherit-to-binding.md new file mode 100644 index 000000000..e739c8288 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/inherit-to-binding.md @@ -0,0 +1,256 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + +## Test 1: Basic inherit without expression +## { inherit x; } -> { x = x; } + +<-- textDocument/didOpen + +```nix file:///basic-inherit.nix +{ inherit x; } +``` + +<-- textDocument/codeAction(1) + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///basic-inherit.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":10 + }, + "end":{ + "line":0, + "character":11 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ + CHECK: "newText": "x = x;", + CHECK: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Convert to explicit binding" +``` + +## Test 2: Inherit with expression +## { inherit (b) a; } -> { a = b.a; } + +<-- textDocument/didOpen + +```nix file:///inherit-with-expr.nix +{ inherit (b) a; } +``` + +<-- textDocument/codeAction(2) + +```json +{ + "jsonrpc":"2.0", + "id":2, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///inherit-with-expr.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":14 + }, + "end":{ + "line":0, + "character":15 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 2, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ + CHECK: "newText": "a = b.a;", + CHECK: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Convert to explicit binding" +``` + +## Test 3: Inherit with complex expression +## { inherit (pkgs.lib) mkOption; } -> { mkOption = pkgs.lib.mkOption; } + +<-- textDocument/didOpen + +```nix file:///inherit-complex-expr.nix +{ inherit (pkgs.lib) mkOption; } +``` + +<-- textDocument/codeAction(3) + +```json +{ + "jsonrpc":"2.0", + "id":3, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///inherit-complex-expr.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":21 + }, + "end":{ + "line":0, + "character":29 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 3, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ + CHECK: "newText": "mkOption = pkgs.lib.mkOption;", + CHECK: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Convert to explicit binding" +``` + +## Test 4: Negative case - multiple names +## { inherit x y; } should NOT offer the action (only works with single name) + +<-- textDocument/didOpen + +```nix file:///multi-inherit.nix +{ inherit x y; } +``` + +<-- textDocument/codeAction(4) + +```json +{ + "jsonrpc":"2.0", + "id":4, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///multi-inherit.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":10 + }, + "end":{ + "line":0, + "character":11 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 4, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NOT: "title": "Convert to explicit binding" +``` + +## Test 5: Inherit with longer identifier name +## { inherit fooBar; } -> { fooBar = fooBar; } + +<-- textDocument/didOpen + +```nix file:///inherit-long-name.nix +{ inherit fooBar; } +``` + +<-- textDocument/codeAction(5) + +```json +{ + "jsonrpc":"2.0", + "id":5, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///inherit-long-name.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":10 + }, + "end":{ + "line":0, + "character":16 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 5, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ + CHECK: "newText": "fooBar = fooBar;", + CHECK: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Convert to explicit binding" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/json-to-nix.md b/nixd/tools/nixd/test/code-action/json-to-nix.md new file mode 100644 index 000000000..15901fb94 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/json-to-nix.md @@ -0,0 +1,67 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///json-to-nix.nix +{"foo": 1, "bar": true} +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///json-to-nix.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":0 + }, + "end":{ + "line":0, + "character":23 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK: "title": "Convert JSON to Nix" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/noogle-lib.md b/nixd/tools/nixd/test/code-action/noogle-lib.md new file mode 100644 index 000000000..3263558fe --- /dev/null +++ b/nixd/tools/nixd/test/code-action/noogle-lib.md @@ -0,0 +1,71 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///noogle-lib.nix +{ lib }: lib.mapAttrs +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///noogle-lib.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":9 + }, + "end":{ + "line":0, + "character":17 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK: "command": { +CHECK: "arguments": [ +CHECK: "https://noogle.dev/f/lib/mapAttrs" +CHECK: "command": "nixd.openNoogle", +CHECK: "title": "Open lib.mapAttrs in noogle.dev" +CHECK: "title": "Open lib.mapAttrs in noogle.dev" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/pack-attrs.md b/nixd/tools/nixd/test/code-action/pack-attrs.md new file mode 100644 index 000000000..1ff0c11cb --- /dev/null +++ b/nixd/tools/nixd/test/code-action/pack-attrs.md @@ -0,0 +1,114 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///pack-attrs.nix +{ foo.bar = 1; } +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///pack-attrs.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":5 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///pack-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:///pack-attrs.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "foo = { bar = 1; };", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 14, +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": "Pack attribute set" +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/quick-fix.md b/nixd/tools/nixd/test/code-action/quick-fix.md index 74d26205b..fff2ed74e 100644 --- a/nixd/tools/nixd/test/code-action/quick-fix.md +++ b/nixd/tools/nixd/test/code-action/quick-fix.md @@ -82,7 +82,6 @@ CHECK-NEXT: }, CHECK-NEXT: "kind": "quickfix", CHECK-NEXT: "title": "insert */" CHECK-NEXT: } -CHECK-NEXT: ] ``` ```json diff --git a/nixd/tools/nixd/test/code-action/quote-attr.md b/nixd/tools/nixd/test/code-action/quote-attr.md new file mode 100644 index 000000000..7039330be --- /dev/null +++ b/nixd/tools/nixd/test/code-action/quote-attr.md @@ -0,0 +1,90 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///quote-attr.nix +{ foo = 1; } +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///quote-attr.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":5 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///quote-attr.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "\"foo\"", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 5, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 2, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Quote attribute name" +CHECK-NEXT: } +CHECK-NEXT: ] +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/redundant-paren.md b/nixd/tools/nixd/test/code-action/redundant-paren.md new file mode 100644 index 000000000..8dbbefd2b --- /dev/null +++ b/nixd/tools/nixd/test/code-action/redundant-paren.md @@ -0,0 +1,103 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///redundant-paren.nix +(1) +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///redundant-paren.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":0 + }, + "end":{ + "line":0, + "character":1 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///redundant-paren.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 1, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 0, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "newText": "", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 3, +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: "isPreferred": true, +CHECK-NEXT: "kind": "quickfix", +CHECK-NEXT: "title": "remove ( and )" +CHECK-NEXT: } +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/rewrite-string-reverse.md b/nixd/tools/nixd/test/code-action/rewrite-string-reverse.md new file mode 100644 index 000000000..9a79f249a --- /dev/null +++ b/nixd/tools/nixd/test/code-action/rewrite-string-reverse.md @@ -0,0 +1,69 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///rewrite-string-reverse.nix +''hello'' +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///rewrite-string-reverse.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":0 + }, + "end":{ + "line":0, + "character":9 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ + CHECK: "newText": "\"hello\"", + CHECK: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Convert to double-quoted string" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/rewrite-string.md b/nixd/tools/nixd/test/code-action/rewrite-string.md new file mode 100644 index 000000000..a2b5e8af5 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/rewrite-string.md @@ -0,0 +1,89 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///rewrite-string.nix +"hello" +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///rewrite-string.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":0 + }, + "end":{ + "line":0, + "character":7 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, + CHECK: "jsonrpc": "2.0", + CHECK: "result": [ + CHECK: { + CHECK: "edit": { + CHECK: "changes": { + CHECK: "file:///rewrite-string.nix": [ + CHECK: { + CHECK: "newText": "''hello''", + CHECK: "range": { + CHECK: "end": { + CHECK: "character": 7, + CHECK: "line": 0 + CHECK: }, + CHECK: "start": { + CHECK: "character": 0, + CHECK: "line": 0 + CHECK: } + CHECK: } + CHECK: } + CHECK: ] + CHECK: } + CHECK: }, + CHECK: "kind": "refactor.rewrite", + CHECK: "title": "Convert to indented string" + CHECK: } +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/undefined-var.md b/nixd/tools/nixd/test/code-action/undefined-var.md new file mode 100644 index 000000000..b3306b5df --- /dev/null +++ b/nixd/tools/nixd/test/code-action/undefined-var.md @@ -0,0 +1,91 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///undefined-var.nix +{x}: y +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///undefined-var.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":4 + }, + "end":{ + "line":0, + "character":5 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///undefined-var.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": ", y", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 2, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 2, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: "isPreferred": true, +CHECK-NEXT: "kind": "quickfix", +CHECK-NEXT: "title": "add `y` to formals" +CHECK-NEXT: }, +CHECK: "title": "Extract expression to file" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unquote-attr.md b/nixd/tools/nixd/test/code-action/unquote-attr.md new file mode 100644 index 000000000..8902944e1 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unquote-attr.md @@ -0,0 +1,90 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unquote-attr.nix +{ "foo" = 1; } +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unquote-attr.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":2 + }, + "end":{ + "line":0, + "character":7 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///unquote-attr.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "foo", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 7, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 2, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: "kind": "refactor.rewrite", +CHECK-NEXT: "title": "Unquote attribute name" +CHECK-NEXT: }, +CHECK: "title": "Convert JSON to Nix" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unused-def.md b/nixd/tools/nixd/test/code-action/unused-def.md new file mode 100644 index 000000000..902b5777e --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unused-def.md @@ -0,0 +1,90 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unused-let.nix +let unused = 1; in 2 +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unused-let.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":4 + }, + "end":{ + "line":0, + "character":10 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///unused-let.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "", +CHECK-NEXT: "range": { +CHECK-NEXT: "end": { +CHECK-NEXT: "character": 15, +CHECK-NEXT: "line": 0 +CHECK-NEXT: }, +CHECK-NEXT: "start": { +CHECK-NEXT: "character": 4, +CHECK-NEXT: "line": 0 +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +CHECK-NEXT: }, +CHECK-NEXT: "isPreferred": true, +CHECK-NEXT: "kind": "quickfix", +CHECK-NEXT: "title": "remove unused binding" +CHECK-NEXT: } +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/unused-formal.md b/nixd/tools/nixd/test/code-action/unused-formal.md new file mode 100644 index 000000000..282bfe657 --- /dev/null +++ b/nixd/tools/nixd/test/code-action/unused-formal.md @@ -0,0 +1,85 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///unused-formal.nix +{x, y}: x +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///unused-formal.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":4 + }, + "end":{ + "line":0, + "character":5 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": [ +CHECK-NEXT: { +CHECK-NEXT: "edit": { +CHECK-NEXT: "changes": { +CHECK-NEXT: "file:///unused-formal.nix": [ +CHECK-NEXT: { +CHECK-NEXT: "newText": "", +CHECK-NEXT: "range": { +CHECK: "line": 0 +CHECK: }, +CHECK: "start": { +CHECK: "line": 0 +CHECK: } +CHECK: } +CHECK: } +CHECK: ] +CHECK: } +CHECK: }, +CHECK: "kind": "quickfix", +CHECK: "title": "remove unused formal" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/code-action/with-to-let.md b/nixd/tools/nixd/test/code-action/with-to-let.md new file mode 100644 index 000000000..0c79e0e6e --- /dev/null +++ b/nixd/tools/nixd/test/code-action/with-to-let.md @@ -0,0 +1,67 @@ + +# RUN: nixd --lit-test < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + + +<-- textDocument/didOpen + +```nix file:///with-to-let.nix +{ lib }: with lib; { x = mapAttrs; } +``` + +<-- textDocument/codeAction(1) + + +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"textDocument/codeAction", + "params":{ + "textDocument":{ + "uri":"file:///with-to-let.nix" + }, + "range":{ + "start":{ + "line": 0, + "character":9 + }, + "end":{ + "line":0, + "character":13 + } + }, + "context":{ + "diagnostics":[], + "triggerKind":2 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK: "newText": "let inherit (lib) mapAttrs; in { x = mapAttrs; }", +CHECK: "title": "Convert with to let/inherit" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/diagnostic/liveness-formal.md b/nixd/tools/nixd/test/diagnostic/liveness-formal.md index 3c5f59c9f..63accd637 100644 --- a/nixd/tools/nixd/test/diagnostic/liveness-formal.md +++ b/nixd/tools/nixd/test/diagnostic/liveness-formal.md @@ -28,7 +28,7 @@ CHECK: "diagnostics": [ CHECK-NEXT: { CHECK-NEXT: "code": "sema-unused-def-lambda-noarg-formal", -CHECK-NEXT: "message": "attribute `y` of argument is not used", +CHECK-NEXT: "message": "attribute `y` of argument is not used (fix available)", CHECK-NEXT: "range": { CHECK-NEXT: "end": { CHECK-NEXT: "character": 5, diff --git a/nixd/tools/nixd/test/initialize.md b/nixd/tools/nixd/test/initialize.md index 3456773b0..789e4c93c 100644 --- a/nixd/tools/nixd/test/initialize.md +++ b/nixd/tools/nixd/test/initialize.md @@ -28,7 +28,11 @@ CHECK-NEXT: "result": { CHECK-NEXT: "capabilities": { CHECK-NEXT: "codeActionProvider": { CHECK-NEXT: "codeActionKinds": [ -CHECK-NEXT: "quickfix" +CHECK-NEXT: "quickfix", +CHECK-NEXT: "refactor", +CHECK-NEXT: "refactor.extract", +CHECK-NEXT: "refactor.rewrite", +CHECK-NEXT: "source.fixAll" CHECK-NEXT: ], CHECK-NEXT: "resolveProvider": false CHECK-NEXT: }, diff --git a/nixd/tools/nixd/test/semantic-tokens/initialize.md b/nixd/tools/nixd/test/semantic-tokens/initialize.md index 85554629d..cee297670 100644 --- a/nixd/tools/nixd/test/semantic-tokens/initialize.md +++ b/nixd/tools/nixd/test/semantic-tokens/initialize.md @@ -28,7 +28,11 @@ CHECK-NEXT: "result": { CHECK-NEXT: "capabilities": { CHECK-NEXT: "codeActionProvider": { CHECK-NEXT: "codeActionKinds": [ -CHECK-NEXT: "quickfix" +CHECK-NEXT: "quickfix", +CHECK-NEXT: "refactor", +CHECK-NEXT: "refactor.extract", +CHECK-NEXT: "refactor.rewrite", +CHECK-NEXT: "source.fixAll" CHECK-NEXT: ], CHECK-NEXT: "resolveProvider": false CHECK-NEXT: },