diff --git a/crates/pi-ast/src/ops.rs b/crates/pi-ast/src/ops.rs index 8592bb36f..250d10406 100644 --- a/crates/pi-ast/src/ops.rs +++ b/crates/pi-ast/src/ops.rs @@ -100,21 +100,129 @@ pub fn compile_pattern( strictness: &MatchStrictness, lang: SupportLang, ) -> Result { - 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 { + 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, 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) {