The old AST matcher has been retired — this note is kept as the record of
the migration that justified removing it. It documented what the
universal-tokenizer matcher (Matcher) covered relative to the old matcher's
test corpus (tests/test_match.ml, ~177 tests in 13 groups) and what retiring
the old matcher would lose, from a parity audit plus re-authoring the old
expansion patterns in the new surface.
Outcome: all three capability gaps (concrete-key matching, element deletion,
field mode) were implemented — none needed the per-language coupling first
feared — and a verification sweep across the supported languages turned up and
fixed several real bugs (trailing-newline leak, partial wildcard-key, phantom
missing-node, metavar-substring substitution). The intentional non-ported
differences (sigil-free PHP/Kotlin metavars, whole-span partial/field
replacement, stricter operators, join/foreach replacing the ~/,
expansion lines) are recorded in §5–§6 below. The old matcher and its tests,
examples, and benchmarks are now gone.
Transforms are surgical (Coccinelle-style) in all three modes: an edit is
localized to the -/+ lines, and everything else is preserved
byte-for-byte — context, partial's tolerated extras, field's ignored optional
fields, and the source captured by ... (see docs/surgical-transforms.md §8
for the mechanism).
What this means in practice:
- Rename a call:
- foo($x) / + bar($x). The whole match is marked, so it is replaced whole — the degenerate single-hunk case. - Remove or edit a sub-part while keeping its siblings — mark only that part:
in partial mode removes only
<Foo - bar={$x} />bar={$x}and keeps the other attributes. In strict mode, bracket the rest with...(matched-and-not-edited, never emitted literally); in field mode the omitted decorators/modifiers/return types stay put. - Marking the whole container (a body with no context line) still replaces
it whole, dropping anything not restated. In partial/field that drops the
tolerated extras / ignored fields, so the matcher warns (
pattern_warnings) — it is the honest reading of marking the whole thing, but an easy mistake in modes built to tolerate/ignore content.
For a sub-part edit that also needs a guard ("only in objects shaped like X"),
multi-section still works: one section matches/guards the container, a
foreach/on-scoped section edits the elements.
Selective in-place edits — "rename the color property specifically,
preserve the rest" — were the audit's one real capability gap: a concrete
key like color, tokenized on its own, parses as a labeled statement
(statement_identifier), not the source pair's property_identifier, and
the (text, node_type) comparison missed.
This is now fixed for the case that matters — a foreach element
pattern. compile_foreach tokenizes the element pattern in its container
context, splicing it into the binding section's scaffold (foo({ color: $V })) so the parser assigns the contextual node-type, then extracting the
element's leaves by byte span. No per-language data enters the code (the
context is the user's own outer pattern) and no precision is relaxed. See
transforms.md §10 for the mechanism, and the foreach: concrete key … tests in test_matcher.ml.
What this leaves:
- Wildcard keys (
$K: $V) — always worked (the wildcard ignores node type). - Concrete keys in
foreachelements — now work (TS object property, TSX attribute), preserving siblings. Kotlin/Scala/PHP were never affected (single identifier node-type). - Bare concrete-key patterns (a standalone
- color: $V) — not addressed, and correctly so: barecolor: $Vis a labeled statement anda={$x}is an assignment, not the in-container element. The bare form honestly means its standalone parse; element-shaped patterns belong in container context, whereforeachnow handles them. - Ellipsis in the element — falls back to standalone tokenization (offsets would shift), so such a selective rule degrades gracefully rather than misfiring.
So the audit's highest-value gap is closed, with no architectural cost.
Removing a list element (deprecated property, unused argument) is now
implemented. A foreach element with an empty replacement (a - line,
no +) deletes the element, and the engine swallows one adjacent separator
so the list stays well-formed — the separator following the element, or
the preceding one if it is last with no trailing comma. A trailing comma
is handled by the same rule (f(a,) → f(), no dangling). See
transforms.md §11 for the mechanism, and the remove: …
tests in test_matcher.ml. This covers the old separator_deletion group
(11 tests).
Field mode (match: field) — match a declaration while ignoring
decorators/modifiers/return types — is now implemented, and without
the per-language registry the design originally anticipated. A pattern's
leaf stream is aligned to a subsequence of the declaration node's
children: optional fields the pattern omits are skipped, addressed fields
are matched in full, with backtracking (so @deprecated can be matched
among several annotations). The once-planned "Tier 3" gap (TS
async/abstract) dissolved — anonymous keywords the pattern omits are
just skipped, and a body-bearing pattern still won't match a bodyless
signature. See field-mode.md and the field: … tests in
test_matcher.ml. This covers the old field group (6 tests).
Two follow-ups remain, both niche: decorator-subset where the grammar
keeps annotations in a single child (PHP attribute_list, Kotlin
modifiers) is a nested match, and TS method decorators are class-body
siblings of the declaration (so a field-mode replace leaves them in
place). Neither blocks the primary use case.
- Search and whole-construct transforms: calls, member calls,
fragments, ellipsis, partial-search, comments, rename, operand swap,
multi-section,
on/foreachscoping. Confirmed by the parity harness. - Operator precision is better: the old matcher matched
m * nwith a$a + $bpattern (ignoring the operator); the new matcher correctly matches only+expressions. - Sequence expansion: the old column-0 expansion (
~/,prefixes) is fully covered by the newforeach/joinsurface — verified by re-authoring the oldexpansiongroup's patterns as passing tests across TS and Kotlin (theport:tests intest_matcher.ml).
- PHP / Kotlin metavars: the old matcher requires
$-sigil metavars and unwraps the<?phptag; the new matcher is sigil-free ($MSGtokenizes to$+MSGin PHP/Kotlin, where$is real syntax) and treats tags literally. Old PHP/Kotlin tests can't run verbatim, but the capability is intact — the Kotlin expansion ports (sigil-free metavars) pass. These tests need re-authoring in new conventions, not new code.
- Merging two captured sequences into one join: old
, $BEFORE $AFTERgathered both sequences into a single comma-join; the new model renders each referenced sequence independently (its ownjoin). Rare. - Passthrough vs drop of non-matching elements: an old transform-expansion passed through elements that didn't match the inner pattern; a new splice renders only the matched elements and drops the rest. Relevant to "transform some elements, keep the others in the list"; ties into the open keep/drop question for filtering.
All three of the audit's gaps are now closed:
| Gap | Footprint | Cost | Note |
|---|---|---|---|
| common surgical edits | — | done (contextual tokenization) | |
| remove-property/arg | — | done (foreach empty +) |
|
| decorated declarations | — | done (child alignment; no registry) |
Each closed without the per-language coupling first feared — concrete keys via contextual tokenization, deletion via the foreach empty-replacement, field mode via child alignment. Retiring the old matcher (the endgame "delete old matcher" step) is no longer gated on a capability gap; what remains is verification breadth and the mechanical work of porting the old corpus's remaining tests and removing the old engine. The niche field-mode follow-ups (§4) and the deferred partial-mode sugar (partial-mode.md §7) are evidence-driven, to be built if the change-summary work asks for them.