From e468934732f825881e5c806adab1d8e936b075e8 Mon Sep 17 00:00:00 2001 From: Oleg Kamlowski Date: Sun, 6 Sep 2026 12:46:00 +0200 Subject: [PATCH 1/3] docs(readme): split reference out of README and index by intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README tried to be the full API reference: 1441 lines, a 129-entry flat TOC, then one
block per export in alphabetical order. Alphabetical order only helps once you already know the name, which is the one thing you don't know with 127 exports. On npmjs.com it was worse — the sanitizer strips the id attributes those TOC links pointed at, so every one of them was dead. Split it in two. README.md is now a landing page: pitch, install, a runnable example, a "Which function do I need?" cheat sheet keyed on intent rather than name, core concepts, and a one-line-per-export index grouped by module. The per-export reference with full descriptions and examples moves to doc/api/*.md, generated from the same JSDoc into real headings so the anchors survive both renderers. generate-readme.mjs emits both from doc/README.template.md and the new doc/api.template.md. The cheat sheet is hand-written, so it also warns when an export is missing from it — combinators land often enough that it would rot silently otherwise. Also fixes the npm listing itself. package.json had no description, so the registry fell back to the README's first non-heading line and stored the floating logo tag as the package description: "description": "" Add a real description, expand keywords from 3 to 13, add homepage and bugs, point the logo at an absolute URL (doc/ is npmignored, so the relative path could never resolve from the tarball), and rename README.MD to README.md to match both the files array and what the generator writes. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 10 +- README.MD | 1441 --------------------------------------- README.md | 609 +++++++++++++++++ bin/generate-readme.mjs | 248 +++++-- doc/README.template.md | 337 +++++---- doc/api.template.md | 19 + doc/api/combinators.md | 563 +++++++++++++++ doc/api/core.md | 110 +++ doc/api/primitives.md | 292 ++++++++ doc/api/terminals.md | 117 ++++ doc/api/utils.md | 85 +++ package.json | 19 +- 12 files changed, 2229 insertions(+), 1621 deletions(-) delete mode 100644 README.MD create mode 100644 README.md create mode 100644 doc/api.template.md create mode 100644 doc/api/combinators.md create mode 100644 doc/api/core.md create mode 100644 doc/api/primitives.md create mode 100644 doc/api/terminals.md create mode 100644 doc/api/utils.md diff --git a/CLAUDE.md b/CLAUDE.md index d3f4981..4b43a27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ npm run lint:fix # auto-fix lint issues npm run format # oxfmt (format all files) npm run generate:index # regenerate all index.ts barrel files from source -npm run generate:readme # regenerate README.MD from JSDoc examples +npm run generate:readme # regenerate README.md and doc/api/*.md from JSDoc examples npm run test:extract # extract JSDoc @examples from .js files into test/examples.test.ts ``` @@ -55,13 +55,17 @@ type Failure = { ok: false; error?: string }; - Every combinator/terminal/primitive lives in its own file: `src/combinators/map.ts` - Co-located test: `src/combinators/map.test.ts` - `index.ts` in each module is generated — run `npm run generate:index` after adding or removing files; do not edit manually -- JSDoc `@example` blocks in source files are the source of truth for README and `test/examples.test.ts` +- JSDoc `@example` blocks in source files are the source of truth for the docs and `test/examples.test.ts` +- `README.md` is generated from `doc/README.template.md` — a landing page (pitch, quick start, intent-based cheat sheet, one-line API index) +- `doc/api/{core,terminals,primitives,combinators,utils}.md` are generated from `doc/api.template.md` — the full per-export reference; the whole directory is wiped and rewritten on every run +- Neither `README.md` nor `doc/api/` is edited by hand; hand-written prose belongs in the two templates in `doc/` ### Adding a new combinator 1. Create `src/combinators/myThing.ts` + `src/combinators/myThing.test.ts` 2. Run `npm run generate:index` to update `src/combinators/index.ts` -3. Add JSDoc `@example` block — run `npm run generate:readme` and `npm run test:extract` to keep README and examples test in sync +3. Add JSDoc `@example` block — run `npm run generate:readme` and `npm run test:extract` to keep the docs and examples test in sync +4. Add the new name to the "Which function do I need?" cheat sheet in `doc/README.template.md` — that table is hand-written, and `generate:readme` warns about any export missing from it ### Test structure diff --git a/README.MD b/README.MD deleted file mode 100644 index 91e913a..0000000 --- a/README.MD +++ /dev/null @@ -1,1441 +0,0 @@ -# unitas — composing parsers into a unified whole - -Unitas logo - -[![npm version](https://img.shields.io/npm/v/unitas)](https://www.npmjs.com/package/unitas) -[![Coverage](https://coveralls.io/repos/github/sovrin/unitas/badge.svg?branch=master)](https://coveralls.io/github/sovrin/unitas?branch=master) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -A lightweight, TypeScript-first parser combinator library for building expressive and composable parsers. - -## Features - -- **Parser Combinators**: Compose small parsers into complex ones using combinators like `many`, `choice`, `sequence`, and more -- **Terminals**: Factory functions for common patterns (`char`, `string`, `regex`, etc.) -- **Primitives**: Pre-built parser instances ready to use (`digit`, `letter`, `whitespace`, etc.) -- **TypeScript**: Full TypeScript support with generic types and inference -- **Tree-shakeable**: ESM-only with separate exports for `combinators`, `terminals`, `primitives`, and `utils` -- **No dependencies**: Zero external runtime dependencies - -> **Note:** This library is in active development. The API may change before v1.0.0. - -## Installation - -```bash -npm install unitas -``` - -## Quick Start - -**CSV parser** — parsing comma-separated values with quoted fields - -```typescript -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'] -``` - -**JSON value parser** — parsing simple json values - -```typescript -import { grammar, run } from 'unitas'; -import { choice, map, quoted } 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: () => map(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 parser** — parsing URL query parameters - -```typescript -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; - param: [string, string]; - key: string; - value: string; -}; - -const query = grammar({ - 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 file section** — parsing section headers and key-value pairs - -```typescript -import { grammar, run } from 'unitas'; -import { 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(char('['), p.name, char(']'), nl, p.entry), - pick(1, 4), - ([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'] } -``` - -## Table of Contents - -- [Core](#core) - - [`failure`](#failure) - - [`grammar`](#grammar) - - [`label`](#label) - - [`lazy`](#lazy) - - [`match`](#match) - - [`memoize`](#memoize) - - [`parser`](#parser) - - [`run`](#run) - - [`success`](#success) -- [Terminals](#terminals) - - [`char`](#char) - - [`charOf`](#charOf) - - [`noneOf`](#noneOf) - - [`oneOf`](#oneOf) - - [`regex`](#regex) - - [`satisfy`](#satisfy) - - [`string`](#string) - - [`stringOf`](#stringOf) - - [`take`](#take) - - [`takeWhile`](#takeWhile) - - [`token`](#token) - - [`word`](#word) -- [Primitives](#primitives) - - [`alphaNum`](#alphaNum) - - [`alphaNums`](#alphaNums) - - [`anyChar`](#anyChar) - - [`bool`](#bool) - - [`crlf`](#crlf) - - [`digit`](#digit) - - [`digits`](#digits) - - [`eof`](#eof) - - [`eol`](#eol) - - [`float`](#float) - - [`hexDigit`](#hexDigit) - - [`hexDigits`](#hexDigits) - - [`identifier`](#identifier) - - [`integer`](#integer) - - [`letter`](#letter) - - [`letters`](#letters) - - [`line`](#line) - - [`literal`](#literal) - - [`lowercase`](#lowercase) - - [`lowercases`](#lowercases) - - [`nl`](#nl) - - [`number`](#number) - - [`octDigit`](#octDigit) - - [`octDigits`](#octDigits) - - [`position`](#position) - - [`rest`](#rest) - - [`space`](#space) - - [`spaces`](#spaces) - - [`tab`](#tab) - - [`uppercase`](#uppercase) - - [`uppercases`](#uppercases) - - [`whitespace`](#whitespace) - - [`whitespaces`](#whitespaces) -- [Combinators](#combinators) - - [`attempt`](#attempt) - - [`bind`](#bind) - - [`braced`](#braced) - - [`bracketed`](#bracketed) - - [`chainLeft`](#chainLeft) - - [`chainLeft1`](#chainLeft1) - - [`chainRight`](#chainRight) - - [`chainRight1`](#chainRight1) - - [`choice`](#choice) - - [`concat`](#concat) - - [`consume`](#consume) - - [`endBy`](#endBy) - - [`endBy1`](#endBy1) - - [`exactly`](#exactly) - - [`first`](#first) - - [`flag`](#flag) - - [`fold`](#fold) - - [`fold1`](#fold1) - - [`foldRight`](#foldRight) - - [`foldRight1`](#foldRight1) - - [`fuse`](#fuse) - - [`guard`](#guard) - - [`inner`](#inner) - - [`interleaved`](#interleaved) - - [`last`](#last) - - [`left`](#left) - - [`lexeme`](#lexeme) - - [`many`](#many) - - [`many1`](#many1) - - [`manyAtLeast`](#manyAtLeast) - - [`manyAtMost`](#manyAtMost) - - [`manyBetween`](#manyBetween) - - [`manyTill`](#manyTill) - - [`map`](#map) - - [`node`](#node) - - [`not`](#not) - - [`nth`](#nth) - - [`optional`](#optional) - - [`optionalConsume`](#optionalConsume) - - [`optionalSeparatedBy`](#optionalSeparatedBy) - - [`outer`](#outer) - - [`padded`](#padded) - - [`parenthesized`](#parenthesized) - - [`peek`](#peek) - - [`postfix`](#postfix) - - [`prefix`](#prefix) - - [`pure`](#pure) - - [`quoted`](#quoted) - - [`recover`](#recover) - - [`right`](#right) - - [`separatedBy`](#separatedBy) - - [`separatedBy1`](#separatedBy1) - - [`separatedEndBy`](#separatedEndBy) - - [`separatedEndBy1`](#separatedEndBy1) - - [`separatedUntil`](#separatedUntil) - - [`sequence`](#sequence) - - [`skip`](#skip) - - [`skipMany`](#skipMany) - - [`skipMany1`](#skipMany1) - - [`surrounded`](#surrounded) - - [`unless`](#unless) - - [`until`](#until) - - [`validate`](#validate) - - [`value`](#value) - - [`when`](#when) -- [Utils](#utils) - - [`filter`](#filter) - - [`flatten`](#flatten) - - [`join`](#join) - - [`pick`](#pick) - - [`pipe`](#pipe) - - [`pop`](#pop) - - [`shift`](#shift) - - [`spread`](#spread) - -## Core Concepts - -### The Parser Type - -A `Parser` is a function that takes an input string and returns a `Result`. The generic `T` represents the type of value the parser produces. - -```typescript -type Parser = (input: string) => Result; -``` - -### The Result Type - -Every parser returns a `Result` which is either: - -- **Success** — The parser matched and produced a value -- **Failure** — The parser did not match - -```typescript -type Success = { ok: true; value: T; remaining: string }; -type Failure = { ok: false; error?: string }; -type Result = Success | Failure; -``` - -The `remaining` string is crucial — it represents what input is left after the parser has done its work. This is how we "consume" input and chain parsers together. - -### Creating a Parser - -Use `create` to wrap a parsing function: - -```typescript -import { create, success, failure } from 'unitas'; - -const parser = create((input) => { - if (input.startsWith('hello')) { - return success('hello', input.slice(5)); - } - return failure('expected "hello"'); -}); -``` - -### Understanding the Monadic Nature - -Parsers are monadic, which means they follow certain laws that make them composable: - -1. **Left identity**: `create(success(a, input))` behaves like `a` -2. **Right identity**: `parser` composed with `success` returns equivalent result -3. **Associativity**: Composition order doesn't affect final result - -The practical implication is that you can chain and combine parsers predictably. - -### Success Results - -When a parser successfully matches, it returns: - -```typescript -{ ok: true, value: 'hello', remaining: ' world' } - │ │ │ - │ │ └── What's left to parse - │ └── The parsed value - └── Always true for success -``` - -### Failure Results - -When a parser fails, it returns: - -```typescript -{ ok: false } // Generic failure -{ ok: false, error: 'expected a' } // Failure with message -``` - -The `error` field is optional — you can always add meaningful error messages later using `label`. - -## Core (`unitas`) - -**Core** provides the fundamental types and functions for building parsers. - -
-failure — Creates a failed result with an optional error message. - -```typescript -failure('unexpected input'); // { ok: false, error: 'unexpected input' } -``` - -
-
-grammar — Creates a recursive grammar where rules can reference each other. - -```typescript -type Math = { - expr: number; - term: number; - value: number; -}; -const g = grammar({ - expr: (p) => - chainLeft1( - p.term, - map(char('+'), () => (l, r) => l + r), - ), - term: (p) => - choice( - p.value, - map(sequence(char('('), p.expr, char(')')), ([, v]) => v), - ), - value: () => digits, -}); -run(g.expr, '1+2'); // 3 -run(g.expr, '1+2+3'); // 6 -run(g.expr, '(1+2)'); // 3 -``` - -
-
-label — Labels a parser with a custom error message on failure. - -```typescript -label(char('x'), 'letter x')(''); // { ok: false, error: 'expected letter x' } -``` - -
-
-lazy — Defers parser creation, useful for recursive grammars. - -```typescript -lazy(() => char('a'))('abc'); // { ok: true, value: 'a', remaining: 'bc' } -``` - -
-
-match — Pattern matching on a Result to handle success and failure cases. - -```typescript -match(success('hello', ''), { success: (v) => v, failure: () => 'failed' }); // 'hello' -``` - -
-
-memoize — Memoizes a parser to cache results by input string. Useful for expensive parsers and recursive grammars to avoid exponential backtracking. - -```typescript -const memoDigits = memoize(digits); -memoDigits('123'); // { ok: true, value: 123, remaining: '' } -``` - -
-
-parser — Creates a parser from a parser function. - -```typescript -create((input) => success('parsed', input.slice(6)))('hello world'); // { ok: true, value: 'parsed', remaining: 'world' } -``` - -
-
-run — Runs a parser and returns the value, throws on failure or unconsumed input. - -```typescript -run(string('hello'), 'hello'); // 'hello' -``` - -
-
-success — Creates a successful result with a value and remaining input. - -```typescript -success('hello', ' world'); // { ok: true, value: 'hello', remaining: ' world' } -``` - -
- -## Terminals (`unitas/terminals`) - -**Terminals** are the basic building blocks that match specific parts of the input. They don't combine other parsers — they directly inspect the input string. - -
-char — Parse a specific character. - -```typescript -char('A')('ABC'); // { ok: true, value: 'A', remaining: 'BC' } -``` - -
-
-charOf — Parse any character from a set. - -```typescript -charOf(['a', 'b', 'c'])('abc'); // { ok: true, value: 'a', remaining: 'bc' } -``` - -
-
-noneOf — Parse any character not in the set. - -```typescript -noneOf(['a', 'b', 'c'])('xyz'); // { ok: true, value: 'x', remaining: 'yz' } -``` - -
-
-oneOf — Parse one string from a set of strings (longest match wins). - -```typescript -oneOf(['hello', 'hell', 'help'])('helpful'); // { ok: true, value: 'help', remaining: 'ful' } -``` - -
-
-regex — Parse with a regular expression. - -```typescript -regex(/^\w+/)('hello world'); // { ok: true, value: 'hello', remaining: ' world' } -``` - -
-
-satisfy — Parse a character satisfying a predicate. - -```typescript -satisfy((c) => c === 'a')('abc'); // { ok: true, value: 'a', remaining: 'bc' } -``` - -
-
-string — Parse a specific string. - -```typescript -string('hello')('hello world'); // { ok: true, value: 'hello', remaining: ' world' } -``` - -
-
-stringOf — Parse first character that exists in string (like charOf but for a string). - -```typescript -stringOf('abc')('abcdef'); // { ok: true, value: 'a', remaining: 'bcdef' } -``` - -
-
-take — Take n characters. - -```typescript -take(3)('abcdef'); // { ok: true, value: 'abc', remaining: 'def' } -``` - -
-
-takeWhile — Takes characters while the predicate returns true. - -```typescript -takeWhile((c) => c !== 'x')('abcx'); // { ok: true, value: 'abc', remaining: 'x' } -``` - -
-
-token — Parses a string as a token, skipping trailing whitespace. Unlike word, it does not enforce word boundaries - useful for parsing strings that might be followed by any character. - -```typescript -token('let')('let x'); // { ok: true, value: 'let', remaining: 'x' } -token('let')('let1'); // { ok: true, value: 'let', remaining: '1' } -token('let')('let x'); // { ok: true, value: 'let', remaining: 'x' } -``` - -
-
-word — Parses a specific word and ensures it is not followed by word characters. Use this when parsing keywords that should not be part of a longer identifier. - -```typescript -word('let')('let x'); // { ok: true, value: 'let', remaining: 'x' } -word('let')('let1'); // { ok: false } -word('if')('if (x)'); // { ok: true, value: 'if', remaining: '(x)' } -``` - -
- -## Primitives (`unitas/primitives`) - -**Primitives** are pre-built parser instances ready to use. Unlike terminals which are factory functions (like `char('x')`), primitives are constants you can pass directly to combinators. - -
-alphaNum — Parse a single alphanumeric character. - -```typescript -alphaNum('a1'); // { ok: true, value: 'a', remaining: '1' } -alphaNum('1a'); // { ok: true, value: '1', remaining: 'a' } -``` - -
-
-alphaNums — Parse one or more alphanumeric characters. - -```typescript -alphaNums('abc123'); // { ok: true, value: 'abc123', remaining: '' } -``` - -
-
-anyChar — Parse any single character. - -```typescript -anyChar('abc'); // { ok: true, value: 'a', remaining: 'bc' } -``` - -
-
-bool — Parse a boolean literal. - -```typescript -bool('true'); // { ok: true, value: true, remaining: '' } -bool('false'); // { ok: true, value: false, remaining: '' } -bool('trueABC'); // { ok: true, value: true, remaining: 'ABC' } -``` - -
-
-crlf — Parse CRLF line ending. - -```typescript -crlf('\r\nabc'); // { ok: true, value: '\r\n', remaining: 'abc' } -``` - -
-
-digit — Parse a single digit and return as number. - -```typescript -digit('5abc'); // { ok: true, value: 5, remaining: 'abc' } -``` - -
-
-digits — Parse one or more digits and return as number. - -```typescript -digits('123abc'); // { ok: true, value: 123, remaining: 'abc' } -``` - -
-
-eof — Parse end of file (succeeds only on empty input). - -```typescript -eof(''); // { ok: true, value: null, remaining: '' } -``` - -
-
-eol — Parse end of line (\\n, \\r\\n, or EOF). - -```typescript -eol('\nabc'); // { ok: true, value: '\n', remaining: 'abc' } -``` - -
-
-float — Parse a floating point number. - -```typescript -float('1.23'); // { ok: true, value: 1.23, remaining: '' } -float('-2.5'); // { ok: true, value: -2.5, remaining: '' } -float('1.23abc'); // { ok: true, value: 1.23, remaining: 'abc' } -``` - -
-
-hexDigit — Parse a single hexadecimal digit. - -```typescript -hexDigit('fF9'); // { ok: true, value: 'f', remaining: 'F9' } -``` - -
-
-hexDigits — Parse one or more hexadecimal digits. - -```typescript -hexDigits('deadbeef'); // { ok: true, value: 'deadbeef', remaining: '' } -``` - -
-
-identifier — Parse an identifier — starts with letter or underscore, no leading digit, no hyphen. - -```typescript -identifier('variable_name'); // { ok: true, value: 'variable_name', remaining: '' } -``` - -
-
-integer — Parse a signed integer. - -```typescript -integer('42'); // { ok: true, value: 42, remaining: '' } -integer('-7'); // { ok: true, value: -7, remaining: '' } -integer('123abc'); // { ok: true, value: 123, remaining: 'abc' } -``` - -
-
-letter — Parse a single letter. - -```typescript -letter('abc'); // { ok: true, value: 'a', remaining: 'bc' } -``` - -
-
-letters — Parse one or more letters. - -```typescript -letters('abc123'); // { ok: true, value: 'abc', remaining: '123' } -``` - -
-
-line — Parse until end of line. - -```typescript -line('hello\nworld'); // { ok: true, value: 'hello', remaining: '\nworld' } -``` - -
-
-literal — Parse a word-like value including hyphens. - -```typescript -literal('foo-bar'); // { ok: true, value: 'foo-bar', remaining: '' } -literal('123abc'); // { ok: true, value: '123abc', remaining: '' } -``` - -
-
-lowercase — Parse a single lowercase letter. - -```typescript -lowercase('abc'); // { ok: true, value: 'a', remaining: 'bc' } -``` - -
-
-lowercases — Parses one or more lowercase letters. - -```typescript -lowercases('abcDEF'); // { ok: true, value: 'abc', remaining: 'DEF' } -``` - -
-
-nl — Parse a newline character. - -```typescript -nl('\ntext'); // { ok: true, value: '\n', remaining: 'text' } -``` - -
-
-number — Parse an integer or float. - -```typescript -number('42'); // { ok: true, value: 42, remaining: '' } -number('3.14'); // { ok: true, value: 3.14, remaining: '' } -number('-7'); // { ok: true, value: -7, remaining: '' } -number('-2.5'); // { ok: true, value: -2.5, remaining: '' } -``` - -
-
-octDigit — Parse a single octal digit. - -```typescript -octDigit('7abc'); // { ok: true, value: '7', remaining: 'abc' } -``` - -
-
-octDigits — Parse one or more octal digits. - -```typescript -octDigits('0777abc'); // { ok: true, value: '0777', remaining: 'abc' } -``` - -
-
-position — Get current position (remaining input length). - -```typescript -position('abc'); // { ok: true, value: 3, remaining: 'abc' } -``` - -
-
-rest — Parse the rest of the input. - -```typescript -rest('hello'); // { ok: true, value: 'hello', remaining: '' } -``` - -
-
-space — Parse a single space character. - -```typescript -space(' abc'); // { ok: true, value: ' ', remaining: 'abc' } -``` - -
-
-spaces — Parse one or more space characters. - -```typescript -spaces(' abc'); // { ok: true, value: ' ', remaining: 'abc' } -``` - -
-
-tab — Parse tab character. - -```typescript -tab('\ttext'); // { ok: true, value: '\t', remaining: 'text' } -``` - -
-
-uppercase — Parses a single uppercase letter. - -```typescript -uppercase('ABC'); // { ok: true, value: 'A', remaining: 'BC' } -``` - -
-
-uppercases — Parses one or more uppercase letters. - -```typescript -uppercases('ABCdef'); // { ok: true, value: 'ABC', remaining: 'def' } -``` - -
-
-whitespace — Parses a single whitespace character. - -```typescript -whitespace(' abc'); // { ok: true, value: ' ', remaining: 'abc' } -``` - -
-
-whitespaces — Parses one or more whitespace characters. - -```typescript -whitespaces(' abc'); // { ok: true, value: ' ', remaining: 'abc' } -``` - -
- -## Combinators (`unitas/combinators`) - -**Combinators** are functions that take one or more parsers and return a new parser. They are the "glue" that lets you compose complex parsers from simple ones. - -
-attempt — Attempt wraps a parser to handle backtracking on failure. - -```typescript -attempt(string('hello'))('hello world'); // { ok: true, value: 'hello', remaining: ' world' } -``` - -
-
-bind — Chain parsers where the second parser depends on the first result. - -```typescript -bind(digits, (n) => take(n))('3abc'); // { ok: true, value: 'abc', remaining: '' } -``` - -
-
-braced — Parse content surrounded by braces. - -```typescript -braced(string('hi'))('{hi}'); // { ok: true, value: 'hi', remaining: '' } -``` - -
-
-bracketed — Parse content surrounded by brackets. - -```typescript -bracketed(string('hi'))('[hi]'); // { ok: true, value: 'hi', remaining: '' } -``` - -
-
-chainLeft — Chain left-associative operations (right-to-left for same precedence). - -```typescript -chainLeft(digits, operation)('1+2+3'); // { ok: true, value: 6, remaining: '' } -chainLeft(digits, operation)('10-3+2'); // { ok: true, value: 9, remaining: '' } -``` - -
-
-chainLeft1 — Chain left-associative operations (fails on empty input). - -```typescript -chainLeft1(digits, operation)('1+2+3'); // { ok: true, value: 6, remaining: '' } -chainLeft1(digits, operation)('8/2*3'); // { ok: true, value: 12, remaining: '' } -``` - -
-
-chainRight — Chain right-associative operations (right-to-left grouping). - -```typescript -chainRight(digits, operation)('2-1-1'); // { ok: true, value: 2, remaining: '' } -chainRight(digits, operation)('4/2/2'); // { ok: true, value: 4, remaining: '' } -``` - -
-
-chainRight1 — Chain right-associative operations (fails on empty input). - -```typescript -chainRight1(digits, operation)('2-1-1'); // { ok: true, value: 2, remaining: '' } -chainRight1(digits, operation)('4/2/2'); // { ok: true, value: 4, remaining: '' } -``` - -
-
-choice — Try each parser in order, return first success. - -```typescript -choice(string('hello'), string('world'))('hello'); // { ok: true, value: 'hello', remaining: '' } -``` - -
-
-concat — Join string array parser result into a single string. - -```typescript -concat(many(letter))('abc123'); // { ok: true, value: 'abc', remaining: '123' } -concat(many(letter), '-')('abc123'); // { ok: true, value: 'a-b-c', remaining: '123' } -``` - -
-
-consume — Consume input but discard the result (return null). - -```typescript -consume(string('hello'))('hello world'); // { ok: true, value: null, remaining: ' world' } -``` - -
-
-endBy — Zero or more items separated and ending with terminator. - -```typescript -endBy(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['item', 'item', 'item'], remaining: '' } -``` - -
-
-endBy1 — One or more items separated and ending with terminator. - -```typescript -endBy1(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['item', 'item', 'item'], remaining: '' } -``` - -
-
-exactly — Parse exactly n occurrences. - -```typescript -exactly(char('a'), 3)('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } -``` - -
-
-first — Extract the first element from a parser result array. - -```typescript -first(sequence(char('a'), digit))('a1bc'); // { ok: true, value: 'a', remaining: 'bc' } -``` - -
-
-flag — Return true if parser succeeds, false otherwise. Always succeeds without consuming input on failure. - -```typescript -flag(string('*'))('*abc'); // { ok: true, value: true, remaining: 'abc' } -flag(string('*'))('abc'); // { ok: true, value: false, remaining: 'abc' } -``` - -
-
-fold — Parse zero or more and fold into a single value. - -```typescript -fold(digit, [], (acc, d) => [...acc, d])('123'); // { ok: true, value: [1, 2, 3], remaining: '' } -``` - -
-
-fold1 — Parse one or more and fold into a single value. - -```typescript -fold1(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, remaining: '' } -``` - -
-
-foldRight — Parse zero or more and fold right-to-left. - -```typescript -foldRight(digit, [], (acc, d) => [...acc, d])('123'); // { ok: true, value: [3, 2, 1], remaining: '' } -``` - -
-
-foldRight1 — Parse one or more and fold right-to-left. - -```typescript -foldRight1(digit, [], (acc, d) => [...acc, d])('123'); // { ok: true, value: [3, 2, 1], remaining: '' } -``` - -
-
-fuse — Fuse multiple string parsers into a single one. The fused parser concatenates all string results. - -```typescript -fuse(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: 'abc', remaining: '' } -fuse(string('hello'), char(' '), string('world'))('hello world'); // { ok: true, value: 'hello world', remaining: '' } -``` - -
-
-guard — Conditionally apply parser based on a condition. - -```typescript -guard(true, string('hello'))('hello'); // { ok: true, value: 'hello', remaining: '' } -guard(false, string('hello'))('hello'); // { ok: false } -``` - -
-
-inner — Extract inner value from surrounded content (like inner of braced). - -```typescript -inner(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: 'hi', remaining: '' } -``` - -
-
-interleaved — Parse items with interleaved separators. - -```typescript -interleaved(char('a'), char(','))('a,a,a'); // { ok: true, value: ['a', ',', 'a', ',', 'a'], remaining: '' } -``` - -
-
-last — Extract the last element from a parser result array. - -```typescript -last(sequence(char('a'), char('b')))('ab'); // { ok: true, value: 'b', remaining: '' } -``` - -
-
-left — Keep only the left result from a sequence. - -```typescript -left(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'hello', remaining: '' } -``` - -
-
-lexeme — Parser that consumes trailing whitespace. - -```typescript -lexeme(string('hello'))('hello world'); // { ok: true, value: 'hello', remaining: 'world' } -``` - -
-
-many — Zero or more occurrences (never fails). - -```typescript -many(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } -``` - -
-
-many1 — One or more occurrences (fails if no matches). - -```typescript -many1(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } -``` - -
-
-manyAtLeast — Parse at least n occurrences. - -```typescript -manyAtLeast(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } -``` - -
-
-manyAtMost — Parse at most n occurrences. - -```typescript -manyAtMost(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a'], remaining: 'a' } -``` - -
-
-manyBetween — Parse between min and max occurrences. - -```typescript -manyBetween(char('a'), 2, 3)('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } -``` - -
-
-manyTill — Parse zero or more until terminator matches. - -```typescript -manyTill(char('a'), char('b'))('aaab'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } -``` - -
-
-map — Transform the parsed value. - -```typescript -map(string('hello'), (v) => v.toUpperCase())('hello'); // { ok: true, value: 'HELLO', remaining: '' } -``` - -
-
-node — Create a node from parser fields. - -```typescript -node('binop', { left: digits, op: char('+'), right: digits })('1+2'); // { ok: true, value: { type: 'binop', left: 1, op: '+', right: 2 }, remaining: '' } -node('number', { value: digits })('123'); // { ok: true, value: { type: 'number', value: 123 }, remaining: '' } -``` - -
-
-not — Succeed if parser fails (without consuming input). - -```typescript -not(string('hello'))('world'); // { ok: true, value: null, remaining: 'world' } -``` - -
-
-nth — Extract the nth element from a parser result array. - -```typescript -nth(sequence(char('a'), char('b'), char('c')), 1)('abc'); // { ok: true, value: 'b', remaining: '' } -``` - -
-
-optional — Make parser optional (return null on failure, without consuming input). - -```typescript -optional(string('hello'))('hello'); // { ok: true, value: 'hello', remaining: '' } -optional(string('hello'))('world'); // { ok: true, value: null, remaining: 'world' } -``` - -
-
-optionalConsume — Optionally consume input (always succeeds, returns void). - -```typescript -optionalConsume(string('hello'))('hello world'); // { ok: true, value: undefined, remaining: ' world' } -optionalConsume(string('hello'))('world'); // { ok: true, value: undefined, remaining: 'world' } -``` - -
-
-optionalSeparatedBy — Zero or more items separated by a separator, with optional null values. - -```typescript -optionalSeparatedBy(digits, char(','))('1,2'); // { ok: true, value: [1, 2], remaining: '' } -optionalSeparatedBy(digits, char(','))(',1'); // { ok: true, value: [null, 1], remaining: '' } -optionalSeparatedBy(digits, char(','))('1,'); // { ok: true, value: [1], remaining: '' } -``` - -
-
-outer — Extract outer values from a sequence of 3 parsers (skip middle). - -```typescript -outer(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: ['(', ')'], remaining: '' } -``` - -
-
-padded — Parse content with optional whitespace on both sides. - -```typescript -padded(string('hi'))(' hi '); // { ok: true, value: 'hi', remaining: '' } -``` - -
-
-parenthesized — Parse content surrounded by parentheses. - -```typescript -parenthesized(string('hi'))('(hi)'); // { ok: true, value: 'hi', remaining: '' } -``` - -
-
-peek — Parse without consuming input. - -```typescript -peek(string('hello'))('hello world'); // { ok: true, value: 'hello', remaining: 'hello world' } -``` - -
-
-postfix — Parse postfix operators (chains atom with operators that return functions). - -```typescript -postfix( - char('a'), - map(char('!'), () => (x) => x), -)('a!'); // { ok: true, value: 'a', remaining: '' } -``` - -
-
-prefix — Parse prefix operators (like - in -5). - -```typescript -prefix( - map(char('-'), () => (x) => -x), - digit, -)('-5'); // { ok: true, value: -5, remaining: '' } -``` - -
-
-pure — Always return a value without consuming input. - -```typescript -pure(42)('abc'); // { ok: true, value: 42, remaining: 'abc' } -pure('ok')(''); // { ok: true, value: 'ok', remaining: '' } -``` - -
-
-quoted — Parse content surrounded by single or double quotes. - -```typescript -quoted(string('hello'))('"hello"'); // { ok: true, value: 'hello', remaining: '' } -``` - -
-
-recover — Use fallback value when parser fails. - -```typescript -recover(string('hello'), 'default')('world'); // { ok: true, value: 'default', remaining: 'world' } -``` - -
- -
-separatedBy — Zero or more items separated by a separator. - -```typescript -separatedBy(char('a'), char(','))('a,a,a'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } -``` - -
-
-separatedBy1 — One or more items separated by a separator. - -```typescript -separatedBy1(char('a'), char(','))('a,a,a'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } -``` - -
-
-separatedEndBy — Zero or more items separated by and ending with a terminator. - -```typescript -separatedEndBy(char('a'), char(';'))('a;a;a;'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } -``` - -
-
-separatedEndBy1 — One or more items separated by and ending with a terminator. - -```typescript -separatedEndBy1(char('a'), char(';'))('a;a;a;'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } -``` - -
-
-separatedUntil — Parse items separated by separator until terminator matches. - -```typescript -separatedUntil(char('a'), char(','), char(';'))('a,a,a;'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } -``` - -
-
-sequence — Parse a sequence of parsers and return all results as an array. - -```typescript -sequence(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: ['a', 'b', 'c'], remaining: '' } -``` - -
-
-skip — Skip a parser n times. - -```typescript -skip(char('a'), 2)('aabc'); // { ok: true, value: null, remaining: 'bc' } -``` - -
-
-skipMany — Skip zero or more occurrences (never fails, returns null). - -```typescript -skipMany(char('a'))('aaabc'); // { ok: true, value: null, remaining: 'bc' } -``` - -
-
-skipMany1 — Skip one or more occurrences (fails if no matches). - -```typescript -skipMany1(char('a'))('aaabc'); // { ok: true, value: null, remaining: 'bc' } -``` - -
-
-surrounded — Parse content surrounded by delimiters. - -```typescript -surrounded(char('['), string('hi'), char(']'))('[hi]'); // { ok: true, value: 'hi', remaining: '' } -surrounded(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: 'b', remaining: '' } -``` - -
-
-unless — Parse unless condition is true (inverse of guard). - -```typescript -unless(false, string('hello'))('hello'); // { ok: true, value: 'hello', remaining: '' } -unless(true, string('hello'))('hello'); // { ok: true, value: null, remaining: 'hello' } -``` - -
-
-until — Parse until terminator matches (fails if terminator never matches). - -```typescript -until(char('a'), char('b'))('baaa'); // { ok: true, value: [], remaining: 'baaa' } -until(char('a'), char('b'))('aaba'); // { ok: true, value: ['a', 'a'], remaining: 'ba' } -``` - -
-
-validate — Validate parsed value with a predicate. - -```typescript -validate(digit, (n) => n > 5)('7'); // { ok: true, value: 7, remaining: '' } -validate(digit, (n) => n > 5)('3'); // { ok: false } -``` - -
-
-value — Replace parsed value with a constant. - -```typescript -value(string('true'), true)('true'); // { ok: true, value: true, remaining: '' } -value(string('null'), null)('null'); // { ok: true, value: null, remaining: '' } -``` - -
-
-when — Branch on a boolean parser result. - -```typescript -when(flag(char('*')), pure('many'), pure('one'))('*rest'); // { ok: true, value: 'many', remaining: 'rest' } -when(flag(char('*')), pure('many'), pure('one'))('abc'); // { ok: true, value: 'one', remaining: 'abc' } -``` - -
- -## Utils (`unitas/utils`) - -**Utils** are utility functions for working with parser results, arrays, and function composition. - -
-filter — Exclude values from array. - -```typescript -filter([1, 2, 3])([1, 2, 3, 4, 5]); // [4, 5] -filter([1, 2], true)([1, false, 3]); // [3] -``` - -
-
-flatten — Flatten nested arrays. - -```typescript -flatten()([1, [2, [3]]]); // [1, 2, [3]] -flatten(2)([1, [2, [3]]]); // [1, 2, 3] -``` - -
-
-join — Join array elements into a string. - -```typescript -join()([1, 2, 3]); // '123' -join('-')([1, 2, 3]); // '1-2-3' -``` - -
-
-pick — Pick elements from an array by index. - -```typescript -pick(0, 2)(['a', 'b', 'c']); // ['a', 'c'] -pick(2, 4)(['a', 'b', 'c', 'd', 'e']); // ['c', 'e'] -``` - -
-
-pipe — Pipe parser functions together. - -```typescript -pipe(lexeme)(letters)('xyz abc'); // { ok: true, value: 'xyz', remaining: 'abc' } -``` - -
-
-pop — Get the last element of an array. - -```typescript -pop()([1, 2, 3]); // 3 -``` - -
-
-shift — Get the first element of an array. - -```typescript -shift()([1, 2, 3]); // 1 -``` - -
-
-spread — Collect spread arguments into an array. - -```typescript -spread()(1, 2, 3); // [1, 2, 3] -``` - -
- -## License - -MIT diff --git a/README.md b/README.md new file mode 100644 index 0000000..aac644c --- /dev/null +++ b/README.md @@ -0,0 +1,609 @@ + + +# 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](https://img.shields.io/npm/v/unitas)](https://www.npmjs.com/package/unitas) +[![Coverage](https://coveralls.io/repos/github/sovrin/unitas/badge.svg?branch=master)](https://coveralls.io/github/sovrin/unitas?branch=master) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +```bash +npm install unitas +``` + +```typescript +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 +- **127 composable exports** — grouped by what they do, indexed below +- **Mutual recursion out of the box** via `grammar`, no forward declarations + +> **Note:** This library is in active development. The API may change before v1.0.0. + +## Contents + +- [Entry points](#entry-points) +- [Which function do I need?](#which-function-do-i-need) +- [Core concepts](#core-concepts) +- [More examples](#more-examples) +- [API index](#api-index) + +## Entry points + +Nothing is re-exported across entry points, so your bundler only ever sees what you import. + +| 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](#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 much input is left? `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` | +| undo input consumption on failure | `attempt` | + +### 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 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` is a function from an input string to a `Result`. That is the whole abstraction — everything else in this library either produces one or wraps one. + +```typescript +type Parser = (input: string) => Result; +``` + +### The `Result` type + +```typescript +type Success = { ok: true; value: T; remaining: string }; +type Failure = { ok: false; error?: string }; +type Result = Success | Failure; +``` + +```typescript +{ ok: true, value: 'hello', remaining: ' world' } + │ │ │ + │ │ └── what is left to parse + │ └── the parsed value + └── always true for success +``` + +`remaining` is the important part: it is how input gets consumed and how parsers chain. A failure carries an optional message, which you can always supply later with `label`. + +```typescript +{ ok: false } // generic failure +{ ok: false, error: 'expected a' } // failure with a message +``` + +### Writing one by hand + +Most of the time you compose existing pieces, but nothing stops you from dropping down a level: + +```typescript +import { create, success, failure } from 'unitas'; + +const parser = create((input) => { + if (input.startsWith('hello')) { + return success('hello', input.slice(5)); + } + + return failure('expected "hello"'); +}); +``` + +### Grammars + +`grammar` hands every rule a proxy `p`, so rules can reference each other — including themselves — without forward declarations or `lazy` boilerplate. + +```typescript +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({ + 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 +``` + +## More examples + +**JSON value** — a tagged union of scalars + +```typescript +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 + +```typescript +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; + param: [string, string]; + key: string; + value: string; +}; + +const query = grammar({ + 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 + +```typescript +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`](https://github.com/sovrin/unitas/tree/master/doc/api). + +### Core — `unitas` + +Types, constructors and the grammar runner. **9** exports — [full reference →][api-core] + +| | | +| ------------------------- | --------------------------------------------------------------------------- | +| [`failure`][core-failure] | Creates a failed result with an optional error message. | +| [`grammar`][core-grammar] | Creates a recursive grammar where rules can reference each other. | +| [`label`][core-label] | Labels a parser with a custom error message on failure. | +| [`lazy`][core-lazy] | Defers parser creation, useful for recursive grammars. | +| [`match`][core-match] | Pattern matching on a Result to handle success and failure cases. | +| [`memoize`][core-memoize] | Memoizes a parser to cache results by input string. | +| [`parser`][core-parser] | Creates a parser from a parser function. | +| [`run`][core-run] | Runs a parser and returns the value, throws on failure or unconsumed input. | +| [`success`][core-success] | Creates a successful result with a value and remaining input. | + +### Terminals — `unitas/terminals` + +Factories that match the input string directly. **12** exports — [full reference →][api-terminals] + +| | | +| ---------------------------------- | --------------------------------------------------------------------------- | +| [`char`][terminals-char] | Parse a specific character. | +| [`charOf`][terminals-charof] | Parse any character from a set. | +| [`noneOf`][terminals-noneof] | Parse any character not in the set. | +| [`oneOf`][terminals-oneof] | Parse one string from a set of strings (longest match wins). | +| [`regex`][terminals-regex] | Parse with a regular expression. | +| [`satisfy`][terminals-satisfy] | Parse a character satisfying a predicate. | +| [`string`][terminals-string] | Parse a specific string. | +| [`stringOf`][terminals-stringof] | Parse first character that exists in string (like charOf but for a string). | +| [`take`][terminals-take] | Take n characters. | +| [`takeWhile`][terminals-takewhile] | Takes characters while the predicate returns true. | +| [`token`][terminals-token] | Parses a string as a token, skipping trailing whitespace. | +| [`word`][terminals-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 →][api-primitives] + +| | | +| --------------------------------------- | ------------------------------------------------------------------------------------ | +| [`alphaNum`][primitives-alphanum] | Parse a single alphanumeric character. | +| [`alphaNums`][primitives-alphanums] | Parse one or more alphanumeric characters. | +| [`anyChar`][primitives-anychar] | Parse any single character. | +| [`bool`][primitives-bool] | Parse a boolean literal. | +| [`crlf`][primitives-crlf] | Parse CRLF line ending. | +| [`digit`][primitives-digit] | Parse a single digit and return as number. | +| [`digits`][primitives-digits] | Parse one or more digits and return as number. | +| [`eof`][primitives-eof] | Parse end of file (succeeds only on empty input). | +| [`eol`][primitives-eol] | Parse end of line (\\n, \\r\\n, or EOF). | +| [`float`][primitives-float] | Parse a floating point number. | +| [`hexDigit`][primitives-hexdigit] | Parse a single hexadecimal digit. | +| [`hexDigits`][primitives-hexdigits] | Parse one or more hexadecimal digits. | +| [`identifier`][primitives-identifier] | Parse an identifier — starts with letter or underscore, no leading digit, no hyphen. | +| [`integer`][primitives-integer] | Parse a signed integer. | +| [`letter`][primitives-letter] | Parse a single letter. | +| [`letters`][primitives-letters] | Parse one or more letters. | +| [`line`][primitives-line] | Parse until end of line. | +| [`literal`][primitives-literal] | Parse a word-like value including hyphens. | +| [`lowercase`][primitives-lowercase] | Parse a single lowercase letter. | +| [`lowercases`][primitives-lowercases] | Parses one or more lowercase letters. | +| [`nl`][primitives-nl] | Parse a newline character. | +| [`number`][primitives-number] | Parse an integer or float. | +| [`octDigit`][primitives-octdigit] | Parse a single octal digit. | +| [`octDigits`][primitives-octdigits] | Parse one or more octal digits. | +| [`position`][primitives-position] | Get current position (remaining input length). | +| [`rest`][primitives-rest] | Parse the rest of the input. | +| [`space`][primitives-space] | Parse a single space character. | +| [`spaces`][primitives-spaces] | Parse one or more space characters. | +| [`tab`][primitives-tab] | Parse tab character. | +| [`uppercase`][primitives-uppercase] | Parses a single uppercase letter. | +| [`uppercases`][primitives-uppercases] | Parses one or more uppercase letters. | +| [`whitespace`][primitives-whitespace] | Parses a single whitespace character. | +| [`whitespaces`][primitives-whitespaces] | Parses one or more whitespace characters. | + +### Combinators — `unitas/combinators` + +Take parsers, return a new parser. **65** exports — [full reference →][api-combinators] + +| | | +| -------------------------------------------------------- | --------------------------------------------------------------------------- | +| [`attempt`][combinators-attempt] | Attempt wraps a parser to handle backtracking on failure. | +| [`bind`][combinators-bind] | Chain parsers where the second parser depends on the first result. | +| [`braced`][combinators-braced] | Parse content surrounded by braces. | +| [`bracketed`][combinators-bracketed] | Parse content surrounded by brackets. | +| [`chainLeft`][combinators-chainleft] | Chain left-associative operations (right-to-left for same precedence). | +| [`chainLeft1`][combinators-chainleft1] | Chain left-associative operations (fails on empty input). | +| [`chainRight`][combinators-chainright] | Chain right-associative operations (right-to-left grouping). | +| [`chainRight1`][combinators-chainright1] | Chain right-associative operations (fails on empty input). | +| [`choice`][combinators-choice] | Try each parser in order, return first success. | +| [`concat`][combinators-concat] | Join string array parser result into a single string. | +| [`consume`][combinators-consume] | Consume input but discard the result (return null). | +| [`endBy`][combinators-endby] | Zero or more items separated and ending with terminator. | +| [`endBy1`][combinators-endby1] | One or more items separated and ending with terminator. | +| [`exactly`][combinators-exactly] | Parse exactly n occurrences. | +| [`first`][combinators-first] | Extract the first element from a parser result array. | +| [`flag`][combinators-flag] | Return true if parser succeeds, false otherwise. | +| [`fold`][combinators-fold] | Parse zero or more and fold into a single value. | +| [`fold1`][combinators-fold1] | Parse one or more and fold into a single value. | +| [`foldRight`][combinators-foldright] | Parse zero or more and fold right-to-left. | +| [`foldRight1`][combinators-foldright1] | Parse one or more and fold right-to-left. | +| [`fuse`][combinators-fuse] | Fuse multiple string parsers into a single one. | +| [`guard`][combinators-guard] | Conditionally apply parser based on a condition. | +| [`inner`][combinators-inner] | Extract inner value from surrounded content (like inner of braced). | +| [`interleaved`][combinators-interleaved] | Parse items with interleaved separators. | +| [`last`][combinators-last] | Extract the last element from a parser result array. | +| [`left`][combinators-left] | Keep only the left result from a sequence. | +| [`lexeme`][combinators-lexeme] | Parser that consumes trailing whitespace. | +| [`many`][combinators-many] | Zero or more occurrences (never fails). | +| [`many1`][combinators-many1] | One or more occurrences (fails if no matches). | +| [`manyAtLeast`][combinators-manyatleast] | Parse at least n occurrences. | +| [`manyAtMost`][combinators-manyatmost] | Parse at most n occurrences. | +| [`manyBetween`][combinators-manybetween] | Parse between min and max occurrences. | +| [`manyTill`][combinators-manytill] | Parse zero or more until terminator matches. | +| [`map`][combinators-map] | Transform the parsed value. | +| [`node`][combinators-node] | Create a node from parser fields. | +| [`not`][combinators-not] | Succeed if parser fails (without consuming input). | +| [`nth`][combinators-nth] | Extract the nth element from a parser result array. | +| [`optional`][combinators-optional] | Make parser optional (return null on failure, without consuming input). | +| [`optionalConsume`][combinators-optionalconsume] | Optionally consume input (always succeeds, returns void). | +| [`optionalSeparatedBy`][combinators-optionalseparatedby] | Zero or more items separated by a separator, with optional null values. | +| [`outer`][combinators-outer] | Extract outer values from a sequence of 3 parsers (skip middle). | +| [`padded`][combinators-padded] | Parse content with optional whitespace on both sides. | +| [`parenthesized`][combinators-parenthesized] | Parse content surrounded by parentheses. | +| [`peek`][combinators-peek] | Parse without consuming input. | +| [`postfix`][combinators-postfix] | Parse postfix operators (chains atom with operators that return functions). | +| [`prefix`][combinators-prefix] | Parse prefix operators (like - in -5). | +| [`pure`][combinators-pure] | Always return a value without consuming input. | +| [`quoted`][combinators-quoted] | Parse content surrounded by single or double quotes. | +| [`recover`][combinators-recover] | Use fallback value when parser fails. | +| [`right`][combinators-right] | Keep only the right result from a sequence. | +| [`separatedBy`][combinators-separatedby] | Zero or more items separated by a separator. | +| [`separatedBy1`][combinators-separatedby1] | One or more items separated by a separator. | +| [`separatedEndBy`][combinators-separatedendby] | Zero or more items separated by and ending with a terminator. | +| [`separatedEndBy1`][combinators-separatedendby1] | One or more items separated by and ending with a terminator. | +| [`separatedUntil`][combinators-separateduntil] | Parse items separated by separator until terminator matches. | +| [`sequence`][combinators-sequence] | Parse a sequence of parsers and return all results as an array. | +| [`skip`][combinators-skip] | Skip a parser n times. | +| [`skipMany`][combinators-skipmany] | Skip zero or more occurrences (never fails, returns null). | +| [`skipMany1`][combinators-skipmany1] | Skip one or more occurrences (fails if no matches). | +| [`surrounded`][combinators-surrounded] | Parse content surrounded by delimiters. | +| [`unless`][combinators-unless] | Parse unless condition is true (inverse of guard). | +| [`until`][combinators-until] | Parse until terminator matches (fails if terminator never matches). | +| [`validate`][combinators-validate] | Validate parsed value with a predicate. | +| [`value`][combinators-value] | Replace parsed value with a constant. | +| [`when`][combinators-when] | Branch on a boolean parser result. | + +### Utils — `unitas/utils` + +Plain helpers for `map` callbacks. **8** exports — [full reference →][api-utils] + +| | | +| -------------------------- | --------------------------------------- | +| [`filter`][utils-filter] | Exclude values from array. | +| [`flatten`][utils-flatten] | Flatten nested arrays. | +| [`join`][utils-join] | Join array elements into a string. | +| [`pick`][utils-pick] | Pick elements from an array by index. | +| [`pipe`][utils-pipe] | Pipe parser functions together. | +| [`pop`][utils-pop] | Get the last element of an array. | +| [`shift`][utils-shift] | Get the first element of an array. | +| [`spread`][utils-spread] | Collect spread arguments into an array. | + +[api-core]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md +[core-failure]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#failure +[core-grammar]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#grammar +[core-label]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#label +[core-lazy]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#lazy +[core-match]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#match +[core-memoize]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#memoize +[core-parser]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#parser +[core-run]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#run +[core-success]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#success +[api-terminals]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md +[terminals-char]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md#char +[terminals-charof]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md#charof +[terminals-noneof]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md#noneof +[terminals-oneof]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md#oneof +[terminals-regex]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md#regex +[terminals-satisfy]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md#satisfy +[terminals-string]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md#string +[terminals-stringof]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md#stringof +[terminals-take]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md#take +[terminals-takewhile]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md#takewhile +[terminals-token]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md#token +[terminals-word]: https://github.com/sovrin/unitas/blob/master/doc/api/terminals.md#word +[api-primitives]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md +[primitives-alphanum]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#alphanum +[primitives-alphanums]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#alphanums +[primitives-anychar]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#anychar +[primitives-bool]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#bool +[primitives-crlf]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#crlf +[primitives-digit]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#digit +[primitives-digits]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#digits +[primitives-eof]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#eof +[primitives-eol]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#eol +[primitives-float]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#float +[primitives-hexdigit]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#hexdigit +[primitives-hexdigits]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#hexdigits +[primitives-identifier]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#identifier +[primitives-integer]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#integer +[primitives-letter]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#letter +[primitives-letters]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#letters +[primitives-line]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#line +[primitives-literal]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#literal +[primitives-lowercase]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#lowercase +[primitives-lowercases]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#lowercases +[primitives-nl]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#nl +[primitives-number]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#number +[primitives-octdigit]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#octdigit +[primitives-octdigits]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#octdigits +[primitives-position]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#position +[primitives-rest]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#rest +[primitives-space]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#space +[primitives-spaces]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#spaces +[primitives-tab]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#tab +[primitives-uppercase]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#uppercase +[primitives-uppercases]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#uppercases +[primitives-whitespace]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#whitespace +[primitives-whitespaces]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#whitespaces +[api-combinators]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md +[combinators-attempt]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#attempt +[combinators-bind]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#bind +[combinators-braced]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#braced +[combinators-bracketed]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#bracketed +[combinators-chainleft]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#chainleft +[combinators-chainleft1]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#chainleft1 +[combinators-chainright]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#chainright +[combinators-chainright1]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#chainright1 +[combinators-choice]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#choice +[combinators-concat]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#concat +[combinators-consume]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#consume +[combinators-endby]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#endby +[combinators-endby1]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#endby1 +[combinators-exactly]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#exactly +[combinators-first]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#first +[combinators-flag]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#flag +[combinators-fold]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#fold +[combinators-fold1]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#fold1 +[combinators-foldright]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#foldright +[combinators-foldright1]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#foldright1 +[combinators-fuse]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#fuse +[combinators-guard]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#guard +[combinators-inner]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#inner +[combinators-interleaved]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#interleaved +[combinators-last]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#last +[combinators-left]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#left +[combinators-lexeme]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#lexeme +[combinators-many]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#many +[combinators-many1]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#many1 +[combinators-manyatleast]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#manyatleast +[combinators-manyatmost]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#manyatmost +[combinators-manybetween]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#manybetween +[combinators-manytill]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#manytill +[combinators-map]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#map +[combinators-node]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#node +[combinators-not]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#not +[combinators-nth]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#nth +[combinators-optional]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#optional +[combinators-optionalconsume]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#optionalconsume +[combinators-optionalseparatedby]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#optionalseparatedby +[combinators-outer]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#outer +[combinators-padded]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#padded +[combinators-parenthesized]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#parenthesized +[combinators-peek]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#peek +[combinators-postfix]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#postfix +[combinators-prefix]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#prefix +[combinators-pure]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#pure +[combinators-quoted]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#quoted +[combinators-recover]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#recover +[combinators-right]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#right +[combinators-separatedby]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#separatedby +[combinators-separatedby1]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#separatedby1 +[combinators-separatedendby]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#separatedendby +[combinators-separatedendby1]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#separatedendby1 +[combinators-separateduntil]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#separateduntil +[combinators-sequence]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#sequence +[combinators-skip]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#skip +[combinators-skipmany]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#skipmany +[combinators-skipmany1]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#skipmany1 +[combinators-surrounded]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#surrounded +[combinators-unless]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#unless +[combinators-until]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#until +[combinators-validate]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#validate +[combinators-value]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#value +[combinators-when]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#when +[api-utils]: https://github.com/sovrin/unitas/blob/master/doc/api/utils.md +[utils-filter]: https://github.com/sovrin/unitas/blob/master/doc/api/utils.md#filter +[utils-flatten]: https://github.com/sovrin/unitas/blob/master/doc/api/utils.md#flatten +[utils-join]: https://github.com/sovrin/unitas/blob/master/doc/api/utils.md#join +[utils-pick]: https://github.com/sovrin/unitas/blob/master/doc/api/utils.md#pick +[utils-pipe]: https://github.com/sovrin/unitas/blob/master/doc/api/utils.md#pipe +[utils-pop]: https://github.com/sovrin/unitas/blob/master/doc/api/utils.md#pop +[utils-shift]: https://github.com/sovrin/unitas/blob/master/doc/api/utils.md#shift +[utils-spread]: https://github.com/sovrin/unitas/blob/master/doc/api/utils.md#spread + +## Contributing + +```bash +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](https://github.com/sovrin) diff --git a/bin/generate-readme.mjs b/bin/generate-readme.mjs index 7d03a11..1e79e1e 100644 --- a/bin/generate-readme.mjs +++ b/bin/generate-readme.mjs @@ -5,11 +5,66 @@ import { fileURLToPath } from 'node:url'; const directoryName = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(directoryName, '..'); const srcDir = path.join(rootDir, 'src'); -const templatePath = path.join(rootDir, 'doc', 'README.template.md'); +const docDir = path.join(rootDir, 'doc'); +const apiDir = path.join(docDir, 'api'); +const readmeTemplatePath = path.join(docDir, 'README.template.md'); +const apiTemplatePath = path.join(docDir, 'api.template.md'); const readmePath = path.join(rootDir, 'README.md'); const COMMENT_REGEX = /\/\*\*([\s\S]*?)\*\//g; +const CHEAT_SHEET_HEADING = '## Which function do I need?'; + +/** Covered by the "Core concepts" prose instead of the cheat sheet. */ +const CHEAT_SHEET_EXEMPT = new Set(['failure', 'parser', 'run', 'success']); + +/** + * Module metadata. Order here drives the order of the API index in the README + * and the cross-page navigation on every generated reference page. + */ +const MODULES = [ + { + key: 'core', + title: 'Core', + entry: 'unitas', + tagline: 'Types, constructors and the grammar runner.', + intro: + 'The core entry point holds everything a parser is built from: the `Parser` and `Result` types, the `success`/`failure` constructors, and the tools for running and wiring parsers together (`run`, `grammar`, `lazy`, `memoize`).', + }, + { + key: 'terminals', + title: 'Terminals', + entry: 'unitas/terminals', + tagline: 'Factories that match the input string directly.', + intro: + 'Terminals are the leaves of a grammar. They do not take other parsers — they inspect the input string themselves. Each one is a factory: call it with what you want to match and it hands back a `Parser`.', + }, + { + key: 'primitives', + title: 'Primitives', + entry: 'unitas/primitives', + tagline: 'Ready-made parsers for the usual suspects.', + intro: + 'Primitives are parser *instances*, not factories. Where a terminal needs an argument (`char("a")`), a primitive is already a parser and can be passed straight to a combinator (`many(digit)`).', + }, + { + key: 'combinators', + title: 'Combinators', + entry: 'unitas/combinators', + tagline: 'Take parsers, return a new parser.', + intro: + 'Combinators are the glue. Every one of them takes one or more parsers and returns a new parser, which is what lets a grammar stay a set of small, independently testable pieces.', + }, + { + key: 'utils', + title: 'Utils', + entry: 'unitas/utils', + tagline: 'Plain helpers for `map` callbacks.', + intro: + 'Utils are not parsers. They are small curried helpers meant to be dropped into a `map` callback so reshaping a result stays a one-liner instead of an arrow function.', + }, +]; + function extractDocs(filePath) { const content = fs.readFileSync(filePath, 'utf-8'); const docs = []; @@ -113,68 +168,149 @@ function formatExample(example) { return `\`\`\`typescript\n${resultLines.join('\n')}\n\`\`\``; } -function generateDocsSection(namespace, items) { - if (items.length === 0) return ''; +/** + * The README index shows one line per export, so it gets the headline sentence + * only — the full description stays on the reference page. + */ +function summarize(description) { + const [first] = description.split(/(?<=\.)\s+(?=[A-Z])/); - const lines = items.map(({ name, description, example }) => { - const formatted = formatExample(example); - return `
-
${name} — ${description} + return (first || description).replace(/\|/g, '\\|'); +} -${formatted} -
`; - }); +function generateApiPage(module, items) { + const template = fs.readFileSync(apiTemplatePath, 'utf-8'); + + const toc = items + .map(({ name }) => `[\`${name}\`](#${name.toLowerCase()})`) + .join(' · '); + + const body = items + .map(({ name, description, example }) => + [ + `### \`${name}\``, + '', + description, + '', + formatExample(example), + ].join('\n'), + ) + .join('\n\n'); + + const nav = MODULES.map(({ key, title }) => + key === module.key ? `**${title}**` : `[${title}](./${key}.md)`, + ).join(' · '); + + return template + .replaceAll('<$title>', module.title) + .replaceAll('<$entry>', module.entry) + .replaceAll('<$intro>', module.intro) + .replaceAll('<$count>', String(items.length)) + .replaceAll('<$nav>', nav) + .replaceAll('<$toc>', toc) + .replaceAll('<$body>', body); +} - return lines.join('\n'); +/** + * Absolute links, not relative ones: the `doc/` directory is not published to + * npm, so a relative link would dead-end on npmjs.com. + */ +function apiUrl(baseUrl, key, anchor) { + const url = `${baseUrl}/doc/api/${key}.md`; + + return anchor ? `${url}#${anchor.toLowerCase()}` : url; } -function generateTOC(docsByNamespace) { - const sections = [ - { key: 'core', title: 'Core' }, - { key: 'terminals', title: 'Terminals' }, - { key: 'primitives', title: 'Primitives' }, - { key: 'combinators', title: 'Combinators' }, - { key: 'utils', title: 'Utils' }, - ]; - - const lines = []; - for (const { key, title } of sections) { - const items = docsByNamespace[key] || []; - if (items.length === 0) continue; +/** + * Reference-style links, not inline ones: the absolute URLs are long enough + * that inlining them makes the formatter pad every table cell to ~80 columns. + */ +function generateIndex(baseUrl, docsByNamespace) { + const sections = []; + const definitions = []; - lines.push(`- [${title}](#${title.toLowerCase()})`); + for (const module of MODULES) { + const items = docsByNamespace[module.key] || []; + if (items.length === 0) continue; - for (const { name } of items) { - lines.push(` - [\`${name}\`](#${name})`); - } + const pageRef = `api-${module.key}`; + definitions.push(`[${pageRef}]: ${apiUrl(baseUrl, module.key)}`); + + const rows = items.map(({ name, description }) => { + const ref = `${module.key}-${name.toLowerCase()}`; + definitions.push(`[${ref}]: ${apiUrl(baseUrl, module.key, name)}`); + + return `| [\`${name}\`][${ref}] | ${summarize(description)} |`; + }); + + sections.push( + [ + `### ${module.title} — \`${module.entry}\``, + '', + `${module.tagline} **${items.length}** exports — [full reference →][${pageRef}]`, + '', + '| | |', + '| --- | --- |', + ...rows, + ].join('\n'), + ); } - return lines.join('\n'); + return [...sections, definitions.join('\n')].join('\n\n'); } -function generateReadme(docsByNamespace) { - let template = fs.readFileSync(templatePath, 'utf-8'); - - const placeholderMap = { - '<$core>': 'core', - '<$terminals>': 'terminals', - '<$primitives>': 'primitives', - '<$combinators>': 'combinators', - '<$utils>': 'utils', - }; - - for (const [placeholder, namespace] of Object.entries(placeholderMap)) { - const items = docsByNamespace[namespace] || []; - const sectionContent = generateDocsSection(namespace, items); - template = template.replace(placeholder, sectionContent); - } +function generateReadme(baseUrl, docsByNamespace) { + const template = fs.readFileSync(readmeTemplatePath, 'utf-8'); + const total = Object.values(docsByNamespace).reduce( + (sum, items) => sum + items.length, + 0, + ); - template = template.replace('<$toc>', generateTOC(docsByNamespace)); + const content = template + .replaceAll('<$index>', generateIndex(baseUrl, docsByNamespace)) + .replaceAll('<$total>', String(total)); - fs.writeFileSync(readmePath, template); + fs.writeFileSync(readmePath, content); console.log(`Updated ${readmePath}`); + + warnAboutCheatSheetGaps(template, docsByNamespace); } +/** + * The cheat sheet is hand-written, so it silently rots as exports are added. + * Every name should be reachable by intent, not just alphabetically. + */ +function warnAboutCheatSheetGaps(template, docsByNamespace) { + const start = template.indexOf(CHEAT_SHEET_HEADING); + const end = template.indexOf('\n## ', start + 1); + if (start === -1) return; + + const section = template.slice(start, end === -1 ? undefined : end); + const mentioned = new Set( + [...section.matchAll(/`([A-Za-z][A-Za-z0-9]*)`/g)].map(([, n]) => n), + ); + + const missing = MODULES.flatMap(({ key }) => + (docsByNamespace[key] || []) + .map(({ name }) => name) + .filter( + (name) => + !mentioned.has(name) && !CHEAT_SHEET_EXEMPT.has(name), + ), + ); + + if (missing.length > 0) { + console.warn( + `Warning: ${missing.length} export(s) missing from the "${CHEAT_SHEET_HEADING}" table in ${path.relative(rootDir, readmeTemplatePath)}: ${missing.join(', ')}`, + ); + } +} + +const pkg = JSON.parse( + fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'), +); +const baseUrl = `${pkg.repository.url.replace(/\.git$/, '')}/blob/master`; + const files = findSourceFiles(srcDir); const docsByNamespace = {}; @@ -189,4 +325,20 @@ for (const file of files) { docsByNamespace[namespace].push(...docs); } -generateReadme(docsByNamespace); +for (const items of Object.values(docsByNamespace)) { + items.sort((a, b) => a.name.localeCompare(b.name)); +} + +fs.rmSync(apiDir, { force: true, recursive: true }); +fs.mkdirSync(apiDir, { recursive: true }); + +for (const module of MODULES) { + const items = docsByNamespace[module.key] || []; + if (items.length === 0) continue; + + const target = path.join(apiDir, `${module.key}.md`); + fs.writeFileSync(target, generateApiPage(module, items)); + console.log(`Updated ${target} (${items.length} exports)`); +} + +generateReadme(baseUrl, docsByNamespace); diff --git a/doc/README.template.md b/doc/README.template.md index 4a2761f..eac85db 100644 --- a/doc/README.template.md +++ b/doc/README.template.md @@ -1,34 +1,19 @@ -# unitas — composing parsers into a unified whole + -Unitas logo +# 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](https://img.shields.io/npm/v/unitas)](https://www.npmjs.com/package/unitas) [![Coverage](https://coveralls.io/repos/github/sovrin/unitas/badge.svg?branch=master)](https://coveralls.io/github/sovrin/unitas?branch=master) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -A lightweight, TypeScript-first parser combinator library for building expressive and composable parsers. - -## Features - -- **Parser Combinators**: Compose small parsers into complex ones using combinators like `many`, `choice`, `sequence`, and more -- **Terminals**: Factory functions for common patterns (`char`, `string`, `regex`, etc.) -- **Primitives**: Pre-built parser instances ready to use (`digit`, `letter`, `whitespace`, etc.) -- **TypeScript**: Full TypeScript support with generic types and inference -- **Tree-shakeable**: ESM-only with separate exports for `combinators`, `terminals`, `primitives`, and `utils` -- **No dependencies**: Zero external runtime dependencies - -> **Note:** This library is in active development. The API may change before v1.0.0. - -## Installation - ```bash npm install unitas ``` -## Quick Start - -**CSV parser** — parsing comma-separated values with quoted fields - ```typescript import { grammar, run } from 'unitas'; import { choice, inner, separatedBy } from 'unitas/combinators'; @@ -46,11 +31,199 @@ run(csv.row, 'a,b,c'); // ['a', 'b', 'c'] run(csv.row, '"a,b",c'); // ['a,b', 'c'] ``` -**JSON value parser** — parsing simple json values +- **Zero dependencies**, ESM-only, fully typed +- **Tree-shakeable** — five entry points, import only what you touch +- **<$total> composable exports** — grouped by what they do, indexed below +- **Mutual recursion out of the box** via `grammar`, no forward declarations + +> **Note:** This library is in active development. The API may change before v1.0.0. + +## Contents + +- [Entry points](#entry-points) +- [Which function do I need?](#which-function-do-i-need) +- [Core concepts](#core-concepts) +- [More examples](#more-examples) +- [API index](#api-index) + +## Entry points + +Nothing is re-exported across entry points, so your bundler only ever sees what you import. + +| 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](#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 much input is left? `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` | +| undo input consumption on failure | `attempt` | + +### 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 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` is a function from an input string to a `Result`. That is the whole abstraction — everything else in this library either produces one or wraps one. + +```typescript +type Parser = (input: string) => Result; +``` + +### The `Result` type + +```typescript +type Success = { ok: true; value: T; remaining: string }; +type Failure = { ok: false; error?: string }; +type Result = Success | Failure; +``` + +```typescript +{ ok: true, value: 'hello', remaining: ' world' } + │ │ │ + │ │ └── what is left to parse + │ └── the parsed value + └── always true for success +``` + +`remaining` is the important part: it is how input gets consumed and how parsers chain. A failure carries an optional message, which you can always supply later with `label`. + +```typescript +{ ok: false } // generic failure +{ ok: false, error: 'expected a' } // failure with a message +``` + +### Writing one by hand + +Most of the time you compose existing pieces, but nothing stops you from dropping down a level: + +```typescript +import { create, success, failure } from 'unitas'; + +const parser = create((input) => { + if (input.startsWith('hello')) { + return success('hello', input.slice(5)); + } + + return failure('expected "hello"'); +}); +``` + +### Grammars + +`grammar` hands every rule a proxy `p`, so rules can reference each other — including themselves — without forward declarations or `lazy` boilerplate. + +```typescript +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({ + 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 +``` + +## More examples + +**JSON value** — a tagged union of scalars ```typescript import { grammar, run } from 'unitas'; -import { choice, map, quoted, value } from 'unitas/combinators'; +import { choice, quoted, value } from 'unitas/combinators'; import { string } from 'unitas/terminals'; import { bool, digits, letters } from 'unitas/primitives'; @@ -68,7 +241,7 @@ run(json.value, 'true'); // true run(json.value, 'null'); // null ``` -**Query string parser** — parsing URL query parameters +**Query string** — separated pairs folded into an object ```typescript import { grammar, run } from 'unitas'; @@ -96,11 +269,11 @@ const query = grammar({ run(query.params, 'foo=bar&baz=qux'); // { foo: 'bar', baz: 'qux' } ``` -**INI file section** — parsing section headers and key-value pairs +**INI section** — a header plus an entry, with the punctuation dropped ```typescript import { grammar, run } from 'unitas'; -import { map, outer, sequence, bracketed } from 'unitas/combinators'; +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'; @@ -121,113 +294,23 @@ const ini = grammar({ run(ini.section, '[database]\nhost=localhost'); // { name: 'database', entry: ['host', 'localhost'] } ``` -## Table of Contents - -<$toc> - -## Core Concepts - -### The Parser Type - -A `Parser` is a function that takes an input string and returns a `Result`. The generic `T` represents the type of value the parser produces. - -```typescript -type Parser = (input: string) => Result; -``` - -### The Result Type - -Every parser returns a `Result` which is either: - -- **Success** — The parser matched and produced a value -- **Failure** — The parser did not match - -```typescript -type Success = { ok: true; value: T; remaining: string }; -type Failure = { ok: false; error?: string }; -type Result = Success | Failure; -``` - -The `remaining` string is crucial — it represents what input is left after the parser has done its work. This is how we "consume" input and chain parsers together. - -### Creating a Parser - -Use `create` to wrap a parsing function: - -```typescript -import { create, success, failure } from 'unitas'; - -const parser = create((input) => { - if (input.startsWith('hello')) { - return success('hello', input.slice(5)); - } - return failure('expected "hello"'); -}); -``` - -### Understanding the Monadic Nature +## API index -Parsers are monadic, which means they follow certain laws that make them composable: +One line per export. Every name links to its full description and runnable example in the reference pages under [`doc/api`](https://github.com/sovrin/unitas/tree/master/doc/api). -1. **Left identity**: `create(success(a, input))` behaves like `a` -2. **Right identity**: `parser` composed with `success` returns equivalent result -3. **Associativity**: Composition order doesn't affect final result +<$index> -The practical implication is that you can chain and combine parsers predictably. +## Contributing -### Success Results - -When a parser successfully matches, it returns: - -```typescript -{ ok: true, value: 'hello', remaining: ' world' } - │ │ │ - │ │ └── What's left to parse - │ └── The parsed value - └── Always true for success -``` - -### Failure Results - -When a parser fails, it returns: - -```typescript -{ ok: false } // Generic failure -{ ok: false, error: 'expected a' } // Failure with message +```bash +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 ``` -The `error` field is optional — you can always add meaningful error messages later using `label`. - -## Core (`unitas`) - -**Core** provides the fundamental types and functions for building parsers. - -<$core> - -## Terminals (`unitas/terminals`) - -**Terminals** are the basic building blocks that match specific parts of the input. They don't combine other parsers — they directly inspect the input string. - -<$terminals> - -## Primitives (`unitas/primitives`) - -**Primitives** are pre-built parser instances ready to use. Unlike terminals which are factory functions (like `char('x')`), primitives are constants you can pass directly to combinators. - -<$primitives> - -## Combinators (`unitas/combinators`) - -**Combinators** are functions that take one or more parsers and return a new parser. They are the "glue" that lets you compose complex parsers from simple ones. - -<$combinators> - -## Utils (`unitas/utils`) - -**Utils** are utility functions for working with parser results, arrays, and function composition. - -<$utils> +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 +MIT © [Oleg Kamlowski](https://github.com/sovrin) diff --git a/doc/api.template.md b/doc/api.template.md new file mode 100644 index 0000000..19e1ba6 --- /dev/null +++ b/doc/api.template.md @@ -0,0 +1,19 @@ + + +# <$title> — `<$entry>` + +[← README](../../README.md) · <$nav> + +<$intro> + +```typescript +import { /* … */ } from '<$entry>'; +``` + +## Index + +<$count> exports: <$toc> + +## Reference + +<$body> diff --git a/doc/api/combinators.md b/doc/api/combinators.md new file mode 100644 index 0000000..a83b3f1 --- /dev/null +++ b/doc/api/combinators.md @@ -0,0 +1,563 @@ + + +# Combinators — `unitas/combinators` + +[← README](../../README.md) · [Core](./core.md) · [Terminals](./terminals.md) · [Primitives](./primitives.md) · **Combinators** · [Utils](./utils.md) + +Combinators are the glue. Every one of them takes one or more parsers and returns a new parser, which is what lets a grammar stay a set of small, independently testable pieces. + +```typescript +import {} from /* … */ 'unitas/combinators'; +``` + +## Index + +65 exports: [`attempt`](#attempt) · [`bind`](#bind) · [`braced`](#braced) · [`bracketed`](#bracketed) · [`chainLeft`](#chainleft) · [`chainLeft1`](#chainleft1) · [`chainRight`](#chainright) · [`chainRight1`](#chainright1) · [`choice`](#choice) · [`concat`](#concat) · [`consume`](#consume) · [`endBy`](#endby) · [`endBy1`](#endby1) · [`exactly`](#exactly) · [`first`](#first) · [`flag`](#flag) · [`fold`](#fold) · [`fold1`](#fold1) · [`foldRight`](#foldright) · [`foldRight1`](#foldright1) · [`fuse`](#fuse) · [`guard`](#guard) · [`inner`](#inner) · [`interleaved`](#interleaved) · [`last`](#last) · [`left`](#left) · [`lexeme`](#lexeme) · [`many`](#many) · [`many1`](#many1) · [`manyAtLeast`](#manyatleast) · [`manyAtMost`](#manyatmost) · [`manyBetween`](#manybetween) · [`manyTill`](#manytill) · [`map`](#map) · [`node`](#node) · [`not`](#not) · [`nth`](#nth) · [`optional`](#optional) · [`optionalConsume`](#optionalconsume) · [`optionalSeparatedBy`](#optionalseparatedby) · [`outer`](#outer) · [`padded`](#padded) · [`parenthesized`](#parenthesized) · [`peek`](#peek) · [`postfix`](#postfix) · [`prefix`](#prefix) · [`pure`](#pure) · [`quoted`](#quoted) · [`recover`](#recover) · [`right`](#right) · [`separatedBy`](#separatedby) · [`separatedBy1`](#separatedby1) · [`separatedEndBy`](#separatedendby) · [`separatedEndBy1`](#separatedendby1) · [`separatedUntil`](#separateduntil) · [`sequence`](#sequence) · [`skip`](#skip) · [`skipMany`](#skipmany) · [`skipMany1`](#skipmany1) · [`surrounded`](#surrounded) · [`unless`](#unless) · [`until`](#until) · [`validate`](#validate) · [`value`](#value) · [`when`](#when) + +## Reference + +### `attempt` + +Attempt wraps a parser to handle backtracking on failure. + +```typescript +attempt(string('hello'))('hello world'); // { ok: true, value: 'hello', remaining: ' world' } +``` + +### `bind` + +Chain parsers where the second parser depends on the first result. + +```typescript +bind(digits, (n) => take(n))('3abc'); // { ok: true, value: 'abc', remaining: '' } +``` + +### `braced` + +Parse content surrounded by braces. + +```typescript +braced(string('hi'))('{hi}'); // { ok: true, value: 'hi', remaining: '' } +``` + +### `bracketed` + +Parse content surrounded by brackets. + +```typescript +bracketed(string('hi'))('[hi]'); // { ok: true, value: 'hi', remaining: '' } +``` + +### `chainLeft` + +Chain left-associative operations (right-to-left for same precedence). + +```typescript +chainLeft(digits, operation)('1+2+3'); // { ok: true, value: 6, remaining: '' } +chainLeft(digits, operation)('10-3+2'); // { ok: true, value: 9, remaining: '' } +``` + +### `chainLeft1` + +Chain left-associative operations (fails on empty input). + +```typescript +chainLeft1(digits, operation)('1+2+3'); // { ok: true, value: 6, remaining: '' } +chainLeft1(digits, operation)('8/2*3'); // { ok: true, value: 12, remaining: '' } +``` + +### `chainRight` + +Chain right-associative operations (right-to-left grouping). + +```typescript +chainRight(digits, operation)('2-1-1'); // { ok: true, value: 2, remaining: '' } +chainRight(digits, operation)('4/2/2'); // { ok: true, value: 4, remaining: '' } +``` + +### `chainRight1` + +Chain right-associative operations (fails on empty input). + +```typescript +chainRight1(digits, operation)('2-1-1'); // { ok: true, value: 2, remaining: '' } +chainRight1(digits, operation)('4/2/2'); // { ok: true, value: 4, remaining: '' } +``` + +### `choice` + +Try each parser in order, return first success. + +```typescript +choice(string('hello'), string('world'))('hello'); // { ok: true, value: 'hello', remaining: '' } +``` + +### `concat` + +Join string array parser result into a single string. + +```typescript +concat(many(letter))('abc123'); // { ok: true, value: 'abc', remaining: '123' } +concat(many(letter), '-')('abc123'); // { ok: true, value: 'a-b-c', remaining: '123' } +``` + +### `consume` + +Consume input but discard the result (return null). + +```typescript +consume(string('hello'))('hello world'); // { ok: true, value: null, remaining: ' world' } +``` + +### `endBy` + +Zero or more items separated and ending with terminator. + +```typescript +endBy(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['item', 'item', 'item'], remaining: '' } +``` + +### `endBy1` + +One or more items separated and ending with terminator. + +```typescript +endBy1(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['item', 'item', 'item'], remaining: '' } +``` + +### `exactly` + +Parse exactly n occurrences. + +```typescript +exactly(char('a'), 3)('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +``` + +### `first` + +Extract the first element from a parser result array. + +```typescript +first(sequence(char('a'), digit))('a1bc'); // { ok: true, value: 'a', remaining: 'bc' } +``` + +### `flag` + +Return true if parser succeeds, false otherwise. Always succeeds without consuming input on failure. + +```typescript +flag(string('*'))('*abc'); // { ok: true, value: true, remaining: 'abc' } +flag(string('*'))('abc'); // { ok: true, value: false, remaining: 'abc' } +``` + +### `fold` + +Parse zero or more and fold into a single value. + +```typescript +fold(digit, [], (acc, d) => [...acc, d])('123'); // { ok: true, value: [1, 2, 3], remaining: '' } +``` + +### `fold1` + +Parse one or more and fold into a single value. + +```typescript +fold1(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, remaining: '' } +``` + +### `foldRight` + +Parse zero or more and fold right-to-left. + +```typescript +foldRight(digit, [], (acc, d) => [...acc, d])('123'); // { ok: true, value: [3, 2, 1], remaining: '' } +``` + +### `foldRight1` + +Parse one or more and fold right-to-left. + +```typescript +foldRight1(digit, [], (acc, d) => [...acc, d])('123'); // { ok: true, value: [3, 2, 1], remaining: '' } +``` + +### `fuse` + +Fuse multiple string parsers into a single one. The fused parser concatenates all string results. + +```typescript +fuse(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: 'abc', remaining: '' } +fuse(string('hello'), char(' '), string('world'))('hello world'); // { ok: true, value: 'hello world', remaining: '' } +``` + +### `guard` + +Conditionally apply parser based on a condition. + +```typescript +guard(true, string('hello'))('hello'); // { ok: true, value: 'hello', remaining: '' } +guard(false, string('hello'))('hello'); // { ok: false } +``` + +### `inner` + +Extract inner value from surrounded content (like inner of braced). + +```typescript +inner(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: 'hi', remaining: '' } +``` + +### `interleaved` + +Parse items with interleaved separators. + +```typescript +interleaved(char('a'), char(','))('a,a,a'); // { ok: true, value: ['a', ',', 'a', ',', 'a'], remaining: '' } +``` + +### `last` + +Extract the last element from a parser result array. + +```typescript +last(sequence(char('a'), char('b')))('ab'); // { ok: true, value: 'b', remaining: '' } +``` + +### `left` + +Keep only the left result from a sequence. + +```typescript +left(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'hello', remaining: '' } +``` + +### `lexeme` + +Parser that consumes trailing whitespace. + +```typescript +lexeme(string('hello'))('hello world'); // { ok: true, value: 'hello', remaining: 'world' } +``` + +### `many` + +Zero or more occurrences (never fails). + +```typescript +many(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +``` + +### `many1` + +One or more occurrences (fails if no matches). + +```typescript +many1(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +``` + +### `manyAtLeast` + +Parse at least n occurrences. + +```typescript +manyAtLeast(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +``` + +### `manyAtMost` + +Parse at most n occurrences. + +```typescript +manyAtMost(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a'], remaining: 'a' } +``` + +### `manyBetween` + +Parse between min and max occurrences. + +```typescript +manyBetween(char('a'), 2, 3)('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +``` + +### `manyTill` + +Parse zero or more until terminator matches. + +```typescript +manyTill(char('a'), char('b'))('aaab'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +``` + +### `map` + +Transform the parsed value. + +```typescript +map(string('hello'), (v) => v.toUpperCase())('hello'); // { ok: true, value: 'HELLO', remaining: '' } +``` + +### `node` + +Create a node from parser fields. + +```typescript +node('binop', { left: digits, op: char('+'), right: digits })('1+2'); // { ok: true, value: { type: 'binop', left: 1, op: '+', right: 2 }, remaining: '' } +node('number', { value: digits })('123'); // { ok: true, value: { type: 'number', value: 123 }, remaining: '' } +``` + +### `not` + +Succeed if parser fails (without consuming input). + +```typescript +not(string('hello'))('world'); // { ok: true, value: null, remaining: 'world' } +``` + +### `nth` + +Extract the nth element from a parser result array. + +```typescript +nth(sequence(char('a'), char('b'), char('c')), 1)('abc'); // { ok: true, value: 'b', remaining: '' } +``` + +### `optional` + +Make parser optional (return null on failure, without consuming input). + +```typescript +optional(string('hello'))('hello'); // { ok: true, value: 'hello', remaining: '' } +optional(string('hello'))('world'); // { ok: true, value: null, remaining: 'world' } +``` + +### `optionalConsume` + +Optionally consume input (always succeeds, returns void). + +```typescript +optionalConsume(string('hello'))('hello world'); // { ok: true, value: undefined, remaining: ' world' } +optionalConsume(string('hello'))('world'); // { ok: true, value: undefined, remaining: 'world' } +``` + +### `optionalSeparatedBy` + +Zero or more items separated by a separator, with optional null values. + +```typescript +optionalSeparatedBy(digits, char(','))('1,2'); // { ok: true, value: [1, 2], remaining: '' } +optionalSeparatedBy(digits, char(','))(',1'); // { ok: true, value: [null, 1], remaining: '' } +optionalSeparatedBy(digits, char(','))('1,'); // { ok: true, value: [1], remaining: '' } +``` + +### `outer` + +Extract outer values from a sequence of 3 parsers (skip middle). + +```typescript +outer(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: ['(', ')'], remaining: '' } +``` + +### `padded` + +Parse content with optional whitespace on both sides. + +```typescript +padded(string('hi'))(' hi '); // { ok: true, value: 'hi', remaining: '' } +``` + +### `parenthesized` + +Parse content surrounded by parentheses. + +```typescript +parenthesized(string('hi'))('(hi)'); // { ok: true, value: 'hi', remaining: '' } +``` + +### `peek` + +Parse without consuming input. + +```typescript +peek(string('hello'))('hello world'); // { ok: true, value: 'hello', remaining: 'hello world' } +``` + +### `postfix` + +Parse postfix operators (chains atom with operators that return functions). + +```typescript +postfix( + char('a'), + map(char('!'), () => (x) => x), +)('a!'); // { ok: true, value: 'a', remaining: '' } +``` + +### `prefix` + +Parse prefix operators (like - in -5). + +```typescript +prefix( + map(char('-'), () => (x) => -x), + digit, +)('-5'); // { ok: true, value: -5, remaining: '' } +``` + +### `pure` + +Always return a value without consuming input. + +```typescript +pure(42)('abc'); // { ok: true, value: 42, remaining: 'abc' } +pure('ok')(''); // { ok: true, value: 'ok', remaining: '' } +``` + +### `quoted` + +Parse content surrounded by single or double quotes. + +```typescript +quoted(string('hello'))('"hello"'); // { ok: true, value: 'hello', remaining: '' } +``` + +### `recover` + +Use fallback value when parser fails. + +```typescript +recover(string('hello'), 'default')('world'); // { ok: true, value: 'default', remaining: 'world' } +``` + +### `right` + +Keep only the right result from a sequence. + +```typescript +right(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'world', remaining: '' } +``` + +### `separatedBy` + +Zero or more items separated by a separator. + +```typescript +separatedBy(char('a'), char(','))('a,a,a'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +``` + +### `separatedBy1` + +One or more items separated by a separator. + +```typescript +separatedBy1(char('a'), char(','))('a,a,a'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +``` + +### `separatedEndBy` + +Zero or more items separated by and ending with a terminator. + +```typescript +separatedEndBy(char('a'), char(';'))('a;a;a;'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +``` + +### `separatedEndBy1` + +One or more items separated by and ending with a terminator. + +```typescript +separatedEndBy1(char('a'), char(';'))('a;a;a;'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +``` + +### `separatedUntil` + +Parse items separated by separator until terminator matches. + +```typescript +separatedUntil(char('a'), char(','), char(';'))('a,a,a;'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +``` + +### `sequence` + +Parse a sequence of parsers and return all results as an array. + +```typescript +sequence(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: ['a', 'b', 'c'], remaining: '' } +``` + +### `skip` + +Skip a parser n times. + +```typescript +skip(char('a'), 2)('aabc'); // { ok: true, value: null, remaining: 'bc' } +``` + +### `skipMany` + +Skip zero or more occurrences (never fails, returns null). + +```typescript +skipMany(char('a'))('aaabc'); // { ok: true, value: null, remaining: 'bc' } +``` + +### `skipMany1` + +Skip one or more occurrences (fails if no matches). + +```typescript +skipMany1(char('a'))('aaabc'); // { ok: true, value: null, remaining: 'bc' } +``` + +### `surrounded` + +Parse content surrounded by delimiters. + +```typescript +surrounded(char('['), string('hi'), char(']'))('[hi]'); // { ok: true, value: 'hi', remaining: '' } +surrounded(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: 'b', remaining: '' } +``` + +### `unless` + +Parse unless condition is true (inverse of guard). + +```typescript +unless(false, string('hello'))('hello'); // { ok: true, value: 'hello', remaining: '' } +unless(true, string('hello'))('hello'); // { ok: true, value: null, remaining: 'hello' } +``` + +### `until` + +Parse until terminator matches (fails if terminator never matches). + +```typescript +until(char('a'), char('b'))('baaa'); // { ok: true, value: [], remaining: 'baaa' } +until(char('a'), char('b'))('aaba'); // { ok: true, value: ['a', 'a'], remaining: 'ba' } +``` + +### `validate` + +Validate parsed value with a predicate. + +```typescript +validate(digit, (n) => n > 5)('7'); // { ok: true, value: 7, remaining: '' } +validate(digit, (n) => n > 5)('3'); // { ok: false } +``` + +### `value` + +Replace parsed value with a constant. + +```typescript +value(string('true'), true)('true'); // { ok: true, value: true, remaining: '' } +value(string('null'), null)('null'); // { ok: true, value: null, remaining: '' } +``` + +### `when` + +Branch on a boolean parser result. + +```typescript +when(flag(char('*')), pure('many'), pure('one'))('*rest'); // { ok: true, value: 'many', remaining: 'rest' } +when(flag(char('*')), pure('many'), pure('one'))('abc'); // { ok: true, value: 'one', remaining: 'abc' } +``` diff --git a/doc/api/core.md b/doc/api/core.md new file mode 100644 index 0000000..b23d036 --- /dev/null +++ b/doc/api/core.md @@ -0,0 +1,110 @@ + + +# Core — `unitas` + +[← README](../../README.md) · **Core** · [Terminals](./terminals.md) · [Primitives](./primitives.md) · [Combinators](./combinators.md) · [Utils](./utils.md) + +The core entry point holds everything a parser is built from: the `Parser` and `Result` types, the `success`/`failure` constructors, and the tools for running and wiring parsers together (`run`, `grammar`, `lazy`, `memoize`). + +```typescript +import {} from /* … */ 'unitas'; +``` + +## Index + +9 exports: [`failure`](#failure) · [`grammar`](#grammar) · [`label`](#label) · [`lazy`](#lazy) · [`match`](#match) · [`memoize`](#memoize) · [`parser`](#parser) · [`run`](#run) · [`success`](#success) + +## Reference + +### `failure` + +Creates a failed result with an optional error message. + +```typescript +failure('unexpected input'); // { ok: false, error: 'unexpected input' } +``` + +### `grammar` + +Creates a recursive grammar where rules can reference each other. + +```typescript +type Math = { + expr: number; + term: number; + value: number; +}; +const g = grammar({ + expr: (p) => + chainLeft1( + p.term, + map(char('+'), () => (l, r) => l + r), + ), + term: (p) => + choice( + p.value, + map(sequence(char('('), p.expr, char(')')), ([, v]) => v), + ), + value: () => digits, +}); +run(g.expr, '1+2'); // 3 +run(g.expr, '1+2+3'); // 6 +run(g.expr, '(1+2)'); // 3 +``` + +### `label` + +Labels a parser with a custom error message on failure. + +```typescript +label(char('x'), 'letter x')(''); // { ok: false, error: 'expected letter x' } +``` + +### `lazy` + +Defers parser creation, useful for recursive grammars. + +```typescript +lazy(() => char('a'))('abc'); // { ok: true, value: 'a', remaining: 'bc' } +``` + +### `match` + +Pattern matching on a Result to handle success and failure cases. + +```typescript +match(success('hello', ''), { success: (v) => v, failure: () => 'failed' }); // 'hello' +``` + +### `memoize` + +Memoizes a parser to cache results by input string. Useful for expensive parsers and recursive grammars to avoid exponential backtracking. + +```typescript +const memoDigits = memoize(digits); +memoDigits('123'); // { ok: true, value: 123, remaining: '' } +``` + +### `parser` + +Creates a parser from a parser function. + +```typescript +create((input) => success('parsed', input.slice(6)))('hello world'); // { ok: true, value: 'parsed', remaining: 'world' } +``` + +### `run` + +Runs a parser and returns the value, throws on failure or unconsumed input. + +```typescript +run(string('hello'), 'hello'); // 'hello' +``` + +### `success` + +Creates a successful result with a value and remaining input. + +```typescript +success('hello', ' world'); // { ok: true, value: 'hello', remaining: ' world' } +``` diff --git a/doc/api/primitives.md b/doc/api/primitives.md new file mode 100644 index 0000000..39d945f --- /dev/null +++ b/doc/api/primitives.md @@ -0,0 +1,292 @@ + + +# Primitives — `unitas/primitives` + +[← README](../../README.md) · [Core](./core.md) · [Terminals](./terminals.md) · **Primitives** · [Combinators](./combinators.md) · [Utils](./utils.md) + +Primitives are parser _instances_, not factories. Where a terminal needs an argument (`char("a")`), a primitive is already a parser and can be passed straight to a combinator (`many(digit)`). + +```typescript +import {} from /* … */ 'unitas/primitives'; +``` + +## Index + +33 exports: [`alphaNum`](#alphanum) · [`alphaNums`](#alphanums) · [`anyChar`](#anychar) · [`bool`](#bool) · [`crlf`](#crlf) · [`digit`](#digit) · [`digits`](#digits) · [`eof`](#eof) · [`eol`](#eol) · [`float`](#float) · [`hexDigit`](#hexdigit) · [`hexDigits`](#hexdigits) · [`identifier`](#identifier) · [`integer`](#integer) · [`letter`](#letter) · [`letters`](#letters) · [`line`](#line) · [`literal`](#literal) · [`lowercase`](#lowercase) · [`lowercases`](#lowercases) · [`nl`](#nl) · [`number`](#number) · [`octDigit`](#octdigit) · [`octDigits`](#octdigits) · [`position`](#position) · [`rest`](#rest) · [`space`](#space) · [`spaces`](#spaces) · [`tab`](#tab) · [`uppercase`](#uppercase) · [`uppercases`](#uppercases) · [`whitespace`](#whitespace) · [`whitespaces`](#whitespaces) + +## Reference + +### `alphaNum` + +Parse a single alphanumeric character. + +```typescript +alphaNum('a1'); // { ok: true, value: 'a', remaining: '1' } +alphaNum('1a'); // { ok: true, value: '1', remaining: 'a' } +``` + +### `alphaNums` + +Parse one or more alphanumeric characters. + +```typescript +alphaNums('abc123'); // { ok: true, value: 'abc123', remaining: '' } +``` + +### `anyChar` + +Parse any single character. + +```typescript +anyChar('abc'); // { ok: true, value: 'a', remaining: 'bc' } +``` + +### `bool` + +Parse a boolean literal. + +```typescript +bool('true'); // { ok: true, value: true, remaining: '' } +bool('false'); // { ok: true, value: false, remaining: '' } +bool('trueABC'); // { ok: true, value: true, remaining: 'ABC' } +``` + +### `crlf` + +Parse CRLF line ending. + +```typescript +crlf('\r\nabc'); // { ok: true, value: '\r\n', remaining: 'abc' } +``` + +### `digit` + +Parse a single digit and return as number. + +```typescript +digit('5abc'); // { ok: true, value: 5, remaining: 'abc' } +``` + +### `digits` + +Parse one or more digits and return as number. + +```typescript +digits('123abc'); // { ok: true, value: 123, remaining: 'abc' } +``` + +### `eof` + +Parse end of file (succeeds only on empty input). + +```typescript +eof(''); // { ok: true, value: null, remaining: '' } +``` + +### `eol` + +Parse end of line (\\n, \\r\\n, or EOF). + +```typescript +eol('\nabc'); // { ok: true, value: '\n', remaining: 'abc' } +``` + +### `float` + +Parse a floating point number. + +```typescript +float('1.23'); // { ok: true, value: 1.23, remaining: '' } +float('-2.5'); // { ok: true, value: -2.5, remaining: '' } +float('1.23abc'); // { ok: true, value: 1.23, remaining: 'abc' } +``` + +### `hexDigit` + +Parse a single hexadecimal digit. + +```typescript +hexDigit('fF9'); // { ok: true, value: 'f', remaining: 'F9' } +``` + +### `hexDigits` + +Parse one or more hexadecimal digits. + +```typescript +hexDigits('deadbeef'); // { ok: true, value: 'deadbeef', remaining: '' } +``` + +### `identifier` + +Parse an identifier — starts with letter or underscore, no leading digit, no hyphen. + +```typescript +identifier('variable_name'); // { ok: true, value: 'variable_name', remaining: '' } +``` + +### `integer` + +Parse a signed integer. + +```typescript +integer('42'); // { ok: true, value: 42, remaining: '' } +integer('-7'); // { ok: true, value: -7, remaining: '' } +integer('123abc'); // { ok: true, value: 123, remaining: 'abc' } +``` + +### `letter` + +Parse a single letter. + +```typescript +letter('abc'); // { ok: true, value: 'a', remaining: 'bc' } +``` + +### `letters` + +Parse one or more letters. + +```typescript +letters('abc123'); // { ok: true, value: 'abc', remaining: '123' } +``` + +### `line` + +Parse until end of line. + +```typescript +line('hello\nworld'); // { ok: true, value: 'hello', remaining: '\nworld' } +``` + +### `literal` + +Parse a word-like value including hyphens. + +```typescript +literal('foo-bar'); // { ok: true, value: 'foo-bar', remaining: '' } +literal('123abc'); // { ok: true, value: '123abc', remaining: '' } +``` + +### `lowercase` + +Parse a single lowercase letter. + +```typescript +lowercase('abc'); // { ok: true, value: 'a', remaining: 'bc' } +``` + +### `lowercases` + +Parses one or more lowercase letters. + +```typescript +lowercases('abcDEF'); // { ok: true, value: 'abc', remaining: 'DEF' } +``` + +### `nl` + +Parse a newline character. + +```typescript +nl('\ntext'); // { ok: true, value: '\n', remaining: 'text' } +``` + +### `number` + +Parse an integer or float. + +```typescript +number('42'); // { ok: true, value: 42, remaining: '' } +number('3.14'); // { ok: true, value: 3.14, remaining: '' } +number('-7'); // { ok: true, value: -7, remaining: '' } +number('-2.5'); // { ok: true, value: -2.5, remaining: '' } +``` + +### `octDigit` + +Parse a single octal digit. + +```typescript +octDigit('7abc'); // { ok: true, value: '7', remaining: 'abc' } +``` + +### `octDigits` + +Parse one or more octal digits. + +```typescript +octDigits('0777abc'); // { ok: true, value: '0777', remaining: 'abc' } +``` + +### `position` + +Get current position (remaining input length). + +```typescript +position('abc'); // { ok: true, value: 3, remaining: 'abc' } +``` + +### `rest` + +Parse the rest of the input. + +```typescript +rest('hello'); // { ok: true, value: 'hello', remaining: '' } +``` + +### `space` + +Parse a single space character. + +```typescript +space(' abc'); // { ok: true, value: ' ', remaining: 'abc' } +``` + +### `spaces` + +Parse one or more space characters. + +```typescript +spaces(' abc'); // { ok: true, value: ' ', remaining: 'abc' } +``` + +### `tab` + +Parse tab character. + +```typescript +tab('\ttext'); // { ok: true, value: '\t', remaining: 'text' } +``` + +### `uppercase` + +Parses a single uppercase letter. + +```typescript +uppercase('ABC'); // { ok: true, value: 'A', remaining: 'BC' } +``` + +### `uppercases` + +Parses one or more uppercase letters. + +```typescript +uppercases('ABCdef'); // { ok: true, value: 'ABC', remaining: 'def' } +``` + +### `whitespace` + +Parses a single whitespace character. + +```typescript +whitespace(' abc'); // { ok: true, value: ' ', remaining: 'abc' } +``` + +### `whitespaces` + +Parses one or more whitespace characters. + +```typescript +whitespaces(' abc'); // { ok: true, value: ' ', remaining: 'abc' } +``` diff --git a/doc/api/terminals.md b/doc/api/terminals.md new file mode 100644 index 0000000..424d9c8 --- /dev/null +++ b/doc/api/terminals.md @@ -0,0 +1,117 @@ + + +# Terminals — `unitas/terminals` + +[← README](../../README.md) · [Core](./core.md) · **Terminals** · [Primitives](./primitives.md) · [Combinators](./combinators.md) · [Utils](./utils.md) + +Terminals are the leaves of a grammar. They do not take other parsers — they inspect the input string themselves. Each one is a factory: call it with what you want to match and it hands back a `Parser`. + +```typescript +import {} from /* … */ 'unitas/terminals'; +``` + +## Index + +12 exports: [`char`](#char) · [`charOf`](#charof) · [`noneOf`](#noneof) · [`oneOf`](#oneof) · [`regex`](#regex) · [`satisfy`](#satisfy) · [`string`](#string) · [`stringOf`](#stringof) · [`take`](#take) · [`takeWhile`](#takewhile) · [`token`](#token) · [`word`](#word) + +## Reference + +### `char` + +Parse a specific character. + +```typescript +char('A')('ABC'); // { ok: true, value: 'A', remaining: 'BC' } +``` + +### `charOf` + +Parse any character from a set. + +```typescript +charOf(['a', 'b', 'c'])('abc'); // { ok: true, value: 'a', remaining: 'bc' } +``` + +### `noneOf` + +Parse any character not in the set. + +```typescript +noneOf(['a', 'b', 'c'])('xyz'); // { ok: true, value: 'x', remaining: 'yz' } +``` + +### `oneOf` + +Parse one string from a set of strings (longest match wins). + +```typescript +oneOf(['hello', 'hell', 'help'])('helpful'); // { ok: true, value: 'help', remaining: 'ful' } +``` + +### `regex` + +Parse with a regular expression. + +```typescript +regex(/^\w+/)('hello world'); // { ok: true, value: 'hello', remaining: ' world' } +``` + +### `satisfy` + +Parse a character satisfying a predicate. + +```typescript +satisfy((c) => c === 'a')('abc'); // { ok: true, value: 'a', remaining: 'bc' } +``` + +### `string` + +Parse a specific string. + +```typescript +string('hello')('hello world'); // { ok: true, value: 'hello', remaining: ' world' } +``` + +### `stringOf` + +Parse first character that exists in string (like charOf but for a string). + +```typescript +stringOf('abc')('abcdef'); // { ok: true, value: 'a', remaining: 'bcdef' } +``` + +### `take` + +Take n characters. + +```typescript +take(3)('abcdef'); // { ok: true, value: 'abc', remaining: 'def' } +``` + +### `takeWhile` + +Takes characters while the predicate returns true. + +```typescript +takeWhile((c) => c !== 'x')('abcx'); // { ok: true, value: 'abc', remaining: 'x' } +``` + +### `token` + +Parses a string as a token, skipping trailing whitespace. Unlike word, it does not enforce word boundaries - useful for parsing strings that might be followed by any character. + +```typescript +token('let')('let x'); // { ok: true, value: 'let', remaining: 'x' } +token('let')('let1'); // { ok: true, value: 'let', remaining: '1' } +token('let')('let x'); // { ok: true, value: 'let', remaining: 'x' } +``` + +### `word` + +Parses a specific word and ensures it is not followed by word characters. Use this when parsing keywords that should not be part of a longer identifier. + +```typescript +word('let')('let x'); // { ok: true, value: 'let', remaining: 'x' } +word('let')('let1'); // { ok: false } +word('if')('if (x)'); // { ok: true, value: 'if', remaining: '(x)' } +``` diff --git a/doc/api/utils.md b/doc/api/utils.md new file mode 100644 index 0000000..11e2e9d --- /dev/null +++ b/doc/api/utils.md @@ -0,0 +1,85 @@ + + +# Utils — `unitas/utils` + +[← README](../../README.md) · [Core](./core.md) · [Terminals](./terminals.md) · [Primitives](./primitives.md) · [Combinators](./combinators.md) · **Utils** + +Utils are not parsers. They are small curried helpers meant to be dropped into a `map` callback so reshaping a result stays a one-liner instead of an arrow function. + +```typescript +import {} from /* … */ 'unitas/utils'; +``` + +## Index + +8 exports: [`filter`](#filter) · [`flatten`](#flatten) · [`join`](#join) · [`pick`](#pick) · [`pipe`](#pipe) · [`pop`](#pop) · [`shift`](#shift) · [`spread`](#spread) + +## Reference + +### `filter` + +Exclude values from array. + +```typescript +filter([1, 2, 3])([1, 2, 3, 4, 5]); // [4, 5] +filter([1, 2], true)([1, false, 3]); // [3] +``` + +### `flatten` + +Flatten nested arrays. + +```typescript +flatten()([1, [2, [3]]]); // [1, 2, [3]] +flatten(2)([1, [2, [3]]]); // [1, 2, 3] +``` + +### `join` + +Join array elements into a string. + +```typescript +join()([1, 2, 3]); // '123' +join('-')([1, 2, 3]); // '1-2-3' +``` + +### `pick` + +Pick elements from an array by index. + +```typescript +pick(0, 2)(['a', 'b', 'c']); // ['a', 'c'] +pick(2, 4)(['a', 'b', 'c', 'd', 'e']); // ['c', 'e'] +``` + +### `pipe` + +Pipe parser functions together. + +```typescript +pipe(lexeme)(letters)('xyz abc'); // { ok: true, value: 'xyz', remaining: 'abc' } +``` + +### `pop` + +Get the last element of an array. + +```typescript +pop()([1, 2, 3]); // 3 +``` + +### `shift` + +Get the first element of an array. + +```typescript +shift()([1, 2, 3]); // 1 +``` + +### `spread` + +Collect spread arguments into an array. + +```typescript +spread()(1, 2, 3); // [1, 2, 3] +``` diff --git a/package.json b/package.json index 64f5875..38f4fc3 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,21 @@ { "name": "unitas", "version": "0.2.5", + "description": "TypeScript-first parser combinator library — compose small typed parsers into whole grammars. Zero dependencies, ESM-only, tree-shakeable.", "keywords": [ + "parser", + "parser-combinator", "combinator", + "parsing", + "grammar", + "peg", + "recursive-descent", + "lexer", + "tokenizer", "functional", - "parser" + "typescript", + "esm", + "zero-dependency" ], "license": "MIT", "author": { @@ -15,6 +26,10 @@ "type": "git", "url": "https://github.com/sovrin/unitas.git" }, + "homepage": "https://github.com/sovrin/unitas#readme", + "bugs": { + "url": "https://github.com/sovrin/unitas/issues" + }, "files": [ "dist", "README.md" @@ -68,7 +83,7 @@ "test:extract": "tsx bin/extract-examples.mjs && oxfmt ./test/examples.test.ts", "test:coverage": "vitest run --coverage", "generate:index": "tsx bin/generate-index.mjs", - "generate:readme": "tsx bin/generate-readme.mjs && oxfmt ./README.md", + "generate:readme": "tsx bin/generate-readme.mjs && oxfmt ./README.md ./doc/api", "lint": "oxlint && node bin/check-test-imports.mjs", "lint:fix": "oxlint --fix", "format": "oxfmt", From 28dd759ffe4eaa9514baa8314c63657ecfef5974 Mon Sep 17 00:00:00 2001 From: Oleg Kamlowski Date: Sun, 6 Sep 2026 12:46:05 +0200 Subject: [PATCH 2/3] refactor(core): drop forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forward was the identity function on Result: export const forward = (result: Result): Result => result; It was called in 8 places as a marker meaning "pass this through unchanged". Replacing every call with the result itself typechecks clean and leaves all tests passing, so it was carrying nothing — just an extra import and an extra indirection per call site. It was also exported from the unitas entry point, where a consumer has no use for it: an identity helper on a public surface only suggests that pass-through needs one. Co-Authored-By: Claude Opus 5 (1M context) --- src/combinators/attempt.ts | 3 +-- src/combinators/chainLeft.ts | 3 +-- src/combinators/chainRight.ts | 3 +-- src/combinators/choice.ts | 3 +-- src/combinators/guard.ts | 3 +-- src/combinators/optional.ts | 3 +-- src/combinators/recover.ts | 3 +-- src/core/forward.test.ts | 32 -------------------------------- src/core/forward.ts | 3 --- src/core/index.ts | 1 - src/core/label.ts | 3 +-- 11 files changed, 8 insertions(+), 52 deletions(-) delete mode 100644 src/core/forward.test.ts delete mode 100644 src/core/forward.ts diff --git a/src/combinators/attempt.ts b/src/combinators/attempt.ts index 676697b..574babf 100644 --- a/src/combinators/attempt.ts +++ b/src/combinators/attempt.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { forward } from '../core/forward'; import { create } from '../core/parser'; /** @@ -13,6 +12,6 @@ export const attempt = (parser: Parser) => { return create((input) => { const result = parser(input); - return forward(result); + return result; }); }; diff --git a/src/combinators/chainLeft.ts b/src/combinators/chainLeft.ts index 47ffd2c..2e0f747 100644 --- a/src/combinators/chainLeft.ts +++ b/src/combinators/chainLeft.ts @@ -1,7 +1,6 @@ import type { Parser } from '../core/parser'; import { failure } from '../core/failure'; -import { forward } from '../core/forward'; import { create } from '../core/parser'; import { chainLeft1 } from './chainLeft1'; import { optional } from './optional'; @@ -23,6 +22,6 @@ export const chainLeft = ( return failure(); } - return forward(result); + return result; }); }; diff --git a/src/combinators/chainRight.ts b/src/combinators/chainRight.ts index 08efcea..46c4d64 100644 --- a/src/combinators/chainRight.ts +++ b/src/combinators/chainRight.ts @@ -1,7 +1,6 @@ import type { Parser } from '../core/parser'; import { failure } from '../core/failure'; -import { forward } from '../core/forward'; import { create } from '../core/parser'; import { chainRight1 } from './chainRight1'; import { optional } from './optional'; @@ -23,6 +22,6 @@ export const chainRight = ( return failure(); } - return forward(result); + return result; }); }; diff --git a/src/combinators/choice.ts b/src/combinators/choice.ts index 7941c4a..6a3fa0c 100644 --- a/src/combinators/choice.ts +++ b/src/combinators/choice.ts @@ -1,7 +1,6 @@ import type { Parser } from '../core/parser'; import { failure } from '../core/failure'; -import { forward } from '../core/forward'; import { create } from '../core/parser'; type ParserOutput

= P extends Parser ? T : never; @@ -19,7 +18,7 @@ export const choice = []>( for (const parser of parsers) { const result = parser(input); if (result.ok) { - return forward(result); + return result; } } diff --git a/src/combinators/guard.ts b/src/combinators/guard.ts index ea37aab..8c70fe0 100644 --- a/src/combinators/guard.ts +++ b/src/combinators/guard.ts @@ -1,7 +1,6 @@ import type { Parser } from '../core/parser'; import { failure } from '../core/failure'; -import { forward } from '../core/forward'; import { create } from '../core/parser'; /** @@ -19,6 +18,6 @@ export const guard = (condition: boolean, parser: Parser) => { const result = parser(input); - return forward(result); + return result; }); }; diff --git a/src/combinators/optional.ts b/src/combinators/optional.ts index 88f6e29..cc8dab6 100644 --- a/src/combinators/optional.ts +++ b/src/combinators/optional.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { forward } from '../core/forward'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -15,6 +14,6 @@ export const optional = (parser: Parser) => { return create((input) => { const result = parser(input); - return result.ok ? forward(result) : success(null, input); + return result.ok ? result : success(null, input); }); }; diff --git a/src/combinators/recover.ts b/src/combinators/recover.ts index 17a559d..ed3829c 100644 --- a/src/combinators/recover.ts +++ b/src/combinators/recover.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { forward } from '../core/forward'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -14,6 +13,6 @@ export const recover = (parser: Parser, fallback: T) => { return create((input) => { const result = parser(input); - return result.ok ? forward(result) : success(fallback, input); + return result.ok ? result : success(fallback, input); }); }; diff --git a/src/core/forward.test.ts b/src/core/forward.test.ts deleted file mode 100644 index abd104c..0000000 --- a/src/core/forward.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { assertFailure, assertSuccess } from '../../test/utils'; -import { failure } from '../core/failure'; -import { success } from '../core/success'; -import { forward } from './forward'; - -describe('forward', () => { - it('should return success result unchanged', () => { - const result = success('test', 'rest'); - const forwarded = forward(result); - - assertSuccess(forwarded, 'test', 'rest'); - expect(forwarded).toBe(result); - }); - - it('should return failure result unchanged', () => { - const result = failure('test error'); - const forwarded = forward(result); - - assertFailure(result, 'test error'); - expect(forwarded).toBe(result); - }); - - it('should return failure without error unchanged', () => { - const result = failure(); - const forwarded = forward(result); - - assertFailure(result); - expect(forwarded).toBe(result); - }); -}); diff --git a/src/core/forward.ts b/src/core/forward.ts deleted file mode 100644 index c5e826a..0000000 --- a/src/core/forward.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { type Result } from './result'; - -export const forward = (result: Result): Result => result; diff --git a/src/core/index.ts b/src/core/index.ts index 8af3a84..e93843e 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,5 +1,4 @@ export * from './failure'; -export * from './forward'; export * from './grammar'; export * from './label'; export * from './lazy'; diff --git a/src/core/label.ts b/src/core/label.ts index f487711..fb5f7f4 100644 --- a/src/core/label.ts +++ b/src/core/label.ts @@ -1,7 +1,6 @@ import type { Parser } from '../core/parser'; import { failure } from './failure'; -import { forward } from './forward'; import { create } from './parser'; /** @@ -17,6 +16,6 @@ export const label = (parser: Parser, expected: string): Parser => { return failure(`expected ${expected}`); } - return forward(result); + return result; }); }; From 43d4e3cead7b10789b642803966759066b927166 Mon Sep 17 00:00:00 2001 From: Oleg Kamlowski Date: Sun, 6 Sep 2026 12:47:41 +0200 Subject: [PATCH 3/3] refactor(combinators): drop attempt, document that backtracking is free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attempt claimed to "wrap a parser to handle backtracking on failure", but it cannot: a Failure is { ok: false; error?: string }, with no field to record a position, and combinators hand every alternative the same input string they started with. sequence discards its local remaining and returns a bare failure(). Since create is itself the identity function, attempt(p) was one extra closure around p and nothing else. It was not accidentally a no-op — it is un-implementable in this design without changing the Failure type. Its own test, "should backtrack on failure", only asserted that a failing parser fails, which holds for any parser at all. Keeping it as a documented no-op would be the worse option: someone arriving from Parsec writes attempt(p) expecting it to change behaviour and silently gets nothing. Remove it and explain the actual guarantee in the README's core concepts, which is where they will look for try. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 13 ++++++++----- doc/README.template.md | 7 ++++++- doc/api/combinators.md | 10 +--------- src/combinators/attempt.test.ts | 26 -------------------------- src/combinators/attempt.ts | 17 ----------------- src/combinators/index.ts | 1 - test/examples.test.ts | 9 --------- 7 files changed, 15 insertions(+), 68 deletions(-) delete mode 100644 src/combinators/attempt.test.ts delete mode 100644 src/combinators/attempt.ts diff --git a/README.md b/README.md index aac644c..7d52ae5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ 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 -- **127 composable exports** — grouped by what they do, indexed below +- **126 composable exports** — grouped by what they do, indexed below - **Mutual recursion out of the box** via `grammar`, no forward declarations > **Note:** This library is in active development. The API may change before v1.0.0. @@ -102,7 +102,6 @@ Most primitives come in a singular and a plural form: `letter` matches one, `let | succeed only when something _doesn't_ match | `not` | | gate on a boolean I already have | `guard`, `unless`, `when` | | look ahead without consuming input | `peek` | -| undo input consumption on failure | `attempt` | ### Sequencing and pulling values out @@ -171,6 +170,12 @@ type Result = Success | Failure; { ok: false, error: 'expected a' } // failure with a message ``` +### Backtracking is free + +Note what a `Failure` does _not_ carry: a position. There is nowhere to record how much input a failed parser got through, and combinators hand every alternative the same string they started with, so a branch that fails can never leave the cursor moved. `choice(a, b)` always offers `b` the full input, 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. + ### Writing one by hand Most of the time you compose existing pieces, but nothing stops you from dropping down a level: @@ -375,11 +380,10 @@ Ready-made parsers for the usual suspects. **33** exports — [full reference ### Combinators — `unitas/combinators` -Take parsers, return a new parser. **65** exports — [full reference →][api-combinators] +Take parsers, return a new parser. **64** exports — [full reference →][api-combinators] | | | | -------------------------------------------------------- | --------------------------------------------------------------------------- | -| [`attempt`][combinators-attempt] | Attempt wraps a parser to handle backtracking on failure. | | [`bind`][combinators-bind] | Chain parsers where the second parser depends on the first result. | | [`braced`][combinators-braced] | Parse content surrounded by braces. | | [`bracketed`][combinators-bracketed] | Parse content surrounded by brackets. | @@ -518,7 +522,6 @@ Plain helpers for `map` callbacks. **8** exports — [full reference →][api-ut [primitives-whitespace]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#whitespace [primitives-whitespaces]: https://github.com/sovrin/unitas/blob/master/doc/api/primitives.md#whitespaces [api-combinators]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md -[combinators-attempt]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#attempt [combinators-bind]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#bind [combinators-braced]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#braced [combinators-bracketed]: https://github.com/sovrin/unitas/blob/master/doc/api/combinators.md#bracketed diff --git a/doc/README.template.md b/doc/README.template.md index eac85db..846254d 100644 --- a/doc/README.template.md +++ b/doc/README.template.md @@ -102,7 +102,6 @@ Most primitives come in a singular and a plural form: `letter` matches one, `let | succeed only when something *doesn't* match | `not` | | gate on a boolean I already have | `guard`, `unless`, `when` | | look ahead without consuming input | `peek` | -| undo input consumption on failure | `attempt` | ### Sequencing and pulling values out @@ -171,6 +170,12 @@ type Result = Success | Failure; { ok: false, error: 'expected a' } // failure with a message ``` +### Backtracking is free + +Note what a `Failure` does *not* carry: a position. There is nowhere to record how much input a failed parser got through, and combinators hand every alternative the same string they started with, so a branch that fails can never leave the cursor moved. `choice(a, b)` always offers `b` the full input, 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. + ### Writing one by hand Most of the time you compose existing pieces, but nothing stops you from dropping down a level: diff --git a/doc/api/combinators.md b/doc/api/combinators.md index a83b3f1..0188ffc 100644 --- a/doc/api/combinators.md +++ b/doc/api/combinators.md @@ -12,18 +12,10 @@ import {} from /* … */ 'unitas/combinators'; ## Index -65 exports: [`attempt`](#attempt) · [`bind`](#bind) · [`braced`](#braced) · [`bracketed`](#bracketed) · [`chainLeft`](#chainleft) · [`chainLeft1`](#chainleft1) · [`chainRight`](#chainright) · [`chainRight1`](#chainright1) · [`choice`](#choice) · [`concat`](#concat) · [`consume`](#consume) · [`endBy`](#endby) · [`endBy1`](#endby1) · [`exactly`](#exactly) · [`first`](#first) · [`flag`](#flag) · [`fold`](#fold) · [`fold1`](#fold1) · [`foldRight`](#foldright) · [`foldRight1`](#foldright1) · [`fuse`](#fuse) · [`guard`](#guard) · [`inner`](#inner) · [`interleaved`](#interleaved) · [`last`](#last) · [`left`](#left) · [`lexeme`](#lexeme) · [`many`](#many) · [`many1`](#many1) · [`manyAtLeast`](#manyatleast) · [`manyAtMost`](#manyatmost) · [`manyBetween`](#manybetween) · [`manyTill`](#manytill) · [`map`](#map) · [`node`](#node) · [`not`](#not) · [`nth`](#nth) · [`optional`](#optional) · [`optionalConsume`](#optionalconsume) · [`optionalSeparatedBy`](#optionalseparatedby) · [`outer`](#outer) · [`padded`](#padded) · [`parenthesized`](#parenthesized) · [`peek`](#peek) · [`postfix`](#postfix) · [`prefix`](#prefix) · [`pure`](#pure) · [`quoted`](#quoted) · [`recover`](#recover) · [`right`](#right) · [`separatedBy`](#separatedby) · [`separatedBy1`](#separatedby1) · [`separatedEndBy`](#separatedendby) · [`separatedEndBy1`](#separatedendby1) · [`separatedUntil`](#separateduntil) · [`sequence`](#sequence) · [`skip`](#skip) · [`skipMany`](#skipmany) · [`skipMany1`](#skipmany1) · [`surrounded`](#surrounded) · [`unless`](#unless) · [`until`](#until) · [`validate`](#validate) · [`value`](#value) · [`when`](#when) +64 exports: [`bind`](#bind) · [`braced`](#braced) · [`bracketed`](#bracketed) · [`chainLeft`](#chainleft) · [`chainLeft1`](#chainleft1) · [`chainRight`](#chainright) · [`chainRight1`](#chainright1) · [`choice`](#choice) · [`concat`](#concat) · [`consume`](#consume) · [`endBy`](#endby) · [`endBy1`](#endby1) · [`exactly`](#exactly) · [`first`](#first) · [`flag`](#flag) · [`fold`](#fold) · [`fold1`](#fold1) · [`foldRight`](#foldright) · [`foldRight1`](#foldright1) · [`fuse`](#fuse) · [`guard`](#guard) · [`inner`](#inner) · [`interleaved`](#interleaved) · [`last`](#last) · [`left`](#left) · [`lexeme`](#lexeme) · [`many`](#many) · [`many1`](#many1) · [`manyAtLeast`](#manyatleast) · [`manyAtMost`](#manyatmost) · [`manyBetween`](#manybetween) · [`manyTill`](#manytill) · [`map`](#map) · [`node`](#node) · [`not`](#not) · [`nth`](#nth) · [`optional`](#optional) · [`optionalConsume`](#optionalconsume) · [`optionalSeparatedBy`](#optionalseparatedby) · [`outer`](#outer) · [`padded`](#padded) · [`parenthesized`](#parenthesized) · [`peek`](#peek) · [`postfix`](#postfix) · [`prefix`](#prefix) · [`pure`](#pure) · [`quoted`](#quoted) · [`recover`](#recover) · [`right`](#right) · [`separatedBy`](#separatedby) · [`separatedBy1`](#separatedby1) · [`separatedEndBy`](#separatedendby) · [`separatedEndBy1`](#separatedendby1) · [`separatedUntil`](#separateduntil) · [`sequence`](#sequence) · [`skip`](#skip) · [`skipMany`](#skipmany) · [`skipMany1`](#skipmany1) · [`surrounded`](#surrounded) · [`unless`](#unless) · [`until`](#until) · [`validate`](#validate) · [`value`](#value) · [`when`](#when) ## Reference -### `attempt` - -Attempt wraps a parser to handle backtracking on failure. - -```typescript -attempt(string('hello'))('hello world'); // { ok: true, value: 'hello', remaining: ' world' } -``` - ### `bind` Chain parsers where the second parser depends on the first result. diff --git a/src/combinators/attempt.test.ts b/src/combinators/attempt.test.ts deleted file mode 100644 index 27a34f5..0000000 --- a/src/combinators/attempt.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it } from 'vitest'; - -import { - assertFailure, - assertSuccess, - createTestParser, -} from '../../test/utils'; -import { attempt } from './attempt'; - -describe('attempt', () => { - it('should run parser and return result on success', () => { - const parser1 = createTestParser('A'); - const parser = attempt(parser1); - const result = parser('ABC'); - - assertSuccess(result, 'A', 'BC'); - }); - - it('should backtrack on failure', () => { - const parser1 = createTestParser('A'); - const parser = attempt(parser1); - const result = parser('BAC'); - - assertFailure(result); - }); -}); diff --git a/src/combinators/attempt.ts b/src/combinators/attempt.ts deleted file mode 100644 index 574babf..0000000 --- a/src/combinators/attempt.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Parser } from '../core/parser'; - -import { create } from '../core/parser'; - -/** - * Attempt wraps a parser to handle backtracking on failure. - * - * @example - * attempt(string('hello'))('hello world') // { ok: true, value: 'hello', remaining: ' world' } - */ -export const attempt = (parser: Parser) => { - return create((input) => { - const result = parser(input); - - return result; - }); -}; diff --git a/src/combinators/index.ts b/src/combinators/index.ts index 9f3dbe6..bcb2d66 100644 --- a/src/combinators/index.ts +++ b/src/combinators/index.ts @@ -1,4 +1,3 @@ -export * from './attempt'; export * from './bind'; export * from './braced'; export * from './bracketed'; diff --git a/test/examples.test.ts b/test/examples.test.ts index 15df021..f50acfa 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -20,15 +20,6 @@ Object.assign( describe('examples from source', () => { describe('combinators', () => { - it('attempt: Attempt wraps a parser to handle backtracking on failure.', () => { - const result0 = attempt(string('hello'))('hello world'); - expect(result0).toEqual({ - ok: true, - value: 'hello', - remaining: ' world', - }); - }); - it('bind: Chain parsers where the second parser depends on the first result.', () => { const result0 = bind(digits, (n) => take(n))('3abc'); expect(result0).toEqual({ ok: true, value: 'abc', remaining: '' });