From 75818644eaeda09f11cd71dcb64350d5d3b94719 Mon Sep 17 00:00:00 2001 From: Oleg Kamlowski Date: Sun, 6 Sep 2026 23:40:57 +0200 Subject: [PATCH 1/5] feat(core)!: parse by offset and report where a failure happened Parsers took a sliced suffix of the input, so nothing in the system knew where it was in the original source. Position was not being withheld, it did not exist: `position` returned the remaining length, and 50 of the 52 failure sites passed no message because there was nothing useful to say. A `Parser` now reads from an offset and never slices: type Parser = (input: string, index?: number) => Result; The offset defaults to 0, so composing the built-in parsers is unchanged and `myParser('input')` still works. Results also carry the furthest offset any branch reached and what was expected there, threaded by the new `merge`. That is what makes the message point at the mistake rather than at wherever the parse stopped: before : Not all input consumed: ",b=x,c=3" offsets: 1:4 expected end of input, found ',' now : 1:7 expected digit, found 'x' 1 | a=1,b=x,c=3 | ^ `separatedBy` succeeds at 1:4 with one pair, so without the trace the error describes the wrong character. Falling out of the offsets: - `parse` returns a failure with line, column and expectations instead of throwing; `run` throws a `ParseError` carrying the same. - `grammar` names left-recursive rules instead of overflowing the stack. - `memoize` keys on an offset, so entries are cheap and are dropped when a different input is parsed. - `regex` matches with a sticky pattern, anchored by construction; a leading `^` is redundant and stripped. - `position` reports the offset from the start, which can be paired with `locate` to give a parsed node a source span. Simple loops got faster (many(char) over 200k: 4.7ms -> 3.3ms) since nothing allocates a slice per step. Choice-heavy loops pay for the trace (many(choice) over 20k: 7.3ms -> 10.8ms). BREAKING CHANGE: `Success.remaining` is replaced by `Success.index`, and `Failure.error` by `index`/`furthest`/`expected`. `success(value, remaining)` becomes `success(value, index)` and `failure(message)` becomes `failure(index, ...expected)`. `run` throws a `ParseError` rather than a generic `Error`, `match`'s failure branch receives `(index, expected)`, and `position` returns an offset rather than the remaining length. Composing the built-in parsers needs no change; see "Migrating from 0.2.x" in the README. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 376 +++++-- doc/README.template.md | 153 ++- doc/api/combinators.md | 218 ++-- doc/api/core.md | 70 +- doc/api/primitives.md | 113 +- doc/api/terminals.md | 34 +- doc/api/utils.md | 2 +- package.json | 2 +- src/combinators/bind.test.ts | 28 +- src/combinators/bind.ts | 14 +- src/combinators/braced.test.ts | 6 +- src/combinators/braced.ts | 2 +- src/combinators/bracketed.test.ts | 6 +- src/combinators/bracketed.ts | 2 +- src/combinators/chainLeft.test.ts | 4 +- src/combinators/chainLeft.ts | 10 +- src/combinators/chainLeft1.test.ts | 16 +- src/combinators/chainLeft1.ts | 28 +- src/combinators/chainRight.test.ts | 6 +- src/combinators/chainRight.ts | 12 +- src/combinators/chainRight1.test.ts | 12 +- src/combinators/chainRight1.ts | 46 +- src/combinators/choice.test.ts | 32 +- src/combinators/choice.ts | 20 +- src/combinators/concat.test.ts | 16 +- src/combinators/concat.ts | 4 +- src/combinators/consume.test.ts | 2 +- src/combinators/consume.ts | 2 +- src/combinators/endBy.test.ts | 10 +- src/combinators/endBy.ts | 2 +- src/combinators/endBy1.test.ts | 6 +- src/combinators/endBy1.ts | 2 +- src/combinators/exactly.test.ts | 6 +- src/combinators/exactly.ts | 20 +- src/combinators/first.test.ts | 8 +- src/combinators/first.ts | 2 +- src/combinators/flag.test.ts | 8 +- src/combinators/flag.ts | 13 +- src/combinators/fold.test.ts | 26 +- src/combinators/fold.ts | 21 +- src/combinators/fold1.test.ts | 12 +- src/combinators/fold1.ts | 31 +- src/combinators/foldRight.test.ts | 26 +- src/combinators/foldRight.ts | 20 +- src/combinators/foldRight1.test.ts | 12 +- src/combinators/foldRight1.ts | 32 +- src/combinators/fuse.test.ts | 8 +- src/combinators/fuse.ts | 30 +- src/combinators/guard.test.ts | 2 +- src/combinators/guard.ts | 12 +- src/combinators/inner.test.ts | 2 +- src/combinators/inner.ts | 2 +- src/combinators/interleaved.test.ts | 6 +- src/combinators/interleaved.ts | 28 +- src/combinators/last.test.ts | 8 +- src/combinators/last.ts | 2 +- src/combinators/left.test.ts | 2 +- src/combinators/left.ts | 2 +- src/combinators/lexeme.test.ts | 8 +- src/combinators/lexeme.ts | 2 +- src/combinators/many.test.ts | 28 +- src/combinators/many.ts | 22 +- src/combinators/many1.test.ts | 8 +- src/combinators/many1.ts | 23 +- src/combinators/manyAtLeast.test.ts | 8 +- src/combinators/manyAtLeast.ts | 25 +- src/combinators/manyAtMost.test.ts | 10 +- src/combinators/manyAtMost.ts | 19 +- src/combinators/manyBetween.test.ts | 10 +- src/combinators/manyBetween.ts | 29 +- src/combinators/manyTill.test.ts | 6 +- src/combinators/manyTill.ts | 24 +- src/combinators/map.test.ts | 20 +- src/combinators/map.ts | 26 +- src/combinators/node.test.ts | 14 +- src/combinators/node.ts | 4 +- src/combinators/not.test.ts | 2 +- src/combinators/not.ts | 11 +- src/combinators/nth.test.ts | 18 +- src/combinators/nth.ts | 2 +- src/combinators/optional.test.ts | 6 +- src/combinators/optional.ts | 11 +- src/combinators/optionalConsume.test.ts | 6 +- src/combinators/optionalConsume.ts | 14 +- src/combinators/optionalSeparatedBy.test.ts | 14 +- src/combinators/optionalSeparatedBy.ts | 38 +- src/combinators/outer.test.ts | 2 +- src/combinators/outer.ts | 2 +- src/combinators/padded.test.ts | 12 +- src/combinators/padded.ts | 8 +- src/combinators/parenthesized.test.ts | 4 +- src/combinators/parenthesized.ts | 2 +- src/combinators/peek.test.ts | 2 +- src/combinators/peek.ts | 12 +- src/combinators/postfix.test.ts | 24 +- src/combinators/postfix.ts | 27 +- src/combinators/prefix.test.ts | 26 +- src/combinators/prefix.ts | 26 +- src/combinators/pure.test.ts | 6 +- src/combinators/pure.ts | 7 +- src/combinators/quoted.test.ts | 8 +- src/combinators/quoted.ts | 2 +- src/combinators/recover.test.ts | 14 +- src/combinators/recover.ts | 9 +- src/combinators/right.test.ts | 2 +- src/combinators/right.ts | 2 +- src/combinators/separatedBy.test.ts | 10 +- src/combinators/separatedBy.ts | 37 +- src/combinators/separatedBy1.test.ts | 6 +- src/combinators/separatedBy1.ts | 25 +- src/combinators/separatedEndBy.test.ts | 10 +- src/combinators/separatedEndBy.ts | 27 +- src/combinators/separatedEndBy1.test.ts | 8 +- src/combinators/separatedEndBy1.ts | 22 +- src/combinators/separatedUntil.test.ts | 6 +- src/combinators/separatedUntil.ts | 27 +- src/combinators/sequence.test.ts | 16 +- src/combinators/sequence.ts | 20 +- src/combinators/skip.test.ts | 4 +- src/combinators/skip.ts | 21 +- src/combinators/skipMany.test.ts | 6 +- src/combinators/skipMany.ts | 2 +- src/combinators/skipMany1.test.ts | 4 +- src/combinators/skipMany1.ts | 2 +- src/combinators/surrounded.test.ts | 6 +- src/combinators/surrounded.ts | 4 +- src/combinators/unless.test.ts | 4 +- src/combinators/unless.ts | 8 +- src/combinators/until.test.ts | 6 +- src/combinators/until.ts | 27 +- src/combinators/validate.test.ts | 2 +- src/combinators/validate.ts | 16 +- src/combinators/value.test.ts | 6 +- src/combinators/value.ts | 4 +- src/combinators/when.test.ts | 14 +- src/combinators/when.ts | 18 +- src/core/error.ts | 28 + src/core/failure.test.ts | 20 +- src/core/failure.ts | 20 +- src/core/format.ts | 45 + src/core/grammar.test.ts | 12 +- src/core/grammar.ts | 35 +- src/core/index.ts | 5 + src/core/label.test.ts | 4 +- src/core/label.ts | 19 +- src/core/lazy.test.ts | 45 +- src/core/lazy.ts | 6 +- src/core/locate.ts | 23 + src/core/match.test.ts | 39 +- src/core/match.ts | 10 +- src/core/memoize.test.ts | 6 +- src/core/memoize.ts | 28 +- src/core/merge.ts | 60 + src/core/parse.ts | 46 + src/core/parser.test.ts | 10 +- src/core/parser.ts | 8 +- src/core/run.test.ts | 16 +- src/core/run.ts | 56 +- src/core/success.test.ts | 4 +- src/core/success.ts | 24 +- src/primitives/alphaNum.test.ts | 6 +- src/primitives/alphaNum.ts | 13 +- src/primitives/alphaNums.test.ts | 10 +- src/primitives/alphaNums.ts | 16 +- src/primitives/anyChar.test.ts | 4 +- src/primitives/anyChar.ts | 8 +- src/primitives/bool.test.ts | 8 +- src/primitives/bool.ts | 6 +- src/primitives/crlf.test.ts | 2 +- src/primitives/crlf.ts | 2 +- src/primitives/digit.test.ts | 8 +- src/primitives/digit.ts | 4 +- src/primitives/digits.test.ts | 8 +- src/primitives/digits.ts | 17 +- src/primitives/eof.test.ts | 2 +- src/primitives/eof.ts | 10 +- src/primitives/eol.test.ts | 8 +- src/primitives/eol.ts | 2 +- src/primitives/float.test.ts | 8 +- src/primitives/float.ts | 6 +- src/primitives/hexDigit.test.ts | 6 +- src/primitives/hexDigit.ts | 11 +- src/primitives/hexDigits.test.ts | 10 +- src/primitives/hexDigits.ts | 16 +- src/primitives/identifier.test.ts | 20 +- src/primitives/identifier.ts | 2 +- src/primitives/integer.test.ts | 8 +- src/primitives/integer.ts | 6 +- src/primitives/letter.test.ts | 10 +- src/primitives/letter.ts | 11 +- src/primitives/letters.test.ts | 8 +- src/primitives/letters.ts | 16 +- src/primitives/line.test.ts | 12 +- src/primitives/line.ts | 2 +- src/primitives/literal.test.ts | 16 +- src/primitives/literal.ts | 4 +- src/primitives/lowercase.test.ts | 4 +- src/primitives/lowercase.ts | 11 +- src/primitives/lowercases.test.ts | 8 +- src/primitives/lowercases.ts | 16 +- src/primitives/nl.test.ts | 2 +- src/primitives/nl.ts | 4 +- src/primitives/number.test.ts | 10 +- src/primitives/number.ts | 8 +- src/primitives/octDigit.test.ts | 4 +- src/primitives/octDigit.ts | 11 +- src/primitives/octDigits.test.ts | 10 +- src/primitives/octDigits.ts | 16 +- src/primitives/position.test.ts | 6 +- src/primitives/position.ts | 12 +- src/primitives/rest.test.ts | 4 +- src/primitives/rest.ts | 8 +- src/primitives/space.test.ts | 2 +- src/primitives/space.ts | 4 +- src/primitives/spaces.test.ts | 4 +- src/primitives/spaces.ts | 15 +- src/primitives/tab.test.ts | 2 +- src/primitives/tab.ts | 2 +- src/primitives/uppercase.test.ts | 4 +- src/primitives/uppercase.ts | 11 +- src/primitives/uppercases.test.ts | 8 +- src/primitives/uppercases.ts | 16 +- src/primitives/whitespace.test.ts | 4 +- src/primitives/whitespace.ts | 4 +- src/primitives/whitespaces.test.ts | 8 +- src/primitives/whitespaces.ts | 16 +- src/terminals/char.test.ts | 2 +- src/terminals/char.ts | 12 +- src/terminals/charOf.test.ts | 2 +- src/terminals/charOf.ts | 19 +- src/terminals/noneOf.test.ts | 4 +- src/terminals/noneOf.ts | 12 +- src/terminals/oneOf.test.ts | 12 +- src/terminals/oneOf.ts | 12 +- src/terminals/regex.test.ts | 6 +- src/terminals/regex.ts | 26 +- src/terminals/satisfy.test.ts | 2 +- src/terminals/satisfy.ts | 17 +- src/terminals/string.test.ts | 6 +- src/terminals/string.ts | 13 +- src/terminals/stringOf.test.ts | 2 +- src/terminals/stringOf.ts | 12 +- src/terminals/take.test.ts | 6 +- src/terminals/take.ts | 12 +- src/terminals/takeWhile.test.ts | 8 +- src/terminals/takeWhile.ts | 12 +- src/terminals/token.test.ts | 12 +- src/terminals/token.ts | 6 +- src/terminals/word.test.ts | 8 +- src/terminals/word.ts | 6 +- src/utils/join.ts | 4 +- src/utils/pipe.test.ts | 10 +- src/utils/pipe.ts | 2 +- test/examples.test.ts | 1128 ++++++++++++++----- test/helpers.ts | 36 +- test/utils.ts | 50 +- 256 files changed, 3261 insertions(+), 1877 deletions(-) create mode 100644 src/core/error.ts create mode 100644 src/core/format.ts create mode 100644 src/core/locate.ts create mode 100644 src/core/merge.ts create mode 100644 src/core/parse.ts diff --git a/README.md b/README.md index 7d52ae5..b0712a8 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,9 @@ 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 -- **126 composable exports** — grouped by what they do, indexed below +- **131 composable exports** — grouped by what they do, indexed below - **Mutual recursion out of the box** via `grammar`, no forward declarations +- **Errors that point at the problem** — line, column and what was expected > **Note:** This library is in active development. The API may change before v1.0.0. @@ -43,8 +44,10 @@ run(csv.row, '"a,b",c'); // ['a,b', 'c'] - [Entry points](#entry-points) - [Which function do I need?](#which-function-do-i-need) - [Core concepts](#core-concepts) +- [Error reporting](#error-reporting) - [More examples](#more-examples) - [API index](#api-index) +- [Migrating from 0.2.x](#migrating-from-02x) ## Entry points @@ -127,54 +130,74 @@ Most primitives come in a singular and a plural form: `letter` matches one, `let ### 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` | +| I want to… | Reach for | +| -------------------------------------------- | ------------------------------------------------------ | +| let rules reference each other by name | `grammar` | +| refer to a parser defined further down | `lazy` | +| cache results and avoid exponential blowup | `memoize` | +| attach a readable error message | `label` | +| parse without throwing, with line and column | `parse` | +| turn an offset into a line and column | `locate` | +| render a failure with a caret under it | `format`, `error` | +| carry a failure trace through a combinator | `merge` | +| 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. +A `Parser` reads `input` from an offset and returns a `Result`. That is the whole abstraction — everything else in this library either produces one or wraps one. The offset defaults to `0`, so a parser can still be called with just an input. ```typescript -type Parser = (input: string) => Result; +type Parser = (input: string, index?: number) => Result; ``` +Parsers never slice the input. They pass the same string down and move an integer instead, which is what lets an error know where in the original source it happened. + ### The `Result` type ```typescript -type Success = { ok: true; value: T; remaining: string }; -type Failure = { ok: false; error?: string }; +type Success = { + ok: true; + value: T; + index: number; + furthest: number; + expected: readonly string[]; +}; +type Failure = { + ok: false; + index: number; + furthest: number; + expected: readonly string[]; +}; type Result = Success | Failure; ``` ```typescript -{ ok: true, value: 'hello', remaining: ' world' } - │ │ │ - │ │ └── what is left to parse +{ ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } + │ │ │ │ │ + │ │ │ │ └── what was wanted there + │ │ │ └── furthest offset any branch reached + │ │ └── how far this parser got │ └── 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`. +`index` is the important part: it is how input gets consumed and how parsers chain. A failure reports the same `index`, plus the set of things that would have matched there: ```typescript -{ ok: false } // generic failure -{ ok: false, error: 'expected a' } // failure with a message +{ ok: false, index: 6, furthest: 6, expected: ['digit'] } ``` +`furthest` and `expected` are bookkeeping for error messages. A parser that backtracks still remembers the furthest point it reached, so `run` can report _that_ instead of the place the parse happened to stop. You only need them if you write a combinator by hand — `merge` threads them for you. + ### 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. +Combinators hand every alternative the same offset they started with, so a branch that fails can never leave the cursor moved. `choice(a, b)` always offers `b` the same starting offset, however far `a` got. -If you are coming from Parsec, this is why there is no `try`/`attempt` here — backtracking is unconditional, so there is nothing to opt into. +If you are coming from Parsec, this is why there is no `try`/`attempt` here — backtracking is unconditional, so there is nothing to opt into. What a failed branch _does_ leave behind is its expectation, recorded at the offset it reached, so a later error can still mention it. ### Writing one by hand @@ -183,12 +206,12 @@ Most of the time you compose existing pieces, but nothing stops you from droppin ```typescript import { create, success, failure } from 'unitas'; -const parser = create((input) => { - if (input.startsWith('hello')) { - return success('hello', input.slice(5)); +const parser = create((input, index = 0) => { + if (input.startsWith('hello', index)) { + return success('hello', index + 5); } - return failure('expected "hello"'); + return failure(index, '"hello"'); }); ``` @@ -222,6 +245,60 @@ run(g.expr, '1+2+3'); // 6 run(g.expr, '(1+2)'); // 3 ``` +## Error reporting + +When a parse fails, `run` throws a `ParseError` that says where and what was expected: + +```typescript +import { run } from 'unitas'; +import { separatedBy, sequence } from 'unitas/combinators'; +import { char } from 'unitas/terminals'; +import { digits, letters } from 'unitas/primitives'; + +const pair = sequence(letters, char('='), digits); +const config = separatedBy(pair, char('\n')); + +run(config, 'host=1\nport=8080\ndebug=yes'); +// ParseError: 3:7 expected digit, found 'y' +// 3 | debug=yes +// | ^ +``` + +The position is the _furthest_ offset any branch reached, not wherever the parse happened to stop. That distinction is what makes the message useful: `separatedBy` succeeded with two pairs and left the rest unconsumed, but the error still points at the `y` that actually broke it. + +Alternatives are reported together, since `choice` unions the expectations of every branch it tried at the same offset: + +```typescript +run(sequence(choice(string('let'), string('const')), char(' ')), 'lot x'); +// ParseError: 1:1 expected 'const' or 'let', found 'l' +// 1 | lot x +// | ^ +``` + +Use `parse` when you would rather branch on the failure than catch it: + +```typescript +import { parse } from 'unitas'; + +const result = parse(config, 'host=1\nport=oops'); + +if (!result.ok) { + result.line; // 2 + result.column; // 6 + result.expected; // ['digit'] + result.message; // the formatted excerpt above +} +``` + +`label` replaces the expectations of a whole parser with one description, so errors can talk about your grammar instead of its characters: + +```typescript +import { label } from 'unitas'; + +run(label(pair, 'a key=value pair'), '!!!'); +// ParseError: 1:1 expected a key=value pair, found '!' +``` + ## More examples **JSON value** — a tagged union of scalars @@ -305,19 +382,24 @@ One line per export. Every name links to its full description and runnable examp ### 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. | +Types, constructors and the grammar runner. **14** exports — [full reference →][api-core] + +| | | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| [`error`][core-error] | Error thrown by {@link run} when a parse fails, carrying the offset, the 1-based line/column and the set of expectations at that offset. | +| [`failure`][core-failure] | Creates a failed result at an offset, describing what was expected there. | +| [`format`][core-format] | Renders a parse failure as a source excerpt with a caret under the offset. | +| [`grammar`][core-grammar] | Creates a recursive grammar where rules can reference each other. | +| [`label`][core-label] | Replaces the expectations of a parser with a single description. | +| [`lazy`][core-lazy] | Defers parser creation, useful for recursive grammars. | +| [`locate`][core-locate] | Converts a character offset into a 1-based line and column. | +| [`match`][core-match] | Pattern matching on a Result to handle success and failure cases. | +| [`memoize`][core-memoize] | Memoizes a parser so each offset is parsed at most once (packrat caching). | +| [`merge`][core-merge] | Carries the furthest-failure trace of an earlier result into a later one. | +| [`parse`][core-parse] | Runs a parser over the whole input without throwing. | +| [`parser`][core-parser] | Creates a parser from a parser function. | +| [`run`][core-run] | Runs a parser over the whole input and returns the value. | +| [`success`][core-success] | Creates a successful result with a value and the offset reached in the input. | ### Terminals — `unitas/terminals` @@ -345,109 +427,109 @@ Ready-made parsers for the usual suspects. **33** exports — [full reference | | | | --------------------------------------- | ------------------------------------------------------------------------------------ | | [`alphaNum`][primitives-alphanum] | Parse a single alphanumeric character. | -| [`alphaNums`][primitives-alphanums] | Parse one or more alphanumeric characters. | +| [`alphaNums`][primitives-alphanums] | Parse one or more alphanumeric characters as a string. | | [`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). | +| [`digits`][primitives-digits] | Parse one or more digits and return as a number. | +| [`eof`][primitives-eof] | Match the end of the 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. | +| [`hexDigits`][primitives-hexdigits] | Parse one or more hex digits as a string. | | [`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. | +| [`letters`][primitives-letters] | Parse one or more letters as a string. | | [`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. | +| [`lowercases`][primitives-lowercases] | Parse one or more lowercase letters as a string. | | [`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. | +| [`octDigits`][primitives-octdigits] | Parse one or more octal digits as a string. | +| [`position`][primitives-position] | Get the current offset into the input, without consuming anything. | +| [`rest`][primitives-rest] | Consume and return everything left in the input. | | [`space`][primitives-space] | Parse a single space character. | -| [`spaces`][primitives-spaces] | Parse one or more space characters. | +| [`spaces`][primitives-spaces] | Parse one or more spaces as a string. | | [`tab`][primitives-tab] | Parse tab character. | | [`uppercase`][primitives-uppercase] | Parses a single uppercase letter. | -| [`uppercases`][primitives-uppercases] | Parses one or more uppercase letters. | +| [`uppercases`][primitives-uppercases] | Parse one or more uppercase letters as a string. | | [`whitespace`][primitives-whitespace] | Parses a single whitespace character. | -| [`whitespaces`][primitives-whitespaces] | Parses one or more whitespace characters. | +| [`whitespaces`][primitives-whitespaces] | Parse one or more whitespaces as a string. | ### Combinators — `unitas/combinators` Take parsers, return a new parser. **64** exports — [full reference →][api-combinators] -| | | -| -------------------------------------------------------- | --------------------------------------------------------------------------- | -| [`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. | +| | | +| -------------------------------------------------------- | ------------------------------------------------------------------------------- | +| [`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. | +| [`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] | Fold zero or more occurrences into a single value. | +| [`fold1`][combinators-fold1] | Fold one or more occurrences into a single value. | +| [`foldRight`][combinators-foldright] | Fold zero or more occurrences from the right into a single value. | +| [`foldRight1`][combinators-foldright1] | Fold one or more occurrences from the right into a single value. | +| [`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 separated by separators, keeping both in the result. | +| [`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. | +| [`manyAtLeast`][combinators-manyatleast] | Parse at least n occurrences. | +| [`manyAtMost`][combinators-manyatmost] | Parse at most n occurrences (never fails). | +| [`manyBetween`][combinators-manybetween] | Parse between min and max occurrences. | +| [`manyTill`][combinators-manytill] | Parse zero or more until terminator matches. | +| [`map`][combinators-map] | Transform a parsed value through one or more functions. | +| [`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] | Consume input if the parser matches, discarding the result. | +| [`optionalSeparatedBy`][combinators-optionalseparatedby] | Parse items separated by a separator, allowing empty slots. | +| [`outer`][combinators-outer] | Extract outer values from a sequence of 3 parsers (skip middle). | +| [`padded`][combinators-padded] | Parse content surrounded by optional whitespace. | +| [`parenthesized`][combinators-parenthesized] | Parse content surrounded by parentheses. | +| [`peek`][combinators-peek] | Look ahead without consuming input. | +| [`postfix`][combinators-postfix] | Apply zero or more postfix operators to an atom. | +| [`prefix`][combinators-prefix] | Apply zero or more prefix operators to an atom. | +| [`pure`][combinators-pure] | Always succeed with 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] | Parse zero or more items separated by a separator. | +| [`separatedBy1`][combinators-separatedby1] | Parse one or more items separated by a separator. | +| [`separatedEndBy`][combinators-separatedendby] | Parse zero or more items separated by a separator, allowing a trailing one. | +| [`separatedEndBy1`][combinators-separatedendby1] | Parse one or more items separated by a separator, allowing a trailing one. | +| [`separatedUntil`][combinators-separateduntil] | Parse items separated by a separator, up to a terminator. | +| [`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 zero or more until terminator matches, leaving the terminator unconsumed. | +| [`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` @@ -465,12 +547,17 @@ Plain helpers for `map` callbacks. **8** exports — [full reference →][api-ut | [`spread`][utils-spread] | Collect spread arguments into an array. | [api-core]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md +[core-error]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#error [core-failure]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#failure +[core-format]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#format [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-locate]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#locate [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-merge]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#merge +[core-parse]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#parse [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 @@ -596,6 +683,61 @@ Plain helpers for `map` callbacks. **8** exports — [full reference →][api-ut [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 +## Migrating from 0.2.x + +`0.3.0` changes how a parser reports where it got to. Parsers now take an offset into the input instead of receiving a sliced suffix, which is what makes line/column error reporting possible. + +**If you only compose the built-in parsers, nothing changes.** `run`, `grammar`, `map`, `choice` and everything built on them behave the same, and the offset argument defaults to `0`, so `myParser('input')` still works. + +Two things do change for everyone: + +```typescript +// reading a result +result.remaining; // 0.2.x +input.slice(result.index); // 0.3.0 — or just use result.index + +// a failed run +run(p, 'bad'); // 0.2.x: Error: Parsing failed: Unexpected error +run(p, 'bad'); // 0.3.0: ParseError, with .line, .column and .expected +``` + +If you write parsers by hand, the shape changed: + +```typescript +// 0.2.x +create((input) => { + if (input.startsWith('hello')) { + return success('hello', input.slice(5)); + } + + return failure('expected "hello"'); +}); + +// 0.3.0 +create((input, index = 0) => { + if (input.startsWith('hello', index)) { + return success('hello', index + 5); + } + + return failure(index, '"hello"'); +}); +``` + +The rest, in full: + +| 0.2.x | 0.3.0 | +| ---------------------------------- | --------------------------------------------------------------- | +| `Parser = (input) => Result` | `Parser = (input, index?) => Result` | +| `success(value, remaining)` | `success(value, index)` | +| `failure()` / `failure(message)` | `failure(index, ...expected)` | +| `{ ok: false, error?: string }` | `{ ok: false, index, furthest, expected }` | +| `match`'s `failure: (error) => …` | `failure: (index, expected) => …` | +| `position` → remaining length | `position` → offset from the start | +| `label(p, 'x')` → `'expected x'` | `label(p, 'x')` → `expected: ['x']`, only when nothing consumed | +| `regex(/^\w+/)` | same, but sticky — a leading `^` is redundant and stripped | + +New in this release: `parse` (non-throwing), `ParseError`, `locate`, `format` and `merge`. `memoize` is now keyed by offset rather than by the remaining input, so its cache entries are cheap and are dropped when a different input is parsed. Left-recursive `grammar` rules now throw a named error instead of overflowing the stack. + ## Contributing ```bash diff --git a/doc/README.template.md b/doc/README.template.md index 846254d..1fcf597 100644 --- a/doc/README.template.md +++ b/doc/README.template.md @@ -35,6 +35,7 @@ run(csv.row, '"a,b",c'); // ['a,b', 'c'] - **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 +- **Errors that point at the problem** — line, column and what was expected > **Note:** This library is in active development. The API may change before v1.0.0. @@ -43,8 +44,10 @@ run(csv.row, '"a,b",c'); // ['a,b', 'c'] - [Entry points](#entry-points) - [Which function do I need?](#which-function-do-i-need) - [Core concepts](#core-concepts) +- [Error reporting](#error-reporting) - [More examples](#more-examples) - [API index](#api-index) +- [Migrating from 0.2.x](#migrating-from-02x) ## Entry points @@ -133,6 +136,10 @@ Most primitives come in a singular and a plural form: `letter` matches one, `let | refer to a parser defined further down | `lazy` | | cache results and avoid exponential blowup | `memoize` | | attach a readable error message | `label` | +| parse without throwing, with line and column | `parse` | +| turn an offset into a line and column | `locate` | +| render a failure with a caret under it | `format`, `error` | +| carry a failure trace through a combinator | `merge` | | parse infix operators with precedence | `chainLeft`, `chainLeft1`, `chainRight`, `chainRight1` | | parse prefix / postfix operators | `prefix`, `postfix` | | handle both outcomes of a `Result` | `match` | @@ -141,40 +148,45 @@ Most primitives come in a singular and a plural form: `letter` matches one, `let ### 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. +A `Parser` reads `input` from an offset and returns a `Result`. That is the whole abstraction — everything else in this library either produces one or wraps one. The offset defaults to `0`, so a parser can still be called with just an input. ```typescript -type Parser = (input: string) => Result; +type Parser = (input: string, index?: number) => Result; ``` +Parsers never slice the input. They pass the same string down and move an integer instead, which is what lets an error know where in the original source it happened. + ### The `Result` type ```typescript -type Success = { ok: true; value: T; remaining: string }; -type Failure = { ok: false; error?: string }; +type Success = { ok: true; value: T; index: number; furthest: number; expected: readonly string[] }; +type Failure = { ok: false; index: number; furthest: number; expected: readonly string[] }; type Result = Success | Failure; ``` ```typescript -{ ok: true, value: 'hello', remaining: ' world' } - │ │ │ - │ │ └── what is left to parse +{ ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } + │ │ │ │ │ + │ │ │ │ └── what was wanted there + │ │ │ └── furthest offset any branch reached + │ │ └── how far this parser got │ └── 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`. +`index` is the important part: it is how input gets consumed and how parsers chain. A failure reports the same `index`, plus the set of things that would have matched there: ```typescript -{ ok: false } // generic failure -{ ok: false, error: 'expected a' } // failure with a message +{ ok: false, index: 6, furthest: 6, expected: ['digit'] } ``` +`furthest` and `expected` are bookkeeping for error messages. A parser that backtracks still remembers the furthest point it reached, so `run` can report *that* instead of the place the parse happened to stop. You only need them if you write a combinator by hand — `merge` threads them for you. + ### 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. +Combinators hand every alternative the same offset they started with, so a branch that fails can never leave the cursor moved. `choice(a, b)` always offers `b` the same starting offset, however far `a` got. -If you are coming from Parsec, this is why there is no `try`/`attempt` here — backtracking is unconditional, so there is nothing to opt into. +If you are coming from Parsec, this is why there is no `try`/`attempt` here — backtracking is unconditional, so there is nothing to opt into. What a failed branch *does* leave behind is its expectation, recorded at the offset it reached, so a later error can still mention it. ### Writing one by hand @@ -183,12 +195,12 @@ Most of the time you compose existing pieces, but nothing stops you from droppin ```typescript import { create, success, failure } from 'unitas'; -const parser = create((input) => { - if (input.startsWith('hello')) { - return success('hello', input.slice(5)); +const parser = create((input, index = 0) => { + if (input.startsWith('hello', index)) { + return success('hello', index + 5); } - return failure('expected "hello"'); + return failure(index, '"hello"'); }); ``` @@ -222,6 +234,60 @@ run(g.expr, '1+2+3'); // 6 run(g.expr, '(1+2)'); // 3 ``` +## Error reporting + +When a parse fails, `run` throws a `ParseError` that says where and what was expected: + +```typescript +import { run } from 'unitas'; +import { separatedBy, sequence } from 'unitas/combinators'; +import { char } from 'unitas/terminals'; +import { digits, letters } from 'unitas/primitives'; + +const pair = sequence(letters, char('='), digits); +const config = separatedBy(pair, char('\n')); + +run(config, 'host=1\nport=8080\ndebug=yes'); +// ParseError: 3:7 expected digit, found 'y' +// 3 | debug=yes +// | ^ +``` + +The position is the *furthest* offset any branch reached, not wherever the parse happened to stop. That distinction is what makes the message useful: `separatedBy` succeeded with two pairs and left the rest unconsumed, but the error still points at the `y` that actually broke it. + +Alternatives are reported together, since `choice` unions the expectations of every branch it tried at the same offset: + +```typescript +run(sequence(choice(string('let'), string('const')), char(' ')), 'lot x'); +// ParseError: 1:1 expected 'const' or 'let', found 'l' +// 1 | lot x +// | ^ +``` + +Use `parse` when you would rather branch on the failure than catch it: + +```typescript +import { parse } from 'unitas'; + +const result = parse(config, 'host=1\nport=oops'); + +if (!result.ok) { + result.line; // 2 + result.column; // 6 + result.expected; // ['digit'] + result.message; // the formatted excerpt above +} +``` + +`label` replaces the expectations of a whole parser with one description, so errors can talk about your grammar instead of its characters: + +```typescript +import { label } from 'unitas'; + +run(label(pair, 'a key=value pair'), '!!!'); +// ParseError: 1:1 expected a key=value pair, found '!' +``` + ## More examples **JSON value** — a tagged union of scalars @@ -305,6 +371,61 @@ One line per export. Every name links to its full description and runnable examp <$index> +## Migrating from 0.2.x + +`0.3.0` changes how a parser reports where it got to. Parsers now take an offset into the input instead of receiving a sliced suffix, which is what makes line/column error reporting possible. + +**If you only compose the built-in parsers, nothing changes.** `run`, `grammar`, `map`, `choice` and everything built on them behave the same, and the offset argument defaults to `0`, so `myParser('input')` still works. + +Two things do change for everyone: + +```typescript +// reading a result +result.remaining; // 0.2.x +input.slice(result.index); // 0.3.0 — or just use result.index + +// a failed run +run(p, 'bad'); // 0.2.x: Error: Parsing failed: Unexpected error +run(p, 'bad'); // 0.3.0: ParseError, with .line, .column and .expected +``` + +If you write parsers by hand, the shape changed: + +```typescript +// 0.2.x +create((input) => { + if (input.startsWith('hello')) { + return success('hello', input.slice(5)); + } + + return failure('expected "hello"'); +}); + +// 0.3.0 +create((input, index = 0) => { + if (input.startsWith('hello', index)) { + return success('hello', index + 5); + } + + return failure(index, '"hello"'); +}); +``` + +The rest, in full: + +| 0.2.x | 0.3.0 | +| --------------------------------- | ----------------------------------------------------------- | +| `Parser = (input) => Result` | `Parser = (input, index?) => Result` | +| `success(value, remaining)` | `success(value, index)` | +| `failure()` / `failure(message)` | `failure(index, ...expected)` | +| `{ ok: false, error?: string }` | `{ ok: false, index, furthest, expected }` | +| `match`'s `failure: (error) => …` | `failure: (index, expected) => …` | +| `position` → remaining length | `position` → offset from the start | +| `label(p, 'x')` → `'expected x'` | `label(p, 'x')` → `expected: ['x']`, only when nothing consumed | +| `regex(/^\w+/)` | same, but sticky — a leading `^` is redundant and stripped | + +New in this release: `parse` (non-throwing), `ParseError`, `locate`, `format` and `merge`. `memoize` is now keyed by offset rather than by the remaining input, so its cache entries are cheap and are dropped when a different input is parsed. Left-recursive `grammar` rules now throw a named error instead of overflowing the stack. + ## Contributing ```bash diff --git a/doc/api/combinators.md b/doc/api/combinators.md index 0188ffc..2c9979e 100644 --- a/doc/api/combinators.md +++ b/doc/api/combinators.md @@ -21,7 +21,7 @@ import {} from /* … */ 'unitas/combinators'; Chain parsers where the second parser depends on the first result. ```typescript -bind(digits, (n) => take(n))('3abc'); // { ok: true, value: 'abc', remaining: '' } +bind(digits, (n) => take(n))('3abc'); // { ok: true, value: 'abc', index: 4, furthest: -1, expected: [] } ``` ### `braced` @@ -29,7 +29,7 @@ bind(digits, (n) => take(n))('3abc'); // { ok: true, value: 'abc', remaining: '' Parse content surrounded by braces. ```typescript -braced(string('hi'))('{hi}'); // { ok: true, value: 'hi', remaining: '' } +braced(string('hi'))('{hi}'); // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } ``` ### `bracketed` @@ -37,7 +37,7 @@ braced(string('hi'))('{hi}'); // { ok: true, value: 'hi', remaining: '' } Parse content surrounded by brackets. ```typescript -bracketed(string('hi'))('[hi]'); // { ok: true, value: 'hi', remaining: '' } +bracketed(string('hi'))('[hi]'); // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } ``` ### `chainLeft` @@ -45,8 +45,7 @@ bracketed(string('hi'))('[hi]'); // { ok: true, value: 'hi', remaining: '' } 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: '' } +chainLeft(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } ``` ### `chainLeft1` @@ -54,17 +53,16 @@ chainLeft(digits, operation)('10-3+2'); // { ok: true, value: 9, remaining: '' } 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: '' } +chainLeft1(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } +chainLeft1(digits, operation)('8/2*3'); // { ok: true, value: 12, index: 5, furthest: 5, expected: ['operator'] } ``` ### `chainRight` -Chain right-associative operations (right-to-left grouping). +Chain right-associative operations. ```typescript -chainRight(digits, operation)('2-1-1'); // { ok: true, value: 2, remaining: '' } -chainRight(digits, operation)('4/2/2'); // { ok: true, value: 4, remaining: '' } +chainRight(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } ``` ### `chainRight1` @@ -72,16 +70,15 @@ chainRight(digits, operation)('4/2/2'); // { ok: true, value: 4, remaining: '' } 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: '' } +chainRight1(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } ``` ### `choice` -Try each parser in order, return first success. +Try each parser in order, return first success. Expectations from every alternative tried at the same offset are unioned, so a failing alternation reports all of them rather than only the last. ```typescript -choice(string('hello'), string('world'))('hello'); // { ok: true, value: 'hello', remaining: '' } +choice(string('hello'), string('world'))('hello'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } ``` ### `concat` @@ -89,8 +86,8 @@ choice(string('hello'), string('world'))('hello'); // { ok: true, value: 'hello' 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' } +concat(many(letter))('abc123'); // { ok: true, value: 'abc', index: 3, furthest: 3, expected: ['letter'] } +concat(many(letter), '-')('abc123'); // { ok: true, value: 'a-b-c', index: 3, furthest: 3, expected: ['letter'] } ``` ### `consume` @@ -98,7 +95,7 @@ concat(many(letter), '-')('abc123'); // { ok: true, value: 'a-b-c', remaining: ' Consume input but discard the result (return null). ```typescript -consume(string('hello'))('hello world'); // { ok: true, value: null, remaining: ' world' } +consume(string('hello'))('hello world'); // { ok: true, value: null, index: 5, furthest: -1, expected: [] } ``` ### `endBy` @@ -106,7 +103,7 @@ consume(string('hello'))('hello world'); // { ok: true, value: null, remaining: Zero or more items separated and ending with terminator. ```typescript -endBy(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['item', 'item', 'item'], remaining: '' } +endBy(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['item', 'item', 'item'], index: 15, furthest: 15, expected: ["'item'"] } ``` ### `endBy1` @@ -114,7 +111,7 @@ endBy(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['ite One or more items separated and ending with terminator. ```typescript -endBy1(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['item', 'item', 'item'], remaining: '' } +endBy1(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['item', 'item', 'item'], index: 15, furthest: 15, expected: ["'item'"] } ``` ### `exactly` @@ -122,7 +119,7 @@ endBy1(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['it Parse exactly n occurrences. ```typescript -exactly(char('a'), 3)('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +exactly(char('a'), 3)('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: -1, expected: [] } ``` ### `first` @@ -130,7 +127,7 @@ exactly(char('a'), 3)('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: Extract the first element from a parser result array. ```typescript -first(sequence(char('a'), digit))('a1bc'); // { ok: true, value: 'a', remaining: 'bc' } +first(sequence(char('a'), digit))('a1bc'); // { ok: true, value: 'a', index: 2, furthest: -1, expected: [] } ``` ### `flag` @@ -138,40 +135,40 @@ first(sequence(char('a'), digit))('a1bc'); // { ok: true, value: 'a', remaining: 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' } +flag(string('*'))('*abc'); // { ok: true, value: true, index: 1, furthest: -1, expected: [] } +flag(string('*'))('abc'); // { ok: true, value: false, index: 0, furthest: 0, expected: ["'*'"] } ``` ### `fold` -Parse zero or more and fold into a single value. +Fold zero or more occurrences into a single value. ```typescript -fold(digit, [], (acc, d) => [...acc, d])('123'); // { ok: true, value: [1, 2, 3], remaining: '' } +fold(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } ``` ### `fold1` -Parse one or more and fold into a single value. +Fold one or more occurrences into a single value. ```typescript -fold1(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, remaining: '' } +fold1(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } ``` ### `foldRight` -Parse zero or more and fold right-to-left. +Fold zero or more occurrences from the right into a single value. ```typescript -foldRight(digit, [], (acc, d) => [...acc, d])('123'); // { ok: true, value: [3, 2, 1], remaining: '' } +foldRight(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } ``` ### `foldRight1` -Parse one or more and fold right-to-left. +Fold one or more occurrences from the right into a single value. ```typescript -foldRight1(digit, [], (acc, d) => [...acc, d])('123'); // { ok: true, value: [3, 2, 1], remaining: '' } +foldRight1(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } ``` ### `fuse` @@ -179,8 +176,8 @@ foldRight1(digit, [], (acc, d) => [...acc, d])('123'); // { ok: true, value: [3, 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: '' } +fuse(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } +fuse(string('hello'), char(' '), string('world'))('hello world'); // { ok: true, value: 'hello world', index: 11, furthest: -1, expected: [] } ``` ### `guard` @@ -188,8 +185,8 @@ fuse(string('hello'), char(' '), string('world'))('hello world'); // { ok: true, 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 } +guard(true, string('hello'))('hello'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } +guard(false, string('hello'))('hello'); // { ok: false, index: 0, furthest: 0, expected: [] } ``` ### `inner` @@ -197,15 +194,15 @@ guard(false, string('hello'))('hello'); // { ok: false } Extract inner value from surrounded content (like inner of braced). ```typescript -inner(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: 'hi', remaining: '' } +inner(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } ``` ### `interleaved` -Parse items with interleaved separators. +Parse items separated by separators, keeping both in the result. ```typescript -interleaved(char('a'), char(','))('a,a,a'); // { ok: true, value: ['a', ',', 'a', ',', 'a'], remaining: '' } +interleaved(digits, char('+'))('1+2'); // { ok: true, value: [1, '+', 2], index: 3, furthest: 3, expected: ["'+'"] } ``` ### `last` @@ -213,7 +210,7 @@ interleaved(char('a'), char(','))('a,a,a'); // { ok: true, value: ['a', ',', 'a' Extract the last element from a parser result array. ```typescript -last(sequence(char('a'), char('b')))('ab'); // { ok: true, value: 'b', remaining: '' } +last(sequence(char('a'), char('b')))('ab'); // { ok: true, value: 'b', index: 2, furthest: -1, expected: [] } ``` ### `left` @@ -221,7 +218,7 @@ last(sequence(char('a'), char('b')))('ab'); // { ok: true, value: 'b', remaining Keep only the left result from a sequence. ```typescript -left(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'hello', remaining: '' } +left(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'hello', index: 10, furthest: -1, expected: [] } ``` ### `lexeme` @@ -229,23 +226,23 @@ left(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'hel Parser that consumes trailing whitespace. ```typescript -lexeme(string('hello'))('hello world'); // { ok: true, value: 'hello', remaining: 'world' } +lexeme(string('hello'))('hello world'); // { ok: true, value: 'hello', index: 8, furthest: -1, expected: [] } ``` ### `many` -Zero or more occurrences (never fails). +Zero or more occurrences (never fails). The failure that stopped the loop is kept as a trace, so a later error can still report what the repetition was expecting next. ```typescript -many(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +many(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 3, expected: ["'a'"] } ``` ### `many1` -One or more occurrences (fails if no matches). +One or more occurrences. ```typescript -many1(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +many1(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 3, expected: ["'a'"] } ``` ### `manyAtLeast` @@ -253,15 +250,15 @@ many1(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } Parse at least n occurrences. ```typescript -manyAtLeast(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +manyAtLeast(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 3, expected: ["'a'"] } ``` ### `manyAtMost` -Parse at most n occurrences. +Parse at most n occurrences (never fails). ```typescript -manyAtMost(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a'], remaining: 'a' } +manyAtMost(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a'], index: 2, furthest: -1, expected: [] } ``` ### `manyBetween` @@ -269,7 +266,7 @@ manyAtMost(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a'], remaining: 'a Parse between min and max occurrences. ```typescript -manyBetween(char('a'), 2, 3)('aaa'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +manyBetween(char('a'), 1, 2)('aaa'); // { ok: true, value: ['a', 'a'], index: 2, furthest: -1, expected: [] } ``` ### `manyTill` @@ -277,15 +274,15 @@ manyBetween(char('a'), 2, 3)('aaa'); // { ok: true, value: ['a', 'a', 'a'], rema Parse zero or more until terminator matches. ```typescript -manyTill(char('a'), char('b'))('aaab'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +manyTill(char('a'), char('b'))('aaab'); // { ok: true, value: ['a', 'a', 'a'], index: 4, furthest: 2, expected: ["'b'"] } ``` ### `map` -Transform the parsed value. +Transform a parsed value through one or more functions. ```typescript -map(string('hello'), (v) => v.toUpperCase())('hello'); // { ok: true, value: 'HELLO', remaining: '' } +map(digits, (n) => n * 2)('21'); // { ok: true, value: 42, index: 2, furthest: -1, expected: [] } ``` ### `node` @@ -293,16 +290,16 @@ map(string('hello'), (v) => v.toUpperCase())('hello'); // { ok: true, value: 'HE 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: '' } +node('binop', { left: digits, op: char('+'), right: digits })('1+2'); // { ok: true, value: { type: 'binop', left: 1, op: '+', right: 2 }, index: 3, furthest: -1, expected: [] } +node('number', { value: digits })('123'); // { ok: true, value: { type: 'number', value: 123 }, index: 3, furthest: -1, expected: [] } ``` ### `not` -Succeed if parser fails (without consuming input). +Succeed if parser fails (without consuming input). The inner failure is deliberately not traced: it is the expected outcome, so reporting it would produce a misleading expectation. ```typescript -not(string('hello'))('world'); // { ok: true, value: null, remaining: 'world' } +not(string('hello'))('world'); // { ok: true, value: null, index: 0, furthest: -1, expected: [] } ``` ### `nth` @@ -310,7 +307,7 @@ not(string('hello'))('world'); // { ok: true, value: null, remaining: 'world' } 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: '' } +nth(sequence(char('a'), char('b'), char('c')), 1)('abc'); // { ok: true, value: 'b', index: 3, furthest: -1, expected: [] } ``` ### `optional` @@ -318,27 +315,24 @@ nth(sequence(char('a'), char('b'), char('c')), 1)('abc'); // { ok: true, value: 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' } +optional(string('hello'))('hello'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } +optional(string('hello'))('world'); // { ok: true, value: null, index: 0, furthest: 0, expected: ["'hello'"] } ``` ### `optionalConsume` -Optionally consume input (always succeeds, returns void). +Consume input if the parser matches, discarding the result. ```typescript -optionalConsume(string('hello'))('hello world'); // { ok: true, value: undefined, remaining: ' world' } -optionalConsume(string('hello'))('world'); // { ok: true, value: undefined, remaining: 'world' } +optionalConsume(string('hi'))('hi there'); // { ok: true, index: 2, furthest: -1, expected: [] } ``` ### `optionalSeparatedBy` -Zero or more items separated by a separator, with optional null values. +Parse items separated by a separator, allowing empty slots. ```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: '' } +optionalSeparatedBy(digits, char(','))('1,2'); // { ok: true, value: [1, 2], index: 3, furthest: 3, expected: ["','"] } ``` ### `outer` @@ -346,15 +340,15 @@ optionalSeparatedBy(digits, char(','))('1,'); // { ok: true, value: [1], remaini Extract outer values from a sequence of 3 parsers (skip middle). ```typescript -outer(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: ['(', ')'], remaining: '' } +outer(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: ['(', ')'], index: 4, furthest: -1, expected: [] } ``` ### `padded` -Parse content with optional whitespace on both sides. +Parse content surrounded by optional whitespace. ```typescript -padded(string('hi'))(' hi '); // { ok: true, value: 'hi', remaining: '' } +padded(string('hi'))(' hi '); // { ok: true, value: 'hi', index: 6, furthest: 6, expected: ['whitespace'] } ``` ### `parenthesized` @@ -362,46 +356,45 @@ padded(string('hi'))(' hi '); // { ok: true, value: 'hi', remaining: '' } Parse content surrounded by parentheses. ```typescript -parenthesized(string('hi'))('(hi)'); // { ok: true, value: 'hi', remaining: '' } +parenthesized(string('hi'))('(hi)'); // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } ``` ### `peek` -Parse without consuming input. +Look ahead without consuming input. ```typescript -peek(string('hello'))('hello world'); // { ok: true, value: 'hello', remaining: 'hello world' } +peek(string('hello'))('hello world'); // { ok: true, value: 'hello', index: 0, furthest: -1, expected: [] } ``` ### `postfix` -Parse postfix operators (chains atom with operators that return functions). +Apply zero or more postfix operators to an atom. ```typescript postfix( - char('a'), - map(char('!'), () => (x) => x), -)('a!'); // { ok: true, value: 'a', remaining: '' } + digits, + map(char('!'), () => (n) => n * 2), +)('3!'); // { ok: true, value: 6, index: 2, furthest: 2, expected: ["'!'"] } ``` ### `prefix` -Parse prefix operators (like - in -5). +Apply zero or more prefix operators to an atom. ```typescript prefix( - map(char('-'), () => (x) => -x), - digit, -)('-5'); // { ok: true, value: -5, remaining: '' } + map(char('-'), () => (n) => -n), + digits, +)('-3'); // { ok: true, value: -3, index: 2, furthest: 1, expected: ["'-'"] } ``` ### `pure` -Always return a value without consuming input. +Always succeed with a value without consuming input. ```typescript -pure(42)('abc'); // { ok: true, value: 42, remaining: 'abc' } -pure('ok')(''); // { ok: true, value: 'ok', remaining: '' } +pure(42)('abc'); // { ok: true, value: 42, index: 0, furthest: -1, expected: [] } ``` ### `quoted` @@ -409,7 +402,7 @@ pure('ok')(''); // { ok: true, value: 'ok', remaining: '' } Parse content surrounded by single or double quotes. ```typescript -quoted(string('hello'))('"hello"'); // { ok: true, value: 'hello', remaining: '' } +quoted(string('hello'))('"hello"'); // { ok: true, value: 'hello', index: 7, furthest: -1, expected: [] } ``` ### `recover` @@ -417,7 +410,7 @@ quoted(string('hello'))('"hello"'); // { ok: true, value: 'hello', remaining: '' Use fallback value when parser fails. ```typescript -recover(string('hello'), 'default')('world'); // { ok: true, value: 'default', remaining: 'world' } +recover(string('hello'), 'default')('world'); // { ok: true, value: 'default', index: 0, furthest: 0, expected: ["'hello'"] } ``` ### `right` @@ -425,47 +418,47 @@ recover(string('hello'), 'default')('world'); // { ok: true, value: 'default', r Keep only the right result from a sequence. ```typescript -right(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'world', remaining: '' } +right(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'world', index: 10, furthest: -1, expected: [] } ``` ### `separatedBy` -Zero or more items separated by a separator. +Parse zero or more items separated by a separator. A trailing separator is not consumed: if the separator matches but the item after it does not, the list ends before the separator. ```typescript -separatedBy(char('a'), char(','))('a,a,a'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +separatedBy(digits, char(','))('1,2,3'); // { ok: true, value: [1, 2, 3], index: 5, furthest: 5, expected: ["','"] } ``` ### `separatedBy1` -One or more items separated by a separator. +Parse one or more items separated by a separator. ```typescript -separatedBy1(char('a'), char(','))('a,a,a'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +separatedBy1(digits, char(','))('1,2,3'); // { ok: true, value: [1, 2, 3], index: 5, furthest: 5, expected: ["','"] } ``` ### `separatedEndBy` -Zero or more items separated by and ending with a terminator. +Parse zero or more items separated by a separator, allowing a trailing one. ```typescript -separatedEndBy(char('a'), char(';'))('a;a;a;'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +separatedEndBy(digits, char(','))('1,2,3,'); // { ok: true, value: [1, 2, 3], index: 6, furthest: 6, expected: ['digit'] } ``` ### `separatedEndBy1` -One or more items separated by and ending with a terminator. +Parse one or more items separated by a separator, allowing a trailing one. ```typescript -separatedEndBy1(char('a'), char(';'))('a;a;a;'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +separatedEndBy1(digits, char(','))('1,2,3,'); // { ok: true, value: [1, 2, 3], index: 6, furthest: 6, expected: ['digit'] } ``` ### `separatedUntil` -Parse items separated by separator until terminator matches. +Parse items separated by a separator, up to a terminator. ```typescript -separatedUntil(char('a'), char(','), char(';'))('a,a,a;'); // { ok: true, value: ['a', 'a', 'a'], remaining: '' } +separatedUntil(digits, char(','), char(';'))('1,2,3;'); // { ok: true, value: [1, 2, 3], index: 6, furthest: 5, expected: ["','"] } ``` ### `sequence` @@ -473,7 +466,7 @@ separatedUntil(char('a'), char(','), char(';'))('a,a,a;'); // { ok: true, value: 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: '' } +sequence(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: ['a', 'b', 'c'], index: 3, furthest: -1, expected: [] } ``` ### `skip` @@ -481,7 +474,7 @@ sequence(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: ['a', 'b Skip a parser n times. ```typescript -skip(char('a'), 2)('aabc'); // { ok: true, value: null, remaining: 'bc' } +skip(char('a'), 2)('aabc'); // { ok: true, value: null, index: 2, furthest: -1, expected: [] } ``` ### `skipMany` @@ -489,7 +482,7 @@ skip(char('a'), 2)('aabc'); // { ok: true, value: null, remaining: 'bc' } Skip zero or more occurrences (never fails, returns null). ```typescript -skipMany(char('a'))('aaabc'); // { ok: true, value: null, remaining: 'bc' } +skipMany(char('a'))('aaabc'); // { ok: true, value: null, index: 3, furthest: 3, expected: ["'a'"] } ``` ### `skipMany1` @@ -497,7 +490,7 @@ skipMany(char('a'))('aaabc'); // { ok: true, value: null, remaining: 'bc' } Skip one or more occurrences (fails if no matches). ```typescript -skipMany1(char('a'))('aaabc'); // { ok: true, value: null, remaining: 'bc' } +skipMany1(char('a'))('aaabc'); // { ok: true, value: null, index: 3, furthest: 3, expected: ["'a'"] } ``` ### `surrounded` @@ -505,8 +498,8 @@ skipMany1(char('a'))('aaabc'); // { ok: true, value: null, remaining: 'bc' } 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: '' } +surrounded(char('['), string('hi'), char(']'))('[hi]'); // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } +surrounded(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: 'b', index: 3, furthest: -1, expected: [] } ``` ### `unless` @@ -514,17 +507,16 @@ surrounded(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: 'b', r 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' } +unless(false, string('hello'))('hello'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } +unless(true, string('hello'))('hello'); // { ok: true, value: null, index: 0, furthest: -1, expected: [] } ``` ### `until` -Parse until terminator matches (fails if terminator never matches). +Parse zero or more until terminator matches, leaving the terminator unconsumed. ```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' } +until(char('a'), char('b'))('aaab'); // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 2, expected: ["'b'"] } ``` ### `validate` @@ -532,8 +524,8 @@ until(char('a'), char('b'))('aaba'); // { ok: true, value: ['a', 'a'], remaining 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 } +validate(digit, (n) => n > 5)('7'); // { ok: true, value: 7, index: 1, furthest: -1, expected: [] } +validate(digit, (n) => n > 5)('3'); // { ok: false, index: 0, furthest: 0, expected: ['valid value'] } ``` ### `value` @@ -541,8 +533,8 @@ validate(digit, (n) => n > 5)('3'); // { ok: false } 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: '' } +value(string('true'), true)('true'); // { ok: true, value: true, index: 4, furthest: -1, expected: [] } +value(string('null'), null)('null'); // { ok: true, value: null, index: 4, furthest: -1, expected: [] } ``` ### `when` @@ -550,6 +542,6 @@ value(string('null'), null)('null'); // { ok: true, value: null, remaining: '' } 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' } +when(flag(char('*')), pure('many'), pure('one'))('*rest'); // { ok: true, value: 'many', index: 1, furthest: -1, expected: [] } +when(flag(char('*')), pure('many'), pure('one'))('abc'); // { ok: true, value: 'one', index: 0, furthest: 0, expected: ["'*'"] } ``` diff --git a/doc/api/core.md b/doc/api/core.md index b23d036..f4cee20 100644 --- a/doc/api/core.md +++ b/doc/api/core.md @@ -12,21 +12,37 @@ import {} from /* … */ 'unitas'; ## Index -9 exports: [`failure`](#failure) · [`grammar`](#grammar) · [`label`](#label) · [`lazy`](#lazy) · [`match`](#match) · [`memoize`](#memoize) · [`parser`](#parser) · [`run`](#run) · [`success`](#success) +14 exports: [`error`](#error) · [`failure`](#failure) · [`format`](#format) · [`grammar`](#grammar) · [`label`](#label) · [`lazy`](#lazy) · [`locate`](#locate) · [`match`](#match) · [`memoize`](#memoize) · [`merge`](#merge) · [`parse`](#parse) · [`parser`](#parser) · [`run`](#run) · [`success`](#success) ## Reference +### `error` + +Error thrown by {@link run} when a parse fails, carrying the offset, the 1-based line/column and the set of expectations at that offset. + +```typescript +new ParseError('a=1,b=x', 6, ['digit']).line; // 1 +``` + ### `failure` -Creates a failed result with an optional error message. +Creates a failed result at an offset, describing what was expected there. Expectations are phrased as nouns ('digit', "'{'") and are unioned by {@link choice}, so a failing alternation reports every branch it tried. ```typescript -failure('unexpected input'); // { ok: false, error: 'unexpected input' } +failure(3, 'digit'); // { ok: false, index: 3, furthest: 3, expected: ['digit'] } +``` + +### `format` + +Renders a parse failure as a source excerpt with a caret under the offset. + +```typescript +format('a=1,b=x', 6, ['digit']); // "1:7 expected digit, found 'x'\n 1 | a=1,b=x\n | ^" ``` ### `grammar` -Creates a recursive grammar where rules can reference each other. +Creates a recursive grammar where rules can reference each other. Rules are resolved lazily through a shared record, so they may refer to one another by name without forward declarations. Left-recursive rules are reported by name rather than overflowing the stack. ```typescript type Math = { @@ -54,10 +70,10 @@ run(g.expr, '(1+2)'); // 3 ### `label` -Labels a parser with a custom error message on failure. +Replaces the expectations of a parser with a single description. Only applies when the parser failed without consuming input: once a branch has committed, its own deeper error is more useful than the label. ```typescript -label(char('x'), 'letter x')(''); // { ok: false, error: 'expected letter x' } +label(char('x'), 'letter x')(''); // { ok: false, index: 0, furthest: 0, expected: ['letter x'] } ``` ### `lazy` @@ -65,7 +81,15 @@ label(char('x'), 'letter x')(''); // { ok: false, error: 'expected letter x' } Defers parser creation, useful for recursive grammars. ```typescript -lazy(() => char('a'))('abc'); // { ok: true, value: 'a', remaining: 'bc' } +lazy(() => char('a'))('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } +``` + +### `locate` + +Converts a character offset into a 1-based line and column. + +```typescript +locate('a=1\nb=x', 5); // { index: 5, line: 2, column: 2 } ``` ### `match` @@ -73,29 +97,45 @@ lazy(() => char('a'))('abc'); // { ok: true, value: 'a', remaining: 'bc' } Pattern matching on a Result to handle success and failure cases. ```typescript -match(success('hello', ''), { success: (v) => v, failure: () => 'failed' }); // 'hello' +match(success('hello', 5), { 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. +Memoizes a parser so each offset is parsed at most once (packrat caching). Keyed by offset rather than by the remaining input, so entries are cheap and the cache is dropped as soon as a different input is parsed. ```typescript const memoDigits = memoize(digits); -memoDigits('123'); // { ok: true, value: 123, remaining: '' } +memoDigits('123'); // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } +``` + +### `merge` + +Carries the furthest-failure trace of an earlier result into a later one. Every combinator that runs more than one parser threads its intermediate results through `merge`, so an expectation recorded deep inside a branch that was later backtracked over still surfaces in the final error. Without it, `a=1,b=x` would only report "unconsumed input" instead of "expected digit". Returns `result` untouched whenever the trace adds nothing, which is the common case and keeps the hot path allocation-free. + +```typescript +merge(failure(7, 'digit'), success('ok', 3)); // { ok: true, value: 'ok', index: 3, furthest: 7, expected: ['digit'] } +``` + +### `parse` + +Runs a parser over the whole input without throwing. The non-throwing counterpart of {@link run}: on failure it reports the offset, line, column, expectations and a formatted message. + +```typescript +parse(digits, '12x').message; // "1:3 expected end of input, found 'x'\n 1 | 12x\n | ^" ``` ### `parser` -Creates a parser from a parser function. +Creates a parser from a parser function. A parser reads `input` starting at `index` and never slices it — results report the offset they reached, so positions stay meaningful all the way up to the top-level error message. ```typescript -create((input) => success('parsed', input.slice(6)))('hello world'); // { ok: true, value: 'parsed', remaining: 'world' } +create((input, index = 0) => success('parsed', index + 6))('hello world'); // { ok: true, value: 'parsed', index: 6, furthest: -1, expected: [] } ``` ### `run` -Runs a parser and returns the value, throws on failure or unconsumed input. +Runs a parser over the whole input and returns the value. Throws a {@link ParseError} — with line, column and the expectations at that point — if the parse fails or leaves input unconsumed. ```typescript run(string('hello'), 'hello'); // 'hello' @@ -103,8 +143,8 @@ run(string('hello'), 'hello'); // 'hello' ### `success` -Creates a successful result with a value and remaining input. +Creates a successful result with a value and the offset reached in the input. The `furthest`/`expected` pair carries the furthest failure seen while producing this success, so a later error can still report it. Use {@link merge} to propagate it — a fresh success starts with no trace. ```typescript -success('hello', ' world'); // { ok: true, value: 'hello', remaining: ' world' } +success('hello', 5); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } ``` diff --git a/doc/api/primitives.md b/doc/api/primitives.md index 39d945f..6ac04da 100644 --- a/doc/api/primitives.md +++ b/doc/api/primitives.md @@ -21,16 +21,16 @@ import {} from /* … */ 'unitas/primitives'; Parse a single alphanumeric character. ```typescript -alphaNum('a1'); // { ok: true, value: 'a', remaining: '1' } -alphaNum('1a'); // { ok: true, value: '1', remaining: 'a' } +alphaNum('a1'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } +alphaNum('1a'); // { ok: true, value: '1', index: 1, furthest: -1, expected: [] } ``` ### `alphaNums` -Parse one or more alphanumeric characters. +Parse one or more alphanumeric characters as a string. ```typescript -alphaNums('abc123'); // { ok: true, value: 'abc123', remaining: '' } +alphaNums('placeholder'); // { ok: true, value: 'placeholder', index: 11, furthest: 11, expected: ['alphanumeric character'] } ``` ### `anyChar` @@ -38,7 +38,7 @@ alphaNums('abc123'); // { ok: true, value: 'abc123', remaining: '' } Parse any single character. ```typescript -anyChar('abc'); // { ok: true, value: 'a', remaining: 'bc' } +anyChar('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } ``` ### `bool` @@ -46,9 +46,9 @@ anyChar('abc'); // { ok: true, value: 'a', remaining: 'bc' } 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' } +bool('true'); // { ok: true, value: true, index: 4, furthest: -1, expected: [] } +bool('false'); // { ok: true, value: false, index: 5, furthest: 0, expected: ["'true'"] } +bool('trueABC'); // { ok: true, value: true, index: 4, furthest: -1, expected: [] } ``` ### `crlf` @@ -56,7 +56,7 @@ bool('trueABC'); // { ok: true, value: true, remaining: 'ABC' } Parse CRLF line ending. ```typescript -crlf('\r\nabc'); // { ok: true, value: '\r\n', remaining: 'abc' } +crlf('\r\nabc'); // { ok: true, value: '\r\n', index: 2, furthest: -1, expected: [] } ``` ### `digit` @@ -64,23 +64,23 @@ crlf('\r\nabc'); // { ok: true, value: '\r\n', remaining: 'abc' } Parse a single digit and return as number. ```typescript -digit('5abc'); // { ok: true, value: 5, remaining: 'abc' } +digit('5abc'); // { ok: true, value: 5, index: 1, furthest: -1, expected: [] } ``` ### `digits` -Parse one or more digits and return as number. +Parse one or more digits and return as a number. ```typescript -digits('123abc'); // { ok: true, value: 123, remaining: 'abc' } +digits('123'); // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } ``` ### `eof` -Parse end of file (succeeds only on empty input). +Match the end of the input. ```typescript -eof(''); // { ok: true, value: null, remaining: '' } +eof(''); // { ok: true, value: null, index: 0, furthest: -1, expected: [] } ``` ### `eol` @@ -88,7 +88,7 @@ eof(''); // { ok: true, value: null, remaining: '' } Parse end of line (\\n, \\r\\n, or EOF). ```typescript -eol('\nabc'); // { ok: true, value: '\n', remaining: 'abc' } +eol('\nabc'); // { ok: true, value: '\n', index: 1, furthest: -1, expected: [] } ``` ### `float` @@ -96,9 +96,9 @@ eol('\nabc'); // { ok: true, value: '\n', remaining: 'abc' } 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' } +float('1.23'); // { ok: true, value: 1.23, index: 4, furthest: -1, expected: [] } +float('-2.5'); // { ok: true, value: -2.5, index: 4, furthest: -1, expected: [] } +float('1.23abc'); // { ok: true, value: 1.23, index: 4, furthest: -1, expected: [] } ``` ### `hexDigit` @@ -106,15 +106,15 @@ float('1.23abc'); // { ok: true, value: 1.23, remaining: 'abc' } Parse a single hexadecimal digit. ```typescript -hexDigit('fF9'); // { ok: true, value: 'f', remaining: 'F9' } +hexDigit('fF9'); // { ok: true, value: 'f', index: 1, furthest: -1, expected: [] } ``` ### `hexDigits` -Parse one or more hexadecimal digits. +Parse one or more hex digits as a string. ```typescript -hexDigits('deadbeef'); // { ok: true, value: 'deadbeef', remaining: '' } +hexDigits('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['hex digit'] } ``` ### `identifier` @@ -122,7 +122,7 @@ hexDigits('deadbeef'); // { ok: true, value: 'deadbeef', remaining: '' } Parse an identifier — starts with letter or underscore, no leading digit, no hyphen. ```typescript -identifier('variable_name'); // { ok: true, value: 'variable_name', remaining: '' } +identifier('variable_name'); // { ok: true, value: 'variable_name', index: 13, furthest: -1, expected: [] } ``` ### `integer` @@ -130,9 +130,9 @@ identifier('variable_name'); // { ok: true, value: 'variable_name', remaining: ' 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' } +integer('42'); // { ok: true, value: 42, index: 2, furthest: -1, expected: [] } +integer('-7'); // { ok: true, value: -7, index: 2, furthest: -1, expected: [] } +integer('123abc'); // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } ``` ### `letter` @@ -140,15 +140,15 @@ integer('123abc'); // { ok: true, value: 123, remaining: 'abc' } Parse a single letter. ```typescript -letter('abc'); // { ok: true, value: 'a', remaining: 'bc' } +letter('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } ``` ### `letters` -Parse one or more letters. +Parse one or more letters as a string. ```typescript -letters('abc123'); // { ok: true, value: 'abc', remaining: '123' } +letters('placeholder'); // { ok: true, value: 'placeholder', index: 11, furthest: 11, expected: ['letter'] } ``` ### `line` @@ -156,7 +156,7 @@ letters('abc123'); // { ok: true, value: 'abc', remaining: '123' } Parse until end of line. ```typescript -line('hello\nworld'); // { ok: true, value: 'hello', remaining: '\nworld' } +line('hello\nworld'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } ``` ### `literal` @@ -164,8 +164,8 @@ line('hello\nworld'); // { ok: true, value: 'hello', remaining: '\nworld' } Parse a word-like value including hyphens. ```typescript -literal('foo-bar'); // { ok: true, value: 'foo-bar', remaining: '' } -literal('123abc'); // { ok: true, value: '123abc', remaining: '' } +literal('foo-bar'); // { ok: true, value: 'foo-bar', index: 7, furthest: -1, expected: [] } +literal('123abc'); // { ok: true, value: '123abc', index: 6, furthest: -1, expected: [] } ``` ### `lowercase` @@ -173,15 +173,15 @@ literal('123abc'); // { ok: true, value: '123abc', remaining: '' } Parse a single lowercase letter. ```typescript -lowercase('abc'); // { ok: true, value: 'a', remaining: 'bc' } +lowercase('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } ``` ### `lowercases` -Parses one or more lowercase letters. +Parse one or more lowercase letters as a string. ```typescript -lowercases('abcDEF'); // { ok: true, value: 'abc', remaining: 'DEF' } +lowercases('placeholder'); // { ok: true, value: 'placeholder', index: 11, furthest: 11, expected: ['lowercase letter'] } ``` ### `nl` @@ -189,7 +189,7 @@ lowercases('abcDEF'); // { ok: true, value: 'abc', remaining: 'DEF' } Parse a newline character. ```typescript -nl('\ntext'); // { ok: true, value: '\n', remaining: 'text' } +nl('\ntext'); // { ok: true, value: '\n', index: 1, furthest: -1, expected: [] } ``` ### `number` @@ -197,10 +197,10 @@ nl('\ntext'); // { ok: true, value: '\n', remaining: 'text' } 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: '' } +number('42'); // { ok: true, value: 42, index: 2, furthest: 0, expected: ['/-?\\d+\\.\\d+/'] } +number('3.14'); // { ok: true, value: 3.14, index: 4, furthest: -1, expected: [] } +number('-7'); // { ok: true, value: -7, index: 2, furthest: 0, expected: ['/-?\\d+\\.\\d+/'] } +number('-2.5'); // { ok: true, value: -2.5, index: 4, furthest: -1, expected: [] } ``` ### `octDigit` @@ -208,31 +208,32 @@ number('-2.5'); // { ok: true, value: -2.5, remaining: '' } Parse a single octal digit. ```typescript -octDigit('7abc'); // { ok: true, value: '7', remaining: 'abc' } +octDigit('7abc'); // { ok: true, value: '7', index: 1, furthest: -1, expected: [] } ``` ### `octDigits` -Parse one or more octal digits. +Parse one or more octal digits as a string. ```typescript -octDigits('0777abc'); // { ok: true, value: '0777', remaining: 'abc' } +octDigits('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['octal digit'] } ``` ### `position` -Get current position (remaining input length). +Get the current offset into the input, without consuming anything. Counts forward from the start of the input, so it can be paired with {@link locate} to attach line/column information to a parsed node. ```typescript -position('abc'); // { ok: true, value: 3, remaining: 'abc' } +position('abc'); // { ok: true, value: 0, index: 0, furthest: -1, expected: [] } +right(string('ab'), position)('abc'); // { ok: true, value: 2, index: 2, furthest: -1, expected: [] } ``` ### `rest` -Parse the rest of the input. +Consume and return everything left in the input. ```typescript -rest('hello'); // { ok: true, value: 'hello', remaining: '' } +rest('abc'); // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } ``` ### `space` @@ -240,15 +241,15 @@ rest('hello'); // { ok: true, value: 'hello', remaining: '' } Parse a single space character. ```typescript -space(' abc'); // { ok: true, value: ' ', remaining: 'abc' } +space(' abc'); // { ok: true, value: ' ', index: 1, furthest: -1, expected: [] } ``` ### `spaces` -Parse one or more space characters. +Parse one or more spaces as a string. ```typescript -spaces(' abc'); // { ok: true, value: ' ', remaining: 'abc' } +spaces('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['space'] } ``` ### `tab` @@ -256,7 +257,7 @@ spaces(' abc'); // { ok: true, value: ' ', remaining: 'abc' } Parse tab character. ```typescript -tab('\ttext'); // { ok: true, value: '\t', remaining: 'text' } +tab('\ttext'); // { ok: true, value: '\t', index: 1, furthest: -1, expected: [] } ``` ### `uppercase` @@ -264,15 +265,15 @@ tab('\ttext'); // { ok: true, value: '\t', remaining: 'text' } Parses a single uppercase letter. ```typescript -uppercase('ABC'); // { ok: true, value: 'A', remaining: 'BC' } +uppercase('ABC'); // { ok: true, value: 'A', index: 1, furthest: -1, expected: [] } ``` ### `uppercases` -Parses one or more uppercase letters. +Parse one or more uppercase letters as a string. ```typescript -uppercases('ABCdef'); // { ok: true, value: 'ABC', remaining: 'def' } +uppercases('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['uppercase letter'] } ``` ### `whitespace` @@ -280,13 +281,13 @@ uppercases('ABCdef'); // { ok: true, value: 'ABC', remaining: 'def' } Parses a single whitespace character. ```typescript -whitespace(' abc'); // { ok: true, value: ' ', remaining: 'abc' } +whitespace(' abc'); // { ok: true, value: ' ', index: 1, furthest: -1, expected: [] } ``` ### `whitespaces` -Parses one or more whitespace characters. +Parse one or more whitespaces as a string. ```typescript -whitespaces(' abc'); // { ok: true, value: ' ', remaining: 'abc' } +whitespaces('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['whitespace'] } ``` diff --git a/doc/api/terminals.md b/doc/api/terminals.md index 424d9c8..fc8962a 100644 --- a/doc/api/terminals.md +++ b/doc/api/terminals.md @@ -21,7 +21,7 @@ import {} from /* … */ 'unitas/terminals'; Parse a specific character. ```typescript -char('A')('ABC'); // { ok: true, value: 'A', remaining: 'BC' } +char('A')('ABC'); // { ok: true, value: 'A', index: 1, furthest: -1, expected: [] } ``` ### `charOf` @@ -29,7 +29,7 @@ char('A')('ABC'); // { ok: true, value: 'A', remaining: 'BC' } Parse any character from a set. ```typescript -charOf(['a', 'b', 'c'])('abc'); // { ok: true, value: 'a', remaining: 'bc' } +charOf(['a', 'b', 'c'])('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } ``` ### `noneOf` @@ -37,7 +37,7 @@ charOf(['a', 'b', 'c'])('abc'); // { ok: true, value: 'a', remaining: 'bc' } Parse any character not in the set. ```typescript -noneOf(['a', 'b', 'c'])('xyz'); // { ok: true, value: 'x', remaining: 'yz' } +noneOf(['a', 'b', 'c'])('xyz'); // { ok: true, value: 'x', index: 1, furthest: -1, expected: [] } ``` ### `oneOf` @@ -45,15 +45,15 @@ noneOf(['a', 'b', 'c'])('xyz'); // { ok: true, value: 'x', remaining: 'yz' } Parse one string from a set of strings (longest match wins). ```typescript -oneOf(['hello', 'hell', 'help'])('helpful'); // { ok: true, value: 'help', remaining: 'ful' } +oneOf(['hello', 'hell', 'help'])('helpful'); // { ok: true, value: 'help', index: 4, furthest: -1, expected: [] } ``` ### `regex` -Parse with a regular expression. +Parse with a regular expression. The pattern is matched with the sticky flag at the current offset, so it is anchored by construction and never scans ahead. A leading `^` is redundant and is stripped. ```typescript -regex(/^\w+/)('hello world'); // { ok: true, value: 'hello', remaining: ' world' } +regex(/^\w+/)('hello world'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } ``` ### `satisfy` @@ -61,7 +61,7 @@ regex(/^\w+/)('hello world'); // { ok: true, value: 'hello', remaining: ' world' Parse a character satisfying a predicate. ```typescript -satisfy((c) => c === 'a')('abc'); // { ok: true, value: 'a', remaining: 'bc' } +satisfy((c) => c === 'a')('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } ``` ### `string` @@ -69,7 +69,7 @@ satisfy((c) => c === 'a')('abc'); // { ok: true, value: 'a', remaining: 'bc' } Parse a specific string. ```typescript -string('hello')('hello world'); // { ok: true, value: 'hello', remaining: ' world' } +string('hello')('hello world'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } ``` ### `stringOf` @@ -77,7 +77,7 @@ string('hello')('hello world'); // { ok: true, value: 'hello', remaining: ' worl Parse first character that exists in string (like charOf but for a string). ```typescript -stringOf('abc')('abcdef'); // { ok: true, value: 'a', remaining: 'bcdef' } +stringOf('abc')('abcdef'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } ``` ### `take` @@ -85,7 +85,7 @@ stringOf('abc')('abcdef'); // { ok: true, value: 'a', remaining: 'bcdef' } Take n characters. ```typescript -take(3)('abcdef'); // { ok: true, value: 'abc', remaining: 'def' } +take(3)('abcdef'); // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } ``` ### `takeWhile` @@ -93,7 +93,7 @@ take(3)('abcdef'); // { ok: true, value: 'abc', remaining: 'def' } Takes characters while the predicate returns true. ```typescript -takeWhile((c) => c !== 'x')('abcx'); // { ok: true, value: 'abc', remaining: 'x' } +takeWhile((c) => c !== 'x')('abcx'); // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } ``` ### `token` @@ -101,9 +101,9 @@ takeWhile((c) => c !== 'x')('abcx'); // { ok: true, value: 'abc', remaining: 'x' 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' } +token('let')('let x'); // { ok: true, value: 'let', index: 4, furthest: -1, expected: [] } +token('let')('let1'); // { ok: true, value: 'let', index: 3, furthest: -1, expected: [] } +token('let')('let x'); // { ok: true, value: 'let', index: 5, furthest: -1, expected: [] } ``` ### `word` @@ -111,7 +111,7 @@ token('let')('let x'); // { ok: true, value: 'let', remaining: 'x' } 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)' } +word('let')('let x'); // { ok: true, value: 'let', index: 4, furthest: -1, expected: [] } +word('let')('let1'); // { ok: false, index: 3, furthest: 3, expected: ['/(?!\\w)/'] } +word('if')('if (x)'); // { ok: true, value: 'if', index: 3, furthest: -1, expected: [] } ``` diff --git a/doc/api/utils.md b/doc/api/utils.md index 11e2e9d..077b910 100644 --- a/doc/api/utils.md +++ b/doc/api/utils.md @@ -57,7 +57,7 @@ pick(2, 4)(['a', 'b', 'c', 'd', 'e']); // ['c', 'e'] Pipe parser functions together. ```typescript -pipe(lexeme)(letters)('xyz abc'); // { ok: true, value: 'xyz', remaining: 'abc' } +pipe(lexeme)(letters)('xyz abc'); // { ok: true, value: 'xyz', index: 6, furthest: 3, expected: ['letter'] } ``` ### `pop` diff --git a/package.json b/package.json index 38f4fc3..6d82ae2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "unitas", - "version": "0.2.5", + "version": "0.3.0", "description": "TypeScript-first parser combinator library — compose small typed parsers into whole grammars. Zero dependencies, ESM-only, tree-shakeable.", "keywords": [ "parser", diff --git a/src/combinators/bind.test.ts b/src/combinators/bind.test.ts index 94d9655..c6bd254 100644 --- a/src/combinators/bind.test.ts +++ b/src/combinators/bind.test.ts @@ -15,16 +15,16 @@ describe('bind', () => { const parser1 = createTestParser('A'); const parser2 = createTestParser('B'); const parser = bind(parser1, (a) => - bind(parser2, (b) => create(() => success(`${a}${b}`, 'CD'))), + bind(parser2, (b) => create((_input, index = 0) => success(`${a}${b}`, index))), ); const result = parser('ABCD'); - assertSuccess(result, 'AB', 'CD'); + assertSuccess(result, 'AB', 2); }); it('should fail if the first parser fails', () => { - const failingParser = create(() => failure()); + const failingParser = create((_input, index = 0) => failure(index)); const parser = bind(failingParser, (_x) => createTestParser('B')); const result = parser('ABC'); @@ -34,7 +34,7 @@ describe('bind', () => { it('should fail if the second parser fails', () => { const parser1 = createTestParser('A'); - const failingSecond = create(() => failure()); + const failingSecond = create((_input, index = 0) => failure(index)); const parser = bind(parser1, () => failingSecond); const result = parser('ABC'); @@ -43,34 +43,34 @@ describe('bind', () => { }); it('should allow the second parser to depend on the first result', () => { - const countParser = create((input) => { - const match = input.match(/^(\d+)/); - if (!match) return failure(); - return success(parseInt(match[1]), input.slice(match[1].length)); + const countParser = create((input, index = 0) => { + const match = input.slice(index).match(/^(\d+)/); + if (!match) return failure(index); + return success(parseInt(match[1]), index + match[1].length); }); const parser = bind(countParser, (count) => - create((input) => { - const taken = input.slice(0, count); - return success(taken, input.slice(count)); + create((input, index = 0) => { + const taken = input.slice(index, index + count); + return success(taken, index + count); }), ); const result = parser('3abc'); - assertSuccess(result, 'abc', ''); + assertSuccess(result, 'abc', 4); }); it('should handle nested bind chains', () => { const parser = bind(createTestParser('A'), (a) => bind(createTestParser('B'), (b) => bind(createTestParser('C'), (c) => - create(() => success(`${a}${b}${c}`, '')), + create((input) => success(`${a}${b}${c}`, input.length)), ), ), ); const result = parser('ABC'); - assertSuccess(result, 'ABC', ''); + assertSuccess(result, 'ABC', 3); }); }); diff --git a/src/combinators/bind.ts b/src/combinators/bind.ts index 5373e16..7b4efab 100644 --- a/src/combinators/bind.ts +++ b/src/combinators/bind.ts @@ -1,4 +1,4 @@ -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Parser } from '../core/parser'; @@ -6,20 +6,18 @@ import { type Parser } from '../core/parser'; * Chain parsers where the second parser depends on the first result. * * @example - * bind(digits, (n) => take(n))('3abc') // { ok: true, value: 'abc', remaining: '' } + * bind(digits, (n) => take(n))('3abc') // { ok: true, value: 'abc', index: 4, furthest: -1, expected: [] } */ export const bind = ( parser: Parser, f: (a: A) => Parser, ): Parser => { - return create((input) => { - const result = parser(input); + return create((input, index = 0) => { + const result = parser(input, index); if (!result.ok) { - return failure(); + return result; } - const { value, remaining } = result; - - return f(value)(remaining); + return merge(result, f(result.value)(input, result.index)); }); }; diff --git a/src/combinators/braced.test.ts b/src/combinators/braced.test.ts index e107fc2..51ad7ad 100644 --- a/src/combinators/braced.test.ts +++ b/src/combinators/braced.test.ts @@ -12,7 +12,7 @@ describe('braced', () => { const parser = braced(createTestParser('ABC')); const result = parser('{ABC}'); - assertSuccess<'ABC'>(result, 'ABC', ''); + assertSuccess<'ABC'>(result, 'ABC', 5); }); it('should fail with uneven braces', () => { @@ -26,13 +26,13 @@ describe('braced', () => { const parser = braced(createTestParser('ABC')); const result = parser('{ABC}rest'); - assertSuccess<'ABC'>(result, 'ABC', 'rest'); + assertSuccess<'ABC'>(result, 'ABC', 5); }); it('should handle empty braces input', () => { const parser = braced(createTestParser('')); const result = parser('{}'); - assertSuccess<''>(result, '', ''); + assertSuccess<''>(result, '', 2); }); }); diff --git a/src/combinators/braced.ts b/src/combinators/braced.ts index c829ff7..03045d0 100644 --- a/src/combinators/braced.ts +++ b/src/combinators/braced.ts @@ -7,7 +7,7 @@ import { surrounded } from './surrounded'; * Parse content surrounded by braces. * * @example - * braced(string('hi'))('{hi}') // { ok: true, value: 'hi', remaining: '' } + * braced(string('hi'))('{hi}') // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } */ export const braced = (content: Parser) => { return surrounded(string('{'), content, string('}')); diff --git a/src/combinators/bracketed.test.ts b/src/combinators/bracketed.test.ts index d57688e..ea14800 100644 --- a/src/combinators/bracketed.test.ts +++ b/src/combinators/bracketed.test.ts @@ -12,7 +12,7 @@ describe('bracketed', () => { const parser = bracketed(createTestParser('ABC')); const result = parser('[ABC]'); - assertSuccess<'ABC'>(result, 'ABC', ''); + assertSuccess<'ABC'>(result, 'ABC', 5); }); it('should fail with uneven brackets', () => { @@ -26,13 +26,13 @@ describe('bracketed', () => { const parser = bracketed(createTestParser('ABC')); const result = parser('[ABC]rest'); - assertSuccess<'ABC'>(result, 'ABC', 'rest'); + assertSuccess<'ABC'>(result, 'ABC', 5); }); it('should handle empty brackets input', () => { const parser = bracketed(createTestParser('')); const result = parser('[]'); - assertSuccess<''>(result, '', ''); + assertSuccess<''>(result, '', 2); }); }); diff --git a/src/combinators/bracketed.ts b/src/combinators/bracketed.ts index 7f282c4..7ff0dd0 100644 --- a/src/combinators/bracketed.ts +++ b/src/combinators/bracketed.ts @@ -7,7 +7,7 @@ import { surrounded } from './surrounded'; * Parse content surrounded by brackets. * * @example - * bracketed(string('hi'))('[hi]') // { ok: true, value: 'hi', remaining: '' } + * bracketed(string('hi'))('[hi]') // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } */ export const bracketed = (content: Parser) => { return surrounded(string('['), content, string(']')); diff --git a/src/combinators/chainLeft.test.ts b/src/combinators/chainLeft.test.ts index fa157c0..78d0165 100644 --- a/src/combinators/chainLeft.test.ts +++ b/src/combinators/chainLeft.test.ts @@ -16,14 +16,14 @@ describe('chainLeft', () => { const parser = chainLeft(digits, operation); const result = parser('1+2+3'); - assertSuccess(result, 6, ''); + assertSuccess(result, 6, 5); }); it('should return single value when successful', () => { const parser = chainLeft(digits, operation); const result = parser('42'); - assertSuccess(result, 42, ''); + assertSuccess(result, 42, 2); }); it('should handle empty input', () => { diff --git a/src/combinators/chainLeft.ts b/src/combinators/chainLeft.ts index 2e0f747..63c0bab 100644 --- a/src/combinators/chainLeft.ts +++ b/src/combinators/chainLeft.ts @@ -1,6 +1,7 @@ import type { Parser } from '../core/parser'; import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { chainLeft1 } from './chainLeft1'; import { optional } from './optional'; @@ -9,17 +10,16 @@ import { optional } from './optional'; * Chain left-associative operations (right-to-left for same precedence). * * @example - * chainLeft(digits, operation)('1+2+3') // { ok: true, value: 6, remaining: '' } - * chainLeft(digits, operation)('10-3+2') // { ok: true, value: 9, remaining: '' } + * chainLeft(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } */ export const chainLeft = ( parser: Parser, operator: Parser<(a: T, b: T) => T>, ) => { - return create((input) => { - const result = optional(chainLeft1(parser, operator))(input); + return create((input, index = 0) => { + const result = optional(chainLeft1(parser, operator))(input, index); if (!result.ok || result.value === null) { - return failure(); + return merge(result, failure(index)); } return result; diff --git a/src/combinators/chainLeft1.test.ts b/src/combinators/chainLeft1.test.ts index c2b28ba..7b04b4f 100644 --- a/src/combinators/chainLeft1.test.ts +++ b/src/combinators/chainLeft1.test.ts @@ -9,35 +9,35 @@ describe('chainLeft1', () => { const parser = chainLeft1(digits, operation); const result = parser('1+1+1'); - assertSuccess(result, 3, ''); // ((1+1)+1) + assertSuccess(result, 3, 5); // ((1+1)+1) }); it('should handle single operand', () => { const parser = chainLeft1(digits, operation); const result = parser('42'); - assertSuccess(result, 42, ''); + assertSuccess(result, 42, 2); }); it('should parse left-associative operations', () => { const parser = chainLeft1(digits, operation); const result = parser('1+2+3'); - assertSuccess(result, 6, ''); // ((1+2)+3) + assertSuccess(result, 6, 5); // ((1+2)+3) }); it('should handle mixed operations with same precedence', () => { const parser = chainLeft1(digits, operation); const result = parser('10-3+2'); - assertSuccess(result, 9, ''); // ((10-3)+2) + assertSuccess(result, 9, 6); // ((10-3)+2) }); it('should stop when operator is not found', () => { const parser = chainLeft1(digits, operation); const result = parser('1+2*3^4'); - assertSuccess(result, 9, '^4'); // 1+2, then stops + assertSuccess(result, 9, 5); // 1+2, then stops }); it('should handle multiplication and division', () => { @@ -45,12 +45,12 @@ describe('chainLeft1', () => { { const result = parser('8/2*3'); - assertSuccess(result, 12, ''); // ((8/2)*3) + assertSuccess(result, 12, 5); // ((8/2)*3) } { const result = parser('24/3/2'); - assertSuccess(result, 4, ''); // ((24/3)/2) + assertSuccess(result, 4, 6); // ((24/3)/2) } }); @@ -65,6 +65,6 @@ describe('chainLeft1', () => { const parser = chainLeft1(digits, operation); const result = parser('1+2+'); - assertSuccess(result, 3, '+'); // Stops at incomplete operation + assertSuccess(result, 3, 3); // Stops at incomplete operation }); }); diff --git a/src/combinators/chainLeft1.ts b/src/combinators/chainLeft1.ts index f8f6d3d..4c65a39 100644 --- a/src/combinators/chainLeft1.ts +++ b/src/combinators/chainLeft1.ts @@ -1,38 +1,44 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** * Chain left-associative operations (fails on empty input). * * @example - * chainLeft1(digits, operation)('1+2+3') // { ok: true, value: 6, remaining: '' } - * chainLeft1(digits, operation)('8/2*3') // { ok: true, value: 12, remaining: '' } + * chainLeft1(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } + * chainLeft1(digits, operation)('8/2*3') // { ok: true, value: 12, index: 5, furthest: 5, expected: ['operator'] } */ export const chainLeft1 = ( term: Parser, operator: Parser<(left: T, right: T) => T>, ) => { - return create((input) => { - const firstResult = term(input); - if (!firstResult.ok) return failure(); + return create((input, index = 0) => { + const firstResult = term(input, index); + if (!firstResult.ok) { + return firstResult; + } let accumulator = firstResult.value; - let remaining = firstResult.remaining; + let at = firstResult.index; + let trace: Result = firstResult; while (true) { - const opResult = operator(remaining); + const opResult: Result<(left: T, right: T) => T> = merge(trace, operator(input, at)); + trace = opResult; if (!opResult.ok) break; - const nextResult = term(opResult.remaining); + const nextResult: Result = merge(trace, term(input, opResult.index)); + trace = nextResult; if (!nextResult.ok) break; accumulator = opResult.value(accumulator, nextResult.value); - remaining = nextResult.remaining; + at = nextResult.index; } - return success(accumulator, remaining); + return merge(trace, success(accumulator, at)); }); }; diff --git a/src/combinators/chainRight.test.ts b/src/combinators/chainRight.test.ts index b787e0f..e4fa4e5 100644 --- a/src/combinators/chainRight.test.ts +++ b/src/combinators/chainRight.test.ts @@ -16,14 +16,14 @@ describe('chainRight', () => { const parser = chainRight(digits, operation); const result = parser('2**3'); - assertSuccess(result, 8, ''); + assertSuccess(result, 8, 4); }); it('should return single value when successful', () => { const parser = chainRight(digits, operation); const result = parser('5'); - assertSuccess(result, 5, ''); + assertSuccess(result, 5, 1); }); it('should handle empty input', () => { @@ -37,6 +37,6 @@ describe('chainRight', () => { const parser = chainRight(digits, operation); const result = parser('10-3-2'); - assertSuccess(result, 9, ''); // 10-(3-2) + assertSuccess(result, 9, 6); // 10-(3-2) }); }); diff --git a/src/combinators/chainRight.ts b/src/combinators/chainRight.ts index 46c4d64..d5b97be 100644 --- a/src/combinators/chainRight.ts +++ b/src/combinators/chainRight.ts @@ -1,25 +1,25 @@ import type { Parser } from '../core/parser'; import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { chainRight1 } from './chainRight1'; import { optional } from './optional'; /** - * Chain right-associative operations (right-to-left grouping). + * Chain right-associative operations. * * @example - * chainRight(digits, operation)('2-1-1') // { ok: true, value: 2, remaining: '' } - * chainRight(digits, operation)('4/2/2') // { ok: true, value: 4, remaining: '' } + * chainRight(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } */ export const chainRight = ( parser: Parser, operator: Parser<(a: T, b: T) => T>, ) => { - return create((input) => { - const result = optional(chainRight1(parser, operator))(input); + return create((input, index = 0) => { + const result = optional(chainRight1(parser, operator))(input, index); if (!result.ok || result.value === null) { - return failure(); + return merge(result, failure(index)); } return result; diff --git a/src/combinators/chainRight1.test.ts b/src/combinators/chainRight1.test.ts index 5f20f2d..0c6f40b 100644 --- a/src/combinators/chainRight1.test.ts +++ b/src/combinators/chainRight1.test.ts @@ -9,35 +9,35 @@ describe('chainRight1', () => { const parser = chainRight1(digits, operation); const result = parser('2'); - assertSuccess(result, 2, ''); + assertSuccess(result, 2, 1); }); it('should parse right-associative operations', () => { const parser = chainRight1(digits, operation); const result = parser('2**3**2'); - assertSuccess(result, 512, ''); // 2**(3**2) = 2**9 = 512 + assertSuccess(result, 512, 7); // 2**(3**2) = 2**9 = 512 }); it('should be right-associative', () => { const parser = chainRight1(digits, operation); const result = parser('10-3-2'); - assertSuccess(result, 9, ''); // 10-(3-2) = 9 + assertSuccess(result, 9, 6); // 10-(3-2) = 9 }); it('should stop when operator is not found', () => { const parser = chainRight1(digits, operation); const result = parser('2**3^4'); - assertSuccess(result, 8, '^4'); // 2**3, then stops + assertSuccess(result, 8, 4); // 2**3, then stops }); it('should handle complex right-associative chains', () => { const parser = chainRight1(digits, operation); const result = parser('2**2**3'); - assertSuccess(result, 256, ''); // 2**(2**3) = 2**8 = 256 + assertSuccess(result, 256, 7); // 2**(2**3) = 2**8 = 256 }); it('should fail when first operand fails', () => { @@ -51,6 +51,6 @@ describe('chainRight1', () => { const parser = chainRight1(digits, operation); const result = parser('2**'); - assertSuccess(result, 2, '**'); // Stops at incomplete operation + assertSuccess(result, 2, 1); // Stops at incomplete operation }); }); diff --git a/src/combinators/chainRight1.ts b/src/combinators/chainRight1.ts index a0421fb..2a28779 100644 --- a/src/combinators/chainRight1.ts +++ b/src/combinators/chainRight1.ts @@ -1,6 +1,6 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,28 +9,42 @@ import { success } from '../core/success'; * Chain right-associative operations (fails on empty input). * * @example - * chainRight1(digits, operation)('2-1-1') // { ok: true, value: 2, remaining: '' } - * chainRight1(digits, operation)('4/2/2') // { ok: true, value: 4, remaining: '' } + * chainRight1(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } */ export const chainRight1 = ( term: Parser, operator: Parser<(left: T, right: T) => T>, ) => { - return create((input) => { - const leftResult = term(input); - if (!leftResult.ok) return failure(); + const parser: Parser = create((input, index = 0) => { + const leftResult = term(input, index); + if (!leftResult.ok) { + return leftResult; + } - const tryRightSide = (leftValue: T, remaining: string): Result => { - const opResult = operator(remaining); - if (!opResult.ok) return success(leftValue, remaining); + const opResult = merge(leftResult, operator(input, leftResult.index)); + if (!opResult.ok) { + return merge(opResult, success(leftResult.value, leftResult.index)); + } - const rightResult = chainRight1(term, operator)(opResult.remaining); - if (!rightResult.ok) return success(leftValue, remaining); + const rightResult: Result = merge( + opResult, + parser(input, opResult.index), + ); + if (!rightResult.ok) { + return merge( + rightResult, + success(leftResult.value, leftResult.index), + ); + } - const combinedValue = opResult.value(leftValue, rightResult.value); - return success(combinedValue, rightResult.remaining); - }; - - return tryRightSide(leftResult.value, leftResult.remaining); + return merge( + rightResult, + success( + opResult.value(leftResult.value, rightResult.value), + rightResult.index, + ), + ); }); + + return parser; }; diff --git a/src/combinators/choice.test.ts b/src/combinators/choice.test.ts index fbf9211..3a1472d 100644 --- a/src/combinators/choice.test.ts +++ b/src/combinators/choice.test.ts @@ -12,30 +12,30 @@ import { choice } from './choice'; describe('choice', () => { it('should try parsers in order and return first success', () => { - const parserA = create<'A'>(() => failure()); - const parserB = create<'B'>((input) => success('B', input.slice(1))); - const parserC = create<'C'>((input) => success('C', input.slice(1))); + const parserA = create<'A'>((_input, index = 0) => failure(index)); + const parserB = create<'B'>((input, index = 0) => success('B', index + 1)); + const parserC = create<'C'>((input, index = 0) => success('C', index + 1)); const parser = choice(parserA, parserB, parserC); const result = parser('ABC'); - assertSuccess<'A' | 'B' | 'C'>(result, 'B', 'BC'); + assertSuccess<'A' | 'B' | 'C'>(result, 'B', 1); }); it('should try all parsers if earlier ones fail', () => { - const parserA = create<'A'>(() => failure()); - const parserB = create<'B'>(() => failure()); - const parserC = create<'C'>((input) => success('C', input.slice(1))); + const parserA = create<'A'>((_input, index = 0) => failure(index)); + const parserB = create<'B'>((_input, index = 0) => failure(index)); + const parserC = create<'C'>((input, index = 0) => success('C', index + 1)); const parser = choice(parserA, parserB, parserC); const result = parser('C D'); - assertSuccess<'A' | 'B' | 'C'>(result, 'C', ' D'); + assertSuccess<'A' | 'B' | 'C'>(result, 'C', 1); }); it('should fail if all parsers fail', () => { const parser = choice( - () => failure(), - () => failure(), - () => failure(), + (_input, index = 0) => failure(index), + (_input, index = 0) => failure(index), + (_input, index = 0) => failure(index), ); const result = parser('D'); @@ -43,14 +43,14 @@ describe('choice', () => { }); it('should handle different types', () => { - const parserA = create<'A'>((input) => success('A', input.slice(1))); - const parserB = create<'B'[]>((input) => - success(['B'], input.slice(1)), + const parserA = create<'A'>((input, index = 0) => success('A', index + 1)); + const parserB = create<'B'[]>((input, index = 0) => + success(['B'], index + 1), ); const parser = choice(parserA, parserB); const result = parser('ABC'); - assertSuccess<'A' | 'B'[]>(result, 'A', 'BC'); + assertSuccess<'A' | 'B'[]>(result, 'A', 1); }); it('should handle single parser', () => { @@ -58,7 +58,7 @@ describe('choice', () => { const parser = choice(parser1); const result = parser('ABCD'); - assertSuccess<'A'>(result, 'A', 'BCD'); + assertSuccess<'A'>(result, 'A', 1); }); it('should handle empty choices', () => { diff --git a/src/combinators/choice.ts b/src/combinators/choice.ts index 6a3fa0c..08345fb 100644 --- a/src/combinators/choice.ts +++ b/src/combinators/choice.ts @@ -1,27 +1,37 @@ import type { Parser } from '../core/parser'; import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; +import { success } from '../core/success'; type ParserOutput

= P extends Parser ? T : never; +const NONE: readonly string[] = []; + /** * Try each parser in order, return first success. * - * Expectations from every alternative tried at the same offset are unioned, so - * a failing alternation reports all of them rather than only the last. + * Alternatives all start from the same offset, so the expectations of those + * that failed there are unioned into the result. Anything they recorded deeper + * in the input is already in the parse context. * * @example - * choice(string('hello'), string('world'))('hello') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } + * choice(string('hello'), string('world'))('hello') // { ok: true, value: 'hello', index: 5 } */ export const choice = []>( ...parsers: P ): Parser> => { - return create>((input, index = 0) => { - let trace: Result = success(null, index); + return create>((input, index = 0, ctx) => { + let expected: readonly string[] = NONE; for (const parser of parsers) { - const result: Result = merge(trace, parser(input, index)); + const result: Result = parser(input, index, ctx); if (result.ok) { return result as Result>; } - trace = result; + if (result.index === index) { + expected = union(expected, result.expected); + } } - return trace.ok ? failure(index) : trace; + return { ok: false, index, expected }; }); }; diff --git a/src/combinators/concat.test.ts b/src/combinators/concat.test.ts index e272cf3..bec19e1 100644 --- a/src/combinators/concat.test.ts +++ b/src/combinators/concat.test.ts @@ -11,7 +11,7 @@ const abcParser = create((input, index = 0) => { return success(['a', 'b', 'c'], index + 3); } - return failure(index); + return failure(undefined, index); }); const emptyParser = create((input, index = 0) => success([], index)); diff --git a/src/combinators/concat.ts b/src/combinators/concat.ts index 52c8e97..766c982 100644 --- a/src/combinators/concat.ts +++ b/src/combinators/concat.ts @@ -6,8 +6,8 @@ import { map } from './map'; * Join string array parser result into a single string. * * @example - * concat(many(letter))('abc123') // { ok: true, value: 'abc', index: 3, furthest: 3, expected: ['letter'] } - * concat(many(letter), '-')('abc123') // { ok: true, value: 'a-b-c', index: 3, furthest: 3, expected: ['letter'] } + * concat(many(letter))('abc123') // { ok: true, value: 'abc', index: 3 } + * concat(many(letter), '-')('abc123') // { ok: true, value: 'a-b-c', index: 3 } */ export const concat = ( parser: Parser, diff --git a/src/combinators/consume.ts b/src/combinators/consume.ts index 163846d..ca9fd39 100644 --- a/src/combinators/consume.ts +++ b/src/combinators/consume.ts @@ -7,7 +7,7 @@ import { map } from './map'; * Consume input but discard the result (return null). * * @example - * consume(string('hello'))('hello world') // { ok: true, value: null, index: 5, furthest: -1, expected: [] } + * consume(string('hello'))('hello world') // { ok: true, value: null, index: 5 } */ export const consume = (parser: Parser) => { return create(map(parser, () => null)); diff --git a/src/combinators/endBy.ts b/src/combinators/endBy.ts index bea601b..cf502dd 100644 --- a/src/combinators/endBy.ts +++ b/src/combinators/endBy.ts @@ -9,7 +9,7 @@ import { sequence } from './sequence'; * Zero or more items separated and ending with terminator. * * @example - * endBy(string('item'), char(';'))('item;item;item;') // { ok: true, value: ['item', 'item', 'item'], index: 15, furthest: 15, expected: ["'item'"] } + * endBy(string('item'), char(';'))('item;item;item;') // { ok: true, value: ['item', 'item', 'item'], index: 15 } */ export const endBy = (parser: Parser, terminator: Parser) => { return create( diff --git a/src/combinators/endBy1.ts b/src/combinators/endBy1.ts index 1bd4221..a494944 100644 --- a/src/combinators/endBy1.ts +++ b/src/combinators/endBy1.ts @@ -9,7 +9,7 @@ import { sequence } from './sequence'; * One or more items separated and ending with terminator. * * @example - * endBy1(string('item'), char(';'))('item;item;item;') // { ok: true, value: ['item', 'item', 'item'], index: 15, furthest: 15, expected: ["'item'"] } + * endBy1(string('item'), char(';'))('item;item;item;') // { ok: true, value: ['item', 'item', 'item'], index: 15 } */ export const endBy1 = (parser: Parser, terminator: Parser) => { return create( diff --git a/src/combinators/exactly.ts b/src/combinators/exactly.ts index f502df6..8bbb494 100644 --- a/src/combinators/exactly.ts +++ b/src/combinators/exactly.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,18 +8,15 @@ import { success } from '../core/success'; * Parse exactly n occurrences. * * @example - * exactly(char('a'), 3)('aaa') // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: -1, expected: [] } + * exactly(char('a'), 3)('aaa') // { ok: true, value: ['a', 'a', 'a'], index: 3 } */ export const exactly = (parser: Parser, n: number) => { - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { const results: T[] = []; let at = index; - let trace: Result = success(null, index); for (let i = 0; i < n; i++) { - const result: Result = merge(trace, parser(input, at)); - trace = result; - + const result: Result = parser(input, at, ctx); if (!result.ok) { return result; } @@ -29,6 +25,6 @@ export const exactly = (parser: Parser, n: number) => { at = result.index; } - return merge(trace, success(results, at)); + return success(results, at); }); }; diff --git a/src/combinators/first.test.ts b/src/combinators/first.test.ts index 1ac5d42..5fe4166 100644 --- a/src/combinators/first.test.ts +++ b/src/combinators/first.test.ts @@ -7,7 +7,9 @@ import { first } from './first'; describe('first', () => { it('should return first element of array parser result', () => { - const parser1 = create((input) => success(['A', 'B', 'C'] as const, input.length)); + const parser1 = create((input) => + success(['A', 'B', 'C'] as const, input.length), + ); const parser = first(parser1); const result = parser('ABC'); diff --git a/src/combinators/first.ts b/src/combinators/first.ts index 669eeba..6a369e8 100644 --- a/src/combinators/first.ts +++ b/src/combinators/first.ts @@ -16,7 +16,7 @@ export type First = T extends readonly [ * Extract the first element from a parser result array. * * @example - * first(sequence(char('a'), digit))('a1bc') // { ok: true, value: 'a', index: 2, furthest: -1, expected: [] } + * first(sequence(char('a'), digit))('a1bc') // { ok: true, value: 'a', index: 2 } */ export const first = ( parser: Parser, diff --git a/src/combinators/flag.test.ts b/src/combinators/flag.test.ts index 1d57ecc..1589c10 100644 --- a/src/combinators/flag.test.ts +++ b/src/combinators/flag.test.ts @@ -16,7 +16,9 @@ describe('flag', () => { }); it('should return false when parser fails', () => { - const parser = flag(create<'A'>((_input, index = 0) => failure(index))); + const parser = flag( + create<'A'>((_input, index = 0) => failure(undefined, index)), + ); const result = parser('ABC'); assertSuccess(result, false, 0); diff --git a/src/combinators/flag.ts b/src/combinators/flag.ts index 8c4fda9..0dda982 100644 --- a/src/combinators/flag.ts +++ b/src/combinators/flag.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -8,15 +7,13 @@ import { success } from '../core/success'; * Return true if parser succeeds, false otherwise. Always succeeds without consuming input on failure. * * @example - * flag(string('*'))('*abc') // { ok: true, value: true, index: 1, furthest: -1, expected: [] } - * flag(string('*'))('abc') // { ok: true, value: false, index: 0, furthest: 0, expected: ["'*'"] } + * flag(string('*'))('*abc') // { ok: true, value: true, index: 1 } + * flag(string('*'))('abc') // { ok: true, value: false, index: 0 } */ export const flag = (parser: Parser): Parser => { - return create((input, index = 0) => { - const result = parser(input, index); + return create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); - return result.ok - ? merge(result, success(true, result.index)) - : merge(result, success(false, index)); + return result.ok ? success(true, result.index) : success(false, index); }); }; diff --git a/src/combinators/fold.test.ts b/src/combinators/fold.test.ts index 9c40aa1..2211158 100644 --- a/src/combinators/fold.test.ts +++ b/src/combinators/fold.test.ts @@ -9,7 +9,7 @@ import { fold } from './fold'; describe('fold', () => { const stringParser = create((input, index = 0) => { if (index >= input.length) { - return failure(index); + return failure(undefined, index); } return success(input[index], index + 1); }); @@ -27,7 +27,7 @@ describe('fold', () => { it('should work with empty input (return initial value and not consume input)', () => { const parser = fold( - create((_input, index = 0) => failure(index)), + create((_input, index = 0) => failure(undefined, index)), 'Z', (acc, item) => `(${acc}${item})`, ); @@ -38,7 +38,7 @@ describe('fold', () => { it('should work with empty input (return initial value)', () => { const parser = fold( - create((_input, index = 0) => failure(index)), + create((_input, index = 0) => failure(undefined, index)), 'Z', (acc, item) => `(${acc}${item})`, ); @@ -75,7 +75,9 @@ describe('fold', () => { }); it('should not fail and return the initial value and not consume', () => { - const failureParser = create((_input, index = 0) => failure(index)); + const failureParser = create((_input, index = 0) => + failure(undefined, index), + ); const parser = fold(failureParser, 0, (acc) => acc + 1); const result = parser('ABC'); diff --git a/src/combinators/fold.ts b/src/combinators/fold.ts index f073747..c9dcfe8 100644 --- a/src/combinators/fold.ts +++ b/src/combinators/fold.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success, type Success } from '../core/success'; import { many } from './many'; @@ -9,19 +8,16 @@ import { many } from './many'; * Fold zero or more occurrences into a single value. * * @example - * fold(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } + * fold(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3 } */ export const fold = ( parser: Parser, initial: U, folder: (acc: U, item: T) => U, ): Parser => { - return create((input, index = 0) => { - const result = many(parser)(input, index) as Success; + return create((input, index = 0, ctx) => { + const result = many(parser)(input, index, ctx) as Success; - return merge( - result, - success(result.value.reduce(folder, initial), result.index), - ); + return success(result.value.reduce(folder, initial), result.index); }); }; diff --git a/src/combinators/fold1.test.ts b/src/combinators/fold1.test.ts index 753472d..6287889 100644 --- a/src/combinators/fold1.test.ts +++ b/src/combinators/fold1.test.ts @@ -10,7 +10,7 @@ describe('fold1', () => { it('should fold left over parsed items', () => { const stringParser = create((input, index = 0) => { if (index >= input.length) { - return failure(index); + return failure(undefined, index); } return success(input[index], index + 1); }); @@ -22,7 +22,9 @@ describe('fold1', () => { }); it('should return null, one or more successful parser returns are required', () => { - const failureParser = create((_input, index = 0) => failure(index)); + const failureParser = create((_input, index = 0) => + failure(undefined, index), + ); const parser = fold1(failureParser, 42, (acc, digit) => acc + digit); const result = parser('ABC'); diff --git a/src/combinators/fold1.ts b/src/combinators/fold1.ts index 4cf966d..874f725 100644 --- a/src/combinators/fold1.ts +++ b/src/combinators/fold1.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success, type Success } from '../core/success'; import { many } from './many'; @@ -9,22 +8,22 @@ import { many } from './many'; * Fold one or more occurrences into a single value. * * @example - * fold1(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } + * fold1(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3 } */ export const fold1 = ( parser: Parser, initial: U, folder: (acc: U, item: T) => U, ): Parser => { - return create((input, index = 0) => { - const first = parser(input, index); + return create((input, index = 0, ctx) => { + const first = parser(input, index, ctx); if (!first.ok) { return first; } - const rest = many(parser)(input, first.index) as Success; + const rest = many(parser)(input, first.index, ctx) as Success; const acc = rest.value.reduce(folder, folder(initial, first.value)); - return merge(first, merge(rest, success(acc, rest.index))); + return success(acc, rest.index); }); }; diff --git a/src/combinators/foldRight.test.ts b/src/combinators/foldRight.test.ts index 62c41a1..056013b 100644 --- a/src/combinators/foldRight.test.ts +++ b/src/combinators/foldRight.test.ts @@ -9,7 +9,7 @@ import { foldRight } from './foldRight'; describe('foldRight', () => { const stringParser = create((input, index = 0) => { if (index >= input.length) { - return failure(index); + return failure(undefined, index); } return success(input[index], index + 1); @@ -28,7 +28,7 @@ describe('foldRight', () => { it('should work with empty input (return initial value and not consume input)', () => { const parser = foldRight( - create((_input, index = 0) => failure(index)), + create((_input, index = 0) => failure(undefined, index)), 'Z', (acc, item) => `(${acc}${item})`, ); @@ -39,7 +39,7 @@ describe('foldRight', () => { it('should work with empty input (return initial value)', () => { const parser = foldRight( - create((_input, index = 0) => failure(index)), + create((_input, index = 0) => failure(undefined, index)), 'Z', (acc, item) => `(${acc}${item})`, ); @@ -76,7 +76,9 @@ describe('foldRight', () => { }); it('should not fail and return the initial value and not consume', () => { - const parserFail = create((_input, index = 0) => failure(index)); + const parserFail = create((_input, index = 0) => + failure(undefined, index), + ); const parser = foldRight(parserFail, 0, (acc) => acc + 1); const result = parser('ABC'); diff --git a/src/combinators/foldRight.ts b/src/combinators/foldRight.ts index 8f06a8e..44eb945 100644 --- a/src/combinators/foldRight.ts +++ b/src/combinators/foldRight.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success, type Success } from '../core/success'; import { many } from './many'; @@ -9,19 +8,16 @@ import { many } from './many'; * Fold zero or more occurrences from the right into a single value. * * @example - * foldRight(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } + * foldRight(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3 } */ export const foldRight = ( parser: Parser, initial: U, folder: (acc: U, item: T) => U, ): Parser => { - return create((input, index = 0) => { - const result = many(parser)(input, index) as Success; + return create((input, index = 0, ctx) => { + const result = many(parser)(input, index, ctx) as Success; - return merge( - result, - success(result.value.reduceRight(folder, initial), result.index), - ); + return success(result.value.reduceRight(folder, initial), result.index); }); }; diff --git a/src/combinators/foldRight1.test.ts b/src/combinators/foldRight1.test.ts index daac744..7d7f433 100644 --- a/src/combinators/foldRight1.test.ts +++ b/src/combinators/foldRight1.test.ts @@ -9,7 +9,7 @@ import { foldRight1 } from './foldRight1'; describe('foldRight1', () => { const stringParser = create((input, index = 0) => { if (index >= input.length) { - return failure(index); + return failure(undefined, index); } return success(input[index], index + 1); }); @@ -26,7 +26,9 @@ describe('foldRight1', () => { }); it('should return null, one or more successful parser returns are required', () => { - const failureParser = create((_input, index = 0) => failure(index)); + const failureParser = create((_input, index = 0) => + failure(undefined, index), + ); const parser = foldRight1( failureParser, 42, diff --git a/src/combinators/foldRight1.ts b/src/combinators/foldRight1.ts index 158f001..c59a77f 100644 --- a/src/combinators/foldRight1.ts +++ b/src/combinators/foldRight1.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success, type Success } from '../core/success'; import { many } from './many'; @@ -9,25 +8,22 @@ import { many } from './many'; * Fold one or more occurrences from the right into a single value. * * @example - * foldRight1(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } + * foldRight1(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3 } */ export const foldRight1 = ( parser: Parser, initial: U, folder: (acc: U, item: T) => U, ): Parser => { - return create((input, index = 0) => { - const first = parser(input, index); + return create((input, index = 0, ctx) => { + const first = parser(input, index, ctx); if (!first.ok) { return first; } - const rest = many(parser)(input, first.index) as Success; + const rest = many(parser)(input, first.index, ctx) as Success; const all = [first.value, ...rest.value]; - return merge( - first, - merge(rest, success(all.reduceRight(folder, initial), rest.index)), - ); + return success(all.reduceRight(folder, initial), rest.index); }); }; diff --git a/src/combinators/fuse.ts b/src/combinators/fuse.ts index 397ab64..9aeaac2 100644 --- a/src/combinators/fuse.ts +++ b/src/combinators/fuse.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -10,19 +9,16 @@ import { success } from '../core/success'; * The fused parser concatenates all string results. * * @example - * fuse(char('a'), char('b'), char('c'))('abc') // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } - * fuse(string('hello'), char(' '), string('world'))('hello world') // { ok: true, value: 'hello world', index: 11, furthest: -1, expected: [] } + * fuse(char('a'), char('b'), char('c'))('abc') // { ok: true, value: 'abc', index: 3 } + * fuse(string('hello'), char(' '), string('world'))('hello world') // { ok: true, value: 'hello world', index: 11 } */ export const fuse = []>(...parsers: T) => { - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { let value = ''; let at = index; - let trace: Result = success(null, index); for (const parser of parsers) { - const parsed: Result = merge(trace, parser(input, at)); - trace = parsed; - + const parsed: Result = parser(input, at, ctx); if (!parsed.ok) { return parsed; } @@ -31,6 +27,6 @@ export const fuse = []>(...parsers: T) => { at = parsed.index; } - return merge(trace, success(value, at)); + return success(value, at); }); }; diff --git a/src/combinators/guard.ts b/src/combinators/guard.ts index 9f42fe7..0b22ed7 100644 --- a/src/combinators/guard.ts +++ b/src/combinators/guard.ts @@ -7,15 +7,15 @@ import { create } from '../core/parser'; * Conditionally apply parser based on a condition. * * @example - * guard(true, string('hello'))('hello') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } - * guard(false, string('hello'))('hello') // { ok: false, index: 0, furthest: 0, expected: [] } + * guard(true, string('hello'))('hello') // { ok: true, value: 'hello', index: 5 } + * guard(false, string('hello'))('hello') // { ok: false, index: 0, expected: [] } */ export const guard = (condition: boolean, parser: Parser) => { - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { if (!condition) { - return failure(index); + return failure(ctx, index); } - return parser(input, index); + return parser(input, index, ctx); }); }; diff --git a/src/combinators/inner.ts b/src/combinators/inner.ts index 5432c6d..2a7bed8 100644 --- a/src/combinators/inner.ts +++ b/src/combinators/inner.ts @@ -8,7 +8,7 @@ import { sequence } from './sequence'; * Extract inner value from surrounded content (like inner of braced). * * @example - * inner(char('('), string('hi'), char(')'))('(hi)') // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } + * inner(char('('), string('hi'), char(')'))('(hi)') // { ok: true, value: 'hi', index: 4 } */ export const inner = ( parserA: Parser, diff --git a/src/combinators/interleaved.ts b/src/combinators/interleaved.ts index 0272ea2..cd1aa00 100644 --- a/src/combinators/interleaved.ts +++ b/src/combinators/interleaved.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,26 +8,23 @@ import { success } from '../core/success'; * Parse items separated by separators, keeping both in the result. * * @example - * interleaved(digits, char('+'))('1+2') // { ok: true, value: [1, '+', 2], index: 3, furthest: 3, expected: ["'+'"] } + * interleaved(digits, char('+'))('1+2') // { ok: true, value: [1, '+', 2], index: 3 } */ export const interleaved = (item: Parser, separator: Parser) => { - return create>((input, index = 0) => { - const firstResult = item(input, index); + return create>((input, index = 0, ctx) => { + const firstResult = item(input, index, ctx); if (!firstResult.ok) { return firstResult; } const results: Array = [firstResult.value]; let at = firstResult.index; - let trace: Result = firstResult; while (true) { - const sepResult: Result = merge(trace, separator(input, at)); - trace = sepResult; + const sepResult: Result = separator(input, at, ctx); if (!sepResult.ok) break; - const nextResult: Result = merge(trace, item(input, sepResult.index)); - trace = nextResult; + const nextResult: Result = item(input, sepResult.index, ctx); if (!nextResult.ok) break; results.push(sepResult.value); @@ -36,6 +32,6 @@ export const interleaved = (item: Parser, separator: Parser) => { at = nextResult.index; } - return merge(trace, success(results, at)); + return success(results, at); }); }; diff --git a/src/combinators/last.test.ts b/src/combinators/last.test.ts index 84f5a18..74c7741 100644 --- a/src/combinators/last.test.ts +++ b/src/combinators/last.test.ts @@ -7,7 +7,9 @@ import { last } from './last'; describe('last', () => { it('should return last element of array parser result', () => { - const parser1 = create((input) => success(['A', 'B', 'C'] as const, input.length)); + const parser1 = create((input) => + success(['A', 'B', 'C'] as const, input.length), + ); const parser = last(parser1); const result = parser('ABC'); diff --git a/src/combinators/last.ts b/src/combinators/last.ts index 987c3cb..b2408de 100644 --- a/src/combinators/last.ts +++ b/src/combinators/last.ts @@ -16,7 +16,7 @@ export type Last = T extends readonly [ * Extract the last element from a parser result array. * * @example - * last(sequence(char('a'), char('b')))('ab') // { ok: true, value: 'b', index: 2, furthest: -1, expected: [] } + * last(sequence(char('a'), char('b')))('ab') // { ok: true, value: 'b', index: 2 } */ export const last = (parser: Parser) => { return create>( diff --git a/src/combinators/left.ts b/src/combinators/left.ts index 6b6af73..0be510b 100644 --- a/src/combinators/left.ts +++ b/src/combinators/left.ts @@ -8,7 +8,7 @@ import { sequence } from './sequence'; * Keep only the left result from a sequence. * * @example - * left(string('hello'), string('world'))('helloworld') // { ok: true, value: 'hello', index: 10, furthest: -1, expected: [] } + * left(string('hello'), string('world'))('helloworld') // { ok: true, value: 'hello', index: 10 } */ export const left = (parserA: Parser, parserB: Parser) => { return create(map(sequence(parserA, parserB), ([a]) => a)); diff --git a/src/combinators/lexeme.ts b/src/combinators/lexeme.ts index 8188da4..c29b3b6 100644 --- a/src/combinators/lexeme.ts +++ b/src/combinators/lexeme.ts @@ -9,7 +9,7 @@ import { sequence } from './sequence'; * Parser that consumes trailing whitespace. * * @example - * lexeme(string('hello'))('hello world') // { ok: true, value: 'hello', index: 8, furthest: -1, expected: [] } + * lexeme(string('hello'))('hello world') // { ok: true, value: 'hello', index: 8 } */ export const lexeme = (parser: Parser) => { return create(map(sequence(parser, regex(/^\s*/)), ([value]) => value)); diff --git a/src/combinators/many.test.ts b/src/combinators/many.test.ts index 1b581c3..a3f1082 100644 --- a/src/combinators/many.test.ts +++ b/src/combinators/many.test.ts @@ -8,7 +8,9 @@ import { many } from './many'; describe('many', () => { it('should parse zero occurrences', () => { - const failureParser = create((_input, index = 0) => failure(index)); + const failureParser = create((_input, index = 0) => + failure(undefined, index), + ); const parser = many(failureParser); const result = parser('BCD'); diff --git a/src/combinators/many.ts b/src/combinators/many.ts index 3faaf7f..525680b 100644 --- a/src/combinators/many.ts +++ b/src/combinators/many.ts @@ -1,4 +1,3 @@ -import { merge } from '../core/merge'; import { create, type Parser } from '../core/parser'; import { type Result } from '../core/result'; import { success, type Success } from '../core/success'; @@ -10,18 +9,15 @@ import { success, type Success } from '../core/success'; * still report what the repetition was expecting next. * * @example - * many(char('a'))('aaa') // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 3, expected: ["'a'"] } + * many(char('a'))('aaa') // { ok: true, value: ['a', 'a', 'a'], index: 3 } */ export const many = (parser: Parser) => { - return create((input, index = 0): Success => { + return create((input, index = 0, ctx): Success => { const results: T[] = []; let at = index; - let trace: Result = success(null, index); while (true) { - const result: Result = merge(trace, parser(input, at)); - trace = result; - + const result: Result = parser(input, at, ctx); if (!result.ok) { break; } @@ -35,6 +31,6 @@ export const many = (parser: Parser) => { at = result.index; } - return merge(trace, success(results, at)) as Success; + return success(results, at) as Success; }); }; diff --git a/src/combinators/many1.ts b/src/combinators/many1.ts index 8045adc..7966184 100644 --- a/src/combinators/many1.ts +++ b/src/combinators/many1.ts @@ -1,4 +1,3 @@ -import { merge } from '../core/merge'; import { create, type Parser } from '../core/parser'; import { type Success } from '../core/success'; import { many } from './many'; @@ -7,20 +6,20 @@ import { many } from './many'; * One or more occurrences. * * @example - * many1(char('a'))('aaa') // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 3, expected: ["'a'"] } + * many1(char('a'))('aaa') // { ok: true, value: ['a', 'a', 'a'], index: 3 } */ export const many1 = (parser: Parser) => { - return create((input, index = 0) => { - const result = parser(input, index); + return create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) { return result; } - const rest = many(parser)(input, result.index) as Success; + const rest = many(parser)(input, result.index, ctx) as Success; - return merge(result, { + return { ...rest, value: [result.value, ...rest.value], - }); + }; }); }; diff --git a/src/combinators/manyAtLeast.ts b/src/combinators/manyAtLeast.ts index 4f7d551..439c912 100644 --- a/src/combinators/manyAtLeast.ts +++ b/src/combinators/manyAtLeast.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Success } from '../core/success'; import { exactly } from './exactly'; @@ -10,20 +9,20 @@ import { many } from './many'; * Parse at least n occurrences. * * @example - * manyAtLeast(char('a'), 2)('aaa') // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 3, expected: ["'a'"] } + * manyAtLeast(char('a'), 2)('aaa') // { ok: true, value: ['a', 'a', 'a'], index: 3 } */ export const manyAtLeast = (parser: Parser, n: number) => { - return create((input, index = 0) => { - const required = exactly(parser, n)(input, index); + return create((input, index = 0, ctx) => { + const required = exactly(parser, n)(input, index, ctx); if (!required.ok) { return required; } - const rest = many(parser)(input, required.index) as Success; + const rest = many(parser)(input, required.index, ctx) as Success; - return merge(required, { + return { ...rest, value: [...required.value, ...rest.value], - }); + }; }); }; diff --git a/src/combinators/manyAtMost.ts b/src/combinators/manyAtMost.ts index c5ae434..2319c09 100644 --- a/src/combinators/manyAtMost.ts +++ b/src/combinators/manyAtMost.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,18 +8,15 @@ import { success } from '../core/success'; * Parse at most n occurrences (never fails). * * @example - * manyAtMost(char('a'), 2)('aaa') // { ok: true, value: ['a', 'a'], index: 2, furthest: -1, expected: [] } + * manyAtMost(char('a'), 2)('aaa') // { ok: true, value: ['a', 'a'], index: 2 } */ export const manyAtMost = (parser: Parser, n: number) => { - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { const results: T[] = []; let at = index; - let trace: Result = success(null, index); for (let i = 0; i < n; i++) { - const result: Result = merge(trace, parser(input, at)); - trace = result; - + const result: Result = parser(input, at, ctx); if (!result.ok) { break; } @@ -29,6 +25,6 @@ export const manyAtMost = (parser: Parser, n: number) => { at = result.index; } - return merge(trace, success(results, at)); + return success(results, at); }); }; diff --git a/src/combinators/manyBetween.ts b/src/combinators/manyBetween.ts index dcfffb4..dce9ce9 100644 --- a/src/combinators/manyBetween.ts +++ b/src/combinators/manyBetween.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Success } from '../core/success'; import { exactly } from './exactly'; @@ -10,11 +9,11 @@ import { manyAtMost } from './manyAtMost'; * Parse between min and max occurrences. * * @example - * manyBetween(char('a'), 1, 2)('aaa') // { ok: true, value: ['a', 'a'], index: 2, furthest: -1, expected: [] } + * manyBetween(char('a'), 1, 2)('aaa') // { ok: true, value: ['a', 'a'], index: 2 } */ export const manyBetween = (parser: Parser, min: number, max: number) => { - return create((input, index = 0) => { - const required = exactly(parser, min)(input, index); + return create((input, index = 0, ctx) => { + const required = exactly(parser, min)(input, index, ctx); if (!required.ok) { return required; } @@ -22,11 +21,12 @@ export const manyBetween = (parser: Parser, min: number, max: number) => { const rest = manyAtMost(parser, max - min)( input, required.index, + ctx, ) as Success; - return merge(required, { + return { ...rest, value: [...required.value, ...rest.value], - }); + }; }); }; diff --git a/src/combinators/manyTill.ts b/src/combinators/manyTill.ts index 1f8d63e..b044004 100644 --- a/src/combinators/manyTill.ts +++ b/src/combinators/manyTill.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,25 +8,19 @@ import { success } from '../core/success'; * Parse zero or more until terminator matches. * * @example - * manyTill(char('a'), char('b'))('aaab') // { ok: true, value: ['a', 'a', 'a'], index: 4, furthest: 2, expected: ["'b'"] } + * manyTill(char('a'), char('b'))('aaab') // { ok: true, value: ['a', 'a', 'a'], index: 4 } */ export const manyTill = (parser: Parser, terminator: Parser) => { - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { const results: T[] = []; let at = index; - let trace: Result = success(null, index); while (true) { - const termResult: Result = merge(trace, terminator(input, at)); + const termResult: Result = terminator(input, at, ctx); if (termResult.ok) { - return merge(termResult, success(results, termResult.index)); + return success(results, termResult.index); } - - trace = termResult; - - const parseResult: Result = merge(trace, parser(input, at)); - trace = parseResult; - + const parseResult: Result = parser(input, at, ctx); if (!parseResult.ok) { return parseResult; } diff --git a/src/combinators/map.test.ts b/src/combinators/map.test.ts index c5f7f3c..a3963a4 100644 --- a/src/combinators/map.test.ts +++ b/src/combinators/map.test.ts @@ -8,7 +8,9 @@ import { map } from './map'; describe('map', () => { it('should transform parser result with single transform', () => { - const parser1 = create<'42'>((_input, index = 0) => success('42', index + 2)); + const parser1 = create<'42'>((_input, index = 0) => + success('42', index + 2), + ); const parser = map(parser1, parseInt); const result = parser('42abc'); @@ -29,7 +31,9 @@ describe('map', () => { }); it('should fail if underlying parser fails', () => { - const parser1 = create((_input, _index = 0) => failure(0)); + const parser1 = create((_input, _index = 0) => + failure(undefined, 0), + ); const parser = map(parser1, (s) => s.toUpperCase()); const result = parser('goodbye'); @@ -53,7 +57,9 @@ describe('map', () => { }); it('should maintain original input consumption', () => { - const parser1 = create<'test'>((_input, index = 0) => success('test', index + 4)); + const parser1 = create<'test'>((_input, index = 0) => + success('test', index + 4), + ); const parser = map(parser1, (s) => s.length); const result = parser('testing'); diff --git a/src/combinators/map.ts b/src/combinators/map.ts index 65dd97d..7480a1d 100644 --- a/src/combinators/map.ts +++ b/src/combinators/map.ts @@ -80,14 +80,14 @@ export function map( * Transform a parsed value through one or more functions. * * @example - * map(digits, (n) => n * 2)('21') // { ok: true, value: 42, index: 2, furthest: -1, expected: [] } + * map(digits, (n) => n * 2)('21') // { ok: true, value: 42, index: 2 } */ export function map( parser: Parser, ...transforms: Array<(value: unknown) => unknown> ) { - return create((input, index = 0) => { - const result = parser(input, index); + return create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) { return result; } @@ -97,10 +97,6 @@ export function map( result.value as unknown, ); - return { - ...success(finalValue, result.index), - furthest: result.furthest, - expected: result.expected, - }; + return success(finalValue, result.index); }); } diff --git a/src/combinators/node.ts b/src/combinators/node.ts index aab6abf..c7bb208 100644 --- a/src/combinators/node.ts +++ b/src/combinators/node.ts @@ -15,8 +15,8 @@ type Node> = { * Create a node from parser fields. * * @example - * node('binop', { left: digits, op: char('+'), right: digits })('1+2') // { ok: true, value: { type: 'binop', left: 1, op: '+', right: 2 }, index: 3, furthest: -1, expected: [] } - * node('number', { value: digits })('123') // { ok: true, value: { type: 'number', value: 123 }, index: 3, furthest: -1, expected: [] } + * node('binop', { left: digits, op: char('+'), right: digits })('1+2') // { ok: true, value: { type: 'binop', left: 1, op: '+', right: 2 }, index: 3 } + * node('number', { value: digits })('123') // { ok: true, value: { type: 'number', value: 123 }, index: 3 } */ export function node< U extends Record, diff --git a/src/combinators/not.ts b/src/combinators/not.ts index 9a88061..f5182e8 100644 --- a/src/combinators/not.ts +++ b/src/combinators/not.ts @@ -1,5 +1,6 @@ import type { Parser } from '../core/parser'; +import { context } from '../core/context'; import { failure } from '../core/failure'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -7,16 +8,17 @@ import { success } from '../core/success'; /** * Succeed if parser fails (without consuming input). * - * The inner failure is deliberately not traced: it is the expected outcome, so - * reporting it would produce a misleading expectation. + * The inner parser runs against an isolated context: its failure is the + * expected outcome here, so reporting it would produce a misleading + * expectation in the final message. * * @example - * not(string('hello'))('world') // { ok: true, value: null, index: 0, furthest: -1, expected: [] } + * not(string('hello'))('world') // { ok: true, value: null, index: 0 } */ export const not = (parser: Parser) => { - return create((input, index = 0) => { - const result = parser(input, index); + return create((input, index = 0, ctx) => { + const result = parser(input, index, context()); - return result.ok ? failure(index) : success(null, index); + return result.ok ? failure(ctx, index) : success(null, index); }); }; diff --git a/src/combinators/nth.test.ts b/src/combinators/nth.test.ts index 4e6c179..1c89cbf 100644 --- a/src/combinators/nth.test.ts +++ b/src/combinators/nth.test.ts @@ -7,7 +7,9 @@ import { nth } from './nth'; describe('nth', () => { it('should return element at specified index', () => { - const parser1 = create((input) => success(['A', 'B', 'C'] as const, input.length)); + const parser1 = create((input) => + success(['A', 'B', 'C'] as const, input.length), + ); { const parser = nth(parser1, 0); @@ -30,7 +32,9 @@ describe('nth', () => { }); it('should return undefined for out-of-bounds index', () => { - const parser1 = create((_input, _index = 0) => success(['A', 'B', 'C'] as const, 3)); + const parser1 = create((_input, _index = 0) => + success(['A', 'B', 'C'] as const, 3), + ); { const parser = nth(parser1, 5); diff --git a/src/combinators/nth.ts b/src/combinators/nth.ts index 748d38e..7a5c83d 100644 --- a/src/combinators/nth.ts +++ b/src/combinators/nth.ts @@ -29,7 +29,7 @@ export type Nth = * Extract the nth element from a parser result array. * * @example - * nth(sequence(char('a'), char('b'), char('c')), 1)('abc') // { ok: true, value: 'b', index: 3, furthest: -1, expected: [] } + * nth(sequence(char('a'), char('b'), char('c')), 1)('abc') // { ok: true, value: 'b', index: 3 } */ export const nth = ( parser: Parser, diff --git a/src/combinators/optional.test.ts b/src/combinators/optional.test.ts index 08ad175..e362a6a 100644 --- a/src/combinators/optional.test.ts +++ b/src/combinators/optional.test.ts @@ -16,7 +16,9 @@ describe('optional', () => { }); it('should return null when parser fails', () => { - const parser1 = create<'A'>((_input, index = 0) => failure(index)); + const parser1 = create<'A'>((_input, index = 0) => + failure(undefined, index), + ); const parser = optional(parser1); const result = parser('ABC'); diff --git a/src/combinators/optional.ts b/src/combinators/optional.ts index 41573af..e8ec11f 100644 --- a/src/combinators/optional.ts +++ b/src/combinators/optional.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -8,13 +7,13 @@ import { success } from '../core/success'; * Make parser optional (return null on failure, without consuming input). * * @example - * optional(string('hello'))('hello') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } - * optional(string('hello'))('world') // { ok: true, value: null, index: 0, furthest: 0, expected: ["'hello'"] } + * optional(string('hello'))('hello') // { ok: true, value: 'hello', index: 5 } + * optional(string('hello'))('world') // { ok: true, value: null, index: 0 } */ export const optional = (parser: Parser) => { - return create((input, index = 0) => { - const result = parser(input, index); + return create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); - return result.ok ? result : merge(result, success(null, index)); + return result.ok ? result : success(null, index); }); }; diff --git a/src/combinators/optionalConsume.test.ts b/src/combinators/optionalConsume.test.ts index 2639fce..35eb624 100644 --- a/src/combinators/optionalConsume.test.ts +++ b/src/combinators/optionalConsume.test.ts @@ -15,7 +15,9 @@ describe('optionalConsume', () => { }); it('should not consume input on failure', () => { - const failureParser = create((_input, index = 0) => failure(index)); + const failureParser = create((_input, index = 0) => + failure(undefined, index), + ); const parser = optionalConsume(failureParser); const result = parser('ABCD'); diff --git a/src/combinators/optionalConsume.ts b/src/combinators/optionalConsume.ts index f6e288e..22caffc 100644 --- a/src/combinators/optionalConsume.ts +++ b/src/combinators/optionalConsume.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -8,14 +7,14 @@ import { success } from '../core/success'; * Consume input if the parser matches, discarding the result. * * @example - * optionalConsume(string('hi'))('hi there') // { ok: true, index: 2, furthest: -1, expected: [] } + * optionalConsume(string('hi'))('hi there') // { ok: true, index: 2 } */ export const optionalConsume = (parser: Parser) => { - return create((input, index = 0) => { - const result = parser(input, index); + return create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); return result.ok - ? merge(result, success(undefined, result.index)) - : merge(result, success(undefined, index)); + ? success(undefined, result.index) + : success(undefined, index); }); }; diff --git a/src/combinators/optionalSeparatedBy.ts b/src/combinators/optionalSeparatedBy.ts index 5486665..5c440f1 100644 --- a/src/combinators/optionalSeparatedBy.ts +++ b/src/combinators/optionalSeparatedBy.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,44 +8,39 @@ import { success } from '../core/success'; * Parse items separated by a separator, allowing empty slots. * * @example - * optionalSeparatedBy(digits, char(','))('1,2') // { ok: true, value: [1, 2], index: 3, furthest: 3, expected: ["','"] } + * optionalSeparatedBy(digits, char(','))('1,2') // { ok: true, value: [1, 2], index: 3 } */ export const optionalSeparatedBy = ( parser: Parser, separator: Parser, ) => { - return create<(T | null)[]>((input, index = 0) => { + return create<(T | null)[]>((input, index = 0, ctx) => { const results: (T | null)[] = []; let at = index; - const leadingSep = separator(input, at); - let trace: Result = leadingSep; + const leadingSep = separator(input, at, ctx); if (leadingSep.ok) { results.push(null); at = leadingSep.index; } - const firstResult: Result = merge(trace, parser(input, at)); - trace = firstResult; - + const firstResult: Result = parser(input, at, ctx); if (firstResult.ok) { results.push(firstResult.value); at = firstResult.index; } else if (!leadingSep.ok) { - return merge(trace, success([], index)); + return success([], index); } while (true) { - const sepResult: Result = merge(trace, separator(input, at)); - trace = sepResult; + const sepResult: Result = separator(input, at, ctx); if (!sepResult.ok) { break; } at = sepResult.index; - const nextResult: Result = merge(trace, parser(input, at)); - trace = nextResult; + const nextResult: Result = parser(input, at, ctx); if (!nextResult.ok) { break; } @@ -55,6 +49,6 @@ export const optionalSeparatedBy = ( at = nextResult.index; } - return merge(trace, success(results, at)); + return success(results, at); }); }; diff --git a/src/combinators/outer.ts b/src/combinators/outer.ts index 08a9548..9c7ba54 100644 --- a/src/combinators/outer.ts +++ b/src/combinators/outer.ts @@ -8,7 +8,7 @@ import { sequence } from './sequence'; * Extract outer values from a sequence of 3 parsers (skip middle). * * @example - * outer(char('('), string('hi'), char(')'))('(hi)') // { ok: true, value: ['(', ')'], index: 4, furthest: -1, expected: [] } + * outer(char('('), string('hi'), char(')'))('(hi)') // { ok: true, value: ['(', ')'], index: 4 } */ export const outer = ( parserA: Parser, diff --git a/src/combinators/padded.ts b/src/combinators/padded.ts index 799c912..c82bbfe 100644 --- a/src/combinators/padded.ts +++ b/src/combinators/padded.ts @@ -10,7 +10,7 @@ import { sequence } from './sequence'; * Parse content surrounded by optional whitespace. * * @example - * padded(string('hi'))(' hi ') // { ok: true, value: 'hi', index: 6, furthest: 6, expected: ['whitespace'] } + * padded(string('hi'))(' hi ') // { ok: true, value: 'hi', index: 6 } */ export const padded = (content: Parser) => { const parser = map( @@ -18,7 +18,7 @@ export const padded = (content: Parser) => { ([, value]) => value, ); - return create((input, index = 0) => { - return parser(input, index); + return create((input, index = 0, ctx) => { + return parser(input, index, ctx); }); }; diff --git a/src/combinators/parenthesized.ts b/src/combinators/parenthesized.ts index 4d00426..c566132 100644 --- a/src/combinators/parenthesized.ts +++ b/src/combinators/parenthesized.ts @@ -7,7 +7,7 @@ import { string } from '../terminals/string'; * Parse content surrounded by parentheses. * * @example - * parenthesized(string('hi'))('(hi)') // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } + * parenthesized(string('hi'))('(hi)') // { ok: true, value: 'hi', index: 4 } */ export const parenthesized = (content: Parser) => { return surrounded(string('('), content, string(')')); diff --git a/src/combinators/peek.ts b/src/combinators/peek.ts index 3648256..35a27a5 100644 --- a/src/combinators/peek.ts +++ b/src/combinators/peek.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -8,12 +7,12 @@ import { success } from '../core/success'; * Look ahead without consuming input. * * @example - * peek(string('hello'))('hello world') // { ok: true, value: 'hello', index: 0, furthest: -1, expected: [] } + * peek(string('hello'))('hello world') // { ok: true, value: 'hello', index: 0 } */ export const peek = (parser: Parser) => { - return create((input, index = 0) => { - const result = parser(input, index); + return create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); - return result.ok ? merge(result, success(result.value, index)) : result; + return result.ok ? success(result.value, index) : result; }); }; diff --git a/src/combinators/postfix.test.ts b/src/combinators/postfix.test.ts index 4bf454a..7feea05 100644 --- a/src/combinators/postfix.test.ts +++ b/src/combinators/postfix.test.ts @@ -9,7 +9,10 @@ import { success } from '../core/success'; import { postfix } from './postfix'; describe('postfix', () => { - const postfixOps: Parser<(value: number) => number> = (input, index = 0) => { + const postfixOps: Parser<(value: number) => number> = ( + input, + index = 0, + ) => { if (input.startsWith('!', index)) { return success((value) => { let result = 1; @@ -23,7 +26,7 @@ describe('postfix', () => { if (input.startsWith('²', index)) { return success((value) => value * value, index + 1); } - return failure(index); + return failure(undefined, index); }; it('should handle atom without postfix operators', () => { diff --git a/src/combinators/postfix.ts b/src/combinators/postfix.ts index b6ac14e..b30d3e4 100644 --- a/src/combinators/postfix.ts +++ b/src/combinators/postfix.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,31 +8,29 @@ import { success } from '../core/success'; * Apply zero or more postfix operators to an atom. * * @example - * postfix(digits, map(char('!'), () => (n) => n * 2))('3!') // { ok: true, value: 6, index: 2, furthest: 2, expected: ["'!'"] } + * postfix(digits, map(char('!'), () => (n) => n * 2))('3!') // { ok: true, value: 6, index: 2 } */ export const postfix = ( atom: Parser, operator: Parser<(value: T) => T>, ) => { - return create((input, index = 0) => { - const atomResult = atom(input, index); + return create((input, index = 0, ctx) => { + const atomResult = atom(input, index, ctx); if (!atomResult.ok) { return atomResult; } let value = atomResult.value; let at = atomResult.index; - let trace: Result = atomResult; while (true) { - const opResult: Result<(value: T) => T> = merge(trace, operator(input, at)); - trace = opResult; + const opResult: Result<(value: T) => T> = operator(input, at, ctx); if (!opResult.ok) break; value = opResult.value(value); at = opResult.index; } - return merge(trace, success(value, at)); + return success(value, at); }); }; diff --git a/src/combinators/prefix.test.ts b/src/combinators/prefix.test.ts index f84c16d..71964be 100644 --- a/src/combinators/prefix.test.ts +++ b/src/combinators/prefix.test.ts @@ -16,7 +16,7 @@ describe('prefix', () => { if (input.startsWith('+', index)) { return success((value) => Math.abs(value), index + 1); } - return failure(index); + return failure(undefined, index); }; it('should handle atom without prefix operators', () => { diff --git a/src/combinators/prefix.ts b/src/combinators/prefix.ts index 5ba490d..dc8f602 100644 --- a/src/combinators/prefix.ts +++ b/src/combinators/prefix.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,27 +8,25 @@ import { success } from '../core/success'; * Apply zero or more prefix operators to an atom. * * @example - * prefix(map(char('-'), () => (n) => -n), digits)('-3') // { ok: true, value: -3, index: 2, furthest: 1, expected: ["'-'"] } + * prefix(map(char('-'), () => (n) => -n), digits)('-3') // { ok: true, value: -3, index: 2 } */ export const prefix = ( operator: Parser<(value: T) => T>, atom: Parser, ) => { - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { const operators: Array<(value: T) => T> = []; let at = index; - let trace: Result = success(null, index); while (true) { - const opResult: Result<(value: T) => T> = merge(trace, operator(input, at)); - trace = opResult; + const opResult: Result<(value: T) => T> = operator(input, at, ctx); if (!opResult.ok) break; operators.push(opResult.value); at = opResult.index; } - const atomResult = merge(trace, atom(input, at)); + const atomResult = atom(input, at, ctx); if (!atomResult.ok) { return atomResult; } @@ -39,6 +36,6 @@ export const prefix = ( atomResult.value, ); - return merge(atomResult, success(finalValue, atomResult.index)); + return success(finalValue, atomResult.index); }); }; diff --git a/src/combinators/pure.ts b/src/combinators/pure.ts index dd9a1dc..22403d7 100644 --- a/src/combinators/pure.ts +++ b/src/combinators/pure.ts @@ -7,7 +7,7 @@ import { success } from '../core/success'; * Always succeed with a value without consuming input. * * @example - * pure(42)('abc') // { ok: true, value: 42, index: 0, furthest: -1, expected: [] } + * pure(42)('abc') // { ok: true, value: 42, index: 0 } */ export const pure = (value: T): Parser => { return create((_input, index = 0) => success(value, index)); diff --git a/src/combinators/quoted.ts b/src/combinators/quoted.ts index f9cdf17..c8001f8 100644 --- a/src/combinators/quoted.ts +++ b/src/combinators/quoted.ts @@ -8,7 +8,7 @@ import { string } from '../terminals/string'; * Parse content surrounded by single or double quotes. * * @example - * quoted(string('hello'))('"hello"') // { ok: true, value: 'hello', index: 7, furthest: -1, expected: [] } + * quoted(string('hello'))('"hello"') // { ok: true, value: 'hello', index: 7 } */ export const quoted = (content: Parser) => { return choice( diff --git a/src/combinators/recover.test.ts b/src/combinators/recover.test.ts index 5633468..c40bf4b 100644 --- a/src/combinators/recover.test.ts +++ b/src/combinators/recover.test.ts @@ -16,7 +16,9 @@ describe('recover', () => { }); it('should not consume input when parser fails', () => { - const parserFail = create<'A'>((_input, index = 0) => failure(index)); + const parserFail = create<'A'>((_input, index = 0) => + failure(undefined, index), + ); const parser = recover(parserFail, 'world'); const result = parser('goodbye'); @@ -31,7 +33,9 @@ describe('recover', () => { }); it('should work with complex default values', () => { - const parser1 = create((_input, index = 0) => failure(index)); + const parser1 = create((_input, index = 0) => + failure(undefined, index), + ); const parser = recover<{ default: boolean; value: number } | string>( parser1, { diff --git a/src/combinators/recover.ts b/src/combinators/recover.ts index 9b30dc9..497fcce 100644 --- a/src/combinators/recover.ts +++ b/src/combinators/recover.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -8,12 +7,12 @@ import { success } from '../core/success'; * Use fallback value when parser fails. * * @example - * recover(string('hello'), 'default')('world') // { ok: true, value: 'default', index: 0, furthest: 0, expected: ["'hello'"] } + * recover(string('hello'), 'default')('world') // { ok: true, value: 'default', index: 0 } */ export const recover = (parser: Parser, fallback: T) => { - return create((input, index = 0) => { - const result = parser(input, index); + return create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); - return result.ok ? result : merge(result, success(fallback, index)); + return result.ok ? result : success(fallback, index); }); }; diff --git a/src/combinators/right.ts b/src/combinators/right.ts index 96ea6e1..53416a2 100644 --- a/src/combinators/right.ts +++ b/src/combinators/right.ts @@ -8,7 +8,7 @@ import { sequence } from './sequence'; * Keep only the right result from a sequence. * * @example - * right(string('hello'), string('world'))('helloworld') // { ok: true, value: 'world', index: 10, furthest: -1, expected: [] } + * right(string('hello'), string('world'))('helloworld') // { ok: true, value: 'world', index: 10 } */ export const right = (parserA: Parser, parserB: Parser) => { return create(map(sequence(parserA, parserB), ([, b]) => b)); diff --git a/src/combinators/separatedBy.ts b/src/combinators/separatedBy.ts index f6ac0b4..f6c8990 100644 --- a/src/combinators/separatedBy.ts +++ b/src/combinators/separatedBy.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -12,27 +11,23 @@ import { success } from '../core/success'; * after it does not, the list ends before the separator. * * @example - * separatedBy(digits, char(','))('1,2,3') // { ok: true, value: [1, 2, 3], index: 5, furthest: 5, expected: ["','"] } + * separatedBy(digits, char(','))('1,2,3') // { ok: true, value: [1, 2, 3], index: 5 } */ export const separatedBy = (parser: Parser, separator: Parser) => { - return create((input, index = 0) => { - const firstResult = parser(input, index); + return create((input, index = 0, ctx) => { + const firstResult = parser(input, index, ctx); if (!firstResult.ok) { - return merge(firstResult, success([], index)); + return success([], index); } const results = [firstResult.value]; let at = firstResult.index; - let trace: Result = firstResult; while (true) { - const sepResult: Result = merge(trace, separator(input, at)); - trace = sepResult; + const sepResult: Result = separator(input, at, ctx); if (!sepResult.ok) break; - const nextResult: Result = merge(trace, parser(input, sepResult.index)); - trace = nextResult; - + const nextResult: Result = parser(input, sepResult.index, ctx); // If separator matched but parser failed, backtrack // Don't consume the separator if (!nextResult.ok) break; @@ -41,6 +36,6 @@ export const separatedBy = (parser: Parser, separator: Parser) => { at = nextResult.index; } - return merge(trace, success(results, at)); + return success(results, at); }); }; diff --git a/src/combinators/separatedBy1.ts b/src/combinators/separatedBy1.ts index 99da120..9ad494c 100644 --- a/src/combinators/separatedBy1.ts +++ b/src/combinators/separatedBy1.ts @@ -1,7 +1,6 @@ import type { Parser } from '../core/parser'; import { failure } from '../core/failure'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Success } from '../core/success'; import { separatedBy } from './separatedBy'; @@ -10,17 +9,18 @@ import { separatedBy } from './separatedBy'; * Parse one or more items separated by a separator. * * @example - * separatedBy1(digits, char(','))('1,2,3') // { ok: true, value: [1, 2, 3], index: 5, furthest: 5, expected: ["','"] } + * separatedBy1(digits, char(','))('1,2,3') // { ok: true, value: [1, 2, 3], index: 5 } */ export const separatedBy1 = (parser: Parser, separator: Parser) => { - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { const result = separatedBy(parser, separator)( input, index, + ctx, ) as Success; if (result.value.length === 0) { - return merge(result, failure(index)); + return failure(ctx, index); } return result; diff --git a/src/combinators/separatedEndBy.ts b/src/combinators/separatedEndBy.ts index 2da5d36..ff66720 100644 --- a/src/combinators/separatedEndBy.ts +++ b/src/combinators/separatedEndBy.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success, type Success } from '../core/success'; import { separatedBy } from './separatedBy'; @@ -9,19 +8,20 @@ import { separatedBy } from './separatedBy'; * Parse zero or more items separated by a separator, allowing a trailing one. * * @example - * separatedEndBy(digits, char(','))('1,2,3,') // { ok: true, value: [1, 2, 3], index: 6, furthest: 6, expected: ['digit'] } + * separatedEndBy(digits, char(','))('1,2,3,') // { ok: true, value: [1, 2, 3], index: 6 } */ export const separatedEndBy = (parser: Parser, separator: Parser) => { - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { const result = separatedBy(parser, separator)( input, index, + ctx, ) as Success; - const sepResult = merge(result, separator(input, result.index)); + const sepResult = separator(input, result.index, ctx); - return merge( - sepResult, - success(result.value, sepResult.ok ? sepResult.index : result.index), + return success( + result.value, + sepResult.ok ? sepResult.index : result.index, ); }); }; diff --git a/src/combinators/separatedEndBy1.ts b/src/combinators/separatedEndBy1.ts index 16db2f3..3f836d8 100644 --- a/src/combinators/separatedEndBy1.ts +++ b/src/combinators/separatedEndBy1.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { separatedBy1 } from './separatedBy1'; @@ -9,20 +8,20 @@ import { separatedBy1 } from './separatedBy1'; * Parse one or more items separated by a separator, allowing a trailing one. * * @example - * separatedEndBy1(digits, char(','))('1,2,3,') // { ok: true, value: [1, 2, 3], index: 6, furthest: 6, expected: ['digit'] } + * separatedEndBy1(digits, char(','))('1,2,3,') // { ok: true, value: [1, 2, 3], index: 6 } */ export const separatedEndBy1 = (parser: Parser, separator: Parser) => { - return create((input, index = 0) => { - const result = separatedBy1(parser, separator)(input, index); + return create((input, index = 0, ctx) => { + const result = separatedBy1(parser, separator)(input, index, ctx); if (!result.ok) { return result; } - const sepResult = merge(result, separator(input, result.index)); + const sepResult = separator(input, result.index, ctx); - return merge( - sepResult, - success(result.value, sepResult.ok ? sepResult.index : result.index), + return success( + result.value, + sepResult.ok ? sepResult.index : result.index, ); }); }; diff --git a/src/combinators/separatedUntil.ts b/src/combinators/separatedUntil.ts index 3efc2ce..c767dec 100644 --- a/src/combinators/separatedUntil.ts +++ b/src/combinators/separatedUntil.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success, type Success } from '../core/success'; import { separatedBy } from './separatedBy'; @@ -9,22 +8,21 @@ import { separatedBy } from './separatedBy'; * Parse items separated by a separator, up to a terminator. * * @example - * separatedUntil(digits, char(','), char(';'))('1,2,3;') // { ok: true, value: [1, 2, 3], index: 6, furthest: 5, expected: ["','"] } + * separatedUntil(digits, char(','), char(';'))('1,2,3;') // { ok: true, value: [1, 2, 3], index: 6 } */ export const separatedUntil = ( parser: Parser, separator: Parser, terminator: Parser, ) => { - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { const items = separatedBy(parser, separator)( input, index, + ctx, ) as Success; - const result = merge(items, terminator(input, items.index)); + const result = terminator(input, items.index, ctx); - return result.ok - ? merge(result, success(items.value, result.index)) - : result; + return result.ok ? success(items.value, result.index) : result; }); }; diff --git a/src/combinators/sequence.test.ts b/src/combinators/sequence.test.ts index 097bb84..9a13f84 100644 --- a/src/combinators/sequence.test.ts +++ b/src/combinators/sequence.test.ts @@ -31,7 +31,9 @@ describe('sequence', () => { }); it('should fail if first parser fails', () => { - const failureParser = create((_input, index = 0) => failure(index)); + const failureParser = create((_input, index = 0) => + failure(undefined, index), + ); const parser2 = createTestParser('B'); const parser = sequence(failureParser, parser2); const result = parser('xxx'); @@ -41,7 +43,9 @@ describe('sequence', () => { it('should fail if middle parser fails', () => { const parser1 = createTestParser('A'); - const failureParser = create((_input, index = 0) => failure(index)); + const failureParser = create((_input, index = 0) => + failure(undefined, index), + ); const parser3 = createTestParser('C'); const parser = sequence(parser1, failureParser, parser3); const result = parser('xxx'); @@ -57,7 +61,9 @@ describe('sequence', () => { }); it('should preserve parser result types', () => { - const strParser = create<'text'>((input) => success('text', input.length)); + const strParser = create<'text'>((input) => + success('text', input.length), + ); const numParser = create<42>((input) => success(42, input.length)); const parser = sequence(strParser, numParser); const result = parser('xx'); diff --git a/src/combinators/sequence.ts b/src/combinators/sequence.ts index be80972..f9d9d48 100644 --- a/src/combinators/sequence.ts +++ b/src/combinators/sequence.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,20 +8,17 @@ import { success } from '../core/success'; * Parse a sequence of parsers and return all results as an array. * * @example - * sequence(char('a'), char('b'), char('c'))('abc') // { ok: true, value: ['a', 'b', 'c'], index: 3, furthest: -1, expected: [] } + * sequence(char('a'), char('b'), char('c'))('abc') // { ok: true, value: ['a', 'b', 'c'], index: 3 } */ export const sequence = ( ...parsers: { [K in keyof T]: Parser } ) => { - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { const results: unknown[] = []; let at = index; - let trace: Result = success(null, index); for (const parser of parsers) { - const result: Result = merge(trace, parser(input, at)); - trace = result; - + const result: Result = parser(input, at, ctx); if (!result.ok) { return result; } @@ -31,6 +27,6 @@ export const sequence = ( at = result.index; } - return merge(trace, success(results as unknown as T, at)); + return success(results as unknown as T, at); }); }; diff --git a/src/combinators/skip.ts b/src/combinators/skip.ts index 619a795..2ce6446 100644 --- a/src/combinators/skip.ts +++ b/src/combinators/skip.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,17 +8,14 @@ import { success } from '../core/success'; * Skip a parser n times. * * @example - * skip(char('a'), 2)('aabc') // { ok: true, value: null, index: 2, furthest: -1, expected: [] } + * skip(char('a'), 2)('aabc') // { ok: true, value: null, index: 2 } */ export const skip = (parser: Parser, count: number) => { - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { let at = index; - let trace: Result = success(null, index); for (let i = 0; i < count; i++) { - const result: Result = merge(trace, parser(input, at)); - trace = result; - + const result: Result = parser(input, at, ctx); if (!result.ok) { return result; } @@ -27,6 +23,6 @@ export const skip = (parser: Parser, count: number) => { at = result.index; } - return merge(trace, success(null, at)); + return success(null, at); }); }; diff --git a/src/combinators/skipMany.ts b/src/combinators/skipMany.ts index 5d3c5cd..7b238e4 100644 --- a/src/combinators/skipMany.ts +++ b/src/combinators/skipMany.ts @@ -8,7 +8,7 @@ import { map } from './map'; * Skip zero or more occurrences (never fails, returns null). * * @example - * skipMany(char('a'))('aaabc') // { ok: true, value: null, index: 3, furthest: 3, expected: ["'a'"] } + * skipMany(char('a'))('aaabc') // { ok: true, value: null, index: 3 } */ export const skipMany = (parser: Parser) => { return create(map(many(parser), () => null)); diff --git a/src/combinators/skipMany1.ts b/src/combinators/skipMany1.ts index 6ae8910..4aff71e 100644 --- a/src/combinators/skipMany1.ts +++ b/src/combinators/skipMany1.ts @@ -8,7 +8,7 @@ import { map } from './map'; * Skip one or more occurrences (fails if no matches). * * @example - * skipMany1(char('a'))('aaabc') // { ok: true, value: null, index: 3, furthest: 3, expected: ["'a'"] } + * skipMany1(char('a'))('aaabc') // { ok: true, value: null, index: 3 } */ export const skipMany1 = (parser: Parser) => { return create(map(many1(parser), () => null)); diff --git a/src/combinators/surrounded.ts b/src/combinators/surrounded.ts index 857a904..d0914b8 100644 --- a/src/combinators/surrounded.ts +++ b/src/combinators/surrounded.ts @@ -7,8 +7,8 @@ import { inner } from './inner'; * Parse content surrounded by delimiters. * * @example - * surrounded(char('['), string('hi'), char(']'))('[hi]') // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } - * surrounded(char('a'), char('b'), char('c'))('abc') // { ok: true, value: 'b', index: 3, furthest: -1, expected: [] } + * surrounded(char('['), string('hi'), char(']'))('[hi]') // { ok: true, value: 'hi', index: 4 } + * surrounded(char('a'), char('b'), char('c'))('abc') // { ok: true, value: 'b', index: 3 } */ export const surrounded = ( first: Parser, diff --git a/src/combinators/unless.ts b/src/combinators/unless.ts index fa10f58..87e4155 100644 --- a/src/combinators/unless.ts +++ b/src/combinators/unless.ts @@ -7,11 +7,11 @@ import { success } from '../core/success'; * Parse unless condition is true (inverse of guard). * * @example - * unless(false, string('hello'))('hello') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } - * unless(true, string('hello'))('hello') // { ok: true, value: null, index: 0, furthest: -1, expected: [] } + * unless(false, string('hello'))('hello') // { ok: true, value: 'hello', index: 5 } + * unless(true, string('hello'))('hello') // { ok: true, value: null, index: 0 } */ export const unless = (condition: boolean, parser: Parser) => { - return create((input, index = 0) => { - return !condition ? parser(input, index) : success(null, index); + return create((input, index = 0, ctx) => { + return !condition ? parser(input, index, ctx) : success(null, index); }); }; diff --git a/src/combinators/until.test.ts b/src/combinators/until.test.ts index d974448..0189150 100644 --- a/src/combinators/until.test.ts +++ b/src/combinators/until.test.ts @@ -21,7 +21,9 @@ describe('until', () => { }); it('should return empty array when terminator is at start', () => { - const failureParser = create((_input, index = 0) => failure(index)); + const failureParser = create((_input, index = 0) => + failure(undefined, index), + ); const parser = until(failureParser, bParser); const result = parser('BAAAA'); diff --git a/src/combinators/until.ts b/src/combinators/until.ts index 41d48b5..a5f4efc 100644 --- a/src/combinators/until.ts +++ b/src/combinators/until.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,25 +8,19 @@ import { success } from '../core/success'; * Parse zero or more until terminator matches, leaving the terminator unconsumed. * * @example - * until(char('a'), char('b'))('aaab') // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 2, expected: ["'b'"] } + * until(char('a'), char('b'))('aaab') // { ok: true, value: ['a', 'a', 'a'], index: 3 } */ export const until = (parser: Parser, terminator: Parser) => { - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { const results: T[] = []; let at = index; - let trace: Result = success(null, index); while (true) { - const termResult: Result = merge(trace, terminator(input, at)); + const termResult: Result = terminator(input, at, ctx); if (termResult.ok) { break; } - - trace = termResult; - - const parseResult: Result = merge(trace, parser(input, at)); - trace = parseResult; - + const parseResult: Result = parser(input, at, ctx); if (!parseResult.ok) { return parseResult; } @@ -36,6 +29,6 @@ export const until = (parser: Parser, terminator: Parser) => { at = parseResult.index; } - return merge(trace, success(results, at)); + return success(results, at); }); }; diff --git a/src/combinators/validate.ts b/src/combinators/validate.ts index f56b4d9..002c026 100644 --- a/src/combinators/validate.ts +++ b/src/combinators/validate.ts @@ -1,27 +1,24 @@ import type { Parser } from '../core/parser'; import { failure } from '../core/failure'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; /** * Validate parsed value with a predicate. * * @example - * validate(digit, (n) => n > 5)('7') // { ok: true, value: 7, index: 1, furthest: -1, expected: [] } - * validate(digit, (n) => n > 5)('3') // { ok: false, index: 0, furthest: 0, expected: ['valid value'] } + * validate(digit, (n) => n > 5)('7') // { ok: true, value: 7, index: 1 } + * validate(digit, (n) => n > 5)('3') // { ok: false, index: 0, expected: ['valid value'] } */ export const validate = ( parser: Parser, predicate: (value: T) => boolean, expected = 'valid value', ) => { - return create((input, index = 0) => { - const result = parser(input, index); + return create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) return result; - return predicate(result.value) - ? result - : merge(result, failure(index, expected)); + return predicate(result.value) ? result : failure(ctx, index, expected); }); }; diff --git a/src/combinators/value.ts b/src/combinators/value.ts index e7c176b..87c1cc4 100644 --- a/src/combinators/value.ts +++ b/src/combinators/value.ts @@ -6,8 +6,8 @@ import { map } from './map'; * Replace parsed value with a constant. * * @example - * value(string('true'), true)('true') // { ok: true, value: true, index: 4, furthest: -1, expected: [] } - * value(string('null'), null)('null') // { ok: true, value: null, index: 4, furthest: -1, expected: [] } + * value(string('true'), true)('true') // { ok: true, value: true, index: 4 } + * value(string('null'), null)('null') // { ok: true, value: null, index: 4 } */ export const value = (parser: Parser, constant: U): Parser => { return map(parser, () => constant); diff --git a/src/combinators/when.test.ts b/src/combinators/when.test.ts index ad44440..706392d 100644 --- a/src/combinators/when.test.ts +++ b/src/combinators/when.test.ts @@ -18,7 +18,9 @@ const boolParser = create((input, index = 0) => { return success(false, index); }); -const failingCondition = create((_input, index = 0) => failure(index)); +const failingCondition = create((_input, index = 0) => + failure(undefined, index), +); describe('when', () => { const thenP = createTestParser('yes'); diff --git a/src/combinators/when.ts b/src/combinators/when.ts index b4455cd..1e9115a 100644 --- a/src/combinators/when.ts +++ b/src/combinators/when.ts @@ -1,22 +1,21 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; /** * Branch on a boolean parser result. * * @example - * when(flag(char('*')), pure('many'), pure('one'))('*rest') // { ok: true, value: 'many', index: 1, furthest: -1, expected: [] } - * when(flag(char('*')), pure('many'), pure('one'))('abc') // { ok: true, value: 'one', index: 0, furthest: 0, expected: ["'*'"] } + * when(flag(char('*')), pure('many'), pure('one'))('*rest') // { ok: true, value: 'many', index: 1 } + * when(flag(char('*')), pure('many'), pure('one'))('abc') // { ok: true, value: 'one', index: 0 } */ export const when = ( condition: Parser, thenParser: Parser, elseParser: Parser, ): Parser => { - return create((input, index = 0) => { - const result = condition(input, index); + return create((input, index = 0, ctx) => { + const result = condition(input, index, ctx); if (!result.ok) { return result; @@ -24,6 +23,6 @@ export const when = ( const branch = result.value ? thenParser : elseParser; - return merge(result, branch(input, result.index)); + return branch(input, result.index, ctx); }); }; diff --git a/src/core/context.test.ts b/src/core/context.test.ts new file mode 100644 index 0000000..716064c --- /dev/null +++ b/src/core/context.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { context, record, union } from './context'; + +describe('context', () => { + it('should start with nothing recorded', () => { + expect(context()).toEqual({ furthest: -1, expected: [] }); + }); +}); + +describe('record', () => { + it('should keep the furthest failure', () => { + const ctx = context(); + record(ctx, 3, ['digit']); + record(ctx, 7, ['letter']); + record(ctx, 5, ['comma']); + + expect(ctx).toEqual({ furthest: 7, expected: ['letter'] }); + }); + + it('should union expectations recorded at the same offset', () => { + const ctx = context(); + record(ctx, 4, ['digit']); + record(ctx, 4, ['letter']); + + expect(ctx.expected).toEqual(['digit', 'letter']); + }); + + it('should not repeat an expectation', () => { + const ctx = context(); + record(ctx, 4, ['digit']); + record(ctx, 4, ['digit']); + + expect(ctx.expected).toEqual(['digit']); + }); + + it('should leave the caller array untouched when unioning', () => { + const ctx = context(); + const first = ['digit']; + record(ctx, 4, first); + record(ctx, 4, ['letter']); + + expect(first).toEqual(['digit']); + }); + + it('should do nothing without a context', () => { + expect(() => record(undefined, 4, ['digit'])).not.toThrow(); + }); +}); + +describe('union', () => { + it('should return the first array when it already covers the second', () => { + const a = ['digit', 'letter']; + + expect(union(a, ['digit'])).toBe(a); + }); + + it('should combine disjoint sets', () => { + expect(union(['digit'], ['letter'])).toEqual(['digit', 'letter']); + }); +}); diff --git a/src/core/context.ts b/src/core/context.ts new file mode 100644 index 0000000..4eb00c0 --- /dev/null +++ b/src/core/context.ts @@ -0,0 +1,78 @@ +const NONE: readonly string[] = []; + +/** + * Per-parse bookkeeping for error messages: the furthest offset any branch + * reached and what was wanted there. + * + * A parse can succeed while leaving input unconsumed, and a branch that failed + * deep in the input can be backtracked over entirely. The context is what lets + * the final error point at the real problem instead of wherever parsing + * happened to stop. It only ever moves forward, so backtracking cannot lose it. + */ +export type Context = { furthest: number; expected: readonly string[] }; + +/** + * Creates a fresh context. {@link run} and {@link parse} make one per call. + * + * @example + * context() // { furthest: -1, expected: [] } + */ +export const context = (): Context => ({ furthest: -1, expected: NONE }); + +/** + * Adds the entries of `b` to `a`, returning `a` itself when it already covers + * them. Callers rely on that identity to skip allocating. + * + * @example + * union(['digit'], ['digit']) // ['digit'] + */ +export const union = ( + a: readonly string[], + b: readonly string[], +): readonly string[] => { + if (b.length === 0 || a === b) return a; + if (a.length === 0) return b; + + let extra: string[] | null = null; + for (const item of b) { + if (!a.includes(item)) { + (extra ??= []).push(item); + } + } + + return extra ? [...a, ...extra] : a; +}; + +/** + * Notes what was expected at an offset, keeping only the furthest one seen. + * + * Replaces `expected` rather than mutating it, so a parser may pass the same + * array every time without it being altered underneath. + * + * @example + * const ctx = context(); + * record(ctx, 3, ['digit']); + * ctx // { furthest: 3, expected: ['digit'] } + */ +export const record = ( + ctx: Context | undefined, + index: number, + expected: readonly string[], +): void => { + if (ctx === undefined) { + return; + } + + if (index > ctx.furthest) { + ctx.furthest = index; + ctx.expected = expected; + + return; + } + + if (index < ctx.furthest || ctx.expected === expected) { + return; + } + + ctx.expected = union(ctx.expected, expected); +}; diff --git a/src/core/failure.test.ts b/src/core/failure.test.ts index b92828b..b755bef 100644 --- a/src/core/failure.test.ts +++ b/src/core/failure.test.ts @@ -1,22 +1,25 @@ import { describe, expect, it } from 'vitest'; +import { context } from './context'; import { failure } from './failure'; describe('failure', () => { it('should create failed parse result at an offset', () => { - const result = failure(3); + const result = failure(undefined, 3); - expect(result).toEqual({ - ok: false, - index: 3, - furthest: 3, - expected: [], - }); + expect(result).toEqual({ ok: false, index: 3, expected: [] }); }); it('should record what was expected at that offset', () => { - const result = failure(3, 'digit', 'letter'); + const result = failure(undefined, 3, 'digit', 'letter'); expect(result.expected).toEqual(['digit', 'letter']); }); + + it('should note the failure in the parse context', () => { + const ctx = context(); + failure(ctx, 3, 'digit'); + + expect(ctx).toEqual({ furthest: 3, expected: ['digit'] }); + }); }); diff --git a/src/core/failure.ts b/src/core/failure.ts index 40479ee..1adf72b 100644 --- a/src/core/failure.ts +++ b/src/core/failure.ts @@ -1,22 +1,41 @@ -export type Failure = { - ok: false; - index: number; - furthest: number; - expected: readonly string[]; -}; +import { type Context, record } from './context'; + +export type Failure = { ok: false; index: number; expected: readonly string[] }; /** - * Creates a failed result at an offset, describing what was expected there. + * Creates a failed result at an offset and notes it in the parse context. * - * Expectations are phrased as nouns ('digit', "'{'") and are unioned by - * {@link choice}, so a failing alternation reports every branch it tried. + * Expectations are phrased as nouns ('digit', "'{'"). Pass the context through + * from the parser so the message can name this position even if the branch is + * later backtracked over. * * @example - * failure(3, 'digit') // { ok: false, index: 3, furthest: 3, expected: ['digit'] } + * failure(undefined, 3, 'digit') // { ok: false, index: 3, expected: ['digit'] } */ -export const failure = (index: number, ...expected: string[]): Failure => ({ - ok: false, - index, - furthest: index, - expected, -}); +export const failure = ( + ctx: Context | undefined, + index: number, + ...expected: string[] +): Failure => { + record(ctx, index, expected); + + return { ok: false, index, expected }; +}; + +/** + * Creates a failed result from an expectation list that is already allocated, + * avoiding a fresh array on every failure. + * + * @example + * const expected = ['digit']; + * reject(undefined, 3, expected) // { ok: false, index: 3, expected: ['digit'] } + */ +export const reject = ( + ctx: Context | undefined, + index: number, + expected: readonly string[], +): Failure => { + record(ctx, index, expected); + + return { ok: false, index, expected }; +}; diff --git a/src/core/grammar.ts b/src/core/grammar.ts index 1313937..8be3801 100644 --- a/src/core/grammar.ts +++ b/src/core/grammar.ts @@ -8,10 +8,13 @@ export type Grammar> = { * Wraps a rule so re-entering it at an offset it is already parsing throws a * named error instead of overflowing the stack. */ -const guardLeftRecursion = (name: string, thunk: () => Parser): Parser => { +const guardLeftRecursion = ( + name: string, + thunk: () => Parser, +): Parser => { const active: number[] = []; - return (input, index = 0) => { + return (input, index = 0, ctx) => { if (active.includes(index)) { throw new Error( `Left recursion detected in grammar rule "${name}" at offset ${index}. ` + @@ -22,7 +25,7 @@ const guardLeftRecursion = (name: string, thunk: () => Parser): Parser active.push(index); try { - return thunk()(input, index); + return thunk()(input, index, ctx); } finally { active.pop(); } diff --git a/src/core/index.ts b/src/core/index.ts index a846eda..cdee1c5 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,3 +1,4 @@ +export * from './context'; export * from './error'; export * from './failure'; export * from './format'; @@ -7,7 +8,6 @@ export * from './lazy'; export * from './locate'; export * from './match'; export * from './memoize'; -export * from './merge'; export * from './parse'; export * from './parser'; export * from './result'; diff --git a/src/core/label.ts b/src/core/label.ts index e0d3df6..5f38299 100644 --- a/src/core/label.ts +++ b/src/core/label.ts @@ -1,24 +1,35 @@ import type { Parser } from './parser'; -import { failure } from './failure'; +import { context, record } from './context'; import { create } from './parser'; /** * Replaces the expectations of a parser with a single description. * * Only applies when the parser failed without consuming input: once a branch - * has committed, its own deeper error is more useful than the label. + * has committed, its own deeper error is more useful than the label. The + * labelled parser runs against an isolated context so that its internals can + * be suppressed rather than leaking into the message. * * @example - * label(char('x'), 'letter x')('') // { ok: false, index: 0, furthest: 0, expected: ['letter x'] } + * label(char('x'), 'letter x')('') // { ok: false, index: 0, expected: ['letter x'] } */ export const label = (parser: Parser, expected: string): Parser => { - return create((input, index = 0) => { - const result = parser(input, index); + return create((input, index = 0, ctx) => { + const inner = context(); + const result = parser(input, index, inner); + if (result.ok || result.index > index) { + if (inner.furthest >= 0) { + record(ctx, inner.furthest, inner.expected); + } + return result; } - return failure(index, expected); + const described = [expected]; + record(ctx, index, described); + + return { ok: false, index, expected: described }; }); }; diff --git a/src/core/lazy.test.ts b/src/core/lazy.test.ts index 4175030..0eb11db 100644 --- a/src/core/lazy.test.ts +++ b/src/core/lazy.test.ts @@ -36,7 +36,7 @@ describe('lazy', () => { if (input[index] === expected) { return success(expected, index + 1); } - return failure(index, expected); + return failure(undefined, index, expected); }); }; @@ -45,7 +45,7 @@ describe('lazy', () => { const recursiveCase = create((input, index = 0) => { if (input[index] !== '(') { - return failure(index, "'('"); + return failure(undefined, index, "'('"); } const innerResult = parent(input, index + 1); @@ -55,7 +55,7 @@ describe('lazy', () => { const after = innerResult.index; if (input[after] !== ')') { - return failure(after, "')'"); + return failure(undefined, after, "')'"); } return success(innerResult.value, after + 1); @@ -92,7 +92,9 @@ describe('lazy', () => { }); it('should handle parser that fails', () => { - const failureParser = create((_input, index = 0) => failure(index)); + const failureParser = create((_input, index = 0) => + failure(undefined, index), + ); const result = failureParser('goodbye'); assertFailure(result); diff --git a/src/core/lazy.ts b/src/core/lazy.ts index 308e525..6b0e9d2 100644 --- a/src/core/lazy.ts +++ b/src/core/lazy.ts @@ -6,8 +6,8 @@ import { create } from './parser'; * Defers parser creation, useful for recursive grammars. * * @example - * lazy(() => char('a'))('abc') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } + * lazy(() => char('a'))('abc') // { ok: true, value: 'a', index: 1 } */ export const lazy = (thunk: () => Parser) => { - return create((input, index = 0) => thunk()(input, index)); + return create((input, index = 0, ctx) => thunk()(input, index, ctx)); }; diff --git a/src/core/match.test.ts b/src/core/match.test.ts index 50d8c8b..2dd62cf 100644 --- a/src/core/match.test.ts +++ b/src/core/match.test.ts @@ -23,7 +23,7 @@ describe('match', () => { }); it('should call failure branch with offset and expectations', () => { - const result = failure(2, 'digit'); + const result = failure(undefined, 2, 'digit'); let capturedIndex = -1; let capturedExpected: readonly string[] = []; @@ -40,7 +40,7 @@ describe('match', () => { }); it('should call failure branch with no expectations when none were recorded', () => { - const result = failure(0); + const result = failure(undefined, 0); let capturedExpected: readonly string[] = ['not called']; match(result, { diff --git a/src/core/memoize.ts b/src/core/memoize.ts index addfc40..6590fb9 100644 --- a/src/core/memoize.ts +++ b/src/core/memoize.ts @@ -1,21 +1,30 @@ +import { type Context, context, record } from './context'; import { type Parser } from './parser'; import { type Result } from './result'; +type Entry = { + result: Result; + furthest: number; + expected: readonly string[]; +}; + /** * Memoizes a parser so each offset is parsed at most once (packrat caching). * * Keyed by offset rather than by the remaining input, so entries are cheap and - * the cache is dropped as soon as a different input is parsed. + * the cache is dropped as soon as a different input is parsed. Each entry also + * remembers what the parse expected, so a cache hit still contributes to the + * error message rather than silently weakening it. * * @example * const memoDigits = memoize(digits); - * memoDigits('123') // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } + * memoDigits('123') // { ok: true, value: 123, index: 3 } */ export const memoize = (parser: Parser): Parser => { let source: string | null = null; - let cache = new Map>(); + let cache = new Map>(); - return (input, index = 0) => { + return (input, index = 0, ctx?: Context) => { if (input !== source) { source = input; cache = new Map(); @@ -23,11 +32,24 @@ export const memoize = (parser: Parser): Parser => { const cached = cache.get(index); if (cached !== undefined) { - return cached; + if (cached.furthest >= 0) { + record(ctx, cached.furthest, cached.expected); + } + + return cached.result; } - const result = parser(input, index); - cache.set(index, result); + const probe = context(); + const result = parser(input, index, probe); + cache.set(index, { + result, + furthest: probe.furthest, + expected: probe.expected, + }); + + if (probe.furthest >= 0) { + record(ctx, probe.furthest, probe.expected); + } return result; }; diff --git a/src/core/merge.ts b/src/core/merge.ts deleted file mode 100644 index b432709..0000000 --- a/src/core/merge.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { type Result } from './result'; - -/** - * Adds the expectations of `b` to `a`, returning `a` itself when it already - * covers them. Callers rely on that identity to skip allocating. - */ -const union = ( - a: readonly string[], - b: readonly string[], -): readonly string[] => { - if (b.length === 0 || a === b) return a; - if (a.length === 0) return b; - - let extra: string[] | null = null; - for (const item of b) { - if (!a.includes(item)) { - (extra ??= []).push(item); - } - } - - return extra ? [...a, ...extra] : a; -}; - -/** - * Carries the furthest-failure trace of an earlier result into a later one. - * - * Every combinator that runs more than one parser threads its intermediate - * results through `merge`, so an expectation recorded deep inside a branch that - * was later backtracked over still surfaces in the final error. Without it, - * `a=1,b=x` would only report "unconsumed input" instead of "expected digit". - * - * Returns `result` untouched whenever the trace adds nothing, which is the - * common case and keeps the hot path allocation-free. - * - * @example - * merge(failure(7, 'digit'), success('ok', 3)) // { ok: true, value: 'ok', index: 3, furthest: 7, expected: ['digit'] } - */ -export const merge = ( - trace: Result, - result: Result, -): Result => { - const { furthest } = trace; - // `furthest >= 0` also rejects a trace that carries no bookkeeping at all, - // so a hand-built result cannot crash the merge. - if (!(furthest >= 0) || furthest < result.furthest) { - return result; - } - - if (furthest > result.furthest) { - const { expected } = trace; - - return result.ok - ? { ...result, furthest, expected } - : { ...result, index: furthest, furthest, expected }; - } - - const expected = union(result.expected, trace.expected); - - return expected === result.expected ? result : { ...result, expected }; -}; diff --git a/src/core/parse.ts b/src/core/parse.ts index 3ae2527..8f0ab24 100644 --- a/src/core/parse.ts +++ b/src/core/parse.ts @@ -1,5 +1,6 @@ import type { Parser } from './parser'; +import { context } from './context'; import { format } from './format'; import { locate } from './locate'; import { diagnose } from './run'; @@ -22,11 +23,12 @@ export type ParseResult = * offset, line, column, expectations and a formatted message. * * @example - * parse(digits, '12x').message // "1:3 expected end of input, found 'x'\n 1 | 12x\n | ^" + * parse(digits, '12x').ok // false */ export const parse = (parser: Parser, input: string): ParseResult => { - const result = parser(input, 0); - const problem = diagnose(input, result); + const ctx = context(); + const result = parser(input, 0, ctx); + const problem = diagnose(input, result, ctx); if (!problem) { return { ok: true, value: (result as { value: T }).value }; diff --git a/src/core/parser.test.ts b/src/core/parser.test.ts index 95d2ba5..d5bcfdf 100644 --- a/src/core/parser.test.ts +++ b/src/core/parser.test.ts @@ -14,7 +14,7 @@ describe('create', () => { }); it('should return null when the parser function fails', () => { - const parser = create((_input, index = 0) => failure(index)); + const parser = create((_input, index = 0) => failure(undefined, index)); const result = parser('hello'); assertFailure(result); diff --git a/src/core/parser.ts b/src/core/parser.ts index 2b191d0..6104146 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -1,16 +1,21 @@ +import { type Context } from './context'; import { type Result } from './result'; -export type Parser = (input: string, index?: number) => Result; +export type Parser = ( + input: string, + index?: number, + ctx?: Context, +) => Result; /** * Creates a parser from a parser function. * - * A parser reads `input` starting at `index` and never slices it — results - * report the offset they reached, so positions stay meaningful all the way up - * to the top-level error message. + * A parser reads `input` from `index` and never slices it. The third argument + * is the parse context — pass it on to any parser you call, so expectations + * recorded inside your parser reach the final error message. * * @example - * create((input, index = 0) => success('parsed', index + 6))('hello world') // { ok: true, value: 'parsed', index: 6, furthest: -1, expected: [] } + * create((input, index = 0) => success('parsed', index + 6))('hello world') // { ok: true, value: 'parsed', index: 6 } */ export const create = (parserFn: Parser): Parser => { return parserFn; diff --git a/src/core/propagation.test.ts b/src/core/propagation.test.ts new file mode 100644 index 0000000..58fecf5 --- /dev/null +++ b/src/core/propagation.test.ts @@ -0,0 +1,109 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { choice } from '../combinators/choice'; +import { many } from '../combinators/many'; +import { not } from '../combinators/not'; +import { optional } from '../combinators/optional'; +import { separatedBy } from '../combinators/separatedBy'; +import { sequence } from '../combinators/sequence'; +import { char } from '../terminals/char'; +import { string } from '../terminals/string'; +import { context } from './context'; +import { label } from './label'; +import { memoize } from './memoize'; +import { type Parser } from './parser'; + +/** + * A combinator that calls a parser without passing the context on will + * silently lose expectations: the parse still succeeds or fails the same way, + * only the error message gets worse. Nothing else would catch that, so the + * call sites are checked directly. + */ +describe('context is forwarded at every parser call site', () => { + const roots = ['combinators', 'primitives', 'terminals']; + const files = roots.flatMap((dir) => { + const full = path.join(process.cwd(), 'src', dir); + + return fs + .readdirSync(full) + .filter( + (f) => + f.endsWith('.ts') && + !f.endsWith('.test.ts') && + f !== 'index.ts', + ) + .map((f) => path.join('src', dir, f)); + }); + + // `foo(input, bar)` / `foo(\n input,\n bar,\n)` — a parser being invoked. + const CALL = /\(\s*input,\s*[^()]*?\)/gs; + + it.each(files)('%s', (file) => { + const source = fs + .readFileSync(path.join(process.cwd(), file), 'utf-8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*/g, ''); + + const offenders = [...source.matchAll(CALL)] + .map((m) => m[0]) + .filter((call) => !/\bctx\b/.test(call)) + // parser *declarations* legitimately omit it when unused + .filter((call) => !/index = 0/.test(call)); + + expect(offenders, `parser call without ctx in ${file}`).toEqual([]); + }); +}); + +describe('expectations survive backtracking', () => { + // Succeeds on 'aa', then wants '!' at offset 2 — deeper than any caller reaches. + const deep = sequence(string('aa'), char('!')); + const furthestOf = (parser: Parser, input: string) => { + const ctx = context(); + parser(input, 0, ctx); + + return ctx; + }; + + const cases: [string, Parser][] = [ + ['optional', optional(deep)], + ['many', many(deep)], + ['choice', choice(deep, string('zz'))], + ['separatedBy', separatedBy(deep, char(','))], + ['memoize', memoize(deep)], + ]; + + it.each(cases)( + '%s reports the furthest failure it backtracked over', + (_name, parser) => { + const ctx = furthestOf(parser, 'aaX'); + + expect(ctx.furthest).toBe(2); + expect(ctx.expected).toContain("'!'"); + }, + ); + + it('memoize replays its trace on a cache hit', () => { + const memo = memoize(deep); + memo('aaX', 0, context()); // prime the cache + + const ctx = context(); + memo('aaX', 0, ctx); + + expect(ctx.furthest).toBe(2); + expect(ctx.expected).toContain("'!'"); + }); + + it('label hides the internals it replaces', () => { + const ctx = furthestOf(label(string('hello'), 'a greeting'), 'x'); + + expect(ctx.expected).toEqual(['a greeting']); + }); + + it('not does not report the failure it wanted', () => { + const ctx = furthestOf(not(string('hello')), 'world'); + + expect(ctx.furthest).toBe(-1); + }); +}); diff --git a/src/core/run.test.ts b/src/core/run.test.ts index c89d70e..25397a6 100644 --- a/src/core/run.test.ts +++ b/src/core/run.test.ts @@ -30,10 +30,31 @@ describe('run', () => { expect(() => { run( create((_input, index = 0) => - failure(index, 'something weird'), + failure(undefined, index, 'something weird'), ), 'AB', ); }).toThrowError('expected something weird'); }); + + /** Records 'A' the way a real terminal does, then discards the failure. */ + const swallowing = (...expected: string[]) => { + return create((_input, index = 0, ctx) => { + failure(ctx, index, 'A'); + + return failure(ctx, index, ...expected); + }); + }; + + it('should fall back to the context when the failure names nothing', () => { + expect(() => { + run(swallowing(), 'B'); + }).toThrowError("1:1 expected A, found 'B'"); + }); + + it('should combine the context and the failure at the same offset', () => { + expect(() => { + run(swallowing('B'), 'C'); + }).toThrowError("1:1 expected A or B, found 'C'"); + }); }); diff --git a/src/core/run.ts b/src/core/run.ts index dc8ba6a..8d4c692 100644 --- a/src/core/run.ts +++ b/src/core/run.ts @@ -1,35 +1,60 @@ import type { Parser } from './parser'; +import { type Context, context, union } from './context'; import { ParseError } from './error'; import { type Result } from './result'; +/** + * Folds in whatever the context recorded at the same offset, so expectations + * from branches that were backtracked over are not lost. + */ +const merge = ( + ctx: Context, + index: number, + expected: readonly string[], +): readonly string[] => { + return ctx.furthest === index ? union(ctx.expected, expected) : expected; +}; + /** * Resolves where a parse went wrong. * * A parser can succeed without consuming everything — the interesting position * is then the furthest offset any branch reached, not where the parse stopped. + * + * A result reaching the same offset as the context carries no more authority + * than the context does, so the two expectation sets are merged. Combinators + * that fail without an expectation of their own — `chainLeft`, `guard`, a + * hand-written `failure(ctx, index)` — would otherwise report nothing at all. */ export const diagnose = ( input: string, result: Result, + ctx: Context, ): { index: number; expected: readonly string[] } | null => { if (!result.ok) { - return { index: result.index, expected: result.expected }; + if (ctx.furthest > result.index) { + return { index: ctx.furthest, expected: ctx.expected }; + } + + return { + index: result.index, + expected: merge(ctx, result.index, result.expected), + }; } if (result.index === input.length) { return null; } - if (result.furthest > result.index) { - return { index: result.furthest, expected: result.expected }; + if (ctx.furthest > result.index) { + return { index: ctx.furthest, expected: ctx.expected }; } - const expected = result.furthest === result.index - ? [...new Set([...result.expected, 'end of input'])] - : ['end of input']; - - return { index: result.index, expected }; + return { + index: result.index, + expected: merge(ctx, result.index, ['end of input']), + }; }; /** @@ -42,8 +67,9 @@ export const diagnose = ( * run(string('hello'), 'hello') // 'hello' */ export const run = (parser: Parser, input: string): T => { - const result = parser(input, 0); - const problem = diagnose(input, result); + const ctx = context(); + const result = parser(input, 0, ctx); + const problem = diagnose(input, result, ctx); if (problem) { throw new ParseError(input, problem.index, problem.expected); diff --git a/src/core/success.ts b/src/core/success.ts index 0fcaad8..32d4dbe 100644 --- a/src/core/success.ts +++ b/src/core/success.ts @@ -1,27 +1,13 @@ -export type Success = { - ok: true; - value: T; - index: number; - furthest: number; - expected: readonly string[]; -}; - -const NONE: readonly string[] = []; +export type Success = { ok: true; value: T; index: number }; /** * Creates a successful result with a value and the offset reached in the input. * - * The `furthest`/`expected` pair carries the furthest failure seen while - * producing this success, so a later error can still report it. Use - * {@link merge} to propagate it — a fresh success starts with no trace. - * * @example - * success('hello', 5) // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } + * success('hello', 5) // { ok: true, value: 'hello', index: 5 } */ export const success = (value: T, index: number): Success => ({ ok: true, value, index, - furthest: -1, - expected: NONE, }); diff --git a/src/primitives/alphaNum.ts b/src/primitives/alphaNum.ts index 9206480..7ac3b42 100644 --- a/src/primitives/alphaNum.ts +++ b/src/primitives/alphaNum.ts @@ -1,3 +1,4 @@ +import { type Context } from '../core/context'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { satisfy } from '../terminals/satisfy'; @@ -7,20 +8,28 @@ import { type Letter } from './letter'; type Head = S extends `${infer C}${string}` ? C : never; export type AlphaNum = Letter | Digit; -const parser = satisfy((c) => /[a-zA-Z0-9]/.test(c), 'alphanumeric character'); +const parser = satisfy( + (c) => /[a-zA-Z0-9]/.test(c), + 'alphanumeric character', +); /** * Parse a single alphanumeric character. * * @example - * alphaNum('a1') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } - * alphaNum('1a') // { ok: true, value: '1', index: 1, furthest: -1, expected: [] } + * alphaNum('a1') // { ok: true, value: 'a', index: 1 } + * alphaNum('1a') // { ok: true, value: '1', index: 1 } */ export function alphaNum( input: S, index?: number, + ctx?: Context, ): Result & AlphaNum>; -export function alphaNum(input: string, index?: number): Result; -export function alphaNum(input: string, index = 0) { - return create(parser)(input, index); +export function alphaNum( + input: string, + index?: number, + ctx?: Context, +): Result; +export function alphaNum(input: string, index = 0, ctx?: Context) { + return create(parser)(input, index, ctx); } diff --git a/src/primitives/alphaNums.ts b/src/primitives/alphaNums.ts index 5b62549..6f553f9 100644 --- a/src/primitives/alphaNums.ts +++ b/src/primitives/alphaNums.ts @@ -1,5 +1,4 @@ import { many1 } from '../combinators/many1'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { alphaNum } from './alphaNum'; @@ -10,13 +9,13 @@ const parser = many1(alphaNum); * Parse one or more alphanumeric characters as a string. * * @example - * alphaNums('placeholder') // { ok: true, value: 'placeholder', index: 11, furthest: 11, expected: ['alphanumeric character'] } + * alphaNums('placeholder') // { ok: true, value: 'placeholder', index: 11 } */ -export const alphaNums = create((input, index = 0) => { - const result = parser(input, index); +export const alphaNums = create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) { return result; } - return merge(result, success(result.value.join(''), result.index)); + return success(result.value.join(''), result.index); }); diff --git a/src/primitives/anyChar.ts b/src/primitives/anyChar.ts index 0936302..61dbd96 100644 --- a/src/primitives/anyChar.ts +++ b/src/primitives/anyChar.ts @@ -6,10 +6,10 @@ import { success } from '../core/success'; * Parse any single character. * * @example - * anyChar('abc') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } + * anyChar('abc') // { ok: true, value: 'a', index: 1 } */ -export const anyChar = create((input, index = 0) => { +export const anyChar = create((input, index = 0, ctx) => { return index < input.length ? success(input[index], index + 1) - : failure(index, 'any character'); + : failure(ctx, index, 'any character'); }); diff --git a/src/primitives/bool.ts b/src/primitives/bool.ts index 7aee632..1899ca2 100644 --- a/src/primitives/bool.ts +++ b/src/primitives/bool.ts @@ -12,8 +12,8 @@ const parser = choice( * Parse a boolean literal. * * @example - * bool('true') // { ok: true, value: true, index: 4, furthest: -1, expected: [] } - * bool('false') // { ok: true, value: false, index: 5, furthest: 0, expected: ["'true'"] } - * bool('trueABC') // { ok: true, value: true, index: 4, furthest: -1, expected: [] } + * bool('true') // { ok: true, value: true, index: 4 } + * bool('false') // { ok: true, value: false, index: 5 } + * bool('trueABC') // { ok: true, value: true, index: 4 } */ export const bool = create(parser); diff --git a/src/primitives/crlf.ts b/src/primitives/crlf.ts index 44aaadf..17a7a1d 100644 --- a/src/primitives/crlf.ts +++ b/src/primitives/crlf.ts @@ -7,6 +7,6 @@ const parser = string('\r\n'); * Parse CRLF line ending. * * @example - * crlf('\r\nabc') // { ok: true, value: '\r\n', index: 2, furthest: -1, expected: [] } + * crlf('\r\nabc') // { ok: true, value: '\r\n', index: 2 } */ export const crlf = create(parser); diff --git a/src/primitives/digit.ts b/src/primitives/digit.ts index e62d7cc..539601e 100644 --- a/src/primitives/digit.ts +++ b/src/primitives/digit.ts @@ -13,6 +13,6 @@ const parser = map( * Parse a single digit and return as number. * * @example - * digit('5abc') // { ok: true, value: 5, index: 1, furthest: -1, expected: [] } + * digit('5abc') // { ok: true, value: 5, index: 1 } */ export const digit = create(parser); diff --git a/src/primitives/digits.ts b/src/primitives/digits.ts index c257c29..3f5eea4 100644 --- a/src/primitives/digits.ts +++ b/src/primitives/digits.ts @@ -1,5 +1,4 @@ import { many1 } from '../combinators/many1'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { digit } from './digit'; @@ -10,15 +9,15 @@ const parser = many1(digit); * Parse one or more digits and return as a number. * * @example - * digits('123') // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } + * digits('123') // { ok: true, value: 123, index: 3 } */ -export const digits = create((input, index = 0) => { - const result = parser(input, index); +export const digits = create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) { return result; } const value = result.value.reduce((acc, d) => acc * 10 + d, 0); - return merge(result, success(value, result.index)); + return success(value, result.index); }); diff --git a/src/primitives/eof.ts b/src/primitives/eof.ts index a412300..d0a9639 100644 --- a/src/primitives/eof.ts +++ b/src/primitives/eof.ts @@ -6,10 +6,10 @@ import { success } from '../core/success'; * Match the end of the input. * * @example - * eof('') // { ok: true, value: null, index: 0, furthest: -1, expected: [] } + * eof('') // { ok: true, value: null, index: 0 } */ -export const eof = create((input, index = 0) => { +export const eof = create((input, index = 0, ctx) => { return index >= input.length ? success(null, index) - : failure(index, 'end of input'); + : failure(ctx, index, 'end of input'); }); diff --git a/src/primitives/eol.ts b/src/primitives/eol.ts index a39ab1a..647d7de 100644 --- a/src/primitives/eol.ts +++ b/src/primitives/eol.ts @@ -15,6 +15,6 @@ const parser = choice( * Parse end of line (\\n, \\r\\n, or EOF). * * @example - * eol('\nabc') // { ok: true, value: '\n', index: 1, furthest: -1, expected: [] } + * eol('\nabc') // { ok: true, value: '\n', index: 1 } */ export const eol = create(parser); diff --git a/src/primitives/float.ts b/src/primitives/float.ts index da29807..aa22ed1 100644 --- a/src/primitives/float.ts +++ b/src/primitives/float.ts @@ -8,8 +8,8 @@ const parser = map(regex(/^-?\d+\.\d+/), (match: string) => parseFloat(match)); * Parse a floating point number. * * @example - * float('1.23') // { ok: true, value: 1.23, index: 4, furthest: -1, expected: [] } - * float('-2.5') // { ok: true, value: -2.5, index: 4, furthest: -1, expected: [] } - * float('1.23abc') // { ok: true, value: 1.23, index: 4, furthest: -1, expected: [] } + * float('1.23') // { ok: true, value: 1.23, index: 4 } + * float('-2.5') // { ok: true, value: -2.5, index: 4 } + * float('1.23abc') // { ok: true, value: 1.23, index: 4 } */ export const float = create(parser); diff --git a/src/primitives/hexDigit.ts b/src/primitives/hexDigit.ts index 71aacd6..11f0a30 100644 --- a/src/primitives/hexDigit.ts +++ b/src/primitives/hexDigit.ts @@ -1,3 +1,4 @@ +import { type Context } from '../core/context'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { satisfy } from '../terminals/satisfy'; @@ -25,13 +26,18 @@ const parser = satisfy((c) => /[0-9a-fA-F]/.test(c), 'hex digit'); * Parse a single hexadecimal digit. * * @example - * hexDigit('fF9') // { ok: true, value: 'f', index: 1, furthest: -1, expected: [] } + * hexDigit('fF9') // { ok: true, value: 'f', index: 1 } */ export function hexDigit( input: S, index?: number, + ctx?: Context, ): Result & HexDigit>; -export function hexDigit(input: string, index?: number): Result; -export function hexDigit(input: string, index = 0) { - return create(parser)(input, index); +export function hexDigit( + input: string, + index?: number, + ctx?: Context, +): Result; +export function hexDigit(input: string, index = 0, ctx?: Context) { + return create(parser)(input, index, ctx); } diff --git a/src/primitives/hexDigits.ts b/src/primitives/hexDigits.ts index 47a58ca..1890e69 100644 --- a/src/primitives/hexDigits.ts +++ b/src/primitives/hexDigits.ts @@ -1,5 +1,4 @@ import { many1 } from '../combinators/many1'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { hexDigit } from './hexDigit'; @@ -10,13 +9,13 @@ const parser = many1(hexDigit); * Parse one or more hex digits as a string. * * @example - * hexDigits('placeholder') // { ok: false, index: 0, furthest: 0, expected: ['hex digit'] } + * hexDigits('deadbeef') // { ok: true, value: 'deadbeef', index: 8 } */ -export const hexDigits = create((input, index = 0) => { - const result = parser(input, index); +export const hexDigits = create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) { return result; } - return merge(result, success(result.value.join(''), result.index)); + return success(result.value.join(''), result.index); }); diff --git a/src/primitives/identifier.ts b/src/primitives/identifier.ts index 76f9b41..248fbf7 100644 --- a/src/primitives/identifier.ts +++ b/src/primitives/identifier.ts @@ -7,6 +7,6 @@ const parser = regex(/^[a-zA-Z_][a-zA-Z0-9_]*/); * Parse an identifier — starts with letter or underscore, no leading digit, no hyphen. * * @example - * identifier('variable_name') // { ok: true, value: 'variable_name', index: 13, furthest: -1, expected: [] } + * identifier('variable_name') // { ok: true, value: 'variable_name', index: 13 } */ export const identifier = create(parser); diff --git a/src/primitives/integer.ts b/src/primitives/integer.ts index d66384d..6db6c43 100644 --- a/src/primitives/integer.ts +++ b/src/primitives/integer.ts @@ -8,8 +8,8 @@ const parser = map(regex(/^-?\d+/), (match: string) => parseInt(match, 10)); * Parse a signed integer. * * @example - * integer('42') // { ok: true, value: 42, index: 2, furthest: -1, expected: [] } - * integer('-7') // { ok: true, value: -7, index: 2, furthest: -1, expected: [] } - * integer('123abc') // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } + * integer('42') // { ok: true, value: 42, index: 2 } + * integer('-7') // { ok: true, value: -7, index: 2 } + * integer('123abc') // { ok: true, value: 123, index: 3 } */ export const integer = create(parser); diff --git a/src/primitives/letter.ts b/src/primitives/letter.ts index 0a5f4c5..940754b 100644 --- a/src/primitives/letter.ts +++ b/src/primitives/letter.ts @@ -1,3 +1,4 @@ +import { type Context } from '../core/context'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { satisfy } from '../terminals/satisfy'; @@ -13,13 +14,18 @@ const parser = satisfy((c) => /[a-zA-Z]/.test(c), 'letter'); * Parse a single letter. * * @example - * letter('abc') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } + * letter('abc') // { ok: true, value: 'a', index: 1 } */ export function letter( input: S, index?: number, + ctx?: Context, ): Result & Letter>; -export function letter(input: string, index?: number): Result; -export function letter(input: string, index = 0) { - return create(parser)(input, index); +export function letter( + input: string, + index?: number, + ctx?: Context, +): Result; +export function letter(input: string, index = 0, ctx?: Context) { + return create(parser)(input, index, ctx); } diff --git a/src/primitives/letters.ts b/src/primitives/letters.ts index eca9ca1..a442654 100644 --- a/src/primitives/letters.ts +++ b/src/primitives/letters.ts @@ -1,5 +1,4 @@ import { many1 } from '../combinators/many1'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { letter } from './letter'; @@ -10,13 +9,13 @@ const parser = many1(letter); * Parse one or more letters as a string. * * @example - * letters('placeholder') // { ok: true, value: 'placeholder', index: 11, furthest: 11, expected: ['letter'] } + * letters('placeholder') // { ok: true, value: 'placeholder', index: 11 } */ -export const letters = create((input, index = 0) => { - const result = parser(input, index); +export const letters = create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) { return result; } - return merge(result, success(result.value.join(''), result.index)); + return success(result.value.join(''), result.index); }); diff --git a/src/primitives/line.ts b/src/primitives/line.ts index 270b037..06b71e4 100644 --- a/src/primitives/line.ts +++ b/src/primitives/line.ts @@ -7,6 +7,6 @@ const parser = takeWhile((c) => c !== '\n' && c !== '\r'); * Parse until end of line. * * @example - * line('hello\nworld') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } + * line('hello\nworld') // { ok: true, value: 'hello', index: 5 } */ export const line = create(parser); diff --git a/src/primitives/literal.ts b/src/primitives/literal.ts index a8c8a9d..d43c5bf 100644 --- a/src/primitives/literal.ts +++ b/src/primitives/literal.ts @@ -7,7 +7,7 @@ const parser = regex(/^[a-zA-Z0-9_-]+/); * Parse a word-like value including hyphens. * * @example - * literal('foo-bar'); // { ok: true, value: 'foo-bar', index: 7, furthest: -1, expected: [] } - * literal('123abc'); // { ok: true, value: '123abc', index: 6, furthest: -1, expected: [] } + * literal('foo-bar'); // { ok: true, value: 'foo-bar', index: 7 } + * literal('123abc'); // { ok: true, value: '123abc', index: 6 } */ export const literal = create(parser); diff --git a/src/primitives/lowercase.ts b/src/primitives/lowercase.ts index d9c202d..de83bc5 100644 --- a/src/primitives/lowercase.ts +++ b/src/primitives/lowercase.ts @@ -1,3 +1,4 @@ +import { type Context } from '../core/context'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { satisfy } from '../terminals/satisfy'; @@ -31,19 +32,27 @@ export type LowercaseLetter = | 'y' | 'z'; -const parser = satisfy((c) => /[a-z]/.test(c), 'lowercase letter'); +const parser = satisfy( + (c) => /[a-z]/.test(c), + 'lowercase letter', +); /** * Parse a single lowercase letter. * * @example - * lowercase('abc') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } + * lowercase('abc') // { ok: true, value: 'a', index: 1 } */ export function lowercase( input: S, index?: number, + ctx?: Context, ): Result & LowercaseLetter>; -export function lowercase(input: string, index?: number): Result; -export function lowercase(input: string, index = 0) { - return create(parser)(input, index); +export function lowercase( + input: string, + index?: number, + ctx?: Context, +): Result; +export function lowercase(input: string, index = 0, ctx?: Context) { + return create(parser)(input, index, ctx); } diff --git a/src/primitives/lowercases.ts b/src/primitives/lowercases.ts index fce0fde..42c3a8b 100644 --- a/src/primitives/lowercases.ts +++ b/src/primitives/lowercases.ts @@ -1,5 +1,4 @@ import { many1 } from '../combinators/many1'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { lowercase } from './lowercase'; @@ -10,13 +9,13 @@ const parser = many1(lowercase); * Parse one or more lowercase letters as a string. * * @example - * lowercases('placeholder') // { ok: true, value: 'placeholder', index: 11, furthest: 11, expected: ['lowercase letter'] } + * lowercases('placeholder') // { ok: true, value: 'placeholder', index: 11 } */ -export const lowercases = create((input, index = 0) => { - const result = parser(input, index); +export const lowercases = create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) { return result; } - return merge(result, success(result.value.join(''), result.index)); + return success(result.value.join(''), result.index); }); diff --git a/src/primitives/nl.ts b/src/primitives/nl.ts index 5f71d0e..10e2a7e 100644 --- a/src/primitives/nl.ts +++ b/src/primitives/nl.ts @@ -7,6 +7,6 @@ const parser = satisfy<'\n'>((c) => /\n/.test(c), 'newline'); * Parse a newline character. * * @example - * nl('\ntext') // { ok: true, value: '\n', index: 1, furthest: -1, expected: [] } + * nl('\ntext') // { ok: true, value: '\n', index: 1 } */ export const nl = create<'\n'>(parser); diff --git a/src/primitives/number.ts b/src/primitives/number.ts index e6331b8..92172c7 100644 --- a/src/primitives/number.ts +++ b/src/primitives/number.ts @@ -9,9 +9,9 @@ const parser = choice(float, integer); * Parse an integer or float. * * @example - * number('42') // { ok: true, value: 42, index: 2, furthest: 0, expected: ['/-?\\d+\\.\\d+/'] } - * number('3.14') // { ok: true, value: 3.14, index: 4, furthest: -1, expected: [] } - * number('-7') // { ok: true, value: -7, index: 2, furthest: 0, expected: ['/-?\\d+\\.\\d+/'] } - * number('-2.5') // { ok: true, value: -2.5, index: 4, furthest: -1, expected: [] } + * number('42') // { ok: true, value: 42, index: 2 } + * number('3.14') // { ok: true, value: 3.14, index: 4 } + * number('-7') // { ok: true, value: -7, index: 2 } + * number('-2.5') // { ok: true, value: -2.5, index: 4 } */ export const number = create(parser); diff --git a/src/primitives/octDigit.ts b/src/primitives/octDigit.ts index bf6c682..7ec90a8 100644 --- a/src/primitives/octDigit.ts +++ b/src/primitives/octDigit.ts @@ -1,3 +1,4 @@ +import { type Context } from '../core/context'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { satisfy } from '../terminals/satisfy'; @@ -11,13 +12,18 @@ const parser = satisfy((c) => /[0-7]/.test(c), 'octal digit'); * Parse a single octal digit. * * @example - * octDigit('7abc') // { ok: true, value: '7', index: 1, furthest: -1, expected: [] } + * octDigit('7abc') // { ok: true, value: '7', index: 1 } */ export function octDigit( input: S, index?: number, + ctx?: Context, ): Result & OctDigit>; -export function octDigit(input: string, index?: number): Result; -export function octDigit(input: string, index = 0) { - return create(parser)(input, index); +export function octDigit( + input: string, + index?: number, + ctx?: Context, +): Result; +export function octDigit(input: string, index = 0, ctx?: Context) { + return create(parser)(input, index, ctx); } diff --git a/src/primitives/octDigits.ts b/src/primitives/octDigits.ts index 90f4706..5054abc 100644 --- a/src/primitives/octDigits.ts +++ b/src/primitives/octDigits.ts @@ -1,5 +1,4 @@ import { many1 } from '../combinators/many1'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { octDigit } from './octDigit'; @@ -10,13 +9,13 @@ const parser = many1(octDigit); * Parse one or more octal digits as a string. * * @example - * octDigits('placeholder') // { ok: false, index: 0, furthest: 0, expected: ['octal digit'] } + * octDigits('755rest') // { ok: true, value: '755', index: 3 } */ -export const octDigits = create((input, index = 0) => { - const result = parser(input, index); +export const octDigits = create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) { return result; } - return merge(result, success(result.value.join(''), result.index)); + return success(result.value.join(''), result.index); }); diff --git a/src/primitives/position.ts b/src/primitives/position.ts index 45c423e..c4f9dc8 100644 --- a/src/primitives/position.ts +++ b/src/primitives/position.ts @@ -8,8 +8,8 @@ import { success } from '../core/success'; * {@link locate} to attach line/column information to a parsed node. * * @example - * position('abc') // { ok: true, value: 0, index: 0, furthest: -1, expected: [] } - * right(string('ab'), position)('abc') // { ok: true, value: 2, index: 2, furthest: -1, expected: [] } + * position('abc') // { ok: true, value: 0, index: 0 } + * right(string('ab'), position)('abc') // { ok: true, value: 2, index: 2 } */ export const position = create((_input, index = 0) => success(index, index), diff --git a/src/primitives/rest.ts b/src/primitives/rest.ts index 1796bd6..7476e4a 100644 --- a/src/primitives/rest.ts +++ b/src/primitives/rest.ts @@ -5,7 +5,7 @@ import { success } from '../core/success'; * Consume and return everything left in the input. * * @example - * rest('abc') // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } + * rest('abc') // { ok: true, value: 'abc', index: 3 } */ export const rest = create((input, index = 0) => success(input.slice(index), input.length), diff --git a/src/primitives/space.ts b/src/primitives/space.ts index d1957f2..8d3ef41 100644 --- a/src/primitives/space.ts +++ b/src/primitives/space.ts @@ -7,6 +7,6 @@ const parser = satisfy((c) => c === ' ', 'space'); * Parse a single space character. * * @example - * space(' abc') // { ok: true, value: ' ', index: 1, furthest: -1, expected: [] } + * space(' abc') // { ok: true, value: ' ', index: 1 } */ export const space = create(parser); diff --git a/src/primitives/spaces.ts b/src/primitives/spaces.ts index 32dca94..4f8a182 100644 --- a/src/primitives/spaces.ts +++ b/src/primitives/spaces.ts @@ -1,5 +1,4 @@ import { many1 } from '../combinators/many1'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { space } from './space'; @@ -10,13 +9,13 @@ const parser = many1(space); * Parse one or more spaces as a string. * * @example - * spaces('placeholder') // { ok: false, index: 0, furthest: 0, expected: ['space'] } + * spaces(' abc') // { ok: true, value: ' ', index: 3 } */ -export const spaces = create((input, index = 0) => { - const result = parser(input, index); +export const spaces = create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) { return result; } - return merge(result, success(result.value.join(''), result.index)); + return success(result.value.join(''), result.index); }); diff --git a/src/primitives/tab.ts b/src/primitives/tab.ts index 939fe8f..a1e4a40 100644 --- a/src/primitives/tab.ts +++ b/src/primitives/tab.ts @@ -7,6 +7,6 @@ const parser = char('\t'); * Parse tab character. * * @example - * tab('\ttext') // { ok: true, value: '\t', index: 1, furthest: -1, expected: [] } + * tab('\ttext') // { ok: true, value: '\t', index: 1 } */ export const tab = create(parser); diff --git a/src/primitives/uppercase.ts b/src/primitives/uppercase.ts index ed1f253..bc92e9c 100644 --- a/src/primitives/uppercase.ts +++ b/src/primitives/uppercase.ts @@ -1,3 +1,4 @@ +import { type Context } from '../core/context'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { satisfy } from '../terminals/satisfy'; @@ -31,19 +32,27 @@ export type UppercaseLetter = | 'Y' | 'Z'; -const parser = satisfy((c) => /[A-Z]/.test(c), 'uppercase letter'); +const parser = satisfy( + (c) => /[A-Z]/.test(c), + 'uppercase letter', +); /** * Parses a single uppercase letter. * * @example - * uppercase('ABC') // { ok: true, value: 'A', index: 1, furthest: -1, expected: [] } + * uppercase('ABC') // { ok: true, value: 'A', index: 1 } */ export function uppercase( input: S, index?: number, + ctx?: Context, ): Result & UppercaseLetter>; -export function uppercase(input: string, index?: number): Result; -export function uppercase(input: string, index = 0) { - return create(parser)(input, index); +export function uppercase( + input: string, + index?: number, + ctx?: Context, +): Result; +export function uppercase(input: string, index = 0, ctx?: Context) { + return create(parser)(input, index, ctx); } diff --git a/src/primitives/uppercases.ts b/src/primitives/uppercases.ts index e28638c..4bc78ba 100644 --- a/src/primitives/uppercases.ts +++ b/src/primitives/uppercases.ts @@ -1,5 +1,4 @@ import { many1 } from '../combinators/many1'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { uppercase } from './uppercase'; @@ -10,13 +9,13 @@ const parser = many1(uppercase); * Parse one or more uppercase letters as a string. * * @example - * uppercases('placeholder') // { ok: false, index: 0, furthest: 0, expected: ['uppercase letter'] } + * uppercases('ABCdef') // { ok: true, value: 'ABC', index: 3 } */ -export const uppercases = create((input, index = 0) => { - const result = parser(input, index); +export const uppercases = create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) { return result; } - return merge(result, success(result.value.join(''), result.index)); + return success(result.value.join(''), result.index); }); diff --git a/src/primitives/whitespace.ts b/src/primitives/whitespace.ts index e7982f6..86cccf2 100644 --- a/src/primitives/whitespace.ts +++ b/src/primitives/whitespace.ts @@ -7,6 +7,6 @@ const parser = satisfy((c) => /\s/.test(c), 'whitespace'); * Parses a single whitespace character. * * @example - * whitespace(' abc') // { ok: true, value: ' ', index: 1, furthest: -1, expected: [] } + * whitespace(' abc') // { ok: true, value: ' ', index: 1 } */ export const whitespace = create(parser); diff --git a/src/primitives/whitespaces.ts b/src/primitives/whitespaces.ts index 09bfa63..744507a 100644 --- a/src/primitives/whitespaces.ts +++ b/src/primitives/whitespaces.ts @@ -1,5 +1,4 @@ import { many1 } from '../combinators/many1'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { whitespace } from './whitespace'; @@ -10,13 +9,13 @@ const parser = many1(whitespace); * Parse one or more whitespaces as a string. * * @example - * whitespaces('placeholder') // { ok: false, index: 0, furthest: 0, expected: ['whitespace'] } + * whitespaces(' \t\nabc') // { ok: true, value: ' \t\n', index: 3 } */ -export const whitespaces = create((input, index = 0) => { - const result = parser(input, index); +export const whitespaces = create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) { return result; } - return merge(result, success(result.value.join(''), result.index)); + return success(result.value.join(''), result.index); }); diff --git a/src/terminals/char.ts b/src/terminals/char.ts index 4c1971d..b761884 100644 --- a/src/terminals/char.ts +++ b/src/terminals/char.ts @@ -1,4 +1,4 @@ -import { failure } from '../core/failure'; +import { reject } from '../core/failure'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -9,7 +9,7 @@ export type Char = * Parse a specific character. * * @example - * char('A')('ABC') // { ok: true, value: 'A', index: 1, furthest: -1, expected: [] } + * char('A')('ABC') // { ok: true, value: 'A', index: 1 } */ export const char = (expected: Char) => { if ((expected as string).length !== 1) { @@ -18,11 +18,11 @@ export const char = (expected: Char) => { ); } - const described = `'${expected}'`; + const described = [`'${expected}'`]; - return create>((input, index = 0) => + return create>((input, index = 0, ctx) => input[index] === expected ? success(expected, index + 1) - : failure(index, described), + : reject(ctx, index, described), ); }; diff --git a/src/terminals/charOf.ts b/src/terminals/charOf.ts index e7a5034..2ed66e0 100644 --- a/src/terminals/charOf.ts +++ b/src/terminals/charOf.ts @@ -1,14 +1,13 @@ -import { failure } from '../core/failure'; +import { reject } from '../core/failure'; import { create } from '../core/parser'; import { success } from '../core/success'; -import type { char } from './char'; -import { type Char } from './char'; +import { type Char, type char } from './char'; /** * Parse any character from a set. * * @example - * charOf(['a', 'b', 'c'])('abc') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } + * charOf(['a', 'b', 'c'])('abc') // { ok: true, value: 'a', index: 1 } */ export function charOf( chars: readonly Char[], @@ -16,11 +15,11 @@ export function charOf( export function charOf(chars: readonly string[]) { const described = chars.map((c) => `'${c}'`); - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { const next = input[index]; return index < input.length && chars.includes(next) ? success(next, index + 1) - : failure(index, ...described); + : reject(ctx, index, described); }); } diff --git a/src/terminals/noneOf.ts b/src/terminals/noneOf.ts index 567ae28..e32b227 100644 --- a/src/terminals/noneOf.ts +++ b/src/terminals/noneOf.ts @@ -1,4 +1,4 @@ -import { failure } from '../core/failure'; +import { reject } from '../core/failure'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -6,14 +6,16 @@ import { success } from '../core/success'; * Parse any character not in the set. * * @example - * noneOf(['a', 'b', 'c'])('xyz') // { ok: true, value: 'x', index: 1, furthest: -1, expected: [] } + * noneOf(['a', 'b', 'c'])('xyz') // { ok: true, value: 'x', index: 1 } */ export function noneOf(chars: readonly string[]) { - const described = `character other than ${chars.map((c) => `'${c}'`).join(', ')}`; + const described = [ + `character other than ${chars.map((c) => `'${c}'`).join(', ')}`, + ]; - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { return index < input.length && !chars.includes(input[index]) ? success(input[index], index + 1) - : failure(index, described); + : reject(ctx, index, described); }); } diff --git a/src/terminals/oneOf.ts b/src/terminals/oneOf.ts index bb8d908..d840730 100644 --- a/src/terminals/oneOf.ts +++ b/src/terminals/oneOf.ts @@ -1,4 +1,4 @@ -import { failure } from '../core/failure'; +import { reject } from '../core/failure'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -6,18 +6,18 @@ import { success } from '../core/success'; * Parse one string from a set of strings (longest match wins). * * @example - * oneOf(['hello', 'hell', 'help'])('helpful') // { ok: true, value: 'help', index: 4, furthest: -1, expected: [] } + * oneOf(['hello', 'hell', 'help'])('helpful') // { ok: true, value: 'help', index: 4 } */ export const oneOf = (strings: readonly [S, ...S[]]) => { const described = strings.map((s) => `'${s}'`); - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { for (const str of strings) { if (input.startsWith(str, index)) { return success(str, index + str.length); } } - return failure(index, ...described); + return reject(ctx, index, described); }); }; diff --git a/src/terminals/regex.test.ts b/src/terminals/regex.test.ts index 690a5b8..afea62e 100644 --- a/src/terminals/regex.test.ts +++ b/src/terminals/regex.test.ts @@ -25,6 +25,14 @@ describe('regex', () => { assertSuccess(result, 'hello', 5); }); + it('should keep a leading caret as a line anchor when multiline', () => { + const parser = regex(/^# /m); + + assertSuccess(parser('# hi'), '# ', 2); + assertSuccess(parser('a\n# hi', 2), '# ', 4); + assertFailure(parser('a# hi', 1), 1); + }); + it('should handle empty matches', () => { const parser = regex(/\d*/); const result = parser('abc'); diff --git a/src/terminals/regex.ts b/src/terminals/regex.ts index c8e93a8..ebcb0aa 100644 --- a/src/terminals/regex.ts +++ b/src/terminals/regex.ts @@ -1,4 +1,4 @@ -import { failure } from '../core/failure'; +import { reject } from '../core/failure'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -7,29 +7,31 @@ import { success } from '../core/success'; * * The pattern is matched with the sticky flag at the current offset, so it is * anchored by construction and never scans ahead. A leading `^` is redundant - * and is stripped. + * and is stripped — unless the pattern is multiline, where it asserts a line + * start and still carries meaning. * * @example - * regex(/^\w+/)('hello world') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } + * regex(/^\w+/)('hello world') // { ok: true, value: 'hello', index: 5 } */ export const regex = (pattern: RegExp, expected?: string) => { if (pattern.global) { throw new Error('Global flag is not supported in regex parsers'); } - const source = pattern.source.startsWith('^') - ? pattern.source.slice(1) - : pattern.source; + const source = + pattern.source.startsWith('^') && !pattern.multiline + ? pattern.source.slice(1) + : pattern.source; const flags = pattern.flags.replace('y', '') + 'y'; const sticky = new RegExp(source, flags); - const described = expected ?? `/${source}/`; + const described = [expected ?? `/${source}/`]; - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { sticky.lastIndex = index; const match = sticky.exec(input); return match ? success(match[0] as T, index + match[0].length) - : failure(index, described); + : reject(ctx, index, described); }); }; diff --git a/src/terminals/satisfy.ts b/src/terminals/satisfy.ts index 8eb78d5..b05329d 100644 --- a/src/terminals/satisfy.ts +++ b/src/terminals/satisfy.ts @@ -1,4 +1,4 @@ -import { failure } from '../core/failure'; +import { reject } from '../core/failure'; import { create } from '../core/parser'; import { success } from '../core/success'; import { type Char } from './char'; @@ -7,17 +7,19 @@ import { type Char } from './char'; * Parse a character satisfying a predicate. * * @example - * satisfy((c) => c === 'a')('abc') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } + * satisfy((c) => c === 'a')('abc') // { ok: true, value: 'a', index: 1 } */ export function satisfy( predicate: (c: Char) => boolean, expected = 'matching character', ) { - return create((input, index = 0) => { + const described = [expected]; + + return create((input, index = 0, ctx) => { const c = input[index] as Char; return index < input.length && predicate(c) ? success(c as T, index + 1) - : failure(index, expected); + : reject(ctx, index, described); }); } diff --git a/src/terminals/string.ts b/src/terminals/string.ts index 81ae70a..9c60d2e 100644 --- a/src/terminals/string.ts +++ b/src/terminals/string.ts @@ -1,4 +1,4 @@ -import { failure } from '../core/failure'; +import { reject } from '../core/failure'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -6,15 +6,15 @@ import { success } from '../core/success'; * Parse a specific string. * * @example - * string('hello')('hello world') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } + * string('hello')('hello world') // { ok: true, value: 'hello', index: 5 } */ export const string = (str: S) => { - const described = `'${str}'`; + const described = [`'${str}'`]; const { length } = str; - return create((input, index = 0) => { + return create((input, index = 0, ctx) => { return input.startsWith(str, index) ? success(str, index + length) - : failure(index, described); + : reject(ctx, index, described); }); }; diff --git a/src/terminals/stringOf.ts b/src/terminals/stringOf.ts index 7e98a2f..67060f8 100644 --- a/src/terminals/stringOf.ts +++ b/src/terminals/stringOf.ts @@ -1,4 +1,4 @@ -import { failure } from '../core/failure'; +import { reject } from '../core/failure'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -6,14 +6,14 @@ import { success } from '../core/success'; * Parse first character that exists in string (like charOf but for a string). * * @example - * stringOf('abc')('abcdef') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } + * stringOf('abc')('abcdef') // { ok: true, value: 'a', index: 1 } */ export const stringOf = (chars: string) => { - const described = `one of '${chars}'`; + const described = [`one of '${chars}'`]; - return create((input, index = 0) => + return create((input, index = 0, ctx) => index < input.length && chars.includes(input[index]) ? success(input[index], index + 1) - : failure(index, described), + : reject(ctx, index, described), ); }; diff --git a/src/terminals/take.ts b/src/terminals/take.ts index f83e85d..41d7ee6 100644 --- a/src/terminals/take.ts +++ b/src/terminals/take.ts @@ -1,4 +1,4 @@ -import { failure } from '../core/failure'; +import { reject } from '../core/failure'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -6,14 +6,14 @@ import { success } from '../core/success'; * Take n characters. * * @example - * take(3)('abcdef') // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } + * take(3)('abcdef') // { ok: true, value: 'abc', index: 3 } */ export const take = (count: number) => { - const described = `${count} more character${count === 1 ? '' : 's'}`; + const described = [`${count} more character${count === 1 ? '' : 's'}`]; - return create((input, index = 0) => + return create((input, index = 0, ctx) => input.length - index >= count ? success(input.slice(index, index + count), index + count) - : failure(index, described), + : reject(ctx, index, described), ); }; diff --git a/src/terminals/takeWhile.ts b/src/terminals/takeWhile.ts index 0d46919..083c996 100644 --- a/src/terminals/takeWhile.ts +++ b/src/terminals/takeWhile.ts @@ -5,7 +5,7 @@ import { success } from '../core/success'; * Takes characters while the predicate returns true. * * @example - * takeWhile((c) => c !== 'x')('abcx') // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } + * takeWhile((c) => c !== 'x')('abcx') // { ok: true, value: 'abc', index: 3 } */ export const takeWhile = (predicate: (char: string) => boolean) => { return create((input, index = 0) => { diff --git a/src/terminals/token.ts b/src/terminals/token.ts index d1b6537..76cae85 100644 --- a/src/terminals/token.ts +++ b/src/terminals/token.ts @@ -8,9 +8,9 @@ import { string } from './string'; * strings that might be followed by any character. * * @example - * token('let')('let x') // { ok: true, value: 'let', index: 4, furthest: -1, expected: [] } - * token('let')('let1') // { ok: true, value: 'let', index: 3, furthest: -1, expected: [] } - * token('let')('let x') // { ok: true, value: 'let', index: 5, furthest: -1, expected: [] } + * token('let')('let x') // { ok: true, value: 'let', index: 4 } + * token('let')('let1') // { ok: true, value: 'let', index: 3 } + * token('let')('let x') // { ok: true, value: 'let', index: 5 } */ export const token = (input: T) => { return create(lexeme(string(input))); diff --git a/src/terminals/word.ts b/src/terminals/word.ts index 7e55908..558b694 100644 --- a/src/terminals/word.ts +++ b/src/terminals/word.ts @@ -10,9 +10,9 @@ import { string } from './string'; * Use this when parsing keywords that should not be part of a longer identifier. * * @example - * word('let')('let x') // { ok: true, value: 'let', index: 4, furthest: -1, expected: [] } - * word('let')('let1') // { ok: false, index: 3, furthest: 3, expected: ['/(?!\\w)/'] } - * word('if')('if (x)') // { ok: true, value: 'if', index: 3, furthest: -1, expected: [] } + * word('let')('let x') // { ok: true, value: 'let', index: 4 } + * word('let')('let1') // { ok: false, index: 3, expected: ['/(?!\\w)/'] } + * word('if')('if (x)') // { ok: true, value: 'if', index: 3 } */ export const word = (targetWord: string) => { return create( diff --git a/src/utils/pipe.test.ts b/src/utils/pipe.test.ts index b5948cb..e6dedbd 100644 --- a/src/utils/pipe.test.ts +++ b/src/utils/pipe.test.ts @@ -46,14 +46,18 @@ describe('pipe', () => { (s: string) => s + '!', ); - const parser = create((_input, index = 0) => success(' hello ', index)); + const parser = create((_input, index = 0) => + success(' hello ', index), + ); const result = transform.call(parser, ' hello '); expect(result).toBe('HELLO!'); }); it('should allow accessing parser state via this', () => { - const parser = create((_input, index = 0) => success(100, index)); + const parser = create((_input, index = 0) => + success(100, index), + ); const transform = pipe(function (n: number) { return n + (this ? 1 : 0); diff --git a/src/utils/pipe.ts b/src/utils/pipe.ts index 8e1f369..48e312c 100644 --- a/src/utils/pipe.ts +++ b/src/utils/pipe.ts @@ -4,7 +4,7 @@ import { type Parser } from '../core/parser'; * Pipe parser functions together. * * @example - * pipe(lexeme)(letters)('xyz abc') // { ok: true, value: 'xyz', index: 6, furthest: 3, expected: ['letter'] } + * pipe(lexeme)(letters)('xyz abc') // { ok: true, value: 'xyz', index: 6 } */ type Pipe = (this: Parser, arg: A) => B; diff --git a/test/examples.test.ts b/test/examples.test.ts index 862a05a..5b1ff28 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -22,128 +22,56 @@ describe('examples from source', () => { describe('combinators', () => { 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', - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'abc', index: 4 }); }); it('braced: Parse content surrounded by braces.', () => { const result0 = braced(string('hi'))('{hi}'); - expect(result0).toEqual({ - ok: true, - value: 'hi', - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hi', index: 4 }); }); it('bracketed: Parse content surrounded by brackets.', () => { const result0 = bracketed(string('hi'))('[hi]'); - expect(result0).toEqual({ - ok: true, - value: 'hi', - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hi', index: 4 }); }); it('chainLeft: Chain left-associative operations (right-to-left for same precedence).', () => { const result0 = chainLeft(digits, operation)('1+2+3'); - expect(result0).toEqual({ - ok: true, - value: 6, - index: 5, - furthest: 5, - expected: ['operator'], - }); + expect(result0).toEqual({ ok: true, value: 6, index: 5 }); }); it('chainLeft1: Chain left-associative operations (fails on empty input).', () => { const result0 = chainLeft1(digits, operation)('1+2+3'); - expect(result0).toEqual({ - ok: true, - value: 6, - index: 5, - furthest: 5, - expected: ['operator'], - }); + expect(result0).toEqual({ ok: true, value: 6, index: 5 }); const result1 = chainLeft1(digits, operation)('8/2*3'); - expect(result1).toEqual({ - ok: true, - value: 12, - index: 5, - furthest: 5, - expected: ['operator'], - }); + expect(result1).toEqual({ ok: true, value: 12, index: 5 }); }); it('chainRight: Chain right-associative operations.', () => { const result0 = chainRight(digits, operation)('1+2+3'); - expect(result0).toEqual({ - ok: true, - value: 6, - index: 5, - furthest: 5, - expected: ['operator'], - }); + expect(result0).toEqual({ ok: true, value: 6, index: 5 }); }); it('chainRight1: Chain right-associative operations (fails on empty input).', () => { const result0 = chainRight1(digits, operation)('1+2+3'); - expect(result0).toEqual({ - ok: true, - value: 6, - index: 5, - furthest: 5, - expected: ['operator'], - }); + expect(result0).toEqual({ ok: true, value: 6, index: 5 }); }); - it('choice: a failing alternation reports all of them rather than only the last.', () => { + it('choice: in the input is already in the parse context.', () => { const result0 = choice(string('hello'), string('world'))('hello'); - expect(result0).toEqual({ - ok: true, - value: 'hello', - index: 5, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hello', index: 5 }); }); it('concat: Join string array parser result into a single string.', () => { const result0 = concat(many(letter))('abc123'); - expect(result0).toEqual({ - ok: true, - value: 'abc', - index: 3, - furthest: 3, - expected: ['letter'], - }); + expect(result0).toEqual({ ok: true, value: 'abc', index: 3 }); const result1 = concat(many(letter), '-')('abc123'); - expect(result1).toEqual({ - ok: true, - value: 'a-b-c', - index: 3, - furthest: 3, - expected: ['letter'], - }); + expect(result1).toEqual({ ok: true, value: 'a-b-c', index: 3 }); }); it('consume: Consume input but discard the result (return null).', () => { const result0 = consume(string('hello'))('hello world'); - expect(result0).toEqual({ - ok: true, - value: null, - index: 5, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: null, index: 5 }); }); it('endBy: Zero or more items separated and ending with terminator.', () => { @@ -152,8 +80,6 @@ describe('examples from source', () => { ok: true, value: ['item', 'item', 'item'], index: 15, - furthest: 15, - expected: ["'item'"], }); }); @@ -166,8 +92,6 @@ describe('examples from source', () => { ok: true, value: ['item', 'item', 'item'], index: 15, - furthest: 15, - expected: ["'item'"], }); }); @@ -177,94 +101,44 @@ describe('examples from source', () => { ok: true, value: ['a', 'a', 'a'], index: 3, - furthest: -1, - expected: [], }); }); it('first: Extract the first element from a parser result array.', () => { const result0 = first(sequence(char('a'), digit))('a1bc'); - expect(result0).toEqual({ - ok: true, - value: 'a', - index: 2, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'a', index: 2 }); }); it('flag: Return true if parser succeeds, false otherwise. Always succeeds without consuming input on failure.', () => { const result0 = flag(string('*'))('*abc'); - expect(result0).toEqual({ - ok: true, - value: true, - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: true, index: 1 }); const result1 = flag(string('*'))('abc'); - expect(result1).toEqual({ - ok: true, - value: false, - index: 0, - furthest: 0, - expected: ["'*'"], - }); + expect(result1).toEqual({ ok: true, value: false, index: 0 }); }); it('fold: Fold zero or more occurrences into a single value.', () => { const result0 = fold(digit, 0, (acc, d) => acc + d)('123'); - expect(result0).toEqual({ - ok: true, - value: 6, - index: 3, - furthest: 3, - expected: ['digit'], - }); + expect(result0).toEqual({ ok: true, value: 6, index: 3 }); }); it('fold1: Fold one or more occurrences into a single value.', () => { const result0 = fold1(digit, 0, (acc, d) => acc + d)('123'); - expect(result0).toEqual({ - ok: true, - value: 6, - index: 3, - furthest: 3, - expected: ['digit'], - }); + expect(result0).toEqual({ ok: true, value: 6, index: 3 }); }); it('foldRight: Fold zero or more occurrences from the right into a single value.', () => { const result0 = foldRight(digit, 0, (acc, d) => acc + d)('123'); - expect(result0).toEqual({ - ok: true, - value: 6, - index: 3, - furthest: 3, - expected: ['digit'], - }); + expect(result0).toEqual({ ok: true, value: 6, index: 3 }); }); it('foldRight1: Fold one or more occurrences from the right into a single value.', () => { const result0 = foldRight1(digit, 0, (acc, d) => acc + d)('123'); - expect(result0).toEqual({ - ok: true, - value: 6, - index: 3, - furthest: 3, - expected: ['digit'], - }); + expect(result0).toEqual({ ok: true, value: 6, index: 3 }); }); it('fuse: The fused parser concatenates all string results.', () => { const result0 = fuse(char('a'), char('b'), char('c'))('abc'); - expect(result0).toEqual({ - ok: true, - value: 'abc', - index: 3, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'abc', index: 3 }); const result1 = fuse( string('hello'), char(' '), @@ -274,60 +148,29 @@ describe('examples from source', () => { ok: true, value: 'hello world', index: 11, - furthest: -1, - expected: [], }); }); it('guard: Conditionally apply parser based on a condition.', () => { const result0 = guard(true, string('hello'))('hello'); - expect(result0).toEqual({ - ok: true, - value: 'hello', - index: 5, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hello', index: 5 }); const result1 = guard(false, string('hello'))('hello'); - expect(result1).toEqual({ - ok: false, - index: 0, - furthest: 0, - expected: [], - }); + expect(result1).toEqual({ ok: false, index: 0, expected: [] }); }); it('inner: Extract inner value from surrounded content (like inner of braced).', () => { const result0 = inner(char('('), string('hi'), char(')'))('(hi)'); - expect(result0).toEqual({ - ok: true, - value: 'hi', - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hi', index: 4 }); }); it('interleaved: Parse items separated by separators, keeping both in the result.', () => { const result0 = interleaved(digits, char('+'))('1+2'); - expect(result0).toEqual({ - ok: true, - value: [1, '+', 2], - index: 3, - furthest: 3, - expected: ["'+'"], - }); + expect(result0).toEqual({ ok: true, value: [1, '+', 2], index: 3 }); }); it('last: Extract the last element from a parser result array.', () => { const result0 = last(sequence(char('a'), char('b')))('ab'); - expect(result0).toEqual({ - ok: true, - value: 'b', - index: 2, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'b', index: 2 }); }); it('left: Keep only the left result from a sequence.', () => { @@ -335,24 +178,12 @@ describe('examples from source', () => { string('hello'), string('world'), )('helloworld'); - expect(result0).toEqual({ - ok: true, - value: 'hello', - index: 10, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hello', index: 10 }); }); it('lexeme: Parser that consumes trailing whitespace.', () => { const result0 = lexeme(string('hello'))('hello world'); - expect(result0).toEqual({ - ok: true, - value: 'hello', - index: 8, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hello', index: 8 }); }); it('many: still report what the repetition was expecting next.', () => { @@ -361,8 +192,6 @@ describe('examples from source', () => { ok: true, value: ['a', 'a', 'a'], index: 3, - furthest: 3, - expected: ["'a'"], }); }); @@ -372,8 +201,6 @@ describe('examples from source', () => { ok: true, value: ['a', 'a', 'a'], index: 3, - furthest: 3, - expected: ["'a'"], }); }); @@ -383,31 +210,17 @@ describe('examples from source', () => { ok: true, value: ['a', 'a', 'a'], index: 3, - furthest: 3, - expected: ["'a'"], }); }); it('manyAtMost: Parse at most n occurrences (never fails).', () => { const result0 = manyAtMost(char('a'), 2)('aaa'); - expect(result0).toEqual({ - ok: true, - value: ['a', 'a'], - index: 2, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: ['a', 'a'], index: 2 }); }); it('manyBetween: Parse between min and max occurrences.', () => { const result0 = manyBetween(char('a'), 1, 2)('aaa'); - expect(result0).toEqual({ - ok: true, - value: ['a', 'a'], - index: 2, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: ['a', 'a'], index: 2 }); }); it('manyTill: Parse zero or more until terminator matches.', () => { @@ -416,20 +229,12 @@ describe('examples from source', () => { ok: true, value: ['a', 'a', 'a'], index: 4, - furthest: 2, - expected: ["'b'"], }); }); it('map: Transform a parsed value through one or more functions.', () => { const result0 = map(digits, (n) => n * 2)('21'); - expect(result0).toEqual({ - ok: true, - value: 42, - index: 2, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 42, index: 2 }); }); it('node: Create a node from parser fields.', () => { @@ -442,28 +247,18 @@ describe('examples from source', () => { ok: true, value: { type: 'binop', left: 1, op: '+', right: 2 }, index: 3, - furthest: -1, - expected: [], }); const result1 = node('number', { value: digits })('123'); expect(result1).toEqual({ ok: true, value: { type: 'number', value: 123 }, index: 3, - furthest: -1, - expected: [], }); }); - it('not: reporting it would produce a misleading expectation.', () => { + it('not: expectation in the final message.', () => { const result0 = not(string('hello'))('world'); - expect(result0).toEqual({ - ok: true, - value: null, - index: 0, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: null, index: 0 }); }); it('nth: Extract the nth element from a parser result array.', () => { @@ -471,97 +266,44 @@ describe('examples from source', () => { sequence(char('a'), char('b'), char('c')), 1, )('abc'); - expect(result0).toEqual({ - ok: true, - value: 'b', - index: 3, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'b', index: 3 }); }); it('optional: Make parser optional (return null on failure, without consuming input).', () => { const result0 = optional(string('hello'))('hello'); - expect(result0).toEqual({ - ok: true, - value: 'hello', - index: 5, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hello', index: 5 }); const result1 = optional(string('hello'))('world'); - expect(result1).toEqual({ - ok: true, - value: null, - index: 0, - furthest: 0, - expected: ["'hello'"], - }); + expect(result1).toEqual({ ok: true, value: null, index: 0 }); }); it('optionalConsume: Consume input if the parser matches, discarding the result.', () => { const result0 = optionalConsume(string('hi'))('hi there'); - expect(result0).toEqual({ - ok: true, - index: 2, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, index: 2 }); }); it('optionalSeparatedBy: Parse items separated by a separator, allowing empty slots.', () => { const result0 = optionalSeparatedBy(digits, char(','))('1,2'); - expect(result0).toEqual({ - ok: true, - value: [1, 2], - index: 3, - furthest: 3, - expected: ["','"], - }); + expect(result0).toEqual({ ok: true, value: [1, 2], index: 3 }); }); it('outer: Extract outer values from a sequence of 3 parsers (skip middle).', () => { const result0 = outer(char('('), string('hi'), char(')'))('(hi)'); - expect(result0).toEqual({ - ok: true, - value: ['(', ')'], - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: ['(', ')'], index: 4 }); }); it('padded: Parse content surrounded by optional whitespace.', () => { const result0 = padded(string('hi'))(' hi '); - expect(result0).toEqual({ - ok: true, - value: 'hi', - index: 6, - furthest: 6, - expected: ['whitespace'], - }); + expect(result0).toEqual({ ok: true, value: 'hi', index: 6 }); }); it('parenthesized: Parse content surrounded by parentheses.', () => { const result0 = parenthesized(string('hi'))('(hi)'); - expect(result0).toEqual({ - ok: true, - value: 'hi', - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hi', index: 4 }); }); it('peek: Look ahead without consuming input.', () => { const result0 = peek(string('hello'))('hello world'); - expect(result0).toEqual({ - ok: true, - value: 'hello', - index: 0, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hello', index: 0 }); }); it('postfix: Apply zero or more postfix operators to an atom.', () => { @@ -569,13 +311,7 @@ describe('examples from source', () => { digits, map(char('!'), () => (n) => n * 2), )('3!'); - expect(result0).toEqual({ - ok: true, - value: 6, - index: 2, - furthest: 2, - expected: ["'!'"], - }); + expect(result0).toEqual({ ok: true, value: 6, index: 2 }); }); it('prefix: Apply zero or more prefix operators to an atom.', () => { @@ -583,46 +319,22 @@ describe('examples from source', () => { map(char('-'), () => (n) => -n), digits, )('-3'); - expect(result0).toEqual({ - ok: true, - value: -3, - index: 2, - furthest: 1, - expected: ["'-'"], - }); + expect(result0).toEqual({ ok: true, value: -3, index: 2 }); }); it('pure: Always succeed with a value without consuming input.', () => { const result0 = pure(42)('abc'); - expect(result0).toEqual({ - ok: true, - value: 42, - index: 0, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 42, index: 0 }); }); it('quoted: Parse content surrounded by single or double quotes.', () => { const result0 = quoted(string('hello'))('"hello"'); - expect(result0).toEqual({ - ok: true, - value: 'hello', - index: 7, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hello', index: 7 }); }); it('recover: Use fallback value when parser fails.', () => { const result0 = recover(string('hello'), 'default')('world'); - expect(result0).toEqual({ - ok: true, - value: 'default', - index: 0, - furthest: 0, - expected: ["'hello'"], - }); + expect(result0).toEqual({ ok: true, value: 'default', index: 0 }); }); it('right: Keep only the right result from a sequence.', () => { @@ -630,57 +342,27 @@ describe('examples from source', () => { string('hello'), string('world'), )('helloworld'); - expect(result0).toEqual({ - ok: true, - value: 'world', - index: 10, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'world', index: 10 }); }); it('separatedBy: after it does not, the list ends before the separator.', () => { const result0 = separatedBy(digits, char(','))('1,2,3'); - expect(result0).toEqual({ - ok: true, - value: [1, 2, 3], - index: 5, - furthest: 5, - expected: ["','"], - }); + expect(result0).toEqual({ ok: true, value: [1, 2, 3], index: 5 }); }); it('separatedBy1: Parse one or more items separated by a separator.', () => { const result0 = separatedBy1(digits, char(','))('1,2,3'); - expect(result0).toEqual({ - ok: true, - value: [1, 2, 3], - index: 5, - furthest: 5, - expected: ["','"], - }); + expect(result0).toEqual({ ok: true, value: [1, 2, 3], index: 5 }); }); it('separatedEndBy: Parse zero or more items separated by a separator, allowing a trailing one.', () => { const result0 = separatedEndBy(digits, char(','))('1,2,3,'); - expect(result0).toEqual({ - ok: true, - value: [1, 2, 3], - index: 6, - furthest: 6, - expected: ['digit'], - }); + expect(result0).toEqual({ ok: true, value: [1, 2, 3], index: 6 }); }); it('separatedEndBy1: Parse one or more items separated by a separator, allowing a trailing one.', () => { const result0 = separatedEndBy1(digits, char(','))('1,2,3,'); - expect(result0).toEqual({ - ok: true, - value: [1, 2, 3], - index: 6, - furthest: 6, - expected: ['digit'], - }); + expect(result0).toEqual({ ok: true, value: [1, 2, 3], index: 6 }); }); it('separatedUntil: Parse items separated by a separator, up to a terminator.', () => { @@ -689,13 +371,7 @@ describe('examples from source', () => { char(','), char(';'), )('1,2,3;'); - expect(result0).toEqual({ - ok: true, - value: [1, 2, 3], - index: 6, - furthest: 5, - expected: ["','"], - }); + expect(result0).toEqual({ ok: true, value: [1, 2, 3], index: 6 }); }); it('sequence: Parse a sequence of parsers and return all results as an array.', () => { @@ -704,42 +380,22 @@ describe('examples from source', () => { ok: true, value: ['a', 'b', 'c'], index: 3, - furthest: -1, - expected: [], }); }); it('skip: Skip a parser n times.', () => { const result0 = skip(char('a'), 2)('aabc'); - expect(result0).toEqual({ - ok: true, - value: null, - index: 2, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: null, index: 2 }); }); it('skipMany: Skip zero or more occurrences (never fails, returns null).', () => { const result0 = skipMany(char('a'))('aaabc'); - expect(result0).toEqual({ - ok: true, - value: null, - index: 3, - furthest: 3, - expected: ["'a'"], - }); + expect(result0).toEqual({ ok: true, value: null, index: 3 }); }); it('skipMany1: Skip one or more occurrences (fails if no matches).', () => { const result0 = skipMany1(char('a'))('aaabc'); - expect(result0).toEqual({ - ok: true, - value: null, - index: 3, - furthest: 3, - expected: ["'a'"], - }); + expect(result0).toEqual({ ok: true, value: null, index: 3 }); }); it('surrounded: Parse content surrounded by delimiters.', () => { @@ -748,40 +404,16 @@ describe('examples from source', () => { string('hi'), char(']'), )('[hi]'); - expect(result0).toEqual({ - ok: true, - value: 'hi', - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hi', index: 4 }); const result1 = surrounded(char('a'), char('b'), char('c'))('abc'); - expect(result1).toEqual({ - ok: true, - value: 'b', - index: 3, - furthest: -1, - expected: [], - }); + expect(result1).toEqual({ ok: true, value: 'b', index: 3 }); }); it('unless: Parse unless condition is true (inverse of guard).', () => { const result0 = unless(false, string('hello'))('hello'); - expect(result0).toEqual({ - ok: true, - value: 'hello', - index: 5, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hello', index: 5 }); const result1 = unless(true, string('hello'))('hello'); - expect(result1).toEqual({ - ok: true, - value: null, - index: 0, - furthest: -1, - expected: [], - }); + expect(result1).toEqual({ ok: true, value: null, index: 0 }); }); it('until: Parse zero or more until terminator matches, leaving the terminator unconsumed.', () => { @@ -790,46 +422,25 @@ describe('examples from source', () => { ok: true, value: ['a', 'a', 'a'], index: 3, - furthest: 2, - expected: ["'b'"], }); }); it('validate: Validate parsed value with a predicate.', () => { const result0 = validate(digit, (n) => n > 5)('7'); - expect(result0).toEqual({ - ok: true, - value: 7, - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 7, index: 1 }); const result1 = validate(digit, (n) => n > 5)('3'); expect(result1).toEqual({ ok: false, index: 0, - furthest: 0, expected: ['valid value'], }); }); it('value: Replace parsed value with a constant.', () => { const result0 = value(string('true'), true)('true'); - expect(result0).toEqual({ - ok: true, - value: true, - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: true, index: 4 }); const result1 = value(string('null'), null)('null'); - expect(result1).toEqual({ - ok: true, - value: null, - index: 4, - furthest: -1, - expected: [], - }); + expect(result1).toEqual({ ok: true, value: null, index: 4 }); }); it('when: Branch on a boolean parser result.', () => { @@ -838,40 +449,54 @@ describe('examples from source', () => { pure('many'), pure('one'), )('*rest'); - expect(result0).toEqual({ - ok: true, - value: 'many', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'many', index: 1 }); const result1 = when( flag(char('*')), pure('many'), pure('one'), )('abc'); - expect(result1).toEqual({ - ok: true, - value: 'one', - index: 0, - furthest: 0, - expected: ["'*'"], - }); + expect(result1).toEqual({ ok: true, value: 'one', index: 0 }); }); }); describe('core', () => { + it('context: Creates a fresh context. {@link run} and {@link parse} make one per call.', () => { + const result0 = context(); + expect(result0).toEqual({ furthest: -1, expected: [] }); + }); + + it('context: them. Callers rely on that identity to skip allocating.', () => { + const result0 = union(['digit'], ['digit']); + expect(result0).toEqual(['digit']); + }); + + it('context: array every time without it being altered underneath.', () => { + const ctx = context(); + record(ctx, 3, ['digit']); + const result0 = ctx; + expect(result0).toEqual({ furthest: 3, expected: ['digit'] }); + }); + it('error: 1-based line/column and the set of expectations at that offset.', () => { const result0 = new ParseError('a=1,b=x', 6, ['digit']).line; expect(result0).toEqual(1); }); - it('failure: {@link choice}, so a failing alternation reports every branch it tried.', () => { - const result0 = failure(3, 'digit'); + it('failure: later backtracked over.', () => { + const result0 = failure(undefined, 3, 'digit'); + expect(result0).toEqual({ + ok: false, + index: 3, + expected: ['digit'], + }); + }); + + it('failure: avoiding a fresh array on every failure.', () => { + const expected = ['digit']; + const result0 = reject(undefined, 3, expected); expect(result0).toEqual({ ok: false, index: 3, - furthest: 3, expected: ['digit'], }); }); @@ -913,25 +538,18 @@ describe('examples from source', () => { expect(result2).toEqual(3); }); - it('label: has committed, its own deeper error is more useful than the label.', () => { + it('label: be suppressed rather than leaking into the message.', () => { const result0 = label(char('x'), 'letter x')(''); expect(result0).toEqual({ ok: false, index: 0, - furthest: 0, expected: ['letter x'], }); }); it('lazy: Defers parser creation, useful for recursive grammars.', () => { const result0 = lazy(() => char('a'))('abc'); - expect(result0).toEqual({ - ok: true, - value: 'a', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'a', index: 1 }); }); it('locate: Converts a character offset into a 1-based line and column.', () => { @@ -947,47 +565,22 @@ describe('examples from source', () => { expect(result0).toEqual('hello'); }); - it('memoize: the cache is dropped as soon as a different input is parsed.', () => { + it('memoize: error message rather than silently weakening it.', () => { const memoDigits = memoize(digits); const result0 = memoDigits('123'); - expect(result0).toEqual({ - ok: true, - value: 123, - index: 3, - furthest: -1, - expected: [], - }); - }); - - it('merge: common case and keeps the hot path allocation-free.', () => { - const result0 = merge(failure(7, 'digit'), success('ok', 3)); - expect(result0).toEqual({ - ok: true, - value: 'ok', - index: 3, - furthest: 7, - expected: ['digit'], - }); + expect(result0).toEqual({ ok: true, value: 123, index: 3 }); }); it('parse: offset, line, column, expectations and a formatted message.', () => { - const result0 = parse(digits, '12x').message; - expect(result0).toEqual( - "1:3 expected end of input, found 'x'\n 1 | 12x\n | ^", - ); + const result0 = parse(digits, '12x').ok; + expect(result0).toEqual(false); }); - it('parser: to the top-level error message.', () => { + it('parser: recorded inside your parser reach the final error message.', () => { const result0 = create((input, index = 0) => success('parsed', index + 6), )('hello world'); - expect(result0).toEqual({ - ok: true, - value: 'parsed', - index: 6, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'parsed', index: 6 }); }); it('run: point — if the parse fails or leaves input unconsumed.', () => { @@ -995,36 +588,18 @@ describe('examples from source', () => { expect(result0).toEqual('hello'); }); - it('success: {@link merge} to propagate it — a fresh success starts with no trace.', () => { + it('success: Creates a successful result with a value and the offset reached in the input.', () => { const result0 = success('hello', 5); - expect(result0).toEqual({ - ok: true, - value: 'hello', - index: 5, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hello', index: 5 }); }); }); describe('primitives', () => { it('alphaNum: Parse a single alphanumeric character.', () => { const result0 = alphaNum('a1'); - expect(result0).toEqual({ - ok: true, - value: 'a', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'a', index: 1 }); const result1 = alphaNum('1a'); - expect(result1).toEqual({ - ok: true, - value: '1', - index: 1, - furthest: -1, - expected: [], - }); + expect(result1).toEqual({ ok: true, value: '1', index: 1 }); }); it('alphaNums: Parse one or more alphanumeric characters as a string.', () => { @@ -1033,150 +608,65 @@ describe('examples from source', () => { ok: true, value: 'placeholder', index: 11, - furthest: 11, - expected: ['alphanumeric character'], }); }); it('anyChar: Parse any single character.', () => { const result0 = anyChar('abc'); - expect(result0).toEqual({ - ok: true, - value: 'a', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'a', index: 1 }); }); it('bool: Parse a boolean literal.', () => { const result0 = bool('true'); - expect(result0).toEqual({ - ok: true, - value: true, - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: true, index: 4 }); const result1 = bool('false'); - expect(result1).toEqual({ - ok: true, - value: false, - index: 5, - furthest: 0, - expected: ["'true'"], - }); + expect(result1).toEqual({ ok: true, value: false, index: 5 }); const result2 = bool('trueABC'); - expect(result2).toEqual({ - ok: true, - value: true, - index: 4, - furthest: -1, - expected: [], - }); + expect(result2).toEqual({ ok: true, value: true, index: 4 }); }); it('crlf: Parse CRLF line ending.', () => { const result0 = crlf('\r\nabc'); - expect(result0).toEqual({ - ok: true, - value: '\r\n', - index: 2, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: '\r\n', index: 2 }); }); it('digit: Parse a single digit and return as number.', () => { const result0 = digit('5abc'); - expect(result0).toEqual({ - ok: true, - value: 5, - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 5, index: 1 }); }); it('digits: Parse one or more digits and return as a number.', () => { const result0 = digits('123'); - expect(result0).toEqual({ - ok: true, - value: 123, - index: 3, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 123, index: 3 }); }); it('eof: Match the end of the input.', () => { const result0 = eof(''); - expect(result0).toEqual({ - ok: true, - value: null, - index: 0, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: null, index: 0 }); }); it('eol: Parse end of line (\\n, \\r\\n, or EOF).', () => { const result0 = eol('\nabc'); - expect(result0).toEqual({ - ok: true, - value: '\n', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: '\n', index: 1 }); }); it('float: Parse a floating point number.', () => { const result0 = float('1.23'); - expect(result0).toEqual({ - ok: true, - value: 1.23, - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 1.23, index: 4 }); const result1 = float('-2.5'); - expect(result1).toEqual({ - ok: true, - value: -2.5, - index: 4, - furthest: -1, - expected: [], - }); + expect(result1).toEqual({ ok: true, value: -2.5, index: 4 }); const result2 = float('1.23abc'); - expect(result2).toEqual({ - ok: true, - value: 1.23, - index: 4, - furthest: -1, - expected: [], - }); + expect(result2).toEqual({ ok: true, value: 1.23, index: 4 }); }); it('hexDigit: Parse a single hexadecimal digit.', () => { const result0 = hexDigit('fF9'); - expect(result0).toEqual({ - ok: true, - value: 'f', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'f', index: 1 }); }); it('hexDigits: Parse one or more hex digits as a string.', () => { - const result0 = hexDigits('placeholder'); - expect(result0).toEqual({ - ok: false, - index: 0, - furthest: 0, - expected: ['hex digit'], - }); + const result0 = hexDigits('deadbeef'); + expect(result0).toEqual({ ok: true, value: 'deadbeef', index: 8 }); }); it('identifier: Parse an identifier — starts with letter or underscore, no leading digit, no hyphen.', () => { @@ -1185,47 +675,21 @@ describe('examples from source', () => { ok: true, value: 'variable_name', index: 13, - furthest: -1, - expected: [], }); }); it('integer: Parse a signed integer.', () => { const result0 = integer('42'); - expect(result0).toEqual({ - ok: true, - value: 42, - index: 2, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 42, index: 2 }); const result1 = integer('-7'); - expect(result1).toEqual({ - ok: true, - value: -7, - index: 2, - furthest: -1, - expected: [], - }); + expect(result1).toEqual({ ok: true, value: -7, index: 2 }); const result2 = integer('123abc'); - expect(result2).toEqual({ - ok: true, - value: 123, - index: 3, - furthest: -1, - expected: [], - }); + expect(result2).toEqual({ ok: true, value: 123, index: 3 }); }); it('letter: Parse a single letter.', () => { const result0 = letter('abc'); - expect(result0).toEqual({ - ok: true, - value: 'a', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'a', index: 1 }); }); it('letters: Parse one or more letters as a string.', () => { @@ -1234,50 +698,24 @@ describe('examples from source', () => { ok: true, value: 'placeholder', index: 11, - furthest: 11, - expected: ['letter'], }); }); it('line: Parse until end of line.', () => { const result0 = line('hello\nworld'); - expect(result0).toEqual({ - ok: true, - value: 'hello', - index: 5, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hello', index: 5 }); }); it('literal: Parse a word-like value including hyphens.', () => { const result0 = literal('foo-bar'); - expect(result0).toEqual({ - ok: true, - value: 'foo-bar', - index: 7, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'foo-bar', index: 7 }); const result1 = literal('123abc'); - expect(result1).toEqual({ - ok: true, - value: '123abc', - index: 6, - furthest: -1, - expected: [], - }); + expect(result1).toEqual({ ok: true, value: '123abc', index: 6 }); }); it('lowercase: Parse a single lowercase letter.', () => { const result0 = lowercase('abc'); - expect(result0).toEqual({ - ok: true, - value: 'a', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'a', index: 1 }); }); it('lowercases: Parse one or more lowercase letters as a string.', () => { @@ -1286,345 +724,154 @@ describe('examples from source', () => { ok: true, value: 'placeholder', index: 11, - furthest: 11, - expected: ['lowercase letter'], }); }); it('nl: Parse a newline character.', () => { const result0 = nl('\ntext'); - expect(result0).toEqual({ - ok: true, - value: '\n', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: '\n', index: 1 }); }); it('number: Parse an integer or float.', () => { const result0 = number('42'); - expect(result0).toEqual({ - ok: true, - value: 42, - index: 2, - furthest: 0, - expected: ['/-?\\d+\\.\\d+/'], - }); + expect(result0).toEqual({ ok: true, value: 42, index: 2 }); const result1 = number('3.14'); - expect(result1).toEqual({ - ok: true, - value: 3.14, - index: 4, - furthest: -1, - expected: [], - }); + expect(result1).toEqual({ ok: true, value: 3.14, index: 4 }); const result2 = number('-7'); - expect(result2).toEqual({ - ok: true, - value: -7, - index: 2, - furthest: 0, - expected: ['/-?\\d+\\.\\d+/'], - }); + expect(result2).toEqual({ ok: true, value: -7, index: 2 }); const result3 = number('-2.5'); - expect(result3).toEqual({ - ok: true, - value: -2.5, - index: 4, - furthest: -1, - expected: [], - }); + expect(result3).toEqual({ ok: true, value: -2.5, index: 4 }); }); it('octDigit: Parse a single octal digit.', () => { const result0 = octDigit('7abc'); - expect(result0).toEqual({ - ok: true, - value: '7', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: '7', index: 1 }); }); it('octDigits: Parse one or more octal digits as a string.', () => { - const result0 = octDigits('placeholder'); - expect(result0).toEqual({ - ok: false, - index: 0, - furthest: 0, - expected: ['octal digit'], - }); + const result0 = octDigits('755rest'); + expect(result0).toEqual({ ok: true, value: '755', index: 3 }); }); it('position: {@link locate} to attach line/column information to a parsed node.', () => { const result0 = position('abc'); - expect(result0).toEqual({ - ok: true, - value: 0, - index: 0, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 0, index: 0 }); const result1 = right(string('ab'), position)('abc'); - expect(result1).toEqual({ - ok: true, - value: 2, - index: 2, - furthest: -1, - expected: [], - }); + expect(result1).toEqual({ ok: true, value: 2, index: 2 }); }); it('rest: Consume and return everything left in the input.', () => { const result0 = rest('abc'); - expect(result0).toEqual({ - ok: true, - value: 'abc', - index: 3, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'abc', index: 3 }); }); it('space: Parse a single space character.', () => { const result0 = space(' abc'); - expect(result0).toEqual({ - ok: true, - value: ' ', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: ' ', index: 1 }); }); it('spaces: Parse one or more spaces as a string.', () => { - const result0 = spaces('placeholder'); - expect(result0).toEqual({ - ok: false, - index: 0, - furthest: 0, - expected: ['space'], - }); + const result0 = spaces(' abc'); + expect(result0).toEqual({ ok: true, value: ' ', index: 3 }); }); it('tab: Parse tab character.', () => { const result0 = tab('\ttext'); - expect(result0).toEqual({ - ok: true, - value: '\t', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: '\t', index: 1 }); }); it('uppercase: Parses a single uppercase letter.', () => { const result0 = uppercase('ABC'); - expect(result0).toEqual({ - ok: true, - value: 'A', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'A', index: 1 }); }); it('uppercases: Parse one or more uppercase letters as a string.', () => { - const result0 = uppercases('placeholder'); - expect(result0).toEqual({ - ok: false, - index: 0, - furthest: 0, - expected: ['uppercase letter'], - }); + const result0 = uppercases('ABCdef'); + expect(result0).toEqual({ ok: true, value: 'ABC', index: 3 }); }); it('whitespace: Parses a single whitespace character.', () => { const result0 = whitespace(' abc'); - expect(result0).toEqual({ - ok: true, - value: ' ', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: ' ', index: 1 }); }); it('whitespaces: Parse one or more whitespaces as a string.', () => { - const result0 = whitespaces('placeholder'); - expect(result0).toEqual({ - ok: false, - index: 0, - furthest: 0, - expected: ['whitespace'], - }); + const result0 = whitespaces(' \t\nabc'); + expect(result0).toEqual({ ok: true, value: ' \t\n', index: 3 }); }); }); describe('terminals', () => { it('char: Parse a specific character.', () => { const result0 = char('A')('ABC'); - expect(result0).toEqual({ - ok: true, - value: 'A', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'A', index: 1 }); }); it('charOf: Parse any character from a set.', () => { const result0 = charOf(['a', 'b', 'c'])('abc'); - expect(result0).toEqual({ - ok: true, - value: 'a', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'a', index: 1 }); }); it('noneOf: Parse any character not in the set.', () => { const result0 = noneOf(['a', 'b', 'c'])('xyz'); - expect(result0).toEqual({ - ok: true, - value: 'x', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'x', index: 1 }); }); it('oneOf: Parse one string from a set of strings (longest match wins).', () => { const result0 = oneOf(['hello', 'hell', 'help'])('helpful'); - expect(result0).toEqual({ - ok: true, - value: 'help', - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'help', index: 4 }); }); - it('regex: and is stripped.', () => { + it('regex: start and still carries meaning.', () => { const result0 = regex(/^\w+/)('hello world'); - expect(result0).toEqual({ - ok: true, - value: 'hello', - index: 5, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hello', index: 5 }); }); it('satisfy: Parse a character satisfying a predicate.', () => { const result0 = satisfy((c) => c === 'a')('abc'); - expect(result0).toEqual({ - ok: true, - value: 'a', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'a', index: 1 }); }); it('string: Parse a specific string.', () => { const result0 = string('hello')('hello world'); - expect(result0).toEqual({ - ok: true, - value: 'hello', - index: 5, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'hello', index: 5 }); }); it('stringOf: Parse first character that exists in string (like charOf but for a string).', () => { const result0 = stringOf('abc')('abcdef'); - expect(result0).toEqual({ - ok: true, - value: 'a', - index: 1, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'a', index: 1 }); }); it('take: Take n characters.', () => { const result0 = take(3)('abcdef'); - expect(result0).toEqual({ - ok: true, - value: 'abc', - index: 3, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'abc', index: 3 }); }); it('takeWhile: Takes characters while the predicate returns true.', () => { const result0 = takeWhile((c) => c !== 'x')('abcx'); - expect(result0).toEqual({ - ok: true, - value: 'abc', - index: 3, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'abc', index: 3 }); }); it('token: strings that might be followed by any character.', () => { const result0 = token('let')('let x'); - expect(result0).toEqual({ - ok: true, - value: 'let', - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'let', index: 4 }); const result1 = token('let')('let1'); - expect(result1).toEqual({ - ok: true, - value: 'let', - index: 3, - furthest: -1, - expected: [], - }); + expect(result1).toEqual({ ok: true, value: 'let', index: 3 }); const result2 = token('let')('let x'); - expect(result2).toEqual({ - ok: true, - value: 'let', - index: 5, - furthest: -1, - expected: [], - }); + expect(result2).toEqual({ ok: true, value: 'let', index: 5 }); }); it('word: Use this when parsing keywords that should not be part of a longer identifier.', () => { const result0 = word('let')('let x'); - expect(result0).toEqual({ - ok: true, - value: 'let', - index: 4, - furthest: -1, - expected: [], - }); + expect(result0).toEqual({ ok: true, value: 'let', index: 4 }); const result1 = word('let')('let1'); expect(result1).toEqual({ ok: false, index: 3, - furthest: 3, expected: ['/(?!\\w)/'], }); const result2 = word('if')('if (x)'); - expect(result2).toEqual({ - ok: true, - value: 'if', - index: 3, - furthest: -1, - expected: [], - }); + expect(result2).toEqual({ ok: true, value: 'if', index: 3 }); }); }); @@ -1659,13 +906,7 @@ describe('examples from source', () => { it('pipe: Pipe parser functions together.', () => { const result0 = pipe(lexeme)(letters)('xyz abc'); - expect(result0).toEqual({ - ok: true, - value: 'xyz', - index: 6, - furthest: 3, - expected: ['letter'], - }); + expect(result0).toEqual({ ok: true, value: 'xyz', index: 6 }); }); it('pop: Get the last element of an array.', () => { diff --git a/test/helpers.ts b/test/helpers.ts index 5906f97..f52cb7c 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -13,7 +13,7 @@ const OPERATOR = /\*\*|[+\-*/]/y; export const operation = create((input, index = 0) => { OPERATOR.lastIndex = index; const [operator] = OPERATOR.exec(input) || []; - if (!operator) return failure(index, 'operator'); + if (!operator) return failure(undefined, index, 'operator'); return success(OPERATORS[operator], index + operator.length); }); @@ -23,7 +23,7 @@ const DIGITS = /\d+/y; export const digits = create((input, index = 0) => { DIGITS.lastIndex = index; const [match] = DIGITS.exec(input) || []; - if (!match) return failure(index, 'digit'); + if (!match) return failure(undefined, index, 'digit'); return success(parseInt(match), index + match.length); }); diff --git a/test/utils.ts b/test/utils.ts index c181ccc..25b4bf9 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -13,7 +13,7 @@ export const createTestParser = (tester: T) => { return success(tester, index + stringTester.length); } - return failure(index, stringTester); + return failure(undefined, index, stringTester); }); }; @@ -53,6 +53,7 @@ export const assertFailure = ( } if (expected !== undefined) { - expect([...result.expected].sort()).toEqual([...expected].sort()); + const actual = (result as { expected: readonly string[] }).expected; + expect([...actual].sort()).toEqual([...expected].sort()); } }; From 3f7e175918dedfe25e683c23d395a3e2450f64e9 Mon Sep 17 00:00:00 2001 From: Oleg Kamlowski Date: Sat, 19 Sep 2026 21:00:00 +0200 Subject: [PATCH 3/5] build: emit shared chunks instead of five independent bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each entry point was built separately, so the core types and constructors were inlined into four of the five bundles. Importing from more than one entry — which is how the library is meant to be used, and what the quick start itself does — shipped that code several times over. One build with five entries emits it once as a shared chunk: one entry: terminals 2264 B -> 2924 B quickstart: core+comb+term+prim 16967 B -> 14797 B total shipped 17357 B -> 15187 B A single entry point costs a little more unbundled, since a chunk is an all-or-nothing unit. Anyone who bundles comes out ahead, because the duplicate copies are what a tree-shaker could never remove: app importing char + many 1232 B -> 850 B Type resolution is unchanged, and the chunks are picked up by the existing `files: ["dist"]`. --- CLAUDE.md | 2 +- README.md | 2 +- doc/README.template.md | 2 +- tsdown.config.ts | 50 ++++++++++++++++-------------------------- 4 files changed, 22 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b43a27..bd40b04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ npx vitest run src/combinators/map.test.ts ## Architecture -ESM-only TypeScript parser combinator library. Five separate entry points, each compiled to its own `.mjs` bundle: +ESM-only TypeScript parser combinator library. Five entry points, built together so that code shared between them is emitted once as a common chunk rather than inlined into each: | Entry | Path | Purpose | | -------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------- | diff --git a/README.md b/README.md index af78499..fea4479 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ run(csv.row, '"a,b",c'); // ['a,b', 'c'] ## Entry points -Nothing is re-exported across entry points, so your bundler only ever sees what you import. +Nothing is re-exported across entry points, so your bundler only ever sees what you import. Code shared between them ships once, however many you import from. | Import from | Contains | | -------------------- | ------------------------------------------------------------------ | diff --git a/doc/README.template.md b/doc/README.template.md index fb9a99e..ca8fe9f 100644 --- a/doc/README.template.md +++ b/doc/README.template.md @@ -51,7 +51,7 @@ run(csv.row, '"a,b",c'); // ['a,b', 'c'] ## Entry points -Nothing is re-exported across entry points, so your bundler only ever sees what you import. +Nothing is re-exported across entry points, so your bundler only ever sees what you import. Code shared between them ships once, however many you import from. | Import from | Contains | | -------------------- | ------------------------------------------------------------------ | diff --git a/tsdown.config.ts b/tsdown.config.ts index c54b2dc..8ac7720 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,34 +1,22 @@ import { defineConfig } from 'tsdown'; -export default defineConfig([ - { - entry: { index: './src/index.ts' }, - format: ['esm'], - minify: true, - dts: true, +/** + * One build with five entries rather than five separate builds. + * + * Every entry point shares the core types and constructors. Built separately, + * each bundle inlines its own copy, so importing from more than one entry — + * which is the normal way to use this library — ships that code several times + * over. A single build emits it once as a shared chunk instead. + */ +export default defineConfig({ + entry: { + index: './src/index.ts', + combinators: './src/combinators/index.ts', + terminals: './src/terminals/index.ts', + primitives: './src/primitives/index.ts', + utils: './src/utils/index.ts', }, - { - entry: { combinators: './src/combinators/index.ts' }, - format: ['esm'], - minify: true, - dts: true, - }, - { - entry: { terminals: './src/terminals/index.ts' }, - format: ['esm'], - minify: true, - dts: true, - }, - { - entry: { primitives: './src/primitives/index.ts' }, - format: ['esm'], - minify: true, - dts: true, - }, - { - entry: { utils: './src/utils/index.ts' }, - format: ['esm'], - minify: true, - dts: true, - }, -]); + format: ['esm'], + minify: true, + dts: true, +}); From bf81eb5c8d5651dc9310b039a3a9ca4e4b204cd3 Mon Sep 17 00:00:00 2001 From: Oleg Kamlowski Date: Sat, 19 Sep 2026 21:10:00 +0200 Subject: [PATCH 4/5] docs: bring CLAUDE.md up to date with the context model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core types block still described the pre-0.3.0 shape — a parser taking a sliced suffix and a failure carrying an optional message — so it contradicted the code it is meant to explain. It now documents the offset and the parse context, including the one rule that is easy to get wrong and impossible to notice: a parser you call must be passed `ctx`, or the parse still succeeds and only the error message quietly suffers. Also corrects three names that no longer matched: `npm run coverage` and `npm run watch` are `test:coverage` and `build:watch`, and `node` was described as an alias for an `ast` export that does not exist. --- CLAUDE.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd40b04..d9c43c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,12 +5,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Commands ```bash -npm test # run all tests once +npm test # run all tests once (vitest, typecheck enabled) npm run test:watch # watch mode -npm run coverage # coverage report +npm run test:coverage # coverage report +npm run test:types # check the published type resolution (attw) npm run build # compile to dist/ -npm run watch # build in watch mode +npm run build:watch # build in watch mode npm run lint # oxlint npm run lint:fix # auto-fix lint issues @@ -33,7 +34,7 @@ ESM-only TypeScript parser combinator library. Five entry points, built together | Entry | Path | Purpose | | -------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------- | -| `unitas` | `src/index.ts` → `src/core/` | Core types: `Parser`, `Result`, `success`, `failure`, `run`, `grammar`, `lazy`, `memoize` | +| `unitas` | `src/index.ts` → `src/core/` | Core types: `Parser`, `Result`, `success`/`failure`, `run`/`parse`, `grammar`, `lazy`, `memoize` | | `unitas/terminals` | `src/terminals/` | Factory functions that match input directly (`char`, `regex`, `string`, `satisfy`, …) | | `unitas/primitives` | `src/primitives/` | Pre-built parser instances (`digit`, `letters`, `whitespace`, …) | | `unitas/combinators` | `src/combinators/` | Higher-order parsers that compose other parsers (`map`, `choice`, `sequence`, `many`, …) | @@ -42,13 +43,20 @@ ESM-only TypeScript parser combinator library. Five entry points, built together ### Core types (`src/core/`) ```typescript -type Parser = (input: string) => Result; +type Parser = (input: string, index?: number, ctx?: Context) => Result; type Result = Success | Failure; -type Success = { ok: true; value: T; remaining: string }; -type Failure = { ok: false; error?: string }; +type Success = { ok: true; value: T; index: number }; +type Failure = { ok: false; index: number; expected: readonly string[] }; +type Context = { furthest: number; expected: readonly string[] }; ``` -`grammar` enables mutual recursion — rules receive a proxy object `p` so they can reference sibling rules by name without forward declarations. `lazy` defers evaluation for self-referential parsers. +Parsers read `input` from an offset and never slice it, so a position stays meaningful all the way up to the error message. + +`ctx` is the parse context, created per `run`/`parse`. Failures record themselves into it via `failure(ctx, …)`, and because it only moves forward, backtracking cannot lose what a discarded branch expected — that is what lets an error point at the furthest position reached rather than wherever parsing stopped. **Any parser you call must be passed `ctx`**; omitting it still parses correctly but silently degrades the error message. `src/core/propagation.test.ts` checks every call site for this. + +`not` and `label` deliberately run their inner parser against an isolated context, since they suppress the failure rather than report it. + +`grammar` enables mutual recursion — rules receive a proxy object `p` so they can reference sibling rules by name without forward declarations, and a left-recursive rule is reported by name instead of overflowing the stack. `lazy` defers evaluation for self-referential parsers. ### File conventions @@ -71,4 +79,4 @@ type Failure = { ok: false; error?: string }; - `src/**/*.test.ts` — unit tests co-located with source (vitest, typecheck enabled) - `test/examples.test.ts` — generated from README examples, do not edit by hand -- `src/combinators/node.test.ts` — newly added, covers `node` (alias for `ast`) +- `src/core/propagation.test.ts` — guards that every parser call site forwards `ctx`; covers new files automatically From 85c23bddd8edd4c0ef5bf14bf72d68f965f2c18e Mon Sep 17 00:00:00 2001 From: Oleg Kamlowski Date: Sat, 19 Sep 2026 11:12:33 +0200 Subject: [PATCH 5/5] test(core): cover the error-reporting paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coveralls was failing the PR: the new error machinery had no tests of its own, and it was reached only incidentally by tests aimed at other things. `format`, `parse` and the two branches in `diagnose` that pick the context over the result — the ones that make an error point at the furthest offset rather than where parsing stopped — were the gaps that mattered, since that behaviour is the reason the release exists. Adds `format.test.ts` and `parse.test.ts`, and covers the left-recursion guard in `grammar`, the committed branch of `label` that keeps the inner error, and `take`'s singular/plural wording. Also drops an unreachable fallback in `format`: `line - 1` is the number of newlines before the offset and `split('\n')` always yields one more entry than that, so the index is never out of range. Statements, branches, functions and lines are all at 100%. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/format.test.ts | 43 ++++++++++++++++++++++++++++++++++++++ src/core/format.ts | 2 +- src/core/grammar.test.ts | 15 +++++++++++++ src/core/label.test.ts | 10 +++++++++ src/core/parse.test.ts | 34 ++++++++++++++++++++++++++++++ src/core/run.test.ts | 29 +++++++++++++++++++++++++ src/terminals/take.test.ts | 12 +++++++++++ 7 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/core/format.test.ts create mode 100644 src/core/parse.test.ts diff --git a/src/core/format.test.ts b/src/core/format.test.ts new file mode 100644 index 0000000..88b82eb --- /dev/null +++ b/src/core/format.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { format } from './format'; + +describe('format', () => { + it('should point a caret at the offset', () => { + expect(format('a=1,b=x', 6, ['digit'])).toBe( + ["1:7 expected digit, found 'x'", ' 1 | a=1,b=x', ' | ^'].join('\n'), + ); + }); + + it('should report the line the offset falls on', () => { + expect(format('one\ntwo\nthree', 8, ['digit'])).toBe( + ["3:1 expected digit, found 't'", ' 3 | three', ' | ^'].join('\n'), + ); + }); + + it('should say end of input when there is nothing at the offset', () => { + expect(format('ab', 2, ['digit'])).toContain('found end of input'); + }); + + it('should escape a control character rather than print it raw', () => { + expect(format('a\nb', 1, ['digit'])).toContain("found '\\n'"); + }); + + it('should list several expectations', () => { + expect(format('x', 0, ['digit', 'letter', 'comma'])).toContain( + 'expected comma, digit or letter', + ); + }); + + it('should fall back when nothing was expected', () => { + expect(format('x', 0, [])).toContain('expected something else'); + }); + + it('should widen the gutter for larger line numbers', () => { + const input = Array.from({ length: 10 }, () => 'x').join('\n'); + + expect(format(input, 18, ['digit'])).toBe( + ["10:1 expected digit, found 'x'", ' 10 | x', ' | ^'].join('\n'), + ); + }); +}); diff --git a/src/core/format.ts b/src/core/format.ts index a235d09..2049997 100644 --- a/src/core/format.ts +++ b/src/core/format.ts @@ -32,7 +32,7 @@ export const format = ( ): string => { const { line, column } = locate(input, index); const lines = input.split('\n'); - const source = lines[line - 1] ?? ''; + const source = lines[line - 1]; const gutter = String(line); const pad = ' '.repeat(gutter.length); const headline = `${line}:${column} ${describeExpected(expected)}, found ${describeFound(input, index)}`; diff --git a/src/core/grammar.test.ts b/src/core/grammar.test.ts index a5cbd1d..04c0959 100644 --- a/src/core/grammar.test.ts +++ b/src/core/grammar.test.ts @@ -5,6 +5,7 @@ import { choice } from '../combinators/choice'; import { map } from '../combinators/map'; import { sequence } from '../combinators/sequence'; import { surrounded } from '../combinators/surrounded'; +import { digits } from '../primitives/digits'; import { char } from '../terminals/char'; import { regex } from '../terminals/regex'; import { string } from '../terminals/string'; @@ -91,4 +92,18 @@ describe('grammar', () => { assertSuccess(result, 6, 7); } }); + + it('should name a left-recursive rule instead of overflowing the stack', () => { + const g = grammar<{ expr: number }>({ + expr: (p) => + choice( + map(sequence(p.expr, char('+'), digits), ([l, , r]) => l + r), + digits, + ), + }); + + expect(() => g.expr('1+2')).toThrowError( + 'Left recursion detected in grammar rule "expr" at offset 0', + ); + }); }); diff --git a/src/core/label.test.ts b/src/core/label.test.ts index fda54b2..0799a3b 100644 --- a/src/core/label.test.ts +++ b/src/core/label.test.ts @@ -5,6 +5,9 @@ import { assertSuccess, createTestParser, } from '../../test/utils'; +import { sequence } from '../combinators/sequence'; +import { char } from '../terminals/char'; +import { string } from '../terminals/string'; import { label } from './label'; describe('label', () => { @@ -23,4 +26,11 @@ describe('label', () => { assertFailure(result, 0, ['letter']); }); + + it('should keep the inner error once the parser has consumed input', () => { + const parser = label(sequence(string('aa'), char('!')), 'a bang'); + const result = parser('aab'); + + assertFailure(result, 2, ["'!'"]); + }); }); diff --git a/src/core/parse.test.ts b/src/core/parse.test.ts new file mode 100644 index 0000000..0a8fbf8 --- /dev/null +++ b/src/core/parse.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { many } from '../combinators/many'; +import { sequence } from '../combinators/sequence'; +import { digits } from '../primitives/digits'; +import { letters } from '../primitives/letters'; +import { char } from '../terminals/char'; +import { parse } from './parse'; + +describe('parse', () => { + it('should return the value when the whole input is consumed', () => { + expect(parse(digits, '123')).toEqual({ ok: true, value: 123 }); + }); + + it('should report where a failure happened instead of throwing', () => { + const result = parse(sequence(letters, char('='), digits), 'port=x'); + + expect(result.ok).toBe(false); + expect(result).toMatchObject({ index: 5, line: 1, column: 6 }); + expect(result.ok === false && result.expected).toEqual(['digit']); + }); + + it('should report the line and column on a later line', () => { + const result = parse(many(sequence(letters, char('\n'))), 'ab\ncd\n1'); + + expect(result).toMatchObject({ line: 3, column: 1 }); + }); + + it('should include a formatted excerpt', () => { + const result = parse(digits, '12x'); + + expect(result.ok === false && result.message).toContain(' 1 | 12x'); + }); +}); diff --git a/src/core/run.test.ts b/src/core/run.test.ts index 25397a6..0a8e605 100644 --- a/src/core/run.test.ts +++ b/src/core/run.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; +import { choice } from '../combinators/choice'; +import { many } from '../combinators/many'; +import { separatedBy } from '../combinators/separatedBy'; +import { sequence } from '../combinators/sequence'; +import { digits } from '../primitives/digits'; +import { letters } from '../primitives/letters'; +import { char } from '../terminals/char'; +import { string } from '../terminals/string'; import { createTestParser } from '../../test/utils'; import { failure } from './failure'; import { create } from './parser'; @@ -57,4 +65,25 @@ describe('run', () => { run(swallowing('B'), 'C'); }).toThrowError("1:1 expected A or B, found 'C'"); }); + + it('should report a branch that reached further than the failure', () => { + // choice fails at 0, but the first alternative got to offset 2. + const alternatives = choice(sequence(string('aa'), char('!')), char('z')); + + expect(() => run(alternatives, 'aab')).toThrowError("1:3 expected '!'"); + }); + + it('should report a branch that reached further than a partial success', () => { + // separatedBy succeeds with one pair and stops, but the failure that + // actually matters is inside the pair it backtracked over. + const pairs = separatedBy(sequence(letters, char('='), digits), char(',')); + + expect(() => run(pairs, 'a=1,b=x')).toThrowError('1:7 expected digit'); + }); + + it('should ask for end of input when nothing reached further', () => { + expect(() => run(many(char('a')), 'aab')).toThrowError( + "1:3 expected 'a' or end of input", + ); + }); }); diff --git a/src/terminals/take.test.ts b/src/terminals/take.test.ts index 4383837..b7cc8f0 100644 --- a/src/terminals/take.test.ts +++ b/src/terminals/take.test.ts @@ -31,4 +31,16 @@ describe('take', () => { assertSuccess(result, '', 0); }); + + it('should describe a single character in the singular', () => { + const result = take(1)(''); + + assertFailure(result, 0, ['1 more character']); + }); + + it('should describe several characters in the plural', () => { + const result = take(3)('ab'); + + assertFailure(result, 0, ['3 more characters']); + }); });

= P extends Parser ? T : never; /** * Try each parser in order, return first success. * + * Expectations from every alternative tried at the same offset are unioned, so + * a failing alternation reports all of them rather than only the last. + * * @example - * choice(string('hello'), string('world'))('hello') // { ok: true, value: 'hello', remaining: '' } + * choice(string('hello'), string('world'))('hello') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } */ export const choice = []>( ...parsers: P ): Parser> => { - return create>((input) => { + return create>((input, index = 0) => { + let trace: Result = success(null, index); + for (const parser of parsers) { - const result = parser(input); + const result: Result = merge(trace, parser(input, index)); if (result.ok) { - return result; + return result as Result>; } + + trace = result; } - return failure(); + return trace.ok ? failure(index) : trace; }); }; diff --git a/src/combinators/concat.test.ts b/src/combinators/concat.test.ts index 25991e3..e272cf3 100644 --- a/src/combinators/concat.test.ts +++ b/src/combinators/concat.test.ts @@ -6,35 +6,35 @@ import { create } from '../core/parser'; import { success } from '../core/success'; import { concat } from './concat'; -const abcParser = create((input) => { - if (input.startsWith('abc')) { - return success(['a', 'b', 'c'], input.slice(3)); +const abcParser = create((input, index = 0) => { + if (input.startsWith('abc', index)) { + return success(['a', 'b', 'c'], index + 3); } - return failure(); + return failure(index); }); -const emptyParser = create((input) => success([], input)); +const emptyParser = create((input, index = 0) => success([], index)); describe('concat', () => { it('should join array results into string', () => { const parser = concat(abcParser); const result = parser('abc'); - assertSuccess(result, 'abc', ''); + assertSuccess(result, 'abc', 3); }); it('should join with separator', () => { const parser = concat(abcParser, '-'); const result = parser('abc'); - assertSuccess(result, 'a-b-c', ''); + assertSuccess(result, 'a-b-c', 3); }); it('should return empty string for empty array', () => { const parser = concat(emptyParser); const result = parser('xyz'); - assertSuccess(result, '', 'xyz'); + assertSuccess(result, '', 0); }); }); diff --git a/src/combinators/concat.ts b/src/combinators/concat.ts index 6151ccd..52c8e97 100644 --- a/src/combinators/concat.ts +++ b/src/combinators/concat.ts @@ -6,8 +6,8 @@ import { map } from './map'; * Join string array parser result into a single string. * * @example - * concat(many(letter))('abc123') // { ok: true, value: 'abc', remaining: '123' } - * concat(many(letter), '-')('abc123') // { ok: true, value: 'a-b-c', remaining: '123' } + * concat(many(letter))('abc123') // { ok: true, value: 'abc', index: 3, furthest: 3, expected: ['letter'] } + * concat(many(letter), '-')('abc123') // { ok: true, value: 'a-b-c', index: 3, furthest: 3, expected: ['letter'] } */ export const concat = ( parser: Parser, diff --git a/src/combinators/consume.test.ts b/src/combinators/consume.test.ts index 6dee132..4fdf01b 100644 --- a/src/combinators/consume.test.ts +++ b/src/combinators/consume.test.ts @@ -13,7 +13,7 @@ describe('consume', () => { const parser = consume(parser1); const result = parser('ABBB'); - assertSuccess(result, null, 'BBB'); + assertSuccess(result, null, 1); }); it('should fail when underlying parser fails', () => { diff --git a/src/combinators/consume.ts b/src/combinators/consume.ts index b2f6c8b..163846d 100644 --- a/src/combinators/consume.ts +++ b/src/combinators/consume.ts @@ -7,7 +7,7 @@ import { map } from './map'; * Consume input but discard the result (return null). * * @example - * consume(string('hello'))('hello world') // { ok: true, value: null, remaining: ' world' } + * consume(string('hello'))('hello world') // { ok: true, value: null, index: 5, furthest: -1, expected: [] } */ export const consume = (parser: Parser) => { return create(map(parser, () => null)); diff --git a/src/combinators/endBy.test.ts b/src/combinators/endBy.test.ts index eabcc8b..5d64705 100644 --- a/src/combinators/endBy.test.ts +++ b/src/combinators/endBy.test.ts @@ -10,7 +10,7 @@ describe('endBy', () => { const parser = endBy(parser1, parser2); const result = parser('A,A,A,'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], ''); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 6); }); it('should parse empty list when no elements', () => { @@ -19,7 +19,7 @@ describe('endBy', () => { const parser = endBy(parser1, parser2); const result = parser('CCC'); - assertSuccess<'A'[]>(result, [], 'CCC'); + assertSuccess<'A'[]>(result, [], 0); }); it('should require terminator after each element', () => { @@ -28,7 +28,7 @@ describe('endBy', () => { const parser = endBy(parser1, parser2); const result = parser('A,A,A'); - assertSuccess<'A'[]>(result, ['A', 'A'], 'A'); + assertSuccess<'A'[]>(result, ['A', 'A'], 4); }); it('should handle single element with terminator', () => { @@ -38,7 +38,7 @@ describe('endBy', () => { const parser = endBy(parser1, parser2); const result = parser('A,'); - assertSuccess<'A'[]>(result, ['A'], ''); + assertSuccess<'A'[]>(result, ['A'], 2); }); it('should handle empty input', () => { @@ -47,6 +47,6 @@ describe('endBy', () => { const parser = endBy(parser1, parser2); const result = parser(''); - assertSuccess<'A'[]>(result, [], ''); + assertSuccess<'A'[]>(result, [], 0); }); }); diff --git a/src/combinators/endBy.ts b/src/combinators/endBy.ts index 4af07d7..bea601b 100644 --- a/src/combinators/endBy.ts +++ b/src/combinators/endBy.ts @@ -9,7 +9,7 @@ import { sequence } from './sequence'; * Zero or more items separated and ending with terminator. * * @example - * endBy(string('item'), char(';'))('item;item;item;') // { ok: true, value: ['item', 'item', 'item'], remaining: '' } + * endBy(string('item'), char(';'))('item;item;item;') // { ok: true, value: ['item', 'item', 'item'], index: 15, furthest: 15, expected: ["'item'"] } */ export const endBy = (parser: Parser, terminator: Parser) => { return create( diff --git a/src/combinators/endBy1.test.ts b/src/combinators/endBy1.test.ts index d360764..a05d52f 100644 --- a/src/combinators/endBy1.test.ts +++ b/src/combinators/endBy1.test.ts @@ -23,7 +23,7 @@ describe('endBy1', () => { const parser = endBy1(parser1, parser2); const result = parser('A,'); - assertSuccess<'A'[]>(result, ['A'], ''); + assertSuccess<'A'[]>(result, ['A'], 2); }); it('should parse multiple elements each with terminator', () => { @@ -32,7 +32,7 @@ describe('endBy1', () => { const parser = endBy1(parser1, parser2); const result = parser('A,A,A,'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], ''); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 6); }); it('should not match when element lacks terminator', () => { @@ -41,7 +41,7 @@ describe('endBy1', () => { const parser = endBy1(parser1, parser2); const result = parser('A,A,A'); - assertSuccess<'A'[]>(result, ['A', 'A'], 'A'); + assertSuccess<'A'[]>(result, ['A', 'A'], 4); }); it('should fail on empty input', () => { diff --git a/src/combinators/endBy1.ts b/src/combinators/endBy1.ts index fd783f4..1bd4221 100644 --- a/src/combinators/endBy1.ts +++ b/src/combinators/endBy1.ts @@ -9,7 +9,7 @@ import { sequence } from './sequence'; * One or more items separated and ending with terminator. * * @example - * endBy1(string('item'), char(';'))('item;item;item;') // { ok: true, value: ['item', 'item', 'item'], remaining: '' } + * endBy1(string('item'), char(';'))('item;item;item;') // { ok: true, value: ['item', 'item', 'item'], index: 15, furthest: 15, expected: ["'item'"] } */ export const endBy1 = (parser: Parser, terminator: Parser) => { return create( diff --git a/src/combinators/exactly.test.ts b/src/combinators/exactly.test.ts index d63b52b..e9de6d4 100644 --- a/src/combinators/exactly.test.ts +++ b/src/combinators/exactly.test.ts @@ -14,7 +14,7 @@ describe('exactly', () => { const parser = exactly(parser1, 3); const result = parser('AAABCD'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 3); }); it('should fail if fewer than n occurrences', () => { @@ -28,14 +28,14 @@ describe('exactly', () => { const parser = exactly(parser1, 2); const result = parser('AAAAA'); - assertSuccess<'A'[]>(result, ['A', 'A'], 'AAA'); + assertSuccess<'A'[]>(result, ['A', 'A'], 2); }); it('should handle count of zero', () => { const parser = exactly(parser1, 0); const result = parser('AAABCD'); - assertSuccess<'A'[]>(result, [], 'AAABCD'); + assertSuccess<'A'[]>(result, [], 0); }); it('should fail on empty input when count > 0', () => { diff --git a/src/combinators/exactly.ts b/src/combinators/exactly.ts index ba3fea3..f502df6 100644 --- a/src/combinators/exactly.ts +++ b/src/combinators/exactly.ts @@ -1,30 +1,34 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** * Parse exactly n occurrences. * * @example - * exactly(char('a'), 3)('aaa') // { ok: true, value: ['a', 'a', 'a'], remaining: '' } + * exactly(char('a'), 3)('aaa') // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: -1, expected: [] } */ export const exactly = (parser: Parser, n: number) => { - return create((input) => { + return create((input, index = 0) => { const results: T[] = []; - let remaining = input; + let at = index; + let trace: Result = success(null, index); for (let i = 0; i < n; i++) { - const result = parser(remaining); + const result: Result = merge(trace, parser(input, at)); + trace = result; + if (!result.ok) { - return failure(); + return result; } results.push(result.value); - remaining = result.remaining; + at = result.index; } - return success(results, remaining); + return merge(trace, success(results, at)); }); }; diff --git a/src/combinators/first.test.ts b/src/combinators/first.test.ts index 8629d9c..1ac5d42 100644 --- a/src/combinators/first.test.ts +++ b/src/combinators/first.test.ts @@ -7,18 +7,18 @@ import { first } from './first'; describe('first', () => { it('should return first element of array parser result', () => { - const parser1 = create(() => success(['A', 'B', 'C'] as const, '')); + const parser1 = create((input) => success(['A', 'B', 'C'] as const, input.length)); const parser = first(parser1); const result = parser('ABC'); - assertSuccess<'A'>(result, 'A', ''); + assertSuccess<'A'>(result, 'A', 3); }); it('should return undefined for empty array', () => { - const parser1 = create(() => success([] as const, 'ABC')); + const parser1 = create((_input, _index = 0) => success([] as const, 0)); const parser = first(parser1); const result = parser('ABC'); - assertSuccess(result, undefined, 'ABC'); + assertSuccess(result, undefined, 0); }); }); diff --git a/src/combinators/first.ts b/src/combinators/first.ts index c31c8df..669eeba 100644 --- a/src/combinators/first.ts +++ b/src/combinators/first.ts @@ -16,7 +16,7 @@ export type First = T extends readonly [ * Extract the first element from a parser result array. * * @example - * first(sequence(char('a'), digit))('a1bc') // { ok: true, value: 'a', remaining: 'bc' } + * first(sequence(char('a'), digit))('a1bc') // { ok: true, value: 'a', index: 2, furthest: -1, expected: [] } */ export const first = ( parser: Parser, diff --git a/src/combinators/flag.test.ts b/src/combinators/flag.test.ts index a1b3784..1d57ecc 100644 --- a/src/combinators/flag.test.ts +++ b/src/combinators/flag.test.ts @@ -12,20 +12,20 @@ describe('flag', () => { const parser = flag(aParser); const result = parser('ABC'); - assertSuccess(result, true, 'BC'); + assertSuccess(result, true, 1); }); it('should return false when parser fails', () => { - const parser = flag(create<'A'>(() => failure())); + const parser = flag(create<'A'>((_input, index = 0) => failure(index))); const result = parser('ABC'); - assertSuccess(result, false, 'ABC'); + assertSuccess(result, false, 0); }); it('should not consume input on failure', () => { const parser = flag(aParser); const result = parser('XYZ'); - assertSuccess(result, false, 'XYZ'); + assertSuccess(result, false, 0); }); }); diff --git a/src/combinators/flag.ts b/src/combinators/flag.ts index d31dcef..8c4fda9 100644 --- a/src/combinators/flag.ts +++ b/src/combinators/flag.ts @@ -1,5 +1,6 @@ import type { Parser } from '../core/parser'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -7,15 +8,15 @@ import { success } from '../core/success'; * Return true if parser succeeds, false otherwise. Always succeeds without consuming input on failure. * * @example - * flag(string('*'))('*abc') // { ok: true, value: true, remaining: 'abc' } - * flag(string('*'))('abc') // { ok: true, value: false, remaining: 'abc' } + * flag(string('*'))('*abc') // { ok: true, value: true, index: 1, furthest: -1, expected: [] } + * flag(string('*'))('abc') // { ok: true, value: false, index: 0, furthest: 0, expected: ["'*'"] } */ export const flag = (parser: Parser): Parser => { - return create((input) => { - const result = parser(input); + return create((input, index = 0) => { + const result = parser(input, index); return result.ok - ? success(true, result.remaining) - : success(false, input); + ? merge(result, success(true, result.index)) + : merge(result, success(false, index)); }); }; diff --git a/src/combinators/fold.test.ts b/src/combinators/fold.test.ts index 74fb258..9c40aa1 100644 --- a/src/combinators/fold.test.ts +++ b/src/combinators/fold.test.ts @@ -7,11 +7,11 @@ import { success } from '../core/success'; import { fold } from './fold'; describe('fold', () => { - const stringParser = create((input: string) => { - if (input.length === 0) { - return failure(); + const stringParser = create((input, index = 0) => { + if (index >= input.length) { + return failure(index); } - return success(input[0], input.slice(1)); + return success(input[index], index + 1); }); it('should fold left over parsed items', () => { @@ -22,29 +22,29 @@ describe('fold', () => { ); const result = parser('ABC'); - assertSuccess(result, '(((ZA)B)C)', ''); + assertSuccess(result, '(((ZA)B)C)', 3); }); it('should work with empty input (return initial value and not consume input)', () => { const parser = fold( - create(() => failure()), + create((_input, index = 0) => failure(index)), 'Z', (acc, item) => `(${acc}${item})`, ); const result = parser('ABC'); - assertSuccess(result, 'Z', 'ABC'); + assertSuccess(result, 'Z', 0); }); it('should work with empty input (return initial value)', () => { const parser = fold( - create(() => failure()), + create((_input, index = 0) => failure(index)), 'Z', (acc, item) => `(${acc}${item})`, ); const result = parser(''); - assertSuccess(result, 'Z', ''); + assertSuccess(result, 'Z', 0); }); it('should work with complex accumulator types', () => { @@ -61,7 +61,7 @@ describe('fold', () => { assertSuccess<{ label: string; count: number; - }>(result, { label: 'ABC', count: 3 }, ''); + }>(result, { label: 'ABC', count: 3 }, 3); }); it('should work with array building', () => { @@ -71,14 +71,14 @@ describe('fold', () => { ]); const result = parser('ABC'); - assertSuccess(result, ['AZ', 'BZ', 'CZ'], ''); + assertSuccess(result, ['AZ', 'BZ', 'CZ'], 3); }); it('should not fail and return the initial value and not consume', () => { - const failureParser = create(() => failure()); + const failureParser = create((_input, index = 0) => failure(index)); const parser = fold(failureParser, 0, (acc) => acc + 1); const result = parser('ABC'); - assertSuccess(result, 0, 'ABC'); + assertSuccess(result, 0, 0); }); }); diff --git a/src/combinators/fold.ts b/src/combinators/fold.ts index 280884d..f073747 100644 --- a/src/combinators/fold.ts +++ b/src/combinators/fold.ts @@ -1,22 +1,27 @@ -import { create, type Parser } from '../core/parser'; -import { type Success, success } from '../core/success'; +import type { Parser } from '../core/parser'; + +import { merge } from '../core/merge'; +import { create } from '../core/parser'; +import { success, type Success } from '../core/success'; import { many } from './many'; /** - * Parse zero or more and fold into a single value. + * Fold zero or more occurrences into a single value. * * @example - * fold(digit, [], (acc, d) => [...acc, d])('123') // { ok: true, value: [1, 2, 3], remaining: '' } + * fold(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } */ export const fold = ( parser: Parser, initial: U, folder: (acc: U, item: T) => U, ): Parser => { - return create((input) => { - const result = many(parser)(input) as Success; - const { value, remaining } = result; + return create((input, index = 0) => { + const result = many(parser)(input, index) as Success; - return success(value.reduce(folder, initial), remaining); + return merge( + result, + success(result.value.reduce(folder, initial), result.index), + ); }); }; diff --git a/src/combinators/fold1.test.ts b/src/combinators/fold1.test.ts index 25a2707..753472d 100644 --- a/src/combinators/fold1.test.ts +++ b/src/combinators/fold1.test.ts @@ -8,21 +8,21 @@ import { fold1 } from './fold1'; describe('fold1', () => { it('should fold left over parsed items', () => { - const stringParser = create((input: string) => { - if (input.length === 0) { - return failure(); + const stringParser = create((input, index = 0) => { + if (index >= input.length) { + return failure(index); } - return success(input[0], input.slice(1)); + return success(input[index], index + 1); }); const parser = fold1(stringParser, '', (acc, value) => acc + value); const result = parser('ABC'); - assertSuccess(result, 'ABC', ''); + assertSuccess(result, 'ABC', 3); }); it('should return null, one or more successful parser returns are required', () => { - const failureParser = create(() => failure()); + const failureParser = create((_input, index = 0) => failure(index)); const parser = fold1(failureParser, 42, (acc, digit) => acc + digit); const result = parser('ABC'); diff --git a/src/combinators/fold1.ts b/src/combinators/fold1.ts index 5009f37..4cf966d 100644 --- a/src/combinators/fold1.ts +++ b/src/combinators/fold1.ts @@ -1,35 +1,30 @@ -import { failure } from '../core/failure'; -import { create, type Parser } from '../core/parser'; -import { type Success, success } from '../core/success'; +import type { Parser } from '../core/parser'; + +import { merge } from '../core/merge'; +import { create } from '../core/parser'; +import { success, type Success } from '../core/success'; import { many } from './many'; /** - * Parse one or more and fold into a single value. + * Fold one or more occurrences into a single value. * * @example - * fold1(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, remaining: '' } + * fold1(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } */ export const fold1 = ( parser: Parser, initial: U, folder: (acc: U, item: T) => U, ): Parser => { - return create((input) => { - const first = parser(input); + return create((input, index = 0) => { + const first = parser(input, index); if (!first.ok) { - return failure(); + return first; } - const { value: firstValue, remaining } = first; - - let acc = folder(initial, firstValue); - - const { value: items, remaining: finalRest } = many(parser)( - remaining, - ) as Success; - - acc = items.reduce(folder, acc); + const rest = many(parser)(input, first.index) as Success; + const acc = rest.value.reduce(folder, folder(initial, first.value)); - return success(acc, finalRest); + return merge(first, merge(rest, success(acc, rest.index))); }); }; diff --git a/src/combinators/foldRight.test.ts b/src/combinators/foldRight.test.ts index e6c13ee..62c41a1 100644 --- a/src/combinators/foldRight.test.ts +++ b/src/combinators/foldRight.test.ts @@ -7,12 +7,12 @@ import { success } from '../core/success'; import { foldRight } from './foldRight'; describe('foldRight', () => { - const stringParser = create((input: string) => { - if (input.length === 0) { - return failure(); + const stringParser = create((input, index = 0) => { + if (index >= input.length) { + return failure(index); } - return success(input[0], input.slice(1)); + return success(input[index], index + 1); }); it('should fold right over parsed items', () => { @@ -23,29 +23,29 @@ describe('foldRight', () => { ); const result = parser('ABC'); - assertSuccess(result, '(((ZC)B)A)', ''); + assertSuccess(result, '(((ZC)B)A)', 3); }); it('should work with empty input (return initial value and not consume input)', () => { const parser = foldRight( - create(() => failure()), + create((_input, index = 0) => failure(index)), 'Z', (acc, item) => `(${acc}${item})`, ); const result = parser('ABC'); - assertSuccess(result, 'Z', 'ABC'); + assertSuccess(result, 'Z', 0); }); it('should work with empty input (return initial value)', () => { const parser = foldRight( - create(() => failure()), + create((_input, index = 0) => failure(index)), 'Z', (acc, item) => `(${acc}${item})`, ); const result = parser(''); - assertSuccess(result, 'Z', ''); + assertSuccess(result, 'Z', 0); }); it('should work with complex accumulator types', () => { @@ -62,7 +62,7 @@ describe('foldRight', () => { assertSuccess<{ label: string; count: number; - }>(result, { label: 'ABC', count: 3 }, ''); + }>(result, { label: 'ABC', count: 3 }, 3); }); it('should work with array building', () => { @@ -72,14 +72,14 @@ describe('foldRight', () => { ]); const result = parser('ABC'); - assertSuccess(result, ['CZ', 'BZ', 'AZ'], ''); + assertSuccess(result, ['CZ', 'BZ', 'AZ'], 3); }); it('should not fail and return the initial value and not consume', () => { - const parserFail = create(() => failure()); + const parserFail = create((_input, index = 0) => failure(index)); const parser = foldRight(parserFail, 0, (acc) => acc + 1); const result = parser('ABC'); - assertSuccess(result, 0, 'ABC'); + assertSuccess(result, 0, 0); }); }); diff --git a/src/combinators/foldRight.ts b/src/combinators/foldRight.ts index 7b85d08..8f06a8e 100644 --- a/src/combinators/foldRight.ts +++ b/src/combinators/foldRight.ts @@ -1,21 +1,27 @@ -import { create, type Parser } from '../core/parser'; -import { type Success, success } from '../core/success'; +import type { Parser } from '../core/parser'; + +import { merge } from '../core/merge'; +import { create } from '../core/parser'; +import { success, type Success } from '../core/success'; import { many } from './many'; /** - * Parse zero or more and fold right-to-left. + * Fold zero or more occurrences from the right into a single value. * * @example - * foldRight(digit, [], (acc, d) => [...acc, d])('123') // { ok: true, value: [3, 2, 1], remaining: '' } + * foldRight(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } */ export const foldRight = ( parser: Parser, initial: U, folder: (acc: U, item: T) => U, ): Parser => { - return create((input) => { - const { value, remaining } = many(parser)(input) as Success; + return create((input, index = 0) => { + const result = many(parser)(input, index) as Success; - return success(value.reduceRight(folder, initial), remaining); + return merge( + result, + success(result.value.reduceRight(folder, initial), result.index), + ); }); }; diff --git a/src/combinators/foldRight1.test.ts b/src/combinators/foldRight1.test.ts index 92e17c3..daac744 100644 --- a/src/combinators/foldRight1.test.ts +++ b/src/combinators/foldRight1.test.ts @@ -7,11 +7,11 @@ import { success } from '../core/success'; import { foldRight1 } from './foldRight1'; describe('foldRight1', () => { - const stringParser = create((input: string) => { - if (input.length === 0) { - return failure(); + const stringParser = create((input, index = 0) => { + if (index >= input.length) { + return failure(index); } - return success(input[0], input.slice(1)); + return success(input[index], index + 1); }); it('should fold right over parsed items', () => { @@ -22,11 +22,11 @@ describe('foldRight1', () => { ); const result = parser('ABC'); - assertSuccess(result, '(((ZC)B)A)', ''); + assertSuccess(result, '(((ZC)B)A)', 3); }); it('should return null, one or more successful parser returns are required', () => { - const failureParser = create(() => failure()); + const failureParser = create((_input, index = 0) => failure(index)); const parser = foldRight1( failureParser, 42, diff --git a/src/combinators/foldRight1.ts b/src/combinators/foldRight1.ts index 24933d8..158f001 100644 --- a/src/combinators/foldRight1.ts +++ b/src/combinators/foldRight1.ts @@ -1,33 +1,33 @@ -import { failure } from '../core/failure'; -import { create, type Parser } from '../core/parser'; -import { type Success, success } from '../core/success'; +import type { Parser } from '../core/parser'; + +import { merge } from '../core/merge'; +import { create } from '../core/parser'; +import { success, type Success } from '../core/success'; import { many } from './many'; /** - * Parse one or more and fold right-to-left. + * Fold one or more occurrences from the right into a single value. * * @example - * foldRight1(digit, [], (acc, d) => [...acc, d])('123') // { ok: true, value: [3, 2, 1], remaining: '' } + * foldRight1(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } */ export const foldRight1 = ( parser: Parser, initial: U, folder: (acc: U, item: T) => U, ): Parser => { - return create((input) => { - const first = parser(input); + return create((input, index = 0) => { + const first = parser(input, index); if (!first.ok) { - return failure(); + return first; } - const { value: firstValue, remaining: rest } = first; - const { value: items, remaining: finalRest } = many(parser)( - rest, - ) as Success; - - const all = [firstValue, ...items]; - const folded = all.reduceRight(folder, initial); + const rest = many(parser)(input, first.index) as Success; + const all = [first.value, ...rest.value]; - return success(folded, finalRest); + return merge( + first, + merge(rest, success(all.reduceRight(folder, initial), rest.index)), + ); }); }; diff --git a/src/combinators/fuse.test.ts b/src/combinators/fuse.test.ts index c888a6a..7735de5 100644 --- a/src/combinators/fuse.test.ts +++ b/src/combinators/fuse.test.ts @@ -16,7 +16,7 @@ describe('fuse', () => { ); const result = parser('abc'); - assertSuccess(result, 'abc', ''); + assertSuccess(result, 'abc', 3); }); it('should fail on non-matching input', () => { @@ -38,20 +38,20 @@ describe('fuse', () => { ); const result = parser('hello world'); - assertSuccess(result, 'hello world', ''); + assertSuccess(result, 'hello world', 11); }); it('should fuse two parsers into string', () => { const parser = fuse(createTestParser('a'), createTestParser('b')); const result = parser('ab'); - assertSuccess(result, 'ab', ''); + assertSuccess(result, 'ab', 2); }); it('should work with single parser', () => { const parser = fuse(createTestParser('a')); const result = parser('a'); - assertSuccess(result, 'a', ''); + assertSuccess(result, 'a', 1); }); }); diff --git a/src/combinators/fuse.ts b/src/combinators/fuse.ts index 0dfdbc0..397ab64 100644 --- a/src/combinators/fuse.ts +++ b/src/combinators/fuse.ts @@ -1,4 +1,8 @@ -import { create, type Parser } from '../core/parser'; +import type { Parser } from '../core/parser'; + +import { merge } from '../core/merge'; +import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** @@ -6,23 +10,27 @@ import { success } from '../core/success'; * The fused parser concatenates all string results. * * @example - * 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: '' } + * fuse(char('a'), char('b'), char('c'))('abc') // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } + * fuse(string('hello'), char(' '), string('world'))('hello world') // { ok: true, value: 'hello world', index: 11, furthest: -1, expected: [] } */ export const fuse = []>(...parsers: T) => { - return create((input) => { - let result = ''; - let remaining = input; + return create((input, index = 0) => { + let value = ''; + let at = index; + let trace: Result = success(null, index); for (const parser of parsers) { - const parsed = parser(remaining); + const parsed: Result = merge(trace, parser(input, at)); + trace = parsed; + if (!parsed.ok) { - return { ok: false }; + return parsed; } - result += parsed.value; - remaining = parsed.remaining; + + value += parsed.value; + at = parsed.index; } - return success(result, remaining); + return merge(trace, success(value, at)); }); }; diff --git a/src/combinators/guard.test.ts b/src/combinators/guard.test.ts index 4ecedbe..3a47be8 100644 --- a/src/combinators/guard.test.ts +++ b/src/combinators/guard.test.ts @@ -13,7 +13,7 @@ describe('guard', () => { const parser = guard(true, parser1); const result = parser('AAA'); - assertSuccess<'A' | null>(result, 'A', 'AA'); + assertSuccess<'A' | null>(result, 'A', 1); }); it('should fail when condition is false', () => { diff --git a/src/combinators/guard.ts b/src/combinators/guard.ts index 8c70fe0..9f42fe7 100644 --- a/src/combinators/guard.ts +++ b/src/combinators/guard.ts @@ -7,17 +7,15 @@ import { create } from '../core/parser'; * Conditionally apply parser based on a condition. * * @example - * guard(true, string('hello'))('hello') // { ok: true, value: 'hello', remaining: '' } - * guard(false, string('hello'))('hello') // { ok: false } + * guard(true, string('hello'))('hello') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } + * guard(false, string('hello'))('hello') // { ok: false, index: 0, furthest: 0, expected: [] } */ export const guard = (condition: boolean, parser: Parser) => { - return create((input) => { + return create((input, index = 0) => { if (!condition) { - return failure(); + return failure(index); } - const result = parser(input); - - return result; + return parser(input, index); }); }; diff --git a/src/combinators/inner.test.ts b/src/combinators/inner.test.ts index 071f495..29460cc 100644 --- a/src/combinators/inner.test.ts +++ b/src/combinators/inner.test.ts @@ -16,7 +16,7 @@ describe('inner', () => { const parser = inner(parser1, parser2, parser3); const result = parser('ABC'); - assertSuccess<'B'>(result, 'B', ''); + assertSuccess<'B'>(result, 'B', 3); }); it('should fail if first parser fails', () => { diff --git a/src/combinators/inner.ts b/src/combinators/inner.ts index efb1990..5432c6d 100644 --- a/src/combinators/inner.ts +++ b/src/combinators/inner.ts @@ -8,7 +8,7 @@ import { sequence } from './sequence'; * Extract inner value from surrounded content (like inner of braced). * * @example - * inner(char('('), string('hi'), char(')'))('(hi)') // { ok: true, value: 'hi', remaining: '' } + * inner(char('('), string('hi'), char(')'))('(hi)') // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } */ export const inner = ( parserA: Parser, diff --git a/src/combinators/interleaved.test.ts b/src/combinators/interleaved.test.ts index 03540b5..d9c5945 100644 --- a/src/combinators/interleaved.test.ts +++ b/src/combinators/interleaved.test.ts @@ -14,7 +14,7 @@ describe('interleaved', () => { const parser = interleaved(parser1, parser2); const result = parser('ABABA'); - assertSuccess<('A' | 'B')[]>(result, ['A', 'B', 'A', 'B', 'A'], ''); + assertSuccess<('A' | 'B')[]>(result, ['A', 'B', 'A', 'B', 'A'], 5); }); it('should handle single item', () => { @@ -23,7 +23,7 @@ describe('interleaved', () => { const parser = interleaved(parser1, parser2); const result = parser('AC'); - assertSuccess<('A' | 'B')[]>(result, ['A'], 'C'); + assertSuccess<('A' | 'B')[]>(result, ['A'], 1); }); it('should fail when first item does not match', () => { @@ -41,6 +41,6 @@ describe('interleaved', () => { const parser = interleaved(parser1, parser2); const result = parser('ABABC'); - assertSuccess<('A' | 'B')[]>(result, ['A', 'B', 'A'], 'BC'); + assertSuccess<('A' | 'B')[]>(result, ['A', 'B', 'A'], 3); }); }); diff --git a/src/combinators/interleaved.ts b/src/combinators/interleaved.ts index bc5df84..0272ea2 100644 --- a/src/combinators/interleaved.ts +++ b/src/combinators/interleaved.ts @@ -1,35 +1,41 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** - * Parse items with interleaved separators. + * Parse items separated by separators, keeping both in the result. * * @example - * interleaved(char('a'), char(','))('a,a,a') // { ok: true, value: ['a', ',', 'a', ',', 'a'], remaining: '' } + * interleaved(digits, char('+'))('1+2') // { ok: true, value: [1, '+', 2], index: 3, furthest: 3, expected: ["'+'"] } */ export const interleaved = (item: Parser, separator: Parser) => { - return create>((input) => { - const firstResult = item(input); - if (!firstResult.ok) return failure(); + return create>((input, index = 0) => { + const firstResult = item(input, index); + if (!firstResult.ok) { + return firstResult; + } const results: Array = [firstResult.value]; - let remaining = firstResult.remaining; + let at = firstResult.index; + let trace: Result = firstResult; while (true) { - const sepResult = separator(remaining); + const sepResult: Result = merge(trace, separator(input, at)); + trace = sepResult; if (!sepResult.ok) break; - const nextResult = item(sepResult.remaining); + const nextResult: Result = merge(trace, item(input, sepResult.index)); + trace = nextResult; if (!nextResult.ok) break; results.push(sepResult.value); results.push(nextResult.value); - remaining = nextResult.remaining; + at = nextResult.index; } - return success(results, remaining); + return merge(trace, success(results, at)); }); }; diff --git a/src/combinators/last.test.ts b/src/combinators/last.test.ts index 3107211..84f5a18 100644 --- a/src/combinators/last.test.ts +++ b/src/combinators/last.test.ts @@ -7,20 +7,20 @@ import { last } from './last'; describe('last', () => { it('should return last element of array parser result', () => { - const parser1 = create(() => success(['A', 'B', 'C'] as const, '')); + const parser1 = create((input) => success(['A', 'B', 'C'] as const, input.length)); const parser = last(parser1); const result = parser('ABC'); - assertSuccess<'C'>(result, 'C', ''); + assertSuccess<'C'>(result, 'C', 3); }); it('should return undefined for empty array', () => { const parser1 = create(() => - success([] as unknown as [unknown, ...unknown[]], 'ABC'), + success([] as unknown as [unknown, ...unknown[]], 0), ); const parser = last(parser1); const result = parser('ABC'); - assertSuccess(result, undefined, 'ABC'); + assertSuccess(result, undefined, 0); }); }); diff --git a/src/combinators/last.ts b/src/combinators/last.ts index e538942..987c3cb 100644 --- a/src/combinators/last.ts +++ b/src/combinators/last.ts @@ -16,7 +16,7 @@ export type Last = T extends readonly [ * Extract the last element from a parser result array. * * @example - * last(sequence(char('a'), char('b')))('ab') // { ok: true, value: 'b', remaining: '' } + * last(sequence(char('a'), char('b')))('ab') // { ok: true, value: 'b', index: 2, furthest: -1, expected: [] } */ export const last = (parser: Parser) => { return create>( diff --git a/src/combinators/left.test.ts b/src/combinators/left.test.ts index 949c8d6..4147c53 100644 --- a/src/combinators/left.test.ts +++ b/src/combinators/left.test.ts @@ -15,7 +15,7 @@ describe('left', () => { const parser = left(parser1, parser2); const result = parser('AB'); - assertSuccess<'A'>(result, 'A', ''); + assertSuccess<'A'>(result, 'A', 2); }); it('should fail if first parser fails', () => { diff --git a/src/combinators/left.ts b/src/combinators/left.ts index 204557d..6b6af73 100644 --- a/src/combinators/left.ts +++ b/src/combinators/left.ts @@ -8,7 +8,7 @@ import { sequence } from './sequence'; * Keep only the left result from a sequence. * * @example - * left(string('hello'), string('world'))('helloworld') // { ok: true, value: 'hello', remaining: '' } + * left(string('hello'), string('world'))('helloworld') // { ok: true, value: 'hello', index: 10, furthest: -1, expected: [] } */ export const left = (parserA: Parser, parserB: Parser) => { return create(map(sequence(parserA, parserB), ([a]) => a)); diff --git a/src/combinators/lexeme.test.ts b/src/combinators/lexeme.test.ts index 6d7436c..deb2ba9 100644 --- a/src/combinators/lexeme.test.ts +++ b/src/combinators/lexeme.test.ts @@ -13,7 +13,7 @@ describe('lexeme', () => { const parser = lexeme(parser1); const result = parser('A B'); - assertSuccess<'A'>(result, 'A', 'B'); + assertSuccess<'A'>(result, 'A', 8); }); it('should parse a token with no trailing whitespace', () => { @@ -21,7 +21,7 @@ describe('lexeme', () => { const parser = lexeme(parser1); const result = parser('AB'); - assertSuccess<'A'>(result, 'A', 'B'); + assertSuccess<'A'>(result, 'A', 1); }); it('should consume various types of whitespace', () => { @@ -29,7 +29,7 @@ describe('lexeme', () => { const parser = lexeme(parser1); const result = parser('A \t\n\r B'); - assertSuccess<'A'>(result, 'A', 'B'); + assertSuccess<'A'>(result, 'A', 7); }); it('should fail when the underlying parser fails', () => { @@ -45,6 +45,6 @@ describe('lexeme', () => { const parser = lexeme(parser1); const result = parser('A '); - assertSuccess<'A'>(result, 'A', ''); + assertSuccess<'A'>(result, 'A', 4); }); }); diff --git a/src/combinators/lexeme.ts b/src/combinators/lexeme.ts index 6ac370e..8188da4 100644 --- a/src/combinators/lexeme.ts +++ b/src/combinators/lexeme.ts @@ -9,7 +9,7 @@ import { sequence } from './sequence'; * Parser that consumes trailing whitespace. * * @example - * lexeme(string('hello'))('hello world') // { ok: true, value: 'hello', remaining: 'world' } + * lexeme(string('hello'))('hello world') // { ok: true, value: 'hello', index: 8, furthest: -1, expected: [] } */ export const lexeme = (parser: Parser) => { return create(map(sequence(parser, regex(/^\s*/)), ([value]) => value)); diff --git a/src/combinators/many.test.ts b/src/combinators/many.test.ts index f12e373..1b581c3 100644 --- a/src/combinators/many.test.ts +++ b/src/combinators/many.test.ts @@ -8,11 +8,11 @@ import { many } from './many'; describe('many', () => { it('should parse zero occurrences', () => { - const failureParser = create(() => failure()); + const failureParser = create((_input, index = 0) => failure(index)); const parser = many(failureParser); const result = parser('BCD'); - assertSuccess(result, [], 'BCD'); + assertSuccess(result, [], 0); }); it('should parse one occurrence', () => { @@ -20,7 +20,7 @@ describe('many', () => { const parser = many(parser1); const result = parser('ABCD'); - assertSuccess<'A'[]>(result, ['A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A'], 1); }); it('should parse multiple occurrences', () => { @@ -28,7 +28,7 @@ describe('many', () => { const parser = many(parser1); const result = parser('AAABCD'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 3); }); it('should handle empty input', () => { @@ -36,32 +36,34 @@ describe('many', () => { const parser = many(parser1); const result = parser(''); - assertSuccess<'A'[]>(result, [], ''); + assertSuccess<'A'[]>(result, [], 0); }); it('should prevent infinite loops with non-consuming parsers', () => { - const nonConsumingParser = () => ['', 'AB'] as [string, string]; - const parser = many(nonConsumingParser as any); + const nonConsumingParser = create((_input, index = 0) => + success('', index), + ); + const parser = many(nonConsumingParser); const result = parser('AB'); - assertSuccess(result, [], 'AB'); + assertSuccess(result, [], 0); }); it('should stop after partial progress when parser later stalls', () => { let callCount = 0; - const partiallyConsumingParser = create((input: string) => { + const partiallyConsumingParser = create((input, index = 0) => { callCount++; - if (input.startsWith('A')) { - return success('A', input.slice(1)); + if (input.startsWith('A', index)) { + return success('A', index + 1); } - return success('X', input); + return success('X', index); }); const parser = many(partiallyConsumingParser); const result = parser('ABCD'); - assertSuccess(result, ['A'], 'BCD'); + assertSuccess(result, ['A'], 1); expect(callCount).toBe(2); }); }); diff --git a/src/combinators/many.ts b/src/combinators/many.ts index e375579..3faaf7f 100644 --- a/src/combinators/many.ts +++ b/src/combinators/many.ts @@ -1,32 +1,40 @@ +import { merge } from '../core/merge'; import { create, type Parser } from '../core/parser'; +import { type Result } from '../core/result'; import { success, type Success } from '../core/success'; /** * Zero or more occurrences (never fails). * + * The failure that stopped the loop is kept as a trace, so a later error can + * still report what the repetition was expecting next. + * * @example - * many(char('a'))('aaa') // { ok: true, value: ['a', 'a', 'a'], remaining: '' } + * many(char('a'))('aaa') // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 3, expected: ["'a'"] } */ export const many = (parser: Parser) => { - return create((input): Success => { + return create((input, index = 0): Success => { const results: T[] = []; - let remaining = input; + let at = index; + let trace: Result = success(null, index); while (true) { - const result = parser(remaining); + const result: Result = merge(trace, parser(input, at)); + trace = result; + if (!result.ok) { break; } // Prevent infinite loop: ensure progress is made - if (result.remaining === remaining) { + if (result.index === at) { break; } results.push(result.value); - remaining = result.remaining; + at = result.index; } - return success(results, remaining); + return merge(trace, success(results, at)) as Success; }); }; diff --git a/src/combinators/many1.test.ts b/src/combinators/many1.test.ts index 0ec92bb..82f9bb0 100644 --- a/src/combinators/many1.test.ts +++ b/src/combinators/many1.test.ts @@ -22,14 +22,14 @@ describe('many1', () => { const parser = many1(parser1); const result = parser('ABCD'); - assertSuccess<'A'[]>(result, ['A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A'], 1); }); it('should parse multiple occurrences', () => { const parser = many1(parser1); const result = parser('AAABCD'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 3); }); it('should fail on empty input', () => { @@ -41,10 +41,10 @@ describe('many1', () => { it('should fail when many returns null after first success', () => { let callCount = 0; - const flakyParser = (input: string) => { + const flakyParser = (_input: string, index = 0) => { callCount++; if (callCount === 1) { - return success('A', input.slice(1)); + return success('A', index + 1); } // On subsequent calls, throw to make many() fail diff --git a/src/combinators/many1.ts b/src/combinators/many1.ts index ebe4c2d..8045adc 100644 --- a/src/combinators/many1.ts +++ b/src/combinators/many1.ts @@ -1,25 +1,26 @@ -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create, type Parser } from '../core/parser'; -import { type Success, success } from '../core/success'; +import { type Success } from '../core/success'; import { many } from './many'; /** - * One or more occurrences (fails if no matches). + * One or more occurrences. * * @example - * many1(char('a'))('aaa') // { ok: true, value: ['a', 'a', 'a'], remaining: '' } + * many1(char('a'))('aaa') // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 3, expected: ["'a'"] } */ export const many1 = (parser: Parser) => { - return create((input) => { - const result = parser(input); + return create((input, index = 0) => { + const result = parser(input, index); if (!result.ok) { - return failure(); + return result; } - const { value: manyValue, remaining: manyRemainder } = many(parser)( - result.remaining, - ) as Success; + const rest = many(parser)(input, result.index) as Success; - return success([result.value, ...manyValue], manyRemainder); + return merge(result, { + ...rest, + value: [result.value, ...rest.value], + }); }); }; diff --git a/src/combinators/manyAtLeast.test.ts b/src/combinators/manyAtLeast.test.ts index a1b0e44..db049e5 100644 --- a/src/combinators/manyAtLeast.test.ts +++ b/src/combinators/manyAtLeast.test.ts @@ -14,14 +14,14 @@ describe('manyAtLeast', () => { const parser = manyAtLeast(parser1, 2); const result = parser('AAABCD'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 3); }); it('should parse exactly n occurrences', () => { const parser = manyAtLeast(parser1, 2); const result = parser('AABCD'); - assertSuccess<'A'[]>(result, ['A', 'A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A', 'A'], 2); }); it('should fail if fewer than n occurrences', () => { @@ -35,13 +35,13 @@ describe('manyAtLeast', () => { const parser = manyAtLeast(parser1, 0); const result = parser('BCD'); - assertSuccess<'A'[]>(result, [], 'BCD'); + assertSuccess<'A'[]>(result, [], 0); }); it('should parse many more than minimum', () => { const parser = manyAtLeast(parser1, 2); const result = parser('AAAAAA'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A', 'A', 'A', 'A'], ''); + assertSuccess<'A'[]>(result, ['A', 'A', 'A', 'A', 'A', 'A'], 6); }); }); diff --git a/src/combinators/manyAtLeast.ts b/src/combinators/manyAtLeast.ts index 95e4c3d..4f7d551 100644 --- a/src/combinators/manyAtLeast.ts +++ b/src/combinators/manyAtLeast.ts @@ -1,6 +1,8 @@ -import { failure } from '../core/failure'; -import { create, type Parser } from '../core/parser'; -import { type Success, success } from '../core/success'; +import type { Parser } from '../core/parser'; + +import { merge } from '../core/merge'; +import { create } from '../core/parser'; +import { type Success } from '../core/success'; import { exactly } from './exactly'; import { many } from './many'; @@ -8,19 +10,20 @@ import { many } from './many'; * Parse at least n occurrences. * * @example - * manyAtLeast(char('a'), 2)('aaa') // { ok: true, value: ['a', 'a', 'a'], remaining: '' } + * manyAtLeast(char('a'), 2)('aaa') // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 3, expected: ["'a'"] } */ export const manyAtLeast = (parser: Parser, n: number) => { - return create((input) => { - const required = exactly(parser, n)(input); + return create((input, index = 0) => { + const required = exactly(parser, n)(input, index); if (!required.ok) { - return failure(); + return required; } - const { value: more, remaining: rest } = many(parser)( - required.remaining, - ) as Success; + const rest = many(parser)(input, required.index) as Success; - return success([...required.value, ...more], rest); + return merge(required, { + ...rest, + value: [...required.value, ...rest.value], + }); }); }; diff --git a/src/combinators/manyAtMost.test.ts b/src/combinators/manyAtMost.test.ts index 59ef0ab..7a3528d 100644 --- a/src/combinators/manyAtMost.test.ts +++ b/src/combinators/manyAtMost.test.ts @@ -10,34 +10,34 @@ describe('manyAtMost', () => { const parser = manyAtMost(parser1, 3); const result = parser('AABCD'); - assertSuccess<'A'[]>(result, ['A', 'A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A', 'A'], 2); }); it('should parse exactly n occurrences when available', () => { const parser = manyAtMost(parser1, 3); const result = parser('AAABCD'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 3); }); it('should not parse more than n occurrences', () => { const parser = manyAtMost(parser1, 2); const result = parser('AAAAAA'); - assertSuccess<'A'[]>(result, ['A', 'A'], 'AAAA'); + assertSuccess<'A'[]>(result, ['A', 'A'], 2); }); it('should parse zero occurrences', () => { const parser = manyAtMost(parser1, 3); const result = parser('BCD'); - assertSuccess<'A'[]>(result, [], 'BCD'); + assertSuccess<'A'[]>(result, [], 0); }); it('should handle limit of zero', () => { const parser = manyAtMost(parser1, 0); const result = parser('AAABCD'); - assertSuccess<'A'[]>(result, [], 'AAABCD'); + assertSuccess<'A'[]>(result, [], 0); }); }); diff --git a/src/combinators/manyAtMost.ts b/src/combinators/manyAtMost.ts index 9139a10..c5ae434 100644 --- a/src/combinators/manyAtMost.ts +++ b/src/combinators/manyAtMost.ts @@ -1,29 +1,34 @@ import type { Parser } from '../core/parser'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** - * Parse at most n occurrences. + * Parse at most n occurrences (never fails). * * @example - * manyAtMost(char('a'), 2)('aaa') // { ok: true, value: ['a', 'a'], remaining: 'a' } + * manyAtMost(char('a'), 2)('aaa') // { ok: true, value: ['a', 'a'], index: 2, furthest: -1, expected: [] } */ export const manyAtMost = (parser: Parser, n: number) => { - return create((input) => { + return create((input, index = 0) => { const results: T[] = []; - let remaining = input; + let at = index; + let trace: Result = success(null, index); for (let i = 0; i < n; i++) { - const result = parser(remaining); + const result: Result = merge(trace, parser(input, at)); + trace = result; + if (!result.ok) { break; } results.push(result.value); - remaining = result.remaining; + at = result.index; } - return success(results, remaining); + return merge(trace, success(results, at)); }); }; diff --git a/src/combinators/manyBetween.test.ts b/src/combinators/manyBetween.test.ts index 573f7b7..219d597 100644 --- a/src/combinators/manyBetween.test.ts +++ b/src/combinators/manyBetween.test.ts @@ -14,28 +14,28 @@ describe('manyBetween', () => { const parser = manyBetween(parser1, 2, 4); const result = parser('AAABCD'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 3); }); it('should parse minimum required', () => { const parser = manyBetween(parser1, 2, 4); const result = parser('AABCD'); - assertSuccess<'A'[]>(result, ['A', 'A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A', 'A'], 2); }); it('should parse maximum allowed', () => { const parser = manyBetween(parser1, 2, 4); const result = parser('AAAABCD'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A', 'A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A', 'A', 'A', 'A'], 4); }); it('should not parse more than maximum', () => { const parser = manyBetween(parser1, 1, 3); const result = parser('AAAAAA'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 'AAA'); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 3); }); it('should fail if fewer than minimum', () => { @@ -49,6 +49,6 @@ describe('manyBetween', () => { const parser = manyBetween(parser1, 3, 3); const result = parser('AAABCD'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 'BCD'); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 3); }); }); diff --git a/src/combinators/manyBetween.ts b/src/combinators/manyBetween.ts index 56d4486..dcfffb4 100644 --- a/src/combinators/manyBetween.ts +++ b/src/combinators/manyBetween.ts @@ -1,6 +1,8 @@ -import { failure } from '../core/failure'; -import { create, type Parser } from '../core/parser'; -import { type Success, success } from '../core/success'; +import type { Parser } from '../core/parser'; + +import { merge } from '../core/merge'; +import { create } from '../core/parser'; +import { type Success } from '../core/success'; import { exactly } from './exactly'; import { manyAtMost } from './manyAtMost'; @@ -8,20 +10,23 @@ import { manyAtMost } from './manyAtMost'; * Parse between min and max occurrences. * * @example - * manyBetween(char('a'), 2, 3)('aaa') // { ok: true, value: ['a', 'a', 'a'], remaining: '' } + * manyBetween(char('a'), 1, 2)('aaa') // { ok: true, value: ['a', 'a'], index: 2, furthest: -1, expected: [] } */ export const manyBetween = (parser: Parser, min: number, max: number) => { - return create((input) => { - const required = exactly(parser, min)(input); + return create((input, index = 0) => { + const required = exactly(parser, min)(input, index); if (!required.ok) { - return failure(); + return required; } - const { value: more, remaining: rest } = manyAtMost( - parser, - max - min, - )(required.remaining) as Success; + const rest = manyAtMost(parser, max - min)( + input, + required.index, + ) as Success; - return success([...required.value, ...more], rest); + return merge(required, { + ...rest, + value: [...required.value, ...rest.value], + }); }); }; diff --git a/src/combinators/manyTill.test.ts b/src/combinators/manyTill.test.ts index 6732f8a..1e14dc2 100644 --- a/src/combinators/manyTill.test.ts +++ b/src/combinators/manyTill.test.ts @@ -14,7 +14,7 @@ describe('manyTill', () => { const parser = manyTill(parser1, parser2); const result = parser('AAAB'); - assertSuccess(result, ['A', 'A', 'A'], ''); + assertSuccess(result, ['A', 'A', 'A'], 4); }); it('should return empty array if terminator matches immediately', () => { @@ -23,7 +23,7 @@ describe('manyTill', () => { const parser = manyTill(parser1, parser2); const result = parser('B'); - assertSuccess(result, [], ''); + assertSuccess(result, [], 1); }); it('should fail if terminator never matches', () => { @@ -41,6 +41,6 @@ describe('manyTill', () => { const parser = manyTill(parser1, parser2); const result = parser('AABAB'); - assertSuccess(result, ['A', 'A'], 'AB'); + assertSuccess(result, ['A', 'A'], 3); }); }); diff --git a/src/combinators/manyTill.ts b/src/combinators/manyTill.ts index 29e047b..1f8d63e 100644 --- a/src/combinators/manyTill.ts +++ b/src/combinators/manyTill.ts @@ -1,33 +1,39 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** * Parse zero or more until terminator matches. * * @example - * manyTill(char('a'), char('b'))('aaab') // { ok: true, value: ['a', 'a', 'a'], remaining: '' } + * manyTill(char('a'), char('b'))('aaab') // { ok: true, value: ['a', 'a', 'a'], index: 4, furthest: 2, expected: ["'b'"] } */ export const manyTill = (parser: Parser, terminator: Parser) => { - return create((input) => { + return create((input, index = 0) => { const results: T[] = []; - let remaining = input; + let at = index; + let trace: Result = success(null, index); while (true) { - const termResult = terminator(remaining); + const termResult: Result = merge(trace, terminator(input, at)); if (termResult.ok) { - return success(results, termResult.remaining); + return merge(termResult, success(results, termResult.index)); } - const parseResult = parser(remaining); + trace = termResult; + + const parseResult: Result = merge(trace, parser(input, at)); + trace = parseResult; + if (!parseResult.ok) { - return failure(); + return parseResult; } results.push(parseResult.value); - remaining = parseResult.remaining; + at = parseResult.index; } }); }; diff --git a/src/combinators/map.test.ts b/src/combinators/map.test.ts index 2565f28..c5f7f3c 100644 --- a/src/combinators/map.test.ts +++ b/src/combinators/map.test.ts @@ -8,15 +8,15 @@ import { map } from './map'; describe('map', () => { it('should transform parser result with single transform', () => { - const parser1 = create<'42'>(() => success('42', 'abc')); + const parser1 = create<'42'>((_input, index = 0) => success('42', index + 2)); const parser = map(parser1, parseInt); const result = parser('42abc'); - assertSuccess(result, 42, 'abc'); + assertSuccess(result, 42, 2); }); it('should chain multiple transforms', () => { - const parser1 = create<'24'>(() => success('24', 'abc')); + const parser1 = create<'24'>((_input, _index = 0) => success('24', 2)); const parser = map( parser1, parseInt, @@ -25,11 +25,11 @@ describe('map', () => { ); const result = parser('21abc'); - assertSuccess(result, '48', 'abc'); + assertSuccess(result, '48', 2); }); it('should fail if underlying parser fails', () => { - const parser1 = create(() => failure()); + const parser1 = create((_input, _index = 0) => failure(0)); const parser = map(parser1, (s) => s.toUpperCase()); const result = parser('goodbye'); @@ -37,8 +37,8 @@ describe('map', () => { }); it('should handle complex transformations', () => { - const parser1 = create(() => - success(['count', '=', '5'] as const, ';'), + const parser1 = create((_input, index = 0) => + success(['count', '=', '5'] as const, index + 7), ); const parser = map( parser1 as never, @@ -49,14 +49,14 @@ describe('map', () => { const result = parser('count=5;'); // :O, surprising! - assertSuccess<{ count: number }>(result, { count: 5 }, ';'); + assertSuccess<{ count: number }>(result, { count: 5 }, 7); }); it('should maintain original input consumption', () => { - const parser1 = create<'test'>(() => success('test', 'ing')); + const parser1 = create<'test'>((_input, index = 0) => success('test', index + 4)); const parser = map(parser1, (s) => s.length); const result = parser('testing'); - assertSuccess(result, 4, 'ing'); + assertSuccess(result, 4, 4); }); }); diff --git a/src/combinators/map.ts b/src/combinators/map.ts index 73e38b7..65dd97d 100644 --- a/src/combinators/map.ts +++ b/src/combinators/map.ts @@ -1,15 +1,8 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; import { create } from '../core/parser'; import { success } from '../core/success'; -/** - * Transform the parsed value. - * - * @example - * map(string('hello'), (v) => v.toUpperCase())('hello') // { ok: true, value: 'HELLO', remaining: '' } - */ export function map( parser: Parser, transform: (value: A) => B, @@ -82,14 +75,21 @@ export function map( transform8: (value: H) => I, transform9: (value: I) => J, ): Parser; + +/** + * Transform a parsed value through one or more functions. + * + * @example + * map(digits, (n) => n * 2)('21') // { ok: true, value: 42, index: 2, furthest: -1, expected: [] } + */ export function map( parser: Parser, ...transforms: Array<(value: unknown) => unknown> ) { - return create((input) => { - const result = parser(input); + return create((input, index = 0) => { + const result = parser(input, index); if (!result.ok) { - return failure(); + return result; } const finalValue = transforms.reduce( @@ -97,6 +97,10 @@ export function map( result.value as unknown, ); - return success(finalValue, result.remaining); + return { + ...success(finalValue, result.index), + furthest: result.furthest, + expected: result.expected, + }; }); } diff --git a/src/combinators/node.test.ts b/src/combinators/node.test.ts index 7699e0e..4e0b9ce 100644 --- a/src/combinators/node.test.ts +++ b/src/combinators/node.test.ts @@ -11,7 +11,7 @@ describe('node', () => { const parser = node<{ value: 'A' }>('word', { value: aParser }); const result = parser('A'); - assertSuccess(result, { type: 'word', value: 'A' }, ''); + assertSuccess(result, { type: 'word', value: 'A' }, 1); }); it('should create node with multiple fields', () => { @@ -21,7 +21,7 @@ describe('node', () => { }); const result = parser('AB'); - assertSuccess(result, { type: 'pair', left: 'A', right: 'B' }, ''); + assertSuccess(result, { type: 'pair', left: 'A', right: 'B' }, 2); }); it('should handle nested nodes', () => { @@ -34,7 +34,7 @@ describe('node', () => { assertSuccess( result, { type: 'outer', child: { type: 'inner', value: 'A' } }, - '', + 1, ); }); @@ -42,15 +42,15 @@ describe('node', () => { const alpha = node<{ value: 'A' }>('alpha', { value: aParser }); const beta = node<{ value: 'B' }>('beta', { value: bParser }); - assertSuccess(alpha('A'), { type: 'alpha', value: 'A' }, ''); - assertSuccess(beta('B'), { type: 'beta', value: 'B' }, ''); + assertSuccess(alpha('A'), { type: 'alpha', value: 'A' }, 1); + assertSuccess(beta('B'), { type: 'beta', value: 'B' }, 1); }); it('should handle empty fields', () => { const parser = node>('empty', {}); const result = parser(''); - assertSuccess(result, { type: 'empty' }, ''); + assertSuccess(result, { type: 'empty' }, 0); }); it('should parse partial input and leave remaining', () => { @@ -60,6 +60,6 @@ describe('node', () => { }); const result = parser('AB rest'); - assertSuccess(result, { type: 'pair', left: 'A', right: 'B' }, ' rest'); + assertSuccess(result, { type: 'pair', left: 'A', right: 'B' }, 2); }); }); diff --git a/src/combinators/node.ts b/src/combinators/node.ts index 3ea408c..aab6abf 100644 --- a/src/combinators/node.ts +++ b/src/combinators/node.ts @@ -15,8 +15,8 @@ type Node> = { * Create a node from parser fields. * * @example - * 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: '' } + * node('binop', { left: digits, op: char('+'), right: digits })('1+2') // { ok: true, value: { type: 'binop', left: 1, op: '+', right: 2 }, index: 3, furthest: -1, expected: [] } + * node('number', { value: digits })('123') // { ok: true, value: { type: 'number', value: 123 }, index: 3, furthest: -1, expected: [] } */ export function node< U extends Record, diff --git a/src/combinators/not.test.ts b/src/combinators/not.test.ts index 1a581d7..bd5f51f 100644 --- a/src/combinators/not.test.ts +++ b/src/combinators/not.test.ts @@ -13,7 +13,7 @@ describe('not', () => { const parser = not(parser1); const result = parser('BCD'); - assertSuccess(result, null, 'BCD'); + assertSuccess(result, null, 0); }); it('should fail when parser succeeds', () => { diff --git a/src/combinators/not.ts b/src/combinators/not.ts index 9cebcfd..9a88061 100644 --- a/src/combinators/not.ts +++ b/src/combinators/not.ts @@ -7,13 +7,16 @@ import { success } from '../core/success'; /** * Succeed if parser fails (without consuming input). * + * The inner failure is deliberately not traced: it is the expected outcome, so + * reporting it would produce a misleading expectation. + * * @example - * not(string('hello'))('world') // { ok: true, value: null, remaining: 'world' } + * not(string('hello'))('world') // { ok: true, value: null, index: 0, furthest: -1, expected: [] } */ export const not = (parser: Parser) => { - return create((input) => { - const result = parser(input); + return create((input, index = 0) => { + const result = parser(input, index); - return result.ok ? failure() : success(null, input); + return result.ok ? failure(index) : success(null, index); }); }; diff --git a/src/combinators/nth.test.ts b/src/combinators/nth.test.ts index 9d7c879..4e6c179 100644 --- a/src/combinators/nth.test.ts +++ b/src/combinators/nth.test.ts @@ -7,51 +7,51 @@ import { nth } from './nth'; describe('nth', () => { it('should return element at specified index', () => { - const parser1 = create(() => success(['A', 'B', 'C'] as const, '')); + const parser1 = create((input) => success(['A', 'B', 'C'] as const, input.length)); { const parser = nth(parser1, 0); const result = parser('ABC'); - assertSuccess<'A'>(result, 'A', ''); + assertSuccess<'A'>(result, 'A', 3); } { const parser = nth(parser1, 1); const result = parser('ABC'); - assertSuccess<'B'>(result, 'B', ''); + assertSuccess<'B'>(result, 'B', 3); } { const parser = nth(parser1, 2); const result = parser('ABC'); - assertSuccess<'C'>(result, 'C', ''); + assertSuccess<'C'>(result, 'C', 3); } }); it('should return undefined for out-of-bounds index', () => { - const parser1 = create(() => success(['A', 'B', 'C'] as const, '')); + const parser1 = create((_input, _index = 0) => success(['A', 'B', 'C'] as const, 3)); { const parser = nth(parser1, 5); const result = parser('ABC'); - assertSuccess(result, undefined, ''); + assertSuccess(result, undefined, 3); } { const parser = nth(parser1, -1); const result = parser('ABC'); - assertSuccess(result, undefined, ''); + assertSuccess(result, undefined, 3); } }); it('should handle empty arrays', () => { - const parser1 = create(() => success([] as const, 'ABC')); + const parser1 = create((_input, _index = 0) => success([] as const, 0)); const parser = nth(parser1, 1); const result = parser(''); - assertSuccess(result, undefined, 'ABC'); + assertSuccess(result, undefined, 0); }); }); diff --git a/src/combinators/nth.ts b/src/combinators/nth.ts index bd5f31c..748d38e 100644 --- a/src/combinators/nth.ts +++ b/src/combinators/nth.ts @@ -29,7 +29,7 @@ export type Nth = * Extract the nth element from a parser result array. * * @example - * nth(sequence(char('a'), char('b'), char('c')), 1)('abc') // { ok: true, value: 'b', remaining: '' } + * nth(sequence(char('a'), char('b'), char('c')), 1)('abc') // { ok: true, value: 'b', index: 3, furthest: -1, expected: [] } */ export const nth = ( parser: Parser, diff --git a/src/combinators/optional.test.ts b/src/combinators/optional.test.ts index 1cb85e1..08ad175 100644 --- a/src/combinators/optional.test.ts +++ b/src/combinators/optional.test.ts @@ -12,14 +12,14 @@ describe('optional', () => { const parser = optional(aParser); const result = parser('ABC'); - assertSuccess<'A' | null>(result, 'A', 'BC'); + assertSuccess<'A' | null>(result, 'A', 1); }); it('should return null when parser fails', () => { - const parser1 = create<'A'>(() => failure()); + const parser1 = create<'A'>((_input, index = 0) => failure(index)); const parser = optional(parser1); const result = parser('ABC'); - assertSuccess<'A' | null>(result, null, 'ABC'); + assertSuccess<'A' | null>(result, null, 0); }); }); diff --git a/src/combinators/optional.ts b/src/combinators/optional.ts index cc8dab6..41573af 100644 --- a/src/combinators/optional.ts +++ b/src/combinators/optional.ts @@ -1,5 +1,6 @@ import type { Parser } from '../core/parser'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -7,13 +8,13 @@ import { success } from '../core/success'; * Make parser optional (return null on failure, without consuming input). * * @example - * optional(string('hello'))('hello') // { ok: true, value: 'hello', remaining: '' } - * optional(string('hello'))('world') // { ok: true, value: null, remaining: 'world' } + * optional(string('hello'))('hello') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } + * optional(string('hello'))('world') // { ok: true, value: null, index: 0, furthest: 0, expected: ["'hello'"] } */ export const optional = (parser: Parser) => { - return create((input) => { - const result = parser(input); + return create((input, index = 0) => { + const result = parser(input, index); - return result.ok ? result : success(null, input); + return result.ok ? result : merge(result, success(null, index)); }); }; diff --git a/src/combinators/optionalConsume.test.ts b/src/combinators/optionalConsume.test.ts index c831d1c..2639fce 100644 --- a/src/combinators/optionalConsume.test.ts +++ b/src/combinators/optionalConsume.test.ts @@ -11,14 +11,14 @@ describe('optionalConsume', () => { const parser = optionalConsume(parser1); const result = parser('ABCD'); - assertSuccess(result, undefined, 'BCD'); + assertSuccess(result, undefined, 1); }); it('should not consume input on failure', () => { - const failureParser = create(() => failure()); + const failureParser = create((_input, index = 0) => failure(index)); const parser = optionalConsume(failureParser); const result = parser('ABCD'); - assertSuccess(result, undefined, 'ABCD'); + assertSuccess(result, undefined, 0); }); }); diff --git a/src/combinators/optionalConsume.ts b/src/combinators/optionalConsume.ts index 4a6d1aa..f6e288e 100644 --- a/src/combinators/optionalConsume.ts +++ b/src/combinators/optionalConsume.ts @@ -1,21 +1,21 @@ import type { Parser } from '../core/parser'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; /** - * Optionally consume input (always succeeds, returns void). + * Consume input if the parser matches, discarding the result. * * @example - * optionalConsume(string('hello'))('hello world') // { ok: true, value: undefined, remaining: ' world' } - * optionalConsume(string('hello'))('world') // { ok: true, value: undefined, remaining: 'world' } + * optionalConsume(string('hi'))('hi there') // { ok: true, index: 2, furthest: -1, expected: [] } */ export const optionalConsume = (parser: Parser) => { - return create((input) => { - const result = parser(input); + return create((input, index = 0) => { + const result = parser(input, index); return result.ok - ? success(undefined, result.remaining) - : success(undefined, input); + ? merge(result, success(undefined, result.index)) + : merge(result, success(undefined, index)); }); }; diff --git a/src/combinators/optionalSeparatedBy.test.ts b/src/combinators/optionalSeparatedBy.test.ts index e6131c0..cd13d75 100644 --- a/src/combinators/optionalSeparatedBy.test.ts +++ b/src/combinators/optionalSeparatedBy.test.ts @@ -11,7 +11,7 @@ describe('optionalSeparatedBy', () => { const parser = optionalSeparatedBy(parser1, parser2); const result = parser('B'); - assertSuccess<(string | null)[]>(result, [], 'B'); + assertSuccess<(string | null)[]>(result, [], 0); }); it('should parse single element without separator', () => { @@ -21,7 +21,7 @@ describe('optionalSeparatedBy', () => { const parser = optionalSeparatedBy(parser1, parser2); const result = parser('AAACCC'); - assertSuccess<(string | null)[]>(result, ['A'], 'AACCC'); + assertSuccess<(string | null)[]>(result, ['A'], 1); }); it('should parse multiple elements separated by separator', () => { @@ -31,7 +31,7 @@ describe('optionalSeparatedBy', () => { const parser = optionalSeparatedBy(parser1, parser2); const result = parser('A,A,A'); - assertSuccess<(string | null)[]>(result, ['A', 'A', 'A'], ''); + assertSuccess<(string | null)[]>(result, ['A', 'A', 'A'], 5); }); it('should handle leading separator as null', () => { @@ -41,7 +41,7 @@ describe('optionalSeparatedBy', () => { const parser = optionalSeparatedBy(parser1, parser2); const result = parser(',A'); - assertSuccess<(string | null)[]>(result, [null, 'A'], ''); + assertSuccess<(string | null)[]>(result, [null, 'A'], 2); }); it('should handle trailing separator without adding null', () => { @@ -51,7 +51,7 @@ describe('optionalSeparatedBy', () => { const parser = optionalSeparatedBy(parser1, parser2); const result = parser('A,'); - assertSuccess<(string | null)[]>(result, ['A'], ''); + assertSuccess<(string | null)[]>(result, ['A'], 2); }); it('should handle empty input', () => { @@ -61,7 +61,7 @@ describe('optionalSeparatedBy', () => { const parser = optionalSeparatedBy(parser1, parser2); const result = parser(''); - assertSuccess<(string | null)[]>(result, [], ''); + assertSuccess<(string | null)[]>(result, [], 0); }); it('should handle single separator only as leading null', () => { @@ -71,6 +71,6 @@ describe('optionalSeparatedBy', () => { const parser = optionalSeparatedBy(parser1, parser2); const result = parser(','); - assertSuccess<(string | null)[]>(result, [null], ''); + assertSuccess<(string | null)[]>(result, [null], 1); }); }); diff --git a/src/combinators/optionalSeparatedBy.ts b/src/combinators/optionalSeparatedBy.ts index c197092..5486665 100644 --- a/src/combinators/optionalSeparatedBy.ts +++ b/src/combinators/optionalSeparatedBy.ts @@ -1,54 +1,60 @@ import type { Parser } from '../core/parser'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** - * Zero or more items separated by a separator, with optional null values. + * Parse items separated by a separator, allowing empty slots. * * @example - * 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: '' } + * optionalSeparatedBy(digits, char(','))('1,2') // { ok: true, value: [1, 2], index: 3, furthest: 3, expected: ["','"] } */ export const optionalSeparatedBy = ( parser: Parser, separator: Parser, ) => { - return create<(T | null)[]>((input) => { + return create<(T | null)[]>((input, index = 0) => { const results: (T | null)[] = []; - let remaining = input; + let at = index; + + const leadingSep = separator(input, at); + let trace: Result = leadingSep; - const leadingSep = separator(remaining); if (leadingSep.ok) { results.push(null); - remaining = leadingSep.remaining; + at = leadingSep.index; } - const firstResult = parser(remaining); + const firstResult: Result = merge(trace, parser(input, at)); + trace = firstResult; + if (firstResult.ok) { results.push(firstResult.value); - remaining = firstResult.remaining; + at = firstResult.index; } else if (!leadingSep.ok) { - return success([], input); + return merge(trace, success([], index)); } while (true) { - const sepResult = separator(remaining); + const sepResult: Result = merge(trace, separator(input, at)); + trace = sepResult; if (!sepResult.ok) { break; } - remaining = sepResult.remaining; + at = sepResult.index; - const nextResult = parser(remaining); + const nextResult: Result = merge(trace, parser(input, at)); + trace = nextResult; if (!nextResult.ok) { break; } results.push(nextResult.value); - remaining = nextResult.remaining; + at = nextResult.index; } - return success(results, remaining); + return merge(trace, success(results, at)); }); }; diff --git a/src/combinators/outer.test.ts b/src/combinators/outer.test.ts index a2bebd0..0ba5b78 100644 --- a/src/combinators/outer.test.ts +++ b/src/combinators/outer.test.ts @@ -16,7 +16,7 @@ describe('outer', () => { const parser = outer(parserA, parserB, parserC); const result = parser('ABC'); - assertSuccess<['A', 'C']>(result, ['A', 'C'], ''); + assertSuccess<['A', 'C']>(result, ['A', 'C'], 3); }); it('should fail if first parser fails', () => { diff --git a/src/combinators/outer.ts b/src/combinators/outer.ts index 214d546..08a9548 100644 --- a/src/combinators/outer.ts +++ b/src/combinators/outer.ts @@ -8,7 +8,7 @@ import { sequence } from './sequence'; * Extract outer values from a sequence of 3 parsers (skip middle). * * @example - * outer(char('('), string('hi'), char(')'))('(hi)') // { ok: true, value: ['(', ')'], remaining: '' } + * outer(char('('), string('hi'), char(')'))('(hi)') // { ok: true, value: ['(', ')'], index: 4, furthest: -1, expected: [] } */ export const outer = ( parserA: Parser, diff --git a/src/combinators/padded.test.ts b/src/combinators/padded.test.ts index d8c8234..da46ccd 100644 --- a/src/combinators/padded.test.ts +++ b/src/combinators/padded.test.ts @@ -12,28 +12,28 @@ describe('padded', () => { const parser = createTestParser('A'); const result = padded(parser)(' A '); - assertSuccess<'A'>(result, 'A', ''); + assertSuccess<'A'>(result, 'A', 5); }); it('should parse content with no padding', () => { const parser = createTestParser('A'); const result = padded(parser)('A'); - assertSuccess<'A'>(result, 'A', ''); + assertSuccess<'A'>(result, 'A', 1); }); it('should parse content with padding on left only', () => { const parser = createTestParser('A'); const result = padded(parser)(' A'); - assertSuccess<'A'>(result, 'A', ''); + assertSuccess<'A'>(result, 'A', 3); }); it('should parse content with padding on right only', () => { const parser = createTestParser('A'); const result = padded(parser)('A '); - assertSuccess<'A'>(result, 'A', ''); + assertSuccess<'A'>(result, 'A', 3); }); it('should fail if content fails', () => { @@ -47,13 +47,13 @@ describe('padded', () => { const parser = createTestParser('A'); const result = padded(parser)(' A B'); - assertSuccess<'A'>(result, 'A', 'B'); + assertSuccess<'A'>(result, 'A', 5); }); it('should handle multiple whitespace characters', () => { const parser = createTestParser('A'); const result = padded(parser)('\t\n A \r\n'); - assertSuccess<'A'>(result, 'A', ''); + assertSuccess<'A'>(result, 'A', 9); }); }); diff --git a/src/combinators/padded.ts b/src/combinators/padded.ts index 09df8a2..799c912 100644 --- a/src/combinators/padded.ts +++ b/src/combinators/padded.ts @@ -7,10 +7,10 @@ import { map } from './map'; import { sequence } from './sequence'; /** - * Parse content with optional whitespace on both sides. + * Parse content surrounded by optional whitespace. * * @example - * padded(string('hi'))(' hi ') // { ok: true, value: 'hi', remaining: '' } + * padded(string('hi'))(' hi ') // { ok: true, value: 'hi', index: 6, furthest: 6, expected: ['whitespace'] } */ export const padded = (content: Parser) => { const parser = map( @@ -18,7 +18,7 @@ export const padded = (content: Parser) => { ([, value]) => value, ); - return create((input) => { - return parser(input); + return create((input, index = 0) => { + return parser(input, index); }); }; diff --git a/src/combinators/parenthesized.test.ts b/src/combinators/parenthesized.test.ts index d337843..adc58ce 100644 --- a/src/combinators/parenthesized.test.ts +++ b/src/combinators/parenthesized.test.ts @@ -12,7 +12,7 @@ describe('parenthesized', () => { const parser = parenthesized(createTestParser('ABC')); const result = parser('(ABC)'); - assertSuccess<'ABC'>(result, 'ABC', ''); + assertSuccess<'ABC'>(result, 'ABC', 5); }); it('should fail with uneven parentheses', () => { @@ -26,6 +26,6 @@ describe('parenthesized', () => { const parser = parenthesized(createTestParser('')); const result = parser('()'); - assertSuccess<''>(result, '', ''); + assertSuccess<''>(result, '', 2); }); }); diff --git a/src/combinators/parenthesized.ts b/src/combinators/parenthesized.ts index ba369d5..4d00426 100644 --- a/src/combinators/parenthesized.ts +++ b/src/combinators/parenthesized.ts @@ -7,7 +7,7 @@ import { string } from '../terminals/string'; * Parse content surrounded by parentheses. * * @example - * parenthesized(string('hi'))('(hi)') // { ok: true, value: 'hi', remaining: '' } + * parenthesized(string('hi'))('(hi)') // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } */ export const parenthesized = (content: Parser) => { return surrounded(string('('), content, string(')')); diff --git a/src/combinators/peek.test.ts b/src/combinators/peek.test.ts index d4f9a85..96681c5 100644 --- a/src/combinators/peek.test.ts +++ b/src/combinators/peek.test.ts @@ -13,7 +13,7 @@ describe('peek', () => { const parser = peek(parser1); const result = parser('AB'); - assertSuccess<'A'>(result, 'A', 'AB'); + assertSuccess<'A'>(result, 'A', 0); }); it('should fail when peek parser does not match', () => { diff --git a/src/combinators/peek.ts b/src/combinators/peek.ts index c50c426..3648256 100644 --- a/src/combinators/peek.ts +++ b/src/combinators/peek.ts @@ -1,19 +1,19 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; /** - * Parse without consuming input. + * Look ahead without consuming input. * * @example - * peek(string('hello'))('hello world') // { ok: true, value: 'hello', remaining: 'hello world' } + * peek(string('hello'))('hello world') // { ok: true, value: 'hello', index: 0, furthest: -1, expected: [] } */ export const peek = (parser: Parser) => { - return create((input) => { - const result = parser(input); + return create((input, index = 0) => { + const result = parser(input, index); - return result.ok ? success(result.value, input) : failure(); + return result.ok ? merge(result, success(result.value, index)) : result; }); }; diff --git a/src/combinators/postfix.test.ts b/src/combinators/postfix.test.ts index 3bd487d..4bf454a 100644 --- a/src/combinators/postfix.test.ts +++ b/src/combinators/postfix.test.ts @@ -9,8 +9,8 @@ import { success } from '../core/success'; import { postfix } from './postfix'; describe('postfix', () => { - const postfixOps: Parser<(value: number) => number> = (input) => { - if (input.startsWith('!')) { + const postfixOps: Parser<(value: number) => number> = (input, index = 0) => { + if (input.startsWith('!', index)) { return success((value) => { let result = 1; for (let i = 2; i <= value; i += 1) { @@ -18,19 +18,19 @@ describe('postfix', () => { } return result; - }, input.slice(1)); + }, index + 1); } - if (input.startsWith('²')) { - return success((value) => value * value, input.slice(1)); + if (input.startsWith('²', index)) { + return success((value) => value * value, index + 1); } - return failure(); + return failure(index); }; it('should handle atom without postfix operators', () => { const parser = postfix(digits, postfixOps); const result = parser('5'); - assertSuccess(result, 5, ''); + assertSuccess(result, 5, 1); }); it('should apply single postfix operator', () => { @@ -38,12 +38,12 @@ describe('postfix', () => { { const result = parser('5!'); - assertSuccess(result, 120, ''); // 5! = 120 + assertSuccess(result, 120, 2); // 5! = 120 } { const result = parser('3²'); - assertSuccess(result, 9, ''); // 3² = 9 + assertSuccess(result, 9, 2); // 3² = 9 } }); @@ -51,7 +51,7 @@ describe('postfix', () => { const parser = postfix(digits, postfixOps); const result = parser('3²!'); - assertSuccess(result, 362880, ''); // (3²)! = 9! = 362880 + assertSuccess(result, 362880, 3); // (3²)! = 9! = 362880 }); it('should fail when atom parser fails', () => { @@ -65,13 +65,13 @@ describe('postfix', () => { const parser = postfix(digits, postfixOps); const result = parser('2²²'); - assertSuccess(result, 16, ''); // (2²)² = 4² = 16 + assertSuccess(result, 16, 3); // (2²)² = 4² = 16 }); it('should stop when no more operators match', () => { const parser = postfix(digits, postfixOps); const result = parser('3!+'); - assertSuccess(result, 6, '+'); // 3! = 6, stops at + + assertSuccess(result, 6, 2); // 3! = 6, stops at + }); }); diff --git a/src/combinators/postfix.ts b/src/combinators/postfix.ts index b7b5b60..b6ac14e 100644 --- a/src/combinators/postfix.ts +++ b/src/combinators/postfix.ts @@ -1,32 +1,39 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** - * Parse postfix operators (chains atom with operators that return functions). + * Apply zero or more postfix operators to an atom. * * @example - * postfix(char('a'), map(char('!'), () => (x) => x))('a!') // { ok: true, value: 'a', remaining: '' } + * postfix(digits, map(char('!'), () => (n) => n * 2))('3!') // { ok: true, value: 6, index: 2, furthest: 2, expected: ["'!'"] } */ export const postfix = ( atom: Parser, operator: Parser<(value: T) => T>, ) => { - return create((input) => { - const atomResult = atom(input); - if (!atomResult.ok) return failure(); + return create((input, index = 0) => { + const atomResult = atom(input, index); + if (!atomResult.ok) { + return atomResult; + } - let { value, remaining } = atomResult; + let value = atomResult.value; + let at = atomResult.index; + let trace: Result = atomResult; while (true) { - const opResult = operator(remaining); + const opResult: Result<(value: T) => T> = merge(trace, operator(input, at)); + trace = opResult; if (!opResult.ok) break; + value = opResult.value(value); - remaining = opResult.remaining; + at = opResult.index; } - return success(value, remaining); + return merge(trace, success(value, at)); }); }; diff --git a/src/combinators/prefix.test.ts b/src/combinators/prefix.test.ts index cb8abfc..f84c16d 100644 --- a/src/combinators/prefix.test.ts +++ b/src/combinators/prefix.test.ts @@ -9,21 +9,21 @@ import { success } from '../core/success'; import { prefix } from './prefix'; describe('prefix', () => { - const unaryOps: Parser<(value: number) => number> = (input) => { - if (input.startsWith('-')) { - return success((value) => -value, input.slice(1)); + const unaryOps: Parser<(value: number) => number> = (input, index = 0) => { + if (input.startsWith('-', index)) { + return success((value) => -value, index + 1); } - if (input.startsWith('+')) { - return success((value) => Math.abs(value), input.slice(1)); + if (input.startsWith('+', index)) { + return success((value) => Math.abs(value), index + 1); } - return failure(); + return failure(index); }; it('should handle atom without prefix operators', () => { const parser = prefix(unaryOps, digits); const result = parser('42'); - assertSuccess(result, 42, ''); + assertSuccess(result, 42, 2); }); it('should apply single prefix operator', () => { @@ -31,12 +31,12 @@ describe('prefix', () => { { const result = parser('-5'); - assertSuccess(result, -5, ''); + assertSuccess(result, -5, 2); } { const result = parser('+5'); - assertSuccess(result, 5, ''); // abs(5) = 5 + assertSuccess(result, 5, 2); // abs(5) = 5 } }); @@ -45,12 +45,12 @@ describe('prefix', () => { { const result = parser('--5'); - assertSuccess(result, 5, ''); // -(-5) = 5 + assertSuccess(result, 5, 3); // -(-5) = 5 } { const result = parser('+-5'); - assertSuccess(result, 5, ''); // +(-5) = abs(-5) = 5 + assertSuccess(result, 5, 3); // +(-5) = abs(-5) = 5 } }); @@ -65,13 +65,13 @@ describe('prefix', () => { const parser = prefix(unaryOps, digits); const result = parser('---5'); - assertSuccess(result, -5, ''); // -(-(- 5)) = -5 + assertSuccess(result, -5, 4); // -(-(- 5)) = -5 }); it('should handle operators that consume no input when none match', () => { const parser = prefix(unaryOps, digits); const result = parser('123*'); - assertSuccess(result, 123, '*'); + assertSuccess(result, 123, 3); }); }); diff --git a/src/combinators/prefix.ts b/src/combinators/prefix.ts index c6e298f..5ba490d 100644 --- a/src/combinators/prefix.ts +++ b/src/combinators/prefix.ts @@ -1,38 +1,44 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** - * Parse prefix operators (like - in -5). + * Apply zero or more prefix operators to an atom. * * @example - * prefix(map(char('-'), () => (x) => -x), digit)('-5') // { ok: true, value: -5, remaining: '' } + * prefix(map(char('-'), () => (n) => -n), digits)('-3') // { ok: true, value: -3, index: 2, furthest: 1, expected: ["'-'"] } */ export const prefix = ( operator: Parser<(value: T) => T>, atom: Parser, ) => { - return create((input) => { + return create((input, index = 0) => { const operators: Array<(value: T) => T> = []; - let remaining = input; + let at = index; + let trace: Result = success(null, index); while (true) { - const opResult = operator(remaining); + const opResult: Result<(value: T) => T> = merge(trace, operator(input, at)); + trace = opResult; if (!opResult.ok) break; + operators.push(opResult.value); - remaining = opResult.remaining; + at = opResult.index; } - const atomResult = atom(remaining); - if (!atomResult.ok) return failure(); + const atomResult = merge(trace, atom(input, at)); + if (!atomResult.ok) { + return atomResult; + } const finalValue = operators.reduceRight( (value, op) => op(value), atomResult.value, ); - return success(finalValue, atomResult.remaining); + return merge(atomResult, success(finalValue, atomResult.index)); }); }; diff --git a/src/combinators/pure.test.ts b/src/combinators/pure.test.ts index 21fda1c..1f44510 100644 --- a/src/combinators/pure.test.ts +++ b/src/combinators/pure.test.ts @@ -8,20 +8,20 @@ describe('pure', () => { const parser = pure(42); const result = parser('abc'); - assertSuccess(result, 42, 'abc'); + assertSuccess(result, 42, 0); }); it('should work on empty input', () => { const parser = pure('ok'); const result = parser(''); - assertSuccess(result, 'ok', ''); + assertSuccess(result, 'ok', 0); }); it('should work with any type', () => { const parser = pure({ x: 1 }); const result = parser('xyz'); - assertSuccess<{ x: number }>(result, { x: 1 }, 'xyz'); + assertSuccess<{ x: number }>(result, { x: 1 }, 0); }); }); diff --git a/src/combinators/pure.ts b/src/combinators/pure.ts index 6e790c9..dd9a1dc 100644 --- a/src/combinators/pure.ts +++ b/src/combinators/pure.ts @@ -4,12 +4,11 @@ import { create } from '../core/parser'; import { success } from '../core/success'; /** - * Always return a value without consuming input. + * Always succeed with a value without consuming input. * * @example - * pure(42)('abc') // { ok: true, value: 42, remaining: 'abc' } - * pure('ok')('') // { ok: true, value: 'ok', remaining: '' } + * pure(42)('abc') // { ok: true, value: 42, index: 0, furthest: -1, expected: [] } */ export const pure = (value: T): Parser => { - return create((input) => success(value, input)); + return create((_input, index = 0) => success(value, index)); }; diff --git a/src/combinators/quoted.test.ts b/src/combinators/quoted.test.ts index b9edd91..62fb547 100644 --- a/src/combinators/quoted.test.ts +++ b/src/combinators/quoted.test.ts @@ -12,14 +12,14 @@ describe('quoted', () => { const parser = quoted(createTestParser('ABC')); const result = parser('"ABC"'); - assertSuccess<'ABC'>(result, 'ABC', ''); + assertSuccess<'ABC'>(result, 'ABC', 5); }); it('should parse single-quoted content', () => { const parser = quoted(createTestParser('ABC')); const result = parser("'ABC'"); - assertSuccess<'ABC'>(result, 'ABC', ''); + assertSuccess<'ABC'>(result, 'ABC', 5); }); it('should fail with mismatched quotes', () => { @@ -33,13 +33,13 @@ describe('quoted', () => { const parser = quoted(createTestParser('ABC')); const result = parser('"ABC"rest'); - assertSuccess<'ABC'>(result, 'ABC', 'rest'); + assertSuccess<'ABC'>(result, 'ABC', 5); }); it('should handle empty quotes input', () => { const parser = quoted(createTestParser('')); const result = parser('""'); - assertSuccess<''>(result, '', ''); + assertSuccess<''>(result, '', 2); }); }); diff --git a/src/combinators/quoted.ts b/src/combinators/quoted.ts index 9af7539..f9cdf17 100644 --- a/src/combinators/quoted.ts +++ b/src/combinators/quoted.ts @@ -8,7 +8,7 @@ import { string } from '../terminals/string'; * Parse content surrounded by single or double quotes. * * @example - * quoted(string('hello'))('"hello"') // { ok: true, value: 'hello', remaining: '' } + * quoted(string('hello'))('"hello"') // { ok: true, value: 'hello', index: 7, furthest: -1, expected: [] } */ export const quoted = (content: Parser) => { return choice( diff --git a/src/combinators/recover.test.ts b/src/combinators/recover.test.ts index 72990fa..5633468 100644 --- a/src/combinators/recover.test.ts +++ b/src/combinators/recover.test.ts @@ -12,26 +12,26 @@ describe('recover', () => { const parser = recover(parserA, 'default'); const result = parser('BCD'); - assertSuccess<'A' | 'default'>(result, 'default', 'BCD'); + assertSuccess<'A' | 'default'>(result, 'default', 0); }); it('should not consume input when parser fails', () => { - const parserFail = create<'A'>(() => failure()); + const parserFail = create<'A'>((_input, index = 0) => failure(index)); const parser = recover(parserFail, 'world'); const result = parser('goodbye'); - assertSuccess<'A' | 'world'>(result, 'world', 'goodbye'); + assertSuccess<'A' | 'world'>(result, 'world', 0); }); it('should handle empty input', () => { const parser = recover(parserA, 'empty'); const result = parser(''); - assertSuccess<'A' | 'empty'>(result, 'empty', ''); + assertSuccess<'A' | 'empty'>(result, 'empty', 0); }); it('should work with complex default values', () => { - const parser1 = create(() => failure()); + const parser1 = create((_input, index = 0) => failure(index)); const parser = recover<{ default: boolean; value: number } | string>( parser1, { @@ -44,7 +44,7 @@ describe('recover', () => { assertSuccess<{ default: boolean; value: number } | string>( result, { default: true, value: 42 }, - 'y', + 0, ); }); @@ -52,6 +52,6 @@ describe('recover', () => { const parser = recover(parserA, 'default'); const result = parser('ABCD'); - assertSuccess<'A' | 'default'>(result, 'A', 'BCD'); + assertSuccess<'A' | 'default'>(result, 'A', 1); }); }); diff --git a/src/combinators/recover.ts b/src/combinators/recover.ts index ed3829c..9b30dc9 100644 --- a/src/combinators/recover.ts +++ b/src/combinators/recover.ts @@ -1,5 +1,6 @@ import type { Parser } from '../core/parser'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; @@ -7,12 +8,12 @@ import { success } from '../core/success'; * Use fallback value when parser fails. * * @example - * recover(string('hello'), 'default')('world') // { ok: true, value: 'default', remaining: 'world' } + * recover(string('hello'), 'default')('world') // { ok: true, value: 'default', index: 0, furthest: 0, expected: ["'hello'"] } */ export const recover = (parser: Parser, fallback: T) => { - return create((input) => { - const result = parser(input); + return create((input, index = 0) => { + const result = parser(input, index); - return result.ok ? result : success(fallback, input); + return result.ok ? result : merge(result, success(fallback, index)); }); }; diff --git a/src/combinators/right.test.ts b/src/combinators/right.test.ts index d62868f..5db5961 100644 --- a/src/combinators/right.test.ts +++ b/src/combinators/right.test.ts @@ -15,7 +15,7 @@ describe('right', () => { const parser = right(parser1, parser2); const result = parser('AB'); - assertSuccess<'B'>(result, 'B', ''); + assertSuccess<'B'>(result, 'B', 2); }); it('should fail if first parser fails', () => { diff --git a/src/combinators/right.ts b/src/combinators/right.ts index 464a6ef..96ea6e1 100644 --- a/src/combinators/right.ts +++ b/src/combinators/right.ts @@ -8,7 +8,7 @@ import { sequence } from './sequence'; * Keep only the right result from a sequence. * * @example - * right(string('hello'), string('world'))('helloworld') // { ok: true, value: 'world', remaining: '' } + * right(string('hello'), string('world'))('helloworld') // { ok: true, value: 'world', index: 10, furthest: -1, expected: [] } */ export const right = (parserA: Parser, parserB: Parser) => { return create(map(sequence(parserA, parserB), ([, b]) => b)); diff --git a/src/combinators/separatedBy.test.ts b/src/combinators/separatedBy.test.ts index 6b330fc..d4c2da0 100644 --- a/src/combinators/separatedBy.test.ts +++ b/src/combinators/separatedBy.test.ts @@ -11,7 +11,7 @@ describe('separatedBy', () => { const parser = separatedBy(parser1, parser2); const result = parser('C'); - assertSuccess<'A'[]>(result, [], 'C'); + assertSuccess<'A'[]>(result, [], 0); }); it('should parse single element without separator', () => { @@ -21,7 +21,7 @@ describe('separatedBy', () => { const parser = separatedBy(parser1, parser2); const result = parser('AAACCC'); - assertSuccess<'A'[]>(result, ['A'], 'AACCC'); + assertSuccess<'A'[]>(result, ['A'], 1); }); it('should parse multiple elements separated by separator', () => { @@ -31,7 +31,7 @@ describe('separatedBy', () => { const parser = separatedBy(parser1, parser2); const result = parser('A,A,A'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], ''); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 5); }); it('should handle trailing separator by not consuming it', () => { @@ -41,7 +41,7 @@ describe('separatedBy', () => { const parser = separatedBy(parser1, parser2); const result = parser('A,A,A,'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], ','); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 5); }); it('should handle separator without following element', () => { @@ -51,6 +51,6 @@ describe('separatedBy', () => { const parser = separatedBy(parser1, parser2); const result = parser('A,A,C'); - assertSuccess<'A'[]>(result, ['A', 'A'], ',C'); + assertSuccess<'A'[]>(result, ['A', 'A'], 3); }); }); diff --git a/src/combinators/separatedBy.ts b/src/combinators/separatedBy.ts index 475fe49..f6ac0b4 100644 --- a/src/combinators/separatedBy.ts +++ b/src/combinators/separatedBy.ts @@ -1,39 +1,46 @@ import type { Parser } from '../core/parser'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** - * Zero or more items separated by a separator. + * Parse zero or more items separated by a separator. + * + * A trailing separator is not consumed: if the separator matches but the item + * after it does not, the list ends before the separator. * * @example - * separatedBy(char('a'), char(','))('a,a,a') // { ok: true, value: ['a', 'a', 'a'], remaining: '' } + * separatedBy(digits, char(','))('1,2,3') // { ok: true, value: [1, 2, 3], index: 5, furthest: 5, expected: ["','"] } */ export const separatedBy = (parser: Parser, separator: Parser) => { - return create((input) => { - const firstResult = parser(input); + return create((input, index = 0) => { + const firstResult = parser(input, index); if (!firstResult.ok) { - return success([], input); + return merge(firstResult, success([], index)); } const results = [firstResult.value]; - let remaining = firstResult.remaining; + let at = firstResult.index; + let trace: Result = firstResult; while (true) { - const sepResult = separator(remaining); + const sepResult: Result = merge(trace, separator(input, at)); + trace = sepResult; if (!sepResult.ok) break; - const nextResult = parser(sepResult.remaining); - if (!nextResult.ok) { - // If separator matched but parser failed, backtrack - // Don't consume the separator - break; - } + const nextResult: Result = merge(trace, parser(input, sepResult.index)); + trace = nextResult; + + // If separator matched but parser failed, backtrack + // Don't consume the separator + if (!nextResult.ok) break; results.push(nextResult.value); - remaining = nextResult.remaining; + at = nextResult.index; } - return success(results, remaining); + return merge(trace, success(results, at)); }); }; diff --git a/src/combinators/separatedBy1.test.ts b/src/combinators/separatedBy1.test.ts index fb94b1a..327a13b 100644 --- a/src/combinators/separatedBy1.test.ts +++ b/src/combinators/separatedBy1.test.ts @@ -25,7 +25,7 @@ describe('separatedBy1', () => { const parser = separatedBy1(parser1, parser2); const result = parser('ABC'); - assertSuccess<'A'[]>(result, ['A'], 'BC'); + assertSuccess<'A'[]>(result, ['A'], 1); }); it('should parse multiple elements', () => { @@ -35,7 +35,7 @@ describe('separatedBy1', () => { const parser = separatedBy1(parser1, parser2); const result = parser('A,A,A,A'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A', 'A'], ''); + assertSuccess<'A'[]>(result, ['A', 'A', 'A', 'A'], 7); }); it('should handle trailing separator', () => { @@ -45,7 +45,7 @@ describe('separatedBy1', () => { const parser = separatedBy1(parser1, parser2); const result = parser('A,A,A,A,'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A', 'A'], ','); + assertSuccess<'A'[]>(result, ['A', 'A', 'A', 'A'], 7); }); it('should fail on empty input', () => { diff --git a/src/combinators/separatedBy1.ts b/src/combinators/separatedBy1.ts index 5e4580c..99da120 100644 --- a/src/combinators/separatedBy1.ts +++ b/src/combinators/separatedBy1.ts @@ -1,21 +1,28 @@ +import type { Parser } from '../core/parser'; + import { failure } from '../core/failure'; -import { create, type Parser } from '../core/parser'; -import { type Success, success } from '../core/success'; +import { merge } from '../core/merge'; +import { create } from '../core/parser'; +import { type Success } from '../core/success'; import { separatedBy } from './separatedBy'; /** - * One or more items separated by a separator. + * Parse one or more items separated by a separator. * * @example - * separatedBy1(char('a'), char(','))('a,a,a') // { ok: true, value: ['a', 'a', 'a'], remaining: '' } + * separatedBy1(digits, char(','))('1,2,3') // { ok: true, value: [1, 2, 3], index: 5, furthest: 5, expected: ["','"] } */ export const separatedBy1 = (parser: Parser, separator: Parser) => { - return create((input) => { - const result = separatedBy(parser, separator)(input); + return create((input, index = 0) => { + const result = separatedBy(parser, separator)( + input, + index, + ) as Success; - const { value: values, remaining } = result as Success; - if (values.length === 0) return failure(); + if (result.value.length === 0) { + return merge(result, failure(index)); + } - return success(values, remaining); + return result; }); }; diff --git a/src/combinators/separatedEndBy.test.ts b/src/combinators/separatedEndBy.test.ts index 6fefdce..262f3f7 100644 --- a/src/combinators/separatedEndBy.test.ts +++ b/src/combinators/separatedEndBy.test.ts @@ -11,7 +11,7 @@ describe('separatedEndBy', () => { const parser = separatedEndBy(parser1, parser2); const result = parser('CCC'); - assertSuccess<'A'[]>(result, [], 'CCC'); + assertSuccess<'A'[]>(result, [], 0); }); it('should parse elements without trailing separator', () => { @@ -21,7 +21,7 @@ describe('separatedEndBy', () => { const parser = separatedEndBy(parser1, parser2); const result = parser('A,A,A'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], ''); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 5); }); it('should parse elements with trailing separator', () => { @@ -31,7 +31,7 @@ describe('separatedEndBy', () => { const parser = separatedEndBy(parser1, parser2); const result = parser('A,A,A,'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], ''); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 6); }); it('should handle single element with separator', () => { @@ -41,7 +41,7 @@ describe('separatedEndBy', () => { const parser = separatedEndBy(parser1, parser2); const result = parser('A,'); - assertSuccess<'A'[]>(result, ['A'], ''); + assertSuccess<'A'[]>(result, ['A'], 2); }); it('should handle single element without separator', () => { @@ -51,6 +51,6 @@ describe('separatedEndBy', () => { const parser = separatedEndBy(parser1, parser2); const result = parser('A'); - assertSuccess<'A'[]>(result, ['A'], ''); + assertSuccess<'A'[]>(result, ['A'], 1); }); }); diff --git a/src/combinators/separatedEndBy.ts b/src/combinators/separatedEndBy.ts index 663fc07..2da5d36 100644 --- a/src/combinators/separatedEndBy.ts +++ b/src/combinators/separatedEndBy.ts @@ -1,20 +1,27 @@ -import { create, type Parser } from '../core/parser'; -import { type Success, success } from '../core/success'; +import type { Parser } from '../core/parser'; + +import { merge } from '../core/merge'; +import { create } from '../core/parser'; +import { success, type Success } from '../core/success'; import { separatedBy } from './separatedBy'; /** - * Zero or more items separated by and ending with a terminator. + * Parse zero or more items separated by a separator, allowing a trailing one. * * @example - * separatedEndBy(char('a'), char(';'))('a;a;a;') // { ok: true, value: ['a', 'a', 'a'], remaining: '' } + * separatedEndBy(digits, char(','))('1,2,3,') // { ok: true, value: [1, 2, 3], index: 6, furthest: 6, expected: ['digit'] } */ export const separatedEndBy = (parser: Parser, separator: Parser) => { - return create((input) => { - const result = separatedBy(parser, separator)(input); - - const { value: values, remaining } = result as Success; - const sepResult = separator(remaining); + return create((input, index = 0) => { + const result = separatedBy(parser, separator)( + input, + index, + ) as Success; + const sepResult = merge(result, separator(input, result.index)); - return success(values, sepResult.ok ? sepResult.remaining : remaining); + return merge( + sepResult, + success(result.value, sepResult.ok ? sepResult.index : result.index), + ); }); }; diff --git a/src/combinators/separatedEndBy1.test.ts b/src/combinators/separatedEndBy1.test.ts index c306e55..3a85886 100644 --- a/src/combinators/separatedEndBy1.test.ts +++ b/src/combinators/separatedEndBy1.test.ts @@ -25,7 +25,7 @@ describe('separatedEndBy1', () => { const parser = separatedEndBy1(parser1, parser2); const result = parser('ACC'); - assertSuccess<'A'[]>(result, ['A'], 'CC'); + assertSuccess<'A'[]>(result, ['A'], 1); }); it('should parse single element with separator', () => { @@ -35,7 +35,7 @@ describe('separatedEndBy1', () => { const parser = separatedEndBy1(parser1, parser2); const result = parser('A,'); - assertSuccess<'A'[]>(result, ['A'], ''); + assertSuccess<'A'[]>(result, ['A'], 2); }); it('should parse multiple elements with trailing separator', () => { @@ -45,7 +45,7 @@ describe('separatedEndBy1', () => { const parser = separatedEndBy1(parser1, parser2); const result = parser('A,A,A,'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], ''); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 6); }); it('should parse multiple elements without trailing separator', () => { @@ -55,7 +55,7 @@ describe('separatedEndBy1', () => { const parser = separatedEndBy1(parser1, parser2); const result = parser('A,A,A'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], ''); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 5); }); it('should fail on empty input', () => { diff --git a/src/combinators/separatedEndBy1.ts b/src/combinators/separatedEndBy1.ts index 3901105..16db2f3 100644 --- a/src/combinators/separatedEndBy1.ts +++ b/src/combinators/separatedEndBy1.ts @@ -1,24 +1,28 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { separatedBy1 } from './separatedBy1'; /** - * One or more items separated by and ending with a terminator. + * Parse one or more items separated by a separator, allowing a trailing one. * * @example - * separatedEndBy1(char('a'), char(';'))('a;a;a;') // { ok: true, value: ['a', 'a', 'a'], remaining: '' } + * separatedEndBy1(digits, char(','))('1,2,3,') // { ok: true, value: [1, 2, 3], index: 6, furthest: 6, expected: ['digit'] } */ export const separatedEndBy1 = (parser: Parser, separator: Parser) => { - return create((input) => { - const result = separatedBy1(parser, separator)(input); - if (!result.ok) return failure(); + return create((input, index = 0) => { + const result = separatedBy1(parser, separator)(input, index); + if (!result.ok) { + return result; + } - const { value: values, remaining } = result; - const sepResult = separator(remaining); + const sepResult = merge(result, separator(input, result.index)); - return success(values, sepResult.ok ? sepResult.remaining : remaining); + return merge( + sepResult, + success(result.value, sepResult.ok ? sepResult.index : result.index), + ); }); }; diff --git a/src/combinators/separatedUntil.test.ts b/src/combinators/separatedUntil.test.ts index 13273f0..3ecdb68 100644 --- a/src/combinators/separatedUntil.test.ts +++ b/src/combinators/separatedUntil.test.ts @@ -16,7 +16,7 @@ describe('separatedUntil', () => { const parser = separatedUntil(parser1, parser2, parser3); const result = parser('A,A,A;'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A'], ''); + assertSuccess<'A'[]>(result, ['A', 'A', 'A'], 6); }); it('should handle single element with terminator', () => { @@ -27,7 +27,7 @@ describe('separatedUntil', () => { const parser = separatedUntil(parser1, parser2, parser3); const result = parser('A;'); - assertSuccess<'A'[]>(result, ['A'], ''); + assertSuccess<'A'[]>(result, ['A'], 2); }); it('should handle empty list with terminator', () => { @@ -38,7 +38,7 @@ describe('separatedUntil', () => { const parser = separatedUntil(parser1, parser2, parser3); const result = parser(';'); - assertSuccess<'A'[]>(result, [], ''); + assertSuccess<'A'[]>(result, [], 1); }); it('should fail without terminator', () => { diff --git a/src/combinators/separatedUntil.ts b/src/combinators/separatedUntil.ts index e4bfb6f..3efc2ce 100644 --- a/src/combinators/separatedUntil.ts +++ b/src/combinators/separatedUntil.ts @@ -1,27 +1,30 @@ -import { failure } from '../core/failure'; -import { create, type Parser } from '../core/parser'; +import type { Parser } from '../core/parser'; + +import { merge } from '../core/merge'; +import { create } from '../core/parser'; import { success, type Success } from '../core/success'; import { separatedBy } from './separatedBy'; /** - * Parse items separated by separator until terminator matches. + * Parse items separated by a separator, up to a terminator. * * @example - * separatedUntil(char('a'), char(','), char(';'))('a,a,a;') // { ok: true, value: ['a', 'a', 'a'], remaining: '' } + * separatedUntil(digits, char(','), char(';'))('1,2,3;') // { ok: true, value: [1, 2, 3], index: 6, furthest: 5, expected: ["','"] } */ export const separatedUntil = ( parser: Parser, separator: Parser, terminator: Parser, ) => { - return create((input) => { - const { value: values, remaining } = separatedBy( - parser, - separator, - )(input) as Success; - - const result = terminator(remaining); + return create((input, index = 0) => { + const items = separatedBy(parser, separator)( + input, + index, + ) as Success; + const result = merge(items, terminator(input, items.index)); - return result.ok ? success(values, result.remaining) : failure(); + return result.ok + ? merge(result, success(items.value, result.index)) + : result; }); }; diff --git a/src/combinators/sequence.test.ts b/src/combinators/sequence.test.ts index 93c233f..097bb84 100644 --- a/src/combinators/sequence.test.ts +++ b/src/combinators/sequence.test.ts @@ -18,7 +18,7 @@ describe('sequence', () => { const parser = sequence(parser1, parser2, parser3); const result = parser('ABCDE'); - assertSuccess<['A', 'B', 'C'] | null>(result, ['A', 'B', 'C'], 'DE'); + assertSuccess<['A', 'B', 'C'] | null>(result, ['A', 'B', 'C'], 3); }); it('should thread remaining input through parsers', () => { @@ -27,11 +27,11 @@ describe('sequence', () => { const parser = sequence(parser1, parser2); const result = parser('ABC'); - assertSuccess<['A', 'B'] | null>(result, ['A', 'B'], 'C'); + assertSuccess<['A', 'B'] | null>(result, ['A', 'B'], 2); }); it('should fail if first parser fails', () => { - const failureParser = create(() => failure()); + const failureParser = create((_input, index = 0) => failure(index)); const parser2 = createTestParser('B'); const parser = sequence(failureParser, parser2); const result = parser('xxx'); @@ -41,7 +41,7 @@ describe('sequence', () => { it('should fail if middle parser fails', () => { const parser1 = createTestParser('A'); - const failureParser = create(() => failure()); + const failureParser = create((_input, index = 0) => failure(index)); const parser3 = createTestParser('C'); const parser = sequence(parser1, failureParser, parser3); const result = parser('xxx'); @@ -53,15 +53,15 @@ describe('sequence', () => { const parser = sequence(); const result = parser('anything'); - assertSuccess<[] | null>(result, [], 'anything'); + assertSuccess<[] | null>(result, [], 0); }); it('should preserve parser result types', () => { - const strParser = create<'text'>(() => success('text', '')); - const numParser = create<42>(() => success(42, '')); + const strParser = create<'text'>((input) => success('text', input.length)); + const numParser = create<42>((input) => success(42, input.length)); const parser = sequence(strParser, numParser); const result = parser('xx'); - assertSuccess<[string, number]>(result, ['text', 42], ''); + assertSuccess<[string, number]>(result, ['text', 42], 2); }); }); diff --git a/src/combinators/sequence.ts b/src/combinators/sequence.ts index d2148d2..be80972 100644 --- a/src/combinators/sequence.ts +++ b/src/combinators/sequence.ts @@ -1,32 +1,36 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** * Parse a sequence of parsers and return all results as an array. * * @example - * sequence(char('a'), char('b'), char('c'))('abc') // { ok: true, value: ['a', 'b', 'c'], remaining: '' } + * sequence(char('a'), char('b'), char('c'))('abc') // { ok: true, value: ['a', 'b', 'c'], index: 3, furthest: -1, expected: [] } */ export const sequence = ( ...parsers: { [K in keyof T]: Parser } ) => { - return create((input) => { + return create((input, index = 0) => { const results: unknown[] = []; - let remaining = input; + let at = index; + let trace: Result = success(null, index); for (const parser of parsers) { - const result = parser(remaining); + const result: Result = merge(trace, parser(input, at)); + trace = result; + if (!result.ok) { - return failure(); + return result; } results.push(result.value); - remaining = result.remaining; + at = result.index; } - return success(results as unknown as T, remaining); + return merge(trace, success(results as unknown as T, at)); }); }; diff --git a/src/combinators/skip.test.ts b/src/combinators/skip.test.ts index c83bce2..81a80d4 100644 --- a/src/combinators/skip.test.ts +++ b/src/combinators/skip.test.ts @@ -13,7 +13,7 @@ describe('skip', () => { const parser = skip(parser1, 3); const result = parser('AAAB'); - assertSuccess(result, null, 'B'); + assertSuccess(result, null, 3); }); it('should fail if not enough occurrences', () => { @@ -29,6 +29,6 @@ describe('skip', () => { const parser = skip(parser1, 0); const result = parser('ABC'); - assertSuccess(result, null, 'ABC'); + assertSuccess(result, null, 0); }); }); diff --git a/src/combinators/skip.ts b/src/combinators/skip.ts index 8f2797d..619a795 100644 --- a/src/combinators/skip.ts +++ b/src/combinators/skip.ts @@ -1,27 +1,32 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** * Skip a parser n times. * * @example - * skip(char('a'), 2)('aabc') // { ok: true, value: null, remaining: 'bc' } + * skip(char('a'), 2)('aabc') // { ok: true, value: null, index: 2, furthest: -1, expected: [] } */ export const skip = (parser: Parser, count: number) => { - return create((input) => { - let remaining = input; + return create((input, index = 0) => { + let at = index; + let trace: Result = success(null, index); for (let i = 0; i < count; i++) { - const result = parser(remaining); + const result: Result = merge(trace, parser(input, at)); + trace = result; + if (!result.ok) { - return failure(); + return result; } - remaining = result.remaining; + + at = result.index; } - return success(null, remaining); + return merge(trace, success(null, at)); }); }; diff --git a/src/combinators/skipMany.test.ts b/src/combinators/skipMany.test.ts index a3505ff..47655cb 100644 --- a/src/combinators/skipMany.test.ts +++ b/src/combinators/skipMany.test.ts @@ -9,7 +9,7 @@ describe('skipMany', () => { const parser = skipMany(parser1); const result = parser('AAABBB'); - assertSuccess(result, null, 'BBB'); + assertSuccess(result, null, 3); }); it('should return null even when no matches found', () => { @@ -17,7 +17,7 @@ describe('skipMany', () => { const parser = skipMany(parser1); const result = parser('BBB'); - assertSuccess(result, null, 'BBB'); + assertSuccess(result, null, 0); }); it('should handle empty input', () => { @@ -25,6 +25,6 @@ describe('skipMany', () => { const parser = skipMany(parser1); const result = parser(''); - assertSuccess(result, null, ''); + assertSuccess(result, null, 0); }); }); diff --git a/src/combinators/skipMany.ts b/src/combinators/skipMany.ts index 1926642..5d3c5cd 100644 --- a/src/combinators/skipMany.ts +++ b/src/combinators/skipMany.ts @@ -8,7 +8,7 @@ import { map } from './map'; * Skip zero or more occurrences (never fails, returns null). * * @example - * skipMany(char('a'))('aaabc') // { ok: true, value: null, remaining: 'bc' } + * skipMany(char('a'))('aaabc') // { ok: true, value: null, index: 3, furthest: 3, expected: ["'a'"] } */ export const skipMany = (parser: Parser) => { return create(map(many(parser), () => null)); diff --git a/src/combinators/skipMany1.test.ts b/src/combinators/skipMany1.test.ts index 2920cd9..1e86cae 100644 --- a/src/combinators/skipMany1.test.ts +++ b/src/combinators/skipMany1.test.ts @@ -14,7 +14,7 @@ describe('skipMany1', () => { const parser = skipMany1(parser1); const result = parser('AAABBB'); - assertSuccess(result, null, 'BBB'); + assertSuccess(result, null, 3); }); it('should fail when no matches found', () => { @@ -32,7 +32,7 @@ describe('skipMany1', () => { const parser = skipMany1(parser1); const result = parser('ABBB'); - assertSuccess(result, null, 'BBB'); + assertSuccess(result, null, 1); }); it('should require at least one match', () => { diff --git a/src/combinators/skipMany1.ts b/src/combinators/skipMany1.ts index 3bff0c3..6ae8910 100644 --- a/src/combinators/skipMany1.ts +++ b/src/combinators/skipMany1.ts @@ -8,7 +8,7 @@ import { map } from './map'; * Skip one or more occurrences (fails if no matches). * * @example - * skipMany1(char('a'))('aaabc') // { ok: true, value: null, remaining: 'bc' } + * skipMany1(char('a'))('aaabc') // { ok: true, value: null, index: 3, furthest: 3, expected: ["'a'"] } */ export const skipMany1 = (parser: Parser) => { return create(map(many1(parser), () => null)); diff --git a/src/combinators/surrounded.test.ts b/src/combinators/surrounded.test.ts index d471030..f1c59ac 100644 --- a/src/combinators/surrounded.test.ts +++ b/src/combinators/surrounded.test.ts @@ -15,7 +15,7 @@ describe('surrounded', () => { const parser = surrounded(parser1, parser2); const result = parser('ABA'); - assertSuccess<'B'>(result, 'B', ''); + assertSuccess<'B'>(result, 'B', 3); }); it('should fail if opening delimiter fails', () => { @@ -56,7 +56,7 @@ describe('surrounded', () => { const parser = surrounded(parser1, parser2, parser3); const result = parser('ABCAA'); - assertSuccess<'B'>(result, 'B', 'AA'); + assertSuccess<'B'>(result, 'B', 3); }); it('should leave remaining input', () => { @@ -66,6 +66,6 @@ describe('surrounded', () => { const parser = surrounded(parser1, parser2); const result = parser('ABAAA'); - assertSuccess<'B'>(result, 'B', 'AA'); + assertSuccess<'B'>(result, 'B', 3); }); }); diff --git a/src/combinators/surrounded.ts b/src/combinators/surrounded.ts index 573030f..857a904 100644 --- a/src/combinators/surrounded.ts +++ b/src/combinators/surrounded.ts @@ -7,8 +7,8 @@ import { inner } from './inner'; * Parse content surrounded by delimiters. * * @example - * surrounded(char('['), string('hi'), char(']'))('[hi]') // { ok: true, value: 'hi', remaining: '' } - * surrounded(char('a'), char('b'), char('c'))('abc') // { ok: true, value: 'b', remaining: '' } + * surrounded(char('['), string('hi'), char(']'))('[hi]') // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } + * surrounded(char('a'), char('b'), char('c'))('abc') // { ok: true, value: 'b', index: 3, furthest: -1, expected: [] } */ export const surrounded = ( first: Parser, diff --git a/src/combinators/unless.test.ts b/src/combinators/unless.test.ts index cad5fe6..b570098 100644 --- a/src/combinators/unless.test.ts +++ b/src/combinators/unless.test.ts @@ -12,14 +12,14 @@ describe('unless', () => { const parser = unless(false, createTestParser('A')); const result = parser('AAA'); - assertSuccess<'A' | null>(result, 'A', 'AA'); + assertSuccess<'A' | null>(result, 'A', 1); }); it('should return null and consume no input when condition is true', () => { const parser = unless(true, createTestParser('A')); const result = parser('AAA'); - assertSuccess<'A' | null>(result, null, 'AAA'); + assertSuccess<'A' | null>(result, null, 0); }); it('should fail when condition is false but parser fails', () => { diff --git a/src/combinators/unless.ts b/src/combinators/unless.ts index 1c3fe46..fa10f58 100644 --- a/src/combinators/unless.ts +++ b/src/combinators/unless.ts @@ -7,11 +7,11 @@ import { success } from '../core/success'; * Parse unless condition is true (inverse of guard). * * @example - * unless(false, string('hello'))('hello') // { ok: true, value: 'hello', remaining: '' } - * unless(true, string('hello'))('hello') // { ok: true, value: null, remaining: 'hello' } + * unless(false, string('hello'))('hello') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } + * unless(true, string('hello'))('hello') // { ok: true, value: null, index: 0, furthest: -1, expected: [] } */ export const unless = (condition: boolean, parser: Parser) => { - return create((input) => { - return !condition ? parser(input) : success(null, input); + return create((input, index = 0) => { + return !condition ? parser(input, index) : success(null, index); }); }; diff --git a/src/combinators/until.test.ts b/src/combinators/until.test.ts index f8a9644..d974448 100644 --- a/src/combinators/until.test.ts +++ b/src/combinators/until.test.ts @@ -17,15 +17,15 @@ describe('until', () => { const parser = until(aParser, bParser); const result = parser('AAAABAAAA'); - assertSuccess<'A'[]>(result, ['A', 'A', 'A', 'A'], 'BAAAA'); + assertSuccess<'A'[]>(result, ['A', 'A', 'A', 'A'], 4); }); it('should return empty array when terminator is at start', () => { - const failureParser = create(() => failure()); + const failureParser = create((_input, index = 0) => failure(index)); const parser = until(failureParser, bParser); const result = parser('BAAAA'); - assertSuccess(result, [], 'BAAAA'); + assertSuccess(result, [], 0); }); it('should fail when terminator is never found and parser fails', () => { diff --git a/src/combinators/until.ts b/src/combinators/until.ts index c1032fc..41d48b5 100644 --- a/src/combinators/until.ts +++ b/src/combinators/until.ts @@ -1,36 +1,41 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; +import { type Result } from '../core/result'; import { success } from '../core/success'; /** - * Parse until terminator matches (fails if terminator never matches). + * Parse zero or more until terminator matches, leaving the terminator unconsumed. * * @example - * until(char('a'), char('b'))('baaa') // { ok: true, value: [], remaining: 'baaa' } - * until(char('a'), char('b'))('aaba') // { ok: true, value: ['a', 'a'], remaining: 'ba' } + * until(char('a'), char('b'))('aaab') // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 2, expected: ["'b'"] } */ export const until = (parser: Parser, terminator: Parser) => { - return create((input) => { + return create((input, index = 0) => { const results: T[] = []; - let remaining = input; + let at = index; + let trace: Result = success(null, index); while (true) { - const termResult = terminator(remaining); + const termResult: Result = merge(trace, terminator(input, at)); if (termResult.ok) { break; } - const parseResult = parser(remaining); + trace = termResult; + + const parseResult: Result = merge(trace, parser(input, at)); + trace = parseResult; + if (!parseResult.ok) { - return failure(); + return parseResult; } results.push(parseResult.value); - remaining = parseResult.remaining; + at = parseResult.index; } - return success(results, remaining); + return merge(trace, success(results, at)); }); }; diff --git a/src/combinators/validate.test.ts b/src/combinators/validate.test.ts index 76fb009..f413ac0 100644 --- a/src/combinators/validate.test.ts +++ b/src/combinators/validate.test.ts @@ -13,7 +13,7 @@ describe('validate', () => { const parser = validate(parser1, (value) => value === 'A'); const result = parser('AAA'); - assertSuccess<'A'>(result, 'A', 'AA'); + assertSuccess<'A'>(result, 'A', 1); }); it('should fail when parser succeeds but predicate returns false', () => { diff --git a/src/combinators/validate.ts b/src/combinators/validate.ts index 3c2ebd3..f56b4d9 100644 --- a/src/combinators/validate.ts +++ b/src/combinators/validate.ts @@ -1,23 +1,27 @@ import type { Parser } from '../core/parser'; import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; /** * Validate parsed value with a predicate. * * @example - * validate(digit, (n) => n > 5)('7') // { ok: true, value: 7, remaining: '' } - * validate(digit, (n) => n > 5)('3') // { ok: false } + * validate(digit, (n) => n > 5)('7') // { ok: true, value: 7, index: 1, furthest: -1, expected: [] } + * validate(digit, (n) => n > 5)('3') // { ok: false, index: 0, furthest: 0, expected: ['valid value'] } */ export const validate = ( parser: Parser, predicate: (value: T) => boolean, + expected = 'valid value', ) => { - return create((input) => { - const result = parser(input); - if (!result.ok) return failure(); + return create((input, index = 0) => { + const result = parser(input, index); + if (!result.ok) return result; - return predicate(result.value) ? result : failure(); + return predicate(result.value) + ? result + : merge(result, failure(index, expected)); }); }; diff --git a/src/combinators/value.test.ts b/src/combinators/value.test.ts index 534f770..565c2d3 100644 --- a/src/combinators/value.test.ts +++ b/src/combinators/value.test.ts @@ -14,21 +14,21 @@ describe('value', () => { const parser = value(aParser, 42); const result = parser('ABC'); - assertSuccess(result, 42, 'BC'); + assertSuccess(result, 42, 1); }); it('should work with boolean constant', () => { const parser = value(aParser, true); const result = parser('ABC'); - assertSuccess(result, true, 'BC'); + assertSuccess(result, true, 1); }); it('should work with null constant', () => { const parser = value(aParser, null); const result = parser('ABC'); - assertSuccess(result, null, 'BC'); + assertSuccess(result, null, 1); }); it('should fail when underlying parser fails', () => { diff --git a/src/combinators/value.ts b/src/combinators/value.ts index 69e8f6c..e7c176b 100644 --- a/src/combinators/value.ts +++ b/src/combinators/value.ts @@ -6,8 +6,8 @@ import { map } from './map'; * Replace parsed value with a constant. * * @example - * value(string('true'), true)('true') // { ok: true, value: true, remaining: '' } - * value(string('null'), null)('null') // { ok: true, value: null, remaining: '' } + * value(string('true'), true)('true') // { ok: true, value: true, index: 4, furthest: -1, expected: [] } + * value(string('null'), null)('null') // { ok: true, value: null, index: 4, furthest: -1, expected: [] } */ export const value = (parser: Parser, constant: U): Parser => { return map(parser, () => constant); diff --git a/src/combinators/when.test.ts b/src/combinators/when.test.ts index 2496c47..ad44440 100644 --- a/src/combinators/when.test.ts +++ b/src/combinators/when.test.ts @@ -10,15 +10,15 @@ import { create } from '../core/parser'; import { success } from '../core/success'; import { when } from './when'; -const boolParser = create((input) => { - if (input.startsWith('*')) { - return success(true, input.slice(1)); +const boolParser = create((input, index = 0) => { + if (input.startsWith('*', index)) { + return success(true, index + 1); } - return success(false, input); + return success(false, index); }); -const failingCondition = create(() => failure()); +const failingCondition = create((_input, index = 0) => failure(index)); describe('when', () => { const thenP = createTestParser('yes'); @@ -28,14 +28,14 @@ describe('when', () => { const parser = when(boolParser, thenP, elseP); const result = parser('*yes'); - assertSuccess<'yes' | 'no'>(result, 'yes', ''); + assertSuccess<'yes' | 'no'>(result, 'yes', 4); }); it('should run elseParser when condition is false', () => { const parser = when(boolParser, thenP, elseP); const result = parser('no'); - assertSuccess<'yes' | 'no'>(result, 'no', ''); + assertSuccess<'yes' | 'no'>(result, 'no', 2); }); it('should fail when condition parser fails', () => { diff --git a/src/combinators/when.ts b/src/combinators/when.ts index 2906799..b4455cd 100644 --- a/src/combinators/when.ts +++ b/src/combinators/when.ts @@ -1,29 +1,29 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; /** * Branch on a boolean parser result. * * @example - * 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' } + * when(flag(char('*')), pure('many'), pure('one'))('*rest') // { ok: true, value: 'many', index: 1, furthest: -1, expected: [] } + * when(flag(char('*')), pure('many'), pure('one'))('abc') // { ok: true, value: 'one', index: 0, furthest: 0, expected: ["'*'"] } */ export const when = ( condition: Parser, thenParser: Parser, elseParser: Parser, ): Parser => { - return create((input) => { - const result = condition(input); + return create((input, index = 0) => { + const result = condition(input, index); if (!result.ok) { - return failure(); + return result; } - return result.value - ? thenParser(result.remaining) - : elseParser(result.remaining); + const branch = result.value ? thenParser : elseParser; + + return merge(result, branch(input, result.index)); }); }; diff --git a/src/core/error.ts b/src/core/error.ts new file mode 100644 index 0000000..ee32c74 --- /dev/null +++ b/src/core/error.ts @@ -0,0 +1,28 @@ +import { format } from './format'; +import { locate } from './locate'; + +/** + * Error thrown by {@link run} when a parse fails, carrying the offset, the + * 1-based line/column and the set of expectations at that offset. + * + * @example + * new ParseError('a=1,b=x', 6, ['digit']).line // 1 + */ +export class ParseError extends Error { + readonly index: number; + readonly line: number; + readonly column: number; + readonly expected: readonly string[]; + + constructor(input: string, index: number, expected: readonly string[]) { + super(format(input, index, expected)); + + const { line, column } = locate(input, index); + + this.name = 'ParseError'; + this.index = index; + this.line = line; + this.column = column; + this.expected = expected; + } +} diff --git a/src/core/failure.test.ts b/src/core/failure.test.ts index 4c81d56..b92828b 100644 --- a/src/core/failure.test.ts +++ b/src/core/failure.test.ts @@ -1,12 +1,22 @@ -import { describe, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; -import { assertFailure } from '../../test/utils'; import { failure } from './failure'; describe('failure', () => { - it('should create failed parse result', () => { - const result = failure(); + it('should create failed parse result at an offset', () => { + const result = failure(3); - assertFailure(result); + expect(result).toEqual({ + ok: false, + index: 3, + furthest: 3, + expected: [], + }); + }); + + it('should record what was expected at that offset', () => { + const result = failure(3, 'digit', 'letter'); + + expect(result.expected).toEqual(['digit', 'letter']); }); }); diff --git a/src/core/failure.ts b/src/core/failure.ts index 1f48646..40479ee 100644 --- a/src/core/failure.ts +++ b/src/core/failure.ts @@ -1,12 +1,22 @@ -export type Failure = { ok: false; error?: string }; +export type Failure = { + ok: false; + index: number; + furthest: number; + expected: readonly string[]; +}; /** - * Creates a failed result with an optional error message. + * Creates a failed result at an offset, describing what was expected there. + * + * Expectations are phrased as nouns ('digit', "'{'") and are unioned by + * {@link choice}, so a failing alternation reports every branch it tried. * * @example - * failure('unexpected input') // { ok: false, error: 'unexpected input' } + * failure(3, 'digit') // { ok: false, index: 3, furthest: 3, expected: ['digit'] } */ -export const failure = (error?: string): Failure => ({ +export const failure = (index: number, ...expected: string[]): Failure => ({ ok: false, - error, + index, + furthest: index, + expected, }); diff --git a/src/core/format.ts b/src/core/format.ts new file mode 100644 index 0000000..a235d09 --- /dev/null +++ b/src/core/format.ts @@ -0,0 +1,45 @@ +import { locate } from './locate'; + +const describeFound = (input: string, index: number): string => { + if (index >= input.length) return 'end of input'; + + const char = input[index]; + const escaped = JSON.stringify(char).slice(1, -1); + + return `'${escaped}'`; +}; + +const describeExpected = (expected: readonly string[]): string => { + if (expected.length === 0) return 'expected something else'; + if (expected.length === 1) return `expected ${expected[0]}`; + + const sorted = [...expected].sort(); + const last = sorted.pop(); + + return `expected ${sorted.join(', ')} or ${last}`; +}; + +/** + * Renders a parse failure as a source excerpt with a caret under the offset. + * + * @example + * format('a=1,b=x', 6, ['digit']) // "1:7 expected digit, found 'x'\n 1 | a=1,b=x\n | ^" + */ +export const format = ( + input: string, + index: number, + expected: readonly string[], +): string => { + const { line, column } = locate(input, index); + const lines = input.split('\n'); + const source = lines[line - 1] ?? ''; + const gutter = String(line); + const pad = ' '.repeat(gutter.length); + const headline = `${line}:${column} ${describeExpected(expected)}, found ${describeFound(input, index)}`; + + return [ + headline, + ` ${gutter} | ${source}`, + ` ${pad} | ${' '.repeat(column - 1)}^`, + ].join('\n'); +}; diff --git a/src/core/grammar.test.ts b/src/core/grammar.test.ts index ccd674c..a5cbd1d 100644 --- a/src/core/grammar.test.ts +++ b/src/core/grammar.test.ts @@ -44,12 +44,12 @@ describe('grammar', () => { { const result = a('abc'); - assertSuccess<'a'>(result, 'a', 'bc'); + assertSuccess<'a'>(result, 'a', 1); } { const result = b('bcd'); - assertSuccess<'b'>(result, 'b', 'cd'); + assertSuccess<'b'>(result, 'b', 1); } }); @@ -73,22 +73,22 @@ describe('grammar', () => { { const result = add('2+3'); - assertSuccess(result, 5, ''); + assertSuccess(result, 5, 3); } { const result = number('5'); - assertSuccess(result, 5, ''); + assertSuccess(result, 5, 1); } { const result = term('(2+3)'); - assertSuccess(result, 5, ''); + assertSuccess(result, 5, 5); } { const result = expr('1+(2+3)'); // [6, ''] - assertSuccess(result, 6, ''); + assertSuccess(result, 6, 7); } }); }); diff --git a/src/core/grammar.ts b/src/core/grammar.ts index 57c5d43..1313937 100644 --- a/src/core/grammar.ts +++ b/src/core/grammar.ts @@ -1,14 +1,41 @@ -import type { Parser } from '../core/parser'; - -import { lazy } from './lazy'; +import type { Parser } from './parser'; export type Grammar> = { [K in keyof T]: (parsers: { [P in keyof T]: Parser }) => Parser; }; +/** + * Wraps a rule so re-entering it at an offset it is already parsing throws a + * named error instead of overflowing the stack. + */ +const guardLeftRecursion = (name: string, thunk: () => Parser): Parser => { + const active: number[] = []; + + return (input, index = 0) => { + if (active.includes(index)) { + throw new Error( + `Left recursion detected in grammar rule "${name}" at offset ${index}. ` + + `Rewrite the rule to consume input before recursing — e.g. with chainLeft1 or many.`, + ); + } + + active.push(index); + + try { + return thunk()(input, index); + } finally { + active.pop(); + } + }; +}; + /** * Creates a recursive grammar where rules can reference each other. * + * Rules are resolved lazily through a shared record, so they may refer to one + * another by name without forward declarations. Left-recursive rules are + * reported by name rather than overflowing the stack. + * * @example * type Math = { * expr: number; @@ -30,7 +57,7 @@ export const grammar = >( const parsers = {} as { [K in keyof T]: Parser }; for (const key in definitions) { - parsers[key] = lazy(() => definitions[key](parsers)); + parsers[key] = guardLeftRecursion(key, () => definitions[key](parsers)); } return parsers; diff --git a/src/core/index.ts b/src/core/index.ts index e93843e..a846eda 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,9 +1,14 @@ +export * from './error'; export * from './failure'; +export * from './format'; export * from './grammar'; export * from './label'; export * from './lazy'; +export * from './locate'; export * from './match'; export * from './memoize'; +export * from './merge'; +export * from './parse'; export * from './parser'; export * from './result'; export * from './run'; diff --git a/src/core/label.test.ts b/src/core/label.test.ts index d2fb825..fda54b2 100644 --- a/src/core/label.test.ts +++ b/src/core/label.test.ts @@ -13,7 +13,7 @@ describe('label', () => { const labeled = label(parser, 'letter'); const result = labeled('ABC'); - assertSuccess(result, 'A', 'BC'); + assertSuccess(result, 'A', 1); }); it('should include label in error message on failure', () => { @@ -21,6 +21,6 @@ describe('label', () => { const labeled = label(parser, 'letter'); const result = labeled('BCD'); - assertFailure(result, 'expected letter'); + assertFailure(result, 0, ['letter']); }); }); diff --git a/src/core/label.ts b/src/core/label.ts index fb5f7f4..e0d3df6 100644 --- a/src/core/label.ts +++ b/src/core/label.ts @@ -1,21 +1,24 @@ -import type { Parser } from '../core/parser'; +import type { Parser } from './parser'; import { failure } from './failure'; import { create } from './parser'; /** - * Labels a parser with a custom error message on failure. + * Replaces the expectations of a parser with a single description. + * + * Only applies when the parser failed without consuming input: once a branch + * has committed, its own deeper error is more useful than the label. * * @example - * label(char('x'), 'letter x')('') // { ok: false, error: 'expected letter x' } + * label(char('x'), 'letter x')('') // { ok: false, index: 0, furthest: 0, expected: ['letter x'] } */ export const label = (parser: Parser, expected: string): Parser => { - return create((input) => { - const result = parser(input); - if (!result.ok) { - return failure(`expected ${expected}`); + return create((input, index = 0) => { + const result = parser(input, index); + if (result.ok || result.index > index) { + return result; } - return result; + return failure(index, expected); }); }; diff --git a/src/core/lazy.test.ts b/src/core/lazy.test.ts index b3bf242..4175030 100644 --- a/src/core/lazy.test.ts +++ b/src/core/lazy.test.ts @@ -25,75 +25,74 @@ describe('lazy', () => { expect(called).toBe(false); const result = parser('ABC'); - assertSuccess<'A'>(result, 'A', 'BC'); + assertSuccess<'A'>(result, 'A', 1); expect(called).toBe(true); }); it('should enable recursive parsers', () => { const charParser = (expected: string) => { - return create((input) => { - if (input.length > 0 && input[0] === expected) { - return success(expected, input.slice(1)); + return create((input, index = 0) => { + if (input[index] === expected) { + return success(expected, index + 1); } - return failure(); + return failure(index, expected); }); }; const parent: Parser = lazy(() => { const baseCase = charParser('x'); - const recursiveCase = create((input) => { - if (input.length === 0 || input[0] !== '(') { - return failure(); + const recursiveCase = create((input, index = 0) => { + if (input[index] !== '(') { + return failure(index, "'('"); } - const innerResult = parent(input.slice(1)); + const innerResult = parent(input, index + 1); if (!innerResult.ok) { - return failure(); + return innerResult; } - const { value: innerValue, remaining: afterInner } = - innerResult; - if (afterInner.length === 0 || afterInner[0] !== ')') { - return failure(); + const after = innerResult.index; + if (input[after] !== ')') { + return failure(after, "')'"); } - return success(innerValue, afterInner.slice(1)); + return success(innerResult.value, after + 1); }); // Try the recursive case first, then base case - return create((input) => { - const recursiveResult = recursiveCase(input); + return create((input, index = 0) => { + const recursiveResult = recursiveCase(input, index); if (recursiveResult.ok) return recursiveResult; - return baseCase(input); + return baseCase(input, index); }); }); { const result = parent('x'); - assertSuccess(result, 'x', ''); + assertSuccess(result, 'x', 1); } { const result = parent('(x)'); - assertSuccess(result, 'x', ''); + assertSuccess(result, 'x', 3); } { const result = parent('((x))'); - assertSuccess(result, 'x', ''); + assertSuccess(result, 'x', 5); } { const result = parent('(((x)))'); - assertSuccess(result, 'x', ''); + assertSuccess(result, 'x', 7); } }); it('should handle parser that fails', () => { - const failureParser = create(() => failure()); + const failureParser = create((_input, index = 0) => failure(index)); const result = failureParser('goodbye'); assertFailure(result); diff --git a/src/core/lazy.ts b/src/core/lazy.ts index 73cfb21..308e525 100644 --- a/src/core/lazy.ts +++ b/src/core/lazy.ts @@ -1,4 +1,4 @@ -import type { Parser } from '../core/parser'; +import type { Parser } from './parser'; import { create } from './parser'; @@ -6,8 +6,8 @@ import { create } from './parser'; * Defers parser creation, useful for recursive grammars. * * @example - * lazy(() => char('a'))('abc') // { ok: true, value: 'a', remaining: 'bc' } + * lazy(() => char('a'))('abc') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } */ export const lazy = (thunk: () => Parser) => { - return create((input) => thunk()(input)); + return create((input, index = 0) => thunk()(input, index)); }; diff --git a/src/core/locate.ts b/src/core/locate.ts new file mode 100644 index 0000000..714eace --- /dev/null +++ b/src/core/locate.ts @@ -0,0 +1,23 @@ +export type Location = { index: number; line: number; column: number }; + +/** + * Converts a character offset into a 1-based line and column. + * + * @example + * locate('a=1\nb=x', 5) // { index: 5, line: 2, column: 2 } + */ +export const locate = (input: string, index: number): Location => { + const clamped = Math.max(0, Math.min(index, input.length)); + + let line = 1; + let lineStart = 0; + + for (let i = 0; i < clamped; i++) { + if (input.charCodeAt(i) === 10) { + line++; + lineStart = i + 1; + } + } + + return { index: clamped, line, column: clamped - lineStart + 1 }; +}; diff --git a/src/core/match.test.ts b/src/core/match.test.ts index 9304cd7..50d8c8b 100644 --- a/src/core/match.test.ts +++ b/src/core/match.test.ts @@ -5,48 +5,51 @@ import { match } from './match'; import { success } from './success'; describe('match', () => { - it('should call success branch with value and remaining', () => { - const result = success('test', 'rest'); + it('should call success branch with value and offset', () => { + const result = success('test', 4); let capturedValue = ''; - let capturedRemaining = ''; + let capturedIndex = -1; match(result, { - success: (value, remaining) => { + success: (value, index) => { capturedValue = value; - capturedRemaining = remaining; + capturedIndex = index; }, failure: () => {}, }); expect(capturedValue).toBe('test'); - expect(capturedRemaining).toBe('rest'); + expect(capturedIndex).toBe(4); }); - it('should call failure branch with error', () => { - const result = failure('test error'); - let capturedError = 'not called'; + it('should call failure branch with offset and expectations', () => { + const result = failure(2, 'digit'); + let capturedIndex = -1; + let capturedExpected: readonly string[] = []; match(result, { success: () => {}, - failure: (error) => { - capturedError = error ?? 'no error'; + failure: (index, expected) => { + capturedIndex = index; + capturedExpected = expected; }, }); - expect(capturedError).toBe('test error'); + expect(capturedIndex).toBe(2); + expect(capturedExpected).toEqual(['digit']); }); - it('should call failure branch with undefined when no error', () => { - const result = failure(); - let capturedError = 'not called'; + it('should call failure branch with no expectations when none were recorded', () => { + const result = failure(0); + let capturedExpected: readonly string[] = ['not called']; match(result, { success: () => {}, - failure: (error) => { - capturedError = error ?? 'no error'; + failure: (_index, expected) => { + capturedExpected = expected; }, }); - expect(capturedError).toBe('no error'); + expect(capturedExpected).toEqual([]); }); }); diff --git a/src/core/match.ts b/src/core/match.ts index 4fd4920..607a99e 100644 --- a/src/core/match.ts +++ b/src/core/match.ts @@ -4,16 +4,16 @@ import { type Result } from './result'; * Pattern matching on a Result to handle success and failure cases. * * @example - * match(success('hello', ''), { success: (v) => v, failure: () => 'failed' }) // 'hello' + * match(success('hello', 5), { success: (v) => v, failure: () => 'failed' }) // 'hello' */ export const match = ( result: Result, branches: { - success: (value: T, remaining: string) => U; - failure: (error?: string) => U; + success: (value: T, index: number) => U; + failure: (index: number, expected: readonly string[]) => U; }, ): U => { return result.ok - ? branches.success(result.value, result.remaining) - : branches.failure(result.error); + ? branches.success(result.value, result.index) + : branches.failure(result.index, result.expected); }; diff --git a/src/core/memoize.test.ts b/src/core/memoize.test.ts index 9b9f05c..317461f 100644 --- a/src/core/memoize.test.ts +++ b/src/core/memoize.test.ts @@ -36,8 +36,8 @@ describe('memoize', () => { const result1 = parser('123'); const result2 = parser('456'); - assertSuccess(result1, 123, ''); - assertSuccess(result2, 456, ''); + assertSuccess(result1, 123, 3); + assertSuccess(result2, 456, 3); }); it('should work with char parser', () => { @@ -46,7 +46,7 @@ describe('memoize', () => { const result1 = parser('xyz'); const result2 = parser('xyz'); - assertSuccess(result1, 'x', 'yz'); + assertSuccess(result1, 'x', 1); expect(result1).toBe(result2); }); }); diff --git a/src/core/memoize.ts b/src/core/memoize.ts index a4ece12..addfc40 100644 --- a/src/core/memoize.ts +++ b/src/core/memoize.ts @@ -1,26 +1,34 @@ -import { type Parser } from '../core/parser'; +import { type Parser } from './parser'; import { type Result } from './result'; /** - * Memoizes a parser to cache results by input string. - * Useful for expensive parsers and recursive grammars to avoid - * exponential backtracking. + * Memoizes a parser so each offset is parsed at most once (packrat caching). + * + * Keyed by offset rather than by the remaining input, so entries are cheap and + * the cache is dropped as soon as a different input is parsed. * * @example * const memoDigits = memoize(digits); - * memoDigits('123') // { ok: true, value: 123, remaining: '' } + * memoDigits('123') // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } */ export const memoize = (parser: Parser): Parser => { - const cache = new Map>(); + let source: string | null = null; + let cache = new Map>(); + + return (input, index = 0) => { + if (input !== source) { + source = input; + cache = new Map(); + } - return (input) => { - const cached = cache.get(input); + const cached = cache.get(index); if (cached !== undefined) { return cached; } - const result = parser(input); - cache.set(input, result); + const result = parser(input, index); + cache.set(index, result); + return result; }; }; diff --git a/src/core/merge.ts b/src/core/merge.ts new file mode 100644 index 0000000..b432709 --- /dev/null +++ b/src/core/merge.ts @@ -0,0 +1,60 @@ +import { type Result } from './result'; + +/** + * Adds the expectations of `b` to `a`, returning `a` itself when it already + * covers them. Callers rely on that identity to skip allocating. + */ +const union = ( + a: readonly string[], + b: readonly string[], +): readonly string[] => { + if (b.length === 0 || a === b) return a; + if (a.length === 0) return b; + + let extra: string[] | null = null; + for (const item of b) { + if (!a.includes(item)) { + (extra ??= []).push(item); + } + } + + return extra ? [...a, ...extra] : a; +}; + +/** + * Carries the furthest-failure trace of an earlier result into a later one. + * + * Every combinator that runs more than one parser threads its intermediate + * results through `merge`, so an expectation recorded deep inside a branch that + * was later backtracked over still surfaces in the final error. Without it, + * `a=1,b=x` would only report "unconsumed input" instead of "expected digit". + * + * Returns `result` untouched whenever the trace adds nothing, which is the + * common case and keeps the hot path allocation-free. + * + * @example + * merge(failure(7, 'digit'), success('ok', 3)) // { ok: true, value: 'ok', index: 3, furthest: 7, expected: ['digit'] } + */ +export const merge = ( + trace: Result, + result: Result, +): Result => { + const { furthest } = trace; + // `furthest >= 0` also rejects a trace that carries no bookkeeping at all, + // so a hand-built result cannot crash the merge. + if (!(furthest >= 0) || furthest < result.furthest) { + return result; + } + + if (furthest > result.furthest) { + const { expected } = trace; + + return result.ok + ? { ...result, furthest, expected } + : { ...result, index: furthest, furthest, expected }; + } + + const expected = union(result.expected, trace.expected); + + return expected === result.expected ? result : { ...result, expected }; +}; diff --git a/src/core/parse.ts b/src/core/parse.ts new file mode 100644 index 0000000..3ae2527 --- /dev/null +++ b/src/core/parse.ts @@ -0,0 +1,46 @@ +import type { Parser } from './parser'; + +import { format } from './format'; +import { locate } from './locate'; +import { diagnose } from './run'; + +export type ParseResult = + | { ok: true; value: T } + | { + ok: false; + index: number; + line: number; + column: number; + expected: readonly string[]; + message: string; + }; + +/** + * Runs a parser over the whole input without throwing. + * + * The non-throwing counterpart of {@link run}: on failure it reports the + * offset, line, column, expectations and a formatted message. + * + * @example + * parse(digits, '12x').message // "1:3 expected end of input, found 'x'\n 1 | 12x\n | ^" + */ +export const parse = (parser: Parser, input: string): ParseResult => { + const result = parser(input, 0); + const problem = diagnose(input, result); + + if (!problem) { + return { ok: true, value: (result as { value: T }).value }; + } + + const { index, expected } = problem; + const { line, column } = locate(input, index); + + return { + ok: false, + index, + line, + column, + expected, + message: format(input, index, expected), + }; +}; diff --git a/src/core/parser.test.ts b/src/core/parser.test.ts index 5e9b45b..95d2ba5 100644 --- a/src/core/parser.test.ts +++ b/src/core/parser.test.ts @@ -7,23 +7,23 @@ import { success } from './success'; describe('create', () => { it('should return the result of the parser function on success', () => { - const parser = create((input) => success(input, '')); + const parser = create((input) => success(input, input.length)); const result = parser('hello'); - assertSuccess(result, 'hello', ''); + assertSuccess(result, 'hello', 5); }); it('should return null when the parser function fails', () => { - const parser = create(() => failure()); + const parser = create((_input, index = 0) => failure(index)); const result = parser('hello'); assertFailure(result); }); it('should pass the input through to the parser function', () => { - const parser = create((input) => success(input.toUpperCase(), input)); + const parser = create((input) => success(input.toUpperCase(), 0)); const result = parser('hello'); - assertSuccess(result, 'HELLO', 'hello'); + assertSuccess(result, 'HELLO', 0); }); }); diff --git a/src/core/parser.ts b/src/core/parser.ts index e069033..2b191d0 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -1,12 +1,16 @@ import { type Result } from './result'; -export type Parser = (input: string) => Result; +export type Parser = (input: string, index?: number) => Result; /** * Creates a parser from a parser function. * + * A parser reads `input` starting at `index` and never slices it — results + * report the offset they reached, so positions stay meaningful all the way up + * to the top-level error message. + * * @example - * create((input) => success('parsed', input.slice(6)))('hello world') // { ok: true, value: 'parsed', remaining: 'world' } + * create((input, index = 0) => success('parsed', index + 6))('hello world') // { ok: true, value: 'parsed', index: 6, furthest: -1, expected: [] } */ export const create = (parserFn: Parser): Parser => { return parserFn; diff --git a/src/core/run.test.ts b/src/core/run.test.ts index 2f7d207..c89d70e 100644 --- a/src/core/run.test.ts +++ b/src/core/run.test.ts @@ -14,24 +14,26 @@ describe('run', () => { expect(result).toBe('A'); }); - it('should return null result', () => { + it('should report the expectation and position on failure', () => { expect(() => { run(parser, 'B'); - }).toThrowError('Parsing failed: Unexpected error'); + }).toThrowError("1:1 expected A, found 'B'"); }); - it('should throw error for not remaining input', () => { + it('should throw error for unconsumed input', () => { expect(() => { run(parser, 'AB'); - }).toThrowError('Not all input consumed'); + }).toThrowError("1:2 expected end of input, found 'B'"); }); - it('should throw error with message', () => { + it('should throw error with the expectation the parser recorded', () => { expect(() => { run( - create(() => failure('Something weird happened')), + create((_input, index = 0) => + failure(index, 'something weird'), + ), 'AB', ); - }).toThrowError('Something weird happened'); + }).toThrowError('expected something weird'); }); }); diff --git a/src/core/run.ts b/src/core/run.ts index c765686..dc8ba6a 100644 --- a/src/core/run.ts +++ b/src/core/run.ts @@ -1,25 +1,53 @@ -import type { Parser } from '../core/parser'; +import type { Parser } from './parser'; + +import { ParseError } from './error'; +import { type Result } from './result'; /** - * Runs a parser and returns the value, throws on failure or unconsumed input. + * Resolves where a parse went wrong. * - * @example - * run(string('hello'), 'hello') // 'hello' + * A parser can succeed without consuming everything — the interesting position + * is then the furthest offset any branch reached, not where the parse stopped. */ -export const run = (parser: Parser, input: string): T => { - const result = parser(input); +export const diagnose = ( + input: string, + result: Result, +): { index: number; expected: readonly string[] } | null => { if (!result.ok) { - if (result.error === undefined) { - throw new Error('Parsing failed: Unexpected error'); - } + return { index: result.index, expected: result.expected }; + } - throw new Error(`Parsing failed: ${result.error}`); + if (result.index === input.length) { + return null; } - const { value, remaining } = result; - if (remaining !== '') { - throw new Error(`Not all input consumed: "${remaining}"`); + if (result.furthest > result.index) { + return { index: result.furthest, expected: result.expected }; + } + + const expected = result.furthest === result.index + ? [...new Set([...result.expected, 'end of input'])] + : ['end of input']; + + return { index: result.index, expected }; +}; + +/** + * Runs a parser over the whole input and returns the value. + * + * Throws a {@link ParseError} — with line, column and the expectations at that + * point — if the parse fails or leaves input unconsumed. + * + * @example + * run(string('hello'), 'hello') // 'hello' + */ +export const run = (parser: Parser, input: string): T => { + const result = parser(input, 0); + const problem = diagnose(input, result); + + if (problem) { + throw new ParseError(input, problem.index, problem.expected); } - return value; + return (result as { value: T }).value; }; diff --git a/src/core/success.test.ts b/src/core/success.test.ts index e47cc21..f7cdd2c 100644 --- a/src/core/success.test.ts +++ b/src/core/success.test.ts @@ -5,8 +5,8 @@ import { success } from './success'; describe('success', () => { it('should create successful parse result', () => { - const result = success('test', 'remaining'); + const result = success('test', 4); - assertSuccess(result, 'test', 'remaining'); + assertSuccess(result, 'test', 4); }); }); diff --git a/src/core/success.ts b/src/core/success.ts index 0e33cad..0fcaad8 100644 --- a/src/core/success.ts +++ b/src/core/success.ts @@ -1,13 +1,27 @@ -export type Success = { ok: true; value: T; remaining: string }; +export type Success = { + ok: true; + value: T; + index: number; + furthest: number; + expected: readonly string[]; +}; + +const NONE: readonly string[] = []; /** - * Creates a successful result with a value and remaining input. + * Creates a successful result with a value and the offset reached in the input. + * + * The `furthest`/`expected` pair carries the furthest failure seen while + * producing this success, so a later error can still report it. Use + * {@link merge} to propagate it — a fresh success starts with no trace. * * @example - * success('hello', ' world') // { ok: true, value: 'hello', remaining: ' world' } + * success('hello', 5) // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } */ -export const success = (value: T, remaining: string): Success => ({ +export const success = (value: T, index: number): Success => ({ ok: true, value, - remaining, + index, + furthest: -1, + expected: NONE, }); diff --git a/src/primitives/alphaNum.test.ts b/src/primitives/alphaNum.test.ts index 6d6519c..c3fab47 100644 --- a/src/primitives/alphaNum.test.ts +++ b/src/primitives/alphaNum.test.ts @@ -7,13 +7,13 @@ describe('alphaNum', () => { it('should parse letter', () => { const result = alphaNum('a1'); - assertSuccess(result, 'a', '1'); + assertSuccess(result, 'a', 1); }); it('should parse digit', () => { const result = alphaNum('1a'); - assertSuccess(result, '1', 'a'); + assertSuccess(result, '1', 1); }); it('should fail on non-alphanumeric', () => { @@ -31,6 +31,6 @@ describe('alphaNum', () => { it('should narrow result to first character type for const string', () => { const result = alphaNum('a1' as const); - assertSuccess<'a'>(result, 'a', '1'); + assertSuccess<'a'>(result, 'a', 1); }); }); diff --git a/src/primitives/alphaNum.ts b/src/primitives/alphaNum.ts index 5ec66a5..9206480 100644 --- a/src/primitives/alphaNum.ts +++ b/src/primitives/alphaNum.ts @@ -7,19 +7,20 @@ import { type Letter } from './letter'; type Head = S extends `${infer C}${string}` ? C : never; export type AlphaNum = Letter | Digit; -const parser = satisfy((c) => /[a-zA-Z0-9]/.test(c)); +const parser = satisfy((c) => /[a-zA-Z0-9]/.test(c), 'alphanumeric character'); /** * Parse a single alphanumeric character. * * @example - * alphaNum('a1') // { ok: true, value: 'a', remaining: '1' } - * alphaNum('1a') // { ok: true, value: '1', remaining: 'a' } + * alphaNum('a1') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } + * alphaNum('1a') // { ok: true, value: '1', index: 1, furthest: -1, expected: [] } */ export function alphaNum( input: S, + index?: number, ): Result & AlphaNum>; -export function alphaNum(input: string): Result; -export function alphaNum(input: string) { - return create(parser)(input); +export function alphaNum(input: string, index?: number): Result; +export function alphaNum(input: string, index = 0) { + return create(parser)(input, index); } diff --git a/src/primitives/alphaNums.test.ts b/src/primitives/alphaNums.test.ts index d5ceda1..5b18db7 100644 --- a/src/primitives/alphaNums.test.ts +++ b/src/primitives/alphaNums.test.ts @@ -8,17 +8,17 @@ describe('alphaNums', () => { { const result = alphaNums('abc123 rest'); - assertSuccess(result, 'abc123', ' rest'); + assertSuccess(result, 'abc123', 6); } { const result = alphaNums('Hello2World!'); - assertSuccess(result, 'Hello2World', '!'); + assertSuccess(result, 'Hello2World', 11); } { const result = alphaNums('abc123'); - assertSuccess(result, 'abc123', ''); + assertSuccess(result, 'abc123', 6); } }); @@ -43,12 +43,12 @@ describe('alphaNums', () => { it('should handle single alphanumeric character', () => { const result = alphaNums('a!'); - assertSuccess(result, 'a', '!'); + assertSuccess(result, 'a', 1); }); it('should stop at first non-alphanumeric character', () => { const result = alphaNums('abc_def'); - assertSuccess(result, 'abc', '_def'); + assertSuccess(result, 'abc', 3); }); }); diff --git a/src/primitives/alphaNums.ts b/src/primitives/alphaNums.ts index 1484ac2..5b62549 100644 --- a/src/primitives/alphaNums.ts +++ b/src/primitives/alphaNums.ts @@ -1,5 +1,5 @@ import { many1 } from '../combinators/many1'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { alphaNum } from './alphaNum'; @@ -7,18 +7,16 @@ import { alphaNum } from './alphaNum'; const parser = many1(alphaNum); /** - * Parse one or more alphanumeric characters. + * Parse one or more alphanumeric characters as a string. * * @example - * alphaNums('abc123') // { ok: true, value: 'abc123', remaining: '' } + * alphaNums('placeholder') // { ok: true, value: 'placeholder', index: 11, furthest: 11, expected: ['alphanumeric character'] } */ -export const alphaNums = create((input) => { - const result = parser(input); +export const alphaNums = create((input, index = 0) => { + const result = parser(input, index); if (!result.ok) { - return failure(); + return result; } - const { value, remaining } = result; - - return success(value.join(''), remaining); + return merge(result, success(result.value.join(''), result.index)); }); diff --git a/src/primitives/anyChar.test.ts b/src/primitives/anyChar.test.ts index 48a6f29..7f94170 100644 --- a/src/primitives/anyChar.test.ts +++ b/src/primitives/anyChar.test.ts @@ -7,13 +7,13 @@ describe('anyChar', () => { it('should match any single character', () => { const result = anyChar('abc'); - assertSuccess(result, 'a', 'bc'); + assertSuccess(result, 'a', 1); }); it('should match special characters', () => { const result = anyChar('@#$'); - assertSuccess(result, '@', '#$'); + assertSuccess(result, '@', 1); }); it('should fail on empty input', () => { diff --git a/src/primitives/anyChar.ts b/src/primitives/anyChar.ts index d961df4..0936302 100644 --- a/src/primitives/anyChar.ts +++ b/src/primitives/anyChar.ts @@ -6,8 +6,10 @@ import { success } from '../core/success'; * Parse any single character. * * @example - * anyChar('abc') // { ok: true, value: 'a', remaining: 'bc' } + * anyChar('abc') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } */ -export const anyChar = create((input) => { - return input.length > 0 ? success(input[0], input.slice(1)) : failure(); +export const anyChar = create((input, index = 0) => { + return index < input.length + ? success(input[index], index + 1) + : failure(index, 'any character'); }); diff --git a/src/primitives/bool.test.ts b/src/primitives/bool.test.ts index 0da3b16..f8ec6dd 100644 --- a/src/primitives/bool.test.ts +++ b/src/primitives/bool.test.ts @@ -8,12 +8,12 @@ describe('bool', () => { { const result = bool('true'); - assertSuccess(result, true, ''); + assertSuccess(result, true, 4); } { const result = bool('trueabc'); - assertSuccess(result, true, 'abc'); + assertSuccess(result, true, 4); } }); @@ -21,12 +21,12 @@ describe('bool', () => { { const result = bool('false'); - assertSuccess(result, false, ''); + assertSuccess(result, false, 5); } { const result = bool('falseabc'); - assertSuccess(result, false, 'abc'); + assertSuccess(result, false, 5); } }); diff --git a/src/primitives/bool.ts b/src/primitives/bool.ts index 95a385f..7aee632 100644 --- a/src/primitives/bool.ts +++ b/src/primitives/bool.ts @@ -12,8 +12,8 @@ const parser = choice( * Parse a boolean literal. * * @example - * bool('true') // { ok: true, value: true, remaining: '' } - * bool('false') // { ok: true, value: false, remaining: '' } - * bool('trueABC') // { ok: true, value: true, remaining: 'ABC' } + * bool('true') // { ok: true, value: true, index: 4, furthest: -1, expected: [] } + * bool('false') // { ok: true, value: false, index: 5, furthest: 0, expected: ["'true'"] } + * bool('trueABC') // { ok: true, value: true, index: 4, furthest: -1, expected: [] } */ export const bool = create(parser); diff --git a/src/primitives/crlf.test.ts b/src/primitives/crlf.test.ts index 7e837d9..8e55e35 100644 --- a/src/primitives/crlf.test.ts +++ b/src/primitives/crlf.test.ts @@ -7,7 +7,7 @@ describe('crlf', () => { it('should parse CRLF sequence', () => { const result = crlf('\r\nabc'); - assertSuccess(result, '\r\n', 'abc'); + assertSuccess(result, '\r\n', 2); }); it('should fail on single CR or LF', () => { diff --git a/src/primitives/crlf.ts b/src/primitives/crlf.ts index ba477b0..44aaadf 100644 --- a/src/primitives/crlf.ts +++ b/src/primitives/crlf.ts @@ -7,6 +7,6 @@ const parser = string('\r\n'); * Parse CRLF line ending. * * @example - * crlf('\r\nabc') // { ok: true, value: '\r\n', remaining: 'abc' } + * crlf('\r\nabc') // { ok: true, value: '\r\n', index: 2, furthest: -1, expected: [] } */ export const crlf = create(parser); diff --git a/src/primitives/digit.test.ts b/src/primitives/digit.test.ts index 03f3fc7..9c3a4d5 100644 --- a/src/primitives/digit.test.ts +++ b/src/primitives/digit.test.ts @@ -8,17 +8,17 @@ describe('digit', () => { { const result = digit('5abc'); - assertSuccess(result, 5, 'abc'); + assertSuccess(result, 5, 1); } { const result = digit('0xyz'); - assertSuccess(result, 0, 'xyz'); + assertSuccess(result, 0, 1); } { const result = digit('9'); - assertSuccess(result, 9, ''); + assertSuccess(result, 9, 1); } }); @@ -43,6 +43,6 @@ describe('digit', () => { it('should only parse first digit', () => { const result = digit('123'); - assertSuccess(result, 1, '23'); + assertSuccess(result, 1, 1); }); }); diff --git a/src/primitives/digit.ts b/src/primitives/digit.ts index 0dfad0d..e62d7cc 100644 --- a/src/primitives/digit.ts +++ b/src/primitives/digit.ts @@ -5,7 +5,7 @@ import { satisfy } from '../terminals/satisfy'; export type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; const parser = map( - satisfy((c) => /^[0-9]$/.test(c)), + satisfy((c) => /^[0-9]$/.test(c), 'digit'), (c) => parseInt(c, 10), ); @@ -13,6 +13,6 @@ const parser = map( * Parse a single digit and return as number. * * @example - * digit('5abc') // { ok: true, value: 5, remaining: 'abc' } + * digit('5abc') // { ok: true, value: 5, index: 1, furthest: -1, expected: [] } */ export const digit = create(parser); diff --git a/src/primitives/digits.test.ts b/src/primitives/digits.test.ts index 1c229b7..59aa865 100644 --- a/src/primitives/digits.test.ts +++ b/src/primitives/digits.test.ts @@ -8,17 +8,17 @@ describe('digits', () => { { const result = digits('123abc'); - assertSuccess(result, 123, 'abc'); + assertSuccess(result, 123, 3); } { const result = digits('42'); - assertSuccess(result, 42, ''); + assertSuccess(result, 42, 2); } { const result = digits('007xyz'); - assertSuccess(result, 7, 'xyz'); + assertSuccess(result, 7, 3); } }); @@ -38,6 +38,6 @@ describe('digits', () => { it('should handle single digit', () => { const result = digits('5abc'); - assertSuccess(result, 5, 'abc'); + assertSuccess(result, 5, 1); }); }); diff --git a/src/primitives/digits.ts b/src/primitives/digits.ts index 88a1d7f..c257c29 100644 --- a/src/primitives/digits.ts +++ b/src/primitives/digits.ts @@ -1,5 +1,5 @@ import { many1 } from '../combinators/many1'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { digit } from './digit'; @@ -7,19 +7,18 @@ import { digit } from './digit'; const parser = many1(digit); /** - * Parse one or more digits and return as number. + * Parse one or more digits and return as a number. * * @example - * digits('123abc') // { ok: true, value: 123, remaining: 'abc' } + * digits('123') // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } */ -export const digits = create((input) => { - const result = parser(input); +export const digits = create((input, index = 0) => { + const result = parser(input, index); if (!result.ok) { - return failure(); + return result; } - const { value: list, remaining: rest } = result; - const value = list.reduce((acc, d) => acc * 10 + d, 0); + const value = result.value.reduce((acc, d) => acc * 10 + d, 0); - return success(value, rest); + return merge(result, success(value, result.index)); }); diff --git a/src/primitives/eof.test.ts b/src/primitives/eof.test.ts index d211862..de513be 100644 --- a/src/primitives/eof.test.ts +++ b/src/primitives/eof.test.ts @@ -7,7 +7,7 @@ describe('eof', () => { it('should succeed on empty input', () => { const result = eof(''); - assertSuccess(result, null, ''); + assertSuccess(result, null, 0); }); it('should fail on non-empty input', () => { diff --git a/src/primitives/eof.ts b/src/primitives/eof.ts index b8c5641..a412300 100644 --- a/src/primitives/eof.ts +++ b/src/primitives/eof.ts @@ -3,11 +3,13 @@ import { create } from '../core/parser'; import { success } from '../core/success'; /** - * Parse end of file (succeeds only on empty input). + * Match the end of the input. * * @example - * eof('') // { ok: true, value: null, remaining: '' } + * eof('') // { ok: true, value: null, index: 0, furthest: -1, expected: [] } */ -export const eof = create((input) => { - return input.length === 0 ? success(null, input) : failure(); +export const eof = create((input, index = 0) => { + return index >= input.length + ? success(null, index) + : failure(index, 'end of input'); }); diff --git a/src/primitives/eol.test.ts b/src/primitives/eol.test.ts index 16b7a59..58ba8aa 100644 --- a/src/primitives/eol.test.ts +++ b/src/primitives/eol.test.ts @@ -8,24 +8,24 @@ describe('eol', () => { { const result = eol('\nabc'); - assertSuccess(result, '\n', 'abc'); + assertSuccess(result, '\n', 1); } { const result = eol('\r\nabc'); - assertSuccess(result, '\r\n', 'abc'); + assertSuccess(result, '\r\n', 2); } }); it('should handle end of file', () => { const result = eol(''); - assertSuccess(result, '', ''); + assertSuccess(result, '', 0); }); it('should prefer CRLF over individual characters', () => { const result = eol('\r\n'); - assertSuccess(result, '\r\n', ''); + assertSuccess(result, '\r\n', 2); }); }); diff --git a/src/primitives/eol.ts b/src/primitives/eol.ts index 615c2fd..a39ab1a 100644 --- a/src/primitives/eol.ts +++ b/src/primitives/eol.ts @@ -15,6 +15,6 @@ const parser = choice( * Parse end of line (\\n, \\r\\n, or EOF). * * @example - * eol('\nabc') // { ok: true, value: '\n', remaining: 'abc' } + * eol('\nabc') // { ok: true, value: '\n', index: 1, furthest: -1, expected: [] } */ export const eol = create(parser); diff --git a/src/primitives/float.test.ts b/src/primitives/float.test.ts index da0719c..b13269a 100644 --- a/src/primitives/float.test.ts +++ b/src/primitives/float.test.ts @@ -7,19 +7,19 @@ describe('float', () => { it('should parse a positive float', () => { const result = float('1.23'); - assertSuccess(result, 1.23, ''); + assertSuccess(result, 1.23, 4); }); it('should parse a negative float', () => { const result = float('-2.5'); - assertSuccess(result, -2.5, ''); + assertSuccess(result, -2.5, 4); }); it('should parse float with many decimal places', () => { const result = float('123.456789'); - assertSuccess(result, 123.456789, ''); + assertSuccess(result, 123.456789, 10); }); it('should fail on integer input', () => { @@ -43,6 +43,6 @@ describe('float', () => { it('should stop at non-numeric character', () => { const result = float('1.23abc'); - assertSuccess(result, 1.23, 'abc'); + assertSuccess(result, 1.23, 4); }); }); diff --git a/src/primitives/float.ts b/src/primitives/float.ts index 8bacdfe..da29807 100644 --- a/src/primitives/float.ts +++ b/src/primitives/float.ts @@ -8,8 +8,8 @@ const parser = map(regex(/^-?\d+\.\d+/), (match: string) => parseFloat(match)); * Parse a floating point number. * * @example - * 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' } + * float('1.23') // { ok: true, value: 1.23, index: 4, furthest: -1, expected: [] } + * float('-2.5') // { ok: true, value: -2.5, index: 4, furthest: -1, expected: [] } + * float('1.23abc') // { ok: true, value: 1.23, index: 4, furthest: -1, expected: [] } */ export const float = create(parser); diff --git a/src/primitives/hexDigit.test.ts b/src/primitives/hexDigit.test.ts index 6f3d865..b09a58f 100644 --- a/src/primitives/hexDigit.test.ts +++ b/src/primitives/hexDigit.test.ts @@ -6,7 +6,7 @@ import { hexDigit, type HexDigit } from './hexDigit'; describe('hexDigit', () => { it('should parse hexadecimal digits', () => { const result = hexDigit('fF9'); - assertSuccess(result, 'f', 'F9'); + assertSuccess(result, 'f', 1); }); it('should fail on non-hexadecimal', () => { @@ -21,11 +21,11 @@ describe('hexDigit', () => { it('should narrow result to first character type for const string', () => { const result = hexDigit('f1a' as const); - assertSuccess<'f'>(result, 'f', '1a'); + assertSuccess<'f'>(result, 'f', 1); }); it('should narrow result for uppercase hex const string', () => { const result = hexDigit('A2B' as const); - assertSuccess<'A'>(result, 'A', '2B'); + assertSuccess<'A'>(result, 'A', 1); }); }); diff --git a/src/primitives/hexDigit.ts b/src/primitives/hexDigit.ts index 2e16f59..71aacd6 100644 --- a/src/primitives/hexDigit.ts +++ b/src/primitives/hexDigit.ts @@ -19,18 +19,19 @@ export type HexDigit = | 'E' | 'F'; -const parser = satisfy((c) => /[0-9a-fA-F]/.test(c)); +const parser = satisfy((c) => /[0-9a-fA-F]/.test(c), 'hex digit'); /** * Parse a single hexadecimal digit. * * @example - * hexDigit('fF9') // { ok: true, value: 'f', remaining: 'F9' } + * hexDigit('fF9') // { ok: true, value: 'f', index: 1, furthest: -1, expected: [] } */ export function hexDigit( input: S, + index?: number, ): Result & HexDigit>; -export function hexDigit(input: string): Result; -export function hexDigit(input: string) { - return create(parser)(input); +export function hexDigit(input: string, index?: number): Result; +export function hexDigit(input: string, index = 0) { + return create(parser)(input, index); } diff --git a/src/primitives/hexDigits.test.ts b/src/primitives/hexDigits.test.ts index bc6c801..57772cb 100644 --- a/src/primitives/hexDigits.test.ts +++ b/src/primitives/hexDigits.test.ts @@ -8,17 +8,17 @@ describe('hexDigits', () => { { const result = hexDigits('1a2bXY'); - assertSuccess(result, '1a2b', 'XY'); + assertSuccess(result, '1a2b', 4); } { const result = hexDigits('DEADBEEF'); - assertSuccess(result, 'DEADBEEF', ''); + assertSuccess(result, 'DEADBEEF', 8); } { const result = hexDigits('ff00ff rest'); - assertSuccess(result, 'ff00ff', ' rest'); + assertSuccess(result, 'ff00ff', 6); } }); @@ -43,12 +43,12 @@ describe('hexDigits', () => { it('should handle single hex digit', () => { const result = hexDigits('fXY'); - assertSuccess(result, 'f', 'XY'); + assertSuccess(result, 'f', 1); }); it('should stop at first non-hex character', () => { const result = hexDigits('a1g9'); - assertSuccess(result, 'a1', 'g9'); + assertSuccess(result, 'a1', 2); }); }); diff --git a/src/primitives/hexDigits.ts b/src/primitives/hexDigits.ts index cf02e74..47a58ca 100644 --- a/src/primitives/hexDigits.ts +++ b/src/primitives/hexDigits.ts @@ -1,5 +1,5 @@ import { many1 } from '../combinators/many1'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { hexDigit } from './hexDigit'; @@ -7,18 +7,16 @@ import { hexDigit } from './hexDigit'; const parser = many1(hexDigit); /** - * Parse one or more hexadecimal digits. + * Parse one or more hex digits as a string. * * @example - * hexDigits('deadbeef') // { ok: true, value: 'deadbeef', remaining: '' } + * hexDigits('placeholder') // { ok: false, index: 0, furthest: 0, expected: ['hex digit'] } */ -export const hexDigits = create((input) => { - const result = parser(input); +export const hexDigits = create((input, index = 0) => { + const result = parser(input, index); if (!result.ok) { - return failure(); + return result; } - const { value, remaining } = result; - - return success(value.join(''), remaining); + return merge(result, success(result.value.join(''), result.index)); }); diff --git a/src/primitives/identifier.test.ts b/src/primitives/identifier.test.ts index db5f3b9..2ce0797 100644 --- a/src/primitives/identifier.test.ts +++ b/src/primitives/identifier.test.ts @@ -8,42 +8,42 @@ describe('identifier', () => { { const result = identifier('variable_name'); - assertSuccess(result, 'variable_name', ''); + assertSuccess(result, 'variable_name', 13); } { const result = identifier('_private'); - assertSuccess(result, '_private', ''); + assertSuccess(result, '_private', 8); } { const result = identifier('camelCase'); - assertSuccess(result, 'camelCase', ''); + assertSuccess(result, 'camelCase', 9); } { const result = identifier('PascalCase'); - assertSuccess(result, 'PascalCase', ''); + assertSuccess(result, 'PascalCase', 10); } { const result = identifier('name123'); - assertSuccess(result, 'name123', ''); + assertSuccess(result, 'name123', 7); } { const result = identifier('some-name'); - assertSuccess(result, 'some', '-name'); + assertSuccess(result, 'some', 4); } { const result = identifier('some.name'); - assertSuccess(result, 'some', '.name'); + assertSuccess(result, 'some', 4); } { const result = identifier('some_name!'); - assertSuccess(result, 'some_name', '!'); + assertSuccess(result, 'some_name', 9); } }); @@ -65,12 +65,12 @@ describe('identifier', () => { { const result = identifier('a'); - assertSuccess(result, 'a', ''); + assertSuccess(result, 'a', 1); } { const result = identifier('_'); - assertSuccess(result, '_', ''); + assertSuccess(result, '_', 1); } }); }); diff --git a/src/primitives/identifier.ts b/src/primitives/identifier.ts index e789ff4..76f9b41 100644 --- a/src/primitives/identifier.ts +++ b/src/primitives/identifier.ts @@ -7,6 +7,6 @@ const parser = regex(/^[a-zA-Z_][a-zA-Z0-9_]*/); * Parse an identifier — starts with letter or underscore, no leading digit, no hyphen. * * @example - * identifier('variable_name') // { ok: true, value: 'variable_name', remaining: '' } + * identifier('variable_name') // { ok: true, value: 'variable_name', index: 13, furthest: -1, expected: [] } */ export const identifier = create(parser); diff --git a/src/primitives/integer.test.ts b/src/primitives/integer.test.ts index 8308071..7877cb2 100644 --- a/src/primitives/integer.test.ts +++ b/src/primitives/integer.test.ts @@ -7,25 +7,25 @@ describe('integer', () => { it('should parse a positive integer', () => { const result = integer('42'); - assertSuccess(result, 42, ''); + assertSuccess(result, 42, 2); }); it('should parse a negative integer', () => { const result = integer('-7'); - assertSuccess(result, -7, ''); + assertSuccess(result, -7, 2); }); it('should parse multi-digit negative integer', () => { const result = integer('-123'); - assertSuccess(result, -123, ''); + assertSuccess(result, -123, 4); }); it('should stop at non-numeric character', () => { const result = integer('123abc'); - assertSuccess(result, 123, 'abc'); + assertSuccess(result, 123, 3); }); it('should fail on non-numeric input', () => { diff --git a/src/primitives/integer.ts b/src/primitives/integer.ts index 684cbb9..d66384d 100644 --- a/src/primitives/integer.ts +++ b/src/primitives/integer.ts @@ -8,8 +8,8 @@ const parser = map(regex(/^-?\d+/), (match: string) => parseInt(match, 10)); * Parse a signed integer. * * @example - * integer('42') // { ok: true, value: 42, remaining: '' } - * integer('-7') // { ok: true, value: -7, remaining: '' } - * integer('123abc') // { ok: true, value: 123, remaining: 'abc' } + * integer('42') // { ok: true, value: 42, index: 2, furthest: -1, expected: [] } + * integer('-7') // { ok: true, value: -7, index: 2, furthest: -1, expected: [] } + * integer('123abc') // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } */ export const integer = create(parser); diff --git a/src/primitives/letter.test.ts b/src/primitives/letter.test.ts index d1d8eb0..3930f89 100644 --- a/src/primitives/letter.test.ts +++ b/src/primitives/letter.test.ts @@ -7,7 +7,7 @@ describe('letter', () => { it('should parse alphabetic characters', () => { const result = letter('A' as const); - assertSuccess(result, 'A', ''); + assertSuccess(result, 'A', 1); }); it('should fail on non-letter characters', () => { @@ -19,24 +19,24 @@ describe('letter', () => { it('should only parse first character', () => { const result = letter('hello' as never); - assertSuccess(result, 'h', 'ello'); + assertSuccess(result, 'h', 1); }); it('should match the expected type', () => { const result = letter('A'); - assertSuccess(result, 'A', ''); + assertSuccess(result, 'A', 1); }); it('should narrow result to first character type for const string', () => { const result = letter('abc' as const); - assertSuccess<'a'>(result, 'a', 'bc'); + assertSuccess<'a'>(result, 'a', 1); }); it('should narrow result type for uppercase const string', () => { const result = letter('XYZ' as const); - assertSuccess<'X'>(result, 'X', 'YZ'); + assertSuccess<'X'>(result, 'X', 1); }); }); diff --git a/src/primitives/letter.ts b/src/primitives/letter.ts index cac1e02..0a5f4c5 100644 --- a/src/primitives/letter.ts +++ b/src/primitives/letter.ts @@ -7,18 +7,19 @@ import { type UppercaseLetter } from './uppercase'; type Head = S extends `${infer C}${string}` ? C : never; export type Letter = LowercaseLetter | UppercaseLetter; -const parser = satisfy((c) => /[a-zA-Z]/.test(c)); +const parser = satisfy((c) => /[a-zA-Z]/.test(c), 'letter'); /** * Parse a single letter. * * @example - * letter('abc') // { ok: true, value: 'a', remaining: 'bc' } + * letter('abc') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } */ export function letter( input: S, + index?: number, ): Result & Letter>; -export function letter(input: string): Result; -export function letter(input: string) { - return create(parser)(input); +export function letter(input: string, index?: number): Result; +export function letter(input: string, index = 0) { + return create(parser)(input, index); } diff --git a/src/primitives/letters.test.ts b/src/primitives/letters.test.ts index 0f8164a..9aa0d66 100644 --- a/src/primitives/letters.test.ts +++ b/src/primitives/letters.test.ts @@ -8,17 +8,17 @@ describe('letters', () => { { const result = letters('abc123'); - assertSuccess(result, 'abc', '123'); + assertSuccess(result, 'abc', 3); } { const result = letters('Hello World'); - assertSuccess(result, 'Hello', ' World'); + assertSuccess(result, 'Hello', 5); } { const result = letters('ABC'); - assertSuccess(result, 'ABC', ''); + assertSuccess(result, 'ABC', 3); } }); @@ -43,6 +43,6 @@ describe('letters', () => { it('should handle single letter', () => { const result = letters('a1b2'); - assertSuccess(result, 'a', '1b2'); + assertSuccess(result, 'a', 1); }); }); diff --git a/src/primitives/letters.ts b/src/primitives/letters.ts index c8baeda..eca9ca1 100644 --- a/src/primitives/letters.ts +++ b/src/primitives/letters.ts @@ -1,5 +1,5 @@ import { many1 } from '../combinators/many1'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { letter } from './letter'; @@ -7,18 +7,16 @@ import { letter } from './letter'; const parser = many1(letter); /** - * Parse one or more letters. + * Parse one or more letters as a string. * * @example - * letters('abc123') // { ok: true, value: 'abc', remaining: '123' } + * letters('placeholder') // { ok: true, value: 'placeholder', index: 11, furthest: 11, expected: ['letter'] } */ -export const letters = create((input) => { - const result = parser(input); +export const letters = create((input, index = 0) => { + const result = parser(input, index); if (!result.ok) { - return failure(); + return result; } - const { value, remaining } = result; - - return success(value.join(''), remaining); + return merge(result, success(result.value.join(''), result.index)); }); diff --git a/src/primitives/line.test.ts b/src/primitives/line.test.ts index 1f87a1a..8911710 100644 --- a/src/primitives/line.test.ts +++ b/src/primitives/line.test.ts @@ -7,37 +7,37 @@ describe('line', () => { it('should parse until newline', () => { const result = line('hello\nworld'); - assertSuccess(result, 'hello', '\nworld'); + assertSuccess(result, 'hello', 5); }); it('should parse until carriage return', () => { const result = line('hello\rworld'); - assertSuccess(result, 'hello', '\rworld'); + assertSuccess(result, 'hello', 5); }); it('should stop before CRLF', () => { const result = line('hello\r\nworld'); - assertSuccess(result, 'hello', '\r\nworld'); + assertSuccess(result, 'hello', 5); }); it('should allow empty line', () => { { const result = line('\nrest'); - assertSuccess(result, '', '\nrest'); + assertSuccess(result, '', 0); } { const result = line('\rrest'); - assertSuccess(result, '', '\rrest'); + assertSuccess(result, '', 0); } }); it('should consume entire input when no line breaks', () => { const result = line('justtext'); - assertSuccess(result, 'justtext', ''); + assertSuccess(result, 'justtext', 8); }); }); diff --git a/src/primitives/line.ts b/src/primitives/line.ts index ebc6b7e..270b037 100644 --- a/src/primitives/line.ts +++ b/src/primitives/line.ts @@ -7,6 +7,6 @@ const parser = takeWhile((c) => c !== '\n' && c !== '\r'); * Parse until end of line. * * @example - * line('hello\nworld') // { ok: true, value: 'hello', remaining: '\nworld' } + * line('hello\nworld') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } */ export const line = create(parser); diff --git a/src/primitives/literal.test.ts b/src/primitives/literal.test.ts index e74acf3..27f5d05 100644 --- a/src/primitives/literal.test.ts +++ b/src/primitives/literal.test.ts @@ -8,42 +8,42 @@ describe('literal', () => { { const result = literal('foo'); - assertSuccess(result, 'foo', ''); + assertSuccess(result, 'foo', 3); } { const result = literal('123f'); - assertSuccess(result, '123f', ''); + assertSuccess(result, '123f', 4); } { const result = literal('foo-bar'); - assertSuccess(result, 'foo-bar', ''); + assertSuccess(result, 'foo-bar', 7); } { const result = literal('_private'); - assertSuccess(result, '_private', ''); + assertSuccess(result, '_private', 8); } { const result = literal('a'); - assertSuccess(result, 'a', ''); + assertSuccess(result, 'a', 1); } { const result = literal('_'); - assertSuccess(result, '_', ''); + assertSuccess(result, '_', 1); } { const result = literal('foo_bar'); - assertSuccess(result, 'foo_bar', ''); + assertSuccess(result, 'foo_bar', 7); } { const result = literal('foo-bar_baz'); - assertSuccess(result, 'foo-bar_baz', ''); + assertSuccess(result, 'foo-bar_baz', 11); } }); diff --git a/src/primitives/literal.ts b/src/primitives/literal.ts index 28ea8ca..a8c8a9d 100644 --- a/src/primitives/literal.ts +++ b/src/primitives/literal.ts @@ -7,7 +7,7 @@ const parser = regex(/^[a-zA-Z0-9_-]+/); * Parse a word-like value including hyphens. * * @example - * literal('foo-bar'); // { ok: true, value: 'foo-bar', remaining: '' } - * literal('123abc'); // { ok: true, value: '123abc', remaining: '' } + * literal('foo-bar'); // { ok: true, value: 'foo-bar', index: 7, furthest: -1, expected: [] } + * literal('123abc'); // { ok: true, value: '123abc', index: 6, furthest: -1, expected: [] } */ export const literal = create(parser); diff --git a/src/primitives/lowercase.test.ts b/src/primitives/lowercase.test.ts index 77e124a..20053c5 100644 --- a/src/primitives/lowercase.test.ts +++ b/src/primitives/lowercase.test.ts @@ -7,7 +7,7 @@ describe('lowercase', () => { it('should parse lowercase letter', () => { const result = lowercase('abc'); - assertSuccess(result, 'a', 'bc'); + assertSuccess(result, 'a', 1); }); it('should fail on uppercase', () => { @@ -25,6 +25,6 @@ describe('lowercase', () => { it('should narrow result to first character type for const string', () => { const result = lowercase('abc' as const); - assertSuccess<'a'>(result, 'a', 'bc'); + assertSuccess<'a'>(result, 'a', 1); }); }); diff --git a/src/primitives/lowercase.ts b/src/primitives/lowercase.ts index 238abac..d9c202d 100644 --- a/src/primitives/lowercase.ts +++ b/src/primitives/lowercase.ts @@ -31,18 +31,19 @@ export type LowercaseLetter = | 'y' | 'z'; -const parser = satisfy((c) => /[a-z]/.test(c)); +const parser = satisfy((c) => /[a-z]/.test(c), 'lowercase letter'); /** * Parse a single lowercase letter. * * @example - * lowercase('abc') // { ok: true, value: 'a', remaining: 'bc' } + * lowercase('abc') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } */ export function lowercase( input: S, + index?: number, ): Result & LowercaseLetter>; -export function lowercase(input: string): Result; -export function lowercase(input: string) { - return create(parser)(input); +export function lowercase(input: string, index?: number): Result; +export function lowercase(input: string, index = 0) { + return create(parser)(input, index); } diff --git a/src/primitives/lowercases.test.ts b/src/primitives/lowercases.test.ts index d812ec6..1e92fca 100644 --- a/src/primitives/lowercases.test.ts +++ b/src/primitives/lowercases.test.ts @@ -8,17 +8,17 @@ describe('lowercases', () => { { const result = lowercases('abcDEF'); - assertSuccess(result, 'abc', 'DEF'); + assertSuccess(result, 'abc', 3); } { const result = lowercases('hello world'); - assertSuccess(result, 'hello', ' world'); + assertSuccess(result, 'hello', 5); } { const result = lowercases('abc'); - assertSuccess(result, 'abc', ''); + assertSuccess(result, 'abc', 3); } }); @@ -43,6 +43,6 @@ describe('lowercases', () => { it('should handle single lowercase letter', () => { const result = lowercases('aBC'); - assertSuccess(result, 'a', 'BC'); + assertSuccess(result, 'a', 1); }); }); diff --git a/src/primitives/lowercases.ts b/src/primitives/lowercases.ts index 60b1933..fce0fde 100644 --- a/src/primitives/lowercases.ts +++ b/src/primitives/lowercases.ts @@ -1,5 +1,5 @@ import { many1 } from '../combinators/many1'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { lowercase } from './lowercase'; @@ -7,18 +7,16 @@ import { lowercase } from './lowercase'; const parser = many1(lowercase); /** - * Parses one or more lowercase letters. + * Parse one or more lowercase letters as a string. * * @example - * lowercases('abcDEF') // { ok: true, value: 'abc', remaining: 'DEF' } + * lowercases('placeholder') // { ok: true, value: 'placeholder', index: 11, furthest: 11, expected: ['lowercase letter'] } */ -export const lowercases = create((input) => { - const result = parser(input); +export const lowercases = create((input, index = 0) => { + const result = parser(input, index); if (!result.ok) { - return failure(); + return result; } - const { value, remaining } = result; - - return success(value.join(''), remaining); + return merge(result, success(result.value.join(''), result.index)); }); diff --git a/src/primitives/nl.test.ts b/src/primitives/nl.test.ts index 90f93dc..db6a05c 100644 --- a/src/primitives/nl.test.ts +++ b/src/primitives/nl.test.ts @@ -7,7 +7,7 @@ describe('nl', () => { it('should parse newline character', () => { const result = nl('\nabc'); - assertSuccess<'\n'>(result, '\n', 'abc'); + assertSuccess<'\n'>(result, '\n', 1); }); it('should fail on non-newline characters', () => { diff --git a/src/primitives/nl.ts b/src/primitives/nl.ts index 2dd5b4f..5f71d0e 100644 --- a/src/primitives/nl.ts +++ b/src/primitives/nl.ts @@ -1,12 +1,12 @@ import { create } from '../core/parser'; import { satisfy } from '../terminals/satisfy'; -const parser = satisfy<'\n'>((c) => /\n/.test(c)); +const parser = satisfy<'\n'>((c) => /\n/.test(c), 'newline'); /** * Parse a newline character. * * @example - * nl('\ntext') // { ok: true, value: '\n', remaining: 'text' } + * nl('\ntext') // { ok: true, value: '\n', index: 1, furthest: -1, expected: [] } */ export const nl = create<'\n'>(parser); diff --git a/src/primitives/number.test.ts b/src/primitives/number.test.ts index 7e04d17..d9066bc 100644 --- a/src/primitives/number.test.ts +++ b/src/primitives/number.test.ts @@ -7,31 +7,31 @@ describe('number', () => { it('should parse integer', () => { const result = number('42'); - assertSuccess(result, 42, ''); + assertSuccess(result, 42, 2); }); it('should parse float', () => { const result = number('3.14'); - assertSuccess(result, 3.14, ''); + assertSuccess(result, 3.14, 4); }); it('should parse negative integer', () => { const result = number('-7'); - assertSuccess(result, -7, ''); + assertSuccess(result, -7, 2); }); it('should parse negative float', () => { const result = number('-2.5'); - assertSuccess(result, -2.5, ''); + assertSuccess(result, -2.5, 4); }); it('should stop at non-numeric', () => { const result = number('42abc'); - assertSuccess(result, 42, 'abc'); + assertSuccess(result, 42, 2); }); it('should fail on non-numeric input', () => { diff --git a/src/primitives/number.ts b/src/primitives/number.ts index bfe2cfc..e6331b8 100644 --- a/src/primitives/number.ts +++ b/src/primitives/number.ts @@ -9,9 +9,9 @@ const parser = choice(float, integer); * Parse an integer or float. * * @example - * 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: '' } + * number('42') // { ok: true, value: 42, index: 2, furthest: 0, expected: ['/-?\\d+\\.\\d+/'] } + * number('3.14') // { ok: true, value: 3.14, index: 4, furthest: -1, expected: [] } + * number('-7') // { ok: true, value: -7, index: 2, furthest: 0, expected: ['/-?\\d+\\.\\d+/'] } + * number('-2.5') // { ok: true, value: -2.5, index: 4, furthest: -1, expected: [] } */ export const number = create(parser); diff --git a/src/primitives/octDigit.test.ts b/src/primitives/octDigit.test.ts index ede74a5..1ca60bc 100644 --- a/src/primitives/octDigit.test.ts +++ b/src/primitives/octDigit.test.ts @@ -6,7 +6,7 @@ import { octDigit, type OctDigit } from './octDigit'; describe('octDigit', () => { it('should parse octal digits', () => { const result = octDigit('712'); - assertSuccess(result, '7', '12'); + assertSuccess(result, '7', 1); }); it('should fail on non-octal', () => { @@ -21,6 +21,6 @@ describe('octDigit', () => { it('should narrow result to first character type for const string', () => { const result = octDigit('37x' as const); - assertSuccess<'3'>(result, '3', '7x'); + assertSuccess<'3'>(result, '3', 1); }); }); diff --git a/src/primitives/octDigit.ts b/src/primitives/octDigit.ts index 15c2bb5..bf6c682 100644 --- a/src/primitives/octDigit.ts +++ b/src/primitives/octDigit.ts @@ -5,18 +5,19 @@ import { satisfy } from '../terminals/satisfy'; type Head = S extends `${infer C}${string}` ? C : never; export type OctDigit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7'; -const parser = satisfy((c) => /[0-7]/.test(c)); +const parser = satisfy((c) => /[0-7]/.test(c), 'octal digit'); /** * Parse a single octal digit. * * @example - * octDigit('7abc') // { ok: true, value: '7', remaining: 'abc' } + * octDigit('7abc') // { ok: true, value: '7', index: 1, furthest: -1, expected: [] } */ export function octDigit( input: S, + index?: number, ): Result & OctDigit>; -export function octDigit(input: string): Result; -export function octDigit(input: string) { - return create(parser)(input); +export function octDigit(input: string, index?: number): Result; +export function octDigit(input: string, index = 0) { + return create(parser)(input, index); } diff --git a/src/primitives/octDigits.test.ts b/src/primitives/octDigits.test.ts index 6186a9f..0c58caf 100644 --- a/src/primitives/octDigits.test.ts +++ b/src/primitives/octDigits.test.ts @@ -8,17 +8,17 @@ describe('octDigits', () => { { const result = octDigits('0777rest'); - assertSuccess(result, '0777', 'rest'); + assertSuccess(result, '0777', 4); } { const result = octDigits('1234567'); - assertSuccess(result, '1234567', ''); + assertSuccess(result, '1234567', 7); } { const result = octDigits('042 end'); - assertSuccess(result, '042', ' end'); + assertSuccess(result, '042', 3); } }); @@ -43,12 +43,12 @@ describe('octDigits', () => { it('should handle single octal digit', () => { const result = octDigits('7rest'); - assertSuccess(result, '7', 'rest'); + assertSuccess(result, '7', 1); }); it('should stop at first non-octal character', () => { const result = octDigits('0128'); - assertSuccess(result, '012', '8'); + assertSuccess(result, '012', 3); }); }); diff --git a/src/primitives/octDigits.ts b/src/primitives/octDigits.ts index a8a2b81..90f4706 100644 --- a/src/primitives/octDigits.ts +++ b/src/primitives/octDigits.ts @@ -1,5 +1,5 @@ import { many1 } from '../combinators/many1'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { octDigit } from './octDigit'; @@ -7,18 +7,16 @@ import { octDigit } from './octDigit'; const parser = many1(octDigit); /** - * Parse one or more octal digits. + * Parse one or more octal digits as a string. * * @example - * octDigits('0777abc') // { ok: true, value: '0777', remaining: 'abc' } + * octDigits('placeholder') // { ok: false, index: 0, furthest: 0, expected: ['octal digit'] } */ -export const octDigits = create((input) => { - const result = parser(input); +export const octDigits = create((input, index = 0) => { + const result = parser(input, index); if (!result.ok) { - return failure(); + return result; } - const { value, remaining } = result; - - return success(value.join(''), remaining); + return merge(result, success(result.value.join(''), result.index)); }); diff --git a/src/primitives/position.test.ts b/src/primitives/position.test.ts index 87d4c18..603dae1 100644 --- a/src/primitives/position.test.ts +++ b/src/primitives/position.test.ts @@ -4,15 +4,15 @@ import { assertSuccess } from '../../test/utils'; import { position } from './position'; describe('position', () => { - it('should return current position (input length)', () => { + it('should return the current offset', () => { const result = position('hello'); - assertSuccess(result, 5, 'hello'); + assertSuccess(result, 0, 0); }); it('should return 0 for empty input', () => { const result = position(''); - assertSuccess(result, 0, ''); + assertSuccess(result, 0, 0); }); }); diff --git a/src/primitives/position.ts b/src/primitives/position.ts index ce239f8..45c423e 100644 --- a/src/primitives/position.ts +++ b/src/primitives/position.ts @@ -2,9 +2,15 @@ import { create } from '../core/parser'; import { success } from '../core/success'; /** - * Get current position (remaining input length). + * Get the current offset into the input, without consuming anything. + * + * Counts forward from the start of the input, so it can be paired with + * {@link locate} to attach line/column information to a parsed node. * * @example - * position('abc') // { ok: true, value: 3, remaining: 'abc' } + * position('abc') // { ok: true, value: 0, index: 0, furthest: -1, expected: [] } + * right(string('ab'), position)('abc') // { ok: true, value: 2, index: 2, furthest: -1, expected: [] } */ -export const position = create((input) => success(input.length, input)); +export const position = create((_input, index = 0) => + success(index, index), +); diff --git a/src/primitives/rest.test.ts b/src/primitives/rest.test.ts index 4ff752b..07a1229 100644 --- a/src/primitives/rest.test.ts +++ b/src/primitives/rest.test.ts @@ -7,12 +7,12 @@ describe('rest', () => { it('should return all remaining input', () => { const result = rest('hello world'); - assertSuccess(result, 'hello world', ''); + assertSuccess(result, 'hello world', 11); }); it('should handle empty input', () => { const result = rest(''); - assertSuccess(result, '', ''); + assertSuccess(result, '', 0); }); }); diff --git a/src/primitives/rest.ts b/src/primitives/rest.ts index e03945f..1796bd6 100644 --- a/src/primitives/rest.ts +++ b/src/primitives/rest.ts @@ -2,9 +2,11 @@ import { create } from '../core/parser'; import { success } from '../core/success'; /** - * Parse the rest of the input. + * Consume and return everything left in the input. * * @example - * rest('hello') // { ok: true, value: 'hello', remaining: '' } + * rest('abc') // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } */ -export const rest = create((input) => success(input, '')); +export const rest = create((input, index = 0) => + success(input.slice(index), input.length), +); diff --git a/src/primitives/space.test.ts b/src/primitives/space.test.ts index 7673e2e..791a641 100644 --- a/src/primitives/space.test.ts +++ b/src/primitives/space.test.ts @@ -7,7 +7,7 @@ describe('space', () => { it('should parse a single space', () => { const result = space(' abc'); - assertSuccess(result, ' ', 'abc'); + assertSuccess(result, ' ', 1); }); it('should fail on tab', () => { diff --git a/src/primitives/space.ts b/src/primitives/space.ts index f2b3de3..d1957f2 100644 --- a/src/primitives/space.ts +++ b/src/primitives/space.ts @@ -1,12 +1,12 @@ import { create } from '../core/parser'; import { satisfy } from '../terminals/satisfy'; -const parser = satisfy((c) => c === ' '); +const parser = satisfy((c) => c === ' ', 'space'); /** * Parse a single space character. * * @example - * space(' abc') // { ok: true, value: ' ', remaining: 'abc' } + * space(' abc') // { ok: true, value: ' ', index: 1, furthest: -1, expected: [] } */ export const space = create(parser); diff --git a/src/primitives/spaces.test.ts b/src/primitives/spaces.test.ts index 55b8b4e..3893cee 100644 --- a/src/primitives/spaces.test.ts +++ b/src/primitives/spaces.test.ts @@ -7,13 +7,13 @@ describe('spaces', () => { it('should parse one or more spaces', () => { const result = spaces(' abc'); - assertSuccess(result, ' ', 'abc'); + assertSuccess(result, ' ', 3); }); it('should parse single space', () => { const result = spaces(' abc'); - assertSuccess(result, ' ', 'abc'); + assertSuccess(result, ' ', 1); }); it('should fail on tab', () => { diff --git a/src/primitives/spaces.ts b/src/primitives/spaces.ts index 5244184..32dca94 100644 --- a/src/primitives/spaces.ts +++ b/src/primitives/spaces.ts @@ -1,5 +1,5 @@ import { many1 } from '../combinators/many1'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { space } from './space'; @@ -7,17 +7,16 @@ import { space } from './space'; const parser = many1(space); /** - * Parse one or more space characters. + * Parse one or more spaces as a string. * * @example - * spaces(' abc') // { ok: true, value: ' ', remaining: 'abc' } + * spaces('placeholder') // { ok: false, index: 0, furthest: 0, expected: ['space'] } */ -export const spaces = create((input) => { - const result = parser(input); - +export const spaces = create((input, index = 0) => { + const result = parser(input, index); if (!result.ok) { - return failure(); + return result; } - return success(result.value.join(''), result.remaining); + return merge(result, success(result.value.join(''), result.index)); }); diff --git a/src/primitives/tab.test.ts b/src/primitives/tab.test.ts index a72ec31..b1231a9 100644 --- a/src/primitives/tab.test.ts +++ b/src/primitives/tab.test.ts @@ -7,7 +7,7 @@ describe('tab', () => { it('should parse tab character', () => { const result = tab('\tabc'); - assertSuccess(result, '\t', 'abc'); + assertSuccess(result, '\t', 1); }); it('should fail on non-tab characters', () => { diff --git a/src/primitives/tab.ts b/src/primitives/tab.ts index 02f6d0a..939fe8f 100644 --- a/src/primitives/tab.ts +++ b/src/primitives/tab.ts @@ -7,6 +7,6 @@ const parser = char('\t'); * Parse tab character. * * @example - * tab('\ttext') // { ok: true, value: '\t', remaining: 'text' } + * tab('\ttext') // { ok: true, value: '\t', index: 1, furthest: -1, expected: [] } */ export const tab = create(parser); diff --git a/src/primitives/uppercase.test.ts b/src/primitives/uppercase.test.ts index 4b51794..5f23738 100644 --- a/src/primitives/uppercase.test.ts +++ b/src/primitives/uppercase.test.ts @@ -7,7 +7,7 @@ describe('uppercase', () => { it('should parse uppercase letter', () => { const result = uppercase('ABC'); - assertSuccess(result, 'A', 'BC'); + assertSuccess(result, 'A', 1); }); it('should fail on lowercase', () => { @@ -25,6 +25,6 @@ describe('uppercase', () => { it('should narrow result to first character type for const string', () => { const result = uppercase('ABC' as const); - assertSuccess<'A'>(result, 'A', 'BC'); + assertSuccess<'A'>(result, 'A', 1); }); }); diff --git a/src/primitives/uppercase.ts b/src/primitives/uppercase.ts index 7339536..ed1f253 100644 --- a/src/primitives/uppercase.ts +++ b/src/primitives/uppercase.ts @@ -31,18 +31,19 @@ export type UppercaseLetter = | 'Y' | 'Z'; -const parser = satisfy((c) => /[A-Z]/.test(c)); +const parser = satisfy((c) => /[A-Z]/.test(c), 'uppercase letter'); /** * Parses a single uppercase letter. * * @example - * uppercase('ABC') // { ok: true, value: 'A', remaining: 'BC' } + * uppercase('ABC') // { ok: true, value: 'A', index: 1, furthest: -1, expected: [] } */ export function uppercase( input: S, + index?: number, ): Result & UppercaseLetter>; -export function uppercase(input: string): Result; -export function uppercase(input: string) { - return create(parser)(input); +export function uppercase(input: string, index?: number): Result; +export function uppercase(input: string, index = 0) { + return create(parser)(input, index); } diff --git a/src/primitives/uppercases.test.ts b/src/primitives/uppercases.test.ts index 792c812..43f1dd1 100644 --- a/src/primitives/uppercases.test.ts +++ b/src/primitives/uppercases.test.ts @@ -8,17 +8,17 @@ describe('uppercases', () => { { const result = uppercases('ABCdef'); - assertSuccess(result, 'ABC', 'def'); + assertSuccess(result, 'ABC', 3); } { const result = uppercases('HELLO world'); - assertSuccess(result, 'HELLO', ' world'); + assertSuccess(result, 'HELLO', 5); } { const result = uppercases('ABC'); - assertSuccess(result, 'ABC', ''); + assertSuccess(result, 'ABC', 3); } }); @@ -43,6 +43,6 @@ describe('uppercases', () => { it('should handle single uppercase letter', () => { const result = uppercases('Abc'); - assertSuccess(result, 'A', 'bc'); + assertSuccess(result, 'A', 1); }); }); diff --git a/src/primitives/uppercases.ts b/src/primitives/uppercases.ts index 60641dc..e28638c 100644 --- a/src/primitives/uppercases.ts +++ b/src/primitives/uppercases.ts @@ -1,5 +1,5 @@ import { many1 } from '../combinators/many1'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { uppercase } from './uppercase'; @@ -7,18 +7,16 @@ import { uppercase } from './uppercase'; const parser = many1(uppercase); /** - * Parses one or more uppercase letters. + * Parse one or more uppercase letters as a string. * * @example - * uppercases('ABCdef') // { ok: true, value: 'ABC', remaining: 'def' } + * uppercases('placeholder') // { ok: false, index: 0, furthest: 0, expected: ['uppercase letter'] } */ -export const uppercases = create((input) => { - const result = parser(input); +export const uppercases = create((input, index = 0) => { + const result = parser(input, index); if (!result.ok) { - return failure(); + return result; } - const { value, remaining } = result; - - return success(value.join(''), remaining); + return merge(result, success(result.value.join(''), result.index)); }); diff --git a/src/primitives/whitespace.test.ts b/src/primitives/whitespace.test.ts index b2f9ca5..5876653 100644 --- a/src/primitives/whitespace.test.ts +++ b/src/primitives/whitespace.test.ts @@ -7,13 +7,13 @@ describe('whitespace', () => { it('should parse whitespace', () => { const result = whitespace(' '); - assertSuccess(result, ' ', ''); + assertSuccess(result, ' ', 1); }); it('should parse only one whitespace', () => { const result = whitespace(' '); - assertSuccess(result, ' ', ' '); + assertSuccess(result, ' ', 1); }); it('should fail on non-whitespace', () => { diff --git a/src/primitives/whitespace.ts b/src/primitives/whitespace.ts index 52dfe0b..e7982f6 100644 --- a/src/primitives/whitespace.ts +++ b/src/primitives/whitespace.ts @@ -1,12 +1,12 @@ import { create } from '../core/parser'; import { satisfy } from '../terminals/satisfy'; -const parser = satisfy((c) => /\s/.test(c)); +const parser = satisfy((c) => /\s/.test(c), 'whitespace'); /** * Parses a single whitespace character. * * @example - * whitespace(' abc') //{ ok: true, value: ' ', remaining: 'abc' } + * whitespace(' abc') // { ok: true, value: ' ', index: 1, furthest: -1, expected: [] } */ export const whitespace = create(parser); diff --git a/src/primitives/whitespaces.test.ts b/src/primitives/whitespaces.test.ts index a8f8943..4f030e3 100644 --- a/src/primitives/whitespaces.test.ts +++ b/src/primitives/whitespaces.test.ts @@ -8,17 +8,17 @@ describe('whitespaces', () => { { const result = whitespaces(' abc'); - assertSuccess(result, ' ', 'abc'); + assertSuccess(result, ' ', 3); } { const result = whitespaces(' \t\n end'); - assertSuccess(result, ' \t\n ', 'end'); + assertSuccess(result, ' \t\n ', 4); } { const result = whitespaces(' '); - assertSuccess(result, ' ', ''); + assertSuccess(result, ' ', 3); } }); @@ -38,6 +38,6 @@ describe('whitespaces', () => { it('should handle single whitespace character', () => { const result = whitespaces(' abc'); - assertSuccess(result, ' ', 'abc'); + assertSuccess(result, ' ', 1); }); }); diff --git a/src/primitives/whitespaces.ts b/src/primitives/whitespaces.ts index 2f679f2..09bfa63 100644 --- a/src/primitives/whitespaces.ts +++ b/src/primitives/whitespaces.ts @@ -1,5 +1,5 @@ import { many1 } from '../combinators/many1'; -import { failure } from '../core/failure'; +import { merge } from '../core/merge'; import { create } from '../core/parser'; import { success } from '../core/success'; import { whitespace } from './whitespace'; @@ -7,18 +7,16 @@ import { whitespace } from './whitespace'; const parser = many1(whitespace); /** - * Parses one or more whitespace characters. + * Parse one or more whitespaces as a string. * * @example - * whitespaces(' abc') // { ok: true, value: ' ', remaining: 'abc' } + * whitespaces('placeholder') // { ok: false, index: 0, furthest: 0, expected: ['whitespace'] } */ -export const whitespaces = create((input) => { - const result = parser(input); +export const whitespaces = create((input, index = 0) => { + const result = parser(input, index); if (!result.ok) { - return failure(); + return result; } - const { value, remaining } = result; - - return success(value.join(''), remaining); + return merge(result, success(result.value.join(''), result.index)); }); diff --git a/src/terminals/char.test.ts b/src/terminals/char.test.ts index ff0b696..842888b 100644 --- a/src/terminals/char.test.ts +++ b/src/terminals/char.test.ts @@ -8,7 +8,7 @@ describe('char', () => { const parser = char('A'); const result = parser('ABC'); - assertSuccess<'A' | null>(result, 'A', 'BC'); + assertSuccess<'A' | null>(result, 'A', 1); }); it('should fail when character does not match', () => { diff --git a/src/terminals/char.ts b/src/terminals/char.ts index 346cad5..4c1971d 100644 --- a/src/terminals/char.ts +++ b/src/terminals/char.ts @@ -9,7 +9,7 @@ export type Char = * Parse a specific character. * * @example - * char('A')('ABC') // { ok: true, value: 'A', remaining: 'BC' } + * char('A')('ABC') // { ok: true, value: 'A', index: 1, furthest: -1, expected: [] } */ export const char = (expected: Char) => { if ((expected as string).length !== 1) { @@ -18,9 +18,11 @@ export const char = (expected: Char) => { ); } - return create>((input) => - input.length > 0 && input[0] === expected - ? success(expected, input.slice(1)) - : failure(), + const described = `'${expected}'`; + + return create>((input, index = 0) => + input[index] === expected + ? success(expected, index + 1) + : failure(index, described), ); }; diff --git a/src/terminals/charOf.test.ts b/src/terminals/charOf.test.ts index 4f9e7be..ba55e5d 100644 --- a/src/terminals/charOf.test.ts +++ b/src/terminals/charOf.test.ts @@ -8,7 +8,7 @@ describe('charOf', () => { const parser = charOf(['A', 'B', 'C'] as const); const result = parser('ABCDEFG'); - assertSuccess<'A' | 'B' | 'C'>(result, 'A', 'BCDEFG'); + assertSuccess<'A' | 'B' | 'C'>(result, 'A', 1); }); it('should fail when character is not in set', () => { diff --git a/src/terminals/charOf.ts b/src/terminals/charOf.ts index a7dd092..e7a5034 100644 --- a/src/terminals/charOf.ts +++ b/src/terminals/charOf.ts @@ -1,23 +1,26 @@ import { failure } from '../core/failure'; import { create } from '../core/parser'; -import { type Char, char } from './char'; +import { success } from '../core/success'; +import type { char } from './char'; +import { type Char } from './char'; /** * Parse any character from a set. * * @example - * charOf(['a', 'b', 'c'])('abc') // { ok: true, value: 'a', remaining: 'bc' } + * charOf(['a', 'b', 'c'])('abc') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } */ export function charOf( chars: readonly Char[], ): ReturnType>>; export function charOf(chars: readonly string[]) { - return create((input) => { - if (input.length === 0) { - return failure(); - } + const described = chars.map((c) => `'${c}'`); - const next = input[0]; - return chars.includes(next) ? char(next as Char)(input) : failure(); + return create((input, index = 0) => { + const next = input[index]; + + return index < input.length && chars.includes(next) + ? success(next, index + 1) + : failure(index, ...described); }); } diff --git a/src/terminals/noneOf.test.ts b/src/terminals/noneOf.test.ts index 3f355e9..6471a6d 100644 --- a/src/terminals/noneOf.test.ts +++ b/src/terminals/noneOf.test.ts @@ -8,7 +8,7 @@ describe('noneOf', () => { const parser = noneOf(['X', 'Y', 'Z']); const result = parser('ABC'); - assertSuccess(result, 'A', 'BC'); + assertSuccess(result, 'A', 1); }); it('should fail when character is in forbidden set', () => { @@ -22,6 +22,6 @@ describe('noneOf', () => { const parser = noneOf([]); const result = parser('ABC'); - assertSuccess(result, 'A', 'BC'); + assertSuccess(result, 'A', 1); }); }); diff --git a/src/terminals/noneOf.ts b/src/terminals/noneOf.ts index da5bca2..567ae28 100644 --- a/src/terminals/noneOf.ts +++ b/src/terminals/noneOf.ts @@ -6,12 +6,14 @@ import { success } from '../core/success'; * Parse any character not in the set. * * @example - * noneOf(['a', 'b', 'c'])('xyz') // { ok: true, value: 'x', remaining: 'yz' } + * noneOf(['a', 'b', 'c'])('xyz') // { ok: true, value: 'x', index: 1, furthest: -1, expected: [] } */ export function noneOf(chars: readonly string[]) { - return create((input) => { - return input.length > 0 && !chars.includes(input[0]) - ? success(input[0], input.slice(1)) - : failure(); + const described = `character other than ${chars.map((c) => `'${c}'`).join(', ')}`; + + return create((input, index = 0) => { + return index < input.length && !chars.includes(input[index]) + ? success(input[index], index + 1) + : failure(index, described); }); } diff --git a/src/terminals/oneOf.test.ts b/src/terminals/oneOf.test.ts index 341ab42..59fe556 100644 --- a/src/terminals/oneOf.test.ts +++ b/src/terminals/oneOf.test.ts @@ -8,14 +8,14 @@ describe('oneOf', () => { const parser = oneOf(['foo', 'bar', 'baz'] as const); const result = parser('foorest'); - assertSuccess<'foo' | 'bar' | 'baz'>(result, 'foo', 'rest'); + assertSuccess<'foo' | 'bar' | 'baz'>(result, 'foo', 3); }); it('should match a later string in the set', () => { const parser = oneOf(['foo', 'bar', 'baz'] as const); const result = parser('barbarian'); - assertSuccess<'foo' | 'bar' | 'baz'>(result, 'bar', 'barian'); + assertSuccess<'foo' | 'bar' | 'baz'>(result, 'bar', 3); }); it('should fail when no string matches', () => { @@ -29,7 +29,7 @@ describe('oneOf', () => { const parser = oneOf(['foo', 'bar'] as const); const result = parser('foo'); - assertSuccess<'foo' | 'bar'>(result, 'foo', ''); + assertSuccess<'foo' | 'bar'>(result, 'foo', 3); }); it('should fail on empty input', () => { @@ -43,21 +43,21 @@ describe('oneOf', () => { const parser = oneOf(['hello'] as const); const result = parser('hello world'); - assertSuccess<'hello'>(result, 'hello', ' world'); + assertSuccess<'hello'>(result, 'hello', 5); }); it('should match strings with multi-character tokens', () => { const parser = oneOf(['+=', '+', '='] as const); const result = parser('+=1'); - assertSuccess<'+=' | '+' | '='>(result, '+=', '1'); + assertSuccess<'+=' | '+' | '='>(result, '+=', 2); }); it('should match the first candidate even when a later one is also a prefix', () => { const parser = oneOf(['+', '+='] as const); const result = parser('+=1'); - assertSuccess<'+' | '+='>(result, '+', '=1'); + assertSuccess<'+' | '+='>(result, '+', 1); }); it('should fail when input is a prefix of a candidate but not a full match', () => { diff --git a/src/terminals/oneOf.ts b/src/terminals/oneOf.ts index db8a0e9..bb8d908 100644 --- a/src/terminals/oneOf.ts +++ b/src/terminals/oneOf.ts @@ -6,16 +6,18 @@ import { success } from '../core/success'; * Parse one string from a set of strings (longest match wins). * * @example - * oneOf(['hello', 'hell', 'help'])('helpful') // { ok: true, value: 'help', remaining: 'ful' } + * oneOf(['hello', 'hell', 'help'])('helpful') // { ok: true, value: 'help', index: 4, furthest: -1, expected: [] } */ export const oneOf = (strings: readonly [S, ...S[]]) => { - return create((input) => { + const described = strings.map((s) => `'${s}'`); + + return create((input, index = 0) => { for (const str of strings) { - if (input.startsWith(str)) { - return success(str, input.slice(str.length)); + if (input.startsWith(str, index)) { + return success(str, index + str.length); } } - return failure(); + return failure(index, ...described); }); }; diff --git a/src/terminals/regex.test.ts b/src/terminals/regex.test.ts index 295bc01..690a5b8 100644 --- a/src/terminals/regex.test.ts +++ b/src/terminals/regex.test.ts @@ -8,7 +8,7 @@ describe('regex', () => { const parser = regex(/\d+/); const result = parser('123abc'); - assertSuccess(result, '123', 'abc'); + assertSuccess(result, '123', 3); }); it('should fail when pattern does not match at beginning', () => { @@ -22,14 +22,14 @@ describe('regex', () => { const parser = regex(/^[a-z]+/); const result = parser('hello123'); - assertSuccess(result, 'hello', '123'); + assertSuccess(result, 'hello', 5); }); it('should handle empty matches', () => { const parser = regex(/\d*/); const result = parser('abc'); - assertSuccess(result, '', 'abc'); + assertSuccess(result, '', 0); }); it('should throw error if a regex with global flag is being used', () => { diff --git a/src/terminals/regex.ts b/src/terminals/regex.ts index 40a892f..c8e93a8 100644 --- a/src/terminals/regex.ts +++ b/src/terminals/regex.ts @@ -5,19 +5,31 @@ import { success } from '../core/success'; /** * Parse with a regular expression. * + * The pattern is matched with the sticky flag at the current offset, so it is + * anchored by construction and never scans ahead. A leading `^` is redundant + * and is stripped. + * * @example - * regex(/^\w+/)('hello world') // { ok: true, value: 'hello', remaining: ' world' } + * regex(/^\w+/)('hello world') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } */ -export const regex = (pattern: RegExp) => { +export const regex = (pattern: RegExp, expected?: string) => { if (pattern.global) { throw new Error('Global flag is not supported in regex parsers'); } - return create((input) => { - const match = input.match(pattern); + const source = pattern.source.startsWith('^') + ? pattern.source.slice(1) + : pattern.source; + const flags = pattern.flags.replace('y', '') + 'y'; + const sticky = new RegExp(source, flags); + const described = expected ?? `/${source}/`; + + return create((input, index = 0) => { + sticky.lastIndex = index; + const match = sticky.exec(input); - return match && match.index === 0 - ? success(match[0] as T, input.slice(match[0].length)) - : failure(); + return match + ? success(match[0] as T, index + match[0].length) + : failure(index, described); }); }; diff --git a/src/terminals/satisfy.test.ts b/src/terminals/satisfy.test.ts index 435b75b..89dca1e 100644 --- a/src/terminals/satisfy.test.ts +++ b/src/terminals/satisfy.test.ts @@ -8,7 +8,7 @@ describe('satisfy', () => { const parser = satisfy((c: string) => c >= '0' && c <= '9'); const result = parser('5abc'); - assertSuccess(result, '5', 'abc'); + assertSuccess(result, '5', 1); }); it('should fail when character does not satisfy predicate', () => { diff --git a/src/terminals/satisfy.ts b/src/terminals/satisfy.ts index a506a04..8eb78d5 100644 --- a/src/terminals/satisfy.ts +++ b/src/terminals/satisfy.ts @@ -7,14 +7,17 @@ import { type Char } from './char'; * Parse a character satisfying a predicate. * * @example - * satisfy((c) => c === 'a')('abc') // { ok: true, value: 'a', remaining: 'bc' } + * satisfy((c) => c === 'a')('abc') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } */ -export function satisfy(predicate: (c: Char) => boolean) { - return create((input) => { - const c = input[0] as Char; +export function satisfy( + predicate: (c: Char) => boolean, + expected = 'matching character', +) { + return create((input, index = 0) => { + const c = input[index] as Char; - return input.length > 0 && predicate(c) - ? success(c as T, input.slice(1)) - : failure(); + return index < input.length && predicate(c) + ? success(c as T, index + 1) + : failure(index, expected); }); } diff --git a/src/terminals/string.test.ts b/src/terminals/string.test.ts index a4229b7..0e6b4e8 100644 --- a/src/terminals/string.test.ts +++ b/src/terminals/string.test.ts @@ -8,7 +8,7 @@ describe('string', () => { const parser = string('test'); const result = parser('testing'); - assertSuccess<'test'>(result, 'test', 'ing'); + assertSuccess<'test'>(result, 'test', 4); }); it('should fail when string does not match', () => { @@ -22,13 +22,13 @@ describe('string', () => { const parser = string('hello'); const result = parser('hello'); - assertSuccess<'hello'>(result, 'hello', ''); + assertSuccess<'hello'>(result, 'hello', 5); }); it('should handle empty string', () => { const parser = string(''); const result = parser('anything'); - assertSuccess<''>(result, '', 'anything'); + assertSuccess<''>(result, '', 0); }); }); diff --git a/src/terminals/string.ts b/src/terminals/string.ts index 8a5c71f..81ae70a 100644 --- a/src/terminals/string.ts +++ b/src/terminals/string.ts @@ -6,12 +6,15 @@ import { success } from '../core/success'; * Parse a specific string. * * @example - * string('hello')('hello world') // { ok: true, value: 'hello', remaining: ' world' } + * string('hello')('hello world') // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } */ export const string = (str: S) => { - return create((input) => { - return input.startsWith(str) - ? success(str, input.slice(str.length)) - : failure(); + const described = `'${str}'`; + const { length } = str; + + return create((input, index = 0) => { + return input.startsWith(str, index) + ? success(str, index + length) + : failure(index, described); }); }; diff --git a/src/terminals/stringOf.test.ts b/src/terminals/stringOf.test.ts index 8993274..3cf1f6e 100644 --- a/src/terminals/stringOf.test.ts +++ b/src/terminals/stringOf.test.ts @@ -8,7 +8,7 @@ describe('stringOf', () => { it('should parse a character from the set', () => { const result = parser('abc'); - assertSuccess(result, 'a', 'bc'); + assertSuccess(result, 'a', 1); }); it('should fail on character not in set', () => { diff --git a/src/terminals/stringOf.ts b/src/terminals/stringOf.ts index 2438ac4..7e98a2f 100644 --- a/src/terminals/stringOf.ts +++ b/src/terminals/stringOf.ts @@ -6,12 +6,14 @@ import { success } from '../core/success'; * Parse first character that exists in string (like charOf but for a string). * * @example - * stringOf('abc')('abcdef') // { ok: true, value: 'a', remaining: 'bcdef' } + * stringOf('abc')('abcdef') // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } */ export const stringOf = (chars: string) => { - return create((input) => - input.length > 0 && chars.includes(input[0]) - ? success(input[0], input.slice(1)) - : failure(), + const described = `one of '${chars}'`; + + return create((input, index = 0) => + index < input.length && chars.includes(input[index]) + ? success(input[index], index + 1) + : failure(index, described), ); }; diff --git a/src/terminals/take.test.ts b/src/terminals/take.test.ts index 001cb8c..4383837 100644 --- a/src/terminals/take.test.ts +++ b/src/terminals/take.test.ts @@ -8,14 +8,14 @@ describe('take', () => { const parser = take(3); const result = parser('abcdef'); - assertSuccess(result, 'abc', 'def'); + assertSuccess(result, 'abc', 3); }); it('should take all characters when count equals input length', () => { const parser = take(3); const result = parser('abc'); - assertSuccess(result, 'abc', ''); + assertSuccess(result, 'abc', 3); }); it('should fail when input is shorter than count', () => { @@ -29,6 +29,6 @@ describe('take', () => { const parser = take(0); const result = parser('abc'); - assertSuccess(result, '', 'abc'); + assertSuccess(result, '', 0); }); }); diff --git a/src/terminals/take.ts b/src/terminals/take.ts index 1173299..f83e85d 100644 --- a/src/terminals/take.ts +++ b/src/terminals/take.ts @@ -6,12 +6,14 @@ import { success } from '../core/success'; * Take n characters. * * @example - * take(3)('abcdef') // { ok: true, value: 'abc', remaining: 'def' } + * take(3)('abcdef') // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } */ export const take = (count: number) => { - return create((input) => - input.length >= count - ? success(input.slice(0, count), input.slice(count)) - : failure(), + const described = `${count} more character${count === 1 ? '' : 's'}`; + + return create((input, index = 0) => + input.length - index >= count + ? success(input.slice(index, index + count), index + count) + : failure(index, described), ); }; diff --git a/src/terminals/takeWhile.test.ts b/src/terminals/takeWhile.test.ts index 807e20f..155859e 100644 --- a/src/terminals/takeWhile.test.ts +++ b/src/terminals/takeWhile.test.ts @@ -8,27 +8,27 @@ describe('takeWhile', () => { const parser = takeWhile((c) => c >= '0' && c <= '9'); const result = parser('123ABC'); - assertSuccess(result, '123', 'ABC'); + assertSuccess(result, '123', 3); }); it('should return empty string when first character fails predicate', () => { const parser = takeWhile((c) => c >= '0' && c <= '9'); const result = parser('ABC123'); - assertSuccess(result, '', 'ABC123'); + assertSuccess(result, '', 0); }); it('should take all characters when all satisfy predicate', () => { const parser = takeWhile((c) => c >= '0' && c <= '9'); const result = parser('123'); - assertSuccess(result, '123', ''); + assertSuccess(result, '123', 3); }); it('should handle empty input', () => { const parser = takeWhile(() => true); const result = parser(''); - assertSuccess(result, '', ''); + assertSuccess(result, '', 0); }); }); diff --git a/src/terminals/takeWhile.ts b/src/terminals/takeWhile.ts index ed2e12a..0d46919 100644 --- a/src/terminals/takeWhile.ts +++ b/src/terminals/takeWhile.ts @@ -5,15 +5,15 @@ import { success } from '../core/success'; * Takes characters while the predicate returns true. * * @example - * takeWhile((c) => c !== 'x')('abcx') // { ok: true, value: 'abc', remaining: 'x' } + * takeWhile((c) => c !== 'x')('abcx') // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } */ export const takeWhile = (predicate: (char: string) => boolean) => { - return create((input) => { - let index = 0; - while (index < input.length && predicate(input[index])) { - index++; + return create((input, index = 0) => { + let end = index; + while (end < input.length && predicate(input[end])) { + end++; } - return success(input.slice(0, index), input.slice(index)); + return success(input.slice(index, end), end); }); }; diff --git a/src/terminals/token.test.ts b/src/terminals/token.test.ts index 18e806b..7331dd3 100644 --- a/src/terminals/token.test.ts +++ b/src/terminals/token.test.ts @@ -8,21 +8,21 @@ describe('token', () => { const parser = token('if'); const result = parser('if (condition)'); - assertSuccess<'if'>(result, 'if', '(condition)'); + assertSuccess<'if'>(result, 'if', 5); }); it('should parse operators with whitespace', () => { const parser = token('=='); const result = parser('== value'); - assertSuccess<'=='>(result, '==', 'value'); + assertSuccess<'=='>(result, '==', 4); }); it('should parse punctuation symbols', () => { const parser = token('('); const result = parser('( )'); - assertSuccess<'('>(result, '(', ')'); + assertSuccess<'('>(result, '(', 3); }); it('should fail when symbol does not match', () => { @@ -36,20 +36,20 @@ describe('token', () => { const parser = token(';'); const result = parser(';next'); - assertSuccess<';'>(result, ';', 'next'); + assertSuccess<';'>(result, ';', 1); }); it('should handle multi-character symbols', () => { const parser = token('<='); const result = parser('<= 100'); - assertSuccess<'<='>(result, '<=', '100'); + assertSuccess<'<='>(result, '<=', 4); }); it('should work with empty string symbol', () => { const parser = token(''); const result = parser(' anything'); - assertSuccess<''>(result, '', 'anything'); + assertSuccess<''>(result, '', 3); }); }); diff --git a/src/terminals/token.ts b/src/terminals/token.ts index 071ac26..d1b6537 100644 --- a/src/terminals/token.ts +++ b/src/terminals/token.ts @@ -8,9 +8,9 @@ import { string } from './string'; * strings that might be followed by any character. * * @example - * 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' } + * token('let')('let x') // { ok: true, value: 'let', index: 4, furthest: -1, expected: [] } + * token('let')('let1') // { ok: true, value: 'let', index: 3, furthest: -1, expected: [] } + * token('let')('let x') // { ok: true, value: 'let', index: 5, furthest: -1, expected: [] } */ export const token = (input: T) => { return create(lexeme(string(input))); diff --git a/src/terminals/word.test.ts b/src/terminals/word.test.ts index 29b3061..7034b06 100644 --- a/src/terminals/word.test.ts +++ b/src/terminals/word.test.ts @@ -9,13 +9,13 @@ describe('word', () => { const parser = word('test'); const result = parser('test abc'); - assertSuccess(result, 'test', 'abc'); + assertSuccess(result, 'test', 5); } { const parser = word('hello'); const result = parser('hello world'); - assertSuccess(result, 'hello', 'world'); + assertSuccess(result, 'hello', 6); } }); @@ -39,13 +39,13 @@ describe('word', () => { const parser = word('test'); const result = parser('test!'); - assertSuccess(result, 'test', '!'); + assertSuccess(result, 'test', 4); } { const parser = word('word'); const result = parser('word.'); - assertSuccess(result, 'word', '.'); + assertSuccess(result, 'word', 4); } }); }); diff --git a/src/terminals/word.ts b/src/terminals/word.ts index 2ce951d..7e55908 100644 --- a/src/terminals/word.ts +++ b/src/terminals/word.ts @@ -10,9 +10,9 @@ import { string } from './string'; * Use this when parsing keywords that should not be part of a longer identifier. * * @example - * 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)' } + * word('let')('let x') // { ok: true, value: 'let', index: 4, furthest: -1, expected: [] } + * word('let')('let1') // { ok: false, index: 3, furthest: 3, expected: ['/(?!\\w)/'] } + * word('if')('if (x)') // { ok: true, value: 'if', index: 3, furthest: -1, expected: [] } */ export const word = (targetWord: string) => { return create( diff --git a/src/utils/join.ts b/src/utils/join.ts index 6f95dc9..66ebe4a 100644 --- a/src/utils/join.ts +++ b/src/utils/join.ts @@ -2,8 +2,8 @@ * Join array elements into a string. * * @example - * join()([1, 2, 3]) // '123' - * join('-')([1, 2, 3]) // '1-2-3' + * join()([1, 2, 3]) // '123' + * join('-')([1, 2, 3]) // '1-2-3' */ export const join = (separator: string = '') => { return >(haystack: T): string => { diff --git a/src/utils/pipe.test.ts b/src/utils/pipe.test.ts index 0621e8a..b5948cb 100644 --- a/src/utils/pipe.test.ts +++ b/src/utils/pipe.test.ts @@ -11,7 +11,7 @@ describe('pipe', () => { (n: number) => n * 2, ); - const parser = create(() => success(5, 'abc')); + const parser = create((_input, index = 0) => success(5, index)); const result = transform.call(parser, 5); expect(result).toBe(12); @@ -24,7 +24,7 @@ describe('pipe', () => { (n: number) => n - 3, ); - const parser = create(() => success(5, 'abc')); + const parser = create((_input, index = 0) => success(5, index)); const result = transform.call(parser, 5); expect(result).toBe(9); @@ -33,7 +33,7 @@ describe('pipe', () => { it('should work with a single function', () => { const transform = pipe((n: number) => n * 2); - const parser = create(() => success(5, 'abc')); + const parser = create((_input, index = 0) => success(5, index)); const result = transform.call(parser, 5); expect(result).toBe(10); @@ -46,14 +46,14 @@ describe('pipe', () => { (s: string) => s + '!', ); - const parser = create(() => success(' hello ', '!')); + const parser = create((_input, index = 0) => success(' hello ', index)); const result = transform.call(parser, ' hello '); expect(result).toBe('HELLO!'); }); it('should allow accessing parser state via this', () => { - const parser = create(() => success(100, 'abc')); + const parser = create((_input, index = 0) => success(100, index)); const transform = pipe(function (n: number) { return n + (this ? 1 : 0); diff --git a/src/utils/pipe.ts b/src/utils/pipe.ts index 343e3c7..8e1f369 100644 --- a/src/utils/pipe.ts +++ b/src/utils/pipe.ts @@ -4,7 +4,7 @@ import { type Parser } from '../core/parser'; * Pipe parser functions together. * * @example - * pipe(lexeme)(letters)('xyz abc') // { ok: true, value: 'xyz', remaining: 'abc' } + * pipe(lexeme)(letters)('xyz abc') // { ok: true, value: 'xyz', index: 6, furthest: 3, expected: ['letter'] } */ type Pipe = (this: Parser, arg: A) => B; diff --git a/test/examples.test.ts b/test/examples.test.ts index f50acfa..862a05a 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -22,53 +22,97 @@ describe('examples from source', () => { describe('combinators', () => { 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: '' }); + expect(result0).toEqual({ + ok: true, + value: 'abc', + index: 4, + furthest: -1, + expected: [], + }); }); it('braced: Parse content surrounded by braces.', () => { const result0 = braced(string('hi'))('{hi}'); - expect(result0).toEqual({ ok: true, value: 'hi', remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 'hi', + index: 4, + furthest: -1, + expected: [], + }); }); it('bracketed: Parse content surrounded by brackets.', () => { const result0 = bracketed(string('hi'))('[hi]'); - expect(result0).toEqual({ ok: true, value: 'hi', remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 'hi', + index: 4, + furthest: -1, + expected: [], + }); }); it('chainLeft: Chain left-associative operations (right-to-left for same precedence).', () => { const result0 = chainLeft(digits, operation)('1+2+3'); - expect(result0).toEqual({ ok: true, value: 6, remaining: '' }); - const result1 = chainLeft(digits, operation)('10-3+2'); - expect(result1).toEqual({ ok: true, value: 9, remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 6, + index: 5, + furthest: 5, + expected: ['operator'], + }); }); it('chainLeft1: Chain left-associative operations (fails on empty input).', () => { const result0 = chainLeft1(digits, operation)('1+2+3'); - expect(result0).toEqual({ ok: true, value: 6, remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 6, + index: 5, + furthest: 5, + expected: ['operator'], + }); const result1 = chainLeft1(digits, operation)('8/2*3'); - expect(result1).toEqual({ ok: true, value: 12, remaining: '' }); + expect(result1).toEqual({ + ok: true, + value: 12, + index: 5, + furthest: 5, + expected: ['operator'], + }); }); - it('chainRight: Chain right-associative operations (right-to-left grouping).', () => { - const result0 = chainRight(digits, operation)('2-1-1'); - expect(result0).toEqual({ ok: true, value: 2, remaining: '' }); - const result1 = chainRight(digits, operation)('4/2/2'); - expect(result1).toEqual({ ok: true, value: 4, remaining: '' }); + it('chainRight: Chain right-associative operations.', () => { + const result0 = chainRight(digits, operation)('1+2+3'); + expect(result0).toEqual({ + ok: true, + value: 6, + index: 5, + furthest: 5, + expected: ['operator'], + }); }); it('chainRight1: Chain right-associative operations (fails on empty input).', () => { - const result0 = chainRight1(digits, operation)('2-1-1'); - expect(result0).toEqual({ ok: true, value: 2, remaining: '' }); - const result1 = chainRight1(digits, operation)('4/2/2'); - expect(result1).toEqual({ ok: true, value: 4, remaining: '' }); + const result0 = chainRight1(digits, operation)('1+2+3'); + expect(result0).toEqual({ + ok: true, + value: 6, + index: 5, + furthest: 5, + expected: ['operator'], + }); }); - it('choice: Try each parser in order, return first success.', () => { + it('choice: a failing alternation reports all of them rather than only the last.', () => { const result0 = choice(string('hello'), string('world'))('hello'); expect(result0).toEqual({ ok: true, value: 'hello', - remaining: '', + index: 5, + furthest: -1, + expected: [], }); }); @@ -77,13 +121,17 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'abc', - remaining: '123', + index: 3, + furthest: 3, + expected: ['letter'], }); const result1 = concat(many(letter), '-')('abc123'); expect(result1).toEqual({ ok: true, value: 'a-b-c', - remaining: '123', + index: 3, + furthest: 3, + expected: ['letter'], }); }); @@ -92,7 +140,9 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: null, - remaining: ' world', + index: 5, + furthest: -1, + expected: [], }); }); @@ -101,7 +151,9 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: ['item', 'item', 'item'], - remaining: '', + index: 15, + furthest: 15, + expected: ["'item'"], }); }); @@ -113,7 +165,9 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: ['item', 'item', 'item'], - remaining: '', + index: 15, + furthest: 15, + expected: ["'item'"], }); }); @@ -122,13 +176,21 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: ['a', 'a', 'a'], - remaining: '', + index: 3, + furthest: -1, + expected: [], }); }); it('first: Extract the first element from a parser result array.', () => { const result0 = first(sequence(char('a'), digit))('a1bc'); - expect(result0).toEqual({ ok: true, value: 'a', remaining: 'bc' }); + expect(result0).toEqual({ + ok: true, + value: 'a', + index: 2, + furthest: -1, + expected: [], + }); }); it('flag: Return true if parser succeeds, false otherwise. Always succeeds without consuming input on failure.', () => { @@ -136,55 +198,73 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: true, - remaining: 'abc', + index: 1, + furthest: -1, + expected: [], }); const result1 = flag(string('*'))('abc'); expect(result1).toEqual({ ok: true, value: false, - remaining: 'abc', + index: 0, + furthest: 0, + expected: ["'*'"], }); }); - it('fold: Parse zero or more and fold into a single value.', () => { - const result0 = fold(digit, [], (acc, d) => [...acc, d])('123'); + it('fold: Fold zero or more occurrences into a single value.', () => { + const result0 = fold(digit, 0, (acc, d) => acc + d)('123'); expect(result0).toEqual({ ok: true, - value: [1, 2, 3], - remaining: '', + value: 6, + index: 3, + furthest: 3, + expected: ['digit'], }); }); - it('fold1: Parse one or more and fold into a single value.', () => { + it('fold1: Fold one or more occurrences into a single value.', () => { const result0 = fold1(digit, 0, (acc, d) => acc + d)('123'); - expect(result0).toEqual({ ok: true, value: 6, remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 6, + index: 3, + furthest: 3, + expected: ['digit'], + }); }); - it('foldRight: Parse zero or more and fold right-to-left.', () => { - const result0 = foldRight(digit, [], (acc, d) => [...acc, d])( - '123', - ); + it('foldRight: Fold zero or more occurrences from the right into a single value.', () => { + const result0 = foldRight(digit, 0, (acc, d) => acc + d)('123'); expect(result0).toEqual({ ok: true, - value: [3, 2, 1], - remaining: '', + value: 6, + index: 3, + furthest: 3, + expected: ['digit'], }); }); - it('foldRight1: Parse one or more and fold right-to-left.', () => { - const result0 = foldRight1(digit, [], (acc, d) => [...acc, d])( - '123', - ); + it('foldRight1: Fold one or more occurrences from the right into a single value.', () => { + const result0 = foldRight1(digit, 0, (acc, d) => acc + d)('123'); expect(result0).toEqual({ ok: true, - value: [3, 2, 1], - remaining: '', + value: 6, + index: 3, + furthest: 3, + expected: ['digit'], }); }); it('fuse: The fused parser concatenates all string results.', () => { const result0 = fuse(char('a'), char('b'), char('c'))('abc'); - expect(result0).toEqual({ ok: true, value: 'abc', remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 'abc', + index: 3, + furthest: -1, + expected: [], + }); const result1 = fuse( string('hello'), char(' '), @@ -193,7 +273,9 @@ describe('examples from source', () => { expect(result1).toEqual({ ok: true, value: 'hello world', - remaining: '', + index: 11, + furthest: -1, + expected: [], }); }); @@ -202,29 +284,50 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'hello', - remaining: '', + index: 5, + furthest: -1, + expected: [], }); const result1 = guard(false, string('hello'))('hello'); - expect(result1).toEqual({ ok: false }); + expect(result1).toEqual({ + ok: false, + index: 0, + furthest: 0, + expected: [], + }); }); it('inner: Extract inner value from surrounded content (like inner of braced).', () => { const result0 = inner(char('('), string('hi'), char(')'))('(hi)'); - expect(result0).toEqual({ ok: true, value: 'hi', remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 'hi', + index: 4, + furthest: -1, + expected: [], + }); }); - it('interleaved: Parse items with interleaved separators.', () => { - const result0 = interleaved(char('a'), char(','))('a,a,a'); + it('interleaved: Parse items separated by separators, keeping both in the result.', () => { + const result0 = interleaved(digits, char('+'))('1+2'); expect(result0).toEqual({ ok: true, - value: ['a', ',', 'a', ',', 'a'], - remaining: '', + value: [1, '+', 2], + index: 3, + furthest: 3, + expected: ["'+'"], }); }); it('last: Extract the last element from a parser result array.', () => { const result0 = last(sequence(char('a'), char('b')))('ab'); - expect(result0).toEqual({ ok: true, value: 'b', remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 'b', + index: 2, + furthest: -1, + expected: [], + }); }); it('left: Keep only the left result from a sequence.', () => { @@ -235,7 +338,9 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'hello', - remaining: '', + index: 10, + furthest: -1, + expected: [], }); }); @@ -244,25 +349,31 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'hello', - remaining: 'world', + index: 8, + furthest: -1, + expected: [], }); }); - it('many: Zero or more occurrences (never fails).', () => { + it('many: still report what the repetition was expecting next.', () => { const result0 = many(char('a'))('aaa'); expect(result0).toEqual({ ok: true, value: ['a', 'a', 'a'], - remaining: '', + index: 3, + furthest: 3, + expected: ["'a'"], }); }); - it('many1: One or more occurrences (fails if no matches).', () => { + it('many1: One or more occurrences.', () => { const result0 = many1(char('a'))('aaa'); expect(result0).toEqual({ ok: true, value: ['a', 'a', 'a'], - remaining: '', + index: 3, + furthest: 3, + expected: ["'a'"], }); }); @@ -271,25 +382,31 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: ['a', 'a', 'a'], - remaining: '', + index: 3, + furthest: 3, + expected: ["'a'"], }); }); - it('manyAtMost: Parse at most n occurrences.', () => { + it('manyAtMost: Parse at most n occurrences (never fails).', () => { const result0 = manyAtMost(char('a'), 2)('aaa'); expect(result0).toEqual({ ok: true, value: ['a', 'a'], - remaining: 'a', + index: 2, + furthest: -1, + expected: [], }); }); it('manyBetween: Parse between min and max occurrences.', () => { - const result0 = manyBetween(char('a'), 2, 3)('aaa'); + const result0 = manyBetween(char('a'), 1, 2)('aaa'); expect(result0).toEqual({ ok: true, - value: ['a', 'a', 'a'], - remaining: '', + value: ['a', 'a'], + index: 2, + furthest: -1, + expected: [], }); }); @@ -298,18 +415,20 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: ['a', 'a', 'a'], - remaining: '', + index: 4, + furthest: 2, + expected: ["'b'"], }); }); - it('map: Transform the parsed value.', () => { - const result0 = map(string('hello'), (v) => v.toUpperCase())( - 'hello', - ); + it('map: Transform a parsed value through one or more functions.', () => { + const result0 = map(digits, (n) => n * 2)('21'); expect(result0).toEqual({ ok: true, - value: 'HELLO', - remaining: '', + value: 42, + index: 2, + furthest: -1, + expected: [], }); }); @@ -322,22 +441,28 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: { type: 'binop', left: 1, op: '+', right: 2 }, - remaining: '', + index: 3, + furthest: -1, + expected: [], }); const result1 = node('number', { value: digits })('123'); expect(result1).toEqual({ ok: true, value: { type: 'number', value: 123 }, - remaining: '', + index: 3, + furthest: -1, + expected: [], }); }); - it('not: Succeed if parser fails (without consuming input).', () => { + it('not: reporting it would produce a misleading expectation.', () => { const result0 = not(string('hello'))('world'); expect(result0).toEqual({ ok: true, value: null, - remaining: 'world', + index: 0, + furthest: -1, + expected: [], }); }); @@ -346,7 +471,13 @@ describe('examples from source', () => { sequence(char('a'), char('b'), char('c')), 1, )('abc'); - expect(result0).toEqual({ ok: true, value: 'b', remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 'b', + index: 3, + furthest: -1, + expected: [], + }); }); it('optional: Make parser optional (return null on failure, without consuming input).', () => { @@ -354,42 +485,39 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'hello', - remaining: '', + index: 5, + furthest: -1, + expected: [], }); const result1 = optional(string('hello'))('world'); expect(result1).toEqual({ ok: true, value: null, - remaining: 'world', + index: 0, + furthest: 0, + expected: ["'hello'"], }); }); - it('optionalConsume: Optionally consume input (always succeeds, returns void).', () => { - const result0 = optionalConsume(string('hello'))('hello world'); + it('optionalConsume: Consume input if the parser matches, discarding the result.', () => { + const result0 = optionalConsume(string('hi'))('hi there'); expect(result0).toEqual({ ok: true, - value: undefined, - remaining: ' world', - }); - const result1 = optionalConsume(string('hello'))('world'); - expect(result1).toEqual({ - ok: true, - value: undefined, - remaining: 'world', + index: 2, + furthest: -1, + expected: [], }); }); - it('optionalSeparatedBy: Zero or more items separated by a separator, with optional null values.', () => { + it('optionalSeparatedBy: Parse items separated by a separator, allowing empty slots.', () => { const result0 = optionalSeparatedBy(digits, char(','))('1,2'); - expect(result0).toEqual({ ok: true, value: [1, 2], remaining: '' }); - const result1 = optionalSeparatedBy(digits, char(','))(',1'); - expect(result1).toEqual({ + expect(result0).toEqual({ ok: true, - value: [null, 1], - remaining: '', + value: [1, 2], + index: 3, + furthest: 3, + expected: ["','"], }); - const result2 = optionalSeparatedBy(digits, char(','))('1,'); - expect(result2).toEqual({ ok: true, value: [1], remaining: '' }); }); it('outer: Extract outer values from a sequence of 3 parsers (skip middle).', () => { @@ -397,50 +525,82 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: ['(', ')'], - remaining: '', + index: 4, + furthest: -1, + expected: [], }); }); - it('padded: Parse content with optional whitespace on both sides.', () => { - const result0 = padded(string('hi'))(' hi '); - expect(result0).toEqual({ ok: true, value: 'hi', remaining: '' }); + it('padded: Parse content surrounded by optional whitespace.', () => { + const result0 = padded(string('hi'))(' hi '); + expect(result0).toEqual({ + ok: true, + value: 'hi', + index: 6, + furthest: 6, + expected: ['whitespace'], + }); }); it('parenthesized: Parse content surrounded by parentheses.', () => { const result0 = parenthesized(string('hi'))('(hi)'); - expect(result0).toEqual({ ok: true, value: 'hi', remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 'hi', + index: 4, + furthest: -1, + expected: [], + }); }); - it('peek: Parse without consuming input.', () => { + it('peek: Look ahead without consuming input.', () => { const result0 = peek(string('hello'))('hello world'); expect(result0).toEqual({ ok: true, value: 'hello', - remaining: 'hello world', + index: 0, + furthest: -1, + expected: [], }); }); - it('postfix: Parse postfix operators (chains atom with operators that return functions).', () => { + it('postfix: Apply zero or more postfix operators to an atom.', () => { const result0 = postfix( - char('a'), - map(char('!'), () => (x) => x), - )('a!'); - expect(result0).toEqual({ ok: true, value: 'a', remaining: '' }); + digits, + map(char('!'), () => (n) => n * 2), + )('3!'); + expect(result0).toEqual({ + ok: true, + value: 6, + index: 2, + furthest: 2, + expected: ["'!'"], + }); }); - it('prefix: Parse prefix operators (like - in -5).', () => { + it('prefix: Apply zero or more prefix operators to an atom.', () => { const result0 = prefix( - map(char('-'), () => (x) => -x), - digit, - )('-5'); - expect(result0).toEqual({ ok: true, value: -5, remaining: '' }); + map(char('-'), () => (n) => -n), + digits, + )('-3'); + expect(result0).toEqual({ + ok: true, + value: -3, + index: 2, + furthest: 1, + expected: ["'-'"], + }); }); - it('pure: Always return a value without consuming input.', () => { + it('pure: Always succeed with a value without consuming input.', () => { const result0 = pure(42)('abc'); - expect(result0).toEqual({ ok: true, value: 42, remaining: 'abc' }); - const result1 = pure('ok')(''); - expect(result1).toEqual({ ok: true, value: 'ok', remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 42, + index: 0, + furthest: -1, + expected: [], + }); }); it('quoted: Parse content surrounded by single or double quotes.', () => { @@ -448,7 +608,9 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'hello', - remaining: '', + index: 7, + furthest: -1, + expected: [], }); }); @@ -457,7 +619,9 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'default', - remaining: 'world', + index: 0, + furthest: 0, + expected: ["'hello'"], }); }); @@ -469,56 +633,68 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'world', - remaining: '', + index: 10, + furthest: -1, + expected: [], }); }); - it('separatedBy: Zero or more items separated by a separator.', () => { - const result0 = separatedBy(char('a'), char(','))('a,a,a'); + it('separatedBy: after it does not, the list ends before the separator.', () => { + const result0 = separatedBy(digits, char(','))('1,2,3'); expect(result0).toEqual({ ok: true, - value: ['a', 'a', 'a'], - remaining: '', + value: [1, 2, 3], + index: 5, + furthest: 5, + expected: ["','"], }); }); - it('separatedBy1: One or more items separated by a separator.', () => { - const result0 = separatedBy1(char('a'), char(','))('a,a,a'); + it('separatedBy1: Parse one or more items separated by a separator.', () => { + const result0 = separatedBy1(digits, char(','))('1,2,3'); expect(result0).toEqual({ ok: true, - value: ['a', 'a', 'a'], - remaining: '', + value: [1, 2, 3], + index: 5, + furthest: 5, + expected: ["','"], }); }); - it('separatedEndBy: Zero or more items separated by and ending with a terminator.', () => { - const result0 = separatedEndBy(char('a'), char(';'))('a;a;a;'); + it('separatedEndBy: Parse zero or more items separated by a separator, allowing a trailing one.', () => { + const result0 = separatedEndBy(digits, char(','))('1,2,3,'); expect(result0).toEqual({ ok: true, - value: ['a', 'a', 'a'], - remaining: '', + value: [1, 2, 3], + index: 6, + furthest: 6, + expected: ['digit'], }); }); - it('separatedEndBy1: One or more items separated by and ending with a terminator.', () => { - const result0 = separatedEndBy1(char('a'), char(';'))('a;a;a;'); + it('separatedEndBy1: Parse one or more items separated by a separator, allowing a trailing one.', () => { + const result0 = separatedEndBy1(digits, char(','))('1,2,3,'); expect(result0).toEqual({ ok: true, - value: ['a', 'a', 'a'], - remaining: '', + value: [1, 2, 3], + index: 6, + furthest: 6, + expected: ['digit'], }); }); - it('separatedUntil: Parse items separated by separator until terminator matches.', () => { + it('separatedUntil: Parse items separated by a separator, up to a terminator.', () => { const result0 = separatedUntil( - char('a'), + digits, char(','), char(';'), - )('a,a,a;'); + )('1,2,3;'); expect(result0).toEqual({ ok: true, - value: ['a', 'a', 'a'], - remaining: '', + value: [1, 2, 3], + index: 6, + furthest: 5, + expected: ["','"], }); }); @@ -527,23 +703,43 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: ['a', 'b', 'c'], - remaining: '', + index: 3, + furthest: -1, + expected: [], }); }); it('skip: Skip a parser n times.', () => { const result0 = skip(char('a'), 2)('aabc'); - expect(result0).toEqual({ ok: true, value: null, remaining: 'bc' }); + expect(result0).toEqual({ + ok: true, + value: null, + index: 2, + furthest: -1, + expected: [], + }); }); it('skipMany: Skip zero or more occurrences (never fails, returns null).', () => { const result0 = skipMany(char('a'))('aaabc'); - expect(result0).toEqual({ ok: true, value: null, remaining: 'bc' }); + expect(result0).toEqual({ + ok: true, + value: null, + index: 3, + furthest: 3, + expected: ["'a'"], + }); }); it('skipMany1: Skip one or more occurrences (fails if no matches).', () => { const result0 = skipMany1(char('a'))('aaabc'); - expect(result0).toEqual({ ok: true, value: null, remaining: 'bc' }); + expect(result0).toEqual({ + ok: true, + value: null, + index: 3, + furthest: 3, + expected: ["'a'"], + }); }); it('surrounded: Parse content surrounded by delimiters.', () => { @@ -552,9 +748,21 @@ describe('examples from source', () => { string('hi'), char(']'), )('[hi]'); - expect(result0).toEqual({ ok: true, value: 'hi', remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 'hi', + index: 4, + furthest: -1, + expected: [], + }); const result1 = surrounded(char('a'), char('b'), char('c'))('abc'); - expect(result1).toEqual({ ok: true, value: 'b', remaining: '' }); + expect(result1).toEqual({ + ok: true, + value: 'b', + index: 3, + furthest: -1, + expected: [], + }); }); it('unless: Parse unless condition is true (inverse of guard).', () => { @@ -562,39 +770,66 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'hello', - remaining: '', + index: 5, + furthest: -1, + expected: [], }); const result1 = unless(true, string('hello'))('hello'); expect(result1).toEqual({ ok: true, value: null, - remaining: 'hello', + index: 0, + furthest: -1, + expected: [], }); }); - it('until: Parse until terminator matches (fails if terminator never matches).', () => { - const result0 = until(char('a'), char('b'))('baaa'); - expect(result0).toEqual({ ok: true, value: [], remaining: 'baaa' }); - const result1 = until(char('a'), char('b'))('aaba'); - expect(result1).toEqual({ + it('until: Parse zero or more until terminator matches, leaving the terminator unconsumed.', () => { + const result0 = until(char('a'), char('b'))('aaab'); + expect(result0).toEqual({ ok: true, - value: ['a', 'a'], - remaining: 'ba', + value: ['a', 'a', 'a'], + index: 3, + furthest: 2, + expected: ["'b'"], }); }); it('validate: Validate parsed value with a predicate.', () => { const result0 = validate(digit, (n) => n > 5)('7'); - expect(result0).toEqual({ ok: true, value: 7, remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 7, + index: 1, + furthest: -1, + expected: [], + }); const result1 = validate(digit, (n) => n > 5)('3'); - expect(result1).toEqual({ ok: false }); + expect(result1).toEqual({ + ok: false, + index: 0, + furthest: 0, + expected: ['valid value'], + }); }); it('value: Replace parsed value with a constant.', () => { const result0 = value(string('true'), true)('true'); - expect(result0).toEqual({ ok: true, value: true, remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: true, + index: 4, + furthest: -1, + expected: [], + }); const result1 = value(string('null'), null)('null'); - expect(result1).toEqual({ ok: true, value: null, remaining: '' }); + expect(result1).toEqual({ + ok: true, + value: null, + index: 4, + furthest: -1, + expected: [], + }); }); it('when: Branch on a boolean parser result.', () => { @@ -606,7 +841,9 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'many', - remaining: 'rest', + index: 1, + furthest: -1, + expected: [], }); const result1 = when( flag(char('*')), @@ -616,18 +853,37 @@ describe('examples from source', () => { expect(result1).toEqual({ ok: true, value: 'one', - remaining: 'abc', + index: 0, + furthest: 0, + expected: ["'*'"], }); }); }); describe('core', () => { - it('failure: Creates a failed result with an optional error message.', () => { - const result0 = failure('unexpected input'); - expect(result0).toEqual({ ok: false, error: 'unexpected input' }); + it('error: 1-based line/column and the set of expectations at that offset.', () => { + const result0 = new ParseError('a=1,b=x', 6, ['digit']).line; + expect(result0).toEqual(1); }); - it('grammar: Creates a recursive grammar where rules can reference each other.', () => { + it('failure: {@link choice}, so a failing alternation reports every branch it tried.', () => { + const result0 = failure(3, 'digit'); + expect(result0).toEqual({ + ok: false, + index: 3, + furthest: 3, + expected: ['digit'], + }); + }); + + it('format: Renders a parse failure as a source excerpt with a caret under the offset.', () => { + const result0 = format('a=1,b=x', 6, ['digit']); + expect(result0).toEqual( + "1:7 expected digit, found 'x'\n 1 | a=1,b=x\n | ^", + ); + }); + + it('grammar: reported by name rather than overflowing the stack.', () => { type Math = { expr: number; term: number; @@ -657,52 +913,96 @@ describe('examples from source', () => { expect(result2).toEqual(3); }); - it('label: Labels a parser with a custom error message on failure.', () => { + it('label: has committed, its own deeper error is more useful than the label.', () => { const result0 = label(char('x'), 'letter x')(''); - expect(result0).toEqual({ ok: false, error: 'expected letter x' }); + expect(result0).toEqual({ + ok: false, + index: 0, + furthest: 0, + expected: ['letter x'], + }); }); it('lazy: Defers parser creation, useful for recursive grammars.', () => { const result0 = lazy(() => char('a'))('abc'); - expect(result0).toEqual({ ok: true, value: 'a', remaining: 'bc' }); + expect(result0).toEqual({ + ok: true, + value: 'a', + index: 1, + furthest: -1, + expected: [], + }); + }); + + it('locate: Converts a character offset into a 1-based line and column.', () => { + const result0 = locate('a=1\nb=x', 5); + expect(result0).toEqual({ index: 5, line: 2, column: 2 }); }); it('match: Pattern matching on a Result to handle success and failure cases.', () => { - const result0 = match(success('hello', ''), { + const result0 = match(success('hello', 5), { success: (v) => v, failure: () => 'failed', }); expect(result0).toEqual('hello'); }); - it('memoize: exponential backtracking.', () => { + it('memoize: the cache is dropped as soon as a different input is parsed.', () => { const memoDigits = memoize(digits); const result0 = memoDigits('123'); - expect(result0).toEqual({ ok: true, value: 123, remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 123, + index: 3, + furthest: -1, + expected: [], + }); + }); + + it('merge: common case and keeps the hot path allocation-free.', () => { + const result0 = merge(failure(7, 'digit'), success('ok', 3)); + expect(result0).toEqual({ + ok: true, + value: 'ok', + index: 3, + furthest: 7, + expected: ['digit'], + }); + }); + + it('parse: offset, line, column, expectations and a formatted message.', () => { + const result0 = parse(digits, '12x').message; + expect(result0).toEqual( + "1:3 expected end of input, found 'x'\n 1 | 12x\n | ^", + ); }); - it('parser: Creates a parser from a parser function.', () => { - const result0 = create((input) => - success('parsed', input.slice(6)), + it('parser: to the top-level error message.', () => { + const result0 = create((input, index = 0) => + success('parsed', index + 6), )('hello world'); expect(result0).toEqual({ ok: true, value: 'parsed', - remaining: 'world', + index: 6, + furthest: -1, + expected: [], }); }); - it('run: Runs a parser and returns the value, throws on failure or unconsumed input.', () => { + it('run: point — if the parse fails or leaves input unconsumed.', () => { const result0 = run(string('hello'), 'hello'); expect(result0).toEqual('hello'); }); - it('success: Creates a successful result with a value and remaining input.', () => { - const result0 = success('hello', ' world'); + it('success: {@link merge} to propagate it — a fresh success starts with no trace.', () => { + const result0 = success('hello', 5); expect(result0).toEqual({ ok: true, value: 'hello', - remaining: ' world', + index: 5, + furthest: -1, + expected: [], }); }); }); @@ -710,35 +1010,69 @@ describe('examples from source', () => { describe('primitives', () => { it('alphaNum: Parse a single alphanumeric character.', () => { const result0 = alphaNum('a1'); - expect(result0).toEqual({ ok: true, value: 'a', remaining: '1' }); + expect(result0).toEqual({ + ok: true, + value: 'a', + index: 1, + furthest: -1, + expected: [], + }); const result1 = alphaNum('1a'); - expect(result1).toEqual({ ok: true, value: '1', remaining: 'a' }); + expect(result1).toEqual({ + ok: true, + value: '1', + index: 1, + furthest: -1, + expected: [], + }); }); - it('alphaNums: Parse one or more alphanumeric characters.', () => { - const result0 = alphaNums('abc123'); + it('alphaNums: Parse one or more alphanumeric characters as a string.', () => { + const result0 = alphaNums('placeholder'); expect(result0).toEqual({ ok: true, - value: 'abc123', - remaining: '', + value: 'placeholder', + index: 11, + furthest: 11, + expected: ['alphanumeric character'], }); }); it('anyChar: Parse any single character.', () => { const result0 = anyChar('abc'); - expect(result0).toEqual({ ok: true, value: 'a', remaining: 'bc' }); + expect(result0).toEqual({ + ok: true, + value: 'a', + index: 1, + furthest: -1, + expected: [], + }); }); it('bool: Parse a boolean literal.', () => { const result0 = bool('true'); - expect(result0).toEqual({ ok: true, value: true, remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: true, + index: 4, + furthest: -1, + expected: [], + }); const result1 = bool('false'); - expect(result1).toEqual({ ok: true, value: false, remaining: '' }); + expect(result1).toEqual({ + ok: true, + value: false, + index: 5, + furthest: 0, + expected: ["'true'"], + }); const result2 = bool('trueABC'); expect(result2).toEqual({ ok: true, value: true, - remaining: 'ABC', + index: 4, + furthest: -1, + expected: [], }); }); @@ -747,23 +1081,43 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: '\r\n', - remaining: 'abc', + index: 2, + furthest: -1, + expected: [], }); }); it('digit: Parse a single digit and return as number.', () => { const result0 = digit('5abc'); - expect(result0).toEqual({ ok: true, value: 5, remaining: 'abc' }); + expect(result0).toEqual({ + ok: true, + value: 5, + index: 1, + furthest: -1, + expected: [], + }); }); - it('digits: Parse one or more digits and return as number.', () => { - const result0 = digits('123abc'); - expect(result0).toEqual({ ok: true, value: 123, remaining: 'abc' }); + it('digits: Parse one or more digits and return as a number.', () => { + const result0 = digits('123'); + expect(result0).toEqual({ + ok: true, + value: 123, + index: 3, + furthest: -1, + expected: [], + }); }); - it('eof: Parse end of file (succeeds only on empty input).', () => { + it('eof: Match the end of the input.', () => { const result0 = eof(''); - expect(result0).toEqual({ ok: true, value: null, remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: null, + index: 0, + furthest: -1, + expected: [], + }); }); it('eol: Parse end of line (\\n, \\r\\n, or EOF).', () => { @@ -771,34 +1125,57 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: '\n', - remaining: 'abc', + index: 1, + furthest: -1, + expected: [], }); }); it('float: Parse a floating point number.', () => { const result0 = float('1.23'); - expect(result0).toEqual({ ok: true, value: 1.23, remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 1.23, + index: 4, + furthest: -1, + expected: [], + }); const result1 = float('-2.5'); - expect(result1).toEqual({ ok: true, value: -2.5, remaining: '' }); + expect(result1).toEqual({ + ok: true, + value: -2.5, + index: 4, + furthest: -1, + expected: [], + }); const result2 = float('1.23abc'); expect(result2).toEqual({ ok: true, value: 1.23, - remaining: 'abc', + index: 4, + furthest: -1, + expected: [], }); }); it('hexDigit: Parse a single hexadecimal digit.', () => { const result0 = hexDigit('fF9'); - expect(result0).toEqual({ ok: true, value: 'f', remaining: 'F9' }); + expect(result0).toEqual({ + ok: true, + value: 'f', + index: 1, + furthest: -1, + expected: [], + }); }); - it('hexDigits: Parse one or more hexadecimal digits.', () => { - const result0 = hexDigits('deadbeef'); + it('hexDigits: Parse one or more hex digits as a string.', () => { + const result0 = hexDigits('placeholder'); expect(result0).toEqual({ - ok: true, - value: 'deadbeef', - remaining: '', + ok: false, + index: 0, + furthest: 0, + expected: ['hex digit'], }); }); @@ -807,30 +1184,58 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'variable_name', - remaining: '', + index: 13, + furthest: -1, + expected: [], }); }); it('integer: Parse a signed integer.', () => { const result0 = integer('42'); - expect(result0).toEqual({ ok: true, value: 42, remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 42, + index: 2, + furthest: -1, + expected: [], + }); const result1 = integer('-7'); - expect(result1).toEqual({ ok: true, value: -7, remaining: '' }); + expect(result1).toEqual({ + ok: true, + value: -7, + index: 2, + furthest: -1, + expected: [], + }); const result2 = integer('123abc'); - expect(result2).toEqual({ ok: true, value: 123, remaining: 'abc' }); + expect(result2).toEqual({ + ok: true, + value: 123, + index: 3, + furthest: -1, + expected: [], + }); }); it('letter: Parse a single letter.', () => { const result0 = letter('abc'); - expect(result0).toEqual({ ok: true, value: 'a', remaining: 'bc' }); + expect(result0).toEqual({ + ok: true, + value: 'a', + index: 1, + furthest: -1, + expected: [], + }); }); - it('letters: Parse one or more letters.', () => { - const result0 = letters('abc123'); + it('letters: Parse one or more letters as a string.', () => { + const result0 = letters('placeholder'); expect(result0).toEqual({ ok: true, - value: 'abc', - remaining: '123', + value: 'placeholder', + index: 11, + furthest: 11, + expected: ['letter'], }); }); @@ -839,7 +1244,9 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'hello', - remaining: '\nworld', + index: 5, + furthest: -1, + expected: [], }); }); @@ -848,27 +1255,39 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'foo-bar', - remaining: '', + index: 7, + furthest: -1, + expected: [], }); const result1 = literal('123abc'); expect(result1).toEqual({ ok: true, value: '123abc', - remaining: '', + index: 6, + furthest: -1, + expected: [], }); }); it('lowercase: Parse a single lowercase letter.', () => { const result0 = lowercase('abc'); - expect(result0).toEqual({ ok: true, value: 'a', remaining: 'bc' }); + expect(result0).toEqual({ + ok: true, + value: 'a', + index: 1, + furthest: -1, + expected: [], + }); }); - it('lowercases: Parses one or more lowercase letters.', () => { - const result0 = lowercases('abcDEF'); + it('lowercases: Parse one or more lowercase letters as a string.', () => { + const result0 = lowercases('placeholder'); expect(result0).toEqual({ ok: true, - value: 'abc', - remaining: 'DEF', + value: 'placeholder', + index: 11, + furthest: 11, + expected: ['lowercase letter'], }); }); @@ -877,60 +1296,116 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: '\n', - remaining: 'text', + index: 1, + furthest: -1, + expected: [], }); }); it('number: Parse an integer or float.', () => { const result0 = number('42'); - expect(result0).toEqual({ ok: true, value: 42, remaining: '' }); + expect(result0).toEqual({ + ok: true, + value: 42, + index: 2, + furthest: 0, + expected: ['/-?\\d+\\.\\d+/'], + }); const result1 = number('3.14'); - expect(result1).toEqual({ ok: true, value: 3.14, remaining: '' }); + expect(result1).toEqual({ + ok: true, + value: 3.14, + index: 4, + furthest: -1, + expected: [], + }); const result2 = number('-7'); - expect(result2).toEqual({ ok: true, value: -7, remaining: '' }); + expect(result2).toEqual({ + ok: true, + value: -7, + index: 2, + furthest: 0, + expected: ['/-?\\d+\\.\\d+/'], + }); const result3 = number('-2.5'); - expect(result3).toEqual({ ok: true, value: -2.5, remaining: '' }); + expect(result3).toEqual({ + ok: true, + value: -2.5, + index: 4, + furthest: -1, + expected: [], + }); }); it('octDigit: Parse a single octal digit.', () => { const result0 = octDigit('7abc'); - expect(result0).toEqual({ ok: true, value: '7', remaining: 'abc' }); + expect(result0).toEqual({ + ok: true, + value: '7', + index: 1, + furthest: -1, + expected: [], + }); }); - it('octDigits: Parse one or more octal digits.', () => { - const result0 = octDigits('0777abc'); + it('octDigits: Parse one or more octal digits as a string.', () => { + const result0 = octDigits('placeholder'); expect(result0).toEqual({ - ok: true, - value: '0777', - remaining: 'abc', + ok: false, + index: 0, + furthest: 0, + expected: ['octal digit'], }); }); - it('position: Get current position (remaining input length).', () => { + it('position: {@link locate} to attach line/column information to a parsed node.', () => { const result0 = position('abc'); - expect(result0).toEqual({ ok: true, value: 3, remaining: 'abc' }); + expect(result0).toEqual({ + ok: true, + value: 0, + index: 0, + furthest: -1, + expected: [], + }); + const result1 = right(string('ab'), position)('abc'); + expect(result1).toEqual({ + ok: true, + value: 2, + index: 2, + furthest: -1, + expected: [], + }); }); - it('rest: Parse the rest of the input.', () => { - const result0 = rest('hello'); + it('rest: Consume and return everything left in the input.', () => { + const result0 = rest('abc'); expect(result0).toEqual({ ok: true, - value: 'hello', - remaining: '', + value: 'abc', + index: 3, + furthest: -1, + expected: [], }); }); it('space: Parse a single space character.', () => { const result0 = space(' abc'); - expect(result0).toEqual({ ok: true, value: ' ', remaining: 'abc' }); + expect(result0).toEqual({ + ok: true, + value: ' ', + index: 1, + furthest: -1, + expected: [], + }); }); - it('spaces: Parse one or more space characters.', () => { - const result0 = spaces(' abc'); + it('spaces: Parse one or more spaces as a string.', () => { + const result0 = spaces('placeholder'); expect(result0).toEqual({ - ok: true, - value: ' ', - remaining: 'abc', + ok: false, + index: 0, + furthest: 0, + expected: ['space'], }); }); @@ -939,35 +1414,51 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: '\t', - remaining: 'text', + index: 1, + furthest: -1, + expected: [], }); }); it('uppercase: Parses a single uppercase letter.', () => { const result0 = uppercase('ABC'); - expect(result0).toEqual({ ok: true, value: 'A', remaining: 'BC' }); + expect(result0).toEqual({ + ok: true, + value: 'A', + index: 1, + furthest: -1, + expected: [], + }); }); - it('uppercases: Parses one or more uppercase letters.', () => { - const result0 = uppercases('ABCdef'); + it('uppercases: Parse one or more uppercase letters as a string.', () => { + const result0 = uppercases('placeholder'); expect(result0).toEqual({ - ok: true, - value: 'ABC', - remaining: 'def', + ok: false, + index: 0, + furthest: 0, + expected: ['uppercase letter'], }); }); it('whitespace: Parses a single whitespace character.', () => { const result0 = whitespace(' abc'); - expect(result0).toEqual({ ok: true, value: ' ', remaining: 'abc' }); + expect(result0).toEqual({ + ok: true, + value: ' ', + index: 1, + furthest: -1, + expected: [], + }); }); - it('whitespaces: Parses one or more whitespace characters.', () => { - const result0 = whitespaces(' abc'); + it('whitespaces: Parse one or more whitespaces as a string.', () => { + const result0 = whitespaces('placeholder'); expect(result0).toEqual({ - ok: true, - value: ' ', - remaining: 'abc', + ok: false, + index: 0, + furthest: 0, + expected: ['whitespace'], }); }); }); @@ -975,17 +1466,35 @@ describe('examples from source', () => { describe('terminals', () => { it('char: Parse a specific character.', () => { const result0 = char('A')('ABC'); - expect(result0).toEqual({ ok: true, value: 'A', remaining: 'BC' }); + expect(result0).toEqual({ + ok: true, + value: 'A', + index: 1, + furthest: -1, + expected: [], + }); }); it('charOf: Parse any character from a set.', () => { const result0 = charOf(['a', 'b', 'c'])('abc'); - expect(result0).toEqual({ ok: true, value: 'a', remaining: 'bc' }); + expect(result0).toEqual({ + ok: true, + value: 'a', + index: 1, + furthest: -1, + expected: [], + }); }); it('noneOf: Parse any character not in the set.', () => { const result0 = noneOf(['a', 'b', 'c'])('xyz'); - expect(result0).toEqual({ ok: true, value: 'x', remaining: 'yz' }); + expect(result0).toEqual({ + ok: true, + value: 'x', + index: 1, + furthest: -1, + expected: [], + }); }); it('oneOf: Parse one string from a set of strings (longest match wins).', () => { @@ -993,22 +1502,32 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'help', - remaining: 'ful', + index: 4, + furthest: -1, + expected: [], }); }); - it('regex: Parse with a regular expression.', () => { + it('regex: and is stripped.', () => { const result0 = regex(/^\w+/)('hello world'); expect(result0).toEqual({ ok: true, value: 'hello', - remaining: ' world', + index: 5, + furthest: -1, + expected: [], }); }); it('satisfy: Parse a character satisfying a predicate.', () => { const result0 = satisfy((c) => c === 'a')('abc'); - expect(result0).toEqual({ ok: true, value: 'a', remaining: 'bc' }); + expect(result0).toEqual({ + ok: true, + value: 'a', + index: 1, + furthest: -1, + expected: [], + }); }); it('string: Parse a specific string.', () => { @@ -1016,7 +1535,9 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'hello', - remaining: ' world', + index: 5, + furthest: -1, + expected: [], }); }); @@ -1025,7 +1546,9 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'a', - remaining: 'bcdef', + index: 1, + furthest: -1, + expected: [], }); }); @@ -1034,34 +1557,73 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'abc', - remaining: 'def', + index: 3, + furthest: -1, + expected: [], }); }); it('takeWhile: Takes characters while the predicate returns true.', () => { const result0 = takeWhile((c) => c !== 'x')('abcx'); - expect(result0).toEqual({ ok: true, value: 'abc', remaining: 'x' }); + expect(result0).toEqual({ + ok: true, + value: 'abc', + index: 3, + furthest: -1, + expected: [], + }); }); it('token: strings that might be followed by any character.', () => { const result0 = token('let')('let x'); - expect(result0).toEqual({ ok: true, value: 'let', remaining: 'x' }); + expect(result0).toEqual({ + ok: true, + value: 'let', + index: 4, + furthest: -1, + expected: [], + }); const result1 = token('let')('let1'); - expect(result1).toEqual({ ok: true, value: 'let', remaining: '1' }); + expect(result1).toEqual({ + ok: true, + value: 'let', + index: 3, + furthest: -1, + expected: [], + }); const result2 = token('let')('let x'); - expect(result2).toEqual({ ok: true, value: 'let', remaining: 'x' }); + expect(result2).toEqual({ + ok: true, + value: 'let', + index: 5, + furthest: -1, + expected: [], + }); }); it('word: Use this when parsing keywords that should not be part of a longer identifier.', () => { const result0 = word('let')('let x'); - expect(result0).toEqual({ ok: true, value: 'let', remaining: 'x' }); + expect(result0).toEqual({ + ok: true, + value: 'let', + index: 4, + furthest: -1, + expected: [], + }); const result1 = word('let')('let1'); - expect(result1).toEqual({ ok: false }); + expect(result1).toEqual({ + ok: false, + index: 3, + furthest: 3, + expected: ['/(?!\\w)/'], + }); const result2 = word('if')('if (x)'); expect(result2).toEqual({ ok: true, value: 'if', - remaining: '(x)', + index: 3, + furthest: -1, + expected: [], }); }); }); @@ -1100,7 +1662,9 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'xyz', - remaining: 'abc', + index: 6, + furthest: 3, + expected: ['letter'], }); }); diff --git a/test/helpers.ts b/test/helpers.ts index 52e66a3..5906f97 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,25 +1,29 @@ import { create, failure, success } from '../src'; -export const operation = create((input) => { - const ops: Record number> = { - '+': (left, right) => left + right, - '-': (left, right) => left - right, - '*': (left, right) => left * right, - '/': (left, right) => left / right, - '**': (left, right) => Math.pow(left, right), - }; +const OPERATORS: Record number> = { + '+': (left, right) => left + right, + '-': (left, right) => left - right, + '*': (left, right) => left * right, + '/': (left, right) => left / right, + '**': (left, right) => Math.pow(left, right), +}; - const [operator] = input.match(/\*\*|[+\-*/]/) || []; - if (!operator) return failure(); +const OPERATOR = /\*\*|[+\-*/]/y; - const op = ops[operator]; +export const operation = create((input, index = 0) => { + OPERATOR.lastIndex = index; + const [operator] = OPERATOR.exec(input) || []; + if (!operator) return failure(index, 'operator'); - return success(op, input.slice(operator.length)); + return success(OPERATORS[operator], index + operator.length); }); -export const digits = create((value: string) => { - const [, match, rest] = value.match(/^(\d+)(.*)/) || ['0']; - if (!match) return failure(); +const DIGITS = /\d+/y; - return success(parseInt(match), rest); +export const digits = create((input, index = 0) => { + DIGITS.lastIndex = index; + const [match] = DIGITS.exec(input) || []; + if (!match) return failure(index, 'digit'); + + return success(parseInt(match), index + match.length); }); diff --git a/test/utils.ts b/test/utils.ts index d158420..c181ccc 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -6,35 +6,53 @@ import { type Result } from '../src/core/result'; import { success } from '../src/core/success'; export const createTestParser = (tester: T) => { - return create((input) => { - const stringTester = String(tester); - if (input.startsWith(stringTester)) { - return success(tester, input.slice(stringTester.length)); + const stringTester = String(tester); + + return create((input, index = 0) => { + if (input.startsWith(stringTester, index)) { + return success(tester, index + stringTester.length); } - return failure(); + return failure(index, stringTester); }); }; +/** + * Asserts a parser succeeded with a value, having consumed up to `index`. + * + * The furthest-failure trace is deliberately not asserted: it is bookkeeping + * for error messages, not part of a parser's contract. + */ export const assertSuccess = ( result: Result, value: T, - remaining: string, + index: number, ) => { assertType>(result); - expect(result).toEqual({ - ok: true, - value, - remaining, - }); + expect(result.ok).toBe(true); + expect((result as { value: T }).value).toEqual(value); + expect(result.index).toBe(index); }; -export const assertFailure = (result: Result, error?: string) => { +/** + * Asserts a parser failed, optionally at a given offset and with given + * expectations. + */ +export const assertFailure = ( + result: Result, + index?: number, + expected?: readonly string[], +) => { assertType>(result); - expect(result).toEqual({ - ok: false, - error, - }); + expect(result.ok).toBe(false); + + if (index !== undefined) { + expect(result.index).toBe(index); + } + + if (expected !== undefined) { + expect([...result.expected].sort()).toEqual([...expected].sort()); + } }; From 8f4c8aa405eec30c77e999a014702f6fadcdd403 Mon Sep 17 00:00:00 2001 From: Oleg Kamlowski Date: Wed, 9 Sep 2026 22:00:00 +0200 Subject: [PATCH 2/5] perf(core)!: track expectations in a parse context, not on the result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The furthest-failure trace rode on every Result and was threaded by `merge`. That is what Parsec and Parsimmon do and it works, but it put two bookkeeping fields on `Success` and allocated on every step that backtracked — which is every `choice`, every `separatedBy`, every sequence that retries: separatedBy(digits, ',') 20k 5.71ms -> 13.29ms (vs master) kv pairs 10k 5.56ms -> 9.94ms many(choice x3) 20k 8.51ms -> 10.92ms It also showed up in every documented example, including successes, where an `expected` on a completed parse reads as a contradiction: fold(digit, 0, (acc, d) => acc + d)('123') // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } The trace now lives in a mutable context, created per `run`/`parse` and passed as a third argument. It only ever moves forward, so backtracking cannot lose it, and it is written only on the failure path: type Parser = (input, index?, ctx?) => Result; type Success = { ok: true; value: T; index: number }; type Failure = { ok: false; index: number; expected: readonly string[] }; Messages are unchanged — same offsets, same expectation sets — and the combinators go back to their plain form. Against master: many(char) 200k 4.63ms -> 2.89ms many(regex) 200k 10.23ms -> 7.97ms separatedBy 20k 5.71ms -> 5.26ms kv pairs 10k 5.56ms -> 5.25ms many(choice x3) 20k 8.51ms -> 8.53ms bracketed list 10k 5.94ms -> 2.27ms A combinator that fails to pass `ctx` on still parses correctly and only produces a worse message, which no ordinary test would catch, so `core/propagation.test.ts` checks every parser call site in combinators/, primitives/ and terminals/ and picks up new files on its own. It immediately found `not` leaking the failure it exists to swallow: `not` and `label` now isolate their inner context, and `memoize` keeps each entry's trace so a cache hit still contributes. The README generator takes an export's name from its declaration rather than from the file name, since one file may now document several exports, and renders {@link} tags as code. BREAKING CHANGE: `failure` takes the parse context first, as `failure(ctx, index, ...expected)`. `Success` no longer carries `furthest`/`expected` and `Failure` no longer carries `furthest`. `merge` is gone — `context` and `record` replace it for hand-written parsers. Composing the built-in parsers needs no change. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 125 +-- bin/generate-readme.mjs | 60 +- doc/README.template.md | 210 +++-- doc/api.template.md | 2 +- doc/api/combinators.md | 158 ++-- doc/api/core.md | 75 +- doc/api/primitives.md | 94 +- doc/api/terminals.md | 36 +- doc/api/utils.md | 4 +- package.json | 18 +- src/combinators/bind.test.ts | 14 +- src/combinators/bind.ts | 9 +- src/combinators/braced.ts | 2 +- src/combinators/bracketed.ts | 2 +- src/combinators/chainLeft.ts | 13 +- src/combinators/chainLeft1.ts | 22 +- src/combinators/chainRight.ts | 13 +- src/combinators/chainRight1.ts | 30 +- src/combinators/choice.test.ts | 34 +- src/combinators/choice.ts | 25 +- src/combinators/concat.test.ts | 2 +- src/combinators/concat.ts | 4 +- src/combinators/consume.ts | 2 +- src/combinators/endBy.ts | 2 +- src/combinators/endBy1.ts | 2 +- src/combinators/exactly.ts | 12 +- src/combinators/first.test.ts | 4 +- src/combinators/first.ts | 2 +- src/combinators/flag.test.ts | 4 +- src/combinators/flag.ts | 13 +- src/combinators/fold.test.ts | 10 +- src/combinators/fold.ts | 12 +- src/combinators/fold1.test.ts | 6 +- src/combinators/fold1.ts | 11 +- src/combinators/foldRight.test.ts | 10 +- src/combinators/foldRight.ts | 12 +- src/combinators/foldRight1.test.ts | 6 +- src/combinators/foldRight1.ts | 14 +- src/combinators/fuse.ts | 14 +- src/combinators/guard.ts | 10 +- src/combinators/inner.ts | 2 +- src/combinators/interleaved.ts | 16 +- src/combinators/last.test.ts | 4 +- src/combinators/last.ts | 2 +- src/combinators/left.ts | 2 +- src/combinators/lexeme.ts | 2 +- src/combinators/many.test.ts | 4 +- src/combinators/many.ts | 12 +- src/combinators/many1.ts | 13 +- src/combinators/manyAtLeast.ts | 13 +- src/combinators/manyAtMost.ts | 12 +- src/combinators/manyBetween.ts | 12 +- src/combinators/manyTill.ts | 17 +- src/combinators/map.test.ts | 12 +- src/combinators/map.ts | 12 +- src/combinators/node.ts | 4 +- src/combinators/not.ts | 14 +- src/combinators/nth.test.ts | 8 +- src/combinators/nth.ts | 2 +- src/combinators/optional.test.ts | 4 +- src/combinators/optional.ts | 11 +- src/combinators/optionalConsume.test.ts | 4 +- src/combinators/optionalConsume.ts | 11 +- src/combinators/optionalSeparatedBy.ts | 22 +- src/combinators/outer.ts | 2 +- src/combinators/padded.ts | 6 +- src/combinators/parenthesized.ts | 2 +- src/combinators/peek.ts | 9 +- src/combinators/postfix.test.ts | 7 +- src/combinators/postfix.ts | 13 +- src/combinators/prefix.test.ts | 2 +- src/combinators/prefix.ts | 13 +- src/combinators/pure.ts | 2 +- src/combinators/quoted.ts | 2 +- src/combinators/recover.test.ts | 8 +- src/combinators/recover.ts | 9 +- src/combinators/right.ts | 2 +- src/combinators/separatedBy.ts | 19 +- src/combinators/separatedBy1.ts | 8 +- src/combinators/separatedEndBy.ts | 14 +- src/combinators/separatedEndBy1.ts | 15 +- src/combinators/separatedUntil.ts | 12 +- src/combinators/sequence.test.ts | 12 +- src/combinators/sequence.ts | 12 +- src/combinators/skip.ts | 12 +- src/combinators/skipMany.ts | 2 +- src/combinators/skipMany1.ts | 2 +- src/combinators/surrounded.ts | 4 +- src/combinators/unless.ts | 8 +- src/combinators/until.test.ts | 4 +- src/combinators/until.ts | 17 +- src/combinators/validate.ts | 13 +- src/combinators/value.ts | 4 +- src/combinators/when.test.ts | 4 +- src/combinators/when.ts | 11 +- src/core/context.test.ts | 61 ++ src/core/context.ts | 78 ++ src/core/failure.test.ts | 19 +- src/core/failure.ts | 51 +- src/core/grammar.ts | 9 +- src/core/index.ts | 2 +- src/core/label.ts | 23 +- src/core/lazy.test.ts | 10 +- src/core/lazy.ts | 4 +- src/core/match.test.ts | 4 +- src/core/memoize.ts | 36 +- src/core/merge.ts | 60 -- src/core/parse.ts | 8 +- src/core/parser.test.ts | 2 +- src/core/parser.ts | 15 +- src/core/propagation.test.ts | 109 +++ src/core/run.test.ts | 23 +- src/core/run.ts | 46 +- src/core/success.ts | 18 +- src/primitives/alphaNum.ts | 21 +- src/primitives/alphaNums.ts | 9 +- src/primitives/anyChar.ts | 6 +- src/primitives/bool.ts | 6 +- src/primitives/crlf.ts | 2 +- src/primitives/digit.ts | 2 +- src/primitives/digits.ts | 9 +- src/primitives/eof.ts | 6 +- src/primitives/eol.ts | 2 +- src/primitives/float.ts | 6 +- src/primitives/hexDigit.ts | 14 +- src/primitives/hexDigits.ts | 9 +- src/primitives/identifier.ts | 2 +- src/primitives/integer.ts | 6 +- src/primitives/letter.ts | 14 +- src/primitives/letters.ts | 9 +- src/primitives/line.ts | 2 +- src/primitives/literal.ts | 4 +- src/primitives/lowercase.ts | 19 +- src/primitives/lowercases.ts | 9 +- src/primitives/nl.ts | 2 +- src/primitives/number.ts | 8 +- src/primitives/octDigit.ts | 14 +- src/primitives/octDigits.ts | 9 +- src/primitives/position.ts | 4 +- src/primitives/rest.ts | 2 +- src/primitives/space.ts | 2 +- src/primitives/spaces.ts | 9 +- src/primitives/tab.ts | 2 +- src/primitives/uppercase.ts | 19 +- src/primitives/uppercases.ts | 9 +- src/primitives/whitespace.ts | 2 +- src/primitives/whitespaces.ts | 9 +- src/terminals/char.ts | 10 +- src/terminals/charOf.ts | 11 +- src/terminals/noneOf.ts | 12 +- src/terminals/oneOf.ts | 8 +- src/terminals/regex.test.ts | 8 + src/terminals/regex.ts | 20 +- src/terminals/satisfy.ts | 10 +- src/terminals/string.ts | 10 +- src/terminals/stringOf.ts | 10 +- src/terminals/take.ts | 10 +- src/terminals/takeWhile.ts | 2 +- src/terminals/token.ts | 6 +- src/terminals/word.ts | 6 +- src/utils/pipe.test.ts | 8 +- src/utils/pipe.ts | 2 +- test/examples.test.ts | 1093 ++++------------------- test/helpers.ts | 4 +- test/utils.ts | 5 +- 165 files changed, 1631 insertions(+), 1993 deletions(-) create mode 100644 src/core/context.test.ts create mode 100644 src/core/context.ts delete mode 100644 src/core/merge.ts create mode 100644 src/core/propagation.test.ts diff --git a/README.md b/README.md index b0712a8..af78499 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 -- **131 composable exports** — grouped by what they do, indexed below +- **134 composable exports** — grouped by what they do, indexed below - **Mutual recursion out of the box** via `grammar`, no forward declarations - **Errors that point at the problem** — line, column and what was expected @@ -81,7 +81,7 @@ The library is broad on purpose: each function does one small thing, so grammars | 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`. +Most primitives come in a singular and a plural form: `letter` matches one, `letters` matches a run of them. The same holds for `digit`/`digits`, `alphaNum`/`alphaNums`, `hexDigit`/`hexDigits`, `octDigit`/`octDigits`, `lowercase`/`lowercases`, `uppercase`/`uppercases`, `space`/`spaces` and `whitespace`/`whitespaces`. Need to know how far into the input you are? `position`. ### Repeating @@ -138,8 +138,8 @@ Most primitives come in a singular and a plural form: `letter` matches one, `let | attach a readable error message | `label` | | parse without throwing, with line and column | `parse` | | turn an offset into a line and column | `locate` | -| render a failure with a caret under it | `format`, `error` | -| carry a failure trace through a combinator | `merge` | +| render a failure with a caret under it | `format`, `ParseError` | +| record what a hand-written parser expected | `context`, `record`, `union` | | parse infix operators with precedence | `chainLeft`, `chainLeft1`, `chainRight`, `chainRight1` | | parse prefix / postfix operators | `prefix`, `postfix` | | handle both outcomes of a `Result` | `match` | @@ -148,38 +148,27 @@ Most primitives come in a singular and a plural form: `letter` matches one, `let ### The `Parser` type -A `Parser` reads `input` from an offset and returns a `Result`. That is the whole abstraction — everything else in this library either produces one or wraps one. The offset defaults to `0`, so a parser can still be called with just an input. +A `Parser` reads `input` from an offset and returns a `Result`. That is the whole abstraction — everything else in this library either produces one or wraps one. Both extra arguments are optional, so a parser can still be called with just an input. ```typescript -type Parser = (input: string, index?: number) => Result; +type Parser = (input: string, index?: number, ctx?: Context) => Result; ``` Parsers never slice the input. They pass the same string down and move an integer instead, which is what lets an error know where in the original source it happened. +`ctx` is the parse context, created by `run` and `parse`. It is where error information accumulates — see [Error reporting](#error-reporting). If you write your own combinator, pass it on to every parser you call. + ### The `Result` type ```typescript -type Success = { - ok: true; - value: T; - index: number; - furthest: number; - expected: readonly string[]; -}; -type Failure = { - ok: false; - index: number; - furthest: number; - expected: readonly string[]; -}; +type Success = { ok: true; value: T; index: number }; +type Failure = { ok: false; index: number; expected: readonly string[] }; type Result = Success | Failure; ``` ```typescript -{ ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } - │ │ │ │ │ - │ │ │ │ └── what was wanted there - │ │ │ └── furthest offset any branch reached +{ ok: true, value: 'hello', index: 5 } + │ │ │ │ │ └── how far this parser got │ └── the parsed value └── always true for success @@ -188,16 +177,14 @@ type Result = Success | Failure; `index` is the important part: it is how input gets consumed and how parsers chain. A failure reports the same `index`, plus the set of things that would have matched there: ```typescript -{ ok: false, index: 6, furthest: 6, expected: ['digit'] } +{ ok: false, index: 6, expected: ['digit'] } ``` -`furthest` and `expected` are bookkeeping for error messages. A parser that backtracks still remembers the furthest point it reached, so `run` can report _that_ instead of the place the parse happened to stop. You only need them if you write a combinator by hand — `merge` threads them for you. - ### Backtracking is free Combinators hand every alternative the same offset they started with, so a branch that fails can never leave the cursor moved. `choice(a, b)` always offers `b` the same starting offset, however far `a` got. -If you are coming from Parsec, this is why there is no `try`/`attempt` here — backtracking is unconditional, so there is nothing to opt into. What a failed branch _does_ leave behind is its expectation, recorded at the offset it reached, so a later error can still mention it. +If you are coming from Parsec, this is why there is no `try`/`attempt` here — backtracking is unconditional, so there is nothing to opt into. What a failed branch _does_ leave behind is its expectation, recorded in the parse context at the offset it reached, so a later error can still mention it. ### Writing one by hand @@ -206,15 +193,30 @@ Most of the time you compose existing pieces, but nothing stops you from droppin ```typescript import { create, success, failure } from 'unitas'; -const parser = create((input, index = 0) => { +const parser = create((input, index = 0, ctx) => { if (input.startsWith('hello', index)) { return success('hello', index + 5); } - return failure(index, '"hello"'); + return failure(ctx, index, '"hello"'); }); ``` +Passing `ctx` to `failure` is what lets this parser's expectation appear in an error message even when a `choice` backtracks past it. Do the same for any parser you call: + +```typescript +const twice = (parser: Parser) => + create<[T, T]>((input, index = 0, ctx) => { + const first = parser(input, index, ctx); + if (!first.ok) return first; + + const second = parser(input, first.index, ctx); + if (!second.ok) return second; + + return success([first.value, second.value], second.index); + }); +``` + ### Grammars `grammar` hands every rule a proxy `p`, so rules can reference each other — including themselves — without forward declarations or `lazy` boilerplate. @@ -382,24 +384,27 @@ One line per export. Every name links to its full description and runnable examp ### Core — `unitas` -Types, constructors and the grammar runner. **14** exports — [full reference →][api-core] - -| | | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| [`error`][core-error] | Error thrown by {@link run} when a parse fails, carrying the offset, the 1-based line/column and the set of expectations at that offset. | -| [`failure`][core-failure] | Creates a failed result at an offset, describing what was expected there. | -| [`format`][core-format] | Renders a parse failure as a source excerpt with a caret under the offset. | -| [`grammar`][core-grammar] | Creates a recursive grammar where rules can reference each other. | -| [`label`][core-label] | Replaces the expectations of a parser with a single description. | -| [`lazy`][core-lazy] | Defers parser creation, useful for recursive grammars. | -| [`locate`][core-locate] | Converts a character offset into a 1-based line and column. | -| [`match`][core-match] | Pattern matching on a Result to handle success and failure cases. | -| [`memoize`][core-memoize] | Memoizes a parser so each offset is parsed at most once (packrat caching). | -| [`merge`][core-merge] | Carries the furthest-failure trace of an earlier result into a later one. | -| [`parse`][core-parse] | Runs a parser over the whole input without throwing. | -| [`parser`][core-parser] | Creates a parser from a parser function. | -| [`run`][core-run] | Runs a parser over the whole input and returns the value. | -| [`success`][core-success] | Creates a successful result with a value and the offset reached in the input. | +Types, constructors and the grammar runner. **17** exports — [full reference →][api-core] + +| | | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| [`context`][core-context] | Creates a fresh context. `run` and `parse` make one per call. | +| [`create`][core-create] | Creates a parser from a parser function. | +| [`failure`][core-failure] | Creates a failed result at an offset and notes it in the parse context. | +| [`format`][core-format] | Renders a parse failure as a source excerpt with a caret under the offset. | +| [`grammar`][core-grammar] | Creates a recursive grammar where rules can reference each other. | +| [`label`][core-label] | Replaces the expectations of a parser with a single description. | +| [`lazy`][core-lazy] | Defers parser creation, useful for recursive grammars. | +| [`locate`][core-locate] | Converts a character offset into a 1-based line and column. | +| [`match`][core-match] | Pattern matching on a Result to handle success and failure cases. | +| [`memoize`][core-memoize] | Memoizes a parser so each offset is parsed at most once (packrat caching). | +| [`parse`][core-parse] | Runs a parser over the whole input without throwing. | +| [`ParseError`][core-parseerror] | Error thrown by `run` when a parse fails, carrying the offset, the 1-based line/column and the set of expectations at that offset. | +| [`record`][core-record] | Notes what was expected at an offset, keeping only the furthest one seen. | +| [`reject`][core-reject] | Creates a failed result from an expectation list that is already allocated, avoiding a fresh array on every failure. | +| [`run`][core-run] | Runs a parser over the whole input and returns the value. | +| [`success`][core-success] | Creates a successful result with a value and the offset reached in the input. | +| [`union`][core-union] | Adds the entries of `b` to `a`, returning `a` itself when it already covers them. | ### Terminals — `unitas/terminals` @@ -547,7 +552,8 @@ Plain helpers for `map` callbacks. **8** exports — [full reference →][api-ut | [`spread`][utils-spread] | Collect spread arguments into an array. | [api-core]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md -[core-error]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#error +[core-context]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#context +[core-create]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#create [core-failure]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#failure [core-format]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#format [core-grammar]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#grammar @@ -556,11 +562,13 @@ Plain helpers for `map` callbacks. **8** exports — [full reference →][api-ut [core-locate]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#locate [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-merge]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#merge [core-parse]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#parse -[core-parser]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#parser +[core-parseerror]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#parseerror +[core-record]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#record +[core-reject]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#reject [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 +[core-union]: https://github.com/sovrin/unitas/blob/master/doc/api/core.md#union [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 @@ -687,7 +695,7 @@ Plain helpers for `map` callbacks. **8** exports — [full reference →][api-ut `0.3.0` changes how a parser reports where it got to. Parsers now take an offset into the input instead of receiving a sliced suffix, which is what makes line/column error reporting possible. -**If you only compose the built-in parsers, nothing changes.** `run`, `grammar`, `map`, `choice` and everything built on them behave the same, and the offset argument defaults to `0`, so `myParser('input')` still works. +**If you only compose the built-in parsers, nothing changes.** `run`, `grammar`, `map`, `choice` and everything built on them behave the same, and both new arguments are optional, so `myParser('input')` still works. Two things do change for everyone: @@ -714,12 +722,12 @@ create((input) => { }); // 0.3.0 -create((input, index = 0) => { +create((input, index = 0, ctx) => { if (input.startsWith('hello', index)) { return success('hello', index + 5); } - return failure(index, '"hello"'); + return failure(ctx, index, '"hello"'); }); ``` @@ -727,16 +735,17 @@ The rest, in full: | 0.2.x | 0.3.0 | | ---------------------------------- | --------------------------------------------------------------- | -| `Parser = (input) => Result` | `Parser = (input, index?) => Result` | +| `Parser = (input) => Result` | `Parser = (input, index?, ctx?) => Result` | | `success(value, remaining)` | `success(value, index)` | -| `failure()` / `failure(message)` | `failure(index, ...expected)` | -| `{ ok: false, error?: string }` | `{ ok: false, index, furthest, expected }` | +| `failure()` / `failure(message)` | `failure(ctx, index, ...expected)` | +| `{ ok: true, value, remaining }` | `{ ok: true, value, index }` | +| `{ ok: false, error?: string }` | `{ ok: false, index, expected }` | | `match`'s `failure: (error) => …` | `failure: (index, expected) => …` | | `position` → remaining length | `position` → offset from the start | | `label(p, 'x')` → `'expected x'` | `label(p, 'x')` → `expected: ['x']`, only when nothing consumed | -| `regex(/^\w+/)` | same, but sticky — a leading `^` is redundant and stripped | +| `regex(/^\w+/)` | same, but sticky — a leading `^` is stripped unless multiline | -New in this release: `parse` (non-throwing), `ParseError`, `locate`, `format` and `merge`. `memoize` is now keyed by offset rather than by the remaining input, so its cache entries are cheap and are dropped when a different input is parsed. Left-recursive `grammar` rules now throw a named error instead of overflowing the stack. +New in this release: `parse` (non-throwing), `ParseError`, `locate`, `format`, and `context`/`record` for hand-written parsers. `memoize` is now keyed by offset rather than by the remaining input, so its cache entries are cheap and are dropped when a different input is parsed. Left-recursive `grammar` rules now throw a named error instead of overflowing the stack. ## Contributing diff --git a/bin/generate-readme.mjs b/bin/generate-readme.mjs index 1e79e1e..0fb3b1f 100644 --- a/bin/generate-readme.mjs +++ b/bin/generate-readme.mjs @@ -13,10 +13,19 @@ const readmePath = path.join(rootDir, 'README.md'); const COMMENT_REGEX = /\/\*\*([\s\S]*?)\*\//g; +const DECLARATION_REGEX = + /^\s*export\s+(?:const|function|class|let|var)\s+([A-Za-z_$][\w$]*)/; + 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']); +const CHEAT_SHEET_EXEMPT = new Set([ + 'create', + 'failure', + 'reject', + 'run', + 'success', +]); /** * Module metadata. Order here drives the order of the API index in the README @@ -27,44 +36,62 @@ const MODULES = [ key: 'core', title: 'Core', entry: 'unitas', + imports: 'run, parse, grammar', 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`).', + 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', + imports: 'char, string, regex', 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`.', + 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', + imports: 'digit, letters, whitespace', 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)`).', + 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', + imports: 'map, choice, sequence', 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.', + 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', + imports: 'pick, join, pipe', 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.', + 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.', }, ]; +/** + * The exported name a doc block belongs to. One file may document several + * exports, so the file name is not enough — it would collide in the index and + * point every entry at the same anchor. + */ +function declaredName(source) { + const [, name] = source.match(DECLARATION_REGEX) || []; + + return name; +} + +/** + * `{@link foo}` means nothing to a markdown reader, so it becomes code. + */ +function resolveLinks(description) { + return description.replace(/\{@link\s+([^}\s|]+)[^}]*\}/g, '`$1`'); +} + function extractDocs(filePath) { const content = fs.readFileSync(filePath, 'utf-8'); const docs = []; @@ -98,10 +125,13 @@ function extractDocs(filePath) { } if (exampleLines.length > 0) { + const after = match.index + match[0].length; docs.push({ - description: descriptionParts.join(' '), + description: resolveLinks(descriptionParts.join(' ')), example: exampleLines.join('\n'), - name: basename(filePath, '.ts'), + name: + declaredName(content.slice(after)) ?? + basename(filePath, '.ts'), }); } } @@ -204,6 +234,7 @@ function generateApiPage(module, items) { return template .replaceAll('<$title>', module.title) .replaceAll('<$entry>', module.entry) + .replaceAll('<$imports>', module.imports) .replaceAll('<$intro>', module.intro) .replaceAll('<$count>', String(items.length)) .replaceAll('<$nav>', nav) @@ -294,8 +325,7 @@ function warnAboutCheatSheetGaps(template, docsByNamespace) { (docsByNamespace[key] || []) .map(({ name }) => name) .filter( - (name) => - !mentioned.has(name) && !CHEAT_SHEET_EXEMPT.has(name), + (name) => !mentioned.has(name) && !CHEAT_SHEET_EXEMPT.has(name), ), ); diff --git a/doc/README.template.md b/doc/README.template.md index 1fcf597..fb9a99e 100644 --- a/doc/README.template.md +++ b/doc/README.template.md @@ -53,13 +53,13 @@ run(csv.row, '"a,b",c'); // ['a,b', 'c'] 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`, … | +| 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? @@ -67,108 +67,108 @@ The library is broad on purpose: each function does one small thing, so grammars ### 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` | +| 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` | +| 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`. +Most primitives come in a singular and a plural form: `letter` matches one, `letters` matches a run of them. The same holds for `digit`/`digits`, `alphaNum`/`alphaNums`, `hexDigit`/`hexDigits`, `octDigit`/`octDigits`, `lowercase`/`lowercases`, `uppercase`/`uppercases`, `space`/`spaces` and `whitespace`/`whitespaces`. Need to know how far into the input you are? `position`. ### Repeating -| I want to… | Reach for | -| -------------------------------------------- | ---------------------------------------------------- | -| repeat zero or more / one or more times | `many`, `many1` | -| repeat a bounded number of times | `exactly`, `manyAtLeast`, `manyAtMost`, `manyBetween` | -| repeat with a separator between items | `separatedBy`, `separatedBy1`, `optionalSeparatedBy` | -| …and allow a trailing separator | `separatedEndBy`, `separatedEndBy1` | -| …where every item ends with the separator | `endBy`, `endBy1`, `interleaved` | -| repeat until something else shows up | `manyTill`, `until`, `separatedUntil` | -| repeat but throw the values away | `skip`, `skipMany`, `skipMany1` | -| reduce the repetitions as I go | `fold`, `fold1`, `foldRight`, `foldRight1` | +| 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` | +| I want to… | Reach for | +| ------------------------------------------- | ------------------------------------------------ | +| take the first alternative that matches | `choice` | +| succeed either way | `optional`, `optionalConsume`, `recover`, `flag` | +| succeed only when something _doesn't_ match | `not` | +| gate on a boolean I already have | `guard`, `unless`, `when` | +| look ahead without consuming input | `peek` | ### Sequencing and pulling values out -| I want to… | Reach for | -| -------------------------------------------- | ---------------------------------------------------- | -| run parsers in order and keep every result | `sequence` | -| keep only one side of a pair | `left`, `right` | -| keep specific positions of a sequence | `first`, `last`, `nth`, `pick` | -| drop the delimiters, keep the middle | `inner`, `outer` | -| glue string results together | `concat`, `fuse`, `join` | -| match content inside delimiters | `surrounded`, `parenthesized`, `braced`, `bracketed`, `quoted`, `padded` | +| 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` | +| I want to… | Reach for | +| ------------------------------------------ | ----------------------------------------------------- | +| transform the parsed value | `map`, `pipe` | +| replace it with a constant | `value`, `pure`, `consume` | +| build a typed AST node | `node` | +| reject a value after parsing it | `validate` | +| choose the next parser from the last value | `bind` | +| tidy up arrays inside a `map` | `pick`, `filter`, `flatten`, `shift`, `pop`, `spread` | ### Grammars, recursion and errors -| I want to… | Reach for | -| -------------------------------------------- | ---------------------------------------------------- | -| let rules reference each other by name | `grammar` | -| refer to a parser defined further down | `lazy` | -| cache results and avoid exponential blowup | `memoize` | -| attach a readable error message | `label` | -| parse without throwing, with line and column | `parse` | -| turn an offset into a line and column | `locate` | -| render a failure with a caret under it | `format`, `error` | -| carry a failure trace through a combinator | `merge` | +| I want to… | Reach for | +| -------------------------------------------- | ------------------------------------------------------ | +| let rules reference each other by name | `grammar` | +| refer to a parser defined further down | `lazy` | +| cache results and avoid exponential blowup | `memoize` | +| attach a readable error message | `label` | +| parse without throwing, with line and column | `parse` | +| turn an offset into a line and column | `locate` | +| render a failure with a caret under it | `format`, `ParseError` | +| record what a hand-written parser expected | `context`, `record`, `union` | | parse infix operators with precedence | `chainLeft`, `chainLeft1`, `chainRight`, `chainRight1` | -| parse prefix / postfix operators | `prefix`, `postfix` | -| handle both outcomes of a `Result` | `match` | +| parse prefix / postfix operators | `prefix`, `postfix` | +| handle both outcomes of a `Result` | `match` | ## Core concepts ### The `Parser` type -A `Parser` reads `input` from an offset and returns a `Result`. That is the whole abstraction — everything else in this library either produces one or wraps one. The offset defaults to `0`, so a parser can still be called with just an input. +A `Parser` reads `input` from an offset and returns a `Result`. That is the whole abstraction — everything else in this library either produces one or wraps one. Both extra arguments are optional, so a parser can still be called with just an input. ```typescript -type Parser = (input: string, index?: number) => Result; +type Parser = (input: string, index?: number, ctx?: Context) => Result; ``` Parsers never slice the input. They pass the same string down and move an integer instead, which is what lets an error know where in the original source it happened. +`ctx` is the parse context, created by `run` and `parse`. It is where error information accumulates — see [Error reporting](#error-reporting). If you write your own combinator, pass it on to every parser you call. + ### The `Result` type ```typescript -type Success = { ok: true; value: T; index: number; furthest: number; expected: readonly string[] }; -type Failure = { ok: false; index: number; furthest: number; expected: readonly string[] }; +type Success = { ok: true; value: T; index: number }; +type Failure = { ok: false; index: number; expected: readonly string[] }; type Result = Success | Failure; ``` ```typescript -{ ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } - │ │ │ │ │ - │ │ │ │ └── what was wanted there - │ │ │ └── furthest offset any branch reached +{ ok: true, value: 'hello', index: 5 } + │ │ │ │ │ └── how far this parser got │ └── the parsed value └── always true for success @@ -177,16 +177,14 @@ type Result = Success | Failure; `index` is the important part: it is how input gets consumed and how parsers chain. A failure reports the same `index`, plus the set of things that would have matched there: ```typescript -{ ok: false, index: 6, furthest: 6, expected: ['digit'] } +{ ok: false, index: 6, expected: ['digit'] } ``` -`furthest` and `expected` are bookkeeping for error messages. A parser that backtracks still remembers the furthest point it reached, so `run` can report *that* instead of the place the parse happened to stop. You only need them if you write a combinator by hand — `merge` threads them for you. - ### Backtracking is free Combinators hand every alternative the same offset they started with, so a branch that fails can never leave the cursor moved. `choice(a, b)` always offers `b` the same starting offset, however far `a` got. -If you are coming from Parsec, this is why there is no `try`/`attempt` here — backtracking is unconditional, so there is nothing to opt into. What a failed branch *does* leave behind is its expectation, recorded at the offset it reached, so a later error can still mention it. +If you are coming from Parsec, this is why there is no `try`/`attempt` here — backtracking is unconditional, so there is nothing to opt into. What a failed branch _does_ leave behind is its expectation, recorded in the parse context at the offset it reached, so a later error can still mention it. ### Writing one by hand @@ -195,15 +193,30 @@ Most of the time you compose existing pieces, but nothing stops you from droppin ```typescript import { create, success, failure } from 'unitas'; -const parser = create((input, index = 0) => { +const parser = create((input, index = 0, ctx) => { if (input.startsWith('hello', index)) { return success('hello', index + 5); } - return failure(index, '"hello"'); + return failure(ctx, index, '"hello"'); }); ``` +Passing `ctx` to `failure` is what lets this parser's expectation appear in an error message even when a `choice` backtracks past it. Do the same for any parser you call: + +```typescript +const twice = (parser: Parser) => + create<[T, T]>((input, index = 0, ctx) => { + const first = parser(input, index, ctx); + if (!first.ok) return first; + + const second = parser(input, first.index, ctx); + if (!second.ok) return second; + + return success([first.value, second.value], second.index); + }); +``` + ### Grammars `grammar` hands every rule a proxy `p`, so rules can reference each other — including themselves — without forward declarations or `lazy` boilerplate. @@ -253,7 +266,7 @@ run(config, 'host=1\nport=8080\ndebug=yes'); // | ^ ``` -The position is the *furthest* offset any branch reached, not wherever the parse happened to stop. That distinction is what makes the message useful: `separatedBy` succeeded with two pairs and left the rest unconsumed, but the error still points at the `y` that actually broke it. +The position is the _furthest_ offset any branch reached, not wherever the parse happened to stop. That distinction is what makes the message useful: `separatedBy` succeeded with two pairs and left the rest unconsumed, but the error still points at the `y` that actually broke it. Alternatives are reported together, since `choice` unions the expectations of every branch it tried at the same offset: @@ -272,10 +285,10 @@ import { parse } from 'unitas'; const result = parse(config, 'host=1\nport=oops'); if (!result.ok) { - result.line; // 2 - result.column; // 6 + result.line; // 2 + result.column; // 6 result.expected; // ['digit'] - result.message; // the formatted excerpt above + result.message; // the formatted excerpt above } ``` @@ -375,7 +388,7 @@ One line per export. Every name links to its full description and runnable examp `0.3.0` changes how a parser reports where it got to. Parsers now take an offset into the input instead of receiving a sliced suffix, which is what makes line/column error reporting possible. -**If you only compose the built-in parsers, nothing changes.** `run`, `grammar`, `map`, `choice` and everything built on them behave the same, and the offset argument defaults to `0`, so `myParser('input')` still works. +**If you only compose the built-in parsers, nothing changes.** `run`, `grammar`, `map`, `choice` and everything built on them behave the same, and both new arguments are optional, so `myParser('input')` still works. Two things do change for everyone: @@ -402,29 +415,30 @@ create((input) => { }); // 0.3.0 -create((input, index = 0) => { +create((input, index = 0, ctx) => { if (input.startsWith('hello', index)) { return success('hello', index + 5); } - return failure(index, '"hello"'); + return failure(ctx, index, '"hello"'); }); ``` The rest, in full: -| 0.2.x | 0.3.0 | -| --------------------------------- | ----------------------------------------------------------- | -| `Parser = (input) => Result` | `Parser = (input, index?) => Result` | -| `success(value, remaining)` | `success(value, index)` | -| `failure()` / `failure(message)` | `failure(index, ...expected)` | -| `{ ok: false, error?: string }` | `{ ok: false, index, furthest, expected }` | -| `match`'s `failure: (error) => …` | `failure: (index, expected) => …` | -| `position` → remaining length | `position` → offset from the start | -| `label(p, 'x')` → `'expected x'` | `label(p, 'x')` → `expected: ['x']`, only when nothing consumed | -| `regex(/^\w+/)` | same, but sticky — a leading `^` is redundant and stripped | - -New in this release: `parse` (non-throwing), `ParseError`, `locate`, `format` and `merge`. `memoize` is now keyed by offset rather than by the remaining input, so its cache entries are cheap and are dropped when a different input is parsed. Left-recursive `grammar` rules now throw a named error instead of overflowing the stack. +| 0.2.x | 0.3.0 | +| ---------------------------------- | --------------------------------------------------------------- | +| `Parser = (input) => Result` | `Parser = (input, index?, ctx?) => Result` | +| `success(value, remaining)` | `success(value, index)` | +| `failure()` / `failure(message)` | `failure(ctx, index, ...expected)` | +| `{ ok: true, value, remaining }` | `{ ok: true, value, index }` | +| `{ ok: false, error?: string }` | `{ ok: false, index, expected }` | +| `match`'s `failure: (error) => …` | `failure: (index, expected) => …` | +| `position` → remaining length | `position` → offset from the start | +| `label(p, 'x')` → `'expected x'` | `label(p, 'x')` → `expected: ['x']`, only when nothing consumed | +| `regex(/^\w+/)` | same, but sticky — a leading `^` is stripped unless multiline | + +New in this release: `parse` (non-throwing), `ParseError`, `locate`, `format`, and `context`/`record` for hand-written parsers. `memoize` is now keyed by offset rather than by the remaining input, so its cache entries are cheap and are dropped when a different input is parsed. Left-recursive `grammar` rules now throw a named error instead of overflowing the stack. ## Contributing diff --git a/doc/api.template.md b/doc/api.template.md index 19e1ba6..18985ac 100644 --- a/doc/api.template.md +++ b/doc/api.template.md @@ -7,7 +7,7 @@ <$intro> ```typescript -import { /* … */ } from '<$entry>'; +import { <$imports> } from '<$entry>'; ``` ## Index diff --git a/doc/api/combinators.md b/doc/api/combinators.md index 2c9979e..3f73864 100644 --- a/doc/api/combinators.md +++ b/doc/api/combinators.md @@ -7,7 +7,7 @@ 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'; +import { map, choice, sequence } from 'unitas/combinators'; ``` ## Index @@ -21,7 +21,7 @@ import {} from /* … */ 'unitas/combinators'; Chain parsers where the second parser depends on the first result. ```typescript -bind(digits, (n) => take(n))('3abc'); // { ok: true, value: 'abc', index: 4, furthest: -1, expected: [] } +bind(digits, (n) => take(n))('3abc'); // { ok: true, value: 'abc', index: 4 } ``` ### `braced` @@ -29,7 +29,7 @@ bind(digits, (n) => take(n))('3abc'); // { ok: true, value: 'abc', index: 4, fur Parse content surrounded by braces. ```typescript -braced(string('hi'))('{hi}'); // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } +braced(string('hi'))('{hi}'); // { ok: true, value: 'hi', index: 4 } ``` ### `bracketed` @@ -37,7 +37,7 @@ braced(string('hi'))('{hi}'); // { ok: true, value: 'hi', index: 4, furthest: -1 Parse content surrounded by brackets. ```typescript -bracketed(string('hi'))('[hi]'); // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } +bracketed(string('hi'))('[hi]'); // { ok: true, value: 'hi', index: 4 } ``` ### `chainLeft` @@ -45,7 +45,7 @@ bracketed(string('hi'))('[hi]'); // { ok: true, value: 'hi', index: 4, furthest: Chain left-associative operations (right-to-left for same precedence). ```typescript -chainLeft(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } +chainLeft(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5 } ``` ### `chainLeft1` @@ -53,8 +53,8 @@ chainLeft(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5, furthe Chain left-associative operations (fails on empty input). ```typescript -chainLeft1(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } -chainLeft1(digits, operation)('8/2*3'); // { ok: true, value: 12, index: 5, furthest: 5, expected: ['operator'] } +chainLeft1(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5 } +chainLeft1(digits, operation)('8/2*3'); // { ok: true, value: 12, index: 5 } ``` ### `chainRight` @@ -62,7 +62,7 @@ chainLeft1(digits, operation)('8/2*3'); // { ok: true, value: 12, index: 5, furt Chain right-associative operations. ```typescript -chainRight(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } +chainRight(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5 } ``` ### `chainRight1` @@ -70,15 +70,15 @@ chainRight(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5, furth Chain right-associative operations (fails on empty input). ```typescript -chainRight1(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } +chainRight1(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5 } ``` ### `choice` -Try each parser in order, return first success. Expectations from every alternative tried at the same offset are unioned, so a failing alternation reports all of them rather than only the last. +Try each parser in order, return first success. Alternatives all start from the same offset, so the expectations of those that failed there are unioned into the result. Anything they recorded deeper in the input is already in the parse context. ```typescript -choice(string('hello'), string('world'))('hello'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } +choice(string('hello'), string('world'))('hello'); // { ok: true, value: 'hello', index: 5 } ``` ### `concat` @@ -86,8 +86,8 @@ choice(string('hello'), string('world'))('hello'); // { ok: true, value: 'hello' Join string array parser result into a single string. ```typescript -concat(many(letter))('abc123'); // { ok: true, value: 'abc', index: 3, furthest: 3, expected: ['letter'] } -concat(many(letter), '-')('abc123'); // { ok: true, value: 'a-b-c', index: 3, furthest: 3, expected: ['letter'] } +concat(many(letter))('abc123'); // { ok: true, value: 'abc', index: 3 } +concat(many(letter), '-')('abc123'); // { ok: true, value: 'a-b-c', index: 3 } ``` ### `consume` @@ -95,7 +95,7 @@ concat(many(letter), '-')('abc123'); // { ok: true, value: 'a-b-c', index: 3, fu Consume input but discard the result (return null). ```typescript -consume(string('hello'))('hello world'); // { ok: true, value: null, index: 5, furthest: -1, expected: [] } +consume(string('hello'))('hello world'); // { ok: true, value: null, index: 5 } ``` ### `endBy` @@ -103,7 +103,7 @@ consume(string('hello'))('hello world'); // { ok: true, value: null, index: 5, f Zero or more items separated and ending with terminator. ```typescript -endBy(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['item', 'item', 'item'], index: 15, furthest: 15, expected: ["'item'"] } +endBy(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['item', 'item', 'item'], index: 15 } ``` ### `endBy1` @@ -111,7 +111,7 @@ endBy(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['ite One or more items separated and ending with terminator. ```typescript -endBy1(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['item', 'item', 'item'], index: 15, furthest: 15, expected: ["'item'"] } +endBy1(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['item', 'item', 'item'], index: 15 } ``` ### `exactly` @@ -119,7 +119,7 @@ endBy1(string('item'), char(';'))('item;item;item;'); // { ok: true, value: ['it Parse exactly n occurrences. ```typescript -exactly(char('a'), 3)('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: -1, expected: [] } +exactly(char('a'), 3)('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3 } ``` ### `first` @@ -127,7 +127,7 @@ exactly(char('a'), 3)('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3, f Extract the first element from a parser result array. ```typescript -first(sequence(char('a'), digit))('a1bc'); // { ok: true, value: 'a', index: 2, furthest: -1, expected: [] } +first(sequence(char('a'), digit))('a1bc'); // { ok: true, value: 'a', index: 2 } ``` ### `flag` @@ -135,8 +135,8 @@ first(sequence(char('a'), digit))('a1bc'); // { ok: true, value: 'a', index: 2, Return true if parser succeeds, false otherwise. Always succeeds without consuming input on failure. ```typescript -flag(string('*'))('*abc'); // { ok: true, value: true, index: 1, furthest: -1, expected: [] } -flag(string('*'))('abc'); // { ok: true, value: false, index: 0, furthest: 0, expected: ["'*'"] } +flag(string('*'))('*abc'); // { ok: true, value: true, index: 1 } +flag(string('*'))('abc'); // { ok: true, value: false, index: 0 } ``` ### `fold` @@ -144,7 +144,7 @@ flag(string('*'))('abc'); // { ok: true, value: false, index: 0, furthest: 0, ex Fold zero or more occurrences into a single value. ```typescript -fold(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } +fold(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3 } ``` ### `fold1` @@ -152,7 +152,7 @@ fold(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3, f Fold one or more occurrences into a single value. ```typescript -fold1(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } +fold1(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3 } ``` ### `foldRight` @@ -160,7 +160,7 @@ fold1(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3, Fold zero or more occurrences from the right into a single value. ```typescript -foldRight(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } +foldRight(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3 } ``` ### `foldRight1` @@ -168,7 +168,7 @@ foldRight(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: Fold one or more occurrences from the right into a single value. ```typescript -foldRight1(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3, furthest: 3, expected: ['digit'] } +foldRight1(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index: 3 } ``` ### `fuse` @@ -176,8 +176,8 @@ foldRight1(digit, 0, (acc, d) => acc + d)('123'); // { ok: true, value: 6, index 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', index: 3, furthest: -1, expected: [] } -fuse(string('hello'), char(' '), string('world'))('hello world'); // { ok: true, value: 'hello world', index: 11, furthest: -1, expected: [] } +fuse(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: 'abc', index: 3 } +fuse(string('hello'), char(' '), string('world'))('hello world'); // { ok: true, value: 'hello world', index: 11 } ``` ### `guard` @@ -185,8 +185,8 @@ fuse(string('hello'), char(' '), string('world'))('hello world'); // { ok: true, Conditionally apply parser based on a condition. ```typescript -guard(true, string('hello'))('hello'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } -guard(false, string('hello'))('hello'); // { ok: false, index: 0, furthest: 0, expected: [] } +guard(true, string('hello'))('hello'); // { ok: true, value: 'hello', index: 5 } +guard(false, string('hello'))('hello'); // { ok: false, index: 0, expected: [] } ``` ### `inner` @@ -194,7 +194,7 @@ guard(false, string('hello'))('hello'); // { ok: false, index: 0, furthest: 0, e Extract inner value from surrounded content (like inner of braced). ```typescript -inner(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } +inner(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: 'hi', index: 4 } ``` ### `interleaved` @@ -202,7 +202,7 @@ inner(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: 'hi', i Parse items separated by separators, keeping both in the result. ```typescript -interleaved(digits, char('+'))('1+2'); // { ok: true, value: [1, '+', 2], index: 3, furthest: 3, expected: ["'+'"] } +interleaved(digits, char('+'))('1+2'); // { ok: true, value: [1, '+', 2], index: 3 } ``` ### `last` @@ -210,7 +210,7 @@ interleaved(digits, char('+'))('1+2'); // { ok: true, value: [1, '+', 2], index: Extract the last element from a parser result array. ```typescript -last(sequence(char('a'), char('b')))('ab'); // { ok: true, value: 'b', index: 2, furthest: -1, expected: [] } +last(sequence(char('a'), char('b')))('ab'); // { ok: true, value: 'b', index: 2 } ``` ### `left` @@ -218,7 +218,7 @@ last(sequence(char('a'), char('b')))('ab'); // { ok: true, value: 'b', index: 2, Keep only the left result from a sequence. ```typescript -left(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'hello', index: 10, furthest: -1, expected: [] } +left(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'hello', index: 10 } ``` ### `lexeme` @@ -226,7 +226,7 @@ left(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'hel Parser that consumes trailing whitespace. ```typescript -lexeme(string('hello'))('hello world'); // { ok: true, value: 'hello', index: 8, furthest: -1, expected: [] } +lexeme(string('hello'))('hello world'); // { ok: true, value: 'hello', index: 8 } ``` ### `many` @@ -234,7 +234,7 @@ lexeme(string('hello'))('hello world'); // { ok: true, value: 'hello', index: Zero or more occurrences (never fails). The failure that stopped the loop is kept as a trace, so a later error can still report what the repetition was expecting next. ```typescript -many(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 3, expected: ["'a'"] } +many(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3 } ``` ### `many1` @@ -242,7 +242,7 @@ many(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3, furthes One or more occurrences. ```typescript -many1(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 3, expected: ["'a'"] } +many1(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3 } ``` ### `manyAtLeast` @@ -250,7 +250,7 @@ many1(char('a'))('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3, furthe Parse at least n occurrences. ```typescript -manyAtLeast(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 3, expected: ["'a'"] } +manyAtLeast(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: 3 } ``` ### `manyAtMost` @@ -258,7 +258,7 @@ manyAtLeast(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a', 'a'], index: Parse at most n occurrences (never fails). ```typescript -manyAtMost(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a'], index: 2, furthest: -1, expected: [] } +manyAtMost(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a'], index: 2 } ``` ### `manyBetween` @@ -266,7 +266,7 @@ manyAtMost(char('a'), 2)('aaa'); // { ok: true, value: ['a', 'a'], index: 2, fur Parse between min and max occurrences. ```typescript -manyBetween(char('a'), 1, 2)('aaa'); // { ok: true, value: ['a', 'a'], index: 2, furthest: -1, expected: [] } +manyBetween(char('a'), 1, 2)('aaa'); // { ok: true, value: ['a', 'a'], index: 2 } ``` ### `manyTill` @@ -274,7 +274,7 @@ manyBetween(char('a'), 1, 2)('aaa'); // { ok: true, value: ['a', 'a'], index: 2, Parse zero or more until terminator matches. ```typescript -manyTill(char('a'), char('b'))('aaab'); // { ok: true, value: ['a', 'a', 'a'], index: 4, furthest: 2, expected: ["'b'"] } +manyTill(char('a'), char('b'))('aaab'); // { ok: true, value: ['a', 'a', 'a'], index: 4 } ``` ### `map` @@ -282,7 +282,7 @@ manyTill(char('a'), char('b'))('aaab'); // { ok: true, value: ['a', 'a', 'a'], i Transform a parsed value through one or more functions. ```typescript -map(digits, (n) => n * 2)('21'); // { ok: true, value: 42, index: 2, furthest: -1, expected: [] } +map(digits, (n) => n * 2)('21'); // { ok: true, value: 42, index: 2 } ``` ### `node` @@ -290,16 +290,16 @@ map(digits, (n) => n * 2)('21'); // { ok: true, value: 42, index: 2, furthest: - 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 }, index: 3, furthest: -1, expected: [] } -node('number', { value: digits })('123'); // { ok: true, value: { type: 'number', value: 123 }, index: 3, furthest: -1, expected: [] } +node('binop', { left: digits, op: char('+'), right: digits })('1+2'); // { ok: true, value: { type: 'binop', left: 1, op: '+', right: 2 }, index: 3 } +node('number', { value: digits })('123'); // { ok: true, value: { type: 'number', value: 123 }, index: 3 } ``` ### `not` -Succeed if parser fails (without consuming input). The inner failure is deliberately not traced: it is the expected outcome, so reporting it would produce a misleading expectation. +Succeed if parser fails (without consuming input). The inner parser runs against an isolated context: its failure is the expected outcome here, so reporting it would produce a misleading expectation in the final message. ```typescript -not(string('hello'))('world'); // { ok: true, value: null, index: 0, furthest: -1, expected: [] } +not(string('hello'))('world'); // { ok: true, value: null, index: 0 } ``` ### `nth` @@ -307,7 +307,7 @@ not(string('hello'))('world'); // { ok: true, value: null, index: 0, furthest: - Extract the nth element from a parser result array. ```typescript -nth(sequence(char('a'), char('b'), char('c')), 1)('abc'); // { ok: true, value: 'b', index: 3, furthest: -1, expected: [] } +nth(sequence(char('a'), char('b'), char('c')), 1)('abc'); // { ok: true, value: 'b', index: 3 } ``` ### `optional` @@ -315,8 +315,8 @@ nth(sequence(char('a'), char('b'), char('c')), 1)('abc'); // { ok: true, value: Make parser optional (return null on failure, without consuming input). ```typescript -optional(string('hello'))('hello'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } -optional(string('hello'))('world'); // { ok: true, value: null, index: 0, furthest: 0, expected: ["'hello'"] } +optional(string('hello'))('hello'); // { ok: true, value: 'hello', index: 5 } +optional(string('hello'))('world'); // { ok: true, value: null, index: 0 } ``` ### `optionalConsume` @@ -324,7 +324,7 @@ optional(string('hello'))('world'); // { ok: true, value: null, index: 0, furthe Consume input if the parser matches, discarding the result. ```typescript -optionalConsume(string('hi'))('hi there'); // { ok: true, index: 2, furthest: -1, expected: [] } +optionalConsume(string('hi'))('hi there'); // { ok: true, index: 2 } ``` ### `optionalSeparatedBy` @@ -332,7 +332,7 @@ optionalConsume(string('hi'))('hi there'); // { ok: true, index: 2, furthest: -1 Parse items separated by a separator, allowing empty slots. ```typescript -optionalSeparatedBy(digits, char(','))('1,2'); // { ok: true, value: [1, 2], index: 3, furthest: 3, expected: ["','"] } +optionalSeparatedBy(digits, char(','))('1,2'); // { ok: true, value: [1, 2], index: 3 } ``` ### `outer` @@ -340,7 +340,7 @@ optionalSeparatedBy(digits, char(','))('1,2'); // { ok: true, value: [1, 2], ind Extract outer values from a sequence of 3 parsers (skip middle). ```typescript -outer(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: ['(', ')'], index: 4, furthest: -1, expected: [] } +outer(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: ['(', ')'], index: 4 } ``` ### `padded` @@ -348,7 +348,7 @@ outer(char('('), string('hi'), char(')'))('(hi)'); // { ok: true, value: ['(', ' Parse content surrounded by optional whitespace. ```typescript -padded(string('hi'))(' hi '); // { ok: true, value: 'hi', index: 6, furthest: 6, expected: ['whitespace'] } +padded(string('hi'))(' hi '); // { ok: true, value: 'hi', index: 6 } ``` ### `parenthesized` @@ -356,7 +356,7 @@ padded(string('hi'))(' hi '); // { ok: true, value: 'hi', index: 6, furthest: Parse content surrounded by parentheses. ```typescript -parenthesized(string('hi'))('(hi)'); // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } +parenthesized(string('hi'))('(hi)'); // { ok: true, value: 'hi', index: 4 } ``` ### `peek` @@ -364,7 +364,7 @@ parenthesized(string('hi'))('(hi)'); // { ok: true, value: 'hi', index: 4, furth Look ahead without consuming input. ```typescript -peek(string('hello'))('hello world'); // { ok: true, value: 'hello', index: 0, furthest: -1, expected: [] } +peek(string('hello'))('hello world'); // { ok: true, value: 'hello', index: 0 } ``` ### `postfix` @@ -375,7 +375,7 @@ Apply zero or more postfix operators to an atom. postfix( digits, map(char('!'), () => (n) => n * 2), -)('3!'); // { ok: true, value: 6, index: 2, furthest: 2, expected: ["'!'"] } +)('3!'); // { ok: true, value: 6, index: 2 } ``` ### `prefix` @@ -386,7 +386,7 @@ Apply zero or more prefix operators to an atom. prefix( map(char('-'), () => (n) => -n), digits, -)('-3'); // { ok: true, value: -3, index: 2, furthest: 1, expected: ["'-'"] } +)('-3'); // { ok: true, value: -3, index: 2 } ``` ### `pure` @@ -394,7 +394,7 @@ prefix( Always succeed with a value without consuming input. ```typescript -pure(42)('abc'); // { ok: true, value: 42, index: 0, furthest: -1, expected: [] } +pure(42)('abc'); // { ok: true, value: 42, index: 0 } ``` ### `quoted` @@ -402,7 +402,7 @@ pure(42)('abc'); // { ok: true, value: 42, index: 0, furthest: -1, expected: [] Parse content surrounded by single or double quotes. ```typescript -quoted(string('hello'))('"hello"'); // { ok: true, value: 'hello', index: 7, furthest: -1, expected: [] } +quoted(string('hello'))('"hello"'); // { ok: true, value: 'hello', index: 7 } ``` ### `recover` @@ -410,7 +410,7 @@ quoted(string('hello'))('"hello"'); // { ok: true, value: 'hello', index: 7, fur Use fallback value when parser fails. ```typescript -recover(string('hello'), 'default')('world'); // { ok: true, value: 'default', index: 0, furthest: 0, expected: ["'hello'"] } +recover(string('hello'), 'default')('world'); // { ok: true, value: 'default', index: 0 } ``` ### `right` @@ -418,7 +418,7 @@ recover(string('hello'), 'default')('world'); // { ok: true, value: 'default', i Keep only the right result from a sequence. ```typescript -right(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'world', index: 10, furthest: -1, expected: [] } +right(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'world', index: 10 } ``` ### `separatedBy` @@ -426,7 +426,7 @@ right(string('hello'), string('world'))('helloworld'); // { ok: true, value: 'wo Parse zero or more items separated by a separator. A trailing separator is not consumed: if the separator matches but the item after it does not, the list ends before the separator. ```typescript -separatedBy(digits, char(','))('1,2,3'); // { ok: true, value: [1, 2, 3], index: 5, furthest: 5, expected: ["','"] } +separatedBy(digits, char(','))('1,2,3'); // { ok: true, value: [1, 2, 3], index: 5 } ``` ### `separatedBy1` @@ -434,7 +434,7 @@ separatedBy(digits, char(','))('1,2,3'); // { ok: true, value: [1, 2, 3], index: Parse one or more items separated by a separator. ```typescript -separatedBy1(digits, char(','))('1,2,3'); // { ok: true, value: [1, 2, 3], index: 5, furthest: 5, expected: ["','"] } +separatedBy1(digits, char(','))('1,2,3'); // { ok: true, value: [1, 2, 3], index: 5 } ``` ### `separatedEndBy` @@ -442,7 +442,7 @@ separatedBy1(digits, char(','))('1,2,3'); // { ok: true, value: [1, 2, 3], index Parse zero or more items separated by a separator, allowing a trailing one. ```typescript -separatedEndBy(digits, char(','))('1,2,3,'); // { ok: true, value: [1, 2, 3], index: 6, furthest: 6, expected: ['digit'] } +separatedEndBy(digits, char(','))('1,2,3,'); // { ok: true, value: [1, 2, 3], index: 6 } ``` ### `separatedEndBy1` @@ -450,7 +450,7 @@ separatedEndBy(digits, char(','))('1,2,3,'); // { ok: true, value: [1, 2, 3], in Parse one or more items separated by a separator, allowing a trailing one. ```typescript -separatedEndBy1(digits, char(','))('1,2,3,'); // { ok: true, value: [1, 2, 3], index: 6, furthest: 6, expected: ['digit'] } +separatedEndBy1(digits, char(','))('1,2,3,'); // { ok: true, value: [1, 2, 3], index: 6 } ``` ### `separatedUntil` @@ -458,7 +458,7 @@ separatedEndBy1(digits, char(','))('1,2,3,'); // { ok: true, value: [1, 2, 3], i Parse items separated by a separator, up to a terminator. ```typescript -separatedUntil(digits, char(','), char(';'))('1,2,3;'); // { ok: true, value: [1, 2, 3], index: 6, furthest: 5, expected: ["','"] } +separatedUntil(digits, char(','), char(';'))('1,2,3;'); // { ok: true, value: [1, 2, 3], index: 6 } ``` ### `sequence` @@ -466,7 +466,7 @@ separatedUntil(digits, char(','), char(';'))('1,2,3;'); // { ok: true, value: [1 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'], index: 3, furthest: -1, expected: [] } +sequence(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: ['a', 'b', 'c'], index: 3 } ``` ### `skip` @@ -474,7 +474,7 @@ sequence(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: ['a', 'b Skip a parser n times. ```typescript -skip(char('a'), 2)('aabc'); // { ok: true, value: null, index: 2, furthest: -1, expected: [] } +skip(char('a'), 2)('aabc'); // { ok: true, value: null, index: 2 } ``` ### `skipMany` @@ -482,7 +482,7 @@ skip(char('a'), 2)('aabc'); // { ok: true, value: null, index: 2, furthest: -1, Skip zero or more occurrences (never fails, returns null). ```typescript -skipMany(char('a'))('aaabc'); // { ok: true, value: null, index: 3, furthest: 3, expected: ["'a'"] } +skipMany(char('a'))('aaabc'); // { ok: true, value: null, index: 3 } ``` ### `skipMany1` @@ -490,7 +490,7 @@ skipMany(char('a'))('aaabc'); // { ok: true, value: null, index: 3, furthest: 3, Skip one or more occurrences (fails if no matches). ```typescript -skipMany1(char('a'))('aaabc'); // { ok: true, value: null, index: 3, furthest: 3, expected: ["'a'"] } +skipMany1(char('a'))('aaabc'); // { ok: true, value: null, index: 3 } ``` ### `surrounded` @@ -498,8 +498,8 @@ skipMany1(char('a'))('aaabc'); // { ok: true, value: null, index: 3, furthest: 3 Parse content surrounded by delimiters. ```typescript -surrounded(char('['), string('hi'), char(']'))('[hi]'); // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } -surrounded(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: 'b', index: 3, furthest: -1, expected: [] } +surrounded(char('['), string('hi'), char(']'))('[hi]'); // { ok: true, value: 'hi', index: 4 } +surrounded(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: 'b', index: 3 } ``` ### `unless` @@ -507,8 +507,8 @@ surrounded(char('a'), char('b'), char('c'))('abc'); // { ok: true, value: 'b', i Parse unless condition is true (inverse of guard). ```typescript -unless(false, string('hello'))('hello'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } -unless(true, string('hello'))('hello'); // { ok: true, value: null, index: 0, furthest: -1, expected: [] } +unless(false, string('hello'))('hello'); // { ok: true, value: 'hello', index: 5 } +unless(true, string('hello'))('hello'); // { ok: true, value: null, index: 0 } ``` ### `until` @@ -516,7 +516,7 @@ unless(true, string('hello'))('hello'); // { ok: true, value: null, index: 0, fu Parse zero or more until terminator matches, leaving the terminator unconsumed. ```typescript -until(char('a'), char('b'))('aaab'); // { ok: true, value: ['a', 'a', 'a'], index: 3, furthest: 2, expected: ["'b'"] } +until(char('a'), char('b'))('aaab'); // { ok: true, value: ['a', 'a', 'a'], index: 3 } ``` ### `validate` @@ -524,8 +524,8 @@ until(char('a'), char('b'))('aaab'); // { ok: true, value: ['a', 'a', 'a'], inde Validate parsed value with a predicate. ```typescript -validate(digit, (n) => n > 5)('7'); // { ok: true, value: 7, index: 1, furthest: -1, expected: [] } -validate(digit, (n) => n > 5)('3'); // { ok: false, index: 0, furthest: 0, expected: ['valid value'] } +validate(digit, (n) => n > 5)('7'); // { ok: true, value: 7, index: 1 } +validate(digit, (n) => n > 5)('3'); // { ok: false, index: 0, expected: ['valid value'] } ``` ### `value` @@ -533,8 +533,8 @@ validate(digit, (n) => n > 5)('3'); // { ok: false, index: 0, furthest: 0, expec Replace parsed value with a constant. ```typescript -value(string('true'), true)('true'); // { ok: true, value: true, index: 4, furthest: -1, expected: [] } -value(string('null'), null)('null'); // { ok: true, value: null, index: 4, furthest: -1, expected: [] } +value(string('true'), true)('true'); // { ok: true, value: true, index: 4 } +value(string('null'), null)('null'); // { ok: true, value: null, index: 4 } ``` ### `when` @@ -542,6 +542,6 @@ value(string('null'), null)('null'); // { ok: true, value: null, index: 4, furth Branch on a boolean parser result. ```typescript -when(flag(char('*')), pure('many'), pure('one'))('*rest'); // { ok: true, value: 'many', index: 1, furthest: -1, expected: [] } -when(flag(char('*')), pure('many'), pure('one'))('abc'); // { ok: true, value: 'one', index: 0, furthest: 0, expected: ["'*'"] } +when(flag(char('*')), pure('many'), pure('one'))('*rest'); // { ok: true, value: 'many', index: 1 } +when(flag(char('*')), pure('many'), pure('one'))('abc'); // { ok: true, value: 'one', index: 0 } ``` diff --git a/doc/api/core.md b/doc/api/core.md index f4cee20..d5eebce 100644 --- a/doc/api/core.md +++ b/doc/api/core.md @@ -7,29 +7,37 @@ 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'; +import { run, parse, grammar } from 'unitas'; ``` ## Index -14 exports: [`error`](#error) · [`failure`](#failure) · [`format`](#format) · [`grammar`](#grammar) · [`label`](#label) · [`lazy`](#lazy) · [`locate`](#locate) · [`match`](#match) · [`memoize`](#memoize) · [`merge`](#merge) · [`parse`](#parse) · [`parser`](#parser) · [`run`](#run) · [`success`](#success) +17 exports: [`context`](#context) · [`create`](#create) · [`failure`](#failure) · [`format`](#format) · [`grammar`](#grammar) · [`label`](#label) · [`lazy`](#lazy) · [`locate`](#locate) · [`match`](#match) · [`memoize`](#memoize) · [`parse`](#parse) · [`ParseError`](#parseerror) · [`record`](#record) · [`reject`](#reject) · [`run`](#run) · [`success`](#success) · [`union`](#union) ## Reference -### `error` +### `context` -Error thrown by {@link run} when a parse fails, carrying the offset, the 1-based line/column and the set of expectations at that offset. +Creates a fresh context. `run` and `parse` make one per call. ```typescript -new ParseError('a=1,b=x', 6, ['digit']).line; // 1 +context(); // { furthest: -1, expected: [] } +``` + +### `create` + +Creates a parser from a parser function. A parser reads `input` from `index` and never slices it. The third argument is the parse context — pass it on to any parser you call, so expectations recorded inside your parser reach the final error message. + +```typescript +create((input, index = 0) => success('parsed', index + 6))('hello world'); // { ok: true, value: 'parsed', index: 6 } ``` ### `failure` -Creates a failed result at an offset, describing what was expected there. Expectations are phrased as nouns ('digit', "'{'") and are unioned by {@link choice}, so a failing alternation reports every branch it tried. +Creates a failed result at an offset and notes it in the parse context. Expectations are phrased as nouns ('digit', "'{'"). Pass the context through from the parser so the message can name this position even if the branch is later backtracked over. ```typescript -failure(3, 'digit'); // { ok: false, index: 3, furthest: 3, expected: ['digit'] } +failure(undefined, 3, 'digit'); // { ok: false, index: 3, expected: ['digit'] } ``` ### `format` @@ -70,10 +78,10 @@ run(g.expr, '(1+2)'); // 3 ### `label` -Replaces the expectations of a parser with a single description. Only applies when the parser failed without consuming input: once a branch has committed, its own deeper error is more useful than the label. +Replaces the expectations of a parser with a single description. Only applies when the parser failed without consuming input: once a branch has committed, its own deeper error is more useful than the label. The labelled parser runs against an isolated context so that its internals can be suppressed rather than leaking into the message. ```typescript -label(char('x'), 'letter x')(''); // { ok: false, index: 0, furthest: 0, expected: ['letter x'] } +label(char('x'), 'letter x')(''); // { ok: false, index: 0, expected: ['letter x'] } ``` ### `lazy` @@ -81,7 +89,7 @@ label(char('x'), 'letter x')(''); // { ok: false, index: 0, furthest: 0, expecte Defers parser creation, useful for recursive grammars. ```typescript -lazy(() => char('a'))('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } +lazy(() => char('a'))('abc'); // { ok: true, value: 'a', index: 1 } ``` ### `locate` @@ -102,40 +110,51 @@ match(success('hello', 5), { success: (v) => v, failure: () => 'failed' }); // ' ### `memoize` -Memoizes a parser so each offset is parsed at most once (packrat caching). Keyed by offset rather than by the remaining input, so entries are cheap and the cache is dropped as soon as a different input is parsed. +Memoizes a parser so each offset is parsed at most once (packrat caching). Keyed by offset rather than by the remaining input, so entries are cheap and the cache is dropped as soon as a different input is parsed. Each entry also remembers what the parse expected, so a cache hit still contributes to the error message rather than silently weakening it. ```typescript const memoDigits = memoize(digits); -memoDigits('123'); // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } +memoDigits('123'); // { ok: true, value: 123, index: 3 } ``` -### `merge` +### `parse` -Carries the furthest-failure trace of an earlier result into a later one. Every combinator that runs more than one parser threads its intermediate results through `merge`, so an expectation recorded deep inside a branch that was later backtracked over still surfaces in the final error. Without it, `a=1,b=x` would only report "unconsumed input" instead of "expected digit". Returns `result` untouched whenever the trace adds nothing, which is the common case and keeps the hot path allocation-free. +Runs a parser over the whole input without throwing. The non-throwing counterpart of `run`: on failure it reports the offset, line, column, expectations and a formatted message. ```typescript -merge(failure(7, 'digit'), success('ok', 3)); // { ok: true, value: 'ok', index: 3, furthest: 7, expected: ['digit'] } +parse(digits, '12x').ok; // false ``` -### `parse` +### `ParseError` -Runs a parser over the whole input without throwing. The non-throwing counterpart of {@link run}: on failure it reports the offset, line, column, expectations and a formatted message. +Error thrown by `run` when a parse fails, carrying the offset, the 1-based line/column and the set of expectations at that offset. ```typescript -parse(digits, '12x').message; // "1:3 expected end of input, found 'x'\n 1 | 12x\n | ^" +new ParseError('a=1,b=x', 6, ['digit']).line; // 1 ``` -### `parser` +### `record` -Creates a parser from a parser function. A parser reads `input` starting at `index` and never slices it — results report the offset they reached, so positions stay meaningful all the way up to the top-level error message. +Notes what was expected at an offset, keeping only the furthest one seen. Replaces `expected` rather than mutating it, so a parser may pass the same array every time without it being altered underneath. ```typescript -create((input, index = 0) => success('parsed', index + 6))('hello world'); // { ok: true, value: 'parsed', index: 6, furthest: -1, expected: [] } +const ctx = context(); +record(ctx, 3, ['digit']); +ctx; // { furthest: 3, expected: ['digit'] } +``` + +### `reject` + +Creates a failed result from an expectation list that is already allocated, avoiding a fresh array on every failure. + +```typescript +const expected = ['digit']; +reject(undefined, 3, expected); // { ok: false, index: 3, expected: ['digit'] } ``` ### `run` -Runs a parser over the whole input and returns the value. Throws a {@link ParseError} — with line, column and the expectations at that point — if the parse fails or leaves input unconsumed. +Runs a parser over the whole input and returns the value. Throws a `ParseError` — with line, column and the expectations at that point — if the parse fails or leaves input unconsumed. ```typescript run(string('hello'), 'hello'); // 'hello' @@ -143,8 +162,16 @@ run(string('hello'), 'hello'); // 'hello' ### `success` -Creates a successful result with a value and the offset reached in the input. The `furthest`/`expected` pair carries the furthest failure seen while producing this success, so a later error can still report it. Use {@link merge} to propagate it — a fresh success starts with no trace. +Creates a successful result with a value and the offset reached in the input. + +```typescript +success('hello', 5); // { ok: true, value: 'hello', index: 5 } +``` + +### `union` + +Adds the entries of `b` to `a`, returning `a` itself when it already covers them. Callers rely on that identity to skip allocating. ```typescript -success('hello', 5); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } +union(['digit'], ['digit']); // ['digit'] ``` diff --git a/doc/api/primitives.md b/doc/api/primitives.md index 6ac04da..f4cf0ca 100644 --- a/doc/api/primitives.md +++ b/doc/api/primitives.md @@ -7,7 +7,7 @@ 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'; +import { digit, letters, whitespace } from 'unitas/primitives'; ``` ## Index @@ -21,8 +21,8 @@ import {} from /* … */ 'unitas/primitives'; Parse a single alphanumeric character. ```typescript -alphaNum('a1'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } -alphaNum('1a'); // { ok: true, value: '1', index: 1, furthest: -1, expected: [] } +alphaNum('a1'); // { ok: true, value: 'a', index: 1 } +alphaNum('1a'); // { ok: true, value: '1', index: 1 } ``` ### `alphaNums` @@ -30,7 +30,7 @@ alphaNum('1a'); // { ok: true, value: '1', index: 1, furthest: -1, expected: [] Parse one or more alphanumeric characters as a string. ```typescript -alphaNums('placeholder'); // { ok: true, value: 'placeholder', index: 11, furthest: 11, expected: ['alphanumeric character'] } +alphaNums('placeholder'); // { ok: true, value: 'placeholder', index: 11 } ``` ### `anyChar` @@ -38,7 +38,7 @@ alphaNums('placeholder'); // { ok: true, value: 'placeholder', index: 11, furthe Parse any single character. ```typescript -anyChar('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } +anyChar('abc'); // { ok: true, value: 'a', index: 1 } ``` ### `bool` @@ -46,9 +46,9 @@ anyChar('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] Parse a boolean literal. ```typescript -bool('true'); // { ok: true, value: true, index: 4, furthest: -1, expected: [] } -bool('false'); // { ok: true, value: false, index: 5, furthest: 0, expected: ["'true'"] } -bool('trueABC'); // { ok: true, value: true, index: 4, furthest: -1, expected: [] } +bool('true'); // { ok: true, value: true, index: 4 } +bool('false'); // { ok: true, value: false, index: 5 } +bool('trueABC'); // { ok: true, value: true, index: 4 } ``` ### `crlf` @@ -56,7 +56,7 @@ bool('trueABC'); // { ok: true, value: true, index: 4, furthest: -1, expected: [ Parse CRLF line ending. ```typescript -crlf('\r\nabc'); // { ok: true, value: '\r\n', index: 2, furthest: -1, expected: [] } +crlf('\r\nabc'); // { ok: true, value: '\r\n', index: 2 } ``` ### `digit` @@ -64,7 +64,7 @@ crlf('\r\nabc'); // { ok: true, value: '\r\n', index: 2, furthest: -1, expected: Parse a single digit and return as number. ```typescript -digit('5abc'); // { ok: true, value: 5, index: 1, furthest: -1, expected: [] } +digit('5abc'); // { ok: true, value: 5, index: 1 } ``` ### `digits` @@ -72,7 +72,7 @@ digit('5abc'); // { ok: true, value: 5, index: 1, furthest: -1, expected: [] } Parse one or more digits and return as a number. ```typescript -digits('123'); // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } +digits('123'); // { ok: true, value: 123, index: 3 } ``` ### `eof` @@ -80,7 +80,7 @@ digits('123'); // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } Match the end of the input. ```typescript -eof(''); // { ok: true, value: null, index: 0, furthest: -1, expected: [] } +eof(''); // { ok: true, value: null, index: 0 } ``` ### `eol` @@ -88,7 +88,7 @@ eof(''); // { ok: true, value: null, index: 0, furthest: -1, expected: [] } Parse end of line (\\n, \\r\\n, or EOF). ```typescript -eol('\nabc'); // { ok: true, value: '\n', index: 1, furthest: -1, expected: [] } +eol('\nabc'); // { ok: true, value: '\n', index: 1 } ``` ### `float` @@ -96,9 +96,9 @@ eol('\nabc'); // { ok: true, value: '\n', index: 1, furthest: -1, expected: [] } Parse a floating point number. ```typescript -float('1.23'); // { ok: true, value: 1.23, index: 4, furthest: -1, expected: [] } -float('-2.5'); // { ok: true, value: -2.5, index: 4, furthest: -1, expected: [] } -float('1.23abc'); // { ok: true, value: 1.23, index: 4, furthest: -1, expected: [] } +float('1.23'); // { ok: true, value: 1.23, index: 4 } +float('-2.5'); // { ok: true, value: -2.5, index: 4 } +float('1.23abc'); // { ok: true, value: 1.23, index: 4 } ``` ### `hexDigit` @@ -106,7 +106,7 @@ float('1.23abc'); // { ok: true, value: 1.23, index: 4, furthest: -1, expected: Parse a single hexadecimal digit. ```typescript -hexDigit('fF9'); // { ok: true, value: 'f', index: 1, furthest: -1, expected: [] } +hexDigit('fF9'); // { ok: true, value: 'f', index: 1 } ``` ### `hexDigits` @@ -114,7 +114,7 @@ hexDigit('fF9'); // { ok: true, value: 'f', index: 1, furthest: -1, expected: [] Parse one or more hex digits as a string. ```typescript -hexDigits('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['hex digit'] } +hexDigits('deadbeef'); // { ok: true, value: 'deadbeef', index: 8 } ``` ### `identifier` @@ -122,7 +122,7 @@ hexDigits('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['hex Parse an identifier — starts with letter or underscore, no leading digit, no hyphen. ```typescript -identifier('variable_name'); // { ok: true, value: 'variable_name', index: 13, furthest: -1, expected: [] } +identifier('variable_name'); // { ok: true, value: 'variable_name', index: 13 } ``` ### `integer` @@ -130,9 +130,9 @@ identifier('variable_name'); // { ok: true, value: 'variable_name', index: 13, f Parse a signed integer. ```typescript -integer('42'); // { ok: true, value: 42, index: 2, furthest: -1, expected: [] } -integer('-7'); // { ok: true, value: -7, index: 2, furthest: -1, expected: [] } -integer('123abc'); // { ok: true, value: 123, index: 3, furthest: -1, expected: [] } +integer('42'); // { ok: true, value: 42, index: 2 } +integer('-7'); // { ok: true, value: -7, index: 2 } +integer('123abc'); // { ok: true, value: 123, index: 3 } ``` ### `letter` @@ -140,7 +140,7 @@ integer('123abc'); // { ok: true, value: 123, index: 3, furthest: -1, expected: Parse a single letter. ```typescript -letter('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } +letter('abc'); // { ok: true, value: 'a', index: 1 } ``` ### `letters` @@ -148,7 +148,7 @@ letter('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } Parse one or more letters as a string. ```typescript -letters('placeholder'); // { ok: true, value: 'placeholder', index: 11, furthest: 11, expected: ['letter'] } +letters('placeholder'); // { ok: true, value: 'placeholder', index: 11 } ``` ### `line` @@ -156,7 +156,7 @@ letters('placeholder'); // { ok: true, value: 'placeholder', index: 11, furthest Parse until end of line. ```typescript -line('hello\nworld'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } +line('hello\nworld'); // { ok: true, value: 'hello', index: 5 } ``` ### `literal` @@ -164,8 +164,8 @@ line('hello\nworld'); // { ok: true, value: 'hello', index: 5, furthest: -1, exp Parse a word-like value including hyphens. ```typescript -literal('foo-bar'); // { ok: true, value: 'foo-bar', index: 7, furthest: -1, expected: [] } -literal('123abc'); // { ok: true, value: '123abc', index: 6, furthest: -1, expected: [] } +literal('foo-bar'); // { ok: true, value: 'foo-bar', index: 7 } +literal('123abc'); // { ok: true, value: '123abc', index: 6 } ``` ### `lowercase` @@ -173,7 +173,7 @@ literal('123abc'); // { ok: true, value: '123abc', index: 6, furthest: -1, expec Parse a single lowercase letter. ```typescript -lowercase('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } +lowercase('abc'); // { ok: true, value: 'a', index: 1 } ``` ### `lowercases` @@ -181,7 +181,7 @@ lowercase('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [ Parse one or more lowercase letters as a string. ```typescript -lowercases('placeholder'); // { ok: true, value: 'placeholder', index: 11, furthest: 11, expected: ['lowercase letter'] } +lowercases('placeholder'); // { ok: true, value: 'placeholder', index: 11 } ``` ### `nl` @@ -189,7 +189,7 @@ lowercases('placeholder'); // { ok: true, value: 'placeholder', index: 11, furth Parse a newline character. ```typescript -nl('\ntext'); // { ok: true, value: '\n', index: 1, furthest: -1, expected: [] } +nl('\ntext'); // { ok: true, value: '\n', index: 1 } ``` ### `number` @@ -197,10 +197,10 @@ nl('\ntext'); // { ok: true, value: '\n', index: 1, furthest: -1, expected: [] } Parse an integer or float. ```typescript -number('42'); // { ok: true, value: 42, index: 2, furthest: 0, expected: ['/-?\\d+\\.\\d+/'] } -number('3.14'); // { ok: true, value: 3.14, index: 4, furthest: -1, expected: [] } -number('-7'); // { ok: true, value: -7, index: 2, furthest: 0, expected: ['/-?\\d+\\.\\d+/'] } -number('-2.5'); // { ok: true, value: -2.5, index: 4, furthest: -1, expected: [] } +number('42'); // { ok: true, value: 42, index: 2 } +number('3.14'); // { ok: true, value: 3.14, index: 4 } +number('-7'); // { ok: true, value: -7, index: 2 } +number('-2.5'); // { ok: true, value: -2.5, index: 4 } ``` ### `octDigit` @@ -208,7 +208,7 @@ number('-2.5'); // { ok: true, value: -2.5, index: 4, furthest: -1, expected: [] Parse a single octal digit. ```typescript -octDigit('7abc'); // { ok: true, value: '7', index: 1, furthest: -1, expected: [] } +octDigit('7abc'); // { ok: true, value: '7', index: 1 } ``` ### `octDigits` @@ -216,16 +216,16 @@ octDigit('7abc'); // { ok: true, value: '7', index: 1, furthest: -1, expected: [ Parse one or more octal digits as a string. ```typescript -octDigits('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['octal digit'] } +octDigits('755rest'); // { ok: true, value: '755', index: 3 } ``` ### `position` -Get the current offset into the input, without consuming anything. Counts forward from the start of the input, so it can be paired with {@link locate} to attach line/column information to a parsed node. +Get the current offset into the input, without consuming anything. Counts forward from the start of the input, so it can be paired with `locate` to attach line/column information to a parsed node. ```typescript -position('abc'); // { ok: true, value: 0, index: 0, furthest: -1, expected: [] } -right(string('ab'), position)('abc'); // { ok: true, value: 2, index: 2, furthest: -1, expected: [] } +position('abc'); // { ok: true, value: 0, index: 0 } +right(string('ab'), position)('abc'); // { ok: true, value: 2, index: 2 } ``` ### `rest` @@ -233,7 +233,7 @@ right(string('ab'), position)('abc'); // { ok: true, value: 2, index: 2, furthes Consume and return everything left in the input. ```typescript -rest('abc'); // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } +rest('abc'); // { ok: true, value: 'abc', index: 3 } ``` ### `space` @@ -241,7 +241,7 @@ rest('abc'); // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } Parse a single space character. ```typescript -space(' abc'); // { ok: true, value: ' ', index: 1, furthest: -1, expected: [] } +space(' abc'); // { ok: true, value: ' ', index: 1 } ``` ### `spaces` @@ -249,7 +249,7 @@ space(' abc'); // { ok: true, value: ' ', index: 1, furthest: -1, expected: [] } Parse one or more spaces as a string. ```typescript -spaces('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['space'] } +spaces(' abc'); // { ok: true, value: ' ', index: 3 } ``` ### `tab` @@ -257,7 +257,7 @@ spaces('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['space' Parse tab character. ```typescript -tab('\ttext'); // { ok: true, value: '\t', index: 1, furthest: -1, expected: [] } +tab('\ttext'); // { ok: true, value: '\t', index: 1 } ``` ### `uppercase` @@ -265,7 +265,7 @@ tab('\ttext'); // { ok: true, value: '\t', index: 1, furthest: -1, expected: [] Parses a single uppercase letter. ```typescript -uppercase('ABC'); // { ok: true, value: 'A', index: 1, furthest: -1, expected: [] } +uppercase('ABC'); // { ok: true, value: 'A', index: 1 } ``` ### `uppercases` @@ -273,7 +273,7 @@ uppercase('ABC'); // { ok: true, value: 'A', index: 1, furthest: -1, expected: [ Parse one or more uppercase letters as a string. ```typescript -uppercases('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['uppercase letter'] } +uppercases('ABCdef'); // { ok: true, value: 'ABC', index: 3 } ``` ### `whitespace` @@ -281,7 +281,7 @@ uppercases('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['up Parses a single whitespace character. ```typescript -whitespace(' abc'); // { ok: true, value: ' ', index: 1, furthest: -1, expected: [] } +whitespace(' abc'); // { ok: true, value: ' ', index: 1 } ``` ### `whitespaces` @@ -289,5 +289,5 @@ whitespace(' abc'); // { ok: true, value: ' ', index: 1, furthest: -1, expected: Parse one or more whitespaces as a string. ```typescript -whitespaces('placeholder'); // { ok: false, index: 0, furthest: 0, expected: ['whitespace'] } +whitespaces(' \t\nabc'); // { ok: true, value: ' \t\n', index: 3 } ``` diff --git a/doc/api/terminals.md b/doc/api/terminals.md index fc8962a..d611fef 100644 --- a/doc/api/terminals.md +++ b/doc/api/terminals.md @@ -7,7 +7,7 @@ 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'; +import { char, string, regex } from 'unitas/terminals'; ``` ## Index @@ -21,7 +21,7 @@ import {} from /* … */ 'unitas/terminals'; Parse a specific character. ```typescript -char('A')('ABC'); // { ok: true, value: 'A', index: 1, furthest: -1, expected: [] } +char('A')('ABC'); // { ok: true, value: 'A', index: 1 } ``` ### `charOf` @@ -29,7 +29,7 @@ char('A')('ABC'); // { ok: true, value: 'A', index: 1, furthest: -1, expected: [ Parse any character from a set. ```typescript -charOf(['a', 'b', 'c'])('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } +charOf(['a', 'b', 'c'])('abc'); // { ok: true, value: 'a', index: 1 } ``` ### `noneOf` @@ -37,7 +37,7 @@ charOf(['a', 'b', 'c'])('abc'); // { ok: true, value: 'a', index: 1, furthest: - Parse any character not in the set. ```typescript -noneOf(['a', 'b', 'c'])('xyz'); // { ok: true, value: 'x', index: 1, furthest: -1, expected: [] } +noneOf(['a', 'b', 'c'])('xyz'); // { ok: true, value: 'x', index: 1 } ``` ### `oneOf` @@ -45,15 +45,15 @@ noneOf(['a', 'b', 'c'])('xyz'); // { ok: true, value: 'x', index: 1, furthest: - Parse one string from a set of strings (longest match wins). ```typescript -oneOf(['hello', 'hell', 'help'])('helpful'); // { ok: true, value: 'help', index: 4, furthest: -1, expected: [] } +oneOf(['hello', 'hell', 'help'])('helpful'); // { ok: true, value: 'help', index: 4 } ``` ### `regex` -Parse with a regular expression. The pattern is matched with the sticky flag at the current offset, so it is anchored by construction and never scans ahead. A leading `^` is redundant and is stripped. +Parse with a regular expression. The pattern is matched with the sticky flag at the current offset, so it is anchored by construction and never scans ahead. A leading `^` is redundant and is stripped — unless the pattern is multiline, where it asserts a line start and still carries meaning. ```typescript -regex(/^\w+/)('hello world'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } +regex(/^\w+/)('hello world'); // { ok: true, value: 'hello', index: 5 } ``` ### `satisfy` @@ -61,7 +61,7 @@ regex(/^\w+/)('hello world'); // { ok: true, value: 'hello', index: 5, furthest: Parse a character satisfying a predicate. ```typescript -satisfy((c) => c === 'a')('abc'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } +satisfy((c) => c === 'a')('abc'); // { ok: true, value: 'a', index: 1 } ``` ### `string` @@ -69,7 +69,7 @@ satisfy((c) => c === 'a')('abc'); // { ok: true, value: 'a', index: 1, furthest: Parse a specific string. ```typescript -string('hello')('hello world'); // { ok: true, value: 'hello', index: 5, furthest: -1, expected: [] } +string('hello')('hello world'); // { ok: true, value: 'hello', index: 5 } ``` ### `stringOf` @@ -77,7 +77,7 @@ string('hello')('hello world'); // { ok: true, value: 'hello', index: 5, furthes Parse first character that exists in string (like charOf but for a string). ```typescript -stringOf('abc')('abcdef'); // { ok: true, value: 'a', index: 1, furthest: -1, expected: [] } +stringOf('abc')('abcdef'); // { ok: true, value: 'a', index: 1 } ``` ### `take` @@ -85,7 +85,7 @@ stringOf('abc')('abcdef'); // { ok: true, value: 'a', index: 1, furthest: -1, ex Take n characters. ```typescript -take(3)('abcdef'); // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } +take(3)('abcdef'); // { ok: true, value: 'abc', index: 3 } ``` ### `takeWhile` @@ -93,7 +93,7 @@ take(3)('abcdef'); // { ok: true, value: 'abc', index: 3, furthest: -1, expected Takes characters while the predicate returns true. ```typescript -takeWhile((c) => c !== 'x')('abcx'); // { ok: true, value: 'abc', index: 3, furthest: -1, expected: [] } +takeWhile((c) => c !== 'x')('abcx'); // { ok: true, value: 'abc', index: 3 } ``` ### `token` @@ -101,9 +101,9 @@ takeWhile((c) => c !== 'x')('abcx'); // { ok: true, value: 'abc', index: 3, furt 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', index: 4, furthest: -1, expected: [] } -token('let')('let1'); // { ok: true, value: 'let', index: 3, furthest: -1, expected: [] } -token('let')('let x'); // { ok: true, value: 'let', index: 5, furthest: -1, expected: [] } +token('let')('let x'); // { ok: true, value: 'let', index: 4 } +token('let')('let1'); // { ok: true, value: 'let', index: 3 } +token('let')('let x'); // { ok: true, value: 'let', index: 5 } ``` ### `word` @@ -111,7 +111,7 @@ token('let')('let x'); // { ok: true, value: 'let', index: 5, furthest: -1, exp 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', index: 4, furthest: -1, expected: [] } -word('let')('let1'); // { ok: false, index: 3, furthest: 3, expected: ['/(?!\\w)/'] } -word('if')('if (x)'); // { ok: true, value: 'if', index: 3, furthest: -1, expected: [] } +word('let')('let x'); // { ok: true, value: 'let', index: 4 } +word('let')('let1'); // { ok: false, index: 3, expected: ['/(?!\\w)/'] } +word('if')('if (x)'); // { ok: true, value: 'if', index: 3 } ``` diff --git a/doc/api/utils.md b/doc/api/utils.md index 077b910..11001ff 100644 --- a/doc/api/utils.md +++ b/doc/api/utils.md @@ -7,7 +7,7 @@ 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'; +import { pick, join, pipe } from 'unitas/utils'; ``` ## Index @@ -57,7 +57,7 @@ pick(2, 4)(['a', 'b', 'c', 'd', 'e']); // ['c', 'e'] Pipe parser functions together. ```typescript -pipe(lexeme)(letters)('xyz abc'); // { ok: true, value: 'xyz', index: 6, furthest: 3, expected: ['letter'] } +pipe(lexeme)(letters)('xyz abc'); // { ok: true, value: 'xyz', index: 6 } ``` ### `pop` diff --git a/package.json b/package.json index 6d82ae2..c8ba8c9 100644 --- a/package.json +++ b/package.json @@ -3,20 +3,24 @@ "version": "0.3.0", "description": "TypeScript-first parser combinator library — compose small typed parsers into whole grammars. Zero dependencies, ESM-only, tree-shakeable.", "keywords": [ + "combinator", + "esm", + "functional", + "grammar", + "lexer", "parser", "parser-combinator", - "combinator", "parsing", - "grammar", "peg", "recursive-descent", - "lexer", "tokenizer", - "functional", "typescript", - "esm", "zero-dependency" ], + "homepage": "https://github.com/sovrin/unitas#readme", + "bugs": { + "url": "https://github.com/sovrin/unitas/issues" + }, "license": "MIT", "author": { "name": "Oleg Kamlowski", @@ -26,10 +30,6 @@ "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" diff --git a/src/combinators/bind.test.ts b/src/combinators/bind.test.ts index c6bd254..9bbefb8 100644 --- a/src/combinators/bind.test.ts +++ b/src/combinators/bind.test.ts @@ -15,7 +15,9 @@ describe('bind', () => { const parser1 = createTestParser('A'); const parser2 = createTestParser('B'); const parser = bind(parser1, (a) => - bind(parser2, (b) => create((_input, index = 0) => success(`${a}${b}`, index))), + bind(parser2, (b) => + create((_input, index = 0) => success(`${a}${b}`, index)), + ), ); const result = parser('ABCD'); @@ -24,7 +26,9 @@ describe('bind', () => { }); it('should fail if the first parser fails', () => { - const failingParser = create((_input, index = 0) => failure(index)); + const failingParser = create((_input, index = 0) => + failure(undefined, index), + ); const parser = bind(failingParser, (_x) => createTestParser('B')); const result = parser('ABC'); @@ -34,7 +38,9 @@ describe('bind', () => { it('should fail if the second parser fails', () => { const parser1 = createTestParser('A'); - const failingSecond = create((_input, index = 0) => failure(index)); + const failingSecond = create((_input, index = 0) => + failure(undefined, index), + ); const parser = bind(parser1, () => failingSecond); const result = parser('ABC'); @@ -45,7 +51,7 @@ describe('bind', () => { it('should allow the second parser to depend on the first result', () => { const countParser = create((input, index = 0) => { const match = input.slice(index).match(/^(\d+)/); - if (!match) return failure(index); + if (!match) return failure(undefined, index); return success(parseInt(match[1]), index + match[1].length); }); const parser = bind(countParser, (count) => diff --git a/src/combinators/bind.ts b/src/combinators/bind.ts index 7b4efab..eab5dd6 100644 --- a/src/combinators/bind.ts +++ b/src/combinators/bind.ts @@ -1,4 +1,3 @@ -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Parser } from '../core/parser'; @@ -6,18 +5,18 @@ import { type Parser } from '../core/parser'; * Chain parsers where the second parser depends on the first result. * * @example - * bind(digits, (n) => take(n))('3abc') // { ok: true, value: 'abc', index: 4, furthest: -1, expected: [] } + * bind(digits, (n) => take(n))('3abc') // { ok: true, value: 'abc', index: 4 } */ export const bind = ( parser: Parser, f: (a: A) => Parser, ): Parser => { - return create((input, index = 0) => { - const result = parser(input, index); + return create((input, index = 0, ctx) => { + const result = parser(input, index, ctx); if (!result.ok) { return result; } - return merge(result, f(result.value)(input, result.index)); + return f(result.value)(input, result.index, ctx); }); }; diff --git a/src/combinators/braced.ts b/src/combinators/braced.ts index 03045d0..da6e6a3 100644 --- a/src/combinators/braced.ts +++ b/src/combinators/braced.ts @@ -7,7 +7,7 @@ import { surrounded } from './surrounded'; * Parse content surrounded by braces. * * @example - * braced(string('hi'))('{hi}') // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } + * braced(string('hi'))('{hi}') // { ok: true, value: 'hi', index: 4 } */ export const braced = (content: Parser) => { return surrounded(string('{'), content, string('}')); diff --git a/src/combinators/bracketed.ts b/src/combinators/bracketed.ts index 7ff0dd0..e0b868e 100644 --- a/src/combinators/bracketed.ts +++ b/src/combinators/bracketed.ts @@ -7,7 +7,7 @@ import { surrounded } from './surrounded'; * Parse content surrounded by brackets. * * @example - * bracketed(string('hi'))('[hi]') // { ok: true, value: 'hi', index: 4, furthest: -1, expected: [] } + * bracketed(string('hi'))('[hi]') // { ok: true, value: 'hi', index: 4 } */ export const bracketed = (content: Parser) => { return surrounded(string('['), content, string(']')); diff --git a/src/combinators/chainLeft.ts b/src/combinators/chainLeft.ts index 63c0bab..08cdda7 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 { merge } from '../core/merge'; import { create } from '../core/parser'; import { chainLeft1 } from './chainLeft1'; import { optional } from './optional'; @@ -10,16 +9,20 @@ import { optional } from './optional'; * Chain left-associative operations (right-to-left for same precedence). * * @example - * chainLeft(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } + * chainLeft(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5 } */ export const chainLeft = ( parser: Parser, operator: Parser<(a: T, b: T) => T>, ) => { - return create((input, index = 0) => { - const result = optional(chainLeft1(parser, operator))(input, index); + return create((input, index = 0, ctx) => { + const result = optional(chainLeft1(parser, operator))( + input, + index, + ctx, + ); if (!result.ok || result.value === null) { - return merge(result, failure(index)); + return failure(ctx, index); } return result; diff --git a/src/combinators/chainLeft1.ts b/src/combinators/chainLeft1.ts index 4c65a39..eaf56e9 100644 --- a/src/combinators/chainLeft1.ts +++ b/src/combinators/chainLeft1.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,36 +8,37 @@ import { success } from '../core/success'; * Chain left-associative operations (fails on empty input). * * @example - * chainLeft1(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } - * chainLeft1(digits, operation)('8/2*3') // { ok: true, value: 12, index: 5, furthest: 5, expected: ['operator'] } + * chainLeft1(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5 } + * chainLeft1(digits, operation)('8/2*3') // { ok: true, value: 12, index: 5 } */ export const chainLeft1 = ( term: Parser, operator: Parser<(left: T, right: T) => T>, ) => { - return create((input, index = 0) => { - const firstResult = term(input, index); + return create((input, index = 0, ctx) => { + const firstResult = term(input, index, ctx); if (!firstResult.ok) { return firstResult; } let accumulator = firstResult.value; let at = firstResult.index; - let trace: Result = firstResult; while (true) { - const opResult: Result<(left: T, right: T) => T> = merge(trace, operator(input, at)); - trace = opResult; + const opResult: Result<(left: T, right: T) => T> = operator( + input, + at, + ctx, + ); if (!opResult.ok) break; - const nextResult: Result = merge(trace, term(input, opResult.index)); - trace = nextResult; + const nextResult: Result = term(input, opResult.index, ctx); if (!nextResult.ok) break; accumulator = opResult.value(accumulator, nextResult.value); at = nextResult.index; } - return merge(trace, success(accumulator, at)); + return success(accumulator, at); }); }; diff --git a/src/combinators/chainRight.ts b/src/combinators/chainRight.ts index d5b97be..dca8015 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 { merge } from '../core/merge'; import { create } from '../core/parser'; import { chainRight1 } from './chainRight1'; import { optional } from './optional'; @@ -10,16 +9,20 @@ import { optional } from './optional'; * Chain right-associative operations. * * @example - * chainRight(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } + * chainRight(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5 } */ export const chainRight = ( parser: Parser, operator: Parser<(a: T, b: T) => T>, ) => { - return create((input, index = 0) => { - const result = optional(chainRight1(parser, operator))(input, index); + return create((input, index = 0, ctx) => { + const result = optional(chainRight1(parser, operator))( + input, + index, + ctx, + ); if (!result.ok || result.value === null) { - return merge(result, failure(index)); + return failure(ctx, index); } return result; diff --git a/src/combinators/chainRight1.ts b/src/combinators/chainRight1.ts index 2a28779..ba7cfc1 100644 --- a/src/combinators/chainRight1.ts +++ b/src/combinators/chainRight1.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { merge } from '../core/merge'; import { create } from '../core/parser'; import { type Result } from '../core/result'; import { success } from '../core/success'; @@ -9,40 +8,31 @@ import { success } from '../core/success'; * Chain right-associative operations (fails on empty input). * * @example - * chainRight1(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5, furthest: 5, expected: ['operator'] } + * chainRight1(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5 } */ export const chainRight1 = ( term: Parser, operator: Parser<(left: T, right: T) => T>, ) => { - const parser: Parser = create((input, index = 0) => { - const leftResult = term(input, index); + const parser: Parser = create((input, index = 0, ctx) => { + const leftResult = term(input, index, ctx); if (!leftResult.ok) { return leftResult; } - const opResult = merge(leftResult, operator(input, leftResult.index)); + const opResult = operator(input, leftResult.index, ctx); if (!opResult.ok) { - return merge(opResult, success(leftResult.value, leftResult.index)); + return success(leftResult.value, leftResult.index); } - const rightResult: Result = merge( - opResult, - parser(input, opResult.index), - ); + const rightResult: Result = parser(input, opResult.index, ctx); if (!rightResult.ok) { - return merge( - rightResult, - success(leftResult.value, leftResult.index), - ); + return success(leftResult.value, leftResult.index); } - return merge( - rightResult, - success( - opResult.value(leftResult.value, rightResult.value), - rightResult.index, - ), + return success( + opResult.value(leftResult.value, rightResult.value), + rightResult.index, ); }); diff --git a/src/combinators/choice.test.ts b/src/combinators/choice.test.ts index 3a1472d..280b8fd 100644 --- a/src/combinators/choice.test.ts +++ b/src/combinators/choice.test.ts @@ -12,9 +12,15 @@ import { choice } from './choice'; describe('choice', () => { it('should try parsers in order and return first success', () => { - const parserA = create<'A'>((_input, index = 0) => failure(index)); - const parserB = create<'B'>((input, index = 0) => success('B', index + 1)); - const parserC = create<'C'>((input, index = 0) => success('C', index + 1)); + const parserA = create<'A'>((_input, index = 0) => + failure(undefined, index), + ); + const parserB = create<'B'>((input, index = 0) => + success('B', index + 1), + ); + const parserC = create<'C'>((input, index = 0) => + success('C', index + 1), + ); const parser = choice(parserA, parserB, parserC); const result = parser('ABC'); @@ -22,9 +28,15 @@ describe('choice', () => { }); it('should try all parsers if earlier ones fail', () => { - const parserA = create<'A'>((_input, index = 0) => failure(index)); - const parserB = create<'B'>((_input, index = 0) => failure(index)); - const parserC = create<'C'>((input, index = 0) => success('C', index + 1)); + const parserA = create<'A'>((_input, index = 0) => + failure(undefined, index), + ); + const parserB = create<'B'>((_input, index = 0) => + failure(undefined, index), + ); + const parserC = create<'C'>((input, index = 0) => + success('C', index + 1), + ); const parser = choice(parserA, parserB, parserC); const result = parser('C D'); @@ -33,9 +45,9 @@ describe('choice', () => { it('should fail if all parsers fail', () => { const parser = choice( - (_input, index = 0) => failure(index), - (_input, index = 0) => failure(index), - (_input, index = 0) => failure(index), + (_input, index = 0) => failure(undefined, index), + (_input, index = 0) => failure(undefined, index), + (_input, index = 0) => failure(undefined, index), ); const result = parser('D'); @@ -43,7 +55,9 @@ describe('choice', () => { }); it('should handle different types', () => { - const parserA = create<'A'>((input, index = 0) => success('A', index + 1)); + const parserA = create<'A'>((input, index = 0) => + success('A', index + 1), + ); const parserB = create<'B'[]>((input, index = 0) => success(['B'], index + 1), ); diff --git a/src/combinators/choice.ts b/src/combinators/choice.ts index 08345fb..24822b9 100644 --- a/src/combinators/choice.ts +++ b/src/combinators/choice.ts @@ -1,37 +1,40 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; -import { merge } from '../core/merge'; +import { union } from '../core/context'; import { create } from '../core/parser'; import { type Result } from '../core/result'; -import { success } from '../core/success'; type ParserOutput