use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::Deserialize;
use tokio::process::Command;
use super::matcher::{PatchContextMatcher, normalise_text, seek_segment};
use super::{PatchChunk, PatchError};
use crate::tools::ast_grep_binary::{
AST_GREP_INSTALL_COMMAND, AST_GREP_OVERRIDE, AstGrepBinaryOverride, missing_ast_grep_message,
resolve_ast_grep_binary_from_env_and_fs,
};
use crate::tools::ast_grep_language::AstGrepLanguage;
static IDENTIFIER_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"[A-Za-z_][A-Za-z0-9_]*").expect("semantic identifier regex must compile"));
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SemanticMatch {
pub(crate) start_idx: usize,
pub(crate) old_segment: Vec<String>,
pub(crate) new_segment: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct AstGrepJsonMatch {
text: String,
range: AstGrepRange,
}
#[derive(Debug, Deserialize)]
struct AstGrepRange {
start: AstGrepPoint,
end: AstGrepPoint,
}
#[derive(Debug, Deserialize)]
struct AstGrepPoint {
line: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct StructuralCandidate {
start_line: usize,
end_line: usize,
}
impl StructuralCandidate {
fn from_match(entry: AstGrepJsonMatch, file_lines: &[String], primary_term: &str) -> Option<Self> {
if file_lines.is_empty() {
return None;
}
let normalized_text = normalise_text(&entry.text).to_lowercase();
if !normalized_text.contains(primary_term) {
return None;
}
let start_line = entry.range.start.line.min(file_lines.len().saturating_sub(1));
let end_line = entry.range.end.line.min(file_lines.len().saturating_sub(1));
if start_line > end_line {
return None;
}
Some(Self { start_line, end_line })
}
fn end_exclusive(self, file_len: usize) -> usize {
self.end_line.saturating_add(1).min(file_len)
}
}
pub(crate) async fn resolve_semantic_match(
source_path: &Path,
display_path: &str,
original_lines: &[String],
chunk: &PatchChunk,
old_segment: Vec<String>,
new_segment: Vec<String>,
) -> Result<SemanticMatch, PatchError> {
let anchor = chunk
.change_context()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| PatchError::SemanticResolutionFailed {
path: display_path.to_string(),
anchor: String::new(),
reason: "missing semantic @@ anchor".to_string(),
})?;
let language = AstGrepLanguage::from_path(source_path).ok_or_else(|| PatchError::SemanticResolutionFailed {
path: display_path.to_string(),
anchor: anchor.to_string(),
reason: "unsupported language for semantic fallback".to_string(),
})?;
let ast_grep = resolve_ast_grep_binary(display_path, anchor)?;
let primary_term = semantic_anchor_term(anchor).ok_or_else(|| PatchError::SemanticResolutionFailed {
path: display_path.to_string(),
anchor: anchor.to_string(),
reason: "anchor does not contain a usable symbol name".to_string(),
})?;
let candidates =
collect_candidates(&ast_grep, language, source_path, original_lines, &primary_term, display_path, anchor)
.await?;
if candidates.is_empty() {
return Err(PatchError::SemanticResolutionFailed {
path: display_path.to_string(),
anchor: anchor.to_string(),
reason: "no structural candidates matched the semantic anchor".to_string(),
});
}
let mut resolved = BTreeMap::new();
for candidate in candidates {
let end_exclusive = candidate.end_exclusive(original_lines.len());
if candidate.start_line >= end_exclusive {
continue;
}
let mut candidate_old = old_segment.clone();
let mut candidate_new = new_segment.clone();
let candidate_lines = &original_lines[candidate.start_line..end_exclusive];
let candidate_matcher = PatchContextMatcher::new(candidate_lines);
if let Some(local_start) =
seek_segment(&candidate_matcher, &mut candidate_old, &mut candidate_new, 0, chunk.is_end_of_file())
{
resolved.entry(candidate.start_line + local_start).or_insert(SemanticMatch {
start_idx: candidate.start_line + local_start,
old_segment: candidate_old,
new_segment: candidate_new,
});
}
}
match resolved.len() {
0 => Err(PatchError::SemanticResolutionFailed {
path: display_path.to_string(),
anchor: anchor.to_string(),
reason:
"anchor resolved to structural candidates, but removal/context lines were not found safely inside them"
.to_string(),
}),
1 => resolved
.into_values()
.next()
.ok_or_else(|| PatchError::SemanticResolutionFailed {
path: display_path.to_string(),
anchor: anchor.to_string(),
reason: "internal error: single semantic match vanished during resolution".to_string(),
}),
candidate_count => Err(PatchError::SemanticAmbiguous {
path: display_path.to_string(),
anchor: anchor.to_string(),
candidate_count,
}),
}
}
fn resolve_ast_grep_binary(display_path: &str, anchor: &str) -> Result<PathBuf, PatchError> {
resolve_ast_grep_binary_path().map_err(|reason| PatchError::SemanticResolutionFailed {
path: display_path.to_string(),
anchor: anchor.to_string(),
reason,
})
}
pub(crate) fn resolve_ast_grep_binary_path() -> Result<PathBuf, String> {
match AST_GREP_OVERRIDE
.lock()
.map_err(|error| format!("ast-grep override mutex must not be poisoned: {error}"))?
.clone()
{
AstGrepBinaryOverride::System => {}
AstGrepBinaryOverride::Missing => {
return Err(missing_ast_grep_message("Use exact context lines if you cannot install it."));
}
AstGrepBinaryOverride::Path(path) => return Ok(path),
}
if let Some(path) = resolve_ast_grep_binary_from_env_and_fs() {
return Ok(path);
}
Err(missing_ast_grep_message(&format!(
"Use exact context lines if you cannot install it with `{AST_GREP_INSTALL_COMMAND}`."
)))
}
async fn collect_candidates(
ast_grep: &Path,
language: AstGrepLanguage,
source_path: &Path,
original_lines: &[String],
primary_term: &str,
display_path: &str,
anchor: &str,
) -> Result<Vec<StructuralCandidate>, PatchError> {
let output = Command::new(ast_grep)
.arg("run")
.arg("--pattern=$A")
.arg("--lang")
.arg(language.as_str())
.arg("--json=stream")
.arg(source_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|err| PatchError::SemanticResolutionFailed {
path: display_path.to_string(),
anchor: anchor.to_string(),
reason: format!("failed to run ast-grep: {err}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = stderr.trim();
return Err(PatchError::SemanticResolutionFailed {
path: display_path.to_string(),
anchor: anchor.to_string(),
reason: if detail.is_empty() {
"ast-grep failed to analyze the file".to_string()
} else {
format!("ast-grep failed to analyze the file: {detail}")
},
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut candidates = Vec::new();
for line in stdout.lines().filter(|line| !line.trim().is_empty()) {
let parsed =
serde_json::from_str::<AstGrepJsonMatch>(line).map_err(|err| PatchError::SemanticResolutionFailed {
path: display_path.to_string(),
anchor: anchor.to_string(),
reason: format!("failed to parse ast-grep output: {err}"),
})?;
if let Some(candidate) = StructuralCandidate::from_match(parsed, original_lines, primary_term) {
candidates.push(candidate);
}
}
candidates.sort_unstable();
candidates.dedup();
Ok(candidates)
}
pub(crate) fn semantic_anchor_term(anchor: &str) -> Option<String> {
const STOPWORDS: &[&str] = &[
"async",
"class",
"const",
"crate",
"def",
"enum",
"export",
"fn",
"for",
"function",
"impl",
"interface",
"let",
"mod",
"module",
"private",
"protected",
"pub",
"public",
"self",
"super",
"static",
"struct",
"trait",
"type",
"void",
"where",
];
IDENTIFIER_RE
.find_iter(anchor)
.map(|m| m.as_str().to_ascii_lowercase())
.find(|term| !STOPWORDS.iter().any(|stopword| term == stopword))
}
#[cfg(test)]
mod tests {
use super::semantic_anchor_term;
#[test]
fn semantic_anchor_term_skips_rust_visibility_noise() {
assert_eq!(semantic_anchor_term("pub(crate) fn second() -> usize"), Some("second".to_string()));
}
#[test]
fn semantic_anchor_term_prefers_symbol_name_after_keywords() {
assert_eq!(semantic_anchor_term("impl ToolDefinition for ProviderTool"), Some("tooldefinition".to_string()));
}
}