Skip to content

Repository files navigation

ast-grep-svelte — Svelte 5 as an ast-grep custom language

What this repo delivers: a tree-sitter grammar for Svelte 5 single-file components, oracle-proven against svelte/compiler 5.56.4, registered as an ast-grep custom language with JS/TS + CSS language injection. It is strict-green: bun run gates proves every coverage leaf against the compiler oracle and the grammar, EBNF ⇄ grammar parity, the full 41-type Svelte-AST denominator, and zero ERROR/MISSING nodes over a 4900-file real-world corpus. The output is a .so you register in any repo's sgconfig.yml so ast-grep can structurally search, lint, and rewrite .svelte files by node kind and field.

This README is an integration guide for an AGENT. It is written so another coding agent can digest this repo and wire the proven grammar into a different target repo's ast-grep setup, end to end, from copy-pasteable commands. The guide leads; the "Working ON the grammar" section at the bottom is for hacking on the grammar itself. Every command below was verified live against the real artifacts in this repo (@ast-grep/cli 0.42.3, tree-sitter 0.26.9, macOS/arm64).


0. Orientation — read this first

You are integrating a compiled tree-sitter parser (svelte.so) into a target repo's ast-grep. The parser is produced from vendored C sources in this repo and compiled locally with a system C compiler — there is no tree-sitter-cli at consume time. Once registered, ast-grep parses .svelte files into a concrete syntax tree whose node kinds and fields your rules target (kind: each_block, has: {field: key, ...}), and language injections re-parse the embedded <script> / <style> / {…} regions as TypeScript / CSS so rules can match inside them.

Pinned toolchain (EXACT — never a range)

Tool Version Role in integration
@ast-grep/cli 0.42.3 Consumes svelte.so via customLanguages. Must be ABI-15-compatible (see below).
C compiler (cc — Apple clang / gcc) system Compiles svelte.so from the vendored parser.c + scanner.c.
tree-sitter-cli 0.26.9 Upstream only — regenerates parser.c from grammar.js inside this repo. NOT needed in the target repo.
svelte / svelte-check / typescript 5.56.4 / 4.4.8 / 6.0.3 The oracle that proved the grammar here. Not a consume-time dependency of the target repo.

The load-bearing compatibility fact — read before anything else

