Skip to main content

harn_rules/
pattern.rs

1//! The pattern compiler: a code snippet with metavariable holes → a
2//! tree-sitter query.
3//!
4//! This is the atomic-tier `pattern` form. The idea (from ast-grep) is to
5//! let rule authors write a *snippet of real code* with `$VAR` holes
6//! instead of hand-authoring a tree-sitter S-expression query:
7//!
8//! ```text
9//!   $SRC?.$KEY ?? $DEFAULT
10//! ```
11//!
12//! compiles to
13//!
14//! ```text
15//!   ((binary_expression
16//!      left: (member_expression object: (_) @SRC (optional_chain) property: (_) @KEY)
17//!      "??"
18//!      right: (_) @DEFAULT) @__match)
19//! ```
20//!
21//! ## How it works
22//!
23//! 1. Each `$VAR` is replaced with a unique placeholder identifier so the
24//!    snippet parses as ordinary code in the target grammar.
25//! 2. We parse the substituted snippet — bare, then in a per-language
26//!    wrapper context (e.g. a function body) when the fragment is not a
27//!    valid compilation unit — and locate the snippet's own subtree by its
28//!    byte range in the parsed source.
29//! 3. We walk that subtree and mirror it into a query: every named child is
30//!    emitted with its field name, every anonymous token (operators,
31//!    keywords, punctuation) is emitted as a quoted literal so the structure
32//!    is matched precisely, and every placeholder becomes a `(_) @VAR`
33//!    wildcard capture.
34//! 4. Repeated metavariables unify: the second and later occurrences get
35//!    helper captures plus an `(#eq? …)` predicate so `$X … $X` only matches
36//!    when both holes carry identical text.
37//!
38//! ## Typed placeholders (`$VAR:kind`, #2839)
39//!
40//! A metavariable may carry a **syntactic-class constraint** so it matches
41//! only nodes of a given kind (rust-analyzer SSR `$x:expr`):
42//!
43//! ```text
44//!   $FN($ARG:identifier)   // matches `f(x)`, not `f(g())`
45//!   $X:expression          // matches any expression-position node
46//! ```
47//!
48//! `:kind` is either a small **semantic alias** (`expr`/`expression`,
49//! `stmt`/`statement`, `ty`/`type`, `ident`/`identifier`) resolved to the
50//! grammar's supertype, or an **exact tree-sitter node kind**. The constraint
51//! lowers a `(_) @VAR` wildcard to `(kind) @VAR`, so it narrows what binds.
52//! A constraint that names no kind in the target grammar is a compile error
53//! (the alias supertypes exist only in some grammars — e.g. `expression` in
54//! TypeScript/JS/Python but not Rust/Go, where exact kinds are used instead).
55//!
56//! Variadic `$$$` holes are not yet supported (tracked for the relational
57//! tier, #2833); they compile to a clear error.
58
59use std::collections::HashMap;
60
61use harn_hostlib::ast::{api, Language};
62use tree_sitter::Node;
63
64/// The capture name bound to the whole matched pattern, used for range
65/// extraction. Chosen to not collide with a user metavar (which are
66/// uppercase by convention and never start with `__`).
67pub const ROOT_CAPTURE: &str = "__match";
68
69/// Placeholder identifier stem substituted for each `$VAR`. Lowercase +
70/// `__` prefix keeps it a valid identifier across grammars and unlikely to
71/// collide with real snippet text.
72const PLACEHOLDER_STEM: &str = "__harn_hole_";
73
74/// A snippet pattern compiled to a tree-sitter query string.
75#[derive(Debug, Clone)]
76pub struct CompiledPattern {
77    /// The generated S-expression query. Always binds the pattern root to
78    /// `@__match` ([`ROOT_CAPTURE`]).
79    pub query: String,
80    /// Metavar names in first-appearance order (without the leading `$`).
81    pub metavars: Vec<String>,
82}
83
84/// Compile a `pattern` snippet for `language` into a tree-sitter query.
85///
86/// A snippet is often a *fragment* (`a + a`, `foo(bar)`) that is not a
87/// valid compilation unit on its own. We therefore try the snippet bare
88/// first (works for expression-statement languages like TS/JS/Python),
89/// then in a small set of per-language wrapper contexts (e.g. a function
90/// body for Rust/Go), and locate the snippet's own subtree by byte range.
91pub fn compile_pattern(snippet: &str, language: Language) -> Result<CompiledPattern, String> {
92    let sub = substitute(snippet)?;
93
94    // Resolve each `$VAR:kind` constraint to its query node-pattern once,
95    // against the target grammar (so an invalid kind errors clearly here
96    // rather than as an opaque query-compile failure later).
97    let mut metavar_node_patterns: HashMap<String, String> = HashMap::new();
98    for (metavar, constraint) in &sub.metavar_constraints {
99        metavar_node_patterns.insert(metavar.clone(), resolve_constraint(constraint, language)?);
100    }
101
102    let mut last_err: Option<String> = None;
103
104    for (prefix, suffix) in contexts(language) {
105        let wrapped = format!("{prefix}{}{suffix}", sub.text);
106        let tree = api::parse_tree(&wrapped, language).map_err(|err| err.to_string())?;
107        let root = tree.root_node();
108        if root.has_error() {
109            last_err = Some(format!(
110                "snippet did not parse cleanly in `{}`: `{snippet}`",
111                language.name()
112            ));
113            continue;
114        }
115
116        // The snippet occupies `[start, end)` inside the wrapped source; the
117        // deepest node spanning that range is its own subtree (no need to
118        // descend wrappers — and no risk of over-descending a single-child
119        // node like a unary expression).
120        let start = prefix.len();
121        let end = start + sub.text.len();
122        let Some(pattern_root) = root.descendant_for_byte_range(start, end.saturating_sub(1))
123        else {
124            last_err = Some(format!(
125                "could not locate snippet subtree in `{}`",
126                language.name()
127            ));
128            continue;
129        };
130
131        let bytes = wrapped.as_bytes();
132        let mut builder =
133            QueryBuilder::new(bytes, &sub.placeholder_to_metavar, &metavar_node_patterns);
134        let body = builder.build(pattern_root);
135        let predicates = builder.predicates();
136        let query = if predicates.is_empty() {
137            format!("({body} @{ROOT_CAPTURE})")
138        } else {
139            format!("({body} @{ROOT_CAPTURE} {predicates})")
140        };
141        return Ok(CompiledPattern {
142            query,
143            metavars: sub.metavar_order,
144        });
145    }
146
147    Err(last_err.unwrap_or_else(|| format!("snippet did not parse in `{}`", language.name())))
148}
149
150/// Candidate parse contexts for a snippet, tried in order. The bare context
151/// (`""`, `""`) comes first; item-required languages add a wrapper that
152/// makes an expression/statement fragment parse. Languages whose top level
153/// already accepts expression statements (TS/JS/Python/Ruby/…) only need
154/// the bare context.
155fn contexts(language: Language) -> Vec<(&'static str, &'static str)> {
156    let mut v = vec![("", "")];
157    let wrapper = match language {
158        Language::Rust => Some(("fn __harn_probe() { ", " }")),
159        Language::Go => Some(("package p\nfunc __harn_probe() { ", " }")),
160        Language::Java | Language::CSharp => {
161            Some(("class __HarnProbe { void __harn_probe() { ", " } }"))
162        }
163        Language::C | Language::Cpp => Some(("void __harn_probe() { ", " }")),
164        Language::Kotlin => Some(("fun __harn_probe() { ", " }")),
165        Language::Swift => Some(("func __harn_probe() { ", " }")),
166        Language::Scala => Some(("def __harn_probe() = { ", " }")),
167        _ => None,
168    };
169    v.extend(wrapper);
170    v
171}
172
173// ---------------------------------------------------------------------------
174// Step 1: metavar substitution
175// ---------------------------------------------------------------------------
176
177struct Substituted {
178    /// Snippet with `$VAR` replaced by placeholder identifiers.
179    text: String,
180    /// placeholder identifier → metavar name.
181    placeholder_to_metavar: HashMap<String, String>,
182    /// Metavar names in first-appearance order.
183    metavar_order: Vec<String>,
184    /// metavar name → its `:kind` constraint (raw, before grammar
185    /// resolution), for metavars written `$VAR:kind`.
186    metavar_constraints: HashMap<String, String>,
187}
188
189#[expect(
190    clippy::string_slice,
191    reason = "cursor offsets advance by len_utf8 or across ASCII bytes, so every offset \
192              is a char boundary"
193)]
194fn substitute(snippet: &str) -> Result<Substituted, String> {
195    let mut text = String::with_capacity(snippet.len());
196    let mut placeholder_to_metavar = HashMap::new();
197    let mut metavar_to_placeholder: HashMap<String, String> = HashMap::new();
198    let mut metavar_order: Vec<String> = Vec::new();
199    let mut metavar_constraints: HashMap<String, String> = HashMap::new();
200
201    let bytes = snippet.as_bytes();
202    let mut i = 0;
203    while i < bytes.len() {
204        if bytes[i] != b'$' {
205            // Copy this UTF-8 scalar verbatim. Indexing the &str at byte
206            // boundaries is safe because we only special-case ASCII `$`.
207            let ch = snippet[i..].chars().next().unwrap();
208            text.push(ch);
209            i += ch.len_utf8();
210            continue;
211        }
212        if snippet[i..].starts_with("$$$") {
213            return Err(
214                "variadic `$$$` metavariables are not yet supported (tracked in #2833)".into(),
215            );
216        }
217        // Parse `$NAME` where NAME is `[A-Za-z_][A-Za-z0-9_]*`.
218        let name_start = i + 1;
219        let mut j = name_start;
220        if j < bytes.len() && is_ident_start(bytes[j]) {
221            j += 1;
222            while j < bytes.len() && is_ident_continue(bytes[j]) {
223                j += 1;
224            }
225        }
226        if j == name_start {
227            // A lone `$` that is not a metavar — keep it literal.
228            text.push('$');
229            i += 1;
230            continue;
231        }
232        let name = &snippet[name_start..j];
233        // Optional `:kind` syntactic-class constraint (`$X:expression`). It is
234        // a constraint only when `:` is immediately followed by an identifier,
235        // so `$X: $T` (a typed binding, space after `:`) and `$X::foo` (a Rust
236        // path) are left as literal snippet text.
237        let mut consumed_end = j;
238        if j < bytes.len() && bytes[j] == b':' {
239            let kind_start = j + 1;
240            if kind_start < bytes.len() && is_ident_start(bytes[kind_start]) {
241                let mut k = kind_start + 1;
242                while k < bytes.len() && is_ident_continue(bytes[k]) {
243                    k += 1;
244                }
245                let constraint = &snippet[kind_start..k];
246                match metavar_constraints.get(name) {
247                    Some(existing) if existing != constraint => {
248                        return Err(format!(
249                            "metavariable `${name}` has conflicting type constraints \
250                             `:{existing}` and `:{constraint}`"
251                        ));
252                    }
253                    _ => {
254                        metavar_constraints.insert(name.to_string(), constraint.to_string());
255                    }
256                }
257                consumed_end = k;
258            }
259        }
260        let placeholder = metavar_to_placeholder
261            .entry(name.to_string())
262            .or_insert_with(|| {
263                let placeholder = format!("{PLACEHOLDER_STEM}{}", metavar_order.len());
264                metavar_order.push(name.to_string());
265                placeholder
266            })
267            .clone();
268        placeholder_to_metavar.insert(placeholder.clone(), name.to_string());
269        text.push_str(&placeholder);
270        i = consumed_end;
271    }
272
273    // A pattern with no metavars is a valid *literal* pattern (it matches a
274    // fixed structure), so we do not require one.
275
276    Ok(Substituted {
277        text,
278        placeholder_to_metavar,
279        metavar_order,
280        metavar_constraints,
281    })
282}
283
284/// Resolve a `$VAR:kind` constraint against the target grammar into the
285/// node-pattern atom the query uses in place of the `(_)` wildcard — `(kind)`
286/// for one kind, `[(k1) (k2)]` for an alias that maps to several. Errors when
287/// the constraint names no node kind in this grammar.
288fn resolve_constraint(constraint: &str, language: Language) -> Result<String, String> {
289    let ts = language
290        .ts_language()
291        .ok_or_else(|| format!("no grammar for `{}`", language.name()))?;
292    // A small set of cross-grammar semantic aliases map to the grammar's
293    // supertype; anything else is treated as an exact tree-sitter kind.
294    let candidates: Vec<&str> = match constraint {
295        "expr" | "expression" => vec!["expression"],
296        "stmt" | "statement" => vec!["statement"],
297        "ty" | "type" => vec!["type"],
298        "ident" | "identifier" => vec!["identifier"],
299        other => vec![other],
300    };
301    let valid: Vec<String> = candidates
302        .iter()
303        .filter(|kind| ts.id_for_node_kind(kind, true) != 0)
304        .map(|kind| format!("({kind})"))
305        .collect();
306    if valid.is_empty() {
307        return Err(format!(
308            "typed placeholder `:{constraint}` is not a node kind in `{}` \
309             (use an exact tree-sitter kind)",
310            language.name()
311        ));
312    }
313    Ok(if valid.len() == 1 {
314        valid.into_iter().next().unwrap()
315    } else {
316        format!("[{}]", valid.join(" "))
317    })
318}
319
320fn is_ident_start(b: u8) -> bool {
321    b.is_ascii_alphabetic() || b == b'_'
322}
323
324fn is_ident_continue(b: u8) -> bool {
325    b.is_ascii_alphanumeric() || b == b'_'
326}
327
328// ---------------------------------------------------------------------------
329// Step 2: walk the located subtree into a query
330// ---------------------------------------------------------------------------
331
332struct QueryBuilder<'a> {
333    src: &'a [u8],
334    placeholder_to_metavar: &'a HashMap<String, String>,
335    /// metavar name → resolved node-pattern atom (`(kind)` / `[(a) (b)]`) for
336    /// typed `$VAR:kind` placeholders. Absent metavars use the `(_)` wildcard.
337    metavar_node_patterns: &'a HashMap<String, String>,
338    /// occurrence count per metavar, to mint unification helper captures.
339    occurrences: HashMap<String, usize>,
340    /// `(#eq? …)` predicates for repeated metavars and literal leaves.
341    eq_predicates: Vec<String>,
342    /// counter for literal-leaf text-constraint captures.
343    literal_count: usize,
344}
345
346impl<'a> QueryBuilder<'a> {
347    fn new(
348        src: &'a [u8],
349        placeholder_to_metavar: &'a HashMap<String, String>,
350        metavar_node_patterns: &'a HashMap<String, String>,
351    ) -> Self {
352        QueryBuilder {
353            src,
354            placeholder_to_metavar,
355            metavar_node_patterns,
356            occurrences: HashMap::new(),
357            eq_predicates: Vec::new(),
358            literal_count: 0,
359        }
360    }
361
362    fn build(&mut self, node: Node<'_>) -> String {
363        // A placeholder leaf is a metavar hole.
364        if node.child_count() == 0 {
365            let text = self.node_text(node);
366            if let Some(metavar) = self.placeholder_to_metavar.get(text) {
367                let node_pattern = self
368                    .metavar_node_patterns
369                    .get(metavar)
370                    .map(String::as_str)
371                    .unwrap_or("(_)");
372                return format!("{node_pattern} @{}", self.capture_for(metavar));
373            }
374            if node.is_named() {
375                // A literal named leaf (a specific identifier / literal in
376                // the snippet): constrain it to its exact text so `foo()`
377                // matches calls to `foo`, not any call.
378                let cap = format!("__lit_{}", self.literal_count);
379                self.literal_count += 1;
380                self.eq_predicates
381                    .push(format!("(#eq? @{cap} {})", quote_literal(text)));
382                return format!("({}) @{cap}", node.kind());
383            }
384            return quote_literal(text);
385        }
386
387        let mut parts: Vec<String> = Vec::new();
388        let mut cursor = node.walk();
389        for (i, child) in node.children(&mut cursor).enumerate() {
390            let sub = self.build(child);
391            // Field names only attach to named children; an anonymous token
392            // in a field slot is matched positionally as a literal, which
393            // tree-sitter accepts where `field: "literal"` may not.
394            match node.field_name_for_child(i as u32) {
395                Some(field) if child.is_named() => parts.push(format!("{field}: {sub}")),
396                _ => parts.push(sub),
397            }
398        }
399        format!("({} {})", node.kind(), parts.join(" "))
400    }
401
402    /// Mint the capture name for this occurrence of `metavar`. The first
403    /// occurrence is `@NAME`; later ones are `@NAME.k` plus an `(#eq? …)`
404    /// predicate tying them to the first (metavar unification).
405    fn capture_for(&mut self, metavar: &str) -> String {
406        let count = self.occurrences.entry(metavar.to_string()).or_insert(0);
407        *count += 1;
408        if *count == 1 {
409            metavar.to_string()
410        } else {
411            let helper = format!("{metavar}.{count}");
412            self.eq_predicates
413                .push(format!("(#eq? @{metavar} @{helper})"));
414            helper
415        }
416    }
417
418    fn predicates(&self) -> String {
419        self.eq_predicates.join(" ")
420    }
421
422    fn node_text(&self, node: Node<'_>) -> &'a str {
423        std::str::from_utf8(&self.src[node.start_byte()..node.end_byte()]).unwrap_or_default()
424    }
425}
426
427/// Quote an anonymous token as a tree-sitter query literal, escaping `"`
428/// and `\`.
429fn quote_literal(text: &str) -> String {
430    let mut out = String::with_capacity(text.len() + 2);
431    out.push('"');
432    for ch in text.chars() {
433        if ch == '"' || ch == '\\' {
434            out.push('\\');
435        }
436        out.push(ch);
437    }
438    out.push('"');
439    out
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use streaming_iterator::StreamingIterator;
446    use tree_sitter::{Query, QueryCursor};
447
448    /// Compile `snippet`, run the query against `code`, and return the
449    /// captured text for each requested metavar from the first match.
450    #[expect(
451        clippy::string_slice,
452        reason = "tree-sitter capture ranges are char-aligned byte offsets into code"
453    )]
454    fn run(snippet: &str, language: Language, code: &str) -> Vec<(String, Vec<String>)> {
455        let compiled = compile_pattern(snippet, language).expect("compiles");
456        let ts_language = language.ts_language().expect("grammar");
457        let query = Query::new(&ts_language, &compiled.query)
458            .unwrap_or_else(|e| panic!("query rejected: {e}\nquery: {}", compiled.query));
459        let tree = api::parse_tree(code, language).expect("parse code");
460        let names: Vec<&str> = query.capture_names().to_vec();
461        let mut cursor = QueryCursor::new();
462        let mut matches = cursor.matches(&query, tree.root_node(), code.as_bytes());
463        let mut out = Vec::new();
464        while let Some(m) = matches.next() {
465            let mut per_capture: HashMap<String, Vec<String>> = HashMap::new();
466            for cap in m.captures {
467                let name = names[cap.index as usize].to_string();
468                let text = code[cap.node.start_byte()..cap.node.end_byte()].to_string();
469                per_capture.entry(name).or_default().push(text);
470            }
471            for (name, texts) in per_capture {
472                out.push((name, texts));
473            }
474        }
475        out
476    }
477
478    fn capture<'a>(binds: &'a [(String, Vec<String>)], name: &str) -> &'a [String] {
479        binds
480            .iter()
481            .find(|(n, _)| n == name)
482            .map(|(_, v)| v.as_slice())
483            .unwrap_or(&[])
484    }
485
486    #[test]
487    fn compiles_destructuring_default_in_typescript() {
488        // The #2824 codemod shape.
489        let snippet = "$SRC?.$KEY ?? $DEFAULT";
490        let compiled = compile_pattern(snippet, Language::TypeScript).expect("compiles");
491        assert_eq!(compiled.metavars, vec!["SRC", "KEY", "DEFAULT"]);
492        // It captures the optional-chain object/property and the fallback.
493        let binds = run(
494            snippet,
495            Language::TypeScript,
496            "const a = cfg?.timeout ?? 30;",
497        );
498        assert_eq!(capture(&binds, "SRC"), ["cfg".to_string()]);
499        assert_eq!(capture(&binds, "KEY"), ["timeout".to_string()]);
500        assert_eq!(capture(&binds, "DEFAULT"), ["30".to_string()]);
501    }
502
503    #[test]
504    fn compiles_optional_chain_nil_coalescing_in_harn() {
505        let snippet = "$SRC?.$KEY ?? $DEFAULT";
506        let compiled = compile_pattern(snippet, Language::Harn).expect("compiles");
507        assert_eq!(compiled.metavars, vec!["SRC", "KEY", "DEFAULT"]);
508        let binds = run(
509            snippet,
510            Language::Harn,
511            "fn main() {\n  let timeout = cfg?.timeout ?? 30\n}\n",
512        );
513        assert_eq!(capture(&binds, "SRC"), ["cfg".to_string()]);
514        assert_eq!(capture(&binds, "KEY"), ["timeout".to_string()]);
515        assert_eq!(capture(&binds, "DEFAULT"), ["30".to_string()]);
516    }
517
518    #[test]
519    fn operator_is_constrained_not_just_structure() {
520        // The `??` literal in the query must reject a `||` with the same
521        // structural shape — otherwise the codemod would be unsound.
522        let snippet = "$SRC?.$KEY ?? $DEFAULT";
523        let binds = run(
524            snippet,
525            Language::TypeScript,
526            "const a = cfg?.timeout || 30;",
527        );
528        assert!(
529            capture(&binds, "SRC").is_empty(),
530            "|| must not match the ?? pattern"
531        );
532    }
533
534    #[test]
535    fn round_trips_the_assignment_form() {
536        // The literal acceptance pattern: `$NAME = $SRC?.$KEY ?? $DEFAULT`.
537        let snippet = "$NAME = $SRC?.$KEY ?? $DEFAULT";
538        let compiled = compile_pattern(snippet, Language::TypeScript).expect("compiles");
539        assert_eq!(compiled.metavars, vec!["NAME", "SRC", "KEY", "DEFAULT"]);
540        let binds = run(
541            snippet,
542            Language::TypeScript,
543            "x = src?.userId ?? fallback;",
544        );
545        assert_eq!(capture(&binds, "NAME"), ["x".to_string()]);
546        assert_eq!(capture(&binds, "SRC"), ["src".to_string()]);
547        assert_eq!(capture(&binds, "KEY"), ["userId".to_string()]);
548        assert_eq!(capture(&binds, "DEFAULT"), ["fallback".to_string()]);
549    }
550
551    #[test]
552    fn lifts_metavars_in_rust() {
553        let snippet = "let $NAME = $VALUE;";
554        let binds = run(snippet, Language::Rust, "fn f() { let total = compute(); }");
555        assert_eq!(capture(&binds, "NAME"), ["total".to_string()]);
556        assert_eq!(capture(&binds, "VALUE"), ["compute()".to_string()]);
557    }
558
559    #[test]
560    fn lifts_metavars_in_python() {
561        let snippet = "$FN($ARG)";
562        let binds = run(snippet, Language::Python, "print(value)");
563        assert_eq!(capture(&binds, "FN"), ["print".to_string()]);
564        assert_eq!(capture(&binds, "ARG"), ["value".to_string()]);
565    }
566
567    #[test]
568    fn lifts_metavars_in_go() {
569        let snippet = "$FN($ARG)";
570        let binds = run(snippet, Language::Go, "package main\nfunc m() { log(err) }");
571        assert_eq!(capture(&binds, "FN"), ["log".to_string()]);
572        assert_eq!(capture(&binds, "ARG"), ["err".to_string()]);
573    }
574
575    #[test]
576    fn repeated_metavar_unifies() {
577        // `$X + $X` must match `a + a` but not `a + b`.
578        let snippet = "$X + $X";
579        let same = run(snippet, Language::Rust, "fn f() { let _ = a + a; }");
580        assert_eq!(capture(&same, "X"), ["a".to_string()]);
581        let different = run(snippet, Language::Rust, "fn f() { let _ = a + b; }");
582        assert!(
583            capture(&different, "X").is_empty(),
584            "unification must reject `a + b`"
585        );
586    }
587
588    #[test]
589    fn rejects_unparseable_snippet() {
590        let err = compile_pattern("$A ?? ?? $B", Language::TypeScript).unwrap_err();
591        assert!(err.contains("did not parse"), "got: {err}");
592    }
593
594    #[test]
595    fn rejects_variadic_for_now() {
596        let err = compile_pattern("foo($$$ARGS)", Language::TypeScript).unwrap_err();
597        assert!(err.contains("variadic"), "got: {err}");
598    }
599
600    #[test]
601    fn typed_placeholder_narrows_to_kind() {
602        // `$ARG:identifier` binds only when the argument is an identifier.
603        let snippet = "$FN($ARG:identifier)";
604        let compiled = compile_pattern(snippet, Language::TypeScript).expect("compiles");
605        // The constraint is stripped from the metavar name.
606        assert_eq!(compiled.metavars, vec!["FN", "ARG"]);
607        // Matches `f(x)` …
608        let hit = run(snippet, Language::TypeScript, "f(x);");
609        assert_eq!(capture(&hit, "ARG"), ["x".to_string()]);
610        // … but not `f(g())` — `g()` is a call_expression, not an identifier.
611        let miss = run(snippet, Language::TypeScript, "f(g());");
612        assert!(
613            capture(&miss, "ARG").is_empty(),
614            "a call argument must not match `:identifier`: {miss:?}"
615        );
616    }
617
618    #[test]
619    fn typed_placeholder_expression_alias_matches_any_expression() {
620        // `:expression` (a supertype alias) matches expression-position nodes
621        // of any concrete kind — the #2839 acceptance: `$x:expr` matches only
622        // expression-position captures, but every expression kind qualifies.
623        let snippet = "$FN($ARG:expression)";
624        let ident = run(snippet, Language::TypeScript, "f(x);");
625        assert_eq!(capture(&ident, "ARG"), ["x".to_string()]);
626        let call = run(snippet, Language::TypeScript, "f(g());");
627        assert_eq!(capture(&call, "ARG"), ["g()".to_string()]);
628    }
629
630    #[test]
631    fn typed_placeholder_unknown_kind_is_an_error() {
632        let err = compile_pattern("$X:not_a_real_kind", Language::TypeScript).unwrap_err();
633        assert!(err.contains("not a node kind"), "got: {err}");
634    }
635
636    #[test]
637    fn typed_placeholder_alias_unavailable_in_grammar_errors() {
638        // Rust has no public `expression` supertype, so the alias must error
639        // (directing the author to an exact kind) rather than silently widen.
640        let err = compile_pattern("let $X = $V:expression;", Language::Rust).unwrap_err();
641        assert!(err.contains("not a node kind"), "got: {err}");
642    }
643
644    #[test]
645    fn typed_placeholder_unifies_and_constrains() {
646        // `$X:identifier + $X` must both unify AND keep the kind constraint.
647        let snippet = "$X:identifier + $X";
648        let same = run(snippet, Language::Rust, "fn f() { let _ = a + a; }");
649        assert_eq!(capture(&same, "X"), ["a".to_string()]);
650        let different = run(snippet, Language::Rust, "fn f() { let _ = a + b; }");
651        assert!(
652            capture(&different, "X").is_empty(),
653            "unification still holds"
654        );
655    }
656
657    #[test]
658    fn colon_without_constraint_is_left_literal() {
659        // `$KEY: $VAL` (space after the colon) is a normal object entry, not a
660        // typed placeholder — both metavars bind and `:` stays in the snippet.
661        let snippet = "{$KEY: $VAL}";
662        let compiled = compile_pattern(snippet, Language::TypeScript).expect("compiles");
663        assert_eq!(compiled.metavars, vec!["KEY", "VAL"]);
664        let binds = run(snippet, Language::TypeScript, "let o = {a: 1};");
665        assert_eq!(capture(&binds, "KEY"), ["a".to_string()]);
666        assert_eq!(capture(&binds, "VAL"), ["1".to_string()]);
667    }
668
669    #[test]
670    fn literal_pattern_matches_exact_text() {
671        // A metavar-free pattern is a literal pattern: `foo()` matches calls
672        // to `foo`, not to other functions.
673        let snippet = "foo()";
674        let compiled = compile_pattern(snippet, Language::TypeScript).expect("compiles");
675        assert!(compiled.metavars.is_empty());
676        // It matches `foo()` …
677        let hit = run(snippet, Language::TypeScript, "foo();");
678        assert!(!hit.is_empty());
679        // … but not `bar()` (the literal identifier is constrained).
680        let miss = run(snippet, Language::TypeScript, "bar();");
681        assert!(
682            miss.is_empty(),
683            "bar() must not match foo()'s literal pattern: {miss:?}"
684        );
685    }
686}