Skip to content

feat: add the DSL markup lexer - #24

Merged
widgetii merged 2 commits into
mainfrom
feat/m6-dsl-lexer
Oct 4, 2026
Merged

widgetii merged 2 commits into
mainfrom
feat/m6-dsl-lexer

Conversation

@widgetii

@widgetii widgetii commented Oct 4, 2026

Copy link
Copy Markdown
Member

First pull request for M6, the BKRS importer. This is layer three of four: turning a line of card body into text and markup. Pure, in the core, validated against the real 350 MB set.

Escapes before tags, which is the whole design

DSL writes a literal bracket as \[, and the BKRS set contains 55,414 of them — a dictionary of Chinese grammar is full of bracketed glosses.

The predecessor's stripDSL recognised tags first: every [ began a tag and everything to the next ] was deleted, so \[обладать\] lost the word inside it. Every "unknown tag" found while investigating that prototype turned out to be an escape artefact, and handling the backslash first makes the whole category disappear.

Nothing is deleted

A span the lexer cannot identify is emitted as literal text and reported. A dictionary that silently drops what it does not understand is worse than one showing a stray bracket: the reader cannot tell "no entry here" from "the importer ate it".

A real bug this found

A zero-width non-joiner binds to the character before it, so ] followed by U+200C is a single Swift Character that does not compare equal to "]". A character-based scanner walks past the closing bracket and swallows the rest of the line into one unknown tag.

Five tags in 13,739,066 tokens are written that way — a rate no hand-written fixture would ever reach. The lexer now scans unicode scalars, which is correct anyway: markup is made of scalars, only text is made of characters.

Same trap as CRLF being one grapheme cluster, which is what stops split(separator: "\n") splitting a CRLF file. That one was caught in M2; this is its sibling.

Validation

Lexing all three files, start to finish:

cards 3,434,224
tokens 13,739,066
unknown tags 0
unterminated brackets 0
elapsed 27 s

Two corrections to the plan

The plan's figures came from an earlier survey; a full scan of this file set disagrees on two:

  • Escaped brackets are 55,414, not ~175k. 175,480 is the [*] tag count, which the plan appears to have conflated.
  • 62 [*] spans contain no [ex], not 6. That path still must not crash — the card builder's problem, carried forward.

Confirmed unchanged: 3,434,224 cards, 77% with _ for pinyin, and the complete tag set is [m1]–[m4], [b], [i], [p], [c], [ex], [*], [ref] with nothing else.

Also noted for the builder: [mN] opens outnumber [/m] closes by 2 across 3.4 M cards, so it cannot assume balance.

A deviation worth your approval

The plan called for committing a ~200-card slice of the real BKRS as a fixture. CONTRIBUTING.md forbids committing BKRS data outright, and the two instructions contradict each other.

I chose the conservative reading: fixtures are hand-written to cover every tag and structure, and the full-set validation above runs against a local copy that is never committed. The hand-written cases cannot prove the parser handles the real file — the full-set run is what proves that, and anyone with the dictionary can reproduce it.

If you would rather have the real slice committed, say so and I will add it with an extraction script.

Next

Layer four, the card builder: [mN] nesting on a level-indexed stack, examples, part-of-speech labels and style runs. Then the byte reader and #INCLUDE discovery, then the resumable pipeline.

Local: 22 new tests, 421 total, lint clean, both app bundles build.

🤖 Generated with Claude Code

Layer three of the BKRS importer: turning a line of card body into text and
markup. Pure, in the core, and validated against the real 350 MB set.

Escapes are handled before tags, and that ordering is the whole design. DSL
writes a literal bracket as `\[`, and the set contains 55,414 of them — a
dictionary of Chinese grammar is full of bracketed glosses. The predecessor's
`stripDSL` recognised tags first, so every `[` began a tag and everything to
the next `]` was deleted: `\[обладать\]` lost the word inside it. Every
"unknown tag" found while investigating that prototype was an escape
artefact, and handling the backslash first makes the whole category vanish.

Nothing is ever deleted. A span the lexer cannot identify is emitted as
literal text *and* reported, because a dictionary that silently drops what it
does not understand is worse than one that shows a stray bracket — the reader
cannot tell "no entry here" from "the importer ate it".