The vendored/compiled svelte.so is tree-sitter ABI 15 (verified: grep LANGUAGE_VERSION tree-sitter-svelte/src/parser.c#define LANGUAGE_VERSION 15). ast-grep can only load a parser whose ABI it supports. The target repo's @ast-grep/cli MUST be an ABI-15-compatible release — the 0.42.x line (0.42.3 here; 0.42.2 is also proven, see below). An ABI mismatch is the #1 integration failure, and its symptom is unmistakable:

Incompatible language version N. Compatible range: X - X. Got: Y

If you see that, the target's ast-grep is too old/new for this ABI — align it to 0.42.x (or rebuild the grammar against the target's ast-grep ABI, see Refreshing). Version-skew note: the injection/metavar contract was probed on ast-grep 0.42.3; the full customLanguages + languageInjections surface used here was independently re-verified inert on 0.42.2 (project-xavier's pin). The customLanguages / languageInjections surface is unchanged across that patch delta.

Which case are you in?

  • Target repo has no ast-grep → do Case A first, then Case B.
  • Target repo already runs ast-grep (has an sgconfig.yml, a lint:ast script) → go straight to Case B. This is the core case.

Case A — target repo has no ast-grep yet

Goal: get an ABI-compatible ast-grep runnable and a minimal sgconfig.yml, then hand off to Case B.

A1. Install the ABI-compatible ast-grep (pin EXACT)

Node repo (has package.json) — preferred:

bun add -D @ast-grep/cli@0.42.3     # or, if your target repo uses npm/pnpm: npm install --save-dev / pnpm add -D @ast-grep/cli@0.42.3

WHY exact 0.42.3: it is the ABI-15 release the grammar was proven against. A floating range risks pulling an ABI-incompatible ast-grep and the Incompatible language version failure above.

Non-Node repo (no package.json) — pick one:

cargo install ast-grep --locked --version 0.42.3   # single binary, `ast-grep` (alias `sg`)
# or run without installing, per-invocation:
bunx @ast-grep/cli@0.42.3 --version

If you install globally instead of as a devDep, make sure the same pinned version is what CI runs — a globally-newer ast-grep is exactly how the ABI mismatch sneaks in.

A2. Verify the version

bunx ast-grep --version        # expect: ast-grep 0.42.3   (or your pinned 0.42.x)

A3. Create a minimal sgconfig.yml (only if none exists)

At the target repo root:

# sgconfig.yml — minimal starting point
ruleDirs:
  - rules            # create this dir; put *.yml rules here (can be empty for now)

A4. Smoke check

bunx ast-grep --version && echo "ast-grep ready"

Now proceed to Case B to add the Svelte language.


Case B — add the Svelte language to an existing ast-grep setup

This is the core integration. Five steps: obtain the .so → guard it with a sync check → register it → wire install/CI → verify. All paths below are relative to the target repo root unless stated. The convention used here (mirroring the project-xavier reference integration) is a tools/ast-grep-svelte/ directory in the target repo holding the vendored sources + build/verify scripts.

B1. Obtain svelte.so — build from vendored source

Approach: vendor the grammar's C source inputs and compile the .so locally. WHY build-from-source rather than committing a prebuilt binary:

  • parser.c/scanner.c are platform-independent C — one vendored copy builds on every dev machine and CI runner (Linux .so, macOS Mach-O dylib) with the system compiler. No per-target binary matrix, no runtime resolver.
  • The compiled .so is a per-target binary — gitignore it and rebuild on install (the same pattern native .node bindings use). Reviewers diff readable C sources in git, never an opaque binary.
  • No tree-sitter-cli at consume time — the CLI is only used upstream in this repo to regenerate parser.c from grammar.js.

Vendor this exact source set from this repo's tree-sitter-svelte/ into the target's tools/ast-grep-svelte/ (same relative layout). These 8 files fully determine svelte.so:

grammar.js                       # human grammar source (regeneration input / reference)
src/parser.c                     # GENERATED parser (platform-independent C, ~1.6 MB)
src/scanner.c                    # external scanner (emits the embedded-region tokens)
src/grammar.json                 # grammar metadata
src/node-types.json              # every node kind + field (the rule-author reference)
src/tree_sitter/alloc.h          # compile headers
src/tree_sitter/array.h
src/tree_sitter/parser.h

Do NOT vendor svelte.so, test/, node bindings, tree-sitter.json, or package.json — they are not compile inputs.

Compile command (this is the exact invocation tree-sitter build runs internally; verified live — produces a loadable Mach-O arm64 / ELF .so):

cd tools/ast-grep-svelte
cc -shared -fPIC -fno-exceptions -g -O2 \
  -I src \
  -o svelte.so \
  src/scanner.c \
  -xc src/parser.c
  • -fPIC -shared → a dlopen-able dynamic library. macOS clang emits a Mach-O dylib under the .so name; ast-grep's loader dlopens it regardless of extension, so a single svelte.so name works cross-platform.
  • The vendored scanner.c emits a few benign -Wincompatible-pointer-types warnings under Apple clang (an upstream Array()-macro typing quirk). Do NOT "fix" them in the vendored copy — that forks it from source-of-truth and breaks the sync check (B2). Any real fix lands upstream in this repo and flows back via the refresh process.

Package this as tools/ast-grep-svelte/build.sh so install/CI can call it. Make the missing-compiler case non-fatal (skip with guidance, exit 0) so a frontend-only pnpm install doesn't hard-break — but note (B4) that an absent svelte.so still hard-fails ast-grep scan:

#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC="$HERE/src"; OUT="${AST_GREP_SVELTE_OUT:-$HERE/svelte.so}"
CC="${CC:-cc}"
if ! command -v "$CC" >/dev/null 2>&1; then
  echo "[ast-grep-svelte] no C compiler ('$CC') — skipping svelte.so build (ast-grep will report svelte unavailable)."
  exit 0                               # non-fatal, like the native-binding postinstall precedent
fi
[ -f "$SRC/parser.c" ] && [ -f "$SRC/scanner.c" ] || { echo "vendored sources missing under $SRC" >&2; exit 1; }
"$CC" -shared -fPIC -fno-exceptions -g -O2 -I "$SRC" -o "$OUT" "$SRC/scanner.c" -xc "$SRC/parser.c"
echo "[ast-grep-svelte] built: $OUT"

Gitignore the artifact (tools/ast-grep-svelte/.gitignore):

svelte.so
svelte.dylib
*.o
*.dSYM/        # clang -g debug bundles emitted alongside svelte.so on macOS

B2. Guard against drift — verify-sync.sh

The vendored copy can silently drift from this repo's proven grammar. Ship a content-hash check so the target's copy provably matches a known-good grammar. Combined-hash method (order-stable, path-relative):

# combined SHA-256 of the vendored source set, computed from inside tools/ast-grep-svelte/
printf '%s\n' grammar.js src/parser.c src/scanner.c src/grammar.json \
  src/node-types.json src/tree_sitter/alloc.h src/tree_sitter/array.h \
  src/tree_sitter/parser.h | LC_ALL=C sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}'

