Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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. |
Expand Down
10 changes: 7 additions & 3 deletions doc/api/combinators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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`
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand Down
5 changes: 3 additions & 2 deletions doc/api/terminals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
6 changes: 3 additions & 3 deletions src/combinators/chainLeft.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
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', () => {
it('should return null when parser fails', () => {
const parser = chainLeft(digits, operation);
const result = parser('abc');

assertFailure(result);
assertSuccess<number | null>(result, null, 0);
});

it('should parse successful chain', () => {
Expand All @@ -30,6 +30,6 @@ describe('chainLeft', () => {
const parser = chainLeft(digits, operation);
const result = parser('');

assertFailure(result);
assertSuccess<number | null>(result, null, 0);
});
});
17 changes: 4 additions & 13 deletions src/combinators/chainLeft.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,21 @@
import type { Parser } from '../core/parser';

import { failure } from '../core/failure';
import { create } from '../core/parser';
import { chainLeft1 } from './chainLeft1';
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 = <T>(
parser: Parser<T>,
operator: Parser<(a: T, b: T) => T>,
) => {
return create<T | null>((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<T | null>(optional(chainLeft1(parser, operator)));
};
13 changes: 12 additions & 1 deletion src/combinators/chainLeft1.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -67,4 +69,13 @@ describe('chainLeft1', () => {

assertSuccess<number>(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<null>(parser('abc'), null, 0);
});
});
2 changes: 1 addition & 1 deletion src/combinators/chainLeft1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const chainLeft1 = <T>(
if (!opResult.ok) break;

const nextResult: Result<T> = 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;
Expand Down
6 changes: 3 additions & 3 deletions src/combinators/chainRight.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
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', () => {
it('should return null when parser fails', () => {
const parser = chainRight(digits, operation);
const result = parser('abc');

assertFailure(result);
assertSuccess<number | null>(result, null, 0);
});

it('should parse successful chain', () => {
Expand All @@ -30,7 +30,7 @@ describe('chainRight', () => {
const parser = chainRight(digits, operation);
const result = parser('');

assertFailure(result);
assertSuccess<number | null>(result, null, 0);
});

it('should demonstrate right associativity', () => {
Expand Down
19 changes: 5 additions & 14 deletions src/combinators/chainRight.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(
parser: Parser<T>,
operator: Parser<(a: T, b: T) => T>,
) => {
return create<T | null>((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<T | null>(optional(chainRight1(parser, operator)));
};
13 changes: 12 additions & 1 deletion src/combinators/chainRight1.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -53,4 +55,13 @@ describe('chainRight1', () => {

assertSuccess<number>(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<null>(parser('abc'), null, 0);
});
});
3 changes: 2 additions & 1 deletion src/combinators/chainRight1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export const chainRight1 = <T>(
}

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);
}

Expand Down
7 changes: 7 additions & 0 deletions src/combinators/interleaved.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it } from 'vitest';

import { nothing } from '../../test/helpers';
import {
assertFailure,
assertSuccess,
Expand Down Expand Up @@ -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<null[]>(parser('abc'), [null], 0);
});
});
2 changes: 1 addition & 1 deletion src/combinators/interleaved.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const interleaved = <T, S>(item: Parser<T>, separator: Parser<S>) => {
if (!sepResult.ok) break;

const nextResult: Result<T> = item(input, sepResult.index, ctx);
if (!nextResult.ok) break;
if (!nextResult.ok || nextResult.index === at) break;

results.push(sepResult.value);
results.push(nextResult.value);
Expand Down
7 changes: 7 additions & 0 deletions src/combinators/manyTill.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it } from 'vitest';

import { nothing } from '../../test/helpers';
import {
assertFailure,
assertSuccess,
Expand Down Expand Up @@ -43,4 +44,10 @@ describe('manyTill', () => {

assertSuccess<string[]>(result, ['A', 'A'], 3);
});

it('should fail instead of looping when the parser consumes nothing', () => {
const parser = manyTill(nothing, createTestParser('x'));

assertFailure(parser('abc'));
});
});
5 changes: 5 additions & 0 deletions src/combinators/manyTill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export const manyTill = <T, U>(parser: Parser<T>, terminator: Parser<U>) => {
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;
}
Expand Down
17 changes: 17 additions & 0 deletions src/combinators/optionalSeparatedBy.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it } from 'vitest';

import { nothing } from '../../test/helpers';
import { assertSuccess, createTestParser } from '../../test/utils';
import { optionalSeparatedBy } from './optionalSeparatedBy';

Expand Down Expand Up @@ -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<null[]>(parser('abc'), [null], 0);
});
});
Loading
Loading