Skip to content

feat: read DSL files and group their lines into cards - #26

Merged
widgetii merged 3 commits into
mainfrom
feat/m6-card-reader
Oct 4, 2026
Merged

widgetii merged 3 commits into
mainfrom
feat/m6-card-reader

Conversation

@widgetii

@widgetii widgetii commented Oct 4, 2026

Copy link
Copy Markdown
Member

Layers one and two of the BKRS importer: bytes to lines, lines to cards, plus #INCLUDE discovery. With the lexer and the sense builder already in, this reads the real 350 MB set end to end — 3,434,224 cards, matching the known count exactly, in 2.3 seconds under 10 MB of resident memory.

The line terminator is a code unit, not a byte pair

In UTF-16LE a line feed is 0x000A — the bytes 0A 00 at an even offset. ਅ昀 (U+0A05 then U+6600) is 05 0A 00 66, so a scanner that searches the raw bytes breaks the line between the two bytes of a single character.

The predecessor scanned for 0A 00 with no alignment check, and got away with it: across all three files there are 10,302,687 aligned line feeds and zero misaligned occurrences of the pair, because a Chinese-Russian dictionary contains no Gurmukhi. So this is not a fix for an observed failure — it is the difference between code that is correct and code that happens to work on one input, and the aligned scan costs nothing. A short read is looped over rather than trusted, so the same misalignment cannot arrive by that route either.

Two measured performance decisions

pread, not FileHandle. Reading 116 MB through FileHandle.read(upToCount:) took peak resident size to 124 MB — the size of the file — because Foundation maps a large read instead of copying it, so resident size follows the input. The import has a 60 MB ceiling. The same read through pread into a reusable buffer costs 1 MiB whatever the file size, which is the entire point of reading in chunks.

UTF-16 is transcoded by hand, 7× faster. Over 3.4 M lines:

cost
gather code units into [UInt16] 0.13 s
…then String(decoding:as: UTF16.self) +3.05 s
write UTF-8 directly, String(decoding:as: UTF8.self) 0.43 s total

Output is byte-identical (85,997,869 UTF-8 bytes either way). It takes reading the whole set from 10 seconds to 1.2.

Line endings and the trailing newline

Line endings are mixed within each file: exactly two CRLF lines — #NAME and #INDEX_LANGUAGE — among three million LF ones. Both are handled on every line. And all three files end with a line feed, which splitting the text turns into an empty final element; an unindented empty line is indistinguishable from a headword, and that is a headword this dictionary does not contain.

The card reader is positional and nothing else

An indented line is article text, an unindented # line is a directive, anything else unindented is a headword. Indentation is tested first, so an indented # stays article text. Consecutive headwords are one card — BKRS never does it, but refusing it would be a parser bug waiting for the next dictionary. Each card records the byte offset of its headword line, which is the only place an interrupted import can restart without losing a card or writing one twice.

The file set is discovered, never hardcoded

dabkrs_1.dsl names the other two in its own #INCLUDE directives. The predecessor hardcoded all three filenames and the dictionary's name, so it imported this one dictionary and no other and stopped working if a file was renamed.

Includes are authoritative and followed with a visited set so a cycle cannot loop. Only when a file declares none does discovery look at the directory, and then only for a contiguous run of numbered siblings — a gap ends the run, and a file outside the run is imported alone, because importing files the reader did not pick needs a better reason than a coincidence of naming. Picking volume two restarts discovery from volume one, because that is where the directives live.

The seam repair the plan asks for is here as two properties rather than a third of an implementation: a headword left dangling at the end of a file is reported and handed out as trailingHeadwords, and a reader can be given leadingHeadwords to complete it. Both are zero for this set, which is card-aligned; they are insurance against a set someone split with split(1).

A 203-card fixture of real data