Package as tools/ast-grep-svelte/verify-sync.sh with three behaviours:

  1. Vendored vs recorded hash — recompute the combined hash; compare to a RECORDED_SHA256 pinned in the script. Mismatch → exit 1 (the vendored copy changed without re-recording).
  2. Peer present (a sibling checkout of this grammar repo, or AST_GREP_SVELTE_PEER=<path>) → also hash the peer's tree-sitter-svelte/ and cross-check. Drift → exit 1.
  3. Peer absent (CI and most checkouts have no peer checkout) → print peer:absent, exit 0. WHY: an absent peer is not drift; the recorded-hash check still guarantees the vendored copy is the one you recorded.

Record the hash of the grammar you actually vendor. As of this writing, the combined hash of this repo's current proven tree-sitter-svelte/ is:

cdefbbdfd0d13c25789f94681e853193ab97b90b941e62d68237db24d1ae742f

Recompute it yourself at vendor time (grammar advances) and pin that value.

B3. Register in the target sgconfig.yml

Add the customLanguages block and the languageInjections matrix. Both blocks below are the frozen contract — copy verbatim, adjusting only libraryPath to where B1 writes the .so (path is resolved relative to sgconfig.yml):

customLanguages:
  svelte:
    libraryPath: tools/ast-grep-svelte/svelte.so   # relative to THIS file
    extensions: [svelte]

languageInjections:
  - hostLanguage: svelte
    rule: { kind: raw_text_css, pattern: $CONTENT }
    injected: css
  - hostLanguage: svelte
    rule: { kind: raw_text_js, pattern: $CONTENT }
    injected: typescript
  - hostLanguage: svelte
    rule: { kind: raw_text_expr, pattern: $CONTENT }
    injected: typescript

