Skip to main content

normalize_refactor/
actions.rs

1//! Semantic actions: query and mutation primitives for refactoring recipes.
2//!
3//! **Query actions** return data without side effects.
4//! **Mutation actions** produce `PlannedEdit`s without touching the filesystem.
5
6use std::collections::HashSet;
7use std::path::Path;
8
9use normalize_edit::SymbolLocation;
10use normalize_languages::parsers::{grammar_loader, parse_with_grammar};
11use normalize_languages::satisfies_predicates;
12use normalize_languages::support_for_path;
13use tree_sitter::StreamingIterator as _;
14
15use crate::{CallerRef, ImportRef, PlannedEdit, RefactoringContext, References};
16
17// ── Query actions ────────────────────────────────────────────────────
18
19/// Find a symbol's location in a file.
20pub fn locate_symbol(
21    ctx: &RefactoringContext,
22    file: &Path,
23    content: &str,
24    name: &str,
25) -> Option<SymbolLocation> {
26    ctx.editor.find_symbol(file, content, name, false)
27}
28
29/// Tree-sitter node `kind` values that count as a leading decoration
30/// (doc comment, attribute, decorator, annotation, pragma) for any language.
31///
32/// Comment kinds are matched separately by substring: any kind containing
33/// `"comment"` is treated as a decoration. Listed here are the non-comment
34/// kinds across the grammars normalize supports.
35const DECORATION_KINDS: &[&str] = &[
36    "attribute_item",       // Rust outer attribute `#[...]`
37    "inner_attribute_item", // Rust inner attribute `#![...]`
38    "meta_item",            // Rust attribute body
39    "attribute",            // C#, generic
40    "attribute_list",       // C#
41    "decorator",            // Python, JS/TS (TypeScript decorators)
42    "decorator_list",       // grouped decorators
43    "annotation",           // Java, Kotlin
44    "marker_annotation",    // Java
45    "modifiers",            // Java/Kotlin annotations live under `modifiers`
46    "pragma",               // C/C++
47    "preproc_call",         // C/C++ preprocessor lines like `#pragma`
48];
49
50fn is_decoration_kind(kind: &str) -> bool {
51    kind.contains("comment") || DECORATION_KINDS.contains(&kind)
52}
53
54/// Tree-sitter node kinds that wrap a definition together with its decorations
55/// (decorators, attributes, export modifier, etc.) under a single parent. When
56/// the captured symbol node's parent is one of these, leading comments live as
57/// siblings of the *wrapper*, not of the symbol — so the walk must climb to
58/// the wrapper before scanning previous siblings.
59const DECORATION_WRAPPER_KINDS: &[&str] = &[
60    "decorated_definition", // Python `@decorator\ndef foo()` / `@decorator\nclass Foo`
61    "export_statement",     // TypeScript/JavaScript `export function foo()` / `export class Foo`
62    "export_default_declaration", // TypeScript/JavaScript `export default class Foo`
63    "ambient_declaration",  // TypeScript `declare ...`
64];
65
66fn is_decoration_wrapper_kind(kind: &str) -> bool {
67    DECORATION_WRAPPER_KINDS.contains(&kind)
68}
69
70/// Walk backward from the symbol's node through preceding named siblings,
71/// collecting decoration nodes (doc comments, attributes, decorators, etc.).
72/// Returns `(byte_offset, warning)` where:
73/// - `byte_offset` is the line-start of the earliest decoration found, or
74///   `loc.start_byte` if there are no decorations or no grammar is available.
75/// - `warning` is `Some(msg)` when the function fell back because the grammar
76///   was unavailable; `None` when the grammar was used (even if no decorations
77///   were found).
78///
79/// Classification is by `node.kind()` from the grammar — never by source text.
80pub fn decoration_extended_start(
81    file: &Path,
82    content: &str,
83    loc: &SymbolLocation,
84    // normalize-syntax-allow: rust/tuple-return
85) -> (usize, Option<String>) {
86    let fallback = loc.start_byte;
87    let Some(support) = support_for_path(file) else {
88        let ext = file
89            .extension()
90            .and_then(|e| e.to_str())
91            .unwrap_or("<unknown>");
92        return (
93            fallback,
94            Some(format!(
95                "No language support for {ext}: doc comments and attributes will not be included with the moved symbol"
96            )),
97        );
98    };
99    let grammar = support.grammar_name();
100    let Some(tree) = parse_with_grammar(grammar, content) else {
101        return (
102            fallback,
103            Some(format!(
104                "Grammar for {grammar} not loaded: doc comments and attributes will not be included. Install grammars with `normalize grammars install`."
105            )),
106        );
107    };
108
109    let root = tree.root_node();
110    // The symbol's def node — descendant_for_byte_range returns the smallest
111    // node containing the range. Using the full [start, end) of the symbol can
112    // overshoot the def node when end_byte is set to the start of the line
113    // after the symbol (a common convention) — that byte may not lie within
114    // the def node, forcing us up to `module`. Use a point query at the start
115    // byte to anchor on the def itself; we then walk up to find the outermost
116    // ancestor that begins at the same byte.
117    let sym_start = loc.start_byte.min(content.len());
118    let Some(mut node) = root.descendant_for_byte_range(sym_start, sym_start) else {
119        return (fallback, None);
120    };
121
122    // descendant_for_byte_range may return a small inner node (e.g. an identifier)
123    // when the symbol's start byte is line-aligned. Walk up to the outermost
124    // ancestor whose start_byte equals the matched node's start_byte — this is
125    // the def/declaration node we want preceding-sibling info for.
126    while let Some(parent) = node.parent() {
127        if parent.start_byte() == node.start_byte() && parent.id() != root.id() {
128            node = parent;
129        } else {
130            break;
131        }
132    }
133
134    // Build the set of decoration node IDs using the decorations query when
135    // available, falling back to the hardcoded kind list otherwise.
136    let loader = grammar_loader();
137    let decoration_ids: Option<HashSet<usize>> = loader.get_decorations(grammar).and_then(|q| {
138        let compiled = loader.get_compiled_query(grammar, "decorations", &q)?;
139        let mut qcursor = tree_sitter::QueryCursor::new();
140        let mut matches = qcursor.matches(&compiled, root, content.as_bytes());
141        let mut ids = HashSet::new();
142        let source_bytes = content.as_bytes();
143        while let Some(m) = matches.next() {
144            if !satisfies_predicates(&compiled, m, source_bytes) {
145                continue;
146            }
147            for capture in m.captures {
148                ids.insert(capture.node.id());
149            }
150        }
151        Some(ids)
152    });
153
154    let is_decoration = |n: tree_sitter::Node<'_>| -> bool {
155        if let Some(ref ids) = decoration_ids {
156            ids.contains(&n.id())
157        } else {
158            is_decoration_kind(n.kind())
159        }
160    };
161
162    // Walk preceding named siblings while they classify as decorations.
163    //
164    // Some grammars wrap a definition together with its decorators/attributes
165    // under a single node (e.g. Python `decorated_definition`, TS `export_statement`).
166    // When we exhaust prev siblings within that wrapper, climb to the wrapper
167    // and continue scanning siblings of the wrapper itself — leading comments
168    // live there, not under the wrapper.
169    let initial_start = node.start_byte();
170    let mut earliest_start = initial_start;
171    let mut cursor = node;
172    loop {
173        while let Some(prev) = cursor.prev_named_sibling() {
174            if !is_decoration(prev) {
175                // Encountered a non-decoration sibling; stop entirely.
176                return finalize(content, earliest_start, initial_start, fallback);
177            }
178            // Only include if the gap between `prev` and the decoration block we've
179            // already accepted is whitespace-only (no intervening code/punctuation).
180            let gap = &content.as_bytes()[prev.end_byte()..earliest_start];
181            if !gap.iter().all(|b| b.is_ascii_whitespace()) {
182                return finalize(content, earliest_start, initial_start, fallback);
183            }
184            earliest_start = prev.start_byte();
185            cursor = prev;
186        }
187        // No more prev siblings inside the current scope. If the parent is a
188        // known decoration-wrapper, step out to the wrapper and keep scanning.
189        let Some(parent) = cursor.parent() else { break };
190        if parent.id() == root.id() || !is_decoration_wrapper_kind(parent.kind()) {
191            break;
192        }
193        // The wrapper's leading content (everything up to its first child) is
194        // part of the symbol's surface — walk wrapper's prev siblings next.
195        cursor = parent;
196    }
197    finalize(content, earliest_start, initial_start, fallback)
198}
199
200fn finalize(
201    content: &str,
202    earliest_start: usize,
203    initial_start: usize,
204    fallback: usize,
205    // normalize-syntax-allow: rust/tuple-return
206) -> (usize, Option<String>) {
207    if earliest_start == initial_start {
208        return (fallback, None);
209    }
210    // Snap to the start of the line containing earliest_start so we capture
211    // any indentation on that line (consistent with `delete_symbol`'s line
212    // semantics).
213    let snapped = content[..earliest_start]
214        .rfind('\n')
215        .map(|i| i + 1)
216        .unwrap_or(0);
217    (snapped, None)
218}
219
220/// Find all cross-file references to a symbol (callers + importers).
221///
222/// Returns empty references if no index is available.
223pub async fn find_references(
224    ctx: &RefactoringContext,
225    symbol_name: &str,
226    def_file: &str,
227) -> References {
228    let Some(ref idx) = ctx.index else {
229        return References {
230            callers: vec![],
231            importers: vec![],
232        };
233    };
234
235    let callers = idx
236        .find_callers(symbol_name, def_file)
237        .await
238        .unwrap_or_default()
239        .into_iter()
240        .map(|(file, caller, line, access)| CallerRef {
241            file,
242            caller,
243            line,
244            access,
245        })
246        .collect();
247
248    let importers = idx
249        .find_symbol_importers(symbol_name)
250        .await
251        .unwrap_or_default()
252        .into_iter()
253        .map(|(file, name, alias, line)| ImportRef {
254            file,
255            name,
256            alias,
257            line,
258        })
259        .collect();
260
261    References { callers, importers }
262}
263
264/// Check for naming conflicts that a rename would introduce.
265///
266/// Returns a list of conflict descriptions (empty = no conflicts).
267pub async fn check_conflicts(
268    ctx: &RefactoringContext,
269    def_file: &Path,
270    def_content: &str,
271    new_name: &str,
272    importers: &[ImportRef],
273) -> Vec<String> {
274    let mut conflicts = vec![];
275
276    // 1. Does new_name already exist as a symbol in the definition file?
277    if ctx
278        .editor
279        .find_symbol(def_file, def_content, new_name, false)
280        .is_some()
281    {
282        let rel = def_file
283            .strip_prefix(&ctx.root)
284            .unwrap_or(def_file)
285            .to_string_lossy();
286        conflicts.push(format!("{}: symbol '{}' already exists", rel, new_name));
287    }
288
289    // 2. Does any importer file already import something named new_name?
290    if !importers.is_empty()
291        && let Some(ref idx) = ctx.index
292    {
293        for imp in importers {
294            if idx
295                .has_import_named(&imp.file, new_name)
296                .await
297                .unwrap_or(false)
298            {
299                conflicts.push(format!("{}: already imports '{}'", imp.file, new_name));
300            }
301        }
302    }
303
304    conflicts
305}
306
307// ── Mutation actions ─────────────────────────────────────────────────
308
309/// Plan renames of an identifier across specific lines in a file.
310///
311/// Groups all line-level renames into a single `PlannedEdit` for the file.
312/// Returns `None` if no lines actually matched (e.g. stale index data).
313pub fn plan_rename_in_file(
314    ctx: &RefactoringContext,
315    file: &Path,
316    content: &str,
317    lines: &[usize],
318    old_name: &str,
319    new_name: &str,
320) -> Option<PlannedEdit> {
321    let mut current = content.to_string();
322    let mut changed = false;
323
324    for &line_no in lines {
325        if let Some(new_content) = ctx
326            .editor
327            .rename_identifier_in_line(&current, line_no, old_name, new_name)
328        {
329            current = new_content;
330            changed = true;
331        }
332    }
333
334    if changed {
335        Some(PlannedEdit {
336            file: file.to_path_buf(),
337            original: content.to_string(),
338            new_content: current,
339            description: format!("{} -> {}", old_name, new_name),
340        })
341    } else {
342        None
343    }
344}
345
346/// Plan deletion of a symbol from a file.
347pub fn plan_delete_symbol(
348    ctx: &RefactoringContext,
349    file: &Path,
350    content: &str,
351    loc: &SymbolLocation,
352) -> PlannedEdit {
353    let new_content = ctx.editor.delete_symbol(content, loc);
354    PlannedEdit {
355        file: file.to_path_buf(),
356        original: content.to_string(),
357        new_content,
358        description: format!("delete {}", loc.name),
359    }
360}
361
362/// Plan insertion of code relative to a symbol.
363pub fn plan_insert(
364    ctx: &RefactoringContext,
365    file: &Path,
366    content: &str,
367    loc: &SymbolLocation,
368    position: InsertPosition,
369    code: &str,
370) -> PlannedEdit {
371    let new_content = match position {
372        InsertPosition::Before => ctx.editor.insert_before(content, loc, code),
373        InsertPosition::After => ctx.editor.insert_after(content, loc, code),
374    };
375    let pos_str = match position {
376        InsertPosition::Before => "before",
377        InsertPosition::After => "after",
378    };
379    PlannedEdit {
380        file: file.to_path_buf(),
381        original: content.to_string(),
382        new_content,
383        description: format!("insert {} {}", pos_str, loc.name),
384    }
385}
386
387/// Plan replacement of a symbol's content.
388pub fn plan_replace_symbol(
389    ctx: &RefactoringContext,
390    file: &Path,
391    content: &str,
392    loc: &SymbolLocation,
393    new_code: &str,
394) -> PlannedEdit {
395    let new_content = ctx.editor.replace_symbol(content, loc, new_code);
396    PlannedEdit {
397        file: file.to_path_buf(),
398        original: content.to_string(),
399        new_content,
400        description: format!("replace {}", loc.name),
401    }
402}
403
404/// Where to insert code relative to a symbol.
405pub enum InsertPosition {
406    Before,
407    After,
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use normalize_edit::Editor;
414
415    fn make_ctx(root: &Path) -> RefactoringContext {
416        RefactoringContext {
417            root: root.to_path_buf(),
418            editor: Editor::new(),
419            index: None,
420            loader: normalize_languages::GrammarLoader::new(),
421        }
422    }
423
424    #[test]
425    fn plan_rename_single_line() {
426        let dir = tempfile::tempdir().unwrap();
427        let ctx = make_ctx(dir.path());
428        let file = dir.path().join("test.rs");
429        let content = "fn old_func() {}\nfn other() { old_func(); }\n";
430
431        let edit = plan_rename_in_file(&ctx, &file, content, &[1], "old_func", "new_func");
432        assert!(edit.is_some());
433        let edit = edit.unwrap();
434        assert!(edit.new_content.contains("new_func"));
435        assert!(edit.new_content.contains("old_func")); // line 2 not renamed
436    }
437
438    #[test]
439    fn plan_rename_multiple_lines() {
440        let dir = tempfile::tempdir().unwrap();
441        let ctx = make_ctx(dir.path());
442        let file = dir.path().join("test.rs");
443        let content = "fn old_func() {}\nfn other() { old_func(); }\n";
444
445        let edit = plan_rename_in_file(&ctx, &file, content, &[1, 2], "old_func", "new_func");
446        assert!(edit.is_some());
447        let edit = edit.unwrap();
448        assert!(!edit.new_content.contains("old_func"));
449    }
450
451    #[test]
452    fn plan_rename_no_match_returns_none() {
453        let dir = tempfile::tempdir().unwrap();
454        let ctx = make_ctx(dir.path());
455        let file = dir.path().join("test.rs");
456        let content = "fn something() {}\n";
457
458        let edit = plan_rename_in_file(&ctx, &file, content, &[1], "nonexistent", "new_name");
459        assert!(edit.is_none());
460    }
461
462    #[test]
463    fn locate_symbol_found() {
464        let dir = tempfile::tempdir().unwrap();
465        let ctx = make_ctx(dir.path());
466        let file = dir.path().join("test.rs");
467        std::fs::write(&file, "fn my_func() {}\n").unwrap();
468
469        let loc = locate_symbol(&ctx, &file, "fn my_func() {}\n", "my_func");
470        assert!(loc.is_some());
471        assert_eq!(loc.unwrap().name, "my_func");
472    }
473
474    /// Returns true if the named external grammar can be loaded; tests that need
475    /// a grammar should `return` early when this is false to avoid spurious failures
476    /// in environments without `NORMALIZE_GRAMMAR_PATH` configured.
477    fn grammar_available(name: &str) -> bool {
478        normalize_languages::parsers::parser_for(name).is_some()
479    }
480
481    #[test]
482    fn decoration_python_decorator_and_comment() {
483        if !grammar_available("python") {
484            eprintln!("skipping: python grammar not available");
485            return;
486        }
487        let content = "\
488import x
489
490# Leading comment line 1.
491# Leading comment line 2.
492@decorator
493@other_decorator
494def my_func():
495    pass
496";
497        let dir = tempfile::tempdir().unwrap();
498        let file = dir.path().join("test.py");
499        let editor = normalize_edit::Editor::new();
500        std::fs::write(&file, content).unwrap();
501        let loc = editor
502            .find_symbol(&file, content, "my_func", false)
503            .expect("locate");
504        let (start, warning) = decoration_extended_start(&file, content, &loc);
505        assert!(warning.is_none(), "unexpected warning: {:?}", warning);
506        let slice = &content[start..];
507        assert!(
508            slice.starts_with("# Leading comment line 1.\n"),
509            "expected leading comments + decorators included; got: {:?}",
510            slice
511        );
512        assert!(slice.contains("@decorator\n"));
513        assert!(slice.contains("@other_decorator\n"));
514    }
515
516    #[test]
517    fn decoration_python_no_decoration_returns_original() {
518        if !grammar_available("python") {
519            eprintln!("skipping: python grammar not available");
520            return;
521        }
522        let content = "def alone():\n    pass\n";
523        let dir = tempfile::tempdir().unwrap();
524        let file = dir.path().join("test.py");
525        std::fs::write(&file, content).unwrap();
526        let editor = normalize_edit::Editor::new();
527        let loc = editor
528            .find_symbol(&file, content, "alone", false)
529            .expect("locate");
530        let (start, warning) = decoration_extended_start(&file, content, &loc);
531        assert!(warning.is_none(), "unexpected warning: {:?}", warning);
532        assert_eq!(start, loc.start_byte);
533    }
534
535    #[test]
536    fn decoration_javascript_decorator() {
537        if !grammar_available("javascript") {
538            eprintln!("skipping: javascript grammar not available");
539            return;
540        }
541        let content = "\
542// Leading comment.
543class Wrapper {
544  @log
545  myMethod() {}
546}
547";
548        let dir = tempfile::tempdir().unwrap();
549        let file = dir.path().join("test.js");
550        std::fs::write(&file, content).unwrap();
551        let editor = normalize_edit::Editor::new();
552        let loc = editor
553            .find_symbol(&file, content, "myMethod", false)
554            .expect("locate");
555        let (start, warning) = decoration_extended_start(&file, content, &loc);
556        assert!(warning.is_none(), "unexpected warning: {:?}", warning);
557        // The decorator and the line above (whitespace-only indent) must be included.
558        let slice = &content[start..];
559        assert!(
560            slice.trim_start().starts_with("@log"),
561            "expected @log decorator included; got: {:?}",
562            slice
563        );
564    }
565
566    #[test]
567    fn decoration_unsupported_language_falls_back() {
568        // Path with no registered grammar — should return loc.start_byte unchanged.
569        let content = "anything here";
570        let file = std::path::PathBuf::from("test.unknown_ext_xyz");
571        let loc = SymbolLocation {
572            name: "x".to_string(),
573            kind: "function".to_string(),
574            start_byte: 5,
575            end_byte: 10,
576            start_line: 1,
577            end_line: 1,
578            indent: String::new(),
579        };
580        let (start, warning) = decoration_extended_start(&file, content, &loc);
581        assert_eq!(start, 5);
582        assert!(
583            warning.is_some(),
584            "expected a warning for unsupported language"
585        );
586        assert!(
587            warning.unwrap().contains("unknown_ext_xyz"),
588            "warning should mention the extension"
589        );
590    }
591
592    #[test]
593    fn locate_symbol_not_found() {
594        let dir = tempfile::tempdir().unwrap();
595        let ctx = make_ctx(dir.path());
596        let file = dir.path().join("test.rs");
597        std::fs::write(&file, "fn my_func() {}\n").unwrap();
598
599        let loc = locate_symbol(&ctx, &file, "fn my_func() {}\n", "nonexistent");
600        assert!(loc.is_none());
601    }
602}