With Scripts/extract-dsl-fixture.py so its provenance is documented and it can be regenerated. Thirteen cards are picked deliberately — 爱, 了, 一 and 打 for their sense structure, one card per piece of markup, and the single card in 3,434,224 whose article breaks a line inside an open [m1] — and 190 are a contiguous run, which covers the shapes nobody thought to pick at a realistic density.

Committed as UTF-8 so it stays diffable in review; the tests re-encode it to UTF-16LE+BOM and assert both readings produce identical cards, so the bytes are reviewable and the real encoding path is still exercised. 52 KB, well inside the 256 KB limit CI enforces, and permitted by the one exception CONTRIBUTING.md names — 大БКРС is not ours to redistribute.

Validation

discovery:   includeDirectives
files:       dabkrs_1.dsl, dabkrs_2.dsl, dabkrs_3.dsl
cards:       3434224      (= the known card count, exactly)
headwords:   3434224      (1144474 + 1145232 + 1144518)
lines:       10302687     (matches an independent decode of each file)
diagnostics: 0
trailing headwords: 0
multi-line articles: 1   (乐芙兰)
multi-headword cards: 0
elapsed:     2.3 s
peak RSS:    9.8 MB

73 new tests. Core suite at 308, lint clean, no warnings under -warnings-as-errors, both app bundles build.

🤖 Generated with Claude Code

Layers one and two of the importer: bytes to lines, lines to cards. With the
lexer and the sense builder already in, this reads the real 350 MB set end to
end — 3,434,224 cards, matching the known count exactly, in 2.3 seconds under
10 MB of resident memory.

**The line terminator is a code unit, not a byte pair.** In UTF-16LE a line
feed is `0x000A`, which is the bytes `0A 00` *at an even offset*. `ਅ昀` —
U+0A05 then U+6600 — is `05 0A 00 66`, so a scanner that searches the raw
bytes breaks the line between the two bytes of a single character. The
predecessor scanned for `0A 00` with no alignment check and got away with it:
across all three files there are 10,302,687 aligned line feeds and **zero**
misaligned occurrences of the pair, because a Chinese-Russian dictionary
contains no Gurmukhi. So this is not a fix for an observed failure — it is the
difference between code that is correct and code that happens to work on one
input, and the aligned scan costs nothing. A short read is looped over rather
than trusted, so the same misalignment cannot arrive by that route either.

**`pread`, not `FileHandle`, and that is a measured decision.** Reading 116 MB
through `FileHandle.read(upToCount:)` took peak resident size to 124 MB — the
size of the file — because Foundation maps a large read instead of copying it,
so resident size follows the input. The import has a 60 MB ceiling. The same
read through `pread` into a reusable buffer costs 1 MiB whatever the file size,
which is the entire point of reading in chunks.

**UTF-16 is transcoded by hand, which is 7× faster.** Over 3.4 million lines,
gathering the code units into a `[UInt16]` costs 0.13 s and handing them to
`String(decoding:as: UTF16.self)` costs a further 3.05 s; writing UTF-8
directly, so `String` is handed its own native storage, costs 0.43 s. Output
is byte-identical — 85,997,869 UTF-8 bytes either way — and it takes reading
the whole set from 10 seconds to 1.2.

Line endings are mixed *within* each file: exactly two CRLF lines, `#NAME` and
`#INDEX_LANGUAGE`, among three million LF ones. Both are handled on every
line. And all three files end with a line feed, which splitting the text turns
into an empty final element — an unindented empty line is indistinguishable
from a headword, and that is a headword this dictionary does not contain.

**The card reader is positional and nothing else.** An indented line is
article text, an unindented `#` line is a directive, anything else unindented
is a headword. Indentation is tested first, so an indented `#` stays article
text. Consecutive headwords are one card: BKRS never does it, but refusing it
would be a parser bug waiting for the next dictionary. Each card records the
byte offset of its headword line, which is the only place an interrupted
import can restart without losing a card or writing one twice.

**The file set is discovered, never hardcoded.** `dabkrs_1.dsl` names the
other two in its own `#INCLUDE` directives; the predecessor hardcoded all
three filenames and the dictionary's name, so it imported this one dictionary
and no other and stopped working if a file was renamed. Includes are
authoritative and followed with a visited set so a cycle cannot loop; only
when a file declares none does discovery look at the directory, and then only
for a contiguous run of numbered siblings. Picking volume two restarts from
volume one, because that is where the directives live.

The seam repair the plan asks for is here as two properties rather than a
third of an implementation: a headword left dangling at the end of a file is
reported and handed out as `trailingHeadwords`, and a reader can be given
`leadingHeadwords` to complete it. Both are zero for this set, which is
card-aligned; they are insurance against a set someone split with `split(1)`.

**A 203-card fixture of real data**, with an extraction script so its
provenance is documented and it can be regenerated. Thirteen cards are picked
deliberately — 爱, 了, 一 and 打 for their sense structure, one per piece of
markup, and the single card whose article breaks a line inside an open `[m1]`
— and 190 are a contiguous run, which covers the shapes nobody thought to
pick. Committed as UTF-8 so it stays diffable; the tests re-encode it to
UTF-16LE+BOM and assert both readings produce identical cards, so the bytes
are reviewable and the real encoding path is still exercised. 52 KB, well
inside the 256 KB limit CI enforces, and permitted by the one exception
CONTRIBUTING names — 大БКРС is not ours to redistribute.

`DSLDiagnostic` moves to its own file now that two layers produce them, and
gains a byte offset for the ones found at card level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii enabled auto-merge (squash) October 4, 2026 20:30
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Stream DSL files into resumable cards and discover included volumes

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Stream UTF-8 and UTF-16 DSL files into lines and cards with byte offsets for resuming imports.
• Discover dictionary volumes from #INCLUDE directives, using numbered siblings only when no
 includes exist.
• Add a regenerable 203-card BKRS fixture and tests for encoding, card boundaries, and discovery.
Diagram