The load-bearing details, each with its WHY:

  • Map key svelte = the grammar name. ast-grep therefore loads the default exported symbol tree_sitter_svelteno languageSymbol override needed.
  • pattern: $CONTENT is mandatory on every injection. A bare rule: { kind: raw_text_js } with no pattern injects nothing (probed on 0.42.x — the scan returns empty). The $CONTENT capture of the whole region is what makes injection fire.
  • <script> bodies inject typescript — the SUPERSET policy, NOT per-attribute ts/js. Valid JS ⊆ valid TS, so a lang="ts" body and a plain <script> body both parse error-free and are always scanned. Per-attribute selection (ts when lang="ts", else js) is expressible via a relational rule, but was rejected: on a file with BOTH a lang="ts" and a plain <script>, ast-grep realizes only one injection per host kind, leaving the other body silently unscanned — a completeness gap for a linter. The relational alternative is a documented one-edit-away migration if a consumer genuinely needs the split.
  • raw_text_expr (interpolations / block headers / {@html} / directive values) injects typescript so rules match $state, handler calls, member expressions inside {…}. Bounded caveat: a destructuring each-header {#each x as {a, b}} reparses as a TS block, not an object_pattern, so rules targeting destructured names in each-headers won't match reliably — no host-tree corruption, host kind: each_block scanning unaffected.
  • No expandoChar. The standard $VAR / $$$ metavar sigils stand. Svelte's $-runes are lowercase ($state) so they never collide with the $UPPER sigil, and legacy $$props/$$restProps appear only inside opaque raw_text_expr regions.

Full probe evidence for every one of these: this repo's docs/decisions/0002-injection-and-metavars.md.

B4. Wire install + CI — REQUIRED (an absent .so hard-fails)

The critical fact: ast-grep loads a registered custom language's library eagerly. With svelte.so absent, ast-grep scan does not degrade — it hard-fails with exit 79, "Cannot load custom language library" (verified live). Therefore svelte.so MUST exist before any ast-grep scan (lint:ast) runs, on every machine and CI job. Two required hooks:

  1. Install / postinstall — build the .so on dependency install, guarded non-fatally (frontend-only contributors and pre-Rust-step CI have no compiler; the build self-skips there):

    # in the repo's postinstall step
    bash tools/ast-grep-svelte/build.sh || \
      echo "WARNING: svelte.so build failed — lint:ast will fail until it is built."
  2. CI (the lint:ast job) — run the build before the scan (GitHub runners ship a C compiler; caching the .so is an optional optimization, not a requirement):

    - run: bash tools/ast-grep-svelte/build.sh   # BEFORE the ast-grep scan step
    - run: bunx ast-grep scan                       # your lint:ast
  3. Optional hardening — wrap the lint:ast script to build-if-missing so a stale checkout self-heals: bash tools/ast-grep-svelte/build.sh && ast-grep scan.

Co-land these with the sgconfig.yml change in the same commit/PR. Registering the language without the build wiring breaks lint:ast on every fresh checkout and CI job (exit 79). This is the single most important sequencing rule of the integration. (Mirrors ADR 0006 §6 in the project-xavier reference integration.)

B5. Verify the integration

Three checks. The first proves you didn't break the existing scan; the rest prove the language is live.

a. lint:ast is green and INERT — mere registration adds zero findings (no rule targets language: svelte yet). Confirm the target's scan still exits 0 and its finding set is unchanged vs before registration:

bunx ast-grep scan            # exit 0; identical finding set to pre-registration

b. Inline each_block smoke — proves the grammar loads and parses .svelte:

bunx ast-grep scan -c sgconfig.yml <dir-with-svelte> --inline-rules 'id: smoke
language: svelte
rule: {kind: each_block}'

Expect matches on any repo with {#each …} blocks. (Verified live in this repo: 111 each_block matches over corpus/; against a real frontend, project-xavier's X1 integration reported 469 matches across 391 .svelte files.)

c. Injection smoke — proves {…} expressions re-parse as TypeScript:

bunx ast-grep scan -c sgconfig.yml <dir-with-svelte> --inline-rules 'id: inj
language: typescript
rule: {kind: member_expression}'

Expect matches inside .svelte interpolations (verified live: 116 over corpus/).


Frozen contracts — the rule-author reference

Everything a rule-author agent in the target repo needs to target the grammar. Node kinds are read off tree-sitter-svelte/src/node-types.json (55 named kinds); discover the exact kinds/fields any snippet produces with tree-sitter parse (see Working ON the grammar) or ast-grep run --debug-query=ast.

Node kinds (what kind: rules target)

Category Kinds
Elements / components regular_element, component, svelte_element, svelte_component, svelte_self, svelte_window, svelte_body, svelte_document, svelte_head, svelte_options, svelte_fragment, svelte_boundary, slot_element, script_element, style_element, title_element
Blocks each_block, if_block, await_block, key_block, snippet_block, else_clause, then_clause, catch_clause
Tags html_tag ({@html}), const_tag ({@const}), debug_tag ({@debug}), render_tag ({@render}), attach_tag ({@attach}), expression_tag ({expr})
Directives animate_directive, bind_directive, class_directive, let_directive, on_directive, style_directive, transition_directive, use_directive, directive_name, directive_modifier, modifier_name
Attributes attribute, attribute_name, spread_attribute, quoted_attribute_value, unquoted_attribute_value
Structural root, fragment, text, comment, tag_name
Injection bodies (host tokens that injection re-parses) raw_text_js (<script>) → typescript, raw_text_css (<style>) → css, raw_text_expr ({…}) → typescript, raw_text (title/textarea literal bodies)

Injection matrix (which language: a rule declares)

To match code in… write language: via host kind
template / elements / blocks / directives / tags svelte (host — no injection)
<style> bodies css raw_text_csscss
<script> bodies (ts AND js — TS superset) typescript raw_text_jstypescript
{…} interpolations / block-header exprs / {@html} etc. typescript raw_text_exprtypescript

Rule-authoring notes

  • Prefer kind: / field: over bare patterns — the grammar has intentional named kinds and fields; that is the whole reason it was authored from scratch.
  • A field: constraint needs a sub-matcher. field: key alone matches nothing on 0.42.x; write has: {field: key, kind: raw_text_expr}.
  • Metavariables: standard $VAR (single) and $$$ (variadic). No expandoChar.
  • Anonymous tokens (string literals like keywords/punctuation) are not reliably kind-matchable — capture with $$VAR or target the enclosing named kind.

Pins recap

@ast-grep/cli 0.42.3 (ABI-15; 0.42.x compatible) · grammar ABI 15 · tree-sitter-cli 0.26.9 (upstream regeneration only) · oracle svelte 5.56.4 / svelte-check 4.4.8 / typescript 6.0.3.


Refreshing — re-vendoring when the grammar updates

When this grammar repo advances and the target repo needs the newer grammar:

  1. In this repo, regenerate + rebuild to confirm src/ is current, then run the gates green:
    bunx tree-sitter generate && bunx tree-sitter build --output svelte.so
    bun run gates
  2. Copy the source set back into the target's tools/ast-grep-svelte/ (the 8 files from B1):
    PEER=<path-to>/ast-grep-svelte/tree-sitter-svelte
    cp "$PEER/grammar.js" tools/ast-grep-svelte/grammar.js
    cp "$PEER/src/"{parser.c,scanner.c,grammar.json,node-types.json} tools/ast-grep-svelte/src/
    cp "$PEER/src/tree_sitter/"{alloc,array,parser}.h tools/ast-grep-svelte/src/tree_sitter/
  3. Recompute + re-record the combined hash (B2) in verify-sync.sh's RECORDED_SHA256.
  4. Rebuild svelte.so, run the target's lint:ast (stays inert until svelte rules exist), and re-run any svelte rules' ast-grep test.
  5. Re-run verify-sync.sh — it must exit 0 (vendored == recorded, and == peer if a peer checkout is present).

If the target's ast-grep changed ABI, regenerate with an explicit tree-sitter generate --abi <N> to target the ABI that ast-grep supports, then rebuild.


Working ON the grammar (in this repo)

The above is for integrating the grammar elsewhere. This section is for hacking on the grammar itself, in this repo. Full orientation: CLAUDE.md; build methodology: .claude/skills/ast-grep-custom-language/SKILL.md (auto-triggers on grammar.js / sgconfig.yml).

HARD CONSTRAINT — no commits in THIS repo. Working-tree-only: no git commit / branch / worktree / PR here. The operator handles all commits. Leave a clean working tree that makes bun run gates green. (This constraint is local to this grammar repo; it does not apply to the target repo you are integrating into.)

Setup

bun install                    # installs the pinned toolchain (devDeps) + oracle
bunx tree-sitter --version      # tree-sitter 0.26.9
bunx ast-grep --version         # ast-grep 0.42.3

The pipeline

grammar/spec/*.ebnf ─► grammar.js ─► generated C parser ─► svelte.so ─► ast-grep customLanguage
        ▲               (Svelte's context-sensitive lexing lives in an external src/scanner.c)
        └── oracle-as-ground-truth: run corpus through svelte-check + the Svelte compiler,
            record accept/reject in grammar/oracle-facts/ (the oracle always wins over a grammar guess)

Rebuilding the parser library (.so)

The compiled parser is a native, host-platform artifact — tree-sitter build cannot cross-compile, so the mac library must be built on macOS and the linux library on Linux. The platform-keyed build scripts assert the host OS and fail LOUD on a mismatch instead of silently producing a wrong-OS artifact:

bun run build:svelte-so          # build for THIS host -> tree-sitter-svelte/svelte-<plat>-<arch>.so
bun run build:svelte-so:mac      # assert macOS, then build (svelte-darwin-arm64.so on Apple Silicon)
bun run build:svelte-so:linux    # assert Linux, then build (svelte-linux-x64.so on x86_64)

All three wrap scripts/build-so.sh, which compiles the checked-in generated parser (tree-sitter-svelte/src/parser.c + scanner.c) with the pinned tree-sitter-cli and then also refreshes tree-sitter-svelte/svelte.so — the host-resolved library sgconfig.yml points at — so local ast-grep scan / ast-grep test runs pick up the build immediately.

The platform-keyed artifacts (svelte-darwin-arm64.so, svelte-linux-x64.so) are committed (a .gitignore negation exempts them from the blanket *.so ignore), so consumers get a working prebuilt parser without a native toolchain. The host-resolved svelte.so copy stays ignored. After rebuilding on a grammar change, commit the refreshed platform artifact(s) alongside the grammar source.

When to rebuild:

  • after any change to grammar.js or src/scanner.c — run cd tree-sitter-svelte && bunx tree-sitter generate first if grammar.js changed (the script compiles src/parser.c; it does not regenerate it);
  • when producing a fresh platform artifact for a consumer (project-xavier's tools/ast-grep-svelte/rebuild.sh runs this same build against this repo's committed HEAD — commit grammar changes here first or its artifact won't include them).

bun run build:parser remains the grammar-dev loop variant: it builds the host svelte.so plus svelte.wasm (for web-tree-sitter tooling), without the platform-keyed artifact.

The gates (completion command)

bun run gates --allow-incomplete   # every checker green on the current state
bun run gates                      # STRICT: additionally every leaf proven, zero unmapped
                                   # denominator types, corpus scan > 0 files

The umbrella runs the coverage/oracle/grammar/parity/denominator/real-world gates in order; each is individually bun gates/<gate>.ts-runnable and prints scanned N, failed M (a zero from a run that scanned nothing is a FAILURE, not a pass).

Rules & selector discovery

# scan the saved ruleset (rules/lint/) — rules/tests/ holds the ast-grep test cases
bunx ast-grep scan -c sgconfig.yml corpus/
bunx ast-grep test -c sgconfig.yml                     # add -U to (re)generate snapshots

# inspect how ast-grep parses a pattern (ESSENTIAL when a pattern matches nothing)
bunx ast-grep run --debug-query=ast -p '<pattern>' -l svelte <file>

# read the real kinds/fields a snippet produces (the selectors you write rules against)
node_modules/.bin/tree-sitter parse --lib-path tree-sitter-svelte/svelte.so --lang-name svelte <file>

References

About

ast grep tree sitter, grammar and ast-grep custom language bindings for svelte

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages