fix(ast): auto-wrap multi-node patterns instead of erroring

A pattern like `"@types/bun": $V` parses to multiple root AST nodes and was
rejected with "Multiple AST nodes are detected". When no explicit selector is
given, retry the fragment wrapped in a minimal single-node context (JSON: `{ <frag> }`
selecting `pair`), quoting bare metavars so JSON parses. Falls back to the
original error if the wrapped form still fails, so bad patterns are unchanged.
Applies to both ast_edit (compile_pattern) and ast_grep (compile_search_patterns).
This commit is contained in:
can1357
2026-07-12 13:40:43 +02:00
parent dabe233c62
commit bb35e79189
+114 -6
View File
@@ -100,21 +100,129 @@ pub fn compile_pattern(
strictness: &MatchStrictness,
lang: SupportLang,
) -> Result<Pattern> {
let mut compiled = if let Some(selector) = selector.map(str::trim).filter(|s| !s.is_empty()) {
Pattern::contextual(pattern, selector, lang)
let selector = selector.map(str::trim).filter(|s| !s.is_empty());
let mut compiled = if let Some(selector) = selector {
Pattern::contextual(pattern, selector, lang).map_err(|err| anyhow!("Invalid pattern: {err}"))?
} else {
Pattern::try_new(pattern, lang)
}
.map_err(|err| anyhow!("Invalid pattern: {err}"))?;
match Pattern::try_new(pattern, lang) {
Ok(compiled) => compiled,
// A fragment like `"key": $V` parses to multiple root nodes and is
// rejected as `MultipleNode`; auto-wrap it in a single-node context
// before giving up. Any other error, or a failed fallback, keeps the
// original message so genuinely-bad patterns behave as before.
Err(err @ PatternError::MultipleNode(_)) => {
match compile_wrapped_fallback(pattern, strictness, lang) {
Some(compiled) => return Ok(compiled),
None => return Err(anyhow!("Invalid pattern: {err}")),
}
},
Err(err) => return Err(anyhow!("Invalid pattern: {err}")),
}
};
compiled.strictness = strictness.clone();
Ok(compiled)
}
/// Language-specific wrapper template used to turn a multi-node fragment into a
/// single selectable node. `None` for languages without a template — those keep
/// the original `MultipleNode` error.
fn wrapper_template(lang: SupportLang) -> Option<(&'static str, &'static str, &'static str)> {
// (prefix, suffix, selector-kind); the fragment is spliced between prefix/suffix.
match lang {
SupportLang::Json => Some(("{", "}", "pair")),
_ => None,
}
}
/// Retry a fragment that failed as `MultipleNode` by wrapping it in a minimal
/// valid context and selecting the node kind that spans it. Returns the compiled
/// pattern (with `strictness` applied) or `None` if this language has no template
/// or the wrapped form still fails to compile.
fn compile_wrapped_fallback(
pattern: &str,
strictness: &MatchStrictness,
lang: SupportLang,
) -> Option<Pattern> {
let (prefix, suffix, selector) = wrapper_template(lang)?;
// JSON only accepts a bare `$V` inside a string, so quote value-position
// metavars; ast-grep still reads the quoted `"$V"` as capture `V`.
let prepared = if lang == SupportLang::Json {
quote_bare_metavars(pattern)
} else {
pattern.to_string()
};
let context = format!("{prefix} {prepared} {suffix}");
let mut compiled = Pattern::contextual(&context, selector, lang).ok()?;
compiled.strictness = strictness.clone();
Some(compiled)
}
/// Wrap bare `$NAME` / `$$$NAME` metavars in double quotes so a JSON wrapper
/// parses. Metavars already inside a string literal (including `"$V"`) are left
/// untouched; a quote toggles in/out of string context.
fn quote_bare_metavars(pattern: &str) -> String {
let bytes = pattern.as_bytes();
let mut out = String::with_capacity(pattern.len() + 4);
let mut in_string = false;
let mut index = 0;
while index < bytes.len() {
let byte = bytes[index];
if byte == b'"' && (index == 0 || bytes[index - 1] != b'\\') {
in_string = !in_string;
out.push('"');
index += 1;
continue;
}
if byte == b'$' && !in_string {
// Consume `$`, an optional `$$` ellipsis, then the identifier.
let start = index;
index += 1;
if bytes[index..].starts_with(b"$$") {
index += 2;
}
while index < bytes.len()
&& (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_')
{
index += 1;
}
out.push('"');
out.push_str(&pattern[start..index]);
out.push('"');
continue;
}
// Copy this byte's full UTF-8 char so multi-byte content is preserved.
let char_end = next_char_boundary(bytes, index);
out.push_str(&pattern[index..char_end]);
index = char_end;
}
out
}
/// Byte index of the end of the UTF-8 character starting at `index`.
fn next_char_boundary(bytes: &[u8], index: usize) -> usize {
let mut end = index + 1;
while end < bytes.len() && (bytes[end] & 0b1100_0000) == 0b1000_0000 {
end += 1;
}
end
}
pub fn compile_search_patterns(
pattern: &str,
language: SupportLang,
) -> Result<Vec<Pattern>, PatternError> {
let mut compiled = vec![Pattern::try_new(pattern, language)?];
let mut compiled = match Pattern::try_new(pattern, language) {
Ok(compiled) => vec![compiled],
// Multi-node fragments (e.g. `"key": $V`) get the same auto-wrap fallback
// as the edit path; other errors propagate unchanged.
Err(err @ PatternError::MultipleNode(_)) => {
match compile_wrapped_fallback(pattern, &MatchStrictness::Smart, language) {
Some(compiled) => vec![compiled],
None => return Err(err),
}
},
Err(err) => return Err(err),
};
if language == SupportLang::Rust {
let trimmed = pattern.trim_end();
if let Some(contextual) = compile_rust_contextual_pattern(trimmed) {