diff --git a/README.md b/README.md index fea4479..b454ba7 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,7 @@ Factories that match the input string directly. **12** exports — [full referen | [`char`][terminals-char] | Parse a specific character. | | [`charOf`][terminals-charof] | Parse any character from a set. | | [`noneOf`][terminals-noneof] | Parse any character not in the set. | -| [`oneOf`][terminals-oneof] | Parse one string from a set of strings (longest match wins). | +| [`oneOf`][terminals-oneof] | Parse one string from a set of strings. | | [`regex`][terminals-regex] | Parse with a regular expression. | | [`satisfy`][terminals-satisfy] | Parse a character satisfying a predicate. | | [`string`][terminals-string] | Parse a specific string. | @@ -476,7 +476,7 @@ Take parsers, return a new parser. **64** exports — [full reference →][api-c | [`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. | +| [`chainRight`][combinators-chainright] | Chain right-associative operations: `2^3^2` is evaluated as `2^(3^2)`. | | [`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. | diff --git a/doc/api/combinators.md b/doc/api/combinators.md index 3f73864..e906839 100644 --- a/doc/api/combinators.md +++ b/doc/api/combinators.md @@ -42,10 +42,11 @@ bracketed(string('hi'))('[hi]'); // { ok: true, value: 'hi', index: 4 } ### `chainLeft` -Chain left-associative operations (right-to-left for same precedence). +Chain left-associative operations (right-to-left for same precedence). Zero or more terms: succeeds with `null` when there is no term at all. ```typescript chainLeft(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5 } +chainLeft(digits, operation)(''); // { ok: true, value: null, index: 0 } ``` ### `chainLeft1` @@ -59,10 +60,11 @@ chainLeft1(digits, operation)('8/2*3'); // { ok: true, value: 12, index: 5 } ### `chainRight` -Chain right-associative operations. +Chain right-associative operations: `2^3^2` is evaluated as `2^(3^2)`. Zero or more terms: succeeds with `null` when there is no term at all. ```typescript chainRight(digits, operation)('1+2+3'); // { ok: true, value: 6, index: 5 } +chainRight(digits, operation)(''); // { ok: true, value: null, index: 0 } ``` ### `chainRight1` @@ -329,10 +331,11 @@ optionalConsume(string('hi'))('hi there'); // { ok: true, index: 2 } ### `optionalSeparatedBy` -Parse items separated by a separator, allowing empty slots. +Parse items separated by a separator, allowing empty slots. A missing item before or between separators becomes `null`; an empty slot after the last separator is dropped. ```typescript optionalSeparatedBy(digits, char(','))('1,2'); // { ok: true, value: [1, 2], index: 3 } +optionalSeparatedBy(digits, char(','))('1,,2'); // { ok: true, value: [1, null, 2], index: 4 } ``` ### `outer` @@ -443,6 +446,7 @@ 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 } +separatedEndBy(digits, char(','))(','); // { ok: true, value: [], index: 0 } ``` ### `separatedEndBy1` diff --git a/doc/api/terminals.md b/doc/api/terminals.md index d611fef..a7cb5c8 100644 --- a/doc/api/terminals.md +++ b/doc/api/terminals.md @@ -42,15 +42,16 @@ noneOf(['a', 'b', 'c'])('xyz'); // { ok: true, value: 'x', index: 1 } ### `oneOf` -Parse one string from a set of strings (longest match wins). +Parse one string from a set of strings. Candidates are tried in order and the first match wins, so list a longer string before any of its prefixes. ```typescript oneOf(['hello', 'hell', 'help'])('helpful'); // { ok: true, value: 'help', index: 4 } +oneOf(['+=', '+'])('+=1'); // { ok: true, value: '+=', index: 2 } ``` ### `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 — unless the pattern is multiline, where it asserts a line start and still carries meaning. +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. Only the leading `^` is stripped: in `/^a|^b/` the second `^` still means start of input, so write `/^(?:a|b)/`. ```typescript regex(/^\w+/)('hello world'); // { ok: true, value: 'hello', index: 5 } diff --git a/src/combinators/chainLeft.test.ts b/src/combinators/chainLeft.test.ts index 78d0165..f234c6b 100644 --- a/src/combinators/chainLeft.test.ts +++ b/src/combinators/chainLeft.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'vitest'; import { operation, digits } from '../../test/helpers'; -import { assertFailure, assertSuccess } from '../../test/utils'; +import { assertSuccess } from '../../test/utils'; import { chainLeft } from './chainLeft'; describe('chainLeft', () => { @@ -9,7 +9,7 @@ describe('chainLeft', () => { const parser = chainLeft(digits, operation); const result = parser('abc'); - assertFailure(result); + assertSuccess(result, null, 0); }); it('should parse successful chain', () => { @@ -30,6 +30,6 @@ describe('chainLeft', () => { const parser = chainLeft(digits, operation); const result = parser(''); - assertFailure(result); + assertSuccess(result, null, 0); }); }); diff --git a/src/combinators/chainLeft.ts b/src/combinators/chainLeft.ts index 08cdda7..6dae7a1 100644 --- a/src/combinators/chainLeft.ts +++ b/src/combinators/chainLeft.ts @@ -1,6 +1,5 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; import { create } from '../core/parser'; import { chainLeft1 } from './chainLeft1'; import { optional } from './optional'; @@ -8,23 +7,15 @@ import { optional } from './optional'; /** * Chain left-associative operations (right-to-left for same precedence). * + * Zero or more terms: succeeds with `null` when there is no term at all. + * * @example * chainLeft(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5 } + * chainLeft(digits, operation)('') // { ok: true, value: null, index: 0 } */ export const chainLeft = ( parser: Parser, operator: Parser<(a: T, b: T) => T>, ) => { - return create((input, index = 0, ctx) => { - const result = optional(chainLeft1(parser, operator))( - input, - index, - ctx, - ); - if (!result.ok || result.value === null) { - return failure(ctx, index); - } - - return result; - }); + return create(optional(chainLeft1(parser, operator))); }; diff --git a/src/combinators/chainLeft1.test.ts b/src/combinators/chainLeft1.test.ts index 7b04b4f..6f92ebc 100644 --- a/src/combinators/chainLeft1.test.ts +++ b/src/combinators/chainLeft1.test.ts @@ -1,7 +1,9 @@ import { describe, it } from 'vitest'; -import { operation, digits } from '../../test/helpers'; +import { operation, digits, nothing } from '../../test/helpers'; import { assertFailure, assertSuccess } from '../../test/utils'; +import { create } from '../core/parser'; +import { success } from '../core/success'; import { chainLeft1 } from './chainLeft1'; describe('chainLeft1', () => { @@ -67,4 +69,13 @@ describe('chainLeft1', () => { assertSuccess(result, 3, 3); // Stops at incomplete operation }); + + it('should stop when neither term nor operator consumes input', () => { + const keep = create<(left: null, right: null) => null>( + (_input, index = 0) => success((left) => left, index), + ); + const parser = chainLeft1(nothing, keep); + + assertSuccess(parser('abc'), null, 0); + }); }); diff --git a/src/combinators/chainLeft1.ts b/src/combinators/chainLeft1.ts index eaf56e9..da5852e 100644 --- a/src/combinators/chainLeft1.ts +++ b/src/combinators/chainLeft1.ts @@ -33,7 +33,7 @@ export const chainLeft1 = ( if (!opResult.ok) break; const nextResult: Result = term(input, opResult.index, ctx); - if (!nextResult.ok) break; + if (!nextResult.ok || nextResult.index === at) break; accumulator = opResult.value(accumulator, nextResult.value); at = nextResult.index; diff --git a/src/combinators/chainRight.test.ts b/src/combinators/chainRight.test.ts index e4fa4e5..aac3dd5 100644 --- a/src/combinators/chainRight.test.ts +++ b/src/combinators/chainRight.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'vitest'; import { digits, operation } from '../../test/helpers'; -import { assertFailure, assertSuccess } from '../../test/utils'; +import { assertSuccess } from '../../test/utils'; import { chainRight } from './chainRight'; describe('chainRight', () => { @@ -9,7 +9,7 @@ describe('chainRight', () => { const parser = chainRight(digits, operation); const result = parser('abc'); - assertFailure(result); + assertSuccess(result, null, 0); }); it('should parse successful chain', () => { @@ -30,7 +30,7 @@ describe('chainRight', () => { const parser = chainRight(digits, operation); const result = parser(''); - assertFailure(result); + assertSuccess(result, null, 0); }); it('should demonstrate right associativity', () => { diff --git a/src/combinators/chainRight.ts b/src/combinators/chainRight.ts index dca8015..585f298 100644 --- a/src/combinators/chainRight.ts +++ b/src/combinators/chainRight.ts @@ -1,30 +1,21 @@ import type { Parser } from '../core/parser'; -import { failure } from '../core/failure'; import { create } from '../core/parser'; import { chainRight1 } from './chainRight1'; import { optional } from './optional'; /** - * Chain right-associative operations. + * Chain right-associative operations: `2^3^2` is evaluated as `2^(3^2)`. + * + * Zero or more terms: succeeds with `null` when there is no term at all. * * @example * chainRight(digits, operation)('1+2+3') // { ok: true, value: 6, index: 5 } + * chainRight(digits, operation)('') // { ok: true, value: null, index: 0 } */ export const chainRight = ( parser: Parser, operator: Parser<(a: T, b: T) => T>, ) => { - return create((input, index = 0, ctx) => { - const result = optional(chainRight1(parser, operator))( - input, - index, - ctx, - ); - if (!result.ok || result.value === null) { - return failure(ctx, index); - } - - return result; - }); + return create(optional(chainRight1(parser, operator))); }; diff --git a/src/combinators/chainRight1.test.ts b/src/combinators/chainRight1.test.ts index 0c6f40b..8a3a082 100644 --- a/src/combinators/chainRight1.test.ts +++ b/src/combinators/chainRight1.test.ts @@ -1,7 +1,9 @@ import { describe, it } from 'vitest'; -import { operation, digits } from '../../test/helpers'; +import { operation, digits, nothing } from '../../test/helpers'; import { assertFailure, assertSuccess } from '../../test/utils'; +import { create } from '../core/parser'; +import { success } from '../core/success'; import { chainRight1 } from './chainRight1'; describe('chainRight1', () => { @@ -53,4 +55,13 @@ describe('chainRight1', () => { assertSuccess(result, 2, 1); // Stops at incomplete operation }); + + it('should stop when neither term nor operator consumes input', () => { + const keep = create<(left: null, right: null) => null>( + (_input, index = 0) => success((left) => left, index), + ); + const parser = chainRight1(nothing, keep); + + assertSuccess(parser('abc'), null, 0); + }); }); diff --git a/src/combinators/chainRight1.ts b/src/combinators/chainRight1.ts index ba7cfc1..f40f6fc 100644 --- a/src/combinators/chainRight1.ts +++ b/src/combinators/chainRight1.ts @@ -21,7 +21,8 @@ export const chainRight1 = ( } const opResult = operator(input, leftResult.index, ctx); - if (!opResult.ok) { + // Recursing at the offset we started from would never end. + if (!opResult.ok || opResult.index === index) { return success(leftResult.value, leftResult.index); } diff --git a/src/combinators/interleaved.test.ts b/src/combinators/interleaved.test.ts index d9c5945..d085f5b 100644 --- a/src/combinators/interleaved.test.ts +++ b/src/combinators/interleaved.test.ts @@ -1,5 +1,6 @@ import { describe, it } from 'vitest'; +import { nothing } from '../../test/helpers'; import { assertFailure, assertSuccess, @@ -43,4 +44,10 @@ describe('interleaved', () => { assertSuccess<('A' | 'B')[]>(result, ['A', 'B', 'A'], 3); }); + + it('should stop when neither item nor separator consumes input', () => { + const parser = interleaved(nothing, nothing); + + assertSuccess(parser('abc'), [null], 0); + }); }); diff --git a/src/combinators/interleaved.ts b/src/combinators/interleaved.ts index cd1aa00..4f6b621 100644 --- a/src/combinators/interleaved.ts +++ b/src/combinators/interleaved.ts @@ -25,7 +25,7 @@ export const interleaved = (item: Parser, separator: Parser) => { if (!sepResult.ok) break; const nextResult: Result = item(input, sepResult.index, ctx); - if (!nextResult.ok) break; + if (!nextResult.ok || nextResult.index === at) break; results.push(sepResult.value); results.push(nextResult.value); diff --git a/src/combinators/manyTill.test.ts b/src/combinators/manyTill.test.ts index 1e14dc2..67b5ce6 100644 --- a/src/combinators/manyTill.test.ts +++ b/src/combinators/manyTill.test.ts @@ -1,5 +1,6 @@ import { describe, it } from 'vitest'; +import { nothing } from '../../test/helpers'; import { assertFailure, assertSuccess, @@ -43,4 +44,10 @@ describe('manyTill', () => { assertSuccess(result, ['A', 'A'], 3); }); + + it('should fail instead of looping when the parser consumes nothing', () => { + const parser = manyTill(nothing, createTestParser('x')); + + assertFailure(parser('abc')); + }); }); diff --git a/src/combinators/manyTill.ts b/src/combinators/manyTill.ts index b044004..17f70d6 100644 --- a/src/combinators/manyTill.ts +++ b/src/combinators/manyTill.ts @@ -25,6 +25,11 @@ export const manyTill = (parser: Parser, terminator: Parser) => { return parseResult; } + // A parser that consumes nothing would never reach the terminator. + if (parseResult.index === at) { + return termResult; + } + results.push(parseResult.value); at = parseResult.index; } diff --git a/src/combinators/optionalSeparatedBy.test.ts b/src/combinators/optionalSeparatedBy.test.ts index cd13d75..47e6250 100644 --- a/src/combinators/optionalSeparatedBy.test.ts +++ b/src/combinators/optionalSeparatedBy.test.ts @@ -1,5 +1,6 @@ import { describe, it } from 'vitest'; +import { nothing } from '../../test/helpers'; import { assertSuccess, createTestParser } from '../../test/utils'; import { optionalSeparatedBy } from './optionalSeparatedBy'; @@ -73,4 +74,20 @@ describe('optionalSeparatedBy', () => { assertSuccess<(string | null)[]>(result, [null], 1); }); + + it('should keep empty slots between separators as null', () => { + const parser = optionalSeparatedBy( + createTestParser(1), + createTestParser(','), + ); + + assertSuccess<(number | null)[]>(parser('1,,1'), [1, null, 1], 4); + assertSuccess<(number | null)[]>(parser(',,1'), [null, null, 1], 3); + }); + + it('should stop when neither item nor separator consumes input', () => { + const parser = optionalSeparatedBy(nothing, nothing); + + assertSuccess(parser('abc'), [null], 0); + }); }); diff --git a/src/combinators/optionalSeparatedBy.ts b/src/combinators/optionalSeparatedBy.ts index 5c440f1..88bed00 100644 --- a/src/combinators/optionalSeparatedBy.ts +++ b/src/combinators/optionalSeparatedBy.ts @@ -7,8 +7,12 @@ import { success } from '../core/success'; /** * Parse items separated by a separator, allowing empty slots. * + * A missing item before or between separators becomes `null`; an empty slot + * after the last separator is dropped. + * * @example * optionalSeparatedBy(digits, char(','))('1,2') // { ok: true, value: [1, 2], index: 3 } + * optionalSeparatedBy(digits, char(','))('1,,2') // { ok: true, value: [1, null, 2], index: 4 } */ export const optionalSeparatedBy = ( parser: Parser, @@ -17,36 +21,42 @@ export const optionalSeparatedBy = ( return create<(T | null)[]>((input, index = 0, ctx) => { const results: (T | null)[] = []; let at = index; + let empty = false; - const leadingSep = separator(input, at, ctx); - - if (leadingSep.ok) { - results.push(null); - at = leadingSep.index; - } + const slot = () => { + const result: Result = parser(input, at, ctx); + empty = !result.ok; + if (result.ok) { + results.push(result.value); + at = result.index; + } else { + results.push(null); + } + }; - const firstResult: Result = parser(input, at, ctx); - if (firstResult.ok) { - results.push(firstResult.value); - at = firstResult.index; - } else if (!leadingSep.ok) { - return success([], index); - } + slot(); while (true) { + const start = at; const sepResult: Result = separator(input, at, ctx); if (!sepResult.ok) { break; } at = sepResult.index; + const wasEmpty = empty; + slot(); - const nextResult: Result = parser(input, at, ctx); - if (!nextResult.ok) { + // Without progress this slot would repeat forever: drop it and stop. + if (at === start) { + results.pop(); + empty = wasEmpty; break; } + } - results.push(nextResult.value); - at = nextResult.index; + // An empty slot after the last separator is not an item. + if (empty) { + results.pop(); } return success(results, at); diff --git a/src/combinators/postfix.test.ts b/src/combinators/postfix.test.ts index 7feea05..71f7124 100644 --- a/src/combinators/postfix.test.ts +++ b/src/combinators/postfix.test.ts @@ -77,4 +77,14 @@ describe('postfix', () => { assertSuccess(result, 6, 2); // 3! = 6, stops at + }); + + it('should stop at an operator that consumes nothing', () => { + const increment: Parser<(value: number) => number> = ( + _input, + index = 0, + ) => success((n) => n + 1, index); + const parser = postfix(digits, increment); + + assertSuccess(parser('5'), 5, 1); + }); }); diff --git a/src/combinators/postfix.ts b/src/combinators/postfix.ts index b30d3e4..8884cff 100644 --- a/src/combinators/postfix.ts +++ b/src/combinators/postfix.ts @@ -25,7 +25,7 @@ export const postfix = ( while (true) { const opResult: Result<(value: T) => T> = operator(input, at, ctx); - if (!opResult.ok) break; + if (!opResult.ok || opResult.index === at) break; value = opResult.value(value); at = opResult.index; diff --git a/src/combinators/prefix.test.ts b/src/combinators/prefix.test.ts index 71964be..d308211 100644 --- a/src/combinators/prefix.test.ts +++ b/src/combinators/prefix.test.ts @@ -74,4 +74,14 @@ describe('prefix', () => { assertSuccess(result, 123, 3); }); + + it('should stop at an operator that consumes nothing', () => { + const increment: Parser<(value: number) => number> = ( + _input, + index = 0, + ) => success((n) => n + 1, index); + const parser = prefix(increment, digits); + + assertSuccess(parser('5'), 5, 1); + }); }); diff --git a/src/combinators/prefix.ts b/src/combinators/prefix.ts index dc8f602..5d20b27 100644 --- a/src/combinators/prefix.ts +++ b/src/combinators/prefix.ts @@ -20,7 +20,7 @@ export const prefix = ( while (true) { const opResult: Result<(value: T) => T> = operator(input, at, ctx); - if (!opResult.ok) break; + if (!opResult.ok || opResult.index === at) break; operators.push(opResult.value); at = opResult.index; diff --git a/src/combinators/separatedBy.test.ts b/src/combinators/separatedBy.test.ts index d4c2da0..dc2e1bd 100644 --- a/src/combinators/separatedBy.test.ts +++ b/src/combinators/separatedBy.test.ts @@ -1,5 +1,6 @@ import { describe, it } from 'vitest'; +import { nothing } from '../../test/helpers'; import { assertSuccess, createTestParser } from '../../test/utils'; import { separatedBy } from './separatedBy'; @@ -53,4 +54,10 @@ describe('separatedBy', () => { assertSuccess<'A'[]>(result, ['A', 'A'], 3); }); + + it('should stop when neither item nor separator consumes input', () => { + const parser = separatedBy(nothing, nothing); + + assertSuccess(parser('abc'), [null], 0); + }); }); diff --git a/src/combinators/separatedBy.ts b/src/combinators/separatedBy.ts index f6c8990..59bb85a 100644 --- a/src/combinators/separatedBy.ts +++ b/src/combinators/separatedBy.ts @@ -30,7 +30,7 @@ export const separatedBy = (parser: Parser, separator: Parser) => { const nextResult: Result = parser(input, sepResult.index, ctx); // If separator matched but parser failed, backtrack // Don't consume the separator - if (!nextResult.ok) break; + if (!nextResult.ok || nextResult.index === at) break; results.push(nextResult.value); at = nextResult.index; diff --git a/src/combinators/separatedEndBy.test.ts b/src/combinators/separatedEndBy.test.ts index 262f3f7..049f6d1 100644 --- a/src/combinators/separatedEndBy.test.ts +++ b/src/combinators/separatedEndBy.test.ts @@ -53,4 +53,13 @@ describe('separatedEndBy', () => { assertSuccess<'A'[]>(result, ['A'], 1); }); + + it('should not consume a separator without a preceding item', () => { + const parser = separatedEndBy( + createTestParser(1), + createTestParser(','), + ); + + assertSuccess(parser(','), [], 0); + }); }); diff --git a/src/combinators/separatedEndBy.ts b/src/combinators/separatedEndBy.ts index ff66720..e7dc07e 100644 --- a/src/combinators/separatedEndBy.ts +++ b/src/combinators/separatedEndBy.ts @@ -9,6 +9,7 @@ import { separatedBy } from './separatedBy'; * * @example * separatedEndBy(digits, char(','))('1,2,3,') // { ok: true, value: [1, 2, 3], index: 6 } + * separatedEndBy(digits, char(','))(',') // { ok: true, value: [], index: 0 } */ export const separatedEndBy = (parser: Parser, separator: Parser) => { return create((input, index = 0, ctx) => { @@ -17,6 +18,10 @@ export const separatedEndBy = (parser: Parser, separator: Parser) => { index, ctx, ) as Success; + if (result.value.length === 0) { + return result; + } + const sepResult = separator(input, result.index, ctx); return success( diff --git a/src/combinators/until.test.ts b/src/combinators/until.test.ts index 0189150..323fe9f 100644 --- a/src/combinators/until.test.ts +++ b/src/combinators/until.test.ts @@ -1,5 +1,6 @@ import { describe, it } from 'vitest'; +import { nothing } from '../../test/helpers'; import { assertFailure, assertSuccess, @@ -36,4 +37,10 @@ describe('until', () => { assertFailure<'A'[]>(result); }); + + it('should fail instead of looping when the parser consumes nothing', () => { + const parser = until(nothing, createTestParser('x')); + + assertFailure(parser('abc')); + }); }); diff --git a/src/combinators/until.ts b/src/combinators/until.ts index a5f4efc..dbcc40c 100644 --- a/src/combinators/until.ts +++ b/src/combinators/until.ts @@ -25,6 +25,11 @@ export const until = (parser: Parser, terminator: Parser) => { return parseResult; } + // A parser that consumes nothing would never reach the terminator. + if (parseResult.index === at) { + return termResult; + } + results.push(parseResult.value); at = parseResult.index; } diff --git a/src/core/grammar.test.ts b/src/core/grammar.test.ts index 04c0959..120eea4 100644 --- a/src/core/grammar.test.ts +++ b/src/core/grammar.test.ts @@ -106,4 +106,20 @@ describe('grammar', () => { 'Left recursion detected in grammar rule "expr" at offset 0', ); }); + + it('should build each rule only once', () => { + let builds = 0; + const g = grammar<{ x: string }>({ + x: () => { + builds++; + + return char('x'); + }, + }); + + g.x('x'); + g.x('x'); + + expect(builds).toBe(1); + }); }); diff --git a/src/core/grammar.ts b/src/core/grammar.ts index 8be3801..7ae256e 100644 --- a/src/core/grammar.ts +++ b/src/core/grammar.ts @@ -13,6 +13,7 @@ const guardLeftRecursion = ( thunk: () => Parser, ): Parser => { const active: number[] = []; + let parser: Parser | undefined; return (input, index = 0, ctx) => { if (active.includes(index)) { @@ -25,7 +26,7 @@ const guardLeftRecursion = ( active.push(index); try { - return thunk()(input, index, ctx); + return (parser ??= thunk())(input, index, ctx); } finally { active.pop(); } diff --git a/src/core/lazy.test.ts b/src/core/lazy.test.ts index 0eb11db..5a2c146 100644 --- a/src/core/lazy.test.ts +++ b/src/core/lazy.test.ts @@ -99,4 +99,18 @@ describe('lazy', () => { assertFailure(result); }); + + it('should create the parser only once', () => { + let builds = 0; + const parser = lazy(() => { + builds++; + + return createTestParser('a'); + }); + + parser('a'); + parser('a'); + + expect(builds).toBe(1); + }); }); diff --git a/src/core/lazy.ts b/src/core/lazy.ts index 6b0e9d2..41acba8 100644 --- a/src/core/lazy.ts +++ b/src/core/lazy.ts @@ -1,6 +1,4 @@ -import type { Parser } from './parser'; - -import { create } from './parser'; +import { create, type Parser } from './parser'; /** * Defers parser creation, useful for recursive grammars. @@ -9,5 +7,9 @@ import { create } from './parser'; * lazy(() => char('a'))('abc') // { ok: true, value: 'a', index: 1 } */ export const lazy = (thunk: () => Parser) => { - return create((input, index = 0, ctx) => thunk()(input, index, ctx)); + let parser: Parser | undefined; + + return create((input, index = 0, ctx) => + (parser ??= thunk())(input, index, ctx), + ); }; diff --git a/src/core/run.ts b/src/core/run.ts index 8d4c692..d562358 100644 --- a/src/core/run.ts +++ b/src/core/run.ts @@ -24,7 +24,7 @@ const merge = ( * * 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 + * that fail without an expectation of their own — `separatedBy1`, `guard`, a * hand-written `failure(ctx, index)` — would otherwise report nothing at all. */ export const diagnose = ( diff --git a/src/terminals/oneOf.ts b/src/terminals/oneOf.ts index d840730..9bfaed0 100644 --- a/src/terminals/oneOf.ts +++ b/src/terminals/oneOf.ts @@ -3,10 +3,14 @@ import { create } from '../core/parser'; import { success } from '../core/success'; /** - * Parse one string from a set of strings (longest match wins). + * Parse one string from a set of strings. + * + * Candidates are tried in order and the first match wins, so list a longer + * string before any of its prefixes. * * @example * oneOf(['hello', 'hell', 'help'])('helpful') // { ok: true, value: 'help', index: 4 } + * oneOf(['+=', '+'])('+=1') // { ok: true, value: '+=', index: 2 } */ export const oneOf = (strings: readonly [S, ...S[]]) => { const described = strings.map((s) => `'${s}'`); diff --git a/src/terminals/regex.ts b/src/terminals/regex.ts index ebcb0aa..f35bcef 100644 --- a/src/terminals/regex.ts +++ b/src/terminals/regex.ts @@ -8,7 +8,8 @@ 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 — unless the pattern is multiline, where it asserts a line - * start and still carries meaning. + * start and still carries meaning. Only the leading `^` is stripped: in + * `/^a|^b/` the second `^` still means start of input, so write `/^(?:a|b)/`. * * @example * regex(/^\w+/)('hello world') // { ok: true, value: 'hello', index: 5 } diff --git a/test/examples.test.ts b/test/examples.test.ts index 5b1ff28..6dea3a2 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -35,9 +35,11 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'hi', index: 4 }); }); - it('chainLeft: Chain left-associative operations (right-to-left for same precedence).', () => { + it('chainLeft: Zero or more terms: succeeds with `null` when there is no term at all.', () => { const result0 = chainLeft(digits, operation)('1+2+3'); expect(result0).toEqual({ ok: true, value: 6, index: 5 }); + const result1 = chainLeft(digits, operation)(''); + expect(result1).toEqual({ ok: true, value: null, index: 0 }); }); it('chainLeft1: Chain left-associative operations (fails on empty input).', () => { @@ -47,9 +49,11 @@ describe('examples from source', () => { expect(result1).toEqual({ ok: true, value: 12, index: 5 }); }); - it('chainRight: Chain right-associative operations.', () => { + it('chainRight: Zero or more terms: succeeds with `null` when there is no term at all.', () => { const result0 = chainRight(digits, operation)('1+2+3'); expect(result0).toEqual({ ok: true, value: 6, index: 5 }); + const result1 = chainRight(digits, operation)(''); + expect(result1).toEqual({ ok: true, value: null, index: 0 }); }); it('chainRight1: Chain right-associative operations (fails on empty input).', () => { @@ -281,9 +285,15 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, index: 2 }); }); - it('optionalSeparatedBy: Parse items separated by a separator, allowing empty slots.', () => { + it('optionalSeparatedBy: after the last separator is dropped.', () => { const result0 = optionalSeparatedBy(digits, char(','))('1,2'); expect(result0).toEqual({ ok: true, value: [1, 2], index: 3 }); + const result1 = optionalSeparatedBy(digits, char(','))('1,,2'); + expect(result1).toEqual({ + ok: true, + value: [1, null, 2], + index: 4, + }); }); it('outer: Extract outer values from a sequence of 3 parsers (skip middle).', () => { @@ -358,6 +368,8 @@ describe('examples from source', () => { 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 }); + const result1 = separatedEndBy(digits, char(','))(','); + expect(result1).toEqual({ ok: true, value: [], index: 0 }); }); it('separatedEndBy1: Parse one or more items separated by a separator, allowing a trailing one.', () => { @@ -817,12 +829,14 @@ describe('examples from source', () => { expect(result0).toEqual({ ok: true, value: 'x', index: 1 }); }); - it('oneOf: Parse one string from a set of strings (longest match wins).', () => { + it('oneOf: string before any of its prefixes.', () => { const result0 = oneOf(['hello', 'hell', 'help'])('helpful'); expect(result0).toEqual({ ok: true, value: 'help', index: 4 }); + const result1 = oneOf(['+=', '+'])('+=1'); + expect(result1).toEqual({ ok: true, value: '+=', index: 2 }); }); - it('regex: start and still carries meaning.', () => { + it('regex: `/^a|^b/` the second `^` still means start of input, so write `/^(?:a|b)/`.', () => { const result0 = regex(/^\w+/)('hello world'); expect(result0).toEqual({ ok: true, value: 'hello', index: 5 }); }); diff --git a/test/helpers.ts b/test/helpers.ts index f52cb7c..22d85d3 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -27,3 +27,8 @@ export const digits = create((input, index = 0) => { return success(parseInt(match), index + match.length); }); + +/** Always succeeds without consuming input, to check that loops terminate. */ +export const nothing = create((_input, index = 0) => + success(null, index), +);