This document provides a comprehensive architectural specification of the textparser engine based on the reference C codebase (the "golden standard"). It explains the internal design, data structures, algorithms, lifecycle, and execution pipelines.
Developers creating or updating ports (Python, Rust, Java, Go, C#, etc.) must use this document and the C implementation in src/textparser.c, src/textparser-json.c, and include/textparser.h as the authoritative reference behavior and contract.
textparser is an incremental syntax lexer, CST/AST constructor, and declarative grammar parsing engine designed for compilers, static analysis tools, linters, and syntax highlighters.
The engine operates on two complementary levels:
- Contextual Tokenization & Incremental CST Engine (Phase 1–3): Fast regex/state-machine-driven lexer that builds linked token trees, tracks source ranges, supports trivia (whitespace, comments), and performs sub-millisecond incremental re-parsing upon text edits.
- Declarative EBNF Grammar & Pratt Expression Engine (Phase 4–6): A transactional packrat/recursive-descent grammar executor supporting structured productions (
sequence,choice,optional,repeat,lookahead,commit), scoped contexts, declarative guards and native predicates, Pratt top-down operator precedence expression parsing, robust synchronization recovery, and semantic lifecycle events.
flowchart TD
subgraph Input ["Input & Buffer Layer"]
A["Source File / In-Memory Buffer"] --> B["BOM & Encoding Detection\n(UTF-8, UTF-16, UTF-32, Latin1)"]
B --> C["Unit-Indexed Buffer Slice"]
end
subgraph Lexer ["Contextual Lexer & Streams"]
C --> D["Contextual Regex Engine (PCRE2 / Fast Matchers)"]
D --> E["Lexer Modes & Lexical Goals\n(pushMode / popMode)"]
E --> F["Token & Trivia Streams\n(textparser_lex_token / textparser_lex_trivia)"]
end
subgraph Grammar ["Grammar & Expression Engine"]
F --> G["Declarative Grammar Executor\n(textparser_execute_production)"]
G --> H["Speculative Parsing & Checkpoints\n(save / commit / rollback)"]
G --> I["Pratt Expression Engine\n(Infix, Prefix, Postfix, Ternary)"]
G --> J["Error Recovery & Diagnostics\n(Synchronization & ASI)"]
end
subgraph Output ["Output CST / AST & Events"]
G --> K["Concrete Syntax Tree (CST)\n(textparser_token_item arena)"]
K --> L["Lifecycle Events\n(VALIDATE -> COMMIT -> SOURCE_COMPLETE)"]
K --> M["Downstream Frontends / AST Transpilers\n(tsc23, ccat, Linters)"]
end
The golden C definitions reside in include/textparser.h.
The main handle (struct textparser_handle) encapsulates all parsing context, state, and resources:
- Buffer & Encoding: Pointer to mapped or allocated text buffer (
text_addr), length (text_size), character encoding enum (text_format), BOM mask (bom), and optional file path (filename). Note thatTEXTPARSER_ENCODING_UTF_16andTEXTPARSER_ENCODING_UTF_32operate on native host-endian code units (uint16_t/uint32_t), ensuring full compatibility across little-endian and big-endian (e.g. s390x) architectures. - Line Index Map: Cached line starts (
lines,no_lines) for O(log N) position-to-line/column mapping. - Arena Memory Allocator: Chunks (
chunks,chunk_count,current_chunk) allocating CST nodes and allocations with instant mass-free or checkpoint rollback. - Lexer Mode Stack & Goals: Transient lexical modes (
mode_stack,mode_stack_depthup to 64) and active lexical goal (lexical_goal). - Lexer Cache & Snapshots: Immutable arrays of scanned syntax tokens (
lexer_tokens) and trivia items (lexer_trivia). - Scoped Contexts & Predicates: Linked lists of scoped variables (
contexts) and registered native predicate functions (predicates). - Operator Precedence Table: Registered Pratt operator rules (
operators,operator_count). - Diagnostics Vector: Collected errors, warnings, and hints (
diagnostics,diagnostic_count). - Memoization Table: Packrat memoization entries (
grammar_memo) keyed by(token_index, production_id). - Transactional State Snapshot: Active runtime cursors (
parser.source_offset,parser.token_index,parser.speculation_depth).
The CST is represented as a doubly-linked n-ary tree:
typedef struct textparser_token_item {
struct textparser_token_item *prev; // Sibling before
struct textparser_token_item *next; // Sibling after
struct textparser_token_item *child; // First child
struct textparser_token_item *parent; // Enclosing parent node
int token_id; // Token ID integer
size_t len; // Node length in encoding units
uint32_t text_color; // Theme foreground
uint32_t text_background; // Theme background
uint32_t text_flags; // Formatting flags
const char *error; // Node-specific error message
/* Enhanced CST / AST properties */
uint64_t id; // Monotonic stable 64-bit ID
uint32_t node_flags; // Bitfield (SYNTHETIC, MISSING, RECOVERED, TRIVIA)
const char *decoded_value; // Decoded literal (e.g. unescaped string)
void *user_data; // Host language / compiler payload
void (*free_user_data)(void *); // Destructor for user_data
/* Schema-v2 CST identity and exact half-open source span */
const char *cst_kind; // Canonical production / AST kind name
size_t source_start; // Start unit offset
size_t source_end; // End unit offset [source_start, source_end)
textparser_cst_category category; // Definition metadata; UNKNOWN uses structural fallback
} textparser_token_item;Node identity: id is assigned from a per-handle monotonic counter
(++handle->next_node_id) and is never reused or compacted. This is deliberate,
not a leak: id is uint64_t, so wraparound would require 2^64 allocations, and
a stable ID lets callers key their own maps/attachments (user_data, AST nodes)
to a node across edits. In-place arena compaction (ROADMAP §1.3) preserves id;
reusing orphaned IDs would risk stale-ID collisions. id is distinct from
token_id, which is the grammar token kind.
TEXTPARSER_NODE_SYNTHETIC(1 << 0): Synthesized node (e.g. ASI semicolon).TEXTPARSER_NODE_MISSING(1 << 1): Required grammar element that was absent and inserted for recovery.TEXTPARSER_NODE_RECOVERED(1 << 2): Node enclosing skipped malformed source text during synchronization.TEXTPARSER_NODE_TRIVIA(1 << 3): Whitespace or comment node attached as trivia.TEXTPARSER_NODE_GRAMMAR_POSTFIX(1 << 4): Intermediate expression tree markers.
CST category metadata is stored as textparser_cst_category on both production
records and emitted container nodes. Schema-v2 productions and inline constructs
accept an optional category: unknown, token, source_file, declaration,
statement, expression, type, jsx, pattern, or other:
{"sequence": [{"token": "Identifier"}], "category": "declaration"}The C API textparser_node_get_category(node) reads that metadata without
requiring a parser handle. Container creation copies the production category;
when a named choice renames an anonymous sequence, it also replaces its category.
Transparent references and choices that return an existing child retain that child's
category; token productions retain the token fallback. Omitted or unknown metadata
uses generic structural defaults: leaves are TOKEN, non-synthetic nodes with
children are EXPRESSION, and other containers or missing tokens are OTHER.
A null node returns UNKNOWN. No category is inferred from the language name or
CST kind spelling.
Productions and inline constructs can also specify astKind (e.g. "astKind": "VariableDeclaration")
to override the emitted CST node kind independently of the production rule name. When omitted,
emitted CST kinds use the production rule name or structural defaults.
TypeScript family assignments live in definitions/typescript_definition.json;
the category API contains no hardcoded language-specific classification rules.
When lexing completes, tokens and trivia are organized into two contiguous immutable vectors:
-
textparser_lex_token:kind: Token ID.start,end: Source half-open range[start, end)in encoding units.leading_trivia_start: Index into the trivia vector for preceding whitespace/comments.leading_trivia_count: Number of trivia elements attached before this token.mode,lexical_goal: Lexer mode and goal under which this token was scanned. The snapshot builder reconstructsmodeby replaying the lexer rulepop_mode/push_modetransitions in document order (so it matchestextparser_contextual_scan_one);lexical_goalis the default goal (0) for the legacy/incremental parse path, which has no grammar-scoped goal.flags: e.g.TEXTPARSER_LEX_FLAG_CONTAINS_LINE_TERMINATOR.decoded_value: Cached unescaped string or decoded payload.
-
textparser_lex_trivia:kind: Whitespace or comment token ID.start,end: Half-open span.flags: ContainsTEXTPARSER_LEX_FLAG_CONTAINS_LINE_TERMINATORif newline present.
The two vectors are owned by the handle and their capacity is retained across
incremental edits. A rebuild re-fills the existing buffers instead of freeing
and reallocating, and the in-leaf resize fast path patches the snapshot in place
(see §4.1 step 0). The buffers are only released on close or textparser_set_text.
Language definitions can be loaded from JSON via textparser_json_load_language_definition_from_json_file() or compiled into static C headers using json2h.py.
The structure (textparser_language_definition) contains:
- Metadata:
name,version,defaultTextEncoding,supportedBom,defaultFileExtensions. - Lexer Config:
tokens: Map of token rules withstartRegex,endRegex,priority,isTrivia,pushMode,popMode.modes: Named token subsets active only when that mode is on top of the mode stack.goals: Goal name mappings that remap scanned token IDs (e.g. remap/fromSlashTokentoRegularExpressionLiteral).
- Grammar Productions (
textparser_grammar_definition):start_production: Entry-point production ID.productions: Array of declarative productions (textparser_production).
- Operator Definitions (
textparser_operator_def):- Operators with role (
INFIX,PREFIX,POSTFIX,TERNARY), numeric precedence (binding power), associativity (LEFTorRIGHT), and optional secondary token (e.g.:for?).
- Operators with role (
- Recovery Policy:
maximumDiagnostics,maximumSkippedTokens,maximumRecoveryAttempts, and globalsynchronizationTokens.
When text is edited, textparser_parse_incremental performs localized re-lexing:
0. In-leaf resize fast path: If the edit lies entirely within one leaf and
re-matching that leaf's pattern at its start still consumes exactly the
edited leaf length, only the leaf's length and its ancestors' lengths are
updated; no re-scan happens. This prevents a greedy leaf (for example JSON
StringContent) from being split into two adjacent leaves or from dropping
its unchanged prefix. out_range covers the resized leaf. On a raw CST the
retained lexer snapshot is patched in place (the edited entry's end and every
later entry's offsets shift by the delta; kinds, trivia indices and modes are
untouched); on a post-processed AST the snapshot is rebuilt instead, because
post-processing may have changed token kinds relative to the snapshot. This
applies to pure inserts, deletes, and same-length replacements that keep the
leaf's pattern intact; anything else falls through to the steps below.
- Text Splicing: Updates the internal memory buffer by inserting or deleting the delta range.
- Context Resolution: Anchors at the root and re-tokenizes the
top-level token containing the edit. Anchoring at the nearest container (or
its parent) was insufficient because a token's match can depend on text
outside its span (the JSON
Keylookahead over its:sibling) or on its own start/end delimiter (a CFMLOutputStartTaginside anOutputTagPair, where an edit in the tag name invalidates the pair). Re-tokenizing from the root re-evaluates every ancestor; the alignment below keeps the unchanged nodes, so tree mutation stays localized. Because the anchor is the root, the reparse always starts in the language's initial lexical state: the parser clears anymode_stack/lexical_goalleft over from a previous parse ortextparser_execute_production(textparser_reset_lexical_state) before re-tokenizing, so the anchor is state-safe for context-sensitive lexing. - Token-boundary dirty region: The reparse window starts at the first
sibling of the anchor container, skips the container's start delimiter, and
extends to the last child before the container's end delimiter.
Re-tokenizing from the first sibling is required because a start token's end
search has unbounded lookahead, so an edit can retroactively change the
extent of an earlier top-level token (chained-edit fuzzing,
tmp/fuzzchain.cpp). Re-tokenizing to the container end lets an overrun (greedy token or newly opened container) resync with the old suffix; the alignment below keeps the unchanged prefix/suffix nodes, so tree mutation stays localized. The container's ownotherTextInsideis used, not the language-level flag. - Local Re-scan: Re-lexes the window with the anchor's nested token rules,
collecting the new sibling run into the per-handle scratch arena
(
arena_reset(&handle->scratch)at entry; no per-call heap traffic). A failed candidate inside a nestedotherTextInsidecontainer emits one unit asUnprocessedand continues (matching the full parser); top-level errors link the partial token and fail. - Suffix resync: A greedy token or newly opened container can consume text past the old dirty end. The old suffix anchor is advanced to the token at the reparse's actual end (mapped back to old coordinates), so no overlapping or malformed leaves are stitched.
- Alignment / splice: The old and new runs are aligned by kind, span, and
mapped position. The unchanged common prefix and suffix keep their existing
nodes; only the middle is replaced (add/delete/change on the linked list).
This is what keeps
out_rangetight and preserves node identity. Sign merging runs after the splice so re-linking cannot undo it. - Dirty Range Calculation: Computes
out_range(dirty_start,dirty_end) from the aligned changed span for editor syntax highlight invalidation. - Memoization Shift: The dirty lexer-token range is derived from the
actually-changed tokens (
old_run[prefix .. count-suffix)), not the reparse window, so unchanged suffix tokens keep their packrat memo entries. Shifts or invalidates entries intersecting that range (textparser_memo_shift_and_invalidate). - AST mode re-derivation: If the current tree was previously passed to
textparser_post_process, the engine detects that (via the internalTEXTPARSER_NODE_POST_PROCESSEDmarker and/or synthesized0x80000000wrapper nodes), flattens the synthesized wrappers back into a raw CST before the splice (freeing the heap wrappers), and re-runstextparser_post_processon the updated tree. A tree that was never post-processed stays a raw CST, so the documentedtextparser_parse/textparser_parse_incrementalcontract is unchanged. A full-document reset edit preserves whichever mode the tree was in.
Arena reclamation: add/delete edits orphan the replaced nodes, which remain
resident until a full parse resets the arena. An abortable in-place
textparser_compact() for idle-time reclamation is specified in ROADMAP.md
§1.3 (not yet implemented).
Result: the incremental tree matches a full parse for all successful
single-character inserts and deletions on JSON, CFML, C, and JavaScript samples
(0 mismatches), with no incremental failures on valid input. Chained-edit
fuzzing on malformed input is down to 3 divergences (JSON 0/2980, CFML 0/2997,
C 1/2441, JavaScript 2/2494). All 4 phases of ROADMAP.md §1.5
(Sub-linear edit window) are implemented:
- Phase 1 (bounded forward-resync): after emitting each new leaf token past
the dirty edit window, the engine compares it against the corresponding old
sibling. When the two tokens match (same
token_id, samelen, both pure leaves — containers are excluded to preserve lookahead-dependent reclassifications),end_posis clamped and the reparse stops early. - Phase 2 (hierarchical position descent & span index):
span_lentracking ontextparser_token_itemand bounds-pruned hierarchical descent infind_token_at_position_internal. - Phase 3 (position bias for in-leaf snapshot shifts): in-leaf edits
accumulate suffix offset changes lazily into
lexer_snapshot_biasin O(1), avoiding O(n) passes over the snapshot on consecutive keystrokes until read or rebuilt. - Phase 4 (scoped incremental AST post-processing): re-derivation of Pratt expression trees, template groups, and disambiguation is scoped to the modified subtree container when isolated.
The differential tests in incremental_tests.cpp (DifferentialAgainstFullParse,
AllStructuralEditsMatchFullParseExactly,
CrossLanguageInsertsAndDeletesMatchFullParse) guard against regressions.
Post-processed ASTs are covered separately by
ProcessedAstIncrementalMatchesFullParse, IncrementalAstPostProcessScopedToEditWindow,
ExpressionPostProcessReDerivedOnIncrementalEdit,
CastDisambiguationReDerivedOnIncrementalEdit,
TemplateDisambiguationReDerivedOnIncrementalEdit,
PostProcessModeStickyAcrossFullReset, and RawCstStaysRawAfterIncrementalEdit.
Full-parse performance is O(n) in the input size. Two O(n^2) traps must stay
closed: PCRE2 must not re-validate the whole subject on every match (the
thread-local adv_regex_thread_utf8_valid fast path set by
adv_regex_set_utf8_valid, which also covers the generated per-token search
functions' private contexts), and the single-line search bound is cached per
line (line_cache_anchor / line_cache_end in the handle, reset each parse).
The grammar executor executes declarative EBNF productions recursively or via dispatch:
Production Kind (textparser_production_kind) |
Semantic Behavior |
|---|---|
TEXTPARSER_PROD_TOKEN |
Matches and consumes a specific token ID from the lexer stream. Fails if the current token does not match. |
TEXTPARSER_PROD_REF |
Calls another production by referenced ID. Supports memoization. |
TEXTPARSER_PROD_SEQUENCE |
Matches an ordered list of child productions sequentially. If any child fails, the whole sequence rolls back (unless committed). |
TEXTPARSER_PROD_CHOICE |
Tries alternatives in order. Takes a checkpoint before each alternative. If an alternative fails without committing, rolls back and tries the next. |
TEXTPARSER_PROD_OPTIONAL |
Attempts child production once. If it matches, consumes; if not (and not committed), rolls back and succeeds without consuming. |
TEXTPARSER_PROD_REPEAT |
Repeatedly executes child production zero-or-more times until mismatch. Enforces forward progress to prevent infinite loops. |
TEXTPARSER_PROD_LOOKAHEAD |
Executes child production speculatively without advancing the parser cursor. Always rolls back token consumption. |
TEXTPARSER_PROD_NOT |
Negative lookahead. Succeeds only if child production fails; fails if child succeeds. |
TEXTPARSER_PROD_PREDICATE |
Evaluates a generic guard or a registered native callback without consuming tokens. A false condition fails the branch. |
TEXTPARSER_PROD_CONTEXT |
Pushes/sets a scoped context flag (e.g. AllowAwait = 1), executes child, and restores prior context value on exit. |
TEXTPARSER_PROD_COMMIT |
Marks the current choice branch as committed. Once passed, subsequent syntax errors in this branch cannot backtrack to sibling choices. |
TEXTPARSER_PROD_PRATT |
Invokes the Pratt expression parser with a given minimum binding power. |
TEXTPARSER_PROD_LEXICAL_GOAL |
Sets transient lexical goal while parsing child production. |
Declarative productions and inline constructs can specify when conditions for non-consuming lookahead evaluation without registering native C callbacks:
{"when": {"noLineTerminatorBefore": true}}
{"when": {"nextTokenIn": ["Dot", "Semicolon"], "allowEOF": true}}
{"when": {"nextToken": "Identifier", "nextTokenText": "meta"}}- Conjunction: Fields within a single
whenguard combine with logical AND. - Token Matching:
nextTokenandnextTokenInmatch token kind IDs.nextTokenTextchecks exact, case-sensitive raw spelling (not decoded identifiers) across UTF-8, UTF-16, and UTF-32 source encodings. - EOF Handling: Token guards reject EOF by default unless
allowEOF: trueis explicitly enabled. A text condition (nextTokenText) always rejects EOF. - Trivia & Newline Guards:
noLineTerminatorBeforeandlineTerminatorBeforeinspect leading trivia before the candidate token. EOF is treated as having no preceding newline. SettinglineTerminatorBefore: falseinverts the condition. - Source File Profiles: Languages can define profile extensions under
grammar.sourceFileKinds:"sourceFileKinds": { "jsx": [".tsx", ".jsx"], "javascript": [".js", ".jsx", ".mjs", ".cjs"] }
{"when": {"sourceFileKind": "jsx"}}matches any suffix in that profile, ignoring ASCII case (unnamed buffers match no profile). - Negation & Alternation: Negate any guard using
{"not": {"when": ...}}; alternative guards use standardchoiceconstructs. - Lifecycle & Execution: Generic
whenconditions compile to an optionaltextparser_guardontextparser_production. The loader resolves token names to IDs and copies profile suffixes into the guard; the executor evaluates them directly without string lookups or JSON parsing. Native callbacks remain supported as standalonenativepredicates.
Backtracking and speculative branching require absolute transactional isolation. A speculative failure must not leak memory, modified cursors, or orphan diagnostics.
Checkpoints (textparser_speculate_begin, textparser_speculate_commit, textparser_speculate_rollback)
A checkpoint stores a complete snapshot of parser state:
source_offsetandtoken_index- Lexer mode stack depth and lexical goal
- Scoped context variable map
- Arena memory allocation watermark (
textparser_arena_checkpoint) - Diagnostic count (discards diagnostics recorded in aborted branches)
- Pending lifecycle events count
- Active node ID counter
void *cp = NULL;
textparser_speculate_begin(handle, &cp);
result = parse_alternative(...);
if (result.status == TEXTPARSER_MATCH_OK) {
textparser_speculate_commit(handle, cp);
publish_events();
} else if (result.committed) {
// Committed: do not backtrack to another choice; report syntax error
textparser_speculate_commit(handle, cp);
} else {
// Ordinary mismatch: rollback everything
textparser_speculate_rollback(handle, cp);
}Expression parsing uses Pratt parsing (textparser_parse_pratt_expression / parse_expression) parameterized by operator binding powers.
TEXTPARSER_OP_PREFIX: Unary operators evaluated before operand (!,-,~,typeof,await).TEXTPARSER_OP_POSTFIX: Unary operators evaluated after operand (++,--,!).TEXTPARSER_OP_INFIX: Binary operators (+,-,*,/,&&,||,===,as).TEXTPARSER_OP_TERNARY: Conditional operators (? ... :) using a secondary terminator token.
left = parse_prefix_or_primary(parser);
while (has_next_operator(parser)) {
op = peek_operator(parser);
if (op.precedence < min_precedence)
break;
consume_operator(parser);
if (op.role == TEXTPARSER_OP_TERNARY) {
middle = parse_expression(parser, 0);
consume(op.secondary_token_id); // e.g. ':'
right = parse_expression(parser, op.precedence - (op.associativity == TEXTPARSER_ASSOC_RIGHT ? 1 : 0));
left = make_ternary_node(op, left, middle, right);
} else if (op.role == TEXTPARSER_OP_POSTFIX) {
left = make_unary_postfix_node(op, left);
} else { // INFIX
next_min = op.precedence + (op.associativity == TEXTPARSER_ASSOC_LEFT ? 1 : 0);
right = parse_expression(parser, next_min);
left = make_binary_node(op, left, right);
}
}
return left;The engine avoids stopping at the first error or generating cascading diagnostic storms:
- Automatic Semicolon Insertion (ASI):
- Semicolons can be omitted in JavaScript/TypeScript if at a newline (
has_preceding_line_terminator), before a closing brace}, or at EOF. - The parser inserts a synthetic node marked
TEXTPARSER_NODE_SYNTHETIC | TEXTPARSER_NODE_MISSINGwith zero width.
- Semicolons can be omitted in JavaScript/TypeScript if at a newline (
- Missing Token Insertion:
- If a required token is missing but the following tokens can proceed cleanly, a synthetic missing node is inserted.
- Synchronization Recovery (
textparser_recover_until_token):- On production mismatch, the parser scans forward until encountering a token in the production's synchronization set (e.g.
;,},class,function). - Skipped source tokens are grouped into a node marked
TEXTPARSER_NODE_RECOVERED.
- On production mismatch, the parser scans forward until encountering a token in the production's synchronization set (e.g.
- Furthest-Failure Diagnostics:
- If all choices in a union fail, the engine suppresses early dead-end errors and reports a diagnostic only at the furthest source offset reached across all speculative branches.
Tokens can supply a display spelling, and a language definition can specify diagnostic codes and message templates:
"diagnostics": {
"expected": {"code": "E_EXPECTED", "message": "Expected %s."},
"tokenExpected": {"code": "E_TOKEN", "message": "'%s' expected."},
"recovered": {"code": "E_RECOVERED", "message": "Recovered while parsing %s."}
}For example, a lexer token can contain "spelling": ";". Tokens may override
diagnostics.expected; productions and inline constructs may override
diagnostics.expected and diagnostics.recovered. Each template requires a
nonempty code and message.
- Precedence: Expected-error precedence is production override -> token override -> language
tokenExpected(when a spelling exists) -> languageexpected. Recovery uses the production'srecoveredoverride or the language default. - Defaults: With no configured template, default
TEXTPARSER_EXPECTED/TEXTPARSER_RECOVEREDapply; a token spelling supplies'<spelling>' expected. - Formatting: Templates accept at most one
%sand any number of%%escapes.%sreceives the token spelling for expected-token errors, otherwise the expected element name or description. Recovery always uses the element description. A bounded formatter expands literal%and text without using definition strings asprintfformats, adhering to a 255-byte limit. - Language Independence: Diagnostic reporting (
textparser_grammar_report_expected()), grammar synchronization recovery, and trailing unconsumed token diagnostics are completely language-independent. There are no language-specific checks hardcoded insrc/textparser.c; diagnostic templates, spellings, recovery synchronization tokens, and boundary detection are driven entirely by declarative language definitions (definitions/*.json).
To allow downstream compilers (e.g. tsc23) to transform the generic CST into semantic ASTs without embedding compiler-specific code in the parser core, textparser emits four lifecycle events:
TEXTPARSER_EVENT_VALIDATE: Fired when a production matches. Must be purely side-effect free; can returnTEXTPARSER_ACTION_ACCEPTorTEXTPARSER_ACTION_REJECT.TEXTPARSER_EVENT_COMMIT: Fired once an enclosing speculative branch has successfully committed. State mutations are safe here.TEXTPARSER_EVENT_RECOVERY: Fired when error recovery triggers or synthesizes nodes.TEXTPARSER_EVENT_SOURCE_COMPLETE: Fired once the root start production completes and reaches EOF.
Lifecycle events are queued during speculative parsing and flushed only after the surrounding branch commits (textparser_publish_pending_events).
Pratt expression operand validation and AST early-error legality checks are decoupled from the core parser into pluggable validators and language validation modules:
- Pluggable Operand Validators: Registered via
textparser_register_operand_validator(handle, name, fn, user_data). Pratt productions specify"validateOperand": "typescript.assignmentTarget"or"typescript.updateTarget"in JSON without embedding language-specific AST inspection insrc/textparser.c. - Pluggable Token Validators: Tokens in language definitions can specify
"validator": "validator_name"(e.g.,"validator": "typescript.identifier"onIdentifierandPrivateIdentifier). The contextual lexer automatically invokestextparser_validate_token()during candidate rule evaluation. - Language Validation Modules: Semantic validation logic is implemented in language-specific libraries
under
src/validation/(such aslibtextparser_typescript.so,libtextparser_php.so,libtextparser_cfml.so,libtextparser_html.so,libtextparser_css.so,libtextparser_sql.so,libtextparser_json_val.so,libtextparser_md.so,libtextparser_r.so,libtextparser_asm.so), exposing registration functions (e.g.textparser_typescript_register_validators(handle),textparser_sql_register_validators(handle),textparser_json_register_validators(handle),textparser_md_register_validators(handle),textparser_r_register_validators(handle),textparser_asm_register_validators(handle)) and diagnostic extractors (e.g.textparser_validate_typescript(handle),textparser_validate_sql(handle),textparser_validate_json(handle),textparser_validate_md(handle),textparser_validate_r(handle),textparser_validate_asm(handle)). - Event Lifecycle Dispatch: Post-parse legality checks are triggered dynamically via grammar definition events
(e.g.
"events": { "onSourceComplete": "typescript.legality" },"events": { "onSourceComplete": "json.legality" },"events": { "onSourceComplete": "md.legality" },"events": { "onSourceComplete": "r.legality" }, or"events": { "onSourceComplete": "asm.legality" }).
When creating or maintaining a port of textparser:
- Golden Standard Compliance:
- The C codebase is the normative behavioral reference. Output AST structures, token spans, node flags, and diagnostics must match the C engine byte-for-byte.
- Memory & Arena Management:
- C handles memory via chunked arenas. High-level ports (Rust, Python, Java) should either bind directly to
libtextparservia FFI or replicate the transactional checkpoint/rollback semantics if writing a standalone implementation.
- C handles memory via chunked arenas. High-level ports (Rust, Python, Java) should either bind directly to
- Contextual Tokenization:
- Do not lex the entire source into a flat, stateless token array upfront. Languages like TypeScript and CFML require dynamic mode changes (
pushMode,popMode) and lexical goals (ExpressionStartvsExpressionContinuation) during scanning.
- Do not lex the entire source into a flat, stateless token array upfront. Languages like TypeScript and CFML require dynamic mode changes (
- Transactional Rollback:
- Speculative choice branches must discard all created nodes, errors, and mode mutations if the branch fails.
- Differential Verification:
- Validate port implementations against test suites in
tests/and fixture suites undertests/docker/fixtures/.
- Validate port implementations against test suites in
The C engine synthesizes additional AST leaves that ports must reproduce. Both
the C JSON serializer (cli/main.c) and the Java TokenItem serializer hide
leaf Whitespace nodes, so the visible contract is defined by the following
rules (the in-memory CST still contains them):
- Start/end delimiters:
- Every
StartStop/StartOptStopmatch begins with aStartDelimiterleaf (the matched start regex span) and, when an end token is found, ends with anEndDelimiterleaf (the matched end regex span). - The end delimiter takes priority over a nested child that starts at the same
offset. This is why
**Bold**parses as a single styled span rather than as aBoldcontainer containing anItalicchild.
- Every
- Unprocessed text:
- Text between delimiters that is not consumed by a nested token becomes
Unprocessedleaves. - When the token has nested tokens, content is split on whitespace (one
Unprocessedleaf per whitespace-separated run); the whitespace itself is represented internally as hiddenWhitespaceleaves. - When the token has no nested tokens, the content is emitted as a single
Unprocessedspan that includes internal and trailing whitespace. Leading whitespace is skipped (hiddenWhitespaceleaf), so the span starts after it. - At the top level, unmatched text is likewise split into whitespace-separated
Unprocessedleaves.
- Text between delimiters that is not consumed by a nested token becomes
- Delimiter pruning:
- When a start/stop token has no custom delimiter styling
(
delimiterTextColor,delimiterTextBackground,delimiterTextFlagsall unset), the synthesized leaves are pruned only if the direct children are exactly[StartDelimiter, Unprocessed, EndDelimiter]or[StartDelimiter, EndDelimiter], or a singleUnprocessedspan covering the whole token. - Because hidden
Whitespaceleaves count as direct children, any whitespace between the delimiters prevents pruning. Ports must therefore materialize whitespace nodes (or otherwise track whether whitespace was skipped) before applying the prune rule. The Java port materializes them, so it uses the same exact-children checks as C. - Custom delimiter styling disables pruning entirely.
- When a start/stop token has no custom delimiter styling
(
- Multi-line validation:
- After a token is parsed, if its definition does not set
multiLinebut its span contains\nor\r, the parse must fail with "Token spans multiple lines but multi_line flag is not set!". - This validation runs on every token, including
GroupOneChildOnlywrappers around a multi-line child.
- After a token is parsed, if its definition does not set
- Speculative error recovery:
- Nested-token attempts are speculative. If a nested attempt fails (throws /
sets an error), the partial result is discarded. When the enclosing token
allows arbitrary text (
otherTextInside) and the failure did not occur at end-of-text, the offending character is emitted as anUnprocessedleaf and parsing continues from the next character. - At the top level, if every candidate start token fails, the first failure is
reported as fatal (the parse is aborted) rather than being silently replaced
by
Unprocessed. - When
otherTextInsideis false and the top-level loop stops before consuming the whole input, the remaining input is emitted as a singleUnprocessedspan. - Candidate fallback: when a candidate start token (or nested child) fails with an error, the first such failure and its diagnostic are remembered, but the error state is cleared before the next candidate so the remaining candidates are still tried. The remembered failure is only re-applied when no candidate succeeds. The arena checkpoint for the failing candidate is intentionally not restored, so the remembered node stays valid. (This was a bug in C: the error leaked into later candidates, so the first failing candidate poisoned all others.)
- Work budget:
textparser_parse_tokencounts calls and aborts with "Parse complexity limit exceeded!" once a generous multiple of the input size (text_size * 200 + 100000) is exceeded. The candidate retry loop is exponential on deeply nested ambiguous input, so this bounds the worst case: a valid parse is O(text) and never reaches it, while pathological input terminates with an error instead of hanging. Once exceeded the budget is fatal —otherTextInsiderecovery is disabled so the parse does not continue one character at a time.
- Nested-token attempts are speculative. If a nested attempt fails (throws /
sets an error), the partial result is discarded. When the enclosing token
allows arbitrary text (
- Anchored matching and single-character advance:
textparser_find_tokencompiles start patterns withPCRE2_ANCHOREDand returns the first capture group's offset relative to the current position (normally0). It never scans ahead. A port must therefore check whether a token matches at the current offset only.- Container loops (
parse_token_group,parse_token_start_stop,parse_token_group_all_children_in_same_order) attempt a nested token only whenfind_token(...) == 0; otherwise, whenotherTextInsideis set, they emit one character asUnprocessedand advance by one. This is what makes the engine linear in the input size. Scanning ahead for the nearest match (as an earlier Java implementation did) is both slower (O(n²)) and behaviourally different. GroupOneChildOnlyfirst tries candidates at offset0; only if none succeed does it look for the smallest positive capture-group offset, emit that prefix asUnprocessed, and parse the child there.
- Sign merging:
maybe_merge_signonly absorbs a sign into a following number when the sign is a single character whose text is+or-. Other operators that happen to be listed insignTokens(for example>&in the Bash definition) are never absorbed.- The operand-context check walks backwards over trivia (
Whitespace,Unprocessed, start/end delimiters) before testingoperandTokens.
formatVersion: 2(lexer-shaped) definitions:- The C CLI does not run the declarative grammar for these definitions.
Its JSON loader (
textparser-json.c) normalizeslexer.tokensfollowed bylexer.triviainto the legacytokensmap, each as aSimpleTokenwhosestartRegexis the lexer token'sregex, and generatesstartTokensfrom all of them in JSON order.multiLineis carried over;priority,pushMode/popMode, modes and goals are ignored on this path. - Ports that only target CLI parity must reproduce this normalization, not the
grammar engine. The grammar engine is reachable only through
textparser_execute_language_grammar.
- The C CLI does not run the declarative grammar for these definitions.
Its JSON loader (
- Regex engine differences:
java.util.regexdoes not know the PCRE2 derived properties\p{ID_Start}/\p{ID_Continue}; ports should rewrite them to the equivalent Unicode category sets (\p{L}\p{Nl}and\p{L}\p{Nl}\p{Mn}\p{Mc}\p{Nd}\p{Pc}).- Java's matcher is recursive, so long matches of loop patterns such as the TypeScript string/template literals can exhaust the thread stack where PCRE2 does not. A large-stack retry (or equivalent) is required.
- Error spans:
- The legacy parser stores
handle->error,handle->error_offsetandhandle->error_length;textparser_parse_error()/_position()/_length()expose message + span. The span anchors at the token start for token-level errors (Token spans multiple lines…,Token must have exactly one child…), at the content start forCan't find end of the token!, and at the offending character (length 1) for unexpected/expected-token errors. Internal errors use length 0. - Full and incremental parsing share the same parser path, so the span is identical in both modes.
- The grammar engine reports spans through
textparser_diagnostic(start_pos+length). For a unified surface, the legacy error accessors fall back to the first error-severity diagnostic whenhandle->erroris null, so callers get a message + span for both engines. - The Java port mirrors this with
ParseError(getPosition()/getLength()), thrown byTextParser.parse(); the CLI printsat offset N (length M).
- The legacy parser stores