Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 95 additions & 2 deletions nixd/lib/Controller/CodeAction.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,96 @@

#include "nixd/Controller/Controller.h"

#include <nixf/Basic/Nodes/Attrs.h>
#include <nixf/Sema/ParentMap.h>

#include <boost/asio/post.hpp>

#include <set>

namespace nixd {

using namespace llvm::json;
using namespace lspserver;

namespace {

/// \brief Create a CodeAction with a single text edit.
CodeAction createSingleEditAction(const std::string &Title,
llvm::StringLiteral Kind,
const std::string &FileURI,
const lspserver::Range &EditRange,
std::string NewText) {
std::vector<TextEdit> Edits;
Edits.emplace_back(
TextEdit{.range = EditRange, .newText = std::move(NewText)});
using Changes = std::map<std::string, std::vector<TextEdit>>;
WorkspaceEdit WE{.changes = Changes{{FileURI, std::move(Edits)}}};
return CodeAction{
.title = Title,
.kind = std::string(Kind),
.edit = std::move(WE),
};
}

/// \brief Check if a string is a valid Nix identifier that can be unquoted.
/// Valid identifiers: start with letter or underscore, contain letters,
/// digits, underscores, hyphens, or single quotes. Must not be a keyword.
bool isValidNixIdentifier(const std::string &S) {
if (S.empty())
return false;

// Check first character: must be letter or underscore
char First = S[0];
if (!((First >= 'a' && First <= 'z') || (First >= 'A' && First <= 'Z') ||
First == '_'))
return false;

// Check remaining characters
for (size_t I = 1; I < S.size(); ++I) {
char C = S[I];
if (!((C >= 'a' && C <= 'z') || (C >= 'A' && C <= 'Z') ||
(C >= '0' && C <= '9') || C == '_' || C == '-' || C == '\''))
return false;
}

// Check against Nix keywords and reserved literals
static const std::set<std::string> Keywords = {
"if", "then", "else", "assert", "with", "let", "in",
"rec", "inherit", "or", "true", "false", "null"};
return Keywords.find(S) == Keywords.end();
}

/// \brief Add refactoring code actions for attribute names (quote/unquote).
void addAttrNameActions(const nixf::Node &N, const nixf::ParentMapAnalysis &PM,
const std::string &FileURI, llvm::StringRef Src,
std::vector<CodeAction> &Actions) {
// Find if we're inside an AttrName
const nixf::Node *AttrNameNode = PM.upTo(N, nixf::Node::NK_AttrName);
if (!AttrNameNode)
return;

const auto &AN = static_cast<const nixf::AttrName &>(*AttrNameNode);

if (AN.kind() == nixf::AttrName::ANK_ID) {
// Offer to quote: foo -> "foo"
const std::string &Name = AN.id()->name();
Actions.emplace_back(createSingleEditAction(
"Quote attribute name", CodeAction::REFACTOR_REWRITE_KIND, FileURI,
toLSPRange(Src, AN.range()), "\"" + Name + "\""));
} else if (AN.kind() == nixf::AttrName::ANK_String && AN.isStatic()) {
// Offer to unquote if valid identifier: "foo" -> foo
const std::string &Name = AN.staticName();
if (isValidNixIdentifier(Name)) {
Actions.emplace_back(createSingleEditAction(
"Unquote attribute name", CodeAction::REFACTOR_REWRITE_KIND, FileURI,
toLSPRange(Src, AN.range()), Name));
}
}
}

} // namespace

void Controller::onCodeAction(const lspserver::CodeActionParams &Params,
Callback<std::vector<CodeAction>> Reply) {
using CheckTy = std::vector<CodeAction>;
Expand All @@ -27,6 +110,8 @@ void Controller::onCodeAction(const lspserver::CodeActionParams &Params,
const auto &Diagnostics = TU->diagnostics();
auto Actions = std::vector<CodeAction>();
Actions.reserve(Diagnostics.size());
std::string FileURI = URIForFile::canonicalize(File, File).uri();

for (const nixf::Diagnostic &D : Diagnostics) {
auto DRange = toLSPRange(TU->src(), D.range());
if (!Range.overlap(DRange))
Expand All @@ -43,9 +128,8 @@ void Controller::onCodeAction(const lspserver::CodeActionParams &Params,
});
}
using Changes = std::map<std::string, std::vector<TextEdit>>;
std::string FileURI = URIForFile::canonicalize(File, File).uri();
WorkspaceEdit WE{.changes = Changes{
{std::move(FileURI), std::move(Edits)},
{FileURI, std::move(Edits)},
}};
Actions.emplace_back(CodeAction{
.title = F.message(),
Expand All @@ -54,6 +138,15 @@ void Controller::onCodeAction(const lspserver::CodeActionParams &Params,
});
}
}

// Add refactoring code actions based on cursor position
if (TU->ast() && TU->parentMap()) {
nixf::PositionRange NixfRange = toNixfRange(Range);
if (const nixf::Node *N = TU->ast()->descend(NixfRange)) {
addAttrNameActions(*N, *TU->parentMap(), FileURI, TU->src(), Actions);
}
}

return Actions;
}());
};
Expand Down
3 changes: 2 additions & 1 deletion nixd/lib/Controller/LifeTime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ void Controller::
{
"codeActionProvider",
Object{
{"codeActionKinds", Array{CodeAction::QUICKFIX_KIND}},
{"codeActionKinds", Array{CodeAction::QUICKFIX_KIND,
CodeAction::REFACTOR_REWRITE_KIND}},
{"resolveProvider", false},
},
},
Expand Down
1 change: 1 addition & 0 deletions nixd/lspserver/include/lspserver/Protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,7 @@ struct CodeAction {
std::optional<std::string> kind;
const static llvm::StringLiteral QUICKFIX_KIND;
const static llvm::StringLiteral REFACTOR_KIND;
const static llvm::StringLiteral REFACTOR_REWRITE_KIND;
const static llvm::StringLiteral INFO_KIND;

/// The diagnostics that this code action resolves.
Expand Down
2 changes: 2 additions & 0 deletions nixd/lspserver/src/Protocol.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,8 @@ 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_REWRITE_KIND =
"refactor.rewrite";
const llvm::StringLiteral CodeAction::INFO_KIND = "info";

llvm::json::Value toJSON(const CodeAction &CA) {
Expand Down
69 changes: 69 additions & 0 deletions nixd/tools/nixd/test/code-action/keywords-comprehensive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# RUN: nixd --lit-test < %s | FileCheck %s

Test that Nix keywords cannot be unquoted (testing "if" - one of the reserved keywords)

<-- initialize(0)

```json
{
"jsonrpc":"2.0",
"id":0,
"method":"initialize",
"params":{
"processId":123,
"rootPath":"",
"capabilities":{
},
"trace":"off"
}
}
```


<-- textDocument/didOpen

```nix file:///keywords-comprehensive.nix
{ "if" = 1; }
```

<-- textDocument/codeAction(2)


```json
{
"jsonrpc":"2.0",
"id":2,
"method":"textDocument/codeAction",
"params":{
"textDocument":{
"uri":"file:///keywords-comprehensive.nix"
},
"range":{
"start":{
"line": 0,
"character":2
},
"end":{
"line":0,
"character":6
}
},
"context":{
"diagnostics":[],
"triggerKind":2
}
}
}
```

Keywords should return empty result (no unquote action available)

```
CHECK: "id": 2,
CHECK-NEXT: "jsonrpc": "2.0",
CHECK-NEXT: "result": []
```

```json
{"jsonrpc":"2.0","method":"exit"}
```
1 change: 0 additions & 1 deletion nixd/tools/nixd/test/code-action/quick-fix.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

# RUN: nixd --lit-test < %s | FileCheck %s

<-- initialize(0)
Expand Down
89 changes: 89 additions & 0 deletions nixd/tools/nixd/test/code-action/quote-attr.md
Original file line number Diff line number Diff line change
@@ -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:///quote-attr.nix
{ foo = 1; }
```

<-- textDocument/codeAction(2)


```json
{
"jsonrpc":"2.0",
"id":2,
"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": 2,
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"}
```
Loading