nixd
Loading...
Searching...
No Matches
ExtractToFile.cpp
Go to the documentation of this file.
1/// \file
2/// \brief Implementation of extract-to-file code action.
3
4#include "ExtractToFile.h"
5#include "Utils.h"
6
7#include "../Convert.h"
8
13
14#include <llvm/Support/FileSystem.h>
15#include <llvm/Support/Path.h>
16
17#include <set>
18#include <string_view>
19
20namespace nixd {
21
22namespace {
23
24/// \brief Result of free variable collection.
25struct FreeVariableResult {
26 std::set<std::string> FreeVars;
27 bool HasWithVars; ///< True if any variables come from 'with' scope
28};
29
30/// \brief Collect all free variables used within an expression subtree.
31///
32/// A free variable is one that is used (ExprVar) but defined outside the
33/// expression being extracted. We traverse the subtree and collect variable
34/// names that resolve to definitions outside our scope.
35class FreeVariableCollector {
36 const nixf::VariableLookupAnalysis &VLA;
37 const nixf::Node &Root;
38 std::set<std::string> FreeVars;
39 bool HasWithVars = false;
40
41 void collect(const nixf::Node &N) {
42 // If this is a variable reference, check if it's free
43 if (N.kind() == nixf::Node::NK_ExprVar) {
44 const auto &Var = static_cast<const nixf::ExprVar &>(N);
45 auto Result = VLA.query(Var);
46
47 // Variable is free if:
48 // - It's defined (not undefined/error)
49 // - Its definition is outside our extraction root
50 // - It's not a builtin
51 if (Result.Kind ==
53 Result.Def && !Result.Def->isBuiltin()) {
54 const nixf::Node *DefSyntax = Result.Def->syntax();
55 if (DefSyntax && !isInsideNode(DefSyntax, Root)) {
56 // This variable is defined outside our subtree - it's free
57 FreeVars.insert(std::string(Var.id().name()));
58 }
59 }
60 // Variables from 'with' are implicitly free since they depend on scope
61 // Note: These may not work correctly after extraction since the 'with'
62 // context is lost. We track this to warn the user.
63 else if (Result.Kind ==
65 FreeVars.insert(std::string(Var.id().name()));
66 HasWithVars = true;
67 }
68 }
69
70 // Recursively collect from children
71 for (const auto &Child : N.children()) {
72 if (Child)
73 collect(*Child);
74 }
75 }
76
77 /// \brief Check if a node is inside (or equal to) the root node.
78 static bool isInsideNode(const nixf::Node *N, const nixf::Node &Root) {
79 if (!N)
80 return false;
81 // Check if N's range is within Root's range
82 const auto &NRange = N->range();
83 const auto &RootRange = Root.range();
84 return NRange.lCur().offset() >= RootRange.lCur().offset() &&
85 NRange.rCur().offset() <= RootRange.rCur().offset();
86 }
87
88public:
89 FreeVariableCollector(const nixf::VariableLookupAnalysis &VLA,
90 const nixf::Node &Root)
91 : VLA(VLA), Root(Root) {}
92
93 FreeVariableResult collect() {
94 FreeVars.clear();
95 HasWithVars = false;
96 collect(Root);
97 return {FreeVars, HasWithVars};
98 }
99};
100
101/// \brief Generate a filename from the expression context.
102///
103/// Tries to derive a meaningful name from:
104/// 1. If inside a binding, use the binding key name
105/// 2. Otherwise, use a generic "extracted" name with expression type
106std::string generateFilename(const nixf::Node &N,
107 const nixf::ParentMapAnalysis &PM) {
108 // Check if we're inside a binding - use the binding key as filename
109 const nixf::Node *BindingNode = PM.upTo(N, nixf::Node::NK_Binding);
110 if (BindingNode) {
111 const auto &Binding = static_cast<const nixf::Binding &>(*BindingNode);
112 const auto &Names = Binding.path().names();
113 if (!Names.empty() && Names.back()->isStatic()) {
114 std::string Name = Names.back()->staticName();
115 // Sanitize: replace invalid chars with underscore
116 for (char &C : Name) {
117 if (!std::isalnum(static_cast<unsigned char>(C)) && C != '-' &&
118 C != '_') {
119 C = '_';
120 }
121 }
122 return Name + ".nix";
123 }
124 }
125
126 // Fallback: use expression type
127 switch (N.kind()) {
128 case nixf::Node::NK_ExprLambda:
129 return "extracted-lambda.nix";
130 case nixf::Node::NK_ExprAttrs:
131 return "extracted-attrs.nix";
132 case nixf::Node::NK_ExprList:
133 return "extracted-list.nix";
134 case nixf::Node::NK_ExprLet:
135 return "extracted-let.nix";
136 case nixf::Node::NK_ExprIf:
137 return "extracted-if.nix";
138 default:
139 return "extracted.nix";
140 }
141}
142
143/// \brief Generate the content for the new file.
144///
145/// If there are free variables, wraps the expression in a lambda:
146/// { freeVar1, freeVar2 }: <original expression>
147std::string generateExtractedContent(llvm::StringRef ExprSrc,
148 const std::set<std::string> &FreeVars) {
149 std::string Content;
150
151 if (!FreeVars.empty()) {
152 // Generate lambda with formal arguments
153 Content += "{ ";
154 bool First = true;
155 for (const auto &Var : FreeVars) {
156 if (!First)
157 Content += ", ";
158 First = false;
159 Content += Var;
160 }
161 Content += " }:\n";
162 }
163
164 Content += ExprSrc;
165 Content += "\n";
166 return Content;
167}
168
169/// \brief Generate the import statement to replace the original expression.
170///
171/// If there are free variables:
172/// import ./filename.nix { inherit var1 var2; }
173/// Otherwise:
174/// import ./filename.nix
175std::string generateImportStatement(const std::string &Filename,
176 const std::set<std::string> &FreeVars) {
177 std::string Import = "import ./";
178 Import += Filename;
179
180 if (!FreeVars.empty()) {
181 Import += " { inherit";
182 for (const auto &Var : FreeVars) {
183 Import += " ";
184 Import += Var;
185 }
186 Import += "; }";
187 }
188
189 return Import;
190}
191
192/// \brief Strip the "file://" scheme prefix from a URI if present.
193///
194/// LSP URIs typically have the form "file:///path/to/file", but
195/// URIForFile::canonicalize expects a filesystem path without the scheme.
196std::string stripFileScheme(llvm::StringRef URI) {
197 if (URI.starts_with("file://"))
198 return URI.drop_front(7).str();
199 return URI.str();
200}
201
202/// \brief Generate a unique filename by appending a numeric suffix if needed.
203///
204/// If the file already exists, tries filename-1.nix, filename-2.nix, etc.
205/// Returns the original filename if no conflict exists.
206std::string makeUniqueFilename(llvm::StringRef Directory,
207 llvm::StringRef BaseFilename) {
208 llvm::SmallString<256> TestPath(Directory);
209 llvm::sys::path::append(TestPath, BaseFilename);
210
211 if (!llvm::sys::fs::exists(TestPath))
212 return std::string(BaseFilename);
213
214 // Extract stem and extension
215 llvm::StringRef Stem = llvm::sys::path::stem(BaseFilename);
216 llvm::StringRef Ext = llvm::sys::path::extension(BaseFilename);
217
218 // Try numbered variants
219 constexpr int MaxAttempts = 100;
220 for (int I = 1; I < MaxAttempts; ++I) {
221 std::string Candidate = (Stem + "-" + std::to_string(I) + Ext).str();
222 TestPath = Directory;
223 llvm::sys::path::append(TestPath, Candidate);
224 if (!llvm::sys::fs::exists(TestPath))
225 return Candidate;
226 }
227
228 // Fallback: return original and let LSP client handle the error
229 return std::string(BaseFilename);
230}
231
232/// \brief Check if an expression is suitable for extraction.
233///
234/// We allow extraction of any expression node, but skip trivial cases
235/// like single identifiers or literals that wouldn't benefit from extraction.
236/// Also skip empty structures and simple select expressions.
237bool isExtractable(const nixf::Node &N) {
238 switch (N.kind()) {
239 // Skip trivial nodes that don't benefit from extraction
240 case nixf::Node::NK_ExprVar:
241 case nixf::Node::NK_ExprInt:
242 case nixf::Node::NK_ExprFloat:
243 case nixf::Node::NK_ExprString:
244 case nixf::Node::NK_ExprPath:
245 case nixf::Node::NK_ExprSPath:
246 // Select expressions like `lib.foo` are just references - not worth
247 // extracting
248 case nixf::Node::NK_ExprSelect:
249 return false;
250
251 // For attribute sets, check if non-empty
252 case nixf::Node::NK_ExprAttrs: {
253 const auto &Attrs = static_cast<const nixf::ExprAttrs &>(N);
254 // Empty attrsets {} are trivial
255 const nixf::Binds *Binds = Attrs.binds();
256 return Binds && !Binds->bindings().empty();
257 }
258
259 // For lists, check if non-empty
260 case nixf::Node::NK_ExprList: {
261 const auto &List = static_cast<const nixf::ExprList &>(N);
262 // Empty lists [] are trivial
263 return !List.elements().empty();
264 }
265
266 // Allow all other expression types
267 case nixf::Node::NK_ExprLambda:
268 case nixf::Node::NK_ExprLet:
269 case nixf::Node::NK_ExprIf:
270 case nixf::Node::NK_ExprWith:
271 case nixf::Node::NK_ExprCall:
272 case nixf::Node::NK_ExprBinOp:
273 case nixf::Node::NK_ExprUnaryOp:
274 case nixf::Node::NK_ExprOpHasAttr:
275 case nixf::Node::NK_ExprAssert:
276 case nixf::Node::NK_ExprParen:
277 return true;
278
279 default:
280 return false;
281 }
282}
283
284/// \brief Check if the given node is an expression node we can extract.
285///
286/// Only returns the node if it's directly extractable - does not walk up
287/// the AST. This ensures we only offer extraction for the exact expression
288/// under the cursor, not parent expressions.
289const nixf::Node *findExtractableExpr(const nixf::Node &N,
290 const nixf::ParentMapAnalysis &PM) {
291 // Only offer extraction if this specific node is extractable.
292 // Don't walk up - this prevents offering extraction for parent expressions
293 // when the user has their cursor on a child (like an attribute name).
294 if (isExtractable(N))
295 return &N;
296
297 // If the immediate node isn't extractable, check if we're inside a binding
298 // value - allow extraction of the binding's value expression.
299 // For example: { foo = { bar = 1; }; }
300 // cursor here ^-- should offer extraction of { bar = 1; }
301 if (N.kind() == nixf::Node::NK_Identifier ||
302 N.kind() == nixf::Node::NK_AttrName) {
303 // We're on an attribute name/identifier, check if parent is AttrPath
304 const nixf::Node *Parent = PM.query(N);
305 if (Parent && Parent->kind() == nixf::Node::NK_AttrPath) {
306 // Check if grandparent is Binding
307 const nixf::Node *GrandParent = PM.query(*Parent);
308 if (GrandParent && GrandParent->kind() == nixf::Node::NK_Binding) {
309 const auto &Binding = static_cast<const nixf::Binding &>(*GrandParent);
310 const auto &Value = Binding.value();
311 if (Value && isExtractable(*Value))
312 return Value.get();
313 }
314 }
315 }
316
317 return nullptr;
318}
319
320} // namespace
321
323 const nixf::ParentMapAnalysis &PM,
325 const std::string &FileURI, llvm::StringRef Src,
326 std::vector<lspserver::CodeAction> &Actions) {
327 // Find an extractable expression at or above the cursor
328 const nixf::Node *ExprNode = findExtractableExpr(N, PM);
329 if (!ExprNode)
330 return;
331
332 // Get the expression source text
333 std::string_view ExprSrc = ExprNode->src(Src);
334 if (ExprSrc.empty())
335 return;
336
337 // Collect free variables
338 FreeVariableCollector Collector(VLA, *ExprNode);
339 FreeVariableResult FreeVarResult = Collector.collect();
340 const std::set<std::string> &FreeVars = FreeVarResult.FreeVars;
341
342 // Generate filename from context
343 std::string BaseFilename = generateFilename(*ExprNode, PM);
344
345 // Build the directory path for the new file (same directory as source)
346 std::string SourceFilePath = stripFileScheme(FileURI);
347 llvm::SmallString<256> Directory(SourceFilePath);
348 llvm::sys::path::remove_filename(Directory);
349
350 // Make filename unique if a file with the same name already exists
351 std::string Filename = makeUniqueFilename(Directory, BaseFilename);
352
353 // Generate the new file content
354 std::string NewFileContent = generateExtractedContent(ExprSrc, FreeVars);
355
356 // Generate the import statement
357 std::string ImportStmt = generateImportStatement(Filename, FreeVars);
358
359 // Build the full file path
360 llvm::SmallString<256> NewFilePath(Directory);
361 llvm::sys::path::append(NewFilePath, Filename);
362
363 // Create the workspace edit with:
364 // 1. CreateFile operation for the new file
365 // 2. TextDocumentEdit to insert content into new file
366 // 3. TextDocumentEdit to replace original expression with import
367
368 lspserver::CreateFile CreateOp;
369 CreateOp.uri = lspserver::URIForFile::canonicalize(std::string(NewFilePath),
370 SourceFilePath);
372 // Use overwrite=false (default) so the operation fails if file exists.
373 // This prevents the inconsistent state where source is modified but
374 // the target file already exists with different content.
375 CreateOp.options->overwrite = false;
376 CreateOp.options->ignoreIfExists = false;
377
378 // Edit for new file: insert content at beginning
379 lspserver::TextDocumentEdit NewFileEdit;
380 NewFileEdit.textDocument.uri = CreateOp.uri;
381 NewFileEdit.textDocument.version = 0;
382 NewFileEdit.edits.push_back(lspserver::TextEdit{
383 .range = lspserver::Range{{0, 0}, {0, 0}},
384 .newText = NewFileContent,
385 });
386
387 // Edit for source file: replace expression with import
389 SourceEdit.textDocument.uri =
390 lspserver::URIForFile::canonicalize(SourceFilePath, SourceFilePath);
391 SourceEdit.edits.push_back(lspserver::TextEdit{
392 .range = toLSPRange(Src, ExprNode->range()),
393 .newText = ImportStmt,
394 });
395
396 // Build workspace edit with documentChanges (order matters!)
398 WE.documentChanges = std::vector<lspserver::DocumentChange>{};
399 WE.documentChanges->push_back(CreateOp);
400 WE.documentChanges->push_back(NewFileEdit);
401 WE.documentChanges->push_back(SourceEdit);
402
403 // Build action title
404 std::string Title = "Extract to " + Filename;
405 if (!FreeVars.empty()) {
406 Title += " (";
407 Title += std::to_string(FreeVars.size());
408 Title += " free variable";
409 if (FreeVars.size() > 1)
410 Title += "s";
411 // Warn about 'with' scope variables that may not work after extraction
412 if (FreeVarResult.HasWithVars)
413 Title += ", has 'with' vars";
414 Title += ")";
415 }
416
418 Action.title = std::move(Title);
419 Action.kind = std::string(lspserver::CodeAction::REFACTOR_KIND);
420 Action.edit = std::move(WE);
421
422 Actions.push_back(std::move(Action));
423}
424
425} // namespace nixd
Convert between LSP and nixf types.
Code action for extracting expressions to separate files.
Shared utilities for code actions.
const std::vector< std::shared_ptr< Node > > & bindings() const
Definition Attrs.h:180
PositionRange range() const
Definition Range.h:123
NodeKind kind() const
Definition Basic.h:34
std::string_view src(std::string_view Src) const
Definition Basic.h:63
LexerCursorRange range() const
Definition Basic.h:35
virtual ChildVector children() const =0
const Node * upTo(const Node &N, Node::NodeKind Kind) const
Search up until some kind of node is found.
Definition ParentMap.cpp:27
const Node * query(const Node &N) const
Definition ParentMap.cpp:13
void addExtractToFileAction(const nixf::Node &N, const nixf::ParentMapAnalysis &PM, const nixf::VariableLookupAnalysis &VLA, const std::string &FileURI, llvm::StringRef Src, std::vector< lspserver::CodeAction > &Actions)
Add extract-to-file action for selected expressions.
lspserver::Range toLSPRange(llvm::StringRef Code, const nixf::LexerCursorRange &R)
Definition Convert.cpp:40
std::string title
A short, human-readable, title for this code action.
static const llvm::StringLiteral REFACTOR_KIND
std::optional< WorkspaceEdit > edit
The workspace edit this code action performs.
std::optional< CreateFileOptions > options
Additional options.
URIForFile uri
The resource to create.
VersionedTextDocumentIdentifier textDocument
The text document to change.
static URIForFile canonicalize(llvm::StringRef AbsPath, llvm::StringRef TUPath)
std::optional< std::vector< DocumentChange > > documentChanges