For AI agents: this page as Markdown · llms.txt · JSON Schema

Rerail's Blade parser: a port of Forte

Rerail parses Blade with a Rust port of Forte 1.1.0, the fault-tolerant parser Blade's own tooling uses (github.com/fortephp/forte, tag v1.1.0, commit 9c9597d0b7, MIT, by John Koster).

It replaced tree-sitter-blade 0.12.2 (b5291d1), which reported an ERROR in hundreds of real templates (T1). fixtures/blade/blade/grammar-gaps/repros/ holds a minimal repro of each error class; Forte reads all of them without an error.

What is ported

The port covers the two stages that decide structure, under native/src/code_rule_dsl/blade/forte/.

Each PHP method is one snake-cased Rust function whose doc comment names it, as literal/phpdoc/{lexer,parser}.rs ports phpstan/phpdoc-parser.

How Forte runs the stages, and the file map

Document::parse($template, $options) (Ast/Document/Document.php:164-213) runs the two stages and keeps their arrays. Node records are {kind, parent, firstChild, lastChild, nextSibling, tokenStart, tokenCount, genericOffset, data}, plus name, args and role extras on directive records.

Between the stages, when the registry accepts every directive, trainCompact trains the registry on the tokens. The rest of Forte is a lazy object API over the arrays.

