Skip to content
sovrinPublic

About

Monadic parser combinators for TypeScript

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

unitas

Composing parsers into a unified whole. A lightweight, TypeScript-first parser combinator library — write small parsers, combine them into big ones, get typed results back.

npm version Coverage License: MIT

npm install unitas
import { grammar, run } from 'unitas';
import { choice, inner, separatedBy } from 'unitas/combinators';
import { char, regex } from 'unitas/terminals';
import { letters } from 'unitas/primitives';

const csv = grammar({
    row: (p) => separatedBy(p.value, char(',')),
    value: (p) => choice(p.quoted, p.unquoted),
    quoted: () => inner(char('"'), regex(/^[^"]*/), char('"')),
    unquoted: () => letters,
});

run(csv.row, 'a,b,c'); // ['a', 'b', 'c']
run(csv.row, '"a,b",c'); // ['a,b', 'c']
  • Zero dependencies, ESM-only, fully typed
  • Tree-shakeable — five entry points, import only what you touch
  • 134 composable exports — grouped by what they do, indexed below
  • Mutual recursion out of the box via grammar, no forward declarations
  • Errors that point at the problem — line, column and what was expected

Note: This library is in active development. The API may change before v1.0.0.

Contents

Entry points

Nothing is re-exported across entry points, so your bundler only ever sees what you import. Code shared between them ships once, however many you import from.

Import from Contains
unitas Types, success/failure, run, grammar, lazy, memoize
unitas/terminals Factories that match input directly — char, string, regex, …
unitas/primitives Ready-made parsers — digit, letters, whitespace, …
unitas/combinators Parsers that take parsers — map, choice, sequence, many, …
unitas/utils Helpers for map callbacks — pick, join, pipe, …

Which function do I need?

The library is broad on purpose: each function does one small thing, so grammars read like the format they parse. This table is the fastest way in — start from the intent, then look the name up in the API index.

Matching a single thing

I want to match… Reach for
an exact character or string char, string
one of several strings (longest wins) oneOf
any character in / not in a set charOf, stringOf, noneOf
a character passing a predicate satisfy
a regular expression regex
a fixed number of characters, or a run take, takeWhile
a keyword not glued to more word characters word
a token, eating trailing whitespace token, lexeme
a value — no need to build it number, integer, float, bool, identifier, literal, line, rest
a character class letter, digit, alphaNum, hexDigit, octDigit, lowercase, uppercase, anyChar
whitespace or a line break whitespace, space, tab, nl, crlf, eol, eof

Most primitives come in a singular and a plural form: letter matches one, letters matches a run of them. The same holds for digit/digits, alphaNum/alphaNums, hexDigit/hexDigits, octDigit/octDigits, lowercase/lowercases, uppercase/uppercases, space/spaces and whitespace/whitespaces. Need to know how far into the input you are? position.

Repeating

I want to… Reach for
repeat zero or more / one or more times many, many1
repeat a bounded number of times exactly, manyAtLeast, manyAtMost, manyBetween
repeat with a separator between items separatedBy, separatedBy1, optionalSeparatedBy
…and allow a trailing separator separatedEndBy, separatedEndBy1
…where every item ends with the separator endBy, endBy1, interleaved
repeat until something else shows up manyTill, until, separatedUntil
repeat but throw the values away skip, skipMany, skipMany1
reduce the repetitions as I go fold, fold1, foldRight, foldRight1

Choosing and branching

I want to… Reach for
take the first alternative that matches choice
succeed either way optional, optionalConsume, recover, flag
succeed only when something doesn't match not
gate on a boolean I already have guard, unless, when
look ahead without consuming input peek

Sequencing and pulling values out

I want to… Reach for
run parsers in order and keep every result sequence
keep only one side of a pair left, right
keep specific positions of a sequence first, last, nth, pick
drop the delimiters, keep the middle inner, outer
glue string results together concat, fuse, join
match content inside delimiters surrounded, parenthesized, braced, bracketed, quoted, padded

Shaping the result

I want to… Reach for
transform the parsed value map, pipe
replace it with a constant value, pure, consume
build a typed AST node node
reject a value after parsing it validate
choose the next parser from the last value bind
tidy up arrays inside a map pick, filter, flatten, shift, pop, spread

Grammars, recursion and errors

I want to… Reach for
let rules reference each other by name grammar
refer to a parser defined further down lazy
cache results and avoid exponential blowup memoize
attach a readable error message label
parse without throwing, with line and column parse
turn an offset into a line and column locate
render a failure with a caret under it format, ParseError
record what a hand-written parser expected context, record, union
parse infix operators with precedence chainLeft, chainLeft1, chainRight, chainRight1
parse prefix / postfix operators prefix, postfix
handle both outcomes of a Result match

Core concepts

The Parser type

A Parser<T> reads input from an offset and returns a Result<T>. That is the whole abstraction — everything else in this library either produces one or wraps one. Both extra arguments are optional, so a parser can still be called with just an input.

type Parser<T> = (input: string, index?: number, ctx?: Context) => Result<T>;

Parsers never slice the input. They pass the same string down and move an integer instead, which is what lets an error know where in the original source it happened.

ctx is the parse context, created by run and parse. It is where error information accumulates — see Error reporting. If you write your own combinator, pass it on to every parser you call.

The Result type

type Success<T> = { ok: true; value: T; index: number };
type Failure = { ok: false; index: number; expected: readonly string[] };
type Result<T> = Success<T> | Failure;
{ ok: true, value: 'hello', index: 5 }
       │           │               │
       │           │               └── how far this parser got
       │           └── the parsed value
       └── always true for success

index is the important part: it is how input gets consumed and how parsers chain. A failure reports the same index, plus the set of things that would have matched there:

{ ok: false, index: 6, expected: ['digit'] }

Backtracking is free

Combinators hand every alternative the same offset they started with, so a branch that fails can never leave the cursor moved. choice(a, b) always offers b the same starting offset, however far a got.

If you are coming from Parsec, this is why there is no try/attempt here — backtracking is unconditional, so there is nothing to opt into. What a failed branch does leave behind is its expectation, recorded in the parse context at the offset it reached, so a later error can still mention it.

Writing one by hand

Most of the time you compose existing pieces, but nothing stops you from dropping down a level:

import { create, success, failure } from 'unitas';

const parser = create<string>((input, index = 0, ctx) => {
    if (input.startsWith('hello', index)) {
        return success('hello', index + 5);
    }

    return failure(ctx, index, '"hello"');
});

Passing ctx to failure is what lets this parser's expectation appear in an error message even when a choice backtracks past it. Do the same for any parser you call:

const twice = <T>(parser: Parser<T>) =>
    create<[T, T]>((input, index = 0, ctx) => {
        const first = parser(input, index, ctx);
        if (!first.ok) return first;

        const second = parser(input, first.index, ctx);
        if (!second.ok) return second;

        return success([first.value, second.value], second.index);
    });

Grammars

grammar hands every rule a proxy p, so rules can reference each other — including themselves — without forward declarations or lazy boilerplate.

import { grammar, run } from 'unitas';
import { chainLeft1, choice, map, sequence } from 'unitas/combinators';
import { char } from 'unitas/terminals';
import { digits } from 'unitas/primitives';

type Math = { expr: number; term: number; value: number };

const g = grammar<Math>({
    expr: (p) =>
        chainLeft1(
            p.term,
            map(char('+'), () => (l: number, r: number) => l + r),
        ),
    term: (p) =>
        choice(
            p.value,
            map(sequence(char('('), p.expr, char(')')), ([, v]) => v),
        ),
    value: () => digits,
});

run(g.expr, '1+2+3'); // 6
run(g.expr, '(1+2)'); // 3

Error reporting

When a parse fails, run throws a ParseError that says where and what was expected:

import { run } from 'unitas';
import { separatedBy, sequence } from 'unitas/combinators';
import { char } from 'unitas/terminals';
import { digits, letters } from 'unitas/primitives';

const pair = sequence(letters, char('='), digits);
const config = separatedBy(pair, char('\n'));

run(config, 'host=1\nport=8080\ndebug=yes');
// ParseError: 3:7 expected digit, found 'y'
//   3 | debug=yes
//     |       ^

The position is the furthest offset any branch reached, not wherever the parse happened to stop. That distinction is what makes the message useful: separatedBy succeeded with two pairs and left the rest unconsumed, but the error still points at the y that actually broke it.

Alternatives are reported together, since choice unions the expectations of every branch it tried at the same offset:

run(sequence(choice(string('let'), string('const')), char(' ')), 'lot x');
// ParseError: 1:1 expected 'const' or 'let', found 'l'
//   1 | lot x
//     | ^

Use parse when you would rather branch on the failure than catch it:

import { parse } from 'unitas';

const result = parse(config, 'host=1\nport=oops');

if (!result.ok) {
    result.line; // 2
    result.column; // 6
    result.expected; // ['digit']
    result.message; // the formatted excerpt above
}

label replaces the expectations of a whole parser with one description, so errors can talk about your grammar instead of its characters:

import { label } from 'unitas';

run(label(pair, 'a key=value pair'), '!!!');
// ParseError: 1:1 expected a key=value pair, found '!'

More examples

JSON value — a tagged union of scalars

import { grammar, run } from 'unitas';
import { choice, quoted, value } from 'unitas/combinators';
import { string } from 'unitas/terminals';
import { bool, digits, letters } from 'unitas/primitives';

const json = grammar({
    value: (p) => choice(p.string, p.number, p.bool, p.null),
    string: () => quoted(letters),
    number: () => digits,
    bool: () => bool,
    null: () => value(string('null'), null),
});

run(json.value, '"hello"'); // 'hello'
run(json.value, '42'); // 42
run(json.value, 'true'); // true
run(json.value, 'null'); // null

Query string — separated pairs folded into an object

import { grammar, run } from 'unitas';
import { map, outer, separatedBy } from 'unitas/combinators';
import { char } from 'unitas/terminals';
import { letters } from 'unitas/primitives';

type Query = {
    params: Record<string, string>;
    param: [string, string];
    key: string;
    value: string;
};

const query = grammar<Query>({
    params: (p) =>
        map(separatedBy(p.param, char('&')), (pairs) =>
            Object.fromEntries(pairs),
        ),
    param: (p) => outer(p.key, char('='), p.value),
    key: () => letters,
    value: () => letters,
});

run(query.params, 'foo=bar&baz=qux'); // { foo: 'bar', baz: 'qux' }

INI section — a header plus an entry, with the punctuation dropped

import { grammar, run } from 'unitas';
import { bracketed, map, outer, sequence } from 'unitas/combinators';
import { char, regex } from 'unitas/terminals';
import { letters, nl } from 'unitas/primitives';
import { pick } from 'unitas/utils';

const ini = grammar({
    section: (p) =>
        map(
            sequence(bracketed(p.name), nl, p.entry),
            pick(0, 2),
            ([name, entry]) => ({ name, entry }),
        ),
    name: () => letters,
    entry: (p) => outer(p.key, char('='), p.value),
    key: () => letters,
    value: () => regex(/^[^\n]+/),
});

run(ini.section, '[database]\nhost=localhost'); // { name: 'database', entry: ['host', 'localhost'] }

API index

One line per export. Every name links to its full description and runnable example in the reference pages under doc/api.

Core — unitas

Types, constructors and the grammar runner. 17 exports — full reference →

context Creates a fresh context. run and parse make one per call.
create Creates a parser from a parser function.
failure Creates a failed result at an offset and notes it in the parse context.
format Renders a parse failure as a source excerpt with a caret under the offset.
grammar Creates a recursive grammar where rules can reference each other.
label Replaces the expectations of a parser with a single description.
lazy Defers parser creation, useful for recursive grammars.
locate Converts a character offset into a 1-based line and column.
match Pattern matching on a Result to handle success and failure cases.
memoize Memoizes a parser so each offset is parsed at most once (packrat caching).
parse Runs a parser over the whole input without throwing.
ParseError Error thrown by run when a parse fails, carrying the offset, the 1-based line/column and the set of expectations at that offset.
record Notes what was expected at an offset, keeping only the furthest one seen.
reject Creates a failed result from an expectation list that is already allocated, avoiding a fresh array on every failure.
run Runs a parser over the whole input and returns the value.
success Creates a successful result with a value and the offset reached in the input.
union Adds the entries of b to a, returning a itself when it already covers them.

Terminals — unitas/terminals

Factories that match the input string directly. 12 exports — full reference →

char Parse a specific character.
charOf Parse any character from a set.
noneOf Parse any character not in the set.
oneOf Parse one string from a set of strings.
regex Parse with a regular expression.
satisfy Parse a character satisfying a predicate.
string Parse a specific string.
stringOf Parse first character that exists in string (like charOf but for a string).
take Take n characters.
takeWhile Takes characters while the predicate returns true.
token Parses a string as a token, skipping trailing whitespace.
word Parses a specific word and ensures it is not followed by word characters.

Primitives — unitas/primitives

Ready-made parsers for the usual suspects. 33 exports — full reference →

alphaNum Parse a single alphanumeric character.
alphaNums Parse one or more alphanumeric characters as a string.
anyChar Parse any single character.
bool Parse a boolean literal.
crlf Parse CRLF line ending.
digit Parse a single digit and return as number.
digits Parse one or more digits and return as a number.
eof Match the end of the input.
eol Parse end of line (\n, \r\n, or EOF).
float Parse a floating point number.
hexDigit Parse a single hexadecimal digit.
hexDigits Parse one or more hex digits as a string.
identifier Parse an identifier — starts with letter or underscore, no leading digit, no hyphen.
integer Parse a signed integer.
letter Parse a single letter.
letters Parse one or more letters as a string.
line Parse until end of line.
literal Parse a word-like value including hyphens.
lowercase Parse a single lowercase letter.
lowercases Parse one or more lowercase letters as a string.
nl Parse a newline character.
number Parse an integer or float.
octDigit Parse a single octal digit.
octDigits Parse one or more octal digits as a string.
position Get the current offset into the input, without consuming anything.
rest Consume and return everything left in the input.
space Parse a single space character.
spaces Parse one or more spaces as a string.
tab Parse tab character.
uppercase Parses a single uppercase letter.
uppercases Parse one or more uppercase letters as a string.
whitespace Parses a single whitespace character.
whitespaces Parse one or more whitespaces as a string.

Combinators — unitas/combinators

Take parsers, return a new parser. 64 exports — full reference →

bind Chain parsers where the second parser depends on the first result.
braced Parse content surrounded by braces.
bracketed Parse content surrounded by brackets.
chainLeft Chain left-associative operations: 1-2-3 is evaluated as (1-2)-3.
chainLeft1 Chain left-associative operations (fails on empty input).
chainRight Chain right-associative operations: 2^3^2 is evaluated as 2^(3^2).
chainRight1 Chain right-associative operations (fails on empty input).
choice Try each parser in order, return first success.
concat Join string array parser result into a single string.
consume Consume input but discard the result (return null).
endBy Zero or more items separated and ending with terminator.
endBy1 One or more items separated and ending with terminator.
exactly Parse exactly n occurrences.
first Extract the first element from a parser result array.
flag Return true if parser succeeds, false otherwise.
fold Fold zero or more occurrences into a single value.
fold1 Fold one or more occurrences into a single value.
foldRight Fold zero or more occurrences from the right into a single value.
foldRight1 Fold one or more occurrences from the right into a single value.
fuse Fuse multiple string parsers into a single one.
guard Conditionally apply parser based on a condition.
inner Parse three parsers in sequence and keep only the middle result.
interleaved Parse items separated by separators, keeping both in the result.
last Extract the last element from a parser result array.
left Keep only the left result from a sequence.
lexeme Parser that consumes trailing whitespace.
many Zero or more occurrences (never fails).
many1 One or more occurrences.
manyAtLeast Parse at least n occurrences.
manyAtMost Parse at most n occurrences (never fails).
manyBetween Parse between min and max occurrences.
manyTill Parse zero or more until terminator matches.
map Transform a parsed value through one or more functions.
node Create a node from parser fields.
not Succeed if parser fails (without consuming input).
nth Extract the nth element from a parser result array.
optional Make parser optional (return null on failure, without consuming input).
optionalConsume Consume input if the parser matches, discarding the result.
optionalSeparatedBy Parse items separated by a separator, allowing empty slots.
outer Extract outer values from a sequence of 3 parsers (skip middle).
padded Parse content surrounded by optional whitespace.
parenthesized Parse content surrounded by parentheses.
peek Look ahead without consuming input.
postfix Apply zero or more postfix operators to an atom.
prefix Apply zero or more prefix operators to an atom.
pure Always succeed with a value without consuming input.
quoted Parse content surrounded by single or double quotes.
recover Use fallback value when parser fails.
right Keep only the right result from a sequence.
separatedBy Parse zero or more items separated by a separator.
separatedBy1 Parse one or more items separated by a separator.
separatedEndBy Parse zero or more items separated by a separator, allowing a trailing one.
separatedEndBy1 Parse one or more items separated by a separator, allowing a trailing one.
separatedUntil Parse items separated by a separator, up to a terminator.
sequence Parse a sequence of parsers and return all results as an array.
skip Skip a parser n times.
skipMany Skip zero or more occurrences (never fails, returns null).
skipMany1 Skip one or more occurrences (fails if no matches).
surrounded Parse content surrounded by delimiters.
unless Parse unless condition is true (inverse of guard).
until Parse zero or more until terminator matches, leaving the terminator unconsumed.
validate Validate parsed value with a predicate.
value Replace parsed value with a constant.
when Branch on a boolean parser result.

Utils — unitas/utils

Plain helpers for map callbacks. 8 exports — full reference →

filter Exclude values from array.
flatten Flatten nested arrays.
join Join array elements into a string.
pick Pick elements from an array by index.
pipe Pipe parser functions together.
pop Get the last element of an array.
shift Get the first element of an array.
spread Collect spread arguments into an array.

Migrating from 0.2.x

0.3.0 changes how a parser reports where it got to. Parsers now take an offset into the input instead of receiving a sliced suffix, which is what makes line/column error reporting possible.

If you only compose the built-in parsers, nothing changes. run, grammar, map, choice and everything built on them behave the same, and both new arguments are optional, so myParser('input') still works.

Two things do change for everyone:

// reading a result
result.remaining; // 0.2.x
input.slice(result.index); // 0.3.0 — or just use result.index

// a failed run
run(p, 'bad'); // 0.2.x: Error: Parsing failed: Unexpected error
run(p, 'bad'); // 0.3.0: ParseError, with .line, .column and .expected

If you write parsers by hand, the shape changed:

// 0.2.x
create<string>((input) => {
    if (input.startsWith('hello')) {
        return success('hello', input.slice(5));
    }

    return failure('expected "hello"');
});

// 0.3.0
create<string>((input, index = 0, ctx) => {
    if (input.startsWith('hello', index)) {
        return success('hello', index + 5);
    }

    return failure(ctx, index, '"hello"');
});

The rest, in full:

0.2.x 0.3.0
Parser<T> = (input) => Result<T> Parser<T> = (input, index?, ctx?) => Result<T>
success(value, remaining) success(value, index)
failure() / failure(message) failure(ctx, index, ...expected)
{ ok: true, value, remaining } { ok: true, value, index }
{ ok: false, error?: string } { ok: false, index, expected }
match's failure: (error) => … failure: (index, expected) => …
position → remaining length position → offset from the start
label(p, 'x') → 'expected x' label(p, 'x') → expected: ['x'], only when nothing consumed
regex(/^\w+/) same, but sticky — a leading ^ is stripped unless multiline

New in this release: parse (non-throwing), ParseError, locate, format, and context/record for hand-written parsers. memoize is now keyed by offset rather than by the remaining input, so its cache entries are cheap and are dropped when a different input is parsed. Left-recursive grammar rules now throw a named error instead of overflowing the stack.

Contributing

npm test                  # run the suite
npm run build             # compile to dist/
npm run generate:index    # regenerate barrel files after adding a source file
npm run generate:readme   # regenerate README.md and doc/api/ from JSDoc

Every export lives in its own file with a co-located test and a JSDoc @example. Those examples are the single source of truth: they are extracted into test/examples.test.ts and into the documentation, so they cannot drift.

License

MIT © Oleg Kamlowski

About

Monadic parser combinators for TypeScript

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages