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_backend::{BackendChoice, BackendReport};
use super::refactor_core::{
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_selected, write_plans, EditEnvelope, EditSpan, EditSymbol,
FilePlan, Scope, SeedLookup, ShadowSite,
};
#[cfg(test)]
use super::state::IndexState;
use super::symbol_graph::{NodeId, NodeKind, SymbolGraph};
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_query = require_string(BUILTIN, symbol_dict, "name")?;
let symbol_name = symbol_query
.rsplit('.')
.next()
.unwrap_or(&symbol_query)
.to_string();
let symbol_path = require_string(BUILTIN, symbol_dict, "path")?;
let symbol_line = super::builtin_args::optional_positive_u32(BUILTIN, symbol_dict, "line")?;
let symbol_kind_raw = optional_string(BUILTIN, symbol_dict, "kind")?;
let symbol_col = super::builtin_args::optional_positive_u32(BUILTIN, symbol_dict, "col")?;
if symbol_col.is_some()
&& Language::detect(Path::new(&symbol_path), None)
.is_some_and(|language| language != Language::Java)
{
return Err(unsupported_column_error());
}
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 proof = optional_bool(BUILTIN, dict, "proof", false)?;
let backend = BackendChoice::resolve(
BUILTIN,
optional_string(BUILTIN, dict, "backend")?.as_deref(),
)?;
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 warmed = (is_rename && backend != BackendChoice::SyntaxTree)
.then(|| language_server::warm(index, &symbol_path))
.flatten();
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 mut env = ResponseEnv {
symbol_name: &symbol_name,
new_name: &new_name,
symbol_path: &symbol_path,
symbol_line,
symbol_kind,
scope,
backend: BackendReport::default(),
stale: Vec::new(),
proof,
preserved: None,
graph: &state.symbols,
};
let normalized_path = super::builtins::normalize_relative_path_for(state, &symbol_path);
let seed = resolve_seed_selected(
&state.symbols,
&normalized_path,
&symbol_query,
symbol_line,
symbol_col,
symbol_kind,
);
let has_non_java_seed = match &seed {
SeedLookup::One(id) => state
.symbols
.node(*id)
.is_some_and(|node| node.language != "java"),
SeedLookup::Many(ids) => ids.iter().any(|id| {
state
.symbols
.node(*id)
.is_some_and(|node| node.language != "java")
}),
SeedLookup::None => false,
};
if symbol_col.is_some() && has_non_java_seed {
return Err(unsupported_column_error());
}
let seed_node_id = match seed {
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.to_string();
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_rename && backend != BackendChoice::SyntaxTree {
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 request = language_server::Request {
state,
seed: seed_node,
in_scope_files: &in_scope_files,
session_id: session_id.as_deref(),
dry_run,
validate,
warmed,
};
match language_server::rename(&mut env, request)? {
language_server::Answer::Response(response) => return Ok(response),
language_server::Answer::Fallback(reason)
if backend == BackendChoice::LanguageServer =>
{
return Ok(emit_response(
&env,
"backend_unavailable",
ResponseExtras {
details: format!("the language server could not answer: {reason}"),
..Default::default()
},
));
}
language_server::Answer::Fallback(reason) => {
env.backend.fallback_reason = Some(reason);
env.stale.clear();
}
}
}
env.backend.syntax_tree = true;
let competing =
competing_declarations(&state.symbols, seed_node_id, &symbol_name, &in_scope_files);
let java_method = seed_node.language == "java"
&& seed_node.kind == NodeKind::Function
&& seed_node.owner_type.is_some();
if !java_method && !competing.is_empty() {
return Ok(ambiguous_response_with_details(
&env,
"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();
if java_method {
if let Err(detail) = super::rename_java::check_new_name(&new_name) {
return Ok(ambiguous_response_with_details(&env, &detail));
}
}
for path in &in_scope_files {
let Some(language) = Language::detect(Path::new(path), None) else {
return Ok(unsupported_language_response(&env, path, None));
};
if java_method && language != Language::Java {
return Ok(unsupported_language_response(
&env,
path,
Some(language.name()),
));
}
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 java_method && language == Language::Java {
match super::rename_java::sites(
state,
seed_node,
path,
&source,
&tree,
targets,
local_shadows,
) {
Ok(sites) => {
targets = sites.targets;
local_shadows = sites.shadows;
}
Err(detail) => {
return Ok(ambiguous_response_with_details(
&env,
&format!("rename aborted without writes: {detail}"),
))
}
}
}
if !local_shadows.is_empty() {
shadows.extend(local_shadows);
continue;
}
if targets.is_empty() {
if path == &seed_path {
return Ok(ambiguous_response_with_details(
&env,
"rename aborted without writes: the indexed declaration is absent from the current source; rebuild the code index",
));
}
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"),
));
}
}
if !java_method {
if let Err(detail) = super::rename_binding::check(
super::rename_binding::Seed {
state,
node: seed_node,
name: &symbol_name,
},
path,
language,
&source,
&tree,
&targets,
) {
return Ok(ambiguous_response_with_details(
&env,
&format!(
"rename aborted without writes: {detail}; use a binding-aware LSP rename"
),
));
}
}
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))
}
fn unsupported_column_error() -> HostlibError {
HostlibError::InvalidParameter {
builtin: BUILTIN,
param: "symbol_ref.col",
message: "column selectors are supported only for Java declarations".into(),
}
}
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,
backend: BackendReport,
stale: Vec<String>,
proof: bool,
preserved: Option<Vec<crate::edit_proof::PreservedSite>>,
graph: &'a SymbolGraph,
}
#[derive(Default)]
struct ResponseExtras {
ambiguity_reason: Option<AmbiguityReason>,
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>,
proof: Option<VmValue>,
}
#[derive(Clone, Copy)]
enum AmbiguityReason {
MultipleDefinitionSeeds,
UnresolvedReferenceBinding,
}
impl AmbiguityReason {
fn as_str(self) -> &'static str {
match self {
Self::MultipleDefinitionSeeds => "multiple_definition_seeds",
Self::UnresolvedReferenceBinding => "unresolved_reference_binding",
}
}
}
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![
("backend", env.backend.value()),
(
"ambiguity_reason",
extras
.ambiguity_reason
.map_or(VmValue::Nil, |reason| str_value(reason.as_str())),
),
],
proof: extras.proof,
},
)
}
fn applied_response(
env: &ResponseEnv<'_>,
plans: &[FilePlan],
dry_run: bool,
failed: Vec<(String, String)>,
) -> VmValue {
let proof = (env.proof && failed.is_empty()).then(|| {
let mut proof = super::refactor_core::edit_proof(plans, &failed);
if let Some(sites) = &env.preserved {
proof.preserved(
sites
.iter()
.cloned()
.map(|mut site| {
if let Some(plan) = plans.iter().find(|plan| plan.path == site.path) {
site.text = plan
.patched
.lines()
.nth(site.line.saturating_sub(1))
.unwrap_or("")
.to_owned();
}
site
})
.collect(),
);
}
proof.value()
});
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(),
warnings: env.stale.iter().map(str_value).collect(),
proof,
..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: &[NodeId]) -> VmValue {
ambiguous_response_with_reason(
env,
candidates,
AmbiguityReason::MultipleDefinitionSeeds,
"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<'_>, details: &str) -> VmValue {
let candidates: Vec<_> = env
.graph
.nodes_named(env.symbol_name)
.iter()
.filter_map(|id| env.graph.node(*id))
.filter(|node| super::refactor_core::is_refactor_declaration(node))
.map(|node| node.id)
.collect();
ambiguous_response_with_reason(
env,
&candidates,
AmbiguityReason::UnresolvedReferenceBinding,
details,
)
}
fn ambiguous_response_with_reason(
env: &ResponseEnv<'_>,
candidates: &[NodeId],
reason: AmbiguityReason,
details: &str,
) -> VmValue {
let candidate_list = candidates
.iter()
.filter_map(|id| env.graph.node(*id))
.map(|node| {
build_dict([
(
"symbol",
str_value(if node.language == "java" {
node.qualified_name()
} else {
node.name.clone()
}),
),
("qualified_name", str_value(node.qualified_name())),
("path", str_value(node.path.as_ref())),
("line", VmValue::Int(node.line as i64)),
(
"col",
node.col
.map_or(VmValue::Nil, |col| VmValue::Int(col as i64)),
),
("kind", str_value(node.kind.as_str())),
])
})
.collect();
emit_response(
env,
"ambiguous_symbol",
ResponseExtras {
warnings: candidate_list,
ambiguity_reason: Some(reason),
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()
},
)
}
#[path = "rename_lsp.rs"]
mod language_server;
#[cfg(test)]
#[path = "rename_tests.rs"]
mod tests;