Forte Port What it decides
Lexer/Lexer.php, ConstructDetector.php, Construct.php, Concerns/* (176 KB) lexer/ tokens and lexer errors
Parser/TreeBuilder.php, ConstructScanner.php, Concerns/*, OptionalTags/* parser/ the flat node array
Parser/Directives/{DirectiveTokenIndex,DirectiveHelper,ArgumentScanner}.php directives/{token_index,helper,arguments}.rs pairing, branches, terminators
Parser/Directives/Directives.php + resources/directives/*.json (21 files) directives/mod.rs which @word is a directive, its kind and pairs
Support/ScanPrimitives.php and the string helpers the two stages call scan.rs quoted strings, PHP/JS skipping
Ast/Elements/VoidElements.php parser/ void elements
ElementNode, ComponentManager, Attributes, Attribute, DirectiveBlockNode, EchoNode, Php*Node reads ast.rs tag names, component/slot names, attributes, a block's start/end directive, echo/PHP content
PHP semantics the port reproduces

The PHP string semantics Forte relies on are reproduced: $s[$i] past the end is "", substr clamps, strtolower and ctype_* are ASCII (ctype_space includes \v and \f), and trim uses its default characters.

The parse path's one regular expression, ProcessesDirectives::recoverRepeatedUnterminatedPairedDirective (a lookbehind-anchored preg_match for a compact terminator inside text), is a byte scan with the same semantics. The lexer has none; LineIndex::build's preg_match_all only computes line numbers.

Not ported, each checked against Forte's code: the AST object API, Rewriting/, Querying/, Extensions/, Enclaves/, Diagnostics/ and Laravel wiring.

What each skipped part is, and why it is safe to skip

Forte's tree

Forte's tree is a lossless flat array of nodes over tokens that partition the template.

Node kinds and block token ranges

Text, whitespace included, is Text; whitespace between attributes is AttributeWhitespace. Kinds (Parser/NodeKind.php): Root, Element, Text, Echo, RawEcho, TripleEcho, Directive, DirectiveBlock, Verbatim, PhpBlock, PhpTag, Comment, BogusComment, ConditionalComment, BladeComment, Doctype, Cdata, Decl, Attribute, JsxAttribute, UnpairedClosingTag, NonOutput, ElementName, ClosingElementName, AttributeWhitespace, AttributeName, AttributeValue, ProcessingInstruction.

The port omits what Forte declares but never uses: Fragment (the tree builder never creates it), unlogged lexer states and error reasons, and registry queries the parse never calls.

A branch's content is its children, outside its own tokens. The block's token range starts as the opener's and grows to the closer's when it closes (ProcessesDirectives.php:681-693).

An unclosed block keeps the opener's range while its children run on. Rerail therefore spans a node by its tokens plus its children's.

Forte recovers from malformed input without error nodes; past fixed nesting limits the tree builder throws.

Recovery rules and depth limits

Rerail's tree

forte/tree.rs exposes a Forte parse as an ast-grep tree for rules, patterns, tapes and the view.

tree::parse_tree accepts every directive, so an application's or package's @name (@ray(...), @livewire(...), a Blade::if pair) is a directive node, not text, as Blade's tooling (tlint, blade-parser) reads it.

Node Fields
element, component, slot name, closing_name
attribute, jsx_attribute name, value
directive name, arguments
echo, raw_echo, triple_echo expression
php_block, php_tag code
directive_block start, end (DirectiveBlockNode::startDirective/endDirective)

The module doc of tree.rs is the authoritative list of kinds, leaves and fields.

How the tree plugs into ast-grep

The Doc is ast-grep-core's, here BladeDoc (nodes BladeNode), built like literal/mod.rs's sub-trees (SubTree/SubDoc/SubNode). Node ids (ID_FLAG, a tree ordinal, the preorder index) never collide with tapes' or tree-sitter's.

tree::parse_tree, used by tapes, patterns and the view, takes Forte's default directives and accepts every directive (ParserOptions::acceptAllDirectives). Nodes follow the preorder of Forte's links.

Full kind and leaf lists

Every recorded tree holds tree-sitter's invariants, even where Forte's links do not nest by span; tree::tests::trees_are_well_formed checks them over the suite, fixtures and corpora.

Where spans don't nest, and how the tree resolves it

Forte's links do not always nest by span. Of 86,361 recorded trees (suite and corpus dumps):

The tree resolves both by rule:

Every recorded tree then holds tree-sitter's invariants: children inside their parent, siblings in order without overlap, every non-whitespace byte in exactly one leaf.

Embedded PHP for the view

The view (blade/view.rs) copies a template's embedded PHP byte for byte. Each span is a php leaf with its exact byte range, under a node naming the construct:

Construct Node PHP span
{{ e }}, {!! e !!}, {{{ e }}} echo, raw_echo, triple_echo expression field
@name(args) directive (name field) inside the parentheses, to the ) Blade's compiler balances (view::balanced)
@php … @endphp php_block code field
<?php … ?>, <?= … ?> php_tag everything between the tags; <?='s code field
:attr="e", :$name on component, slot attribute value without its quotes, or the name after :
@if … @elseif … @else … @endif, loops, @switch, @once, @pushIf, @push directive_block with start, branch directives, end; a directive's body is its children each directive's arguments

Where Forte leaves a structure unpaired or text unread, the view reads it as Blade's compiler does.

Where the view departs from Forte

The view reads @name(args) only where spaces or tabs alone separate the name and the (, as Blade's compileStatements does. Forte also skips line breaks there; the view reads such a (…) as template text, and the tape reports that text's syntax errors instead of Forte's (view::Reading::syntax_errors).

Forte pairs a block's directives; the view reads a structure from the block. Where Forte leaves a control structure unpaired (directives side by side in an attribute value, class="a @if($x) b @endif"), the view pairs it among its siblings as Blade's compiler does (view::flat_structures). One it cannot close is not read.

Text Forte leaves unread, the view reads as a template of its own (Emitter::template), as Blade's compiler reads any text:

An escaped echo Forte reads as an echo in raw text (@{{ … }}) is text, its directives compiled.

Patterns

A Blade pattern is parsed with tree::parse_tree like a template. {{ $X }} is an echo whose php leaf is the metavariable; @include($V), a directive whose arguments leaf is.

A pattern Blade reads as text alone (sprintf($F, $$$A)) is PHP and matches the embedded program (language::rule_pattern).

How ast-grep builds a Blade pattern

ast-grep parses a pattern into the language's Doc (Pattern::try_new → Language::build_pattern → PatternBuilder::build(|src| Doc)) and matches node against node. A leaf whose text is a metavariable binds (Language::extract_meta_var, after pre_process_pattern turns $X into the expando form).

PatternBuilder::build accepts any Doc, so the pattern becomes a BladeDoc whose kinds are numbered as in Blade's tapes.

BladeLang writes a metavariable $X as µX (pre_process_pattern, expando µ), so a pattern's PHP variables ($x) stay PHP. Forte reads µX in an echo or arguments as opaque PHP bytes, the leaf's whole text.

Bounds

A step budget caps each parse and nothing recurses, so no template hangs the parser or overflows the stack.

The budget is 256 steps per template byte plus 2^20 (Budget::for_template). Exhausting it is ParseError::Budget, an unknown parse.

How steps are counted, and observed use

Forte bounds nesting (the three depth limits), not work: lookahead scans, directive-token-index searches, terminator searches and recovery retries revisit input, some quadratically in PHP.

Observed maxima: 1.4 steps per byte for a whole parse on the corpora; 2.7 (lexer) and 3.7 (tree builder) on the suite's templates and their prefixes.

The budget ends Forte's quadratic cases: 20,000 unclosed @section('a') (260 KB, each searching to the end for its @endsection), and 200 KB of <? (bogus-comment retries) or of <a <A x (TSX retries).

Recursion, sub-templates and multi-byte text

Forte's two recursions (template literals in ScanPrimitives, and createConditionalComment calling processToken) run on explicit stacks, so nesting costs heap, not thread stack. findLineEnding and quoted-attribute-value scans, which rescan to the end of the source in PHP, are single forward passes that find the same positions.

The view's sub-templates (text Forte left unread, above) nest at most 4 deep (real templates need 2); deeper ones are read as text, keeping work linear and recursion shallow. The view polls the tape's budget before each one.

Forte lexes bytes and can end a text node inside a multi-byte character (<a é@b)); the view widens every node to whole characters before slicing.

Robustness tests

forte_survives_random_and_truncated_templates parses, with both registries:

All 8,036 inputs (16,072 parses) stay within budget, every token and node inside the template and the token array. It also runs 13 inputs nesting 20,000 deep (elements, components, conditions, loops, sections, @php, echoes, open parentheses, template literals in attributes and scripts, conditional comments). Each ends as Forte 1.1.0 ends it (its element-depth exception, or a tree), except the unclosed sections above.

tree::tests::trees_views_and_tapes_survive_random_and_truncated_templates runs the same inputs, plus 20,000 nested <script>s holding a component tag or a @php block, through the tree, view and tape. The tree keeps its invariants, its syntax errors stay inside the template, and the tape encodes and round-trips.

License

The port is a derived work of MIT-licensed Forte, recorded in the notices.

Forte is MIT (Copyright (c) Johnathon Koster). THIRD_PARTY_NOTICES lists its license for the port (native/src/code_rule_dsl/blade/forte/, a derived work) and for the test material (fixtures/blade/licenses/forte/: Forte's license verbatim and UPSTREAM.md, naming the suite's templates and the directive JSON copied). The module doc of forte/mod.rs states the derivation.

Oracle

A PHP script records real Forte's output for the port to match.

fixtures/blade/blade/forte/generate.php runs Forte 1.1.0 with laravel/framework 13.34.0 on PHP 8.5.11. Per template and registry it records tokens, flat nodes, lexer errors and AST reads, or the depth exception.

Recorded files and flags

Forte and laravel/framework come from one Composer project holding both. A record holds the flat nodes with their extras and what the AST reads above return; forte_tree.php documents it.

t1_reference.php counts, per corpus, the templates each reference parser reports an error in.

Parity

The port's output is Forte's, byte for byte, on every recorded template: tokens, flat nodes with extras, lexer errors, AST reads and depth exceptions.

Oracle Trees Differences
Forte's test suite (suite.expected.json, prefixes included; FNV digests) 83,698 0
the same, every tree spelled out (generate.php --full) 83,698 0
Blade fixtures and grammar-gap repros, both registries 64 0
corpus samples, 25 templates × 2 registries per corpus 4 × 50 0
every corpus template × 2 registries (benchmark_forte_corpus): BookStack / Filament / Bagisto / laravel 650 / 394 / 1,108 / 254 0
Per-stage and depth-limit checks

Each stage alone (FORTE_PARITY=lexer|parser|ast) also shows 0 differences over the full suite and corpus dumps: the lexer from the template, the tree builder from Forte's tokens, the AST reads from Forte's tokens and nodes. The three depth limits agree with Forte on 153 template × limit × registry combinations, from 1/1/1 to 100,000/256/256.

T1: templates with an error

The port flags exactly Forte's templates, far fewer than tree-sitter-blade did.

Corpus Templates Port Forte Forte, accept-all BladeCompiler + php -l tree-sitter-blade
BookStack f585a7d910 325 0 0 0 0 218
Filament ca4f55bb5e 197 0 0 0 0 25
Bagisto 635cab68b3 554 18 18 18 0 258
laravel/framework e5f3d6265f 127 0 0 0 0 21

Bagisto's 18 are one Forte false positive, kept for parity: a @php … @endphp inside a <script type="text/x-template">.

What counts as an error, and why Forte flags the Bagisto templates

The <script type="text/x-template"> content is raw text. Forte's lexer emits the @php opener and keeps the raw text up to </script>, @endphp included, as text. It then reads the rest of the file as the PHP block and logs UnexpectedEof at the end. BladeCompiler compiles @php before any HTML and accepts them.

This is not a port bug. The view reads these templates as Blade does (above); a rule over the template's tree misses the nodes Forte swallowed into the block.

Performance

The port parses 4 to 24 times as fast as tree-sitter-blade, using a fraction of its memory.

Parser Speed vs tree-sitter-blade Peak memory vs tree-sitter-blade
port: lexer and tree builder (Forte's flat arrays) 12 to 24 times 26 to 39 %
port + tree: plus Rerail's tree (tree::parse_tree), tree-sitter's counterpart 4 to 7 times 45 to 65 %

Measured by benchmark_forte_throughput (release build, every template of a corpus, one process per parser and corpus) on the four pinned corpora: throughput and the growth of peak resident memory while parsing, one tree alive at a time.

Wiring

The port plugs into the kind catalog, tapes, the language registry, build identity and the rules.

Details per integration point

Results

On the pinned corpora Forte reads more of what Blade copies than tree-sitter-blade did, and the Blade pack finds more with no false positive.

Per-corpus results against tree-sitter-blade

Forte against tree-sitter-blade on the same rules and oracles, over the pinned corpora (the Bagisto, BookStack and Filament checkouts and laravel's 127 templates). Syntax errors are in T1.

Check BookStack Filament Bagisto laravel
tape ⇄ tree parity (benchmark_blade_worktree_oracle_parity): templates, differences 325, 0 197, 0 554, 0 127, 0
bytes Blade copies the view reads (benchmark_blade_region_parity), tree-sitter → Forte 146,417 → 146,469 of 146,469 181,426 → 181,895 of 181,895 655,190 → 655,672 of 655,672
region disagreements, tree-sitter → Forte 6 → 0 51 → 0 76 → 36
reads decided against PHPStan (benchmark_blade_definedness_corpus), tree-sitter → Forte 569 → 656 2,594 → 2,623 2,056 → 2,609
contradictions with PHPStan, tree-sitter → Forte 0 → 0 13 → 9 1 → 1

Bagisto's 36 remaining region disagreements are one class tree-sitter-blade had too: @{{ … }} inside a directive's string argument, which Blade copies without its @. The contradictions are a subset of tree-sitter-blade's.

Blade pack conformance

The Blade pack's conformance (npm run conformance:languages -- --pack blade) passes on the fixtures and the three corpora with no false positive. Seven fixture divergences tree-sitter-blade caused are gone, and true positives rise:

The remaining false negatives are in the 18 Bagisto templates above.