use std::collections::HashSet;
use std::path::Path;
use normalize_edit::SymbolLocation;
use normalize_languages::parsers::{grammar_loader, parse_with_grammar};
use normalize_languages::satisfies_predicates;
use normalize_languages::support_for_path;
use tree_sitter::StreamingIterator as _;
use crate::{CallerRef, ImportRef, PlannedEdit, RefactoringContext, References};
pub fn locate_symbol(
ctx: &RefactoringContext,
file: &Path,
content: &str,
name: &str,
) -> Option<SymbolLocation> {
ctx.editor.find_symbol(file, content, name, false)
}
const DECORATION_KINDS: &[&str] = &[
"attribute_item", "inner_attribute_item", "meta_item", "attribute", "attribute_list", "decorator", "decorator_list", "annotation", "marker_annotation", "modifiers", "pragma", "preproc_call", ];
fn is_decoration_kind(kind: &str) -> bool {
kind.contains("comment") || DECORATION_KINDS.contains(&kind)
}
const DECORATION_WRAPPER_KINDS: &[&str] = &[
"decorated_definition", "export_statement", "export_default_declaration", "ambient_declaration", ];
fn is_decoration_wrapper_kind(kind: &str) -> bool {
DECORATION_WRAPPER_KINDS.contains(&kind)
}
pub fn decoration_extended_start(
file: &Path,
content: &str,
loc: &SymbolLocation,
) -> (usize, Option<String>) {
let fallback = loc.start_byte;
let Some(support) = support_for_path(file) else {
let ext = file
.extension()
.and_then(|e| e.to_str())
.unwrap_or("<unknown>");
return (
fallback,
Some(format!(
"No language support for {ext}: doc comments and attributes will not be included with the moved symbol"
)),
);
};
let grammar = support.grammar_name();
let Some(tree) = parse_with_grammar(grammar, content) else {
return (
fallback,
Some(format!(
"Grammar for {grammar} not loaded: doc comments and attributes will not be included. Install grammars with `normalize grammars install`."
)),
);
};
let root = tree.root_node();
let sym_start = loc.start_byte.min(content.len());
let Some(mut node) = root.descendant_for_byte_range(sym_start, sym_start) else {
return (fallback, None);
};
while let Some(parent) = node.parent() {
if parent.start_byte() == node.start_byte() && parent.id() != root.id() {
node = parent;
} else {
break;
}
}
let loader = grammar_loader();
let decoration_ids: Option<HashSet<usize>> = loader.get_decorations(grammar).and_then(|q| {
let compiled = loader.get_compiled_query(grammar, "decorations", &q)?;
let mut qcursor = tree_sitter::QueryCursor::new();
let mut matches = qcursor.matches(&compiled, root, content.as_bytes());
let mut ids = HashSet::new();
let source_bytes = content.as_bytes();
while let Some(m) = matches.next() {
if !satisfies_predicates(&compiled, m, source_bytes) {
continue;
}
for capture in m.captures {
ids.insert(capture.node.id());
}
}
Some(ids)
});
let is_decoration = |n: tree_sitter::Node<'_>| -> bool {
if let Some(ref ids) = decoration_ids {
ids.contains(&n.id())
} else {
is_decoration_kind(n.kind())
}
};
let initial_start = node.start_byte();
let mut earliest_start = initial_start;
let mut cursor = node;
loop {
while let Some(prev) = cursor.prev_named_sibling() {
if !is_decoration(prev) {
return finalize(content, earliest_start, initial_start, fallback);
}
let gap = &content.as_bytes()[prev.end_byte()..earliest_start];
if !gap.iter().all(|b| b.is_ascii_whitespace()) {
return finalize(content, earliest_start, initial_start, fallback);
}
earliest_start = prev.start_byte();
cursor = prev;
}
let Some(parent) = cursor.parent() else { break };
if parent.id() == root.id() || !is_decoration_wrapper_kind(parent.kind()) {
break;
}
cursor = parent;
}
finalize(content, earliest_start, initial_start, fallback)
}
fn finalize(
content: &str,
earliest_start: usize,
initial_start: usize,
fallback: usize,
) -> (usize, Option<String>) {
if earliest_start == initial_start {
return (fallback, None);
}
let snapped = content[..earliest_start]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
(snapped, None)
}
pub async fn find_references(
ctx: &RefactoringContext,
symbol_name: &str,
def_file: &str,
) -> References {
let Some(ref idx) = ctx.index else {
return References {
callers: vec![],
importers: vec![],
};
};
let callers = idx
.find_callers(symbol_name, def_file)
.await
.unwrap_or_default()
.into_iter()
.map(|(file, caller, line, access)| CallerRef {
file,
caller,
line,
access,
})
.collect();
let importers = idx
.find_symbol_importers(symbol_name)
.await
.unwrap_or_default()
.into_iter()
.map(|(file, name, alias, line)| ImportRef {
file,
name,
alias,
line,
})
.collect();
References { callers, importers }
}
pub async fn check_conflicts(
ctx: &RefactoringContext,
def_file: &Path,
def_content: &str,
new_name: &str,
importers: &[ImportRef],
) -> Vec<String> {
let mut conflicts = vec![];
if ctx
.editor
.find_symbol(def_file, def_content, new_name, false)
.is_some()
{
let rel = def_file
.strip_prefix(&ctx.root)
.unwrap_or(def_file)
.to_string_lossy();
conflicts.push(format!("{}: symbol '{}' already exists", rel, new_name));
}
if !importers.is_empty()
&& let Some(ref idx) = ctx.index
{
for imp in importers {
if idx
.has_import_named(&imp.file, new_name)
.await
.unwrap_or(false)
{
conflicts.push(format!("{}: already imports '{}'", imp.file, new_name));
}
}
}
conflicts
}
pub fn plan_rename_in_file(
ctx: &RefactoringContext,
file: &Path,
content: &str,
lines: &[usize],
old_name: &str,
new_name: &str,
) -> Option<PlannedEdit> {
let mut current = content.to_string();
let mut changed = false;
for &line_no in lines {
if let Some(new_content) = ctx
.editor
.rename_identifier_in_line(¤t, line_no, old_name, new_name)
{
current = new_content;
changed = true;
}
}
if changed {
Some(PlannedEdit {
file: file.to_path_buf(),
original: content.to_string(),
new_content: current,
description: format!("{} -> {}", old_name, new_name),
})
} else {
None
}
}
pub fn plan_delete_symbol(
ctx: &RefactoringContext,
file: &Path,
content: &str,
loc: &SymbolLocation,
) -> PlannedEdit {
let new_content = ctx.editor.delete_symbol(content, loc);
PlannedEdit {
file: file.to_path_buf(),
original: content.to_string(),
new_content,
description: format!("delete {}", loc.name),
}
}
pub fn plan_insert(
ctx: &RefactoringContext,
file: &Path,
content: &str,
loc: &SymbolLocation,
position: InsertPosition,
code: &str,
) -> PlannedEdit {
let new_content = match position {
InsertPosition::Before => ctx.editor.insert_before(content, loc, code),
InsertPosition::After => ctx.editor.insert_after(content, loc, code),
};
let pos_str = match position {
InsertPosition::Before => "before",
InsertPosition::After => "after",
};
PlannedEdit {
file: file.to_path_buf(),
original: content.to_string(),
new_content,
description: format!("insert {} {}", pos_str, loc.name),
}
}
pub fn plan_replace_symbol(
ctx: &RefactoringContext,
file: &Path,
content: &str,
loc: &SymbolLocation,
new_code: &str,
) -> PlannedEdit {
let new_content = ctx.editor.replace_symbol(content, loc, new_code);
PlannedEdit {
file: file.to_path_buf(),
original: content.to_string(),
new_content,
description: format!("replace {}", loc.name),
}
}
pub enum InsertPosition {
Before,
After,
}
#[cfg(test)]
mod tests {
use super::*;
use normalize_edit::Editor;
fn make_ctx(root: &Path) -> RefactoringContext {
RefactoringContext {
root: root.to_path_buf(),
editor: Editor::new(),
index: None,
loader: normalize_languages::GrammarLoader::new(),
}
}
#[test]
fn plan_rename_single_line() {
let dir = tempfile::tempdir().unwrap();
let ctx = make_ctx(dir.path());
let file = dir.path().join("test.rs");
let content = "fn old_func() {}\nfn other() { old_func(); }\n";
let edit = plan_rename_in_file(&ctx, &file, content, &[1], "old_func", "new_func");
assert!(edit.is_some());
let edit = edit.unwrap();
assert!(edit.new_content.contains("new_func"));
assert!(edit.new_content.contains("old_func")); }
#[test]
fn plan_rename_multiple_lines() {
let dir = tempfile::tempdir().unwrap();
let ctx = make_ctx(dir.path());
let file = dir.path().join("test.rs");
let content = "fn old_func() {}\nfn other() { old_func(); }\n";
let edit = plan_rename_in_file(&ctx, &file, content, &[1, 2], "old_func", "new_func");
assert!(edit.is_some());
let edit = edit.unwrap();
assert!(!edit.new_content.contains("old_func"));
}
#[test]
fn plan_rename_no_match_returns_none() {
let dir = tempfile::tempdir().unwrap();
let ctx = make_ctx(dir.path());
let file = dir.path().join("test.rs");
let content = "fn something() {}\n";
let edit = plan_rename_in_file(&ctx, &file, content, &[1], "nonexistent", "new_name");
assert!(edit.is_none());
}
#[test]
fn locate_symbol_found() {
let dir = tempfile::tempdir().unwrap();
let ctx = make_ctx(dir.path());
let file = dir.path().join("test.rs");
std::fs::write(&file, "fn my_func() {}\n").unwrap();
let loc = locate_symbol(&ctx, &file, "fn my_func() {}\n", "my_func");
assert!(loc.is_some());
assert_eq!(loc.unwrap().name, "my_func");
}
fn grammar_available(name: &str) -> bool {
normalize_languages::parsers::parser_for(name).is_some()
}
#[test]
fn decoration_python_decorator_and_comment() {
if !grammar_available("python") {
eprintln!("skipping: python grammar not available");
return;
}
let content = "\
import x
# Leading comment line 1.
# Leading comment line 2.
@decorator
@other_decorator
def my_func():
pass
";
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.py");
let editor = normalize_edit::Editor::new();
std::fs::write(&file, content).unwrap();
let loc = editor
.find_symbol(&file, content, "my_func", false)
.expect("locate");
let (start, warning) = decoration_extended_start(&file, content, &loc);
assert!(warning.is_none(), "unexpected warning: {:?}", warning);
let slice = &content[start..];
assert!(
slice.starts_with("# Leading comment line 1.\n"),
"expected leading comments + decorators included; got: {:?}",
slice
);
assert!(slice.contains("@decorator\n"));
assert!(slice.contains("@other_decorator\n"));
}
#[test]
fn decoration_python_no_decoration_returns_original() {
if !grammar_available("python") {
eprintln!("skipping: python grammar not available");
return;
}
let content = "def alone():\n pass\n";
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.py");
std::fs::write(&file, content).unwrap();
let editor = normalize_edit::Editor::new();
let loc = editor
.find_symbol(&file, content, "alone", false)
.expect("locate");
let (start, warning) = decoration_extended_start(&file, content, &loc);
assert!(warning.is_none(), "unexpected warning: {:?}", warning);
assert_eq!(start, loc.start_byte);
}
#[test]
fn decoration_javascript_decorator() {
if !grammar_available("javascript") {
eprintln!("skipping: javascript grammar not available");
return;
}
let content = "\
// Leading comment.
class Wrapper {
@log
myMethod() {}
}
";
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.js");
std::fs::write(&file, content).unwrap();
let editor = normalize_edit::Editor::new();
let loc = editor
.find_symbol(&file, content, "myMethod", false)
.expect("locate");
let (start, warning) = decoration_extended_start(&file, content, &loc);
assert!(warning.is_none(), "unexpected warning: {:?}", warning);
let slice = &content[start..];
assert!(
slice.trim_start().starts_with("@log"),
"expected @log decorator included; got: {:?}",
slice
);
}
#[test]
fn decoration_unsupported_language_falls_back() {
let content = "anything here";
let file = std::path::PathBuf::from("test.unknown_ext_xyz");
let loc = SymbolLocation {
name: "x".to_string(),
kind: "function".to_string(),
start_byte: 5,
end_byte: 10,
start_line: 1,
end_line: 1,
indent: String::new(),
};
let (start, warning) = decoration_extended_start(&file, content, &loc);
assert_eq!(start, 5);
assert!(
warning.is_some(),
"expected a warning for unsupported language"
);
assert!(
warning.unwrap().contains("unknown_ext_xyz"),
"warning should mention the extension"
);
}
#[test]
fn locate_symbol_not_found() {
let dir = tempfile::tempdir().unwrap();
let ctx = make_ctx(dir.path());
let file = dir.path().join("test.rs");
std::fs::write(&file, "fn my_func() {}\n").unwrap();
let loc = locate_symbol(&ctx, &file, "fn my_func() {}\n", "nonexistent");
assert!(loc.is_none());
}
}