From b23dde37fbf09796b39b336353853dceba9d0e1f Mon Sep 17 00:00:00 2001 From: Christoph Wolf Date: Sun, 12 Apr 2026 09:28:19 +0200 Subject: [PATCH 1/3] feat(diagnostics): add wrong/empty enum Value diagnostic --- nixd/include/nixd/Controller/Controller.h | 9 +- nixd/include/nixd/Controller/NixTU.h | 25 +++ nixd/include/nixd/Protocol/AttrSet.h | 1 + nixd/lib/Controller/Configuration.cpp | 3 +- nixd/lib/Controller/Diagnostics.cpp | 14 +- nixd/lib/Controller/OptionDiagnostics.cpp | 165 ++++++++++++++++++ nixd/lib/Controller/Support.cpp | 20 ++- nixd/lib/Eval/AttrSetProvider.cpp | 25 +++ nixd/lib/Protocol/AttrSet.cpp | 6 +- nixd/lib/meson.build | 1 + .../diagnostic/option-enum-empty-value.md | 38 ++++ .../nixd/test/diagnostic/option-enum-value.md | 38 ++++ 12 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 nixd/lib/Controller/OptionDiagnostics.cpp create mode 100644 nixd/tools/nixd/test/diagnostic/option-enum-empty-value.md create mode 100644 nixd/tools/nixd/test/diagnostic/option-enum-value.md diff --git a/nixd/include/nixd/Controller/Controller.h b/nixd/include/nixd/Controller/Controller.h index f49fac53b..f3c5c2bb4 100644 --- a/nixd/include/nixd/Controller/Controller.h +++ b/nixd/include/nixd/Controller/Controller.h @@ -230,9 +230,12 @@ class Controller : public lspserver::LSPServer { /// Determine whether or not this diagnostic is suppressed. bool isSuppressed(nixf::Diagnostic::DiagnosticKind Kind); - void publishDiagnostics(lspserver::PathRef File, - std::optional Version, std::string_view Src, - const std::vector &Diagnostics); + std::vector collectOptionDiagnostics(const NixTU &TU); + void + publishDiagnostics(lspserver::PathRef File, std::optional Version, + std::string_view Src, + const std::vector &Diagnostics, + const std::vector &NixdDiagnostics = {}); void onRename(const lspserver::RenameParams &Params, lspserver::Callback Reply); diff --git a/nixd/include/nixd/Controller/NixTU.h b/nixd/include/nixd/Controller/NixTU.h index 8a4a84a39..dfa626716 100644 --- a/nixd/include/nixd/Controller/NixTU.h +++ b/nixd/include/nixd/Controller/NixTU.h @@ -9,15 +9,32 @@ #include #include +#include #include namespace nixd { +enum class NixdDiagnosticSeverity { + Error = 1, + Warning = 2, + Information = 3, + Hint = 4, +}; + +struct NixdDiagnostic { + nixf::LexerCursorRange Range; + NixdDiagnosticSeverity Severity = NixdDiagnosticSeverity::Error; + std::string Code; + std::string Source; + std::string Message; +}; + /// \brief Holds analyzed information about a document. /// /// TU stands for "Translation Unit". class NixTU { std::vector Diagnostics; + std::vector NixdDiagnostics; std::shared_ptr AST; std::optional ASTByteCode; std::unique_ptr VLA; @@ -36,6 +53,14 @@ class NixTU { return Diagnostics; } + [[nodiscard]] const std::vector &nixdDiagnostics() const { + return NixdDiagnostics; + } + + void setNixdDiagnostics(std::vector Diagnostics) { + NixdDiagnostics = std::move(Diagnostics); + } + [[nodiscard]] const std::shared_ptr &ast() const { return AST; } [[nodiscard]] const nixf::ParentMapAnalysis *parentMap() const { diff --git a/nixd/include/nixd/Protocol/AttrSet.h b/nixd/include/nixd/Protocol/AttrSet.h index 7db9c8255..7c292e406 100644 --- a/nixd/include/nixd/Protocol/AttrSet.h +++ b/nixd/include/nixd/Protocol/AttrSet.h @@ -118,6 +118,7 @@ using AttrPathCompleteResponse = std::vector; struct OptionType { std::optional Description; + std::optional> EnumValues; std::optional Name; }; diff --git a/nixd/lib/Controller/Configuration.cpp b/nixd/lib/Controller/Configuration.cpp index 3bb28e8a9..7fac653e4 100644 --- a/nixd/lib/Controller/Configuration.cpp +++ b/nixd/lib/Controller/Configuration.cpp @@ -84,7 +84,8 @@ void Controller::updateConfig(Configuration NewConfig) { // After all, notify all AST modules the diagnostic set has been updated. std::lock_guard TUsGuard(TUsLock); for (const auto &[File, TU] : TUs) { - publishDiagnostics(File, std::nullopt, TU->src(), TU->diagnostics()); + publishDiagnostics(File, std::nullopt, TU->src(), TU->diagnostics(), + TU->nixdDiagnostics()); } } diff --git a/nixd/lib/Controller/Diagnostics.cpp b/nixd/lib/Controller/Diagnostics.cpp index 4557888c8..1e9b930f4 100644 --- a/nixd/lib/Controller/Diagnostics.cpp +++ b/nixd/lib/Controller/Diagnostics.cpp @@ -41,9 +41,10 @@ bool Controller::isSuppressed(nixf::Diagnostic::DiagnosticKind Kind) { void Controller::publishDiagnostics( PathRef File, std::optional Version, std::string_view Src, - const std::vector &Diagnostics) { + const std::vector &Diagnostics, + const std::vector &NixdDiagnostics) { std::vector LSPDiags; - LSPDiags.reserve(Diagnostics.size()); + LSPDiags.reserve(Diagnostics.size() + NixdDiagnostics.size()); for (const nixf::Diagnostic &D : Diagnostics) { // Before actually doing anything, // let's check if the diagnostic is suppressed. @@ -112,6 +113,15 @@ void Controller::publishDiagnostics( }); } } + for (const NixdDiagnostic &D : NixdDiagnostics) { + LSPDiags.emplace_back(Diagnostic{ + .range = toLSPRange(Src, D.Range), + .severity = static_cast(D.Severity), + .code = D.Code, + .source = D.Source, + .message = D.Message, + }); + } PublishDiagnostic({ .uri = URIForFile::canonicalize(File, File), .diagnostics = std::move(LSPDiags), diff --git a/nixd/lib/Controller/OptionDiagnostics.cpp b/nixd/lib/Controller/OptionDiagnostics.cpp new file mode 100644 index 000000000..981af805c --- /dev/null +++ b/nixd/lib/Controller/OptionDiagnostics.cpp @@ -0,0 +1,165 @@ +#include "AST.h" + +#include "nixd/Controller/Controller.h" +#include "nixd/Protocol/AttrSet.h" + +#include "lspserver/Logger.h" + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +using namespace lspserver; +using namespace nixd; +using namespace nixf; + +namespace { + +constexpr llvm::StringLiteral OptionValueTypeCode = "option-value-type"; + +std::optional +resolveOptionInfo(AttrSetClient &Client, + const std::vector &Scope) { + std::binary_semaphore Ready(0); + std::optional Desc; + auto OnReply = [&Ready, &Desc](llvm::Expected Resp) { + if (Resp) + Desc = *Resp; + else + llvm::consumeError(Resp.takeError()); + Ready.release(); + }; + + Client.optionInfo(Scope, std::move(OnReply)); + Ready.acquire(); + return Desc; +} + +std::string joinOptionPath(const std::vector &Scope) { + std::ostringstream OS; + for (std::size_t I = 0; I < Scope.size(); ++I) { + if (I != 0) + OS << "."; + OS << Scope[I]; + } + return OS.str(); +} + +bool enumContains(const OptionType &Type, llvm::StringRef Value) { + assert(Type.EnumValues && "must have enum values"); + return llvm::is_contained(*Type.EnumValues, Value); +} + +std::string sourceText(llvm::StringRef Src, LexerCursorRange Range) { + return Src + .substr(Range.lCur().offset(), + Range.rCur().offset() - Range.lCur().offset()) + .str(); +} + +std::optional getLiteralString(const ExprString &String) { + if (String.parts().fragments().empty()) + return ""; + + if (!String.isLiteral()) + return std::nullopt; + + return String.literal(); +} + +std::optional +validateOptionBinding(const Binding &Binding, const ParentMapAnalysis &PM, + llvm::StringRef Src, Controller::OptionMapTy &Options) { + const auto &Value = Binding.value(); + if (!Value || Value->kind() != Node::NK_ExprString) + return std::nullopt; + + const auto &String = static_cast(*Value); + std::optional LiteralString = getLiteralString(String); + if (!LiteralString) + return std::nullopt; + + const auto &Names = Binding.path().names(); + if (Names.empty()) + return std::nullopt; + + std::vector Scope; + if (findAttrPathForOptions(*Names.back(), PM, Scope) != + FindAttrPathResult::OK || + Scope.empty()) + return std::nullopt; + + for (const auto &[Name, Provider] : Options) { + AttrSetClient *Client = Provider->client(); + if (!Client) { + elog("skipped option client {0} as it is dead", Name); + continue; + } + + std::optional Desc = resolveOptionInfo(*Client, Scope); + if (!Desc || !Desc->Type) + continue; + + const OptionType &Type = *Desc->Type; + if (Type.Name != "enum") + continue; + + if (!Type.EnumValues) + return std::nullopt; + + if (enumContains(Type, *LiteralString)) + return std::nullopt; + + std::string TypeDescription = + Type.Description.value_or(Type.Name.value_or("expected option type")); + return NixdDiagnostic{ + .Range = Value->range(), + .Severity = NixdDiagnosticSeverity::Error, + .Code = std::string(OptionValueTypeCode), + .Source = "nixd", + .Message = "definition `" + sourceText(Src, Value->range()) + + "` for option `" + joinOptionPath(Scope) + + "` is not of type `" + TypeDescription + "`.", + }; + } + + return std::nullopt; +} + +void collectOptionDiagnosticsFromNode( + const Node &N, const ParentMapAnalysis &PM, llvm::StringRef Src, + Controller::OptionMapTy &Options, + std::vector &Diagnostics) { + if (N.kind() == Node::NK_Binding) { + if (std::optional Diag = validateOptionBinding( + static_cast(N), PM, Src, Options)) + Diagnostics.emplace_back(std::move(*Diag)); + } + + for (const Node *Child : N.children()) { + if (Child) + collectOptionDiagnosticsFromNode(*Child, PM, Src, Options, Diagnostics); + } +} + +} // namespace + +std::vector +Controller::collectOptionDiagnostics(const NixTU &TU) { + std::vector Diagnostics; + if (!TU.ast() || !TU.parentMap()) + return Diagnostics; + + std::lock_guard _(OptionsLock); + collectOptionDiagnosticsFromNode(*TU.ast(), *TU.parentMap(), TU.src(), + Options, Diagnostics); + return Diagnostics; +} diff --git a/nixd/lib/Controller/Support.cpp b/nixd/lib/Controller/Support.cpp index d9c1670b2..ae7a54cb4 100644 --- a/nixd/lib/Controller/Support.cpp +++ b/nixd/lib/Controller/Support.cpp @@ -32,25 +32,27 @@ void Controller::actOnDocumentAdd(PathRef File, nixf::parse(*Draft->Contents, Diagnostics); if (!AST) { - std::lock_guard G(TUsLock); publishDiagnostics(File, Version, *Src, Diagnostics); - TUs.insert_or_assign(File, - std::make_shared(std::move(Diagnostics), - std::move(AST), std::nullopt, - /*VLA=*/nullptr, Src)); + auto TU = std::make_shared(std::move(Diagnostics), std::move(AST), + std::nullopt, /*VLA=*/nullptr, Src); + std::lock_guard G(TUsLock); + TUs.insert_or_assign(File, std::move(TU)); return; } auto VLA = std::make_unique(Diagnostics); VLA->runOnAST(*AST); - publishDiagnostics(File, Version, *Src, Diagnostics); + auto TU = std::make_shared(std::move(Diagnostics), std::move(AST), + std::nullopt, std::move(VLA), Src); + TU->setNixdDiagnostics(collectOptionDiagnostics(*TU)); + + publishDiagnostics(File, Version, *Src, TU->diagnostics(), + TU->nixdDiagnostics()); { std::lock_guard G(TUsLock); - TUs.insert_or_assign( - File, std::make_shared(std::move(Diagnostics), std::move(AST), - std::nullopt, std::move(VLA), Src)); + TUs.insert_or_assign(File, std::move(TU)); return; } }; diff --git a/nixd/lib/Eval/AttrSetProvider.cpp b/nixd/lib/Eval/AttrSetProvider.cpp index 01c0a5d19..c590da07f 100644 --- a/nixd/lib/Eval/AttrSetProvider.cpp +++ b/nixd/lib/Eval/AttrSetProvider.cpp @@ -125,9 +125,34 @@ void fillOptionDeclarations(nix::EvalState &State, nix::Value &V, } } +std::optional> +getEnumValuesFromFunctor(nix::EvalState &State, nix::Value &VType) { + try { + nix::Value &Values = + nixt::selectStringViews(State, VType, {"functor", "payload", "values"}); + State.forceValue(Values, nix::noPos); + if (Values.type() != nix::ValueType::nList) + return std::nullopt; + + std::vector EnumValues; + for (nix::Value *Item : Values.listView()) { + State.forceValue(*Item, nix::noPos); + if (Item->type() != nix::ValueType::nString) + return std::nullopt; + EnumValues.emplace_back(Item->string_view()); + } + return EnumValues; + } catch (std::exception &E) { + return std::nullopt; + } +} + void fillOptionType(nix::EvalState &State, nix::Value &VType, OptionType &R) { fillString(State, VType, {"description"}, R.Description); fillString(State, VType, {"name"}, R.Name); + + if (R.Name && *R.Name == "enum") + R.EnumValues = getEnumValuesFromFunctor(State, VType); } void fillOptionDescription(nix::EvalState &State, nix::Value &V, diff --git a/nixd/lib/Protocol/AttrSet.cpp b/nixd/lib/Protocol/AttrSet.cpp index cf3dc2e8a..d427092a6 100644 --- a/nixd/lib/Protocol/AttrSet.cpp +++ b/nixd/lib/Protocol/AttrSet.cpp @@ -4,16 +4,20 @@ using namespace nixd; using namespace llvm::json; Value nixd::toJSON(const OptionType &Params) { - return Object{ + Object Result{ {"Description", Params.Description}, {"Name", Params.Name}, }; + if (Params.EnumValues) + Result["EnumValues"] = *Params.EnumValues; + return Result; } bool nixd::fromJSON(const Value &Params, OptionType &R, Path P) { ObjectMapper O(Params, P); return O // && O.mapOptional("Description", R.Description) // + && O.mapOptional("EnumValues", R.EnumValues) // && O.mapOptional("Name", R.Name); } diff --git a/nixd/lib/meson.build b/nixd/lib/meson.build index 9edbfaee1..c496cce4b 100644 --- a/nixd/lib/meson.build +++ b/nixd/lib/meson.build @@ -35,6 +35,7 @@ libnixd_lib = library( 'Controller/InlayHints.cpp', 'Controller/LifeTime.cpp', 'Controller/NixTU.cpp', + 'Controller/OptionDiagnostics.cpp', 'Controller/Rename.cpp', 'Controller/SemanticTokens.cpp', 'Controller/Support.cpp', diff --git a/nixd/tools/nixd/test/diagnostic/option-enum-empty-value.md b/nixd/tools/nixd/test/diagnostic/option-enum-empty-value.md new file mode 100644 index 000000000..66d979e44 --- /dev/null +++ b/nixd/tools/nixd/test/diagnostic/option-enum-empty-value.md @@ -0,0 +1,38 @@ +# RUN: nixd --lit-test \ +# RUN: --nixos-options-expr='let roleType = { name = "enum"; description = "one of \"server\", \"agent\""; functor.payload.values = [ "server" "agent" ]; }; in { demo.role = { _type = "option"; type = roleType; }; }' \ +# RUN: < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + +<-- textDocument/didOpen + +```nix file:///basic.nix +{ demo.role = ""; } +``` + +``` + CHECK: "method": "textDocument/publishDiagnostics", + CHECK: "code": "option-value-type", + CHECK: "message": "definition `\"\"` for option `demo.role` is not of type `one of \"server\", \"agent\"`.", + CHECK: "severity": 1, + CHECK: "source": "nixd" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/diagnostic/option-enum-value.md b/nixd/tools/nixd/test/diagnostic/option-enum-value.md new file mode 100644 index 000000000..6b027f6e3 --- /dev/null +++ b/nixd/tools/nixd/test/diagnostic/option-enum-value.md @@ -0,0 +1,38 @@ +# RUN: nixd --lit-test \ +# RUN: --nixos-options-expr='let roleType = { name = "enum"; description = "one of \"server\", \"agent\""; functor.payload.values = [ "server" "agent" ]; }; in { demo.role = { _type = "option"; type = roleType; }; }' \ +# RUN: < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + +<-- textDocument/didOpen + +```nix file:///basic.nix +{ demo.role = "wrongValue"; } +``` + +``` + CHECK: "method": "textDocument/publishDiagnostics", + CHECK: "code": "option-value-type", + CHECK: "message": "definition `\"wrongValue\"` for option `demo.role` is not of type `one of \"server\", \"agent\"`.", + CHECK: "severity": 1, + CHECK: "source": "nixd" +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` From 772de274f7ed751c7c90964199efc18834528724 Mon Sep 17 00:00:00 2001 From: Christoph Wolf Date: Sun, 12 Apr 2026 09:31:44 +0200 Subject: [PATCH 2/3] feat(completion): add enum completions --- nixd/lib/Controller/Completion.cpp | 96 +++++++++++++-- nixd/lib/Controller/CompletionOptionEnum.h | 52 +++++++++ .../Controller/CompletionOptionEnumCommon.cpp | 109 ++++++++++++++++++ .../CompletionOptionEnumMissingValue.cpp | 70 +++++++++++ .../Controller/CompletionOptionEnumName.cpp | 31 +++++ .../Controller/CompletionOptionEnumValue.cpp | 53 +++++++++ nixd/lib/meson.build | 4 + .../completion/option-enum-missing-value.md | 80 +++++++++++++ .../nixd/test/completion/option-enum-name.md | 92 +++++++++++++++ .../nixd/test/completion/option-enum-value.md | 76 ++++++++++++ 10 files changed, 656 insertions(+), 7 deletions(-) create mode 100644 nixd/lib/Controller/CompletionOptionEnum.h create mode 100644 nixd/lib/Controller/CompletionOptionEnumCommon.cpp create mode 100644 nixd/lib/Controller/CompletionOptionEnumMissingValue.cpp create mode 100644 nixd/lib/Controller/CompletionOptionEnumName.cpp create mode 100644 nixd/lib/Controller/CompletionOptionEnumValue.cpp create mode 100644 nixd/tools/nixd/test/completion/option-enum-missing-value.md create mode 100644 nixd/tools/nixd/test/completion/option-enum-name.md create mode 100644 nixd/tools/nixd/test/completion/option-enum-value.md diff --git a/nixd/lib/Controller/Completion.cpp b/nixd/lib/Controller/Completion.cpp index bea1e3e56..1a7bfe9af 100644 --- a/nixd/lib/Controller/Completion.cpp +++ b/nixd/lib/Controller/Completion.cpp @@ -5,9 +5,11 @@ #include "AST.h" #include "CheckReturn.h" +#include "CompletionOptionEnum.h" #include "Convert.h" #include "lspserver/Protocol.h" +#include "lspserver/SourceCode.h" #include "nixd/Controller/Controller.h" #include "nixd/Protocol/AttrSet.h" @@ -16,7 +18,10 @@ #include +#include + #include +#include #include #include #include @@ -183,14 +188,55 @@ class OptionCompletionProvider { return Ret; } + static std::string escapeSnippetChoice(llvm::StringRef Origin) { + std::string Ret; + for (char Ch : Origin) { + if (Ch == '\\' || Ch == ',' || Ch == '|' || Ch == '}') + Ret += "\\"; + Ret += Ch; + } + return Ret; + } + + static std::optional + enumChoiceSnippet(const std::vector &EnumValues) { + if (EnumValues.empty()) + return std::nullopt; + + std::string Choices; + for (std::size_t I = 0; I < EnumValues.size(); ++I) { + if (I != 0) + Choices += ","; + Choices += escapeSnippetChoice(quoteNixString(EnumValues[I])); + } + return "${1|" + Choices + "|}"; + } + + static std::optional + defaultEnumValue(const OptionDescription &Desc) { + if (!Desc.Type || !Desc.Type->EnumValues || Desc.Type->EnumValues->empty()) + return std::nullopt; + return quoteNixString(Desc.Type->EnumValues->front()); + } + void fillInsertText(CompletionItem &Item, const std::string &Name, const OptionDescription &Desc) const { + std::optional EnumDefault = defaultEnumValue(Desc); if (!ClientSupportSnippet) { Item.insertTextFormat = InsertTextFormat::PlainText; - Item.insertText = Name + " = " + Desc.Example.value_or("") + ";"; + Item.insertText = + Name + " = " + EnumDefault.value_or(Desc.Example.value_or("")) + ";"; return; } Item.insertTextFormat = InsertTextFormat::Snippet; + if (Desc.Type && Desc.Type->EnumValues) { + if (std::optional Choice = + enumChoiceSnippet(*Desc.Type->EnumValues)) { + Item.insertText = Name + " = " + *Choice + ";"; + return; + } + } + Item.insertText = Name + " = " + "${1:" + escapeCharacters({'\\', '$', '}'}, Desc.Example.value_or("")) + @@ -245,6 +291,9 @@ class OptionCompletionProvider { Item.detail += "? (missing type)"; } addItem(Items, std::move(Item)); + addOptionEnumNameItems( + Field, Desc, ModuleOrigin, + [&Items](CompletionItem Item) { addItem(Items, std::move(Item)); }); } else { Item.kind = OptionAttrKind; addItem(Items, std::move(Item)); @@ -378,27 +427,46 @@ void Controller::onCompletion(const CompletionParams &Params, Callback Reply) { using CheckTy = CompletionList; auto Action = [Reply = std::move(Reply), URI = Params.textDocument.uri, - Pos = toNixfPosition(Params.position), this]() mutable { + Pos = toNixfPosition(Params.position), + LSPPos = Params.position, this]() mutable { const auto File = URI.file().str(); return Reply([&]() -> llvm::Expected { const auto TU = CheckDefault(getTU(File)); const auto AST = CheckDefault(getAST(*TU)); + llvm::Expected MaybeOffset = + lspserver::positionToOffset(TU->src(), LSPPos); + std::optional Offset; + if (MaybeOffset) + Offset = *MaybeOffset; + else + llvm::consumeError(MaybeOffset.takeError()); const auto *Desc = AST->descend({Pos, Pos}); - CheckDefault(Desc && Desc->children().empty()); + CheckDefault(Desc); const auto &N = *Desc; const auto &PM = *TU->parentMap(); - const auto &UpExpr = *CheckDefault(PM.upExpr(N)); + const auto *UpExpr = Desc->children().empty() ? PM.upExpr(N) : nullptr; return [&]() { CompletionList List; const VariableLookupAnalysis &VLA = *TU->variableLookup(); try { - switch (UpExpr.kind()) { + if (!UpExpr) { + if (List.items.empty() && Offset) + completeOptionEnumValuesAfterEq( + TU->src(), *Offset, OptionsLock, Options, + [&List](CompletionItem Item) { + addItem(List.items, std::move(Item)); + }); + return List; + } + + switch (UpExpr->kind()) { // In these cases, assume the cursor have "variable" scoping. case Node::NK_ExprVar: { - completeVarName(VLA, PM, static_cast(UpExpr), + completeVarName(VLA, PM, + static_cast(*UpExpr), *nixpkgsClient(), List.items); return List; } @@ -407,7 +475,7 @@ void Controller::onCompletion(const CompletionParams &Params, // foo.| // foo.a.bar| case Node::NK_ExprSelect: { - const auto &Select = static_cast(UpExpr); + const auto &Select = static_cast(*UpExpr); completeSelect(Select, *nixpkgsClient(), VLA, PM, N.kind() == Node::NK_Dot, List.items); return List; @@ -415,6 +483,20 @@ void Controller::onCompletion(const CompletionParams &Params, case Node::NK_ExprAttrs: { completeAttrPath(N, PM, OptionsLock, Options, ClientCaps.CompletionSnippets, List.items); + if (List.items.empty() && Offset) + completeOptionEnumValuesAfterEq( + TU->src(), *Offset, OptionsLock, Options, + [&List](CompletionItem Item) { + addItem(List.items, std::move(Item)); + }); + return List; + } + case Node::NK_ExprString: { + completeOptionEnumValuesInString( + N, PM, OptionsLock, Options, + [&List](CompletionItem Item) { + addItem(List.items, std::move(Item)); + }); return List; } default: diff --git a/nixd/lib/Controller/CompletionOptionEnum.h b/nixd/lib/Controller/CompletionOptionEnum.h new file mode 100644 index 000000000..4b802fdf4 --- /dev/null +++ b/nixd/lib/Controller/CompletionOptionEnum.h @@ -0,0 +1,52 @@ +#pragma once + +#include "lspserver/Protocol.h" + +#include "nixd/Controller/Controller.h" +#include "nixd/Protocol/AttrSet.h" + +#include + +#include +#include +#include +#include +#include + +namespace nixf { +class Node; +class ParentMapAnalysis; +} // namespace nixf + +namespace nixd { + +using AddCompletionItem = std::function; + +std::string quoteNixString(llvm::StringRef Origin); + +std::optional +resolveOptionInfo(AttrSetClient &Client, const std::vector &Scope); + +void completeOptionEnumValuesForScope(const std::vector &Scope, + std::mutex &OptionsLock, + Controller::OptionMapTy &Options, + const AddCompletionItem &AddItem, + bool InsertQuotedValue, + std::optional Range = {}); + +void addOptionEnumNameItems(const OptionField &Field, const OptionDescription &Desc, + const std::string &ModuleOrigin, + const AddCompletionItem &AddItem); + +void completeOptionEnumValuesInString(const nixf::Node &N, + const nixf::ParentMapAnalysis &PM, + std::mutex &OptionsLock, + Controller::OptionMapTy &Options, + const AddCompletionItem &AddItem); + +void completeOptionEnumValuesAfterEq(llvm::StringRef Src, std::size_t Offset, + std::mutex &OptionsLock, + Controller::OptionMapTy &Options, + const AddCompletionItem &AddItem); + +} // namespace nixd diff --git a/nixd/lib/Controller/CompletionOptionEnumCommon.cpp b/nixd/lib/Controller/CompletionOptionEnumCommon.cpp new file mode 100644 index 000000000..9132df956 --- /dev/null +++ b/nixd/lib/Controller/CompletionOptionEnumCommon.cpp @@ -0,0 +1,109 @@ +#include "CompletionOptionEnum.h" + +#include + +#include + +using namespace nixd; +using namespace lspserver; + +namespace { + +std::string escapeNixString(llvm::StringRef Origin) { + std::string Ret; + for (char Ch : Origin) { + switch (Ch) { + case '\\': + Ret += "\\\\"; + break; + case '"': + Ret += "\\\""; + break; + case '$': + Ret += "\\$"; + break; + case '\n': + Ret += "\\n"; + break; + case '\r': + Ret += "\\r"; + break; + case '\t': + Ret += "\\t"; + break; + default: + Ret += Ch; + break; + } + } + return Ret; +} + +} // namespace + +std::string nixd::quoteNixString(llvm::StringRef Origin) { + return "\"" + escapeNixString(Origin) + "\""; +} + +std::optional +nixd::resolveOptionInfo(AttrSetClient &Client, + const std::vector &Scope) { + std::binary_semaphore Ready(0); + std::optional Desc; + auto OnReply = [&Ready, &Desc](llvm::Expected Resp) { + if (Resp) + Desc = *Resp; + else + llvm::consumeError(Resp.takeError()); + Ready.release(); + }; + + Client.optionInfo(Scope, std::move(OnReply)); + Ready.acquire(); + return Desc; +} + +void nixd::completeOptionEnumValuesForScope( + const std::vector &Scope, std::mutex &OptionsLock, + Controller::OptionMapTy &Options, const AddCompletionItem &AddItem, + bool InsertQuotedValue, std::optional Range) { + std::lock_guard _(OptionsLock); + for (const auto &[Name, Provider] : Options) { + AttrSetClient *Client = Provider->client(); + if (!Client) [[unlikely]] { + elog("skipped client {0} as it is dead", Name); + continue; + } + + std::optional Desc = resolveOptionInfo(*Client, Scope); + if (!Desc || !Desc->Type) + continue; + + const OptionType &Type = *Desc->Type; + if (Type.Name != "enum" || !Type.EnumValues || Type.EnumValues->empty()) + continue; + + std::string Detail = Name; + if (Type.Description) + Detail += " | " + *Type.Description; + + for (const std::string &Value : *Type.EnumValues) { + CompletionItem Item{ + .label = Value, + .kind = CompletionItemKind::EnumMember, + .detail = Detail, + }; + if (InsertQuotedValue) { + Item.insertText = quoteNixString(Value); + Item.insertTextFormat = InsertTextFormat::PlainText; + } else if (Range) { + Item.insertTextFormat = InsertTextFormat::PlainText; + Item.textEdit = TextEdit{ + .range = *Range, + .newText = " = " + quoteNixString(Value) + ";", + }; + } + AddItem(std::move(Item)); + } + } +} diff --git a/nixd/lib/Controller/CompletionOptionEnumMissingValue.cpp b/nixd/lib/Controller/CompletionOptionEnumMissingValue.cpp new file mode 100644 index 000000000..a00f583f1 --- /dev/null +++ b/nixd/lib/Controller/CompletionOptionEnumMissingValue.cpp @@ -0,0 +1,70 @@ +#include "CompletionOptionEnum.h" + +#include + +#include +#include + +using namespace nixd; + +namespace { + +bool isPathChar(char Ch) { + return std::isalnum(static_cast(Ch)) || Ch == '_' || + Ch == '-' || Ch == '\'' || Ch == '.'; +} + +std::optional> +findOptionScopeBeforeMissingValue(llvm::StringRef Src, std::size_t Offset) { + Offset = std::min(Offset, Src.size()); + llvm::StringRef BeforeCursor = Src.take_front(Offset); + std::size_t Eq = BeforeCursor.rfind('='); + if (Eq == llvm::StringRef::npos) + return std::nullopt; + + if (!BeforeCursor.drop_front(Eq + 1).trim().empty()) + return std::nullopt; + + llvm::StringRef BeforeEq = BeforeCursor.take_front(Eq).rtrim(); + std::size_t End = BeforeEq.size(); + std::size_t Begin = End; + while (Begin > 0 && isPathChar(BeforeEq[Begin - 1])) + --Begin; + + llvm::StringRef Path = BeforeEq.slice(Begin, End).trim(); + if (Path.empty()) + return std::nullopt; + + std::vector Scope; + llvm::SmallVector Parts; + Path.split(Parts, '.'); + Scope.reserve(Parts.size()); + for (llvm::StringRef Part : Parts) { + Part = Part.trim(); + if (Part.empty()) + return std::nullopt; + Scope.emplace_back(Part.str()); + } + + if (!Scope.empty() && Scope.front() == "config") + Scope.erase(Scope.begin()); + if (Scope.empty()) + return std::nullopt; + return Scope; +} + +} // namespace + +void nixd::completeOptionEnumValuesAfterEq(llvm::StringRef Src, + std::size_t Offset, + std::mutex &OptionsLock, + Controller::OptionMapTy &Options, + const AddCompletionItem &AddItem) { + std::optional> Scope = + findOptionScopeBeforeMissingValue(Src, Offset); + if (!Scope) + return; + + completeOptionEnumValuesForScope(*Scope, OptionsLock, Options, AddItem, + /*InsertQuotedValue=*/true); +} diff --git a/nixd/lib/Controller/CompletionOptionEnumName.cpp b/nixd/lib/Controller/CompletionOptionEnumName.cpp new file mode 100644 index 000000000..34e182bb8 --- /dev/null +++ b/nixd/lib/Controller/CompletionOptionEnumName.cpp @@ -0,0 +1,31 @@ +#include "CompletionOptionEnum.h" + +using namespace nixd; +using namespace lspserver; + +void nixd::addOptionEnumNameItems(const OptionField &Field, + const OptionDescription &Desc, + const std::string &ModuleOrigin, + const AddCompletionItem &AddItem) { + if (!Desc.Type || !Desc.Type->EnumValues) + return; + + std::string Detail = ModuleOrigin; + if (Desc.Type->Description) + Detail += " | " + *Desc.Type->Description; + + for (const std::string &Value : *Desc.Type->EnumValues) { + AddItem(CompletionItem{ + .label = Field.Name + " = " + Value, + .kind = CompletionItemKind::EnumMember, + .detail = Detail, + .documentation = + MarkupContent{ + .kind = MarkupKind::Markdown, + .value = Desc.Description.value_or(""), + }, + .insertText = Field.Name + " = " + quoteNixString(Value) + ";", + .insertTextFormat = InsertTextFormat::PlainText, + }); + } +} diff --git a/nixd/lib/Controller/CompletionOptionEnumValue.cpp b/nixd/lib/Controller/CompletionOptionEnumValue.cpp new file mode 100644 index 000000000..8b9aa7d01 --- /dev/null +++ b/nixd/lib/Controller/CompletionOptionEnumValue.cpp @@ -0,0 +1,53 @@ +#include "AST.h" +#include "CompletionOptionEnum.h" + +using namespace nixd; +using namespace nixf; + +namespace { + +const Binding *findStringValueBinding(const Node &N, + const ParentMapAnalysis &PM) { + const Node *StringNode = PM.upTo(N, Node::NK_ExprString); + if (!StringNode) + return nullptr; + + const Node *BindingNode = PM.upTo(*StringNode, Node::NK_Binding); + if (!BindingNode) + return nullptr; + + const auto &OptionBinding = static_cast(*BindingNode); + if (OptionBinding.value().get() != StringNode) + return nullptr; + + const auto &String = static_cast(*StringNode); + if (!String.parts().fragments().empty() && !String.isLiteral()) + return nullptr; + + return &OptionBinding; +} + +} // namespace + +void nixd::completeOptionEnumValuesInString(const Node &N, + const ParentMapAnalysis &PM, + std::mutex &OptionsLock, + Controller::OptionMapTy &Options, + const AddCompletionItem &AddItem) { + const Binding *OptionBinding = findStringValueBinding(N, PM); + if (!OptionBinding) + return; + + const auto &Names = OptionBinding->path().names(); + if (Names.empty()) + return; + + std::vector Scope; + if (findAttrPathForOptions(*Names.back(), PM, Scope) != + FindAttrPathResult::OK || + Scope.empty()) + return; + + completeOptionEnumValuesForScope(Scope, OptionsLock, Options, AddItem, + /*InsertQuotedValue=*/false); +} diff --git a/nixd/lib/meson.build b/nixd/lib/meson.build index c496cce4b..e31359214 100644 --- a/nixd/lib/meson.build +++ b/nixd/lib/meson.build @@ -21,6 +21,10 @@ libnixd_lib = library( 'Controller/CodeActions/Utils.cpp', 'Controller/CodeActions/WithToLet.cpp', 'Controller/Completion.cpp', + 'Controller/CompletionOptionEnumCommon.cpp', + 'Controller/CompletionOptionEnumMissingValue.cpp', + 'Controller/CompletionOptionEnumName.cpp', + 'Controller/CompletionOptionEnumValue.cpp', 'Controller/Configuration.cpp', 'Controller/Convert.cpp', 'Controller/Definition.cpp', diff --git a/nixd/tools/nixd/test/completion/option-enum-missing-value.md b/nixd/tools/nixd/test/completion/option-enum-missing-value.md new file mode 100644 index 000000000..ecdec757d --- /dev/null +++ b/nixd/tools/nixd/test/completion/option-enum-missing-value.md @@ -0,0 +1,80 @@ +# RUN: nixd --lit-test \ +# RUN: --nixos-options-expr='let roleType = { name = "enum"; description = "one of \"server\", \"agent\""; functor.payload.values = [ "server" "agent" ]; }; in { demo.role = { _type = "option"; type = roleType; }; }' \ +# RUN: < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + +<-- textDocument/didOpen + +```nix file:///completion.nix +{ demo.role = ; } +``` + +<-- textDocument/completion(1) + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "textDocument/completion", + "params": { + "textDocument": { + "uri": "file:///completion.nix" + }, + "position": { + "line": 0, + "character": 14 + }, + "context": { + "triggerKind": 1 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": { +CHECK-NEXT: "isIncomplete": false, +CHECK-NEXT: "items": [ +CHECK-NEXT: { +CHECK-NEXT: "data": "", +CHECK-NEXT: "detail": "nixos | one of \"server\", \"agent\"", +CHECK-NEXT: "insertText": "\"server\"", +CHECK-NEXT: "insertTextFormat": 1, +CHECK-NEXT: "kind": 20, +CHECK-NEXT: "label": "server", +CHECK-NEXT: "score": 0 +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "data": "", +CHECK-NEXT: "detail": "nixos | one of \"server\", \"agent\"", +CHECK-NEXT: "insertText": "\"agent\"", +CHECK-NEXT: "insertTextFormat": 1, +CHECK-NEXT: "kind": 20, +CHECK-NEXT: "label": "agent", +CHECK-NEXT: "score": 0 +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/completion/option-enum-name.md b/nixd/tools/nixd/test/completion/option-enum-name.md new file mode 100644 index 000000000..f05ceeebd --- /dev/null +++ b/nixd/tools/nixd/test/completion/option-enum-name.md @@ -0,0 +1,92 @@ +# RUN: nixd --lit-test \ +# RUN: --nixos-options-expr='let roleType = { name = "enum"; description = "one of \"server\", \"agent\""; functor.payload.values = [ "server" "agent" ]; }; in { demo.role = { _type = "option"; type = roleType; }; }' \ +# RUN: < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + +<-- textDocument/didOpen + +```nix file:///completion.nix +{ demo.ro } +``` + +<-- textDocument/completion(1) + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "textDocument/completion", + "params": { + "textDocument": { + "uri": "file:///completion.nix" + }, + "position": { + "line": 0, + "character": 9 + }, + "context": { + "triggerKind": 1 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": { +CHECK-NEXT: "isIncomplete": false, +CHECK-NEXT: "items": [ +CHECK-NEXT: { +CHECK-NEXT: "data": "", +CHECK-NEXT: "detail": "nixos | enum (one of \"server\", \"agent\")", +CHECK-NEXT: "documentation": null, +CHECK-NEXT: "insertText": "role = \"server\";", +CHECK-NEXT: "insertTextFormat": 1, +CHECK-NEXT: "kind": 4, +CHECK-NEXT: "label": "role", +CHECK-NEXT: "score": 0 +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "data": "", +CHECK-NEXT: "detail": "nixos | one of \"server\", \"agent\"", +CHECK-NEXT: "documentation": null, +CHECK-NEXT: "insertText": "role = \"server\";", +CHECK-NEXT: "insertTextFormat": 1, +CHECK-NEXT: "kind": 20, +CHECK-NEXT: "label": "role = server", +CHECK-NEXT: "score": 0 +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "data": "", +CHECK-NEXT: "detail": "nixos | one of \"server\", \"agent\"", +CHECK-NEXT: "documentation": null, +CHECK-NEXT: "insertText": "role = \"agent\";", +CHECK-NEXT: "insertTextFormat": 1, +CHECK-NEXT: "kind": 20, +CHECK-NEXT: "label": "role = agent", +CHECK-NEXT: "score": 0 +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` diff --git a/nixd/tools/nixd/test/completion/option-enum-value.md b/nixd/tools/nixd/test/completion/option-enum-value.md new file mode 100644 index 000000000..093b88776 --- /dev/null +++ b/nixd/tools/nixd/test/completion/option-enum-value.md @@ -0,0 +1,76 @@ +# RUN: nixd --lit-test \ +# RUN: --nixos-options-expr='let roleType = { name = "enum"; description = "one of \"server\", \"agent\""; functor.payload.values = [ "server" "agent" ]; }; in { demo.role = { _type = "option"; type = roleType; }; }' \ +# RUN: < %s | FileCheck %s + +<-- initialize(0) + +```json +{ + "jsonrpc":"2.0", + "id":0, + "method":"initialize", + "params":{ + "processId":123, + "rootPath":"", + "capabilities":{ + }, + "trace":"off" + } +} +``` + +<-- textDocument/didOpen + +```nix file:///completion.nix +{ demo.role = ""; } +``` + +<-- textDocument/completion(1) + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "textDocument/completion", + "params": { + "textDocument": { + "uri": "file:///completion.nix" + }, + "position": { + "line": 0, + "character": 15 + }, + "context": { + "triggerKind": 1 + } + } +} +``` + +``` + CHECK: "id": 1, +CHECK-NEXT: "jsonrpc": "2.0", +CHECK-NEXT: "result": { +CHECK-NEXT: "isIncomplete": false, +CHECK-NEXT: "items": [ +CHECK-NEXT: { +CHECK-NEXT: "data": "", +CHECK-NEXT: "detail": "nixos | one of \"server\", \"agent\"", +CHECK-NEXT: "kind": 20, +CHECK-NEXT: "label": "server", +CHECK-NEXT: "score": 0 +CHECK-NEXT: }, +CHECK-NEXT: { +CHECK-NEXT: "data": "", +CHECK-NEXT: "detail": "nixos | one of \"server\", \"agent\"", +CHECK-NEXT: "kind": 20, +CHECK-NEXT: "label": "agent", +CHECK-NEXT: "score": 0 +CHECK-NEXT: } +CHECK-NEXT: ] +CHECK-NEXT: } +``` + +```json +{"jsonrpc":"2.0","method":"exit"} +``` From 36280c2b17c415b3b852abde6336c4c2a9d19357 Mon Sep 17 00:00:00 2001 From: Christoph Wolf Date: Sun, 12 Apr 2026 09:49:47 +0200 Subject: [PATCH 3/3] chore(completion): abide to formatting rules --- nixd/lib/Controller/Completion.cpp | 3 +-- nixd/lib/Controller/CompletionOptionEnum.h | 13 ++++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/nixd/lib/Controller/Completion.cpp b/nixd/lib/Controller/Completion.cpp index 1a7bfe9af..b96cf4579 100644 --- a/nixd/lib/Controller/Completion.cpp +++ b/nixd/lib/Controller/Completion.cpp @@ -493,8 +493,7 @@ void Controller::onCompletion(const CompletionParams &Params, } case Node::NK_ExprString: { completeOptionEnumValuesInString( - N, PM, OptionsLock, Options, - [&List](CompletionItem Item) { + N, PM, OptionsLock, Options, [&List](CompletionItem Item) { addItem(List.items, std::move(Item)); }); return List; diff --git a/nixd/lib/Controller/CompletionOptionEnum.h b/nixd/lib/Controller/CompletionOptionEnum.h index 4b802fdf4..8cd1970b3 100644 --- a/nixd/lib/Controller/CompletionOptionEnum.h +++ b/nixd/lib/Controller/CompletionOptionEnum.h @@ -27,14 +27,13 @@ std::string quoteNixString(llvm::StringRef Origin); std::optional resolveOptionInfo(AttrSetClient &Client, const std::vector &Scope); -void completeOptionEnumValuesForScope(const std::vector &Scope, - std::mutex &OptionsLock, - Controller::OptionMapTy &Options, - const AddCompletionItem &AddItem, - bool InsertQuotedValue, - std::optional Range = {}); +void completeOptionEnumValuesForScope( + const std::vector &Scope, std::mutex &OptionsLock, + Controller::OptionMapTy &Options, const AddCompletionItem &AddItem, + bool InsertQuotedValue, std::optional Range = {}); -void addOptionEnumNameItems(const OptionField &Field, const OptionDescription &Desc, +void addOptionEnumNameItems(const OptionField &Field, + const OptionDescription &Desc, const std::string &ModuleOrigin, const AddCompletionItem &AddItem);