use std::path::Path;
use harn_vm::VmValue;
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::refactor_core::{
candidates_value, collect_identifier_spans, competing_declarations, edit_envelope,
failed_paths_value, file_plan_value, files_in_scope, first_syntax_error, is_identifier_token,
parse_kind, plan_file, read_source, resolve_seed, write_plans, EditEnvelope, EditSpan,
EditSymbol, FilePlan, Scope, SeedLookup, ShadowSite,
};
#[cfg(test)]
use super::state::IndexState;
use super::symbol_graph::NodeKind;
pub(super) const BUILTIN: &str = "hostlib_code_index_rename_symbol";
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(|raw| parse_kind(BUILTIN, raw))
.transpose()?;
let replacement_text = optional_string(BUILTIN, dict, "replacement_text")?;
let scope = Scope::parse(BUILTIN, &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)?;
let new_name = match &replacement_text {
None => require_string(BUILTIN, dict, "new_name")?,
Some(text) => text.clone(),
};
let is_rename = replacement_text.is_none();
if is_rename && new_name == symbol_name {
return Err(HostlibError::InvalidParameter {
builtin: BUILTIN,
param: "new_name",
message: "new_name must differ from symbol_ref.name".into(),
});
}
if let Some(text) = &replacement_text {
if text.is_empty() {
return Err(HostlibError::InvalidParameter {
builtin: BUILTIN,
param: "replacement_text",
message: "replacement_text must be non-empty (to delete a symbol \
use `remove_symbol` / `delete_range`)"
.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));
}
let competing =
competing_declarations(&state.symbols, seed_node_id, &symbol_name, &in_scope_files);
if !competing.is_empty() {
let mut candidates = vec![(seed_path, seed_node.line, seed_node.kind.as_str())];
candidates.extend(competing);
return Ok(ambiguous_response_with_details(
&env,
&candidates,
"rename aborted without writes: the scope contains separate declarations with this name. Pinning the seed does not disambiguate their references; use a binding-aware LSP rename or narrow the scope.",
));
}
if is_rename && !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 Some(language) = Language::detect(Path::new(path), None) else {
return Ok(unsupported_language_response(&env, path, None));
};
let source = read_source(BUILTIN, &state.root, path, session_id.as_deref())?;
let tree = match ast_api::parse_tree(&source, language) {
Ok(tree) => tree,
Err(err) => {
return Ok(syntax_error_response(
&env,
format!("`{path}` failed to parse: {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();
let shadow_target: &str = if is_rename { &new_name } else { "" };
collect_identifier_spans(
tree.root_node(),
source.as_bytes(),
&symbol_name,
shadow_target,
identifier_kinds,
path,
&mut targets,
&mut local_shadows,
);
if !local_shadows.is_empty() {
shadows.extend(local_shadows);
continue;
}
if targets.is_empty() {
continue;
}
if validate {
if let Some(detail) = first_syntax_error(&source, language) {
return Ok(syntax_error_response(
&env,
format!("`{path}` does not parse before the edit ({detail}); fix it first"),
));
}
}
let edits: Vec<EditSpan> = targets
.into_iter()
.map(|span| EditSpan {
span,
before: symbol_name.clone(),
after: new_name.clone(),
})
.collect();
match plan_file(path.clone(), language, source, edits, validate) {
Ok(plan) => plans.push(plan),
Err(detail) => {
return Ok(syntax_error_response(
&env,
format!("rewriting `{path}` produced syntax errors: {detail}"),
));
}
}
}
if !shadows.is_empty() {
return Ok(conflict_response(&env, &shadows));
}
if plans.is_empty() {
return Ok(no_match_response(&env));
}
let failed = if dry_run {
Vec::new()
} else {
write_plans(BUILTIN, &state.root, &plans, session_id.as_deref())?
};
Ok(applied_response(&env, &plans, dry_run, failed))
}
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 {
edit_envelope(
tag,
env.scope,
&EditSymbol {
name: env.symbol_name,
new_name: Some(env.new_name),
path: env.symbol_path,
line: env.symbol_line,
kind: env.symbol_kind,
},
EditEnvelope {
applied: extras.applied,
dry_run: extras.dry_run,
touched_files: extras.touched_files,
conflicts: extras.conflicts,
warnings: extras.warnings,
failed_paths: extras.failed_paths,
match_count: extras.match_count,
details: extras.details,
fallback_suggestion: extras.fallback_suggestion,
extra: Vec::new(),
},
)
}
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_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 {
ambiguous_response_with_details(
env,
candidates,
"multiple symbols share `symbol_ref.name`; pass `symbol_ref.line` (and optionally `symbol_ref.kind`) to disambiguate. Candidates surfaced in the `warnings` field.",
)
}
fn ambiguous_response_with_details(
env: &ResponseEnv<'_>,
candidates: &[(String, u32, &'static str)],
details: &str,
) -> VmValue {
let candidate_list = candidates_value(candidates);
emit_response(
env,
"ambiguous_symbol",
ResponseExtras {
warnings: candidate_list,
details: details.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<'_>, details: String) -> VmValue {
emit_response(
env,
"syntax_error",
ResponseExtras {
details,
..Default::default()
},
)
}
#[cfg(test)]
#[path = "rename_tests.rs"]
mod tests;