Running it over all three files found a bug worth the exercise. A
**zero-width non-joiner binds to the character before it**, so `]` followed
by U+200C is a single Swift `Character` that does not compare equal to `"]"`.
A character-based scanner walks past the closing bracket and swallows the
rest of the line into one unknown tag. Five tags in 13,739,066 tokens are
written that way — a rate no hand-written fixture would ever reach. The lexer
now scans unicode scalars, which is right anyway: markup is made of scalars,
only text is made of characters. It is the same trap as CRLF being one
grapheme cluster, which is what stops `split(separator: "\n")` splitting a
CRLF file.

Validated on the full set: 3,434,224 cards, 13,739,066 tokens, **zero**
unknown tags and zero unterminated brackets, in 27 seconds.

Two numbers from the plan are corrected by that scan. Escaped brackets are
55,414, not ~175k — 175,480 is the `[*]` tag count, which the plan appears to
have conflated. And 62 `[*]` spans contain no `[ex]`, not 6; that path still
must not crash, which is the card builder's problem.

No BKRS data is committed. The plan called for a 200-card slice of the real
dictionary as a fixture, which CONTRIBUTING forbids outright, so the fixtures
are hand-written to cover every tag and the validation above runs against a
local copy instead. The hand-written cases cannot prove the parser handles
the real file; the full-set run is what proves that, and it is reproducible
by anyone who has the dictionary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add a lossless DSL markup lexer for BKRS card bodies

✨ Enhancement 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Add a core lexer that separates BKRS card text from known DSL markup for the importer.
• Process escapes before tags and retain malformed spans as text with diagnostics to prevent lost
 definitions.
• Test tag coverage, multiple senses, escaped brackets, and Unicode scalar edge cases.
Diagram

graph TD
  Input["Card body line"] --> Scan["Scalar scanner"] --> Branch{"Escape or tag?"} -->|tag| Parser["Tag parser"] -->|recognized| Tokens["DSL tokens"]
  Parser -->|unknown| Diagnostics["Diagnostics"] -->|literal text| Tokens
  Branch -->|escaped or plain text| Tokens
  Scan -->|unterminated bracket| Diagnostics
Loading
High-Level Assessment

Keep the scalar-based, single-pass lexer in HanReaderCore. A character-based scanner misses brackets adjacent to certain Unicode scalars, while stripping or regex-based parsing would make escape precedence and lossless anomaly reporting harder to guarantee. The separate token stream leaves sense nesting and presentation to the later card builder.

Files changed (2) +558 / -0

Enhancement (1) +307 / -0
DSLLexer.swiftIntroduce a lossless Unicode-scalar DSL lexer +307/-0

Introduce a lossless Unicode-scalar DSL lexer

• Defines tag, token, and diagnostic types for BKRS card-body markup. Scans escapes before tags, preserves unsupported or unterminated spans as text, and reports anomalies for downstream handling.

Sources/HanReaderCore/Format/DSLLexer.swift

Tests (1) +251 / -0
DSLLexerTests.swiftCover DSL tags, escapes, diagnostics, and Unicode boundaries +251/-0

Cover DSL tags, escapes, diagnostics, and Unicode boundaries

• Adds hand-written tests for supported tags, sense levels, mixed text and markup, and lossless handling of malformed spans. Regression cases verify escaped brackets and closing brackets followed by a zero-width non-joiner.

Tests/HanReaderCoreTests/DSLLexerTests.swift

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Invalid sense closers disappear from text ✓ Resolved
Description
parse passes closing names to kind(forName:), which accepts mN as a sense name even though a
sense closes only with [/m]. An input such as [/m1]literal therefore emits a close token without
a diagnostic, rather than retaining [/m1] as text.
Code

Sources/HanReaderCore/Format/DSLLexer.swift[R245-246]

+            guard let kind = kind(forName: name) else { return .unknown }
+            return .close(kind)
Evidence
The documented sense close is bare [/m], but closing-tag parsing uses a lookup that also
recognizes level-bearing sense openings; unknown spans would otherwise be preserved and diagnosed.

Sources/HanReaderCore/Format/DSLLexer.swift[24-26]
Sources/HanReaderCore/Format/DSLLexer.swift[243-247]
Sources/HanReaderCore/Format/DSLLexer.swift[272-281]
Sources/HanReaderCore/Format/DSLLexer.swift[219-226]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Closing sense tags with a level are silently accepted although only `[/m]` is valid.
## Fix Focus Areas
- Sources/HanReaderCore/Format/DSLLexer.swift[243-247]
- Sources/HanReaderCore/Format/DSLLexer.swift[272-281]
## Recommended Fix
Parse closing names independently of opening sense names. Accept `m` as the sole sense close, and retain and diagnose `[/m1]` and other invalid closers. Add a regression test.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Unsupported sense levels go unreported 🐞 Bug ≡ Correctness
Description
senseLevel(forName:) accepts any positive wholeNumberValue rather than the documented ASCII
levels 1 through 4. A bracket such as [m9] or [m٥] consequently becomes an opening sense token
instead of being retained as anomalous text with a diagnostic.
Code

