quint/pars
2
0
Fork
You've already forked pars
0
A bytecode virtual machine for parsing
  • Zig 91.3%
  • TypeScript 4.6%
  • HTML 2.6%
  • CSS 0.9%
  • Shell 0.5%
  • Other 0.1%
Quint Daenen ae6a8e8108 refactor: harden FIRST analysis and share string-escape decoding
ScopedResolver grows `disjointness_mode` so canDemoteLongest refuses
to classify `#[lr]` self-refs as disjoint. Canonical-rule collection
plus per-iteration where-binding dedup prevents the fixed point from
oscillating on duplicate names. Compiler and FIRST analyzer now share
`decodeStringBody` for single-quoted escape handling.
2026年04月23日 08:06:21 +02:00
assets feat: vsx with bundled language server 2026年04月19日 08:07:10 +02:00
editors/vsx feat: railroad-diagram webview 2026年04月20日 22:10:43 +02:00
examples refactor: group src/ by pipeline stage (frontend/runtime/abnf) 2026年04月21日 09:14:33 +02:00
lib feat: embed ABNF grammars via @abnf""" blocks 2026年04月20日 08:40:54 +02:00
src refactor: harden FIRST analysis and share string-escape decoding 2026年04月23日 08:06:21 +02:00
tools refactor: harden FIRST analysis and share string-escape decoding 2026年04月23日 08:06:21 +02:00
.gitignore feat: chunk bytecode with constant pool and disassembler 2026年04月09日 20:59:28 +02:00
ADR.md docs: document #[longest] in README and add ADR 011 2026年04月19日 15:16:00 +02:00
build.zig feat: embed ABNF grammars via @abnf""" blocks 2026年04月20日 08:40:54 +02:00
build.zig.zon feat: vsx with bundled language server 2026年04月19日 08:07:10 +02:00
flake.lock feat: chunk bytecode with constant pool and disassembler 2026年04月09日 20:59:28 +02:00
flake.nix feat: vsx with bundled language server 2026年04月19日 08:07:10 +02:00
LICENSE feat: rule declarations, ordered choice, quantifiers, and call frames 2026年04月12日 12:37:32 +02:00
README.md feat: embed ABNF grammars via @abnf""" blocks 2026年04月20日 08:40:54 +02:00

pars

A bytecode virtual machine for PEG grammars. Grammars compile to a compact instruction set and execute against arbitrary byte input.

Quick start

echo -n "192.168.1.1" | pars examples/ipv4.pars

Exit code 0 means the input matched; 1 means it did not.

Example

-- examples/identifier.pars
use "std/abnf";
ident = (ALPHA / '_') (ALPHA / DIGIT / '_')*;
$ echo -n "foo_123" | pars examples/identifier.pars && echo matched
matched

More examples in examples/.

Language

A pars program is a sequence of rule declarations. The last rule is the entry point.

Primaries

syntax meaning
"literal" match an exact byte sequence
"""literal""" triple-quoted, allows embedded newlines
i"literal" case-insensitive (ASCII letters)
'c' match a single byte
. match any one byte
['a'-'z'] charset: match one byte in the set
(A B) grouping
#[longest](A / B) longest-match choice: commit to the longest arm
name call a named rule
<x: A> capture the span matched by A as x

Within the same rule, referencing a captured name matches the exact bytes it captured earlier (back-reference).

Operators (loosest to tightest)

operator meaning
A / B ordered choice: try A, else B (| is a synonym)
A B sequence (juxtaposition)
!A negative lookahead: succeeds when A fails
&A positive lookahead: succeeds without consuming
A* zero or more
A+ one or more
A? optional
A{n} exactly n times
A{n,m} between n and m times
A{n,} at least n times
A{,m} at most m times
^ cut: commit the innermost /; ^"msg" adds a label

Rules

name = body;

Rules can reference each other in any order. The last rule in the file is matched against the input.

A rule body may introduce locally-scoped sub-rules with where:

kv = k "=" v
 where
 k = ident;
 v = ident
 end

A declaration may carry bracketed attributes. Today lr is the only declaration attribute: it opts a rule into direct left recursion via seed-growing, so expr below matches left-associative chains like 1+2-3 that a plain PEG would reject:

#[lr]
expr = expr "+" term
 / expr "-" term
 / term;

See examples/left-recursive-expr.pars.

The #[longest](...) form reuses the same bracketed-attribute syntax in expression position. It runs every alternative from the same starting position and commits to the one that consumed the most input, so a shorter prefix cannot starve a longer one:

op = #[longest]("<" / "<=" / ">" / ">=" / "==" / "!=");

Ties resolve to the earlier arm; if every arm fails, the group fails. See examples/longest-match.pars.

Attribute names are not reserved words: lr and longest are only recognized inside #[...], so rules or bindings named lr or longest keep working unchanged.

Imports

use "std/abnf";

Makes the rules from a standard library module (currently std/abnf, std/abnf_grammar, and std/pars_grammar) available in the current grammar.

Inline ABNF blocks

RFC 5234 ABNF can be pasted directly into a pars file with an @abnf"""...""" block. The block contributes rules to the same registry as the surrounding pars file, and those rules are callable from the rest of the file like any other:

use "std/abnf";
@abnf"""
 greeting = salutation SP subject
 salutation = "hello" / "hi" / "hey"
 subject = 1*ALPHA
