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.
223///
224/// Each reference is tagged with a `confidence` field:
225/// - `"resolved"` — backed by `ModuleResolver` import resolution (accurate)
226/// - `"heuristic"` — found via import-name matching without full resolution (may have false positives)
227pub async fn find_references(
228    ctx: &RefactoringContext,
229    symbol_name: &str,
230    def_file: &str,
231) -> References {
232    let Some(ref idx) = ctx.index else {
233        return References {
234            callers: vec![],
235            importers: vec![],
236        };
237    };
238
239    // Determine confidence level based on whether the def_file's language has a resolver.
240    // If it does, imports for that language were resolved via ModuleResolver; results are
241    // accurate. If not, results are heuristic (import-name matching only).
242    let confidence: &'static str = {
243        let def_path = ctx.root.join(def_file);
244        if support_for_path(&def_path)
245            .and_then(|lang| lang.module_resolver())
246            .is_some()
247        {
248            "resolved"
249        } else {
250            "heuristic"
251        }
252    };
253
254    let callers = idx
255        .find_callers(symbol_name, def_file)
256        .await
257        .unwrap_or_default()
258        .into_iter()
259        .map(|(file, caller, line, access)| CallerRef {
260            file,
261            caller,
262            line,
263            access,
264            confidence,
265        })
266        .collect();
267
268    let importers = idx
269        .find_symbol_importers(symbol_name)
270        .await
271        .unwrap_or_default()
272        .into_iter()
273        .map(|(file, name, alias, line)| ImportRef {
274            file,
275            name,
276            alias,
277            line,
278            confidence,
279        })
280        .collect();
281
282    References { callers, importers }
283}
284
285/// Check for naming conflicts that a rename would introduce.
286///
287/// Returns a list of conflict descriptions (empty = no conflicts).
288pub async fn check_conflicts(
289    ctx: &RefactoringContext,
290    def_file: &Path,
291    def_content: &str,
292    new_name: &str,
293    importers: &[ImportRef],
294) -> Vec<String> {
295    let mut conflicts = vec![];
296
297    // 1. Does new_name already exist as a symbol in the definition file?
298    if ctx
299        .editor
300        .find_symbol(def_file, def_content, new_name, false)
301        .is_some()
302    {
303        let rel = def_file
304            .strip_prefix(&ctx.root)
305            .unwrap_or(def_file)
306            .to_string_lossy();
307        conflicts.push(format!("{}: symbol '{}' already exists", rel, new_name));
308    }
309
310    // 2. Does any importer file already import something named new_name?
311    if !importers.is_empty()
312        && let Some(ref idx) = ctx.index
313    {
314        for imp in importers {
315            if idx
316                .has_import_named(&imp.file, new_name)
317                .await
318                .unwrap_or(false)
319            {
320                conflicts.push(format!("{}: already imports '{}'", imp.file, new_name));
321            }
322        }
323    }
324
325    conflicts
326}
327
328// ── Mutation actions ─────────────────────────────────────────────────
329
330/// Plan renames of an identifier across specific lines in a file.
331///
332/// Groups all line-level renames into a single `PlannedEdit` for the file.
333/// Returns `None` if no lines actually matched (e.g. stale index data).
334pub fn plan_rename_in_file(
335    ctx: &RefactoringContext,
336    file: &Path,
337    content: &str,
338    lines: &[usize],
339    old_name: &str,
340    new_name: &str,
341) -> Option<PlannedEdit> {
342    let mut current = content.to_string();
343    let mut changed = false;
344
345    for &line_no in lines {
346        if let Some(new_content) = ctx
347            .editor
348            .rename_identifier_in_line(&current, line_no, old_name, new_name)
349        {
350            current = new_content;
351            changed = true;
352        }
353    }
354
355    if changed {
356        Some(PlannedEdit {
357            file: file.to_path_buf(),
358            original: content.to_string(),
359            new_content: current,
360            description: format!("{} -> {}", old_name, new_name),
361        })
362    } else {
363        None
364    }
365}
366
367/// Plan deletion of a symbol from a file.
368pub fn plan_delete_symbol(
369    ctx: &RefactoringContext,
370    file: &Path,
371    content: &str,
372    loc: &SymbolLocation,
373) -> PlannedEdit {
374    let new_content = ctx.editor.delete_symbol(content, loc);
375    PlannedEdit {
376        file: file.to_path_buf(),
377        original: content.to_string(),
378        new_content,
379        description: format!("delete {}", loc.name),
380    }
381}
382
383/// Plan insertion of code relative to a symbol.
384pub fn plan_insert(
385    ctx: &RefactoringContext,
386    file: &Path,
387    content: &str,
388    loc: &SymbolLocation,
389    position: InsertPosition,
390    code: &str,
391) -> PlannedEdit {
392    let new_content = match position {
393        InsertPosition::Before => ctx.editor.insert_before(content, loc, code),
394        InsertPosition::After => ctx.editor.insert_after(content, loc, code),
395    };
396    let pos_str = match position {
397        InsertPosition::Before => "before",
398        InsertPosition::After => "after",
399    };
400    PlannedEdit {
401        file: file.to_path_buf(),
402        original: content.to_string(),
403        new_content,
404        description: format!("insert {} {}", pos_str, loc.name),
405    }
406}
407
408/// Plan replacement of a symbol's content.
409pub fn plan_replace_symbol(
410    ctx: &RefactoringContext,
411    file: &Path,
412    content: &str,
413    loc: &SymbolLocation,
414    new_code: &str,
415) -> PlannedEdit {
416    let new_content = ctx.editor.replace_symbol(content, loc, new_code);
417    PlannedEdit {
418        file: file.to_path_buf(),
419        original: content.to_string(),
420        new_content,
421        description: format!("replace {}", loc.name),
422    }
423}
424
425/// Where to insert code relative to a symbol.
426pub enum InsertPosition {
427    Before,
428    After,
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use normalize_edit::Editor;
435
436    fn make_ctx(root: &Path) -> RefactoringContext {
437        RefactoringContext {
438            root: root.to_path_buf(),
439            editor: Editor::new(),
440            index: None,
441            loader: normalize_languages::GrammarLoader::new(),
442        }
443    }
444
445    #[test]
446    fn plan_rename_single_line() {
447        let dir = tempfile::tempdir().unwrap();
448        let ctx = make_ctx(dir.path());
449        let file = dir.path().join("test.rs");
450        let content = "fn old_func() {}\nfn other() { old_func(); }\n";
451
452        let edit = plan_rename_in_file(&ctx, &file, content, &[1], "old_func", "new_func");
453        assert!(edit.is_some());
454        let edit = edit.unwrap();
455        assert!(edit.new_content.contains("new_func"));
456        assert!(edit.new_content.contains("old_func")); // line 2 not renamed
457    }
458
459    #[test]
460    fn plan_rename_multiple_lines() {
461        let dir = tempfile::tempdir().unwrap();
462        let ctx = make_ctx(dir.path());
463        let file = dir.path().join("test.rs");
464        let content = "fn old_func() {}\nfn other() { old_func(); }\n";
465
466        let edit = plan_rename_in_file(&ctx, &file, content, &[1, 2], "old_func", "new_func");
467        assert!(edit.is_some());
468        let edit = edit.unwrap();
469        assert!(!edit.new_content.contains("old_func"));
470    }
471
472    #[test]
473    fn plan_rename_no_match_returns_none() {
474        let dir = tempfile::tempdir().unwrap();
475        let ctx = make_ctx(dir.path());
476        let file = dir.path().join("test.rs");
477        let content = "fn something() {}\n";
478
479        let edit = plan_rename_in_file(&ctx, &file, content, &[1], "nonexistent", "new_name");
480        assert!(edit.is_none());
481    }
482
483    #[test]
484    fn locate_symbol_found() {
485        let dir = tempfile::tempdir().unwrap();
486        let ctx = make_ctx(dir.path());
487        let file = dir.path().join("test.rs");
488        std::fs::write(&file, "fn my_func() {}\n").unwrap();
489
490        let loc = locate_symbol(&ctx, &file, "fn my_func() {}\n", "my_func");
491        assert!(loc.is_some());
492        assert_eq!(loc.unwrap().name, "my_func");
493    }
494
495    /// Returns true if the named external grammar can be loaded; tests that need
496    /// a grammar should `return` early when this is false to avoid spurious failures
497    /// in environments without `NORMALIZE_GRAMMAR_PATH` configured.
498    fn grammar_available(name: &str) -> bool {
499        normalize_languages::parsers::parser_for(name).is_some()
500    }
501
502    #[test]
503    fn decoration_python_decorator_and_comment() {
504        if !grammar_available("python") {
505            eprintln!("skipping: python grammar not available");
506            return;
507        }
508        let content = "\
509import x
510
511# Leading comment line 1.
512# Leading comment line 2.
513@decorator
514@other_decorator
515def my_func():
516    pass
517";
518        let dir = tempfile::tempdir().unwrap();
519        let file = dir.path().join("test.py");
520        let editor = normalize_edit::Editor::new();
521        std::fs::write(&file, content).unwrap();
522        let loc = editor
523            .find_symbol(&file, content, "my_func", false)
524            .expect("locate");
525        let (start, warning) = decoration_extended_start(&file, content, &loc);
526        assert!(warning.is_none(), "unexpected warning: {:?}", warning);
527        let slice = &content[start..];
528        assert!(
529            slice.starts_with("# Leading comment line 1.\n"),
530            "expected leading comments + decorators included; got: {:?}",
531            slice
532        );
533        assert!(slice.contains("@decorator\n"));
534        assert!(slice.contains("@other_decorator\n"));
535    }
536
537    #[test]
538    fn decoration_python_no_decoration_returns_original() {
539        if !grammar_available("python") {
540            eprintln!("skipping: python grammar not available");
541            return;
542        }
543        let content = "def alone():\n    pass\n";
544        let dir = tempfile::tempdir().unwrap();
545        let file = dir.path().join("test.py");
546        std::fs::write(&file, content).unwrap();
547        let editor = normalize_edit::Editor::new();
548        let loc = editor
549            .find_symbol(&file, content, "alone", false)
550            .expect("locate");
551        let (start, warning) = decoration_extended_start(&file, content, &loc);
552        assert!(warning.is_none(), "unexpected warning: {:?}", warning);
553        assert_eq!(start, loc.start_byte);
554    }
555
556    #[test]
557    fn decoration_javascript_decorator() {
558        if !grammar_available("javascript") {
559            eprintln!("skipping: javascript grammar not available");
560            return;
561        }
562        let content = "\
563// Leading comment.
564class Wrapper {
565  @log
566  myMethod() {}
567}
568";
569        let dir = tempfile::tempdir().unwrap();
570        let file = dir.path().join("test.js");
571        std::fs::write(&file, content).unwrap();
572        let editor = normalize_edit::Editor::new();
573        let loc = editor
574            .find_symbol(&file, content, "myMethod", false)
575            .expect("locate");
576        let (start, warning) = decoration_extended_start(&file, content, &loc);
577        assert!(warning.is_none(), "unexpected warning: {:?}", warning);
578        // The decorator and the line above (whitespace-only indent) must be included.
579        let slice = &content[start..];
580        assert!(
581            slice.trim_start().starts_with("@log"),
582            "expected @log decorator included; got: {:?}",
583            slice
584        );
585    }
586
587    #[test]
588    fn decoration_unsupported_language_falls_back() {
589        // Path with no registered grammar — should return loc.start_byte unchanged.
590        let content = "anything here";
591        let file = std::path::PathBuf::from("test.unknown_ext_xyz");
592        let loc = SymbolLocation {
593            name: "x".to_string(),
594            kind: "function".to_string(),
595            start_byte: 5,
596            end_byte: 10,
597            start_line: 1,
598            end_line: 1,
599            indent: String::new(),
600        };
601        let (start, warning) = decoration_extended_start(&file, content, &loc);
602        assert_eq!(start, 5);
603        assert!(
604            warning.is_some(),
605            "expected a warning for unsupported language"
606        );
607        assert!(
608            warning.unwrap().contains("unknown_ext_xyz"),
609            "warning should mention the extension"
610        );
611    }
612
613    #[test]
614    fn locate_symbol_not_found() {
615        let dir = tempfile::tempdir().unwrap();
616        let ctx = make_ctx(dir.path());
617        let file = dir.path().join("test.rs");
618        std::fs::write(&file, "fn my_func() {}\n").unwrap();
619
620        let loc = locate_symbol(&ctx, &file, "fn my_func() {}\n", "nonexistent");
621        assert!(loc.is_none());
622    }
623}