use std::path::{Path, PathBuf};
use crate::lsp::SymbolInfo;
pub fn editing_start_line(sym: &crate::lsp::SymbolInfo, lines: &[&str]) -> usize {
if let Some(r) = sym.range_start_line {
let r = r as usize;
if r < lines.len() {
let t = lines[r].trim_start();
if t.starts_with('*') && !t.starts_with("/**") && !t.starts_with("/*") {
let walked = find_insert_before_line(lines, r);
if walked < lines.len() {
let landed = lines[walked].trim_start();
if landed.starts_with("/**") || landed.starts_with("/*") {
return walked;
}
}
return r;
}
let line_is_decorator = t.starts_with("///")
|| t.starts_with("//!")
|| t.starts_with("#[")
|| t.starts_with("/**")
|| t.starts_with("/*")
|| t.starts_with('@')
|| t.starts_with("*/");
if !line_is_decorator && r > 0 {
let mut doc_check = r;
while doc_check > 0 && lines[doc_check - 1].trim_start().starts_with("#[") {
doc_check -= 1;
}
let above = if doc_check > 0 {
lines[doc_check - 1].trim_start()
} else {
""
};
let above_is_doc_or_decorator = above.starts_with("//") || above.starts_with("*/")
|| above.starts_with("/**")
|| above.starts_with('@');
if above_is_doc_or_decorator {
return find_insert_before_line(lines, r);
}
}
}
return r;
}
find_insert_before_line(lines, sym.start_line as usize)
}
pub fn editing_end_line(sym: &crate::lsp::SymbolInfo) -> u32 {
if let Some(ast_end) = ast_confirmed_end_line(sym) {
const DISAGREE_THRESHOLD: u32 = 64;
if ast_end.abs_diff(sym.end_line) > DISAGREE_THRESHOLD {
tracing::warn!(
target: "codescout::editing_end_line",
"AST/LSP end-line disagreement > {} lines for '{}' in {:?}: ast={}, lsp={} (trusting AST)",
DISAGREE_THRESHOLD, sym.name, sym.file, ast_end + 1, sym.end_line + 1,
);
}
return ast_end;
}
sym.end_line
}
pub fn editing_end_line_strict(sym: &crate::lsp::SymbolInfo) -> Option<u32> {
ast_confirmed_end_line(sym)
}
fn ast_confirmed_end_line(sym: &crate::lsp::SymbolInfo) -> Option<u32> {
let source = match std::fs::read_to_string(&sym.file) {
Ok(s) => s,
Err(err) => {
tracing::trace!(
target: "codescout::editing_end_line",
"cannot read {:?} ({}); no AST end-line available",
sym.file, err,
);
return None;
}
};
let lang = crate::ast::detect_language(&sym.file);
let ast_syms = match crate::ast::parser::extract_symbols_from_source(&source, lang, &sym.file) {
Ok(syms) => syms,
Err(err) => {
tracing::trace!(
target: "codescout::editing_end_line",
"AST unavailable for {:?} ({}); no AST end-line available",
sym.file, err,
);
return None;
}
};
crate::symbol::query::find_ast_end_line_in(
&ast_syms,
&sym.name,
sym.start_line,
Some(&sym.name_path),
)
}
pub fn clamp_range_to_parent(
start: usize,
end: usize,
parent_body_start: usize,
parent_body_end_exclusive: usize,
) -> (usize, usize) {
let clamped_start = start.max(parent_body_start);
let clamped_end = end.min(parent_body_end_exclusive);
let clamped_end = clamped_end.max(clamped_start);
(clamped_start, clamped_end)
}
pub fn collect_all_name_paths(
syms: &[crate::lsp::SymbolInfo],
) -> std::collections::HashSet<String> {
fn walk(syms: &[crate::lsp::SymbolInfo], out: &mut std::collections::HashSet<String>) {
for s in syms {
out.insert(s.name_path.clone());
walk(&s.children, out);
}
}
let mut out = std::collections::HashSet::new();
walk(syms, &mut out);
out
}
pub fn find_ast_name_path(
ast_syms: &[crate::lsp::SymbolInfo],
lsp_name: &str,
lsp_start: u32,
) -> Option<String> {
for s in ast_syms {
if crate::symbol::query::names_match_ignoring_backticks(&s.name, lsp_name)
&& s.start_line.abs_diff(lsp_start) <= 1
{
return Some(s.name_path.clone());
}
if let Some(found) = find_ast_name_path(&s.children, lsp_name, lsp_start) {
return Some(found);
}
}
None
}
pub fn find_insert_before_line(lines: &[&str], symbol_start: usize) -> usize {
let mut cursor = symbol_start;
let mut pending_open_brackets: usize = 0;
while cursor > 0 {
let trimmed = lines[cursor - 1].trim();
if pending_open_brackets > 0 {
for ch in trimmed.chars() {
match ch {
'(' | '[' => {
pending_open_brackets = pending_open_brackets.saturating_sub(1);
}
')' | ']' => pending_open_brackets += 1,
_ => {}
}
}
cursor -= 1;
continue;
}
let is_attr_or_doc = trimmed.starts_with("#[")
|| trimmed.starts_with('@')
|| trimmed.starts_with("//") || trimmed.starts_with("/**")
|| trimmed.starts_with("* ")
|| trimmed == "*" || trimmed == "*/"
|| trimmed.starts_with("/*");
let is_bracket_closer =
!trimmed.is_empty() && trimmed.chars().all(|c| matches!(c, ')' | ']'));
if is_attr_or_doc || is_bracket_closer {
let mut depth: isize = 0;
for ch in trimmed.chars() {
match ch {
'(' | '[' => depth += 1,
')' | ']' => depth -= 1,
_ => {}
}
}
if depth < 0 {
pending_open_brackets = (-depth) as usize;
}
cursor -= 1;
} else {
break;
}
}
cursor
}
#[derive(Debug)]
pub struct TextualMatch {
pub file: String,
pub lines: Vec<u32>,
pub previews: Vec<String>,
pub occurrence_count: usize,
pub kind: &'static str,
}
fn classify_file(path: &Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
"md" | "txt" | "rst" | "adoc" => "documentation",
"toml" | "yaml" | "yml" | "json" => "config",
_ => "source",
}
}
fn classify_sort_key(kind: &str) -> u8 {
match kind {
"documentation" => 0,
"config" => 1,
_ => 2,
}
}
pub fn text_sweep(
project_root: &Path,
old_name: &str,
lsp_modified_files: &std::collections::HashSet<PathBuf>,
max_matches: usize,
max_previews_per_file: usize,
) -> anyhow::Result<Vec<TextualMatch>> {
const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024;
let escaped = regex::escape(old_name);
let pattern = format!(r"\b{escaped}\b");
let re = regex::RegexBuilder::new(&pattern)
.size_limit(1 << 20)
.dfa_size_limit(1 << 20)
.build()?;
let mut file_matches: Vec<TextualMatch> = Vec::new();
let walker = ignore::WalkBuilder::new(project_root)
.hidden(true)
.git_ignore(true)
.build();
for entry in walker.flatten() {
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let path = entry.path();
if lsp_modified_files.contains(path) {
continue;
}
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > MAX_FILE_BYTES {
tracing::trace!(
target: "codescout::text_sweep",
"skipping {} ({} bytes > {} cap)",
path.display(), meta.len(), MAX_FILE_BYTES,
);
continue;
}
}
let Ok(content) = std::fs::read_to_string(path) else {
continue; };
let mut lines = Vec::new();
let mut previews = Vec::new();
for (i, line) in content.lines().enumerate() {
if re.is_match(line) {
lines.push((i + 1) as u32);
if previews.len() < max_previews_per_file {
previews.push(line.trim().to_string());
}
}
}
if !lines.is_empty() {
let rel_path = path
.strip_prefix(project_root)
.unwrap_or(path)
.display()
.to_string();
let kind = classify_file(path);
let occurrence_count = lines.len();
file_matches.push(TextualMatch {
file: rel_path,
lines,
previews,
occurrence_count,
kind,
});
}
}
file_matches.sort_by_key(|m| classify_sort_key(m.kind));
file_matches.truncate(max_matches);
Ok(file_matches)
}
pub fn write_lines(
path: &std::path::Path,
lines: &[&str],
had_trailing_newline: bool,
) -> std::io::Result<()> {
let mut out = lines.join("\n");
if had_trailing_newline && !out.is_empty() {
out.push('\n');
}
crate::util::fs::atomic_write(path, &out)
}
pub fn find_parent_symbol<'a>(
symbols: &'a [SymbolInfo],
child_name_path: &str,
) -> Option<&'a SymbolInfo> {
if !child_name_path.contains('/') {
return None;
}
for sym in symbols {
for child in &sym.children {
if child.name_path == child_name_path {
return Some(sym);
}
}
if let Some(parent) = find_parent_symbol(&sym.children, child_name_path) {
return Some(parent);
}
}
None
}
fn utf16_to_byte_offset(s: &str, utf16_offset: usize) -> usize {
let mut byte_pos = 0;
let mut utf16_pos = 0usize;
for ch in s.chars() {
if utf16_pos >= utf16_offset {
break;
}
byte_pos += ch.len_utf8();
utf16_pos += ch.len_utf16();
}
byte_pos.min(s.len())
}
pub fn apply_text_edits(content: &str, edits: &[lsp_types::TextEdit]) -> String {
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
if content.ends_with('\n') {
lines.push(String::new());
}
let mut sorted: Vec<&lsp_types::TextEdit> = edits.iter().collect();
sorted.sort_by(|a, b| {
b.range
.start
.line
.cmp(&a.range.start.line)
.then(b.range.start.character.cmp(&a.range.start.character))
});
for pair in sorted.windows(2) {
let later = &pair[0].range; let earlier = &pair[1].range; let overlaps = earlier.end.line > later.start.line
|| (earlier.end.line == later.start.line
&& earlier.end.character > later.start.character);
if overlaps {
tracing::warn!(
target: "codescout::apply_text_edits",
"overlapping LSP edits: [{}:{}..{}:{}] and [{}:{}..{}:{}]",
earlier.start.line, earlier.start.character,
earlier.end.line, earlier.end.character,
later.start.line, later.start.character,
later.end.line, later.end.character,
);
}
}
let mut skipped_oob: usize = 0;
for edit in sorted {
let start_line = edit.range.start.line as usize;
let start_char = edit.range.start.character as usize;
let end_line = edit.range.end.line as usize;
let end_char = edit.range.end.character as usize;
if start_line >= lines.len() {
skipped_oob += 1;
tracing::warn!(
target: "codescout::apply_text_edits",
"skipping out-of-bounds LSP edit: range [{}:{}..{}:{}] but file has {} lines",
start_line, start_char, end_line, end_char, lines.len(),
);
continue;
}
let start_byte = utf16_to_byte_offset(&lines[start_line], start_char);
let prefix = &lines[start_line][..start_byte];
let suffix = if end_line < lines.len() {
let end_byte = utf16_to_byte_offset(&lines[end_line], end_char);
&lines[end_line][end_byte..]
} else {
""
};
let replacement = format!("{}{}{}", prefix, edit.new_text, suffix);
let replacement_lines: Vec<String> = replacement.lines().map(|s| s.to_string()).collect();
let remove_end = (end_line + 1).min(lines.len());
lines.splice(start_line..remove_end, replacement_lines);
}
if skipped_oob > 0 {
tracing::warn!(
target: "codescout::apply_text_edits",
"skipped {} out-of-bounds edit(s) out of {} total",
skipped_oob,
edits.len(),
);
}
lines.join("\n")
}