use aura_lang::lexer::token::KEYWORDS;
use lsp_types::Range;
use crate::diagnostics::LineIndex;
use crate::goto::resolution;
#[derive(Debug, PartialEq)]
pub enum Refusal {
Unparseable,
NotABinding,
BadName(String),
WouldCapture(String),
IsPublic(String),
}
impl Refusal {
pub fn message(&self) -> String {
match self {
Refusal::Unparseable => {
"cannot rename while the file has syntax errors: scopes are unknown".into()
}
Refusal::NotABinding => {
"not a renameable symbol (only variables, parameters, `def`, `type`, \
`enum` and import aliases can be renamed)"
.into()
}
Refusal::BadName(why) => format!("invalid new name: {why}"),
Refusal::WouldCapture(name) => format!(
"'{name}' is already bound in an overlapping scope — renaming would \
change which binding some occurrence refers to; pick another name"
),
Refusal::IsPublic(name) => format!(
"'{name}' is `pub`, so other modules may import it by name; rename it \
there first, or make it private"
),
}
}
}
fn check_name(name: &str) -> Result<(), Refusal> {
if name.is_empty() {
return Err(Refusal::BadName("empty".into()));
}
if KEYWORDS.contains(&name) {
return Err(Refusal::BadName(format!("'{name}' is a keyword")));
}
let mut chars = name.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_alphabetic() || first == '_') {
return Err(Refusal::BadName(
"must start with a letter or underscore".into(),
));
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(Refusal::BadName(
"only letters, digits and underscores are allowed".into(),
));
}
Ok(())
}
pub fn prepare(text: &str, line: u32, character: u32) -> Result<Range, Refusal> {
let r = resolution(text).ok_or(Refusal::Unparseable)?;
let index = LineIndex::new(text);
let offset = index.offset(text, line, character) as u32;
let b = r.binding_at(offset).ok_or(Refusal::NotABinding)?;
let binding = &r.bindings[b];
if binding.public {
return Err(Refusal::IsPublic(binding.name.clone()));
}
let d = binding.decl;
Ok(Range {
start: index.position(text, d.start as usize),
end: index.position(text, d.end as usize),
})
}
pub fn edits(text: &str, line: u32, character: u32, new_name: &str) -> Result<Vec<Range>, Refusal> {
check_name(new_name)?;
let r = resolution(text).ok_or(Refusal::Unparseable)?;
let index = LineIndex::new(text);
let offset = index.offset(text, line, character) as u32;
let b = r.binding_at(offset).ok_or(Refusal::NotABinding)?;
let binding = &r.bindings[b];
if binding.public {
return Err(Refusal::IsPublic(binding.name.clone()));
}
if new_name == binding.name {
return Ok(Vec::new()); }
if !r.conflicting(new_name, binding.scope).is_empty() {
return Err(Refusal::WouldCapture(new_name.to_string()));
}
Ok(r.occurrences(b)
.into_iter()
.map(|s| Range {
start: index.position(text, s.start as usize),
end: index.position(text, s.end as usize),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn apply(text: &str, line: u32, ch: u32, new_name: &str) -> Result<String, Refusal> {
let ranges = edits(text, line, ch, new_name)?;
let index = LineIndex::new(text);
let mut offsets: Vec<(usize, usize)> = ranges
.iter()
.map(|r| {
(
index.offset(text, r.start.line, r.start.character),
index.offset(text, r.end.line, r.end.character),
)
})
.collect();
offsets.sort_unstable();
let mut out = text.to_string();
for (s, e) in offsets.into_iter().rev() {
out.replace_range(s..e, new_name);
}
Ok(out)
}
#[test]
fn renames_only_the_binding_under_the_cursor() {
let src = "x = 1\ndef f(x)\n y: x\nend\nz: x\n";
assert_eq!(
apply(src, 0, 0, "count").unwrap(),
"count = 1\ndef f(x)\n y: x\nend\nz: count\n"
);
assert_eq!(
apply(src, 1, 6, "p").unwrap(),
"x = 1\ndef f(p)\n y: p\nend\nz: x\n"
);
}
#[test]
fn renames_inside_interpolation() {
let src = "name = \"api\"\nid: \"#{name}-1\"\n";
assert_eq!(
apply(src, 0, 0, "svc").unwrap(),
"svc = \"api\"\nid: \"#{svc}-1\"\n"
);
}
#[test]
fn renames_a_shadowing_binding_independently() {
let src = "p = 1\ndomain \"d\"\n shadow p = 2\n a: p\nend\nb: p\n";
assert_eq!(
apply(src, 3, 5, "inner").unwrap(),
"p = 1\ndomain \"d\"\n shadow inner = 2\n a: inner\nend\nb: p\n"
);
}
#[test]
fn renames_a_type_and_its_uses() {
let src = "type Quota\n cpu: Int\nend\nq: new Quota\n cpu: 2\nend\n";
assert_eq!(
apply(src, 0, 5, "Limits").unwrap(),
"type Limits\n cpu: Int\nend\nq: new Limits\n cpu: 2\nend\n"
);
}
#[test]
fn refuses_when_the_file_does_not_parse() {
assert_eq!(
edits("x = 1\ndef f(\ny: x\n", 0, 0, "y"),
Err(Refusal::Unparseable)
);
}
#[test]
fn refuses_a_capture() {
let src = "x = 1\ndef f()\n y = 2\n w: y\nend\nz: x\n";
assert_eq!(
edits(src, 0, 0, "y"),
Err(Refusal::WouldCapture("y".into()))
);
assert!(matches!(
edits("a = 1\nb = 2\nc: a\n", 0, 0, "b"),
Err(Refusal::WouldCapture(_))
));
}
#[test]
fn refuses_keywords_and_malformed_names() {
let src = "x = 1\ny: x\n";
assert!(matches!(edits(src, 0, 0, "end"), Err(Refusal::BadName(_))));
assert!(matches!(edits(src, 0, 0, "2fa"), Err(Refusal::BadName(_))));
assert!(matches!(edits(src, 0, 0, "a-b"), Err(Refusal::BadName(_))));
assert!(matches!(edits(src, 0, 0, ""), Err(Refusal::BadName(_))));
}
#[test]
fn refuses_pub_items_because_importers_are_invisible() {
let src = "pub def make()\n x: 1\nend\n";
assert_eq!(
edits(src, 0, 8, "build"),
Err(Refusal::IsPublic("make".into()))
);
assert!(prepare(src, 0, 8).is_err(), "refused before prompting");
}
#[test]
fn refuses_non_bindings() {
let src = "xs = [1]\nys: xs.map (v, i) -> v end\ntype E\n host: String\nend\n";
assert_eq!(edits(src, 1, 8, "z"), Err(Refusal::NotABinding)); assert_eq!(edits(src, 3, 3, "hostname"), Err(Refusal::NotABinding)); }
#[test]
fn prepare_returns_the_declaration_range_and_renaming_to_itself_is_a_noop() {
let src = "count = 1\ny: count\n";
let r = prepare(src, 1, 4).unwrap();
assert_eq!((r.start.line, r.start.character), (0, 0));
assert_eq!(r.end.character, 5);
assert!(edits(src, 0, 0, "count").unwrap().is_empty());
}
}
#[cfg(test)]
mod invariance {
use std::path::{Path, PathBuf};
use aura_lang::facade::{eval_file, EvalOptions};
use super::tests_support::apply_at_offset;
fn examples_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../examples/showcase")
.canonicalize()
.expect("examples/showcase exists")
}
fn opts(dir: &Path) -> EvalOptions {
EvalOptions {
allow_read: vec![dir.to_path_buf()],
allow_env: aura_lang::eval::EnvCap::Allow(vec!["APP_ENV".to_string()]),
..Default::default()
}
}
#[test]
fn renaming_any_binding_leaves_the_evaluated_json_identical() {
let dir = examples_dir();
let original = std::fs::read_to_string(dir.join("showcase.aura")).expect("read showcase");
let scratch = std::env::temp_dir().join("aura-rename-invariance");
let _ = std::fs::remove_dir_all(&scratch);
std::fs::create_dir_all(&scratch).expect("scratch dir");
for f in ["lib.aura", "data.json"] {
std::fs::copy(dir.join(f), scratch.join(f)).unwrap_or_else(|e| panic!("copy {f}: {e}"));
}
std::env::set_var("APP_ENV", "production");
std::env::set_current_dir(&scratch).expect("chdir to scratch");
let path = scratch.join("showcase.aura");
std::fs::write(&path, &original).expect("write baseline");
let baseline = eval_file(&path, &opts(&scratch))
.expect("showcase evaluates")
.json;
let r = crate::goto::resolution(&original).expect("showcase parses");
let mut renamed = 0usize;
for (i, b) in r.bindings.iter().enumerate() {
let new_name = format!("renamed_{i}");
let Ok(text) = apply_at_offset(&original, b.decl.start, &new_name) else {
continue; };
std::fs::write(&path, &text).expect("write copy");
let after = eval_file(&path, &opts(&scratch)).unwrap_or_else(|e| {
panic!(
"renaming '{}' -> '{new_name}' broke evaluation: {e:?}",
b.name
)
});
assert_eq!(
after.json, baseline,
"renaming '{}' -> '{new_name}' changed the output",
b.name
);
renamed += 1;
}
assert!(
renamed >= 15,
"expected the showcase to exercise many bindings, got {renamed}"
);
}
}
#[cfg(test)]
mod tests_support {
use super::{edits, Refusal};
use crate::diagnostics::LineIndex;
pub fn apply_at_offset(text: &str, offset: u32, new_name: &str) -> Result<String, Refusal> {
let index = LineIndex::new(text);
let pos = index.position(text, offset as usize);
let ranges = edits(text, pos.line, pos.character, new_name)?;
let mut spans: Vec<(usize, usize)> = ranges
.iter()
.map(|r| {
(
index.offset(text, r.start.line, r.start.character),
index.offset(text, r.end.line, r.end.character),
)
})
.collect();
spans.sort_unstable();
let mut out = text.to_string();
for (s, e) in spans.into_iter().rev() {
out.replace_range(s..e, new_name);
}
Ok(out)
}
}