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:
+114
-6
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user