Sources/HanReaderCore/Format/DSLLexer.swift[R265-269]

+        guard name.count == 2, name.hasPrefix("m"),
+              let digit = name.last, let level = digit.wholeNumberValue,
+              level > 0
+        else { return nil }
+        return level
Evidence
The declared complete sense-tag set ends at [m4], whereas the level check has neither that upper
bound nor an ASCII-digit requirement; successful parsing emits a tag rather than the unknown-span
diagnostic.

Sources/HanReaderCore/Format/DSLLexer.swift[5-16]
Sources/HanReaderCore/Format/DSLLexer.swift[255-269]
Sources/HanReaderCore/Format/DSLLexer.swift[219-226]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The lexer treats unsupported and non-ASCII sense levels as valid markup, hiding anomalies from diagnostics.
## Fix Focus Areas
- Sources/HanReaderCore/Format/DSLLexer.swift[263-270]
- Tests/HanReaderCoreTests/DSLLexerTests.swift[71-77]
## Recommended Fix
Recognize only the exact spellings `m1` through `m4` as sense openings. Keep other bracketed forms as text and report them, with tests for an out-of-range and a non-ASCII digit.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Malformed tag arguments evade diagnostics ✓ Resolved
Description
parse splits an argument from every opening tag and constructs a valid token without checking
whether that kind permits one. Forms such as [b extra] and [m1 extra] are therefore consumed as
markup instead of being shown literally and reported, even though the lexer identifies colour as the
only argument-bearing tag.
Code

Sources/HanReaderCore/Format/DSLLexer.swift[R256-260]

+        if let level = senseLevel(forName: name) {
+            return .open(DSLTag(kind: .sense, level: level, argument: argument))
+        }
+        guard let kind = kind(forName: name), kind != .sense else { return .unknown }
+        return .open(DSLTag(kind: kind, level: nil, argument: argument))
Evidence
The parser comment says only colour carries an argument in this data, yet both token-construction
branches accept the split argument; the unknown branch that would retain the span is bypassed.

Sources/HanReaderCore/Format/DSLLexer.swift[249-260]
Sources/HanReaderCore/Format/DSLLexer.swift[219-226]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Opening tags other than colour silently accept arguments and bypass anomaly reporting.
## Fix Focus Areas
- Sources/HanReaderCore/Format/DSLLexer.swift[249-260]
- Tests/HanReaderCoreTests/DSLLexerTests.swift[95-113]
## Recommended Fix
Allow an argument only on `[c ...]`; treat an argument on other opening tags as unknown markup so its original span remains text and receives a diagnostic. Add regression tests for ordinary and sense tags.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can describe a rule in plain language on the Rules page and Qodo drafts it for you

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread Sources/HanReaderCore/Format/DSLLexer.swift Outdated
Comment thread Sources/HanReaderCore/Format/DSLLexer.swift
Comment thread Sources/HanReaderCore/Format/DSLLexer.swift
Three ways the lexer accepted things DSL never writes, each turning an
unparsed fragment into valid-looking markup — which is the worst outcome
available, because the result reads correctly and has silently dropped
whatever it could not understand.

`[/m1]` closed a sense. A sense ends with a bare `[/m]`; a levelled close is
not one, and accepting it admitted markup this format does not produce while
hiding it from the diagnostics.

`[m٥]` opened a sense at level five. `Character.wholeNumberValue` answers for
Arabic-Indic digits and Roman numerals too, so the level test now requires an
ASCII digit. The accepted range is the format's 1–9 rather than the 1–4 BKRS
happens to use: the lexer implements DSL, and which levels a dictionary uses
is the card builder's business.

`[b extra]` became bold with an argument. Colour is the only tag that carries
one, so an argument anywhere else is now reported.

All three are reported and kept as text, the same as any unknown tag.
Re-running the full set confirms none of these forms occur in it: 3,434,224
cards, 13,739,066 tokens, still zero diagnostics. The stricter rules cost
nothing against real data and close three holes against data that is not
this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii merged commit f229a51 into main Oct 4, 2026
8 checks passed
@widgetii
widgetii deleted the feat/m6-dsl-lexer branch October 4, 2026 19:13
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