Skip to content

fix(core): pin the parser's line-ending and blank-line boundaries - #40

Merged
kewang merged 1 commit into
masterfrom
fix/task-parser-runtime-parity
Aug 6, 2026
Merged

fix(core): pin the parser's line-ending and blank-line boundaries#40
kewang merged 1 commit into
masterfrom
fix/task-parser-runtime-parity

Conversation

@kewang

@kewang kewang commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #33.

The problem

parseTasks and the Kotlin TaskParser are one rule written in two languages, and each left two
boundary decisions to its runtime — where the two runtimes disagree, and case-mirrored unit tests
cannot notice, because both sides spell the rule the same way.

Line endings. Java's regex $ also matches before a trailing line terminator; JavaScript's does
not. A tasks.md leaving a bare \r at the end of a line therefore reported a different total in
IntelliJ than in Web and VS Code.

Blank lines. isBlank() and trim() === "" cover different whitespace, so an NBSP-only line
moved the continuation boundary on one side only. This one was found while verifying the first.

Neither side was right

Measured against the reference renderer (react-markdown + remark-gfm, the same reference the
existing folding tests compare to):

Source Reference Kotlin TypeScript
- [x] a\r 1 1 0
- [x] a\r\r\n- [x] b 2 2 1
- [x] a\n- [x] b\r 2 2 1
- [x] a\r- [x] b 2 0 0
Blank-line candidate Reference Kotlin TypeScript
U+00A0, U+FEFF, U+2007, U+202F not blank not blank blank
U+001C not blank blank not blank

CommonMark counts a lone \r as a line ending and counts only spaces and tabs as blank. Aligning
Kotlin down to TypeScript's answer — the fix the issue suggests — would have settled both on the
wrong result and left the last row of the first table broken on both sides.

What changed

  • All three CommonMark line endings normalised to \n before the split, on both sides.
  • An explicit [ \t] predicate does the blank test and the trims on both sides, replacing
    trim() === "" / isBlank() at four sites each.
  • Kotlin's CHECKBOX_RE and SECTION_RE move to \z, matching what BLOCK_OPENER_RE already does.
    Not cosmetic: Java's $ also admits U+2028 / U+2029, which \z closes.
  • Mirrored tests on both sides for every case, written with escape sequences rather than literal
    characters.

U+0085 stays divergent and is pinned by a test on each side rather than fixed — it is an ordinary
character to JS's . and a terminator to Java's, and closing it needs an identical negated class in
both engines' (.+), which is not worth the cost for a character with no natural source in a task
list.

Verification

Each new test was checked against a temporarily reverted implementation, since a test that passes on
the old code proves nothing: 5 turn red on the TypeScript side, 4 on the Kotlin side. The two sets
differ because each guards the direction the other runtime got wrong; the rest are regression
guards and the documented exception.

Gates: type-check, lint, npm test (207 + 24 + 60), ./gradlew test (40) — all green.

Note for whoever cuts the release

@spekjs/core changes observable output for files with CR line endings or exotic whitespace (nothing
in this repo — verified by scanning every .md for those characters). That warrants a minor bump,
not a patch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HjHibWXs9MTLcVmewMXDrL

parseTasks and the Kotlin TaskParser are one rule written in two
languages, and each left two boundary decisions to its runtime, where the
two disagree. Java's regex `$` also matches before a trailing line
terminator while JavaScript's does not, so a tasks.md ending a line in a
bare CR reported a different total in IntelliJ than in Web and VS Code
(#33). Separately, `isBlank()` and `trim() === ""` cover different
whitespace, so an NBSP-only line moved the continuation boundary on one
side only.

Measured against the reference renderer, neither side was right. It
counts a lone CR as a line ending, which both parsers dropped, and counts
only spaces and tabs as blank, where each runtime errs in one direction.
So both boundaries are now stated rather than inherited: all three
CommonMark line endings are normalised before the split, and an explicit
[ \t] predicate does the blank test and the trims on both sides. Kotlin's
CHECKBOX_RE and SECTION_RE move to \z, which also closes U+2028/U+2029.

U+0085 stays divergent, pinned by a test on each side rather than fixed:
closing it needs an identical negated class in both engines' `(.+)`,
which is not worth the cost in the parser's two most load-bearing
patterns for a character with no natural source in a task list.

Case-mirrored tests cannot catch this class of drift by construction, so
the spelling is the control. A shared-fixture corpus that could catch it
structurally is left as follow-up.

Closes #33

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjHibWXs9MTLcVmewMXDrL
@kewang
kewang merged commit 2bce9be into master Aug 6, 2026
3 checks passed
@kewang
kewang deleted the fix/task-parser-runtime-parity branch August 6, 2026 03:38
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.

TaskParser.kt counts a checkbox whose line ends in a bare CR; @spekjs/core does not

1 participant