use std::collections::BTreeSet;
use std::path::Path;
use std::sync::Arc;
use harn_vm::VmValue;
use sha2::{Digest, Sha256};
use tree_sitter::Node;
use crate::ast::{api as ast_api, Language, TEXT_PATCH_FALLBACK};
use crate::error::HostlibError;
use crate::tools::args::{
build_dict, dict_arg, optional_bool, optional_string, require_string, str_value,
};
use super::builtins::SharedIndex;
use super::state::IndexState;
use super::symbol_graph::{NodeKind, SymbolGraph};
pub(super) const BUILTIN: &str = "hostlib_code_index_rename_symbol";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Scope {
File,
Module,
Workspace,
}
impl Scope {
fn parse(raw: &str) -> Result<Self, HostlibError> {
match raw {
"file" => Ok(Self::File),
"module" => Ok(Self::Module),
"workspace" => Ok(Self::Workspace),
other => Err(HostlibError::InvalidParameter {
builtin: BUILTIN,
param: "scope",
message: format!(
"expected one of \"file\" | \"module\" | \"workspace\", got `{other}`"
),
}),
}
}
fn as_str(self) -> &'static str {
match self {
Self::File => "file",
Self::Module => "module",
Self::Workspace => "workspace",
}
}
}
struct FilePlan {
path: String,
language: Language,
source: String,
patched: String,
edits: Vec<EditSpan>,
}
#[derive(Clone, Debug)]
struct EditSpan {
start_byte: usize,
end_byte: usize,
start_row: usize,
start_col: usize,
end_row: usize,
end_col: usize,
before: String,
after: String,
}
#[derive(Clone, Debug)]
struct ShadowSite {
path: String,
row: usize,
col: usize,
}
pub(super) fn run(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
let raw = dict_arg(BUILTIN, args)?;
let dict = raw.as_ref();
let symbol_ref = match dict.get("symbol_ref") {
Some(VmValue::Dict(d)) => d.clone(),
Some(other) => {
return Err(HostlibError::InvalidParameter {
builtin: BUILTIN,
param: "symbol_ref",
message: format!("expected dict, got {}", other.type_name()),
});
}
None => {
return Err(HostlibError::MissingParameter {
builtin: BUILTIN,
param: "symbol_ref",
});
}
};
let symbol_dict = symbol_ref.as_ref();
let symbol_name = require_string(BUILTIN, symbol_dict, "name")?;
let symbol_path = require_string(BUILTIN, symbol_dict, "path")?;
let symbol_line = match symbol_dict.get("line") {
None | Some(VmValue::Nil) => None,
Some(VmValue::Int(n)) if *n >= 1 => Some(*n as u32),
Some(VmValue::Int(n)) => {
return Err(HostlibError::InvalidParameter {
builtin: BUILTIN,
param: "symbol_ref.line",
message: format!("must be >= 1, got {n}"),
});
}
Some(other) => {
return Err(HostlibError::InvalidParameter {
builtin: BUILTIN,
param: "symbol_ref.line",
message: format!("expected integer, got {}", other.type_name()),
});
}
};
let symbol_kind_raw = optional_string(BUILTIN, symbol_dict, "kind")?;
let symbol_kind = symbol_kind_raw.as_deref().map(parse_kind).transpose()?;
let new_name = require_string(BUILTIN, dict, "new_name")?;
let scope = Scope::parse(&require_string(BUILTIN, dict, "scope")?)?;
let session_id = optional_string(BUILTIN, dict, "session_id")?;
let dry_run = optional_bool(BUILTIN, dict, "dry_run", false)?;
let validate = optional_bool(BUILTIN, dict, "validate", true)?;
if new_name == symbol_name {
return Err(HostlibError::InvalidParameter {
builtin: BUILTIN,
param: "new_name",
message: "new_name must differ from symbol_ref.name".into(),
});
}
let guard = index.lock().expect("code_index mutex poisoned");
let Some(state) = guard.as_ref() else {
return Err(HostlibError::Backend {
builtin: BUILTIN,
message: "code index has not been initialised — call \
`hostlib_code_index_rebuild` first"
.into(),
});
};
let env = ResponseEnv {
symbol_name: &symbol_name,
new_name: &new_name,
symbol_path: &symbol_path,
symbol_line,
symbol_kind,
scope,
};
let normalized_path = super::builtins::normalize_relative_path_for(state, &symbol_path);
let seed_node_id = match resolve_seed(
&state.symbols,
&normalized_path,
&symbol_name,
symbol_line,
symbol_kind,
) {
SeedLookup::One(id) => id,
SeedLookup::None => return Ok(no_match_response(&env)),
SeedLookup::Many(candidates) => return Ok(ambiguous_response(&env, &candidates)),
};
let seed_node = state
.symbols
.node(seed_node_id)
.expect("resolve_seed returned a node id present in the graph");
let seed_path = seed_node.path.clone();
let in_scope_files = files_in_scope(
state,
scope,
&symbol_name,
&seed_path,
session_id.as_deref(),
);
if in_scope_files.is_empty() {
return Ok(no_match_response(&env));
}
if !is_identifier_token(&new_name) {
return Ok(invalid_identifier_response(
&env,
"must start with a letter or underscore and consist of identifier characters",
));
}
let mut plans: Vec<FilePlan> = Vec::new();
let mut shadows: Vec<ShadowSite> = Vec::new();
for path in &in_scope_files {
let abs = state.root.join(path);
let Some(language) = Language::detect(Path::new(path), None) else {
return Ok(unsupported_language_response(&env, path, None));
};
let source = read_source(&abs, session_id.as_deref())?;
let tree = match ast_api::parse_tree(&source, language) {
Ok(tree) => tree,
Err(err) => {
return Ok(syntax_error_response(
&env,
path,
&format!("parse failed: {err}"),
));
}
};
let Some(identifier_kinds) = language.rename_identifier_kinds() else {
return Ok(unsupported_language_response(
&env,
path,
Some(language.name()),
));
};
let mut targets = Vec::new();
let mut local_shadows: Vec<ShadowSite> = Vec::new();
collect_identifier_spans(
tree.root_node(),
source.as_bytes(),
&symbol_name,
&new_name,
identifier_kinds,
path,
&mut targets,
&mut local_shadows,
);
if !local_shadows.is_empty() {
shadows.extend(local_shadows);
continue;
}
if targets.is_empty() {
continue;
}
let edits: Vec<EditSpan> = targets
.into_iter()
.map(|(start, end, srow, scol, erow, ecol)| EditSpan {
start_byte: start,
end_byte: end,
start_row: srow,
start_col: scol,
end_row: erow,
end_col: ecol,
before: symbol_name.clone(),
after: new_name.clone(),
})
.collect();
let patched = splice(&source, &edits);
if validate {
if let Some(detail) = first_syntax_error(&patched, language) {
return Ok(syntax_error_response(&env, path, &detail));
}
}
plans.push(FilePlan {
path: path.clone(),
language,
source,
patched,
edits,
});
}
if !shadows.is_empty() {
return Ok(conflict_response(&env, &shadows));
}
if plans.is_empty() {
return Ok(no_match_response(&env));
}
let mut failed: Vec<(String, String)> = Vec::new();
if !dry_run {
for plan in &plans {
let abs = state.root.join(&plan.path);
if let Err(err) = write_source(&abs, &plan.patched, session_id.as_deref()) {
failed.push((plan.path.clone(), err));
}
}
}
Ok(applied_response(&env, &plans, dry_run, failed))
}
enum SeedLookup {
One(super::symbol_graph::NodeId),
None,
Many(Vec<(String, u32, &'static str)>),
}
fn resolve_seed(
graph: &SymbolGraph,
relative_path: &str,
name: &str,
line: Option<u32>,
kind: Option<NodeKind>,
) -> SeedLookup {
let candidates: Vec<&super::symbol_graph::Node> = graph
.nodes_named(name)
.iter()
.filter_map(|id| graph.node(*id))
.filter(|node| {
matches!(
node.kind,
NodeKind::Function | NodeKind::Type | NodeKind::Module
)
})
.collect();
let mut narrowed: Vec<&super::symbol_graph::Node> = candidates
.iter()
.copied()
.filter(|node| paths_match(&node.path, relative_path))
.collect();
if narrowed.is_empty() {
narrowed = candidates;
}
if let Some(line) = line {
narrowed.retain(|node| node.line == line);
}
if let Some(kind) = kind {
narrowed.retain(|node| node.kind == kind);
}
match narrowed.len() {
0 => SeedLookup::None,
1 => SeedLookup::One(narrowed[0].id),
_ => SeedLookup::Many(
narrowed
.iter()
.map(|n| (n.path.clone(), n.line, n.kind.as_str()))
.collect(),
),
}
}
fn paths_match(a: &str, b: &str) -> bool {
a == b
|| a.replace('\\', "/") == b.replace('\\', "/")
|| a.ends_with(&format!("/{b}"))
|| b.ends_with(&format!("/{a}"))
}
fn files_in_scope(
state: &IndexState,
scope: Scope,
name: &str,
seed_path: &str,
session_id: Option<&str>,
) -> Vec<String> {
let mut seen: BTreeSet<String> = BTreeSet::new();
seen.insert(seed_path.to_string());
if scope == Scope::Workspace {
for id in state.symbols.nodes_named(name) {
if let Some(node) = state.symbols.node(*id) {
seen.insert(node.path.clone());
}
}
for file in state.files.values() {
let abs = state.root.join(&file.relative_path);
if file_contains_word(&abs, name, session_id) {
seen.insert(file.relative_path.clone());
}
}
}
seen.into_iter().collect()
}
fn file_contains_word(path: &Path, name: &str, session_id: Option<&str>) -> bool {
let bytes = match crate::fs::read(path, session_id) {
Some(Ok(bytes)) => bytes,
Some(Err(_)) => return false,
None => match std::fs::read(path) {
Ok(bytes) => bytes,
Err(_) => return false,
},
};
let Ok(text) = std::str::from_utf8(&bytes) else {
return false;
};
text.split(|c: char| !(c.is_alphanumeric() || c == '_'))
.any(|tok| tok == name)
}
fn parse_kind(raw: &str) -> Result<NodeKind, HostlibError> {
match raw {
"Function" => Ok(NodeKind::Function),
"Type" => Ok(NodeKind::Type),
"Module" => Ok(NodeKind::Module),
other => Err(HostlibError::InvalidParameter {
builtin: BUILTIN,
param: "symbol_ref.kind",
message: format!("expected one of [Function, Type, Module], got `{other}`"),
}),
}
}
fn is_identifier_token(text: &str) -> bool {
let mut chars = text.chars();
match chars.next() {
Some(c) if c.is_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_alphanumeric() || c == '_')
}
fn is_skip_kind(kind: &str) -> bool {
matches!(
kind,
"comment"
| "line_comment"
| "block_comment"
| "doc_comment"
| "hash_bang_line"
| "shebang"
| "string"
| "string_literal"
| "string_fragment"
| "string_content"
| "raw_string_literal"
| "interpreted_string_literal"
| "interpreted_string"
| "char_literal"
| "character_literal"
| "template_string"
| "template_substitution"
)
}
fn collect_identifier_spans(
root: Node<'_>,
bytes: &[u8],
target_name: &str,
new_name: &str,
identifier_kinds: &[&str],
path: &str,
targets: &mut Vec<(usize, usize, usize, usize, usize, usize)>,
shadows: &mut Vec<ShadowSite>,
) {
let mut stack = vec![root];
while let Some(node) = stack.pop() {
if is_skip_kind(node.kind()) {
continue;
}
if identifier_kinds.contains(&node.kind()) {
let text = match std::str::from_utf8(&bytes[node.start_byte()..node.end_byte()]) {
Ok(s) => s,
Err(_) => continue,
};
if text == target_name {
let start = node.start_position();
let end = node.end_position();
targets.push((
node.start_byte(),
node.end_byte(),
start.row,
start.column,
end.row,
end.column,
));
} else if text == new_name {
let pos = node.start_position();
shadows.push(ShadowSite {
path: path.to_string(),
row: pos.row,
col: pos.column,
});
}
continue;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
stack.push(child);
}
}
}
fn splice(source: &str, edits: &[EditSpan]) -> String {
let mut ordered: Vec<&EditSpan> = edits.iter().collect();
ordered.sort_by_key(|e| std::cmp::Reverse(e.start_byte));
let mut out = source.to_string();
for edit in ordered {
out.replace_range(edit.start_byte..edit.end_byte, &edit.after);
}
out
}
fn first_syntax_error(source: &str, language: Language) -> Option<String> {
let tree = ast_api::parse_tree(source, language).ok()?;
let root = tree.root_node();
if !root.has_error() {
return None;
}
let mut stack = vec![root];
while let Some(node) = stack.pop() {
if node.is_missing() {
let pos = node.start_position();
return Some(format!(
"missing `{}` at line {}, column {}",
node.kind(),
pos.row + 1,
pos.column + 1
));
}
if node.is_error() {
let pos = node.start_position();
return Some(format!(
"unexpected token at line {}, column {}",
pos.row + 1,
pos.column + 1
));
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.has_error() || child.is_missing() {
stack.push(child);
}
}
}
Some("post-edit source has parse errors".into())
}
fn read_source(path: &Path, session_id: Option<&str>) -> Result<String, HostlibError> {
let bytes = if let Some(result) = crate::fs::read(path, session_id) {
result.map_err(|err| HostlibError::Backend {
builtin: BUILTIN,
message: format!("read `{}`: {err}", path.display()),
})?
} else {
std::fs::read(path).map_err(|err| HostlibError::Backend {
builtin: BUILTIN,
message: format!("read `{}`: {err}", path.display()),
})?
};
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
fn write_source(path: &Path, contents: &str, session_id: Option<&str>) -> Result<(), String> {
match crate::fs::stage_write_or_none(BUILTIN, path, contents.as_bytes(), true, true, session_id)
{
Ok(Some(_)) => return Ok(()),
Ok(None) => {}
Err(err) => return Err(err.to_string()),
}
crate::fs_snapshot::auto_capture_for_write(BUILTIN, path);
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|err| format!("mkdir `{}`: {err}", parent.display()))?;
}
}
std::fs::write(path, contents).map_err(|err| format!("write `{}`: {err}", path.display()))
}
struct ResponseEnv<'a> {
symbol_name: &'a str,
new_name: &'a str,
symbol_path: &'a str,
symbol_line: Option<u32>,
symbol_kind: Option<NodeKind>,
scope: Scope,
}
#[derive(Default)]
struct ResponseExtras {
applied: bool,
dry_run: bool,
touched_files: Vec<VmValue>,
conflicts: Vec<VmValue>,
warnings: Vec<VmValue>,
failed_paths: Vec<VmValue>,
match_count: usize,
details: String,
fallback_suggestion: Option<String>,
}
fn emit_response(env: &ResponseEnv<'_>, tag: &'static str, extras: ResponseExtras) -> VmValue {
let mut entries: Vec<(&'static str, VmValue)> = vec![
("result", str_value(tag)),
("applied", VmValue::Bool(extras.applied)),
("dry_run", VmValue::Bool(extras.dry_run)),
("scope", str_value(env.scope.as_str())),
("symbol", symbol_descriptor(env)),
(
"touched_files",
VmValue::List(Arc::new(extras.touched_files)),
),
("conflicts", VmValue::List(Arc::new(extras.conflicts))),
("warnings", VmValue::List(Arc::new(extras.warnings))),
(
"failed_paths_with_reasons",
VmValue::List(Arc::new(extras.failed_paths)),
),
("match_count", VmValue::Int(extras.match_count as i64)),
("details", str_value(&extras.details)),
];
if let Some(fallback) = extras.fallback_suggestion {
entries.push(("fallback_suggestion", str_value(fallback)));
}
build_dict(entries)
}
fn applied_response(
env: &ResponseEnv<'_>,
plans: &[FilePlan],
dry_run: bool,
failed: Vec<(String, String)>,
) -> VmValue {
let touched_files: Vec<VmValue> = plans.iter().map(file_plan_to_value).collect();
let match_count: usize = plans.iter().map(|p| p.edits.len()).sum();
let details = if dry_run {
"dry_run — no files were written"
} else if failed.is_empty() {
"rename applied"
} else {
"rename partially applied; see failed_paths_with_reasons"
};
emit_response(
env,
"applied",
ResponseExtras {
applied: failed.is_empty() && !dry_run,
dry_run,
touched_files,
failed_paths: failed_paths_value(&failed),
match_count,
details: details.to_string(),
..Default::default()
},
)
}
fn no_match_response(env: &ResponseEnv<'_>) -> VmValue {
emit_response(
env,
"no_match",
ResponseExtras {
details: format!(
"no symbol named `{}` resolved against the typed graph; \
either the workspace has not been indexed, the file is not tracked, \
or `symbol_ref.line`/`symbol_ref.kind` over-narrowed the search",
env.symbol_name
),
..Default::default()
},
)
}
fn ambiguous_response(
env: &ResponseEnv<'_>,
candidates: &[(String, u32, &'static str)],
) -> VmValue {
let candidate_list: Vec<VmValue> = candidates
.iter()
.map(|(path, line, kind)| {
build_dict([
("path", str_value(path)),
("line", VmValue::Int(*line as i64)),
("kind", str_value(*kind)),
])
})
.collect();
emit_response(
env,
"ambiguous_symbol",
ResponseExtras {
warnings: candidate_list,
details: "multiple symbols share `symbol_ref.name`; pass `symbol_ref.line` \
(and optionally `symbol_ref.kind`) to disambiguate. \
Candidates surfaced in the `warnings` field."
.to_string(),
..Default::default()
},
)
}
fn conflict_response(env: &ResponseEnv<'_>, shadows: &[ShadowSite]) -> VmValue {
let conflicts: Vec<VmValue> = shadows
.iter()
.map(|s| {
build_dict([
("path", str_value(&s.path)),
("row", VmValue::Int(s.row as i64)),
("col", VmValue::Int(s.col as i64)),
("shadow", str_value(env.new_name)),
])
})
.collect();
emit_response(
env,
"conflict",
ResponseExtras {
conflicts,
details: format!(
"rename to `{}` would shadow an existing identifier; \
see `conflicts` for site list",
env.new_name
),
..Default::default()
},
)
}
fn unsupported_language_response(
env: &ResponseEnv<'_>,
file_path: &str,
language: Option<&str>,
) -> VmValue {
let supported = Language::all()
.iter()
.filter(|l| l.supports_rename())
.map(|l| l.name())
.collect::<Vec<_>>()
.join(", ");
emit_response(
env,
"unsupported_language",
ResponseExtras {
details: format!(
"no identifier-kind table for `{}` in `{file_path}`; \
rename supports {supported}",
language.unwrap_or("?")
),
fallback_suggestion: Some(TEXT_PATCH_FALLBACK.to_string()),
..Default::default()
},
)
}
fn invalid_identifier_response(env: &ResponseEnv<'_>, detail: &str) -> VmValue {
emit_response(
env,
"invalid_identifier",
ResponseExtras {
details: format!("`new_name` rejected: {detail}"),
..Default::default()
},
)
}
fn syntax_error_response(env: &ResponseEnv<'_>, file_path: &str, detail: &str) -> VmValue {
emit_response(
env,
"syntax_error",
ResponseExtras {
details: format!("rewriting `{file_path}` produced syntax errors: {detail}"),
..Default::default()
},
)
}
fn symbol_descriptor(env: &ResponseEnv<'_>) -> VmValue {
build_dict([
("name", str_value(env.symbol_name)),
("new_name", str_value(env.new_name)),
("path", str_value(env.symbol_path)),
(
"line",
env.symbol_line
.map(|n| VmValue::Int(n as i64))
.unwrap_or(VmValue::Nil),
),
(
"kind",
env.symbol_kind
.map(|k| str_value(k.as_str()))
.unwrap_or(VmValue::Nil),
),
])
}
fn file_plan_to_value(plan: &FilePlan) -> VmValue {
let edits: Vec<VmValue> = plan
.edits
.iter()
.map(|edit| {
build_dict([
("start_byte", VmValue::Int(edit.start_byte as i64)),
("end_byte", VmValue::Int(edit.end_byte as i64)),
("start_row", VmValue::Int(edit.start_row as i64)),
("start_col", VmValue::Int(edit.start_col as i64)),
("end_row", VmValue::Int(edit.end_row as i64)),
("end_col", VmValue::Int(edit.end_col as i64)),
("before", str_value(&edit.before)),
("after", str_value(&edit.after)),
])
})
.collect();
build_dict([
("path", str_value(&plan.path)),
("language", str_value(plan.language.name())),
(
"before_sha256",
str_value(sha256_hex(plan.source.as_bytes())),
),
(
"after_sha256",
str_value(sha256_hex(plan.patched.as_bytes())),
),
("edits", VmValue::List(Arc::new(edits))),
])
}
fn failed_paths_value(failed: &[(String, String)]) -> Vec<VmValue> {
failed
.iter()
.map(|(path, reason)| {
build_dict([("path", str_value(path)), ("reason", str_value(reason))])
})
.collect()
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::fs;
use tempfile::tempdir;
use crate::code_index::CodeIndexCapability;
fn vm_string(s: &str) -> VmValue {
VmValue::String(Arc::from(s))
}
fn dict(pairs: &[(&str, VmValue)]) -> VmValue {
let mut map: BTreeMap<String, VmValue> = BTreeMap::new();
for (k, v) in pairs {
map.insert((*k).to_string(), v.clone());
}
VmValue::Dict(Arc::new(map))
}
fn field<'a>(value: &'a VmValue, key: &str) -> &'a VmValue {
match value {
VmValue::Dict(d) => d.get(key).expect("missing field"),
_ => panic!("expected dict, got {value:?}"),
}
}
fn s(value: &VmValue) -> String {
match value {
VmValue::String(s) => s.to_string(),
other => panic!("expected string, got {other:?}"),
}
}
fn list_len(value: &VmValue) -> usize {
match value {
VmValue::List(list) => list.len(),
other => panic!("expected list, got {other:?}"),
}
}
fn build_index(root: &Path) -> CodeIndexCapability {
let capability = CodeIndexCapability::new();
let shared = capability.shared();
let mut guard = shared.lock().expect("mutex");
let (state, _outcome) = IndexState::build_from_root(root);
*guard = Some(state);
drop(guard);
capability
}
fn rename(
capability: &CodeIndexCapability,
symbol_ref: VmValue,
new_name: &str,
scope: &str,
) -> VmValue {
let entries = vec![
("symbol_ref", symbol_ref),
("new_name", vm_string(new_name)),
("scope", vm_string(scope)),
];
run(&capability.shared(), &[dict(&entries)]).expect("rename runs")
}
#[test]
fn rust_workspace_rename_rewrites_definitions_and_call_sites() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("src/lib.rs"),
"pub struct Widget {\n pub size: u32,\n}\n\nimpl Widget {\n pub fn new() -> Widget { Widget { size: 0 } }\n}\n",
)
.unwrap();
fs::write(
root.join("src/main.rs"),
"use crate::Widget;\nfn main() {\n let w: Widget = Widget::new();\n let _ = w.size;\n}\n",
)
.unwrap();
let capability = build_index(root);
let symbol_ref = dict(&[
("name", vm_string("Widget")),
("path", vm_string("src/lib.rs")),
("kind", vm_string("Type")),
]);
let result = rename(&capability, symbol_ref, "Gadget", "workspace");
assert_eq!(s(field(&result, "result")), "applied");
assert_eq!(list_len(field(&result, "touched_files")), 2);
let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
let main = fs::read_to_string(root.join("src/main.rs")).unwrap();
assert!(lib.contains("struct Gadget"));
assert!(lib.contains("impl Gadget"));
assert!(!lib.contains("Widget"));
assert!(main.contains("use crate::Gadget;"));
assert!(main.contains("let w: Gadget = Gadget::new();"));
}
#[test]
fn rename_skips_string_literals_and_comments() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("src/lib.rs"),
"// Widget is the doc word\npub struct Widget { size: u32 }\nfn label() -> &'static str { \"Widget\" }\n",
)
.unwrap();
let capability = build_index(root);
let symbol_ref = dict(&[
("name", vm_string("Widget")),
("path", vm_string("src/lib.rs")),
("kind", vm_string("Type")),
]);
let result = rename(&capability, symbol_ref, "Gadget", "workspace");
assert_eq!(s(field(&result, "result")), "applied");
let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
assert!(lib.contains("struct Gadget"));
assert!(lib.contains("// Widget is the doc word"));
assert!(lib.contains("\"Widget\""));
}
#[test]
fn rename_detects_shadow_conflict_and_skips_write() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
let original_main = "pub struct Widget {}\npub struct Gadget {}\nfn main() { let _ = Widget {}; let _ = Gadget {}; }\n";
fs::write(root.join("src/main.rs"), original_main).unwrap();
let capability = build_index(root);
let symbol_ref = dict(&[
("name", vm_string("Widget")),
("path", vm_string("src/main.rs")),
("kind", vm_string("Type")),
]);
let result = rename(&capability, symbol_ref, "Gadget", "workspace");
assert_eq!(s(field(&result, "result")), "conflict");
assert!(list_len(field(&result, "conflicts")) >= 1);
let on_disk = fs::read_to_string(root.join("src/main.rs")).unwrap();
assert_eq!(on_disk, original_main, "rename must not write on conflict");
}
#[test]
fn dry_run_does_not_modify_disk() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
let original = "fn alpha() { println!(\"hi\"); }\nfn caller() { alpha(); }\n";
fs::write(root.join("src/lib.rs"), original).unwrap();
let capability = build_index(root);
let symbol_ref = dict(&[
("name", vm_string("alpha")),
("path", vm_string("src/lib.rs")),
("kind", vm_string("Function")),
]);
let result = run(
&capability.shared(),
&[dict(&[
("symbol_ref", symbol_ref),
("new_name", vm_string("beta")),
("scope", vm_string("workspace")),
("dry_run", VmValue::Bool(true)),
])],
)
.expect("rename runs");
assert_eq!(s(field(&result, "result")), "applied");
let on_disk = fs::read_to_string(root.join("src/lib.rs")).unwrap();
assert_eq!(on_disk, original, "dry_run must leave disk untouched");
}
#[test]
fn typescript_workspace_rename() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("src/widget.ts"),
"export class Widget {\n size = 0;\n resize(n: number) { this.size = n; }\n}\n",
)
.unwrap();
fs::write(
root.join("src/main.ts"),
"import { Widget } from \"./widget\";\nconst w = new Widget();\nw.resize(7);\nconsole.log(w.size);\n",
)
.unwrap();
let capability = build_index(root);
let symbol_ref = dict(&[
("name", vm_string("Widget")),
("path", vm_string("src/widget.ts")),
("kind", vm_string("Type")),
]);
let result = rename(&capability, symbol_ref, "Gadget", "workspace");
assert_eq!(s(field(&result, "result")), "applied");
let widget = fs::read_to_string(root.join("src/widget.ts")).unwrap();
let main = fs::read_to_string(root.join("src/main.ts")).unwrap();
assert!(widget.contains("class Gadget"));
assert!(main.contains("import { Gadget }"));
assert!(main.contains("new Gadget()"));
}
#[test]
fn python_workspace_rename() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("pkg")).unwrap();
fs::write(
root.join("pkg/widget.py"),
"class Widget:\n def __init__(self):\n self.size = 0\n",
)
.unwrap();
fs::write(
root.join("pkg/main.py"),
"from .widget import Widget\nw = Widget()\nprint(w.size)\n",
)
.unwrap();
let capability = build_index(root);
let symbol_ref = dict(&[
("name", vm_string("Widget")),
("path", vm_string("pkg/widget.py")),
("kind", vm_string("Type")),
]);
let result = rename(&capability, symbol_ref, "Gadget", "workspace");
assert_eq!(s(field(&result, "result")), "applied");
let widget = fs::read_to_string(root.join("pkg/widget.py")).unwrap();
let main = fs::read_to_string(root.join("pkg/main.py")).unwrap();
assert!(widget.contains("class Gadget"));
assert!(main.contains("from .widget import Gadget"));
assert!(main.contains("w = Gadget()"));
}
#[test]
fn go_workspace_rename() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("widget")).unwrap();
fs::write(
root.join("widget/widget.go"),
"package widget\n\ntype Widget struct{ Size int }\n\nfunc New() *Widget { return &Widget{Size: 0} }\n",
)
.unwrap();
fs::write(
root.join("main.go"),
"package main\n\nimport \"./widget\"\n\nfunc main() {\n w := widget.New()\n _ = w\n}\n",
)
.unwrap();
let capability = build_index(root);
let symbol_ref = dict(&[
("name", vm_string("Widget")),
("path", vm_string("widget/widget.go")),
("kind", vm_string("Type")),
]);
let result = rename(&capability, symbol_ref, "Gadget", "workspace");
assert_eq!(s(field(&result, "result")), "applied");
let widget = fs::read_to_string(root.join("widget/widget.go")).unwrap();
assert!(widget.contains("type Gadget struct"));
assert!(widget.contains("*Gadget"));
assert!(!widget.contains("Widget"));
}
#[test]
fn swift_file_rename() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::write(
root.join("Widget.swift"),
"class Widget {\n var size = 0\n func resize(_ n: Int) { self.size = n }\n}\n\nlet w = Widget()\nw.resize(3)\n",
)
.unwrap();
let capability = build_index(root);
let symbol_ref = dict(&[
("name", vm_string("Widget")),
("path", vm_string("Widget.swift")),
("kind", vm_string("Type")),
]);
let result = rename(&capability, symbol_ref, "Gadget", "file");
assert_eq!(s(field(&result, "result")), "applied");
let swift = fs::read_to_string(root.join("Widget.swift")).unwrap();
assert!(swift.contains("class Gadget"));
assert!(swift.contains("let w = Gadget()"));
}
#[test]
fn harn_workspace_rename() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("src/task.harn"),
"pub struct Task {\n title: string\n}\n\nimpl Task {\n fn summary(task: Task) -> string {\n return task.title\n }\n}\n\nfn summarize(task: Task) -> string {\n return task.title\n}\n",
)
.unwrap();
let capability = build_index(root);
let symbol_ref = dict(&[
("name", vm_string("Task")),
("path", vm_string("src/task.harn")),
("kind", vm_string("Type")),
]);
let result = rename(&capability, symbol_ref, "Job", "workspace");
assert_eq!(s(field(&result, "result")), "applied");
let source = fs::read_to_string(root.join("src/task.harn")).unwrap();
assert!(source.contains("struct Job"));
assert!(source.contains("impl Job"));
assert!(source.contains("task: Job"));
assert!(source.contains("return task.title"));
assert!(!source.contains("Task"));
}
#[test]
fn ambiguous_symbol_without_disambiguator() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("src/a.rs"),
"fn helper() {}\nfn helper2() { helper(); }\n",
)
.unwrap();
fs::write(
root.join("src/b.rs"),
"fn helper() {}\nfn helper3() { helper(); }\n",
)
.unwrap();
let capability = build_index(root);
let symbol_ref = dict(&[
("name", vm_string("helper")),
("path", vm_string("src/missing.rs")),
("kind", vm_string("Function")),
]);
let result = rename(&capability, symbol_ref, "renamed", "workspace");
assert_eq!(s(field(&result, "result")), "ambiguous_symbol");
}
#[test]
fn rejects_invalid_new_name() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/lib.rs"), "fn alpha() {}\n").unwrap();
let capability = build_index(root);
let symbol_ref = dict(&[
("name", vm_string("alpha")),
("path", vm_string("src/lib.rs")),
("kind", vm_string("Function")),
]);
let result = rename(&capability, symbol_ref, "1bad", "file");
assert_eq!(s(field(&result, "result")), "invalid_identifier");
}
#[test]
fn staged_fs_session_routes_writes_through_overlay() {
let dir = tempdir().unwrap();
let root = dir.path().canonicalize().expect("canonicalize");
std::fs::create_dir_all(root.join("src")).unwrap();
let original = "pub fn alpha() {}\nfn caller() { alpha(); }\n";
std::fs::write(root.join("src/lib.rs"), original).unwrap();
let capability = build_index(&root);
let session_id = format!(
"rename-test-{:?}-{}",
std::thread::current().id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
crate::fs::configure_session_root(&session_id, &root);
crate::fs::set_mode(&session_id, crate::fs::FsMode::Staged, Some(&root)).expect("set_mode");
let symbol_ref = dict(&[
("name", vm_string("alpha")),
("path", vm_string("src/lib.rs")),
("kind", vm_string("Function")),
]);
let result = run(
&capability.shared(),
&[dict(&[
("symbol_ref", symbol_ref),
("new_name", vm_string("beta")),
("scope", vm_string("workspace")),
("session_id", vm_string(&session_id)),
])],
)
.expect("rename runs");
assert_eq!(s(field(&result, "result")), "applied");
let on_disk_pre = std::fs::read_to_string(root.join("src/lib.rs")).unwrap();
assert_eq!(
on_disk_pre, original,
"staged session must not flush to disk before commit"
);
let status = crate::fs::staged_status(&session_id).expect("status");
assert_eq!(
status.pending_writes.len(),
1,
"expected one pending file; got {status:?}"
);
let commit = crate::fs::commit_staged(&session_id, &[]).expect("commit");
assert!(
commit.failed_paths_with_reasons.is_empty(),
"commit reported failed paths: {:?}",
commit.failed_paths_with_reasons
);
let on_disk_post = std::fs::read_to_string(root.join("src/lib.rs")).unwrap();
assert!(on_disk_post.contains("fn beta()"));
assert!(on_disk_post.contains("beta();"));
assert!(!on_disk_post.contains("alpha"));
}
}