graph TD
  A["Selected DSL"] --> B["File Discovery"] --> C["Chunked Bytes"] --> D["Decoded Lines"] --> E["DSL Cards"] --> F["Dictionary Entries"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use standard-library UTF-16 decoding per line
  • ➕ Less custom Unicode code to maintain and verify.
  • ➖ Reported measurements show substantially slower decoding across millions of lines.
2. Read whole files through Foundation
  • ➕ Simpler file access and decoding pipeline.
  • ➖ Reported resident memory scales with file size and exceeds the import ceiling.

Recommendation: Keep the bounded pread pipeline and measured transcoding approach for this dataset, with the focused encoding and boundary tests guarding its added complexity. These alternatives simplify implementation but conflict with the stated throughput or memory requirements.

Files changed (13) +2959 / -23

Enhancement (4) +988 / -0
DSLByteReader.swiftStream DSL bytes into offset-bearing lines +535/-0

Stream DSL bytes into offset-bearing lines

• Adds memory and 'pread' byte sources, BOM and encoding detection, aligned UTF-16 line scanning, and reusable decoding buffers. Handles mixed line endings, short reads, malformed characters, and validated resume offsets.

Sources/HanReaderCore/Format/DSLByteReader.swift

DSLCardReader.swiftGroup positional DSL lines into resumable cards +253/-0

Group positional DSL lines into resumable cards

• Parses header directives and groups consecutive headwords with their indented reading and article lines. Records card-start byte offsets and exposes diagnostics and headword handoff for split-file seams.

Sources/HanReaderCore/Format/DSLCardReader.swift

DSLDiagnostic.swiftShare diagnostics across DSL parsing stages +46/-0

Share diagnostics across DSL parsing stages

• Moves the existing lexer diagnostic type into a shared file. Extends it with optional byte offsets and cases for misplaced card lines and missing includes.

Sources/HanReaderCore/Format/DSLDiagnostic.swift

DSLFileSet.swiftDiscover DSL volumes from headers or numbered siblings +154/-0

Discover DSL volumes from headers or numbered siblings

• Follows declared includes with cycle protection and reports missing targets. When none are declared, finds a contiguous numbered run and restarts at its first volume if a later one was selected.

Sources/HanReaderCore/Format/DSLFileSet.swift

Refactor (1) +0 / -20
DSLLexer.swiftRemove the lexer-local diagnostic definition +0/-20

Remove the lexer-local diagnostic definition

• Removes the original diagnostic declaration now supplied by the shared DSL diagnostic type. Lexer tokenization behavior is unchanged.

Sources/HanReaderCore/Format/DSLLexer.swift

Documentation (3) +246 / -3
Package.swiftDocument both regenerable test-data slices +4/-3

Document both regenerable test-data slices

• Updates the test-target resource comment to identify the CC-CEDICT and BKRS extraction scripts. The resource configuration itself is unchanged.

Package.swift

README.mdDocument BKRS fixture provenance and licensing +34/-0

Document BKRS fixture provenance and licensing

• Explains the fixture's card selection, regeneration command, encoding strategy, and manifest. Reiterates the restriction against committing or enlarging the source dictionary data.

Tests/HanReaderCoreTests/Fixtures/README.md

bkrs-slice.manifest.txtRecord why each BKRS fixture card was selected +208/-0

Record why each BKRS fixture card was selected

• Lists provenance for the curated edge cases and contiguous card run. Keeps these explanations outside the DSL fixture, where hash-prefixed lines would be interpreted as directives.

Tests/HanReaderCoreTests/Fixtures/bkrs-slice.manifest.txt

Other (5) +1725 / -0
extract-dsl-fixture.pyAdd a reproducible BKRS fixture extractor +207/-0

Add a reproducible BKRS fixture extractor

• Extracts selected markup and sense edge cases plus a contiguous card run from source DSL files. Writes a UTF-8 fixture and a per-card provenance manifest, and enforces the fixture size limit.

Scripts/extract-dsl-fixture.py

DSLByteReaderTests.swiftTest encoding, alignment, line boundaries, and resume offsets +266/-0

Test encoding, alignment, line boundaries, and resume offsets

• Covers UTF-8 and both UTF-16 byte orders, BOM-less detection, mixed endings, malformed input, and chunk boundaries. Includes a misaligned byte-pair regression case and offset-based resumption.

Tests/HanReaderCoreTests/DSLByteReaderTests.swift

DSLCardReaderTests.swiftTest card grouping, headers, seams, and volume discovery +394/-0

Test card grouping, headers, seams, and volume discovery

• Checks multi-headword and multiline cards, indentation precedence, diagnostics, and card-offset resumption. Temporary file sets exercise includes, cycles, missing files, and numbered-sibling fallback.

Tests/HanReaderCoreTests/DSLCardReaderTests.swift

DSLFixtureTests.swiftExercise the reader and builder against real BKRS cards +244/-0

Exercise the reader and builder against real BKRS cards

• Compares UTF-8 and re-encoded UTF-16 readings of the committed fixture. Asserts card and sense counts and checks representative readings, markup, cross-references, and multiline articles.

Tests/HanReaderCoreTests/DSLFixtureTests.swift

bkrs-slice.dsl.txtAdd a reviewable 203-card BKRS test slice +614/-0

Add a reviewable 203-card BKRS test slice

• Commits selected real DSL cards as UTF-8 text, including dense consecutive coverage and targeted parser edge cases. Tests re-encode this text to exercise the source dictionary's UTF-16LE path.

Tests/HanReaderCoreTests/Fixtures/bkrs-slice.dsl.txt

@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 (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Other dictionaries lose their definitions ✓ Resolved
Description
takeCard() always moves the first indented line into pinyin and removes it from body, even
when that line is the definition. For a DSL card such as dog\n [m1]animal[/m]\n with no separate
reading line, DSLCardBuilder receives an empty body and produces no senses.
Code

Sources/HanReaderCore/Format/DSLCardReader.swift[R193-194]

+                pinyin: body.first ?? "",
+                body: Array(body.dropFirst()),
Evidence
The new reader removes body.first from the only lines the existing builder lexes, while the
fixture's no-reading cards use a BKRS-specific _ placeholder rather than testing a definition-only
card.

Sources/HanReaderCore/Format/DSLCardReader.swift[189-197]
Sources/HanReaderCore/Format/DSLCardBuilder.swift[75-98]
Tests/HanReaderCoreTests/DSLFixtureTests.swift[217-229]

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

## Issue description
Cards without a separate reading line lose their first definition line because the card reader always treats it as pinyin.
## Fix Focus Areas
- Sources/HanReaderCore/Format/DSLCardReader.swift[189-200]
- Sources/HanReaderCore/Format/DSLCardBuilder.swift[75-98]
## Recommended Fix
Make the BKRS reading-line convention explicit rather than applying it to every DSL card. Preserve the first indented line as body text when no separate reading is identified, and test a definition-only card through the reader and builder.

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



Remediation recommended

2. Large file sets import incompletely ✓ Resolved
Description
discover() stops traversing includes when files.count reaches fileLimit but returns the
collected files without reporting the remaining queue; numbered-sibling discovery likewise stops
without checking the next sibling. A dictionary with a 65th declared or numbered volume therefore
appears successfully discovered while that volume is omitted.
Code

Sources/HanReaderCore/Format/DSLFileSet.swift[R88-89]

+            while !queue.isEmpty, files.count < fileLimit {
+                let (parent, name) = queue.removeFirst()
Evidence
Both loops cap collection at 64, and their return paths expose neither an error nor a diagnostic for
files left undiscovered.

Sources/HanReaderCore/Format/DSLFileSet.swift[57-59]
Sources/HanReaderCore/Format/DSLFileSet.swift[88-107]
Sources/HanReaderCore/Format/DSLFileSet.swift[136-152]

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

## Issue description
Discovery silently returns an incomplete dictionary when its 64-file safety limit is reached.
## Fix Focus Areas
- Sources/HanReaderCore/Format/DSLFileSet.swift[88-107]
- Sources/HanReaderCore/Format/DSLFileSet.swift[136-152]
## Recommended Fix
When the limit is reached, detect any unprocessed include or next numbered sibling and fail discovery or return an explicit truncation diagnostic. Add tests with 65 volumes for both discovery paths.

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


3. Tab-separated includes are ignored ✓ Resolved
Description
DSLHeader.consume(directive:) finds the directive name's end using only a literal space, so
#INCLUDE\t"part.dsl" is stored as an unknown directive instead of an include. Header discovery
then sees an empty includes list and can return only the selected file when its other volumes have
no matching numbered names.
Code

Sources/HanReaderCore/Format/DSLCardReader.swift[R36-40]

+        let rest = line.dropFirst()
+        let split = rest.firstIndex(of: " ")
+        let directive = String(split.map { rest[rest.startIndex ..< $0] } ?? rest)
+        let rawValue = split.map { String(rest[rest.index(after: $0)...]) } ?? ""
+        let value = Self.unquote(rawValue.trimmingCharacters(in: .whitespaces))
Evidence
The header parser splits only on a space, whereas the committed fixture extractor recognizes
whitespace-separated #INCLUDE directives; file discovery relies on the resulting includes list.

Sources/HanReaderCore/Format/DSLCardReader.swift[35-48]
Sources/HanReaderCore/Format/DSLFileSet.swift[80-86]
Scripts/extract-dsl-fixture.py[103-114]

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

## Issue description
Tab-separated directive values are not parsed, which prevents discovery of their included files.
## Fix Focus Areas
- Sources/HanReaderCore/Format/DSLCardReader.swift[35-48]
- Sources/HanReaderCore/Format/DSLFileSet.swift[80-86]
## Recommended Fix
Split directive names at the first horizontal-whitespace character and trim the value as before. Test a tab-separated `#INCLUDE` through file-set discovery.

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


4. Truncated files can lose bytes silently ✓ Resolved
Description
fill() treats a zero-byte read as normal end of file even if DSLFileBytes measured a larger
file, and UTF-16 decoding ignores any final incomplete code unit. If a UTF-16 file is truncated
after its length is measured but before its content is read, the reader can return a shortened line
without raising the odd-length error.
Code

Sources/HanReaderCore/Format/DSLByteReader.swift[R344-347]

+            let read = try source.read(at: nextOffset, into: &chunk, from: available)
+            guard read > 0 else {
+                reachedEnd = true
+                break
Evidence
The source snapshots st_size, but refill does not compare EOF with that size, and the transcoder
processes only complete two-byte units. For a six-byte UTF-16LE file truncated to five bytes after
initialization, the remaining LF byte is ignored.

Sources/HanReaderCore/Format/DSLByteReader.swift[148-160]
Sources/HanReaderCore/Format/DSLByteReader.swift[281-283]
Sources/HanReaderCore/Format/DSLByteReader.swift[338-353]
Sources/HanReaderCore/Format/DSLByteReader.swift[458-462]

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

## Issue description
A source file truncated during reading can be accepted as a complete UTF-16 file, silently dropping its last byte.
## Fix Focus Areas
- Sources/HanReaderCore/Format/DSLByteReader.swift[338-353]
- Sources/HanReaderCore/Format/DSLByteReader.swift[454-462]
## Recommended Fix
Compare EOF with the measured `byteCount` and reject premature EOF; also reject a final incomplete UTF-16 code unit. Test truncation between reader initialization and its first content read.

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


View medium (1)
5. Carried cards have unusable resume points ✓ Resolved
Description
DSLCardReader initializes cardOffset to zero when supplied leadingHeadwords without a resume
offset, then publishes that value in the completed card record. When a headword from one file is
completed by article lines in the next, resuming the returned record at zero starts at the second
file's byte-order mark rather than at that card.
Code

Sources/HanReaderCore/Format/DSLCardReader.swift[R136-137]

+        headwords = leadingHeadwords
+        cardOffset = offset ?? 0
Evidence
The seam API accepts headword text but not its original location, while every returned record
promises the byte offset of its first headword line.

Sources/HanReaderCore/Format/DSLCardReader.swift[57-65]
Sources/HanReaderCore/Format/DSLCardReader.swift[111-118]
Sources/HanReaderCore/Format/DSLCardReader.swift[125-138]
Sources/HanReaderCore/Format/DSLCardReader.swift[189-197]

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

## Issue description
A card assembled across two files exposes offset zero as a resume point, despite its headword belonging to the previous file.
## Fix Focus Areas
- Sources/HanReaderCore/Format/DSLCardReader.swift[125-138]
- Sources/HanReaderCore/Format/DSLCardReader.swift[189-201]
## Recommended Fix
Carry the originating file and headword offset alongside seam headwords, or make the record's resume location unavailable until it can represent that origin accurately. Test the offset and restart behavior of a seam-completed card.

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



Informational

6. Later dangling headwords point to the first ✓ Resolved
Description
finalCard() assigns cardOffset to every .headwordWithoutBody diagnostic, although that offset
is updated only for the first headword in a consecutive run. If a file ends with two or more
headwords without an article, diagnostics for the later lines identify the first line's byte
position instead.
Code

Sources/HanReaderCore/Format/DSLCardReader.swift[R209-213]

+                diagnostics.append(DSLDiagnostic(
+                    kind: .headwordWithoutBody,
+                    text: headword,
+                    offset: cardOffset,
+                ))
Evidence
Subsequent headwords are appended without retaining line.offset, and the diagnostic loop reuses
the first headword's cardOffset for each one.

Sources/HanReaderCore/Format/DSLCardReader.swift[171-182]
Sources/HanReaderCore/Format/DSLCardReader.swift[205-216]

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

## Issue description
Diagnostics for consecutive dangling headwords all point to the first headword's location.
## Fix Focus Areas
- Sources/HanReaderCore/Format/DSLCardReader.swift[171-182]
- Sources/HanReaderCore/Format/DSLCardReader.swift[205-216]
## Recommended Fix
Retain each headword's line offset while assembling a card and use the corresponding offset when emitting each dangling-headword diagnostic. Test two consecutive headwords at EOF.

ⓘ 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/DSLCardReader.swift Outdated
Comment thread Sources/HanReaderCore/Format/DSLFileSet.swift Outdated
Comment thread Sources/HanReaderCore/Format/DSLCardReader.swift
Comment thread Sources/HanReaderCore/Format/DSLByteReader.swift
Comment thread Sources/HanReaderCore/Format/DSLCardReader.swift Outdated
Comment thread Sources/HanReaderCore/Format/DSLCardReader.swift
widgetii and others added 2 commits October 5, 2026 00:06
Six correctness fixes raised in review, each measured against the full
3,434,224-card set.

**A reading line is a convention, not a rule of the format.** The card reader
took the first indented line as the reading unconditionally, so a DSL
dictionary that writes no readings — the format does not require them — lost
the first line of every article to a field it does not have, and
`DSLCardBuilder` received a card with no senses in it.

It is now detected, by a rule the whole set agrees with: the first indented
line is the reading unless it contains a `[mN]` tag. **Zero of 3,434,224
reading lines do**, and an article's first line effectively always does. The
test has to be the sense tag specifically rather than markup in general,
because 287 reading lines *do* carry other markup — `lùzhóu, [i]разг.[/i]
liùzhóu`, a register note between two readings — and a markup test would have
read all 287 as articles.

A single indented line is now article text rather than a reading. That case is
genuinely ambiguous and the two mistakes are not equal: a reading with no
definition is not an entry worth having, while a definition read as a reading
leaves the entry saying nothing at all. No BKRS card has one, so nothing in
this set changes.

**A card completed across a file seam offered offset zero as its resume
point**, which restarts at the next file's byte-order mark and loses the
carried headword entirely. `DSLCardRecord.offset` is now optional and nil for
exactly that card: no offset in the file it was completed in restarts it, and
the one that does is in the previous file, which an importer already has.

**Each dangling headword now reports its own line.** A run of them at the end
of a file all carried the first one's offset, pointing every diagnostic but
the first at the wrong place in the file — worse than no offset at all.

**Discovery says when it stops at its file limit.** Returning 64 volumes of a
65-volume dictionary as though it were complete is the worst of the three
possible behaviours: it imports, it looks right, and the missing volume's
words simply cannot be found. Both the include walk and the numbered-sibling
scan now report `tooManyFiles` instead.

**A directive may be separated by a tab.** `#INCLUDE\t"b.dsl"` was read as an
unknown directive, which leaves the include list empty and the other volumes
undiscovered — the one failure that turns a three-volume dictionary into a
third of one with no error at all.

**A file that ends before the length it reported is refused.** `fill()` took a
zero-byte read as end of file even when the file said there was more, so a
source shortened between being measured and being read produced a dictionary
quietly missing its tail — and in UTF-16, a final half-character dropped
without the odd-length check ever running.

Counts over the whole set are unchanged: 3,434,224 cards, 1 multi-line
article, 0 trailing headwords, 0 diagnostics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`discover` was doing three things in one body — reading the header, walking
the `#INCLUDE` graph, and scanning for numbered siblings — and the walk is the
part with the loop, the visited set and the file limit in it. Moved to its
own function, which is also what gets it back under the length the linter
enforces.

No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii merged commit 7e43cd9 into main Oct 4, 2026
8 checks passed
@widgetii
widgetii deleted the feat/m6-card-reader branch October 4, 2026 21:31
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