Skip to content

fix(combinators): stop non-advancing loops and correct edge cases - #4

Merged
sovrin merged 1 commit into
masterfrom
sovrin/fix-combinator-logic
Oct 19, 2026
Merged

sovrin merged 1 commit into
masterfrom
sovrin/fix-combinator-logic

Conversation

@sovrin

@sovrin sovrin commented Oct 19, 2026

Copy link
Copy Markdown
Owner

Fixes logic bugs found while reviewing the library. Each one now has a regression test.

Infinite loops. manyTill, until, postfix, prefix, separatedBy, interleaved, chainLeft1, chainRight1 and optionalSeparatedBy looped forever when an iteration consumed no input (e.g. postfix(digit, pure(x => x))), until the process ran out of memory. They now stop the same way many already did.

Breaking: chainLeft / chainRight. They wrapped the 1 variant in optional and then failed on null, so they behaved like chainLeft1/chainRight1 and also failed when a term legitimately parsed to null. They now succeed with null on zero terms, which matches their T | null type and the zero-or-more pattern used by many, separatedBy and fold.

grammar / lazy. These rebuilt the parser on every call, so a memoize inside a rule never got a cache hit. Each parser is now built once.

separatedEndBy. No longer consumes a separator that has no item before it (',' → [] at index 0).

optionalSeparatedBy. Empty slots between separators are now kept as null ('1,,2' → [1, null, 2]). Before, parsing stopped at the second comma.

Docs only. The oneOf docs claimed "longest match wins", but the code and its original tests use first match, so the docs now say that. regex now documents that only a leading ^ is stripped.

README.md, doc/api/ and test/examples.test.ts were regenerated. All 1386 tests and lint pass.

🤖 Generated with Claude Code

- manyTill, until, postfix, prefix, separatedBy, interleaved, chainLeft1,
  chainRight1 and optionalSeparatedBy stop when an iteration consumes no
  input, instead of looping until the process runs out of memory
- chainLeft/chainRight now succeed with null on zero terms, as their
  type and the zero-or-more naming promise, instead of duplicating the
  `1` variants
- grammar and lazy build each parser once, so memoize inside a rule
  actually caches
- separatedEndBy no longer consumes a separator that has no item before it
- optionalSeparatedBy keeps empty slots between separators as null
- oneOf docs now say first match wins, which is what the code and its
  tests always did; regex documents that only a leading ^ is stripped

BREAKING CHANGE: chainLeft and chainRight return { ok: true, value: null }
on input with no term, where they previously failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sovrin
sovrin merged commit 008ddc6 into master Oct 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant