use crate::links;
use crate::utils;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tower_lsp::lsp_types::{Position, Range, TextEdit, Url};
#[derive(Debug, Clone)]
pub struct FileRename {
pub old_path: PathBuf,
pub new_path: PathBuf,
}
#[derive(Debug, Default)]
pub struct RenameStats {
pub read_failures: usize,
pub no_parent: usize,
pub pathdiff_failures: usize,
pub no_op_skips: usize,
pub dedup_skips: usize,
pub duplicate_renames: usize,
}
pub struct RenameResult {
pub edits: HashMap<Url, Vec<TextEdit>>,
pub stats: RenameStats,
}
#[derive(Debug, Default)]
pub struct DeleteStats {
pub read_failures: usize,
pub no_parent: usize,
pub statement_range_failures: usize,
pub duplicate_deletes: usize,
pub dedup_skips: usize,
}
pub struct DeleteResult {
pub edits: HashMap<Url, Vec<TextEdit>>,
pub stats: DeleteStats,
}
pub fn expand_folder_renames(
params: &[(PathBuf, PathBuf)],
source_files: &[String],
) -> Vec<FileRename> {
let mut dedup: HashMap<PathBuf, PathBuf> = HashMap::new();
for (old_path, new_path) in params {
if old_path.is_dir() || old_path.extension().is_none_or(|e| e != "sol") {
for sf in source_files {
let sf_path = Path::new(sf);
if let Ok(suffix) = sf_path.strip_prefix(old_path) {
dedup.insert(sf_path.to_path_buf(), new_path.join(suffix));
}
}
} else {
dedup.insert(old_path.clone(), new_path.clone());
}
}
dedup
.into_iter()
.map(|(old_path, new_path)| FileRename { old_path, new_path })
.collect()
}
pub fn expand_folder_deletes(params: &[PathBuf], source_files: &[String]) -> Vec<PathBuf> {
let mut dedup: HashMap<PathBuf, ()> = HashMap::new();
for old_path in params {
if old_path.is_dir() || old_path.extension().is_none_or(|e| e != "sol") {
for sf in source_files {
let sf_path = Path::new(sf);
if sf_path.strip_prefix(old_path).is_ok() {
dedup.insert(sf_path.to_path_buf(), ());
}
}
} else {
dedup.insert(old_path.clone(), ());
}
}
dedup.into_keys().collect()
}
pub fn expand_folder_renames_from_paths(
params: &[(Url, Url)],
candidate_paths: &[PathBuf],
) -> Vec<(String, String)> {
let mut dedup: HashMap<String, String> = HashMap::new();
for (old_uri, new_uri) in params {
let old_path = match old_uri.to_file_path() {
Ok(p) => p,
Err(_) => continue,
};
let new_path = match new_uri.to_file_path() {
Ok(p) => p,
Err(_) => continue,
};
if old_path.extension().is_some_and(|e| e == "sol") && !old_path.is_dir() {
dedup.insert(old_uri.to_string(), new_uri.to_string());
} else {
for existing_path in candidate_paths {
if let Ok(suffix) = existing_path.strip_prefix(&old_path) {
let new_file_path = new_path.join(suffix);
let Ok(existing_uri) = Url::from_file_path(existing_path) else {
continue;
};
let Ok(new_file_uri) = Url::from_file_path(&new_file_path) else {
continue;
};
dedup.insert(existing_uri.to_string(), new_file_uri.to_string());
}
}
}
}
dedup.into_iter().collect()
}
pub fn expand_folder_deletes_from_paths(
params: &[Url],
candidate_paths: &[PathBuf],
) -> Vec<PathBuf> {
let mut dedup: HashMap<PathBuf, ()> = HashMap::new();
for uri in params {
let old_path = match uri.to_file_path() {
Ok(p) => p,
Err(_) => continue,
};
if old_path.extension().is_some_and(|e| e == "sol") && !old_path.is_dir() {
dedup.insert(old_path, ());
} else {
for existing_path in candidate_paths {
if existing_path.strip_prefix(&old_path).is_ok() {
dedup.insert(existing_path.clone(), ());
}
}
}
}
dedup.into_keys().collect()
}
pub fn rename_imports(
source_files: &[String],
renames: &[FileRename],
project_root: &Path,
get_source_bytes: &dyn Fn(&str) -> Option<Vec<u8>>,
) -> RenameResult {
let mut edits: HashMap<Url, Vec<TextEdit>> = HashMap::new();
let mut stats = RenameStats::default();
if renames.is_empty() {
return RenameResult { edits, stats };
}
let mut rename_map: HashMap<PathBuf, PathBuf> = HashMap::with_capacity(renames.len());
for r in renames {
if rename_map
.insert(r.old_path.clone(), r.new_path.clone())
.is_some()
{
stats.duplicate_renames += 1;
}
}
for source_fs_str in source_files {
let source_path = resolve_source_path(source_fs_str, project_root);
let source_new_path = rename_map.get(&source_path);
let effective_source_dir = match source_new_path {
Some(new_p) => match new_p.parent() {
Some(d) => d.to_path_buf(),
None => {
stats.no_parent += 1;
continue;
}
},
None => match source_path.parent() {
Some(d) => d.to_path_buf(),
None => {
stats.no_parent += 1;
continue;
}
},
};
let current_source_dir = match source_path.parent() {
Some(d) => d,
None => {
stats.no_parent += 1;
continue;
}
};
let bytes = match source_path
.to_str()
.and_then(get_source_bytes)
.or_else(|| get_source_bytes(source_fs_str))
{
Some(b) => b,
None => {
stats.read_failures += 1;
continue;
}
};
let imports = links::ts_find_imports(&bytes);
for imp in &imports {
let resolved = normalize_path(¤t_source_dir.join(&imp.path));
let resolved_target = if rename_map.contains_key(&resolved) {
Some(resolved)
} else if !imp.path.starts_with('.') {
let via_root = normalize_path(&project_root.join(&imp.path));
if rename_map.contains_key(&via_root) {
Some(via_root)
} else {
None
}
} else {
None
};
let old_target = match resolved_target {
Some(t) => t,
None => continue,
};
let new_target = &rename_map[&old_target];
let new_import_path = if imp.path.starts_with('.') {
match pathdiff::diff_paths(new_target, &effective_source_dir) {
Some(p) => ensure_dot_prefix(&p),
None => {
stats.pathdiff_failures += 1;
continue;
}
}
} else {
match pathdiff::diff_paths(new_target, project_root) {
Some(p) => normalize_slashes(&p.to_string_lossy()),
None => {
stats.pathdiff_failures += 1;
continue;
}
}
};
if new_import_path == imp.path {
stats.no_op_skips += 1;
continue;
}
let source_uri = match Url::from_file_path(&source_path) {
Ok(u) => u,
Err(_) => continue,
};
edits.entry(source_uri).or_default().push(TextEdit {
range: range_with_quotes(imp.inner_range),
new_text: format!("\"{}\"", new_import_path),
});
}
}
for rename in renames {
let old_dir = match rename.old_path.parent() {
Some(d) => d,
None => {
stats.no_parent += 1;
continue;
}
};
let new_dir = match rename.new_path.parent() {
Some(d) => d,
None => {
stats.no_parent += 1;
continue;
}
};
if old_dir == new_dir {
continue;
}
let old_fs_str = match rename.old_path.to_str() {
Some(s) => s,
None => continue,
};
let bytes = match get_source_bytes(old_fs_str) {
Some(b) => b,
None => {
stats.read_failures += 1;
continue;
}
};
let imports = links::ts_find_imports(&bytes);
let old_uri = match Url::from_file_path(&rename.old_path) {
Ok(u) => u,
Err(_) => continue,
};
for imp in &imports {
if !imp.path.starts_with('.') {
continue;
}
let target_fs = normalize_path(&old_dir.join(&imp.path));
let effective_target = rename_map.get(&target_fs).unwrap_or(&target_fs);
let new_rel = match pathdiff::diff_paths(effective_target, new_dir) {
Some(p) => p,
None => {
stats.pathdiff_failures += 1;
continue;
}
};
let new_import_str = ensure_dot_prefix(&new_rel);
if new_import_str == imp.path {
stats.no_op_skips += 1;
continue;
}
let already_edited = edits.get(&old_uri).is_some_and(|file_edits| {
let qr = range_with_quotes(imp.inner_range);
file_edits.iter().any(|e| e.range == qr)
});
if already_edited {
stats.dedup_skips += 1;
continue;
}
edits.entry(old_uri.clone()).or_default().push(TextEdit {
range: range_with_quotes(imp.inner_range),
new_text: format!("\"{}\"", new_import_str),
});
}
}
RenameResult { edits, stats }
}
pub fn delete_imports(
source_files: &[String],
deletes: &[PathBuf],
project_root: &Path,
get_source_bytes: &dyn Fn(&str) -> Option<Vec<u8>>,
) -> DeleteResult {
let mut edits: HashMap<Url, Vec<TextEdit>> = HashMap::new();
let mut stats = DeleteStats::default();
if deletes.is_empty() {
return DeleteResult { edits, stats };
}
let mut delete_set: HashMap<PathBuf, ()> = HashMap::with_capacity(deletes.len());
for p in deletes {
if delete_set.insert(normalize_path(p), ()).is_some() {
stats.duplicate_deletes += 1;
}
}
for source_fs_str in source_files {
let source_path = resolve_source_path(source_fs_str, project_root);
let source_dir = match source_path.parent() {
Some(d) => d,
None => {
stats.no_parent += 1;
continue;
}
};
let bytes = match source_path
.to_str()
.and_then(get_source_bytes)
.or_else(|| get_source_bytes(source_fs_str))
{
Some(b) => b,
None => {
stats.read_failures += 1;
continue;
}
};
let source_str = match std::str::from_utf8(&bytes) {
Ok(s) => s,
Err(_) => {
stats.read_failures += 1;
continue;
}
};
let imports = links::ts_find_imports(&bytes);
let source_uri = match Url::from_file_path(&source_path) {
Ok(u) => u,
Err(_) => continue,
};
for imp in &imports {
let resolved = normalize_path(&source_dir.join(&imp.path));
let is_deleted = if delete_set.contains_key(&resolved) {
true
} else if !imp.path.starts_with('.') {
let via_root = normalize_path(&project_root.join(&imp.path));
delete_set.contains_key(&via_root)
} else {
false
};
if !is_deleted {
continue;
}
let Some(statement_range) = import_statement_range(source_str, imp.inner_range) else {
stats.statement_range_failures += 1;
continue;
};
let duplicate = edits
.get(&source_uri)
.is_some_and(|file_edits| file_edits.iter().any(|e| e.range == statement_range));
if duplicate {
stats.dedup_skips += 1;
continue;
}
edits.entry(source_uri.clone()).or_default().push(TextEdit {
range: statement_range,
new_text: String::new(),
});
}
}
DeleteResult { edits, stats }
}
pub fn rename_imports_single(
source_files: &[String],
old_uri: &Url,
new_uri: &Url,
project_root: &Path,
get_source_bytes: &dyn Fn(&str) -> Option<Vec<u8>>,
) -> HashMap<Url, Vec<TextEdit>> {
let old_path = match old_uri.to_file_path() {
Ok(p) => p,
Err(_) => return HashMap::new(),
};
let new_path = match new_uri.to_file_path() {
Ok(p) => p,
Err(_) => return HashMap::new(),
};
rename_imports(
source_files,
&[FileRename { old_path, new_path }],
project_root,
get_source_bytes,
)
.edits
}
pub fn apply_edits_to_cache(
edits: &HashMap<Url, Vec<TextEdit>>,
cache: &mut HashMap<crate::types::DocumentUri, (i32, String)>,
) -> usize {
let mut patched = 0;
for (uri, text_edits) in edits {
let uri_str = uri.to_string();
if let Some((version, content)) = cache.get(uri_str.as_str()).cloned() {
let new_content = apply_text_edits(&content, text_edits);
cache.insert(uri_str.into(), (version, new_content));
patched += 1;
}
}
patched
}
fn range_with_quotes(inner: Range) -> Range {
Range {
start: Position {
line: inner.start.line,
character: inner.start.character.saturating_sub(1),
},
end: Position {
line: inner.end.line,
character: inner.end.character + 1,
},
}
}
fn ensure_dot_prefix(rel: &Path) -> String {
let s = normalize_slashes(&rel.to_string_lossy());
if s.starts_with("..") || s.starts_with('.') {
s
} else {
format!("./{s}")
}
}
fn resolve_source_path(source_fs_str: &str, project_root: &Path) -> PathBuf {
let raw = Path::new(source_fs_str);
if raw.is_absolute() {
return normalize_path(raw);
}
let joined_project = normalize_path(&project_root.join(raw));
if joined_project.exists() {
return joined_project;
}
if let Ok(cwd) = std::env::current_dir() {
let joined_cwd = normalize_path(&cwd.join(raw));
if joined_cwd.exists() {
return joined_cwd;
}
}
joined_project
}
fn normalize_slashes(s: &str) -> String {
s.replace('\\', "/")
}
fn import_statement_range(source: &str, inner: Range) -> Option<Range> {
let start = utils::position_to_byte_offset(source, inner.start);
let end = utils::position_to_byte_offset(source, inner.end);
if start > end || end > source.len() {
return None;
}
let bytes = source.as_bytes();
let mut import_start = None;
let mut i = start;
while i > 0 {
if i >= 6 && &bytes[i - 6..i] == b"import" {
import_start = Some(i - 6);
break;
}
if bytes[i - 1] == b';' {
break;
}
i -= 1;
}
let import_start = import_start?;
let mut semi = end;
while semi < bytes.len() && bytes[semi] != b';' {
semi += 1;
}
if semi >= bytes.len() || bytes[semi] != b';' {
return None;
}
let mut import_end = semi + 1;
if import_end + 1 < bytes.len() && bytes[import_end] == b'\r' && bytes[import_end + 1] == b'\n'
{
import_end += 2;
} else if import_end < bytes.len() && bytes[import_end] == b'\n' {
import_end += 1;
}
Some(Range {
start: utils::byte_offset_to_position(source, import_start),
end: utils::byte_offset_to_position(source, import_end),
})
}
pub fn apply_text_edits(source: &str, edits: &[TextEdit]) -> String {
let mut resolved: Vec<(usize, usize, &str)> = edits
.iter()
.filter_map(|e| {
let start = utils::position_to_byte_offset(source, e.range.start);
let end = utils::position_to_byte_offset(source, e.range.end);
if start > end {
None
} else {
Some((start, end, e.new_text.as_str()))
}
})
.collect();
resolved.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
let mut filtered: Vec<(usize, usize, &str)> = Vec::with_capacity(resolved.len());
for (start, end, new_text) in resolved {
if let Some((_, last_end, _)) = filtered.last()
&& start < *last_end
{
continue;
}
filtered.push((start, end, new_text));
}
let mut result = source.to_string();
for (start, end, new_text) in filtered.into_iter().rev() {
result.replace_range(start..end, new_text);
}
result
}
pub fn generate_scaffold(uri: &Url, solc_version: Option<&str>) -> Option<String> {
let path = uri.to_file_path().ok()?;
let stem = path.file_stem()?.to_str()?;
let ext = path.extension()?;
if ext != "sol" {
return None;
}
let base_name = sanitize_identifier(stem);
if base_name.is_empty() {
return None;
}
let pragma = match solc_version {
Some(v) if !v.is_empty() => {
let v = v.trim();
if v.starts_with('^')
|| v.starts_with('>')
|| v.starts_with('<')
|| v.starts_with('=')
|| v.starts_with('~')
{
v.to_string()
} else {
format!("^{v}")
}
}
_ => "^0.8.0".to_string(),
};
let is_test = stem.ends_with(".t");
let is_script = stem.ends_with(".s");
let kind = if is_test || is_script {
"contract"
} else if stem.starts_with('I')
&& stem.len() > 1
&& stem.chars().nth(1).is_some_and(|c| c.is_uppercase())
{
"interface"
} else if stem.starts_with("Lib") || stem.starts_with("lib") {
"library"
} else {
"contract"
};
let contract_name = if is_test {
format!("{base_name}Test")
} else if is_script {
format!("{base_name}Script")
} else {
base_name
};
if is_test {
Some(format!(
"// SPDX-License-Identifier: MIT\n\
pragma solidity {pragma};\n\
\n\
import {{Test}} from \"forge-std/Test.sol\";\n\
\n\
{kind} {contract_name} is Test {{\n\
\n\
}}\n"
))
} else if is_script {
Some(format!(
"// SPDX-License-Identifier: MIT\n\
pragma solidity {pragma};\n\
\n\
import {{Script}} from \"forge-std/Script.sol\";\n\
\n\
{kind} {contract_name} is Script {{\n\
\n\
}}\n"
))
} else {
Some(format!(
"// SPDX-License-Identifier: MIT\n\
pragma solidity {pragma};\n\
\n\
{kind} {contract_name} {{\n\
\n\
}}\n"
))
}
}
fn sanitize_identifier(stem: &str) -> String {
let stem = stem
.strip_suffix(".t")
.or_else(|| stem.strip_suffix(".s"))
.unwrap_or(stem);
let mut result = String::with_capacity(stem.len());
for ch in stem.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
result.push(ch);
}
}
if result.starts_with(|c: char| c.is_ascii_digit()) {
result.insert(0, '_');
}
if !result.is_empty() && !utils::is_valid_solidity_identifier(&result) {
result.insert(0, '_');
}
result
}
pub fn normalize_path(path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
if matches!(components.last(), Some(std::path::Component::Normal(_))) {
components.pop();
}
}
other => components.push(other),
}
}
components.iter().collect()
}