"""
entry = greeting;

See examples/abnf-block.pars.

Lowering to pars is mechanical and opinionated:

  • ABNF / becomes #[longest](...) so shorter alternatives cannot starve longer ones (RFC ABNF / is unordered; longest-match is the disambiguation protocol grammars assume).
  • Directly left-recursive rules are annotated with #[lr] so expressions like expr = expr "+" term / term work.
  • Hyphenated rule names (hier-part) are mangled to underscores (hier_part) for the pars registry; the original spelling is used in diagnostics.
  • Numeric byte values (%x41, %x30-39, %x41.42.43, etc.) validate at compile time; values > 255 are rejected.
  • =/ incremental alternatives merge within a block.
  • Rule names are case-insensitive per RFC 5234 §2.1: two spellings of the same rule in one block are a compile error.
  • prose-val (<...>) is rejected as unsupported.

Referenced core rules (ALPHA, DIGIT, ...) are not auto-imported; add use "std/abnf"; in the host file when you need them.

Usage

pars <grammar> [input-file]

With one argument, input is read from stdin. With two, the second argument is an input file. Exit codes: 0 match, 1 no match, 65 compile error, 70 runtime error.

REPL

pars with no arguments starts an interactive session with a sticky input buffer.

$ pars
pars REPL. type :help for commands.
> :input GET /foo HTTP/1.1
input set (17 bytes)
> "GET"
ok: matched 3/17 bytes
> method = ['A'-'Z']+;
ok: matched 3/17 bytes
> method " " "/" ['a'-'z']+
ok: matched 8/17 bytes
> :exit

Rules defined in the REPL persist across lines.

command effect
:input <text> set the sticky input buffer
:input show the current input
:clear clear the input
:help list commands
:exit/:quit exit

Editor support

A VSCode extension lives in editors/vsx/. It bundles the pars-lsp language server, which talks JSON-RPC over stdio and provides:

  • syntax-aware diagnostics (scanner and compiler errors)
  • semantic token highlighting
  • go-to-definition on rule references and capture back-references
  • hover showing a rule's body (or a note for captures)
  • document outline of top-level rules
  • inlay hints flagging identifiers that resolve to capture back-references rather than rule calls
  • snippets for common declarations (rule, rulew, where, cap, alt, longest, neg, pos, cut, bq, use, cs)

Install

./editors/vsx/install.sh

Builds pars-lsp in ReleaseSafe, bundles it into the extension, packs a .vsix, and installs it into VSCode via the code CLI. Pass --no-install to build only, or --no-build to reuse an existing zig-out/bin/pars-lsp.

To use a custom server path instead of the bundled one, set pars.serverPath in VSCode settings.

Building

Requires Nix with flakes enabled.

nix develop --command zig build
nix develop --command zig build test