diff --git a/Package.swift b/Package.swift index a02cea9..866cef0 100644 --- a/Package.swift +++ b/Package.swift @@ -144,9 +144,10 @@ let package = Package( .testTarget( name: "HanReaderCoreTests", dependencies: ["HanReaderCore"], - // Real CC-CEDICT data, a small slice extracted by - // Scripts/extract-cedict-fixture.py so its provenance is - // documented and it can be regenerated rather than hand-edited. + // Small slices of the real CC-CEDICT and 大БКРС data, each + // extracted by a committed script (Scripts/extract-cedict-fixture.py + // and Scripts/extract-dsl-fixture.py) so their provenance is + // documented and they can be regenerated rather than hand-edited. // // Kept inside the target directory rather than at Tests/Fixtures: // a resource path reaching outside the target works on the current diff --git a/Scripts/extract-dsl-fixture.py b/Scripts/extract-dsl-fixture.py new file mode 100755 index 0000000..7ec69c6 --- /dev/null +++ b/Scripts/extract-dsl-fixture.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Extract the committed BKRS test fixture from a full 大БКРС DSL set. + +The fixture is a deliberately small slice of real DSL data, chosen to cover +every shape the importer has to handle. It is committed so the tests are +hermetic and fast; this script exists so its provenance is documented and the +slice can be regenerated rather than hand-maintained. + + ./Scripts/extract-dsl-fixture.py path/to/dabkrs_1.dsl + +`#INCLUDE` directives are followed, so pointing it at the master file is +enough. Writes Tests/HanReaderCoreTests/Fixtures/bkrs-slice.dsl.txt. + +The output is **UTF-8 with no byte-order mark**, where the source is +UTF-16LE+BOM. That is on purpose twice over: a UTF-8 file stays diffable in +git, and the tests re-encode it to UTF-16LE+BOM at runtime and assert both +readings produce identical cards — so the committed bytes are reviewable and +the real encoding path is still exercised. + +**Licensing.** 大БКРС is not freely licensed and is not ours to redistribute. +HanReader ships an importer, not the data. This slice exists under the one +exception CONTRIBUTING.md names: a ~200-card test fixture, regenerable by this +script. Do not enlarge it, and do not commit the source files. +""" + +from __future__ import annotations + +import pathlib +import re +import sys + +# Cards picked by headword, each for a stated reason, so a future edit can tell +# what it would be removing. +WANTED: dict[str, str] = { + # The worked examples from the design: a division heading, numbered senses + # under it, examples, and a second division. + "爱": "division + numbered senses + example; the plan's worked example", + # Homographs and multi-reading cards. The predecessor put the whole + # comma-separated reading above the word; one entry per reading is what + # replaces that. + "了": "two readings, le and liǎo; 30 senses", + "一": "57 senses; carries its own tone-sandhi note as prose", + "打": "57 senses, the deepest nesting in the set", + # The one card in 3,434,224 whose article is split across two physical + # lines, inside an open [m1]. + "乐芙兰": "article spans two lines inside an open [m1]", + # A run-together reading with the U+2019 apostrophe the set uses as a + # syllable boundary. + "土耳其斯坦": "run-together reading with a U+2019 syllable boundary", +} + +# Cards picked by what their markup contains. The first card matching each +# pattern is taken, so the slice stays deterministic. +PATTERNS: dict[str, tuple[str, str]] = { + "escaped-brackets": (r"\\\[", "escaped \\[ — 55,414 in the set; stripDSL ate them"), + "colour-argument": (r"\[c [a-z]+\]", "[c brown]: the only tag taking an argument"), + "level-four": (r"\[m4\]", "[m4]: 344 in the set, the deepest level used"), + "block-no-example": ( + r"\[\*\](?:(?!\[ex\]).)*\[/\*\]", + "[*] with no [ex] inside — 62 in the set, and that path must not crash", + ), + "reference": (r"\[ref\]", "[ref] cross-reference"), + "register": (r"\[p\](?:бот|ист|уст)\.", "a [p] field marker rather than a part of speech"), + "no-reading": (r"^_$", "`_` for the reading — 77% of the set"), +} + +HEADER = """#NAME "HanReader test slice of 大БКРС" +#INDEX_LANGUAGE "Chinese" +#CONTENTS_LANGUAGE "Russian" +""" + +# A run of consecutive cards taken verbatim after the anchor, on top of the +# picks above. Curated cards cover the shapes someone thought of; a contiguous +# run covers the ones nobody did, at a realistic density. 爱 is the anchor +# because it is already in the slice and its position is stable. +RUN_ANCHOR = "爱" +RUN_LENGTH = 190 + +# DSL has no comment syntax — a `#` line is a directive — so the reasons go in +# a sidecar instead of into the fixture, which stays a file the importer can +# read as-is. +MANIFEST_HEADER = """Why each card is in bkrs-slice.dsl.txt. + +Generated by Scripts/extract-dsl-fixture.py; do not hand-edit. DSL has no +comment syntax, so this cannot live inside the fixture itself. +""" + + +def read_dsl(path: pathlib.Path) -> list[str]: + """Decodes one DSL file into lines, honouring its byte-order mark.""" + raw = path.read_bytes() + if raw[:2] == b"\xff\xfe": + text = raw[2:].decode("utf-16-le") + elif raw[:2] == b"\xfe\xff": + text = raw[2:].decode("utf-16-be") + elif raw[:3] == b"\xef\xbb\xbf": + text = raw[3:].decode("utf-8") + else: + text = raw.decode("utf-8") + return [line.rstrip("\r") for line in text.split("\n")] + + +def file_set(master: pathlib.Path) -> list[pathlib.Path]: + """Follows #INCLUDE from the master file.""" + files = [master] + for line in read_dsl(master): + if not line.startswith("#"): + break + match = re.match(r'#INCLUDE\s+"([^"]+)"', line) + if match: + sibling = master.parent / match.group(1) + if sibling.exists(): + files.append(sibling) + return files + + +def cards(lines: list[str]): + """Yields (headwords, body_lines) for each card.""" + index, count = 0, len(lines) + while index < count: + line = lines[index] + if not line or line.startswith("#"): + index += 1 + continue + headwords = [] + while index < count and lines[index] and not lines[index][:1] in (" ", "\t", "#"): + headwords.append(lines[index]) + index += 1 + body = [] + while index < count and lines[index][:1] in (" ", "\t"): + body.append(lines[index]) + index += 1 + if headwords and body: + yield headwords, body + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__, file=sys.stderr) + return 2 + master = pathlib.Path(sys.argv[1]).expanduser() + if not master.exists(): + print(f"no such file: {master}", file=sys.stderr) + return 1 + + compiled = {key: re.compile(pattern) for key, (pattern, _) in PATTERNS.items()} + chosen: dict[str, tuple[list[str], list[str], str]] = {} + found_patterns: set[str] = set() + run_remaining = 0 + + for path in file_set(master): + for headwords, body in cards(read_dsl(path)): + key = headwords[0] + if run_remaining > 0 and key not in chosen: + run_remaining -= 1 + chosen[key] = (headwords, body, f"consecutive run after {RUN_ANCHOR}") + continue + if key in WANTED and key not in chosen: + chosen[key] = (headwords, body, WANTED[key]) + if key == RUN_ANCHOR: + run_remaining = RUN_LENGTH + continue + stripped = [line.strip() for line in body] + for name, regex in compiled.items(): + if name in found_patterns or key in chosen: + continue + if any(regex.search(line) for line in stripped): + found_patterns.add(name) + chosen[key] = (headwords, body, PATTERNS[name][1]) + + missing = sorted(set(WANTED) - set(chosen)) + if missing: + print(f"warning: headwords not found: {', '.join(missing)}", file=sys.stderr) + for name in sorted(set(PATTERNS) - found_patterns): + print(f"warning: no card matched {name}", file=sys.stderr) + + root = pathlib.Path(__file__).resolve().parent.parent + out = root / "Tests/HanReaderCoreTests/Fixtures/bkrs-slice.dsl.txt" + manifest = root / "Tests/HanReaderCoreTests/Fixtures/bkrs-slice.manifest.txt" + + with out.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(HEADER) + handle.write("\n") + for key in sorted(chosen): + headwords, body, _ = chosen[key] + for headword in headwords: + handle.write(headword + "\n") + for line in body: + handle.write(line + "\n") + + with manifest.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(MANIFEST_HEADER) + handle.write("\n") + for key in sorted(chosen): + handle.write(f"{key}\t{chosen[key][2]}\n") + + size = out.stat().st_size + print(f"wrote {out.relative_to(root)} and {manifest.relative_to(root)}") + print(f" {len(chosen)} cards, {size:,} bytes") + if size > 256 * 1024: + print(" error: over the 256 KB fixture limit CI enforces", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Sources/HanReaderCore/Format/DSLByteReader.swift b/Sources/HanReaderCore/Format/DSLByteReader.swift new file mode 100644 index 0000000..fda544c --- /dev/null +++ b/Sources/HanReaderCore/Format/DSLByteReader.swift @@ -0,0 +1,549 @@ +// HanReader — MIT licensed. See LICENSE. + +public import Foundation + +/// How a DSL file's bytes spell its characters. +/// +/// ABBYY's own tools write UTF-16 with a byte-order mark, and all three files +/// of the BKRS set are UTF-16LE+BOM. Dictionaries assembled by hand are +/// routinely UTF-8, so both are first-class rather than one being a fallback. +public enum DSLByteEncoding: Hashable, Sendable { + case utf16LittleEndian + case utf16BigEndian + case utf8 + + /// Bytes per code unit. Line scanning, offset validation and + /// carriage-return stripping all turn on this. + var codeUnitWidth: Int { + self == .utf8 ? 1 : 2 + } +} + +/// One line of a DSL file, with where it started. +public struct DSLLine: Hashable, Sendable { + /// The line's text, with the line terminator and any carriage return + /// removed. + public let text: String + /// Byte offset of the line's first byte from the start of the file. + /// + /// A byte offset, not a line number, because this is what a resumed + /// import seeks to. It is also stable under a parser change in a way a + /// line count is not. + public let offset: Int + + public init(text: String, offset: Int) { + self.text = text + self.offset = offset + } +} + +/// Something that cannot be read as a DSL file at all. +/// +/// All three cases are about the file's shape rather than its content. A line +/// the parser dislikes is a diagnostic; a file whose bytes cannot form whole +/// characters is an error, because every offset derived from it would be a +/// guess. +public enum DSLByteError: Error, Hashable, Sendable, CustomStringConvertible { + /// A UTF-16 file whose length is not a whole number of code units, so its + /// last character is cut in half. + case oddByteLength(byteCount: Int) + /// A resume offset that does not sit on a code-unit boundary. + case misalignedOffset(Int) + /// A resume offset past the end of the file, which is what a stale + /// checkpoint against a replaced file looks like. + case offsetOutOfRange(offset: Int, byteCount: Int) + /// The file ended before the length it reported, so it was shortened + /// while being read. + case truncated(read: Int, expected: Int) + /// The file could not be opened or measured. + case cannotOpen(path: String, code: Int32) + /// A read failed part-way through the file. + case readFailed(offset: Int, code: Int32) + + public var description: String { + switch self { + case let .oddByteLength(byteCount): + "the file is \(byteCount) bytes long, which is not a whole number of " + + "UTF-16 code units — it is truncated or not UTF-16" + case let .misalignedOffset(offset): + "byte offset \(offset) is odd, so it falls inside a UTF-16 code unit" + case let .offsetOutOfRange(offset, byteCount): + "byte offset \(offset) is past the end of a \(byteCount)-byte file" + case let .truncated(read, expected): + "the file ended after \(read) bytes but reported \(expected) — it was " + + "shortened while being read" + case let .cannotOpen(path, code): + "cannot open \(path): \(String(cString: strerror(code)))" + case let .readFailed(offset, code): + "read at byte \(offset) failed: \(String(cString: strerror(code)))" + } + } +} + +// MARK: - Byte sources + +/// Random-access bytes for `DSLByteReader`. +/// +/// Random access rather than a stream, because resuming an interrupted import +/// means starting at a recorded byte offset, and because it keeps the source +/// free of a read position that would have to be kept in step with the +/// reader's own. +public protocol DSLByteSource { + /// Total length of the file. Needed before reading in order to reject an + /// odd-length UTF-16 file rather than discovering it 300 MB in. + var byteCount: Int { get } + + /// Reads from `offset` into `buffer` starting at index `destination`, + /// filling at most to the end of `buffer`. + /// + /// Returns the number of bytes written, or 0 at end of file. A short read + /// is allowed: the reader loops until the buffer is full, so a source need + /// not return everything asked for. + func read(at offset: Int, into buffer: inout [UInt8], from destination: Int) throws -> Int +} + +/// Bytes already in memory, for fixtures and tests. +public struct DSLMemoryBytes: DSLByteSource, Sendable { + private let bytes: [UInt8] + + public init(_ bytes: [UInt8]) { + self.bytes = bytes + } + + public init(_ data: Data) { + bytes = [UInt8](data) + } + + public var byteCount: Int { + bytes.count + } + + public func read( + at offset: Int, + into buffer: inout [UInt8], + from destination: Int, + ) throws + -> Int + { + guard offset >= 0, offset < bytes.count, destination < buffer.count else { return 0 } + let count = min(buffer.count - destination, bytes.count - offset) + buffer.replaceSubrange( + destination ..< (destination + count), + with: bytes[offset ..< (offset + count)], + ) + return count + } +} + +/// Bytes read from a file a chunk at a time. +/// +/// `pread` rather than `FileHandle`, for a measured reason. The import has a +/// 60 MB ceiling over baseline, and 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 same read through `pread` into a +/// reusable buffer costs 1 MiB regardless of file size, which is the whole +/// point of reading in chunks. +/// +/// `pread` also takes the offset as an argument rather than as file state, so +/// there is no seek position to keep in step with the reader's own. +public final class DSLFileBytes: DSLByteSource { + private let descriptor: Int32 + public let byteCount: Int + + public init(url: URL) throws { + descriptor = open(url.path, O_RDONLY) + guard descriptor >= 0 else { + throw DSLByteError.cannotOpen(path: url.path, code: errno) + } + var status = stat() + guard fstat(descriptor, &status) == 0 else { + let code = errno + close(descriptor) + throw DSLByteError.cannotOpen(path: url.path, code: code) + } + byteCount = Int(status.st_size) + } + + deinit { + close(descriptor) + } + + public func read( + at offset: Int, + into buffer: inout [UInt8], + from destination: Int, + ) throws + -> Int + { + guard offset >= 0, offset < byteCount, destination < buffer.count else { return 0 } + let wanted = buffer.count - destination + return try buffer.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { return 0 } + let count = pread(descriptor, base.advanced(by: destination), wanted, off_t(offset)) + guard count >= 0 else { throw DSLByteError.readFailed(offset: offset, code: errno) } + return count + } + } +} + +// MARK: - The reader + +/// Reads a DSL file as lines, each with its byte offset. +/// +/// ### The line terminator is a code unit, not a byte pair +/// +/// In UTF-16LE a line feed is the code unit `0x000A`, which is the bytes +/// `0A 00` **at an even offset**. Scanning the raw bytes for that pair +/// without checking alignment is a latent corruption: a character in +/// `U+0A00`–`U+0AFF` puts `0A` at an odd offset, and if the next character's +/// low byte is zero the pair appears across the two of them. `ਅ昀` — +/// `U+0A05` then `U+6600` — is `05 0A 00 66`, and a byte scanner breaks the +/// line between the two bytes of a single character. +/// +/// The predecessor prototype scanned for `0A 00` with no alignment check. It +/// got away with it: across all three BKRS 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 bug 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. +/// +/// ### Line endings are mixed within a single file +/// +/// Each BKRS file has exactly two CRLF lines — `#NAME` and `#INDEX_LANGUAGE` +/// — and three million LF ones. A reader that splits on CRLF leaves a stray +/// carriage return on the dictionary's own name; one that splits on LF and +/// forgets to strip CR does the same. Both terminators are handled on every +/// line. +/// +/// ### A trailing newline does not make a final empty line +/// +/// All three files end with a line feed, so splitting the text yields an +/// empty final element. That empty string is not a line — and at the card +/// level an unindented empty line looks exactly like a headword, which is a +/// headword this dictionary does not contain. +public struct DSLByteReader { + /// 1 MiB. Large enough that a 350 MB file costs ~350 reads, small enough + /// to be irrelevant against the memory ceiling. + public static let defaultChunkSize = 1 << 20 + + /// How the file spells its characters, from its BOM or from a sniff. + public let encoding: DSLByteEncoding + /// Total file length. + public let byteCount: Int + + private let source: any DSLByteSource + /// The read buffer, allocated once and reused for the whole file. + private var chunk: [UInt8] + /// Absolute offset of `chunk[0]`. Always even for UTF-16, which is what + /// makes an index's parity within the chunk equal its parity in the file. + private var chunkStart = 0 + /// Index into `chunk` of the next unexamined byte. + private var cursor = 0 + /// Valid bytes in `chunk`. + private var available = 0 + /// Absolute offset of the next byte to read from the source. + private var nextOffset: Int + /// Bytes of the line being assembled, which may span chunks. + private var pending: [UInt8] = [] + /// Absolute offset of `pending`'s first byte. + private var pendingStart: Int + /// UTF-8 scratch for decoding, reused rather than allocated per line. + private var scratch: [UInt8] = [] + private var reachedEnd = false + + /// Opens a source for reading. + /// + /// - Parameters: + /// - source: the bytes to read. + /// - offset: where to start, for resuming an interrupted import. Nil + /// starts at the beginning, skipping the byte-order mark. A supplied + /// offset is taken literally — a resume point recorded by this reader + /// is already past the BOM. + /// - chunkSize: read-buffer size, rounded up to a whole number of + /// UTF-16 code units. + public init( + source: any DSLByteSource, + resumingAt offset: Int? = nil, + chunkSize: Int = Self.defaultChunkSize, + ) throws { + self.source = source + byteCount = source.byteCount + + var prefix = [UInt8](repeating: 0, count: min(64, max(byteCount, 0))) + if !prefix.isEmpty { + var filled = 0 + while filled < prefix.count { + let read = try source.read(at: filled, into: &prefix, from: filled) + guard read > 0 else { break } + filled += read + } + prefix.removeLast(prefix.count - filled) + } + let detected = Self.detectEncoding(prefix: prefix) + encoding = detected.encoding + + if encoding.codeUnitWidth == 2, !byteCount.isMultiple(of: 2) { + throw DSLByteError.oddByteLength(byteCount: byteCount) + } + + let start = offset ?? detected.bomLength + guard start >= 0, start <= byteCount else { + throw DSLByteError.offsetOutOfRange(offset: start, byteCount: byteCount) + } + guard start.isMultiple(of: encoding.codeUnitWidth) else { + throw DSLByteError.misalignedOffset(start) + } + + // Even, so a UTF-16 code unit never straddles two chunks. + chunk = [UInt8](repeating: 0, count: max(2, chunkSize + chunkSize % 2)) + nextOffset = start + pendingStart = start + pending.reserveCapacity(4096) + scratch.reserveCapacity(4096) + } + + public init( + url: URL, + resumingAt offset: Int? = nil, + chunkSize: Int = Self.defaultChunkSize, + ) throws { + try self.init( + source: DSLFileBytes(url: url), + resumingAt: offset, + chunkSize: chunkSize, + ) + } + + /// The next line, or nil at end of file. + public mutating func next() throws -> DSLLine? { + while true { + if cursor == available { + guard try fill() else { return finalLine() } + } + if let terminator = terminatorIndex(from: cursor) { + pending.append(contentsOf: chunk[cursor ..< terminator]) + cursor = terminator + encoding.codeUnitWidth + let line = takeLine() + pendingStart = chunkStart + cursor + return line + } + pending.append(contentsOf: chunk[cursor ..< available]) + cursor = available + } + } + + // MARK: - Reading + + /// Refills the chunk. Returns false at end of file. + /// + /// Loops until the buffer is full so that a source returning a short read + /// cannot leave half a UTF-16 code unit at the end of the chunk — which + /// would be the same misalignment bug arriving by a different route. + private mutating func fill() throws -> Bool { + guard !reachedEnd else { return false } + chunkStart = nextOffset + cursor = 0 + available = 0 + while available < chunk.count { + let read = try source.read(at: nextOffset, into: &chunk, from: available) + guard read > 0 else { + // End of file where the file said there was more. Something + // shortened it since it was measured, and the alternative to + // saying so is a dictionary quietly missing its tail — or, in + // UTF-16, a final half-character dropped without the + // odd-length check ever running. + guard nextOffset >= byteCount else { + throw DSLByteError.truncated(read: nextOffset, expected: byteCount) + } + reachedEnd = true + break + } + available += read + nextOffset += read + } + return available > 0 + } + + /// Index within the chunk of the line terminator's first byte, if the + /// current chunk holds one. + /// + /// For UTF-16 the scan steps two bytes at a time from an even index, so + /// it can only ever match a whole code unit. `cursor` is even throughout: + /// `chunkStart` is even, a chunk holds whole code units, and the cursor + /// advances by the code-unit width or to the end of the chunk. + private func terminatorIndex(from start: Int) -> Int? { + switch encoding { + case .utf8: + var index = start + while index < available { + if chunk[index] == 0x0A { + return index + } + index += 1 + } + case .utf16LittleEndian: + var index = start + while index + 1 < available { + if chunk[index] == 0x0A, chunk[index + 1] == 0x00 { + return index + } + index += 2 + } + case .utf16BigEndian: + var index = start + while index + 1 < available { + if chunk[index] == 0x00, chunk[index + 1] == 0x0A { + return index + } + index += 2 + } + } + return nil + } + + private mutating func finalLine() -> DSLLine? { + guard !pending.isEmpty else { return nil } + return takeLine() + } + + private mutating func takeLine() -> DSLLine { + stripCarriageReturn() + let line = DSLLine(text: decodePending(), offset: pendingStart) + pending.removeAll(keepingCapacity: true) + return line + } + + private mutating func stripCarriageReturn() { + let width = encoding.codeUnitWidth + guard pending.count >= width else { return } + let last = pending.count - width + switch encoding { + case .utf8: + if pending[last] == 0x0D { + pending.removeLast(width) + } + case .utf16LittleEndian: + if pending[last] == 0x0D, pending[last + 1] == 0x00 { + pending.removeLast(width) + } + case .utf16BigEndian: + if pending[last] == 0x00, pending[last + 1] == 0x0D { + pending.removeLast(width) + } + } + } + + // The lint rule against a lossy decode is suppressed rather than obeyed, + // and only here. It is right in general — a silent U+FFFD hides a real + // encoding problem — but lossy is the specification at this one point: + // `String(bytes:encoding:)` returns nil for a line holding one bad code + // unit, which would cost the whole card. + // swiftlint:disable optional_data_string_conversion + + /// Decodes the pending bytes. + /// + /// Malformed input costs the character it mangles and nothing more: an + /// unpaired surrogate or a bad byte sequence becomes U+FFFD rather than + /// failing the line, the card around it or the import. That is the same + /// bargain the lexer makes with a tag it cannot read. + private mutating func decodePending() -> String { + guard encoding != .utf8 else { return String(decoding: pending, as: UTF8.self) } + transcodeUTF16ToUTF8() + return String(decoding: scratch, as: UTF8.self) + } + + // swiftlint:enable optional_data_string_conversion + + /// Rewrites the pending UTF-16 bytes into `scratch` as UTF-8. + /// + /// Hand-rolled for a measured reason. Over this dictionary's 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 that `String` is handed its own + /// native storage, costs 0.43 s in total. Byte-identical output, 7× the + /// speed, and it is the difference between a 10-second and a 2-second + /// read of the whole set. + private mutating func transcodeUTF16ToUTF8() { + let low = encoding == .utf16LittleEndian ? 0 : 1 + let high = 1 - low + scratch.removeAll(keepingCapacity: true) + var index = 0 + let end = pending.count + while index + 1 < end { + var scalar = UInt32(pending[index + low]) | (UInt32(pending[index + high]) << 8) + index += 2 + if scalar < 0x80 { + scratch.append(UInt8(scalar)) + continue + } + // A high surrogate followed by a low one is a single character + // above the basic plane. + if scalar >= 0xD800, scalar < 0xDC00, index + 1 < end { + let next = UInt32(pending[index + low]) | (UInt32(pending[index + high]) << 8) + if next >= 0xDC00, next < 0xE000 { + scalar = 0x10000 + ((scalar - 0xD800) << 10) + (next - 0xDC00) + index += 2 + } + } + switch scalar { + case ..<0x800: + scratch.append(UInt8(0xC0 | (scalar >> 6))) + scratch.append(UInt8(0x80 | (scalar & 0x3F))) + case ..<0x10000: + // A surrogate still standing alone here is not a character. + let value = scalar >= 0xD800 && scalar < 0xE000 ? 0xFFFD : scalar + scratch.append(UInt8(0xE0 | (value >> 12))) + scratch.append(UInt8(0x80 | ((value >> 6) & 0x3F))) + scratch.append(UInt8(0x80 | (value & 0x3F))) + default: + scratch.append(UInt8(0xF0 | (scalar >> 18))) + scratch.append(UInt8(0x80 | ((scalar >> 12) & 0x3F))) + scratch.append(UInt8(0x80 | ((scalar >> 6) & 0x3F))) + scratch.append(UInt8(0x80 | (scalar & 0x3F))) + } + } + } + + // MARK: - Encoding detection + + /// Picks an encoding from the start of the file. + /// + /// A byte-order mark settles it. Without one the rule is UTF-8, with one + /// exception: UTF-8 text cannot contain a NUL byte, so NUL bytes all on + /// odd offsets mean UTF-16LE and all on even offsets mean UTF-16BE. The + /// sniff needs one character below U+0100 in the first 64 bytes, which + /// the format guarantees — a DSL file opens with `#NAME`. + /// + /// Defaulting the no-BOM case to UTF-8 rather than refusing is deliberate: + /// hand-made DSL dictionaries are commonly UTF-8 with no mark. + static func detectEncoding(prefix: [UInt8]) -> (encoding: DSLByteEncoding, bomLength: Int) { + if prefix.count >= 3, prefix[0] == 0xEF, prefix[1] == 0xBB, prefix[2] == 0xBF { + return (.utf8, 3) + } + if prefix.count >= 2, prefix[0] == 0xFF, prefix[1] == 0xFE { + return (.utf16LittleEndian, 2) + } + if prefix.count >= 2, prefix[0] == 0xFE, prefix[1] == 0xFF { + return (.utf16BigEndian, 2) + } + + var evenZeros = 0 + var oddZeros = 0 + for (index, byte) in prefix.enumerated() where byte == 0 { + if index.isMultiple(of: 2) { + evenZeros += 1 + } else { + oddZeros += 1 + } + } + if oddZeros > 0, evenZeros == 0 { + return (.utf16LittleEndian, 0) + } + if evenZeros > 0, oddZeros == 0 { + return (.utf16BigEndian, 0) + } + return (.utf8, 0) + } +} diff --git a/Sources/HanReaderCore/Format/DSLCardReader.swift b/Sources/HanReaderCore/Format/DSLCardReader.swift new file mode 100644 index 0000000..d0532cd --- /dev/null +++ b/Sources/HanReaderCore/Format/DSLCardReader.swift @@ -0,0 +1,316 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation + +/// What a DSL file's `#` directives say about itself. +/// +/// Read rather than assumed. The predecessor prototype hardcoded the three +/// BKRS filenames and took the dictionary's name from a constant in the source +/// — so importing any other DSL dictionary mislabelled it, and importing a +/// renamed copy of this one failed outright. Everything here comes out of the +/// file. +public struct DSLHeader: Hashable, Sendable { + /// `#NAME` — what the dictionary calls itself: `大БКРС - 250920 ( 1 / 3 )`. + public var name: String? + /// `#INDEX_LANGUAGE` — the language of the headwords. + public var indexLanguage: String? + /// `#CONTENTS_LANGUAGE` — the language of the definitions. This is what + /// decides whether a Russian-locale reader sees this dictionary first. + public var contentsLanguage: String? + /// `#INCLUDE` targets, as written: file names relative to this file. + public var includes: [String] = [] + /// Every other directive, keyed by name without the `#`. + /// + /// Kept rather than discarded, because a directive this version has not + /// heard of is still something the dictionary chose to state. + public var other: [String: String] = [:] + + public init() {} + + /// Reads a directive line. + /// + /// The name runs to the first space; the value is the rest, with one layer + /// of surrounding quotes removed. A directive with no value records the + /// empty string, which is distinguishable from absent. + mutating func consume(directive line: String) { + let rest = line.dropFirst() + // Any horizontal whitespace, not a literal space. `#INCLUDE\t"b.dsl"` + // is a legal directive, and reading it as an unknown one leaves the + // include list empty and the other volumes undiscovered. + let split = rest.firstIndex { $0 == " " || $0 == "\t" } + 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)) + + switch directive { + case "NAME": name = value + case "INDEX_LANGUAGE": indexLanguage = value + case "CONTENTS_LANGUAGE": contentsLanguage = value + case "INCLUDE": includes.append(value) + default: other[directive] = value + } + } + + private static func unquote(_ value: String) -> String { + guard value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") else { return value } + return String(value.dropFirst().dropLast()) + } +} + +/// One card, and where it starts. +public struct DSLCardRecord: Hashable, Sendable { + public let card: DSLCard + /// Byte offset of the card's first headword line, or nil when the card has + /// no resume point in this file. + /// + /// The resume point is a card boundary rather than a line or a batch + /// boundary because that is the only place an importer can restart without + /// either losing a card or writing one twice. + /// + /// Nil for one case: a card whose headword was carried in from the + /// previous file of the set. No offset in *this* file restarts at it, and + /// an importer that checkpointed there would resume past the headword and + /// lose the card. A reader that needs a resume point for such a card wants + /// the previous file's, which it already has. + public let offset: Int? + + public init(card: DSLCard, offset: Int?) { + self.card = card + self.offset = offset + } +} + +/// Groups a DSL file's lines into cards. +/// +/// The structure is positional, and that is the whole of it: +/// +/// ``` +/// 爱 ← unindented: a headword +/// ài ← first indented line: the reading +/// [m1][b]I[/b] … ← the rest: the article +/// ``` +/// +/// ### Three line kinds, and the order the tests go in +/// +/// An indented line is article text; an unindented line starting with `#` is a +/// directive; anything else unindented is a headword. Indentation is tested +/// first, so an indented line beginning with `#` stays article text rather +/// than being read as a directive — the format escapes a literal leading `#`, +/// but only on an unindented line, where it would be ambiguous. +/// +/// ### Consecutive headwords are one card +/// +/// The DSL spec lets several spellings share one article, written as +/// consecutive unindented lines. BKRS never does — all 3,434,224 cards have +/// exactly one headword — but accepting it costs two lines and refusing it +/// would be a parser bug waiting for the next dictionary. +/// +/// ### A card's article is not always one line +/// +/// This set writes each card's entire sense run on a single physical line, +/// which is what made the predecessor's "one line is one sense" reading +/// produce exactly one sense per card. Exactly one card in the set — +/// 乐芙兰 — breaks a line *inside* an open `[m1]`, and most other DSL +/// dictionaries write one sense per line. So the article is a list of lines +/// and the sense structure is read from the markup, never from the line count. +public struct DSLCardReader { + /// What the file says about itself, filled in as directives are read. + /// Complete once the first card has been returned. + public private(set) var header = DSLHeader() + public private(set) var diagnostics: [DSLDiagnostic] = [] + /// Headwords found at the end of the file with no article under them. + /// + /// Empty for a card-aligned file. A file split on a byte boundary leaves + /// its last headword here, and the matching article arrives as + /// `bodyWithoutHeadword` diagnostics at the start of the next file; a + /// multi-file driver repairs the seam by passing these in as + /// `leadingHeadwords`. + public private(set) var trailingHeadwords: [String] = [] + + private var lines: DSLByteReader + private var headwords: [String] + /// Byte offset of each headword line, parallel to `headwords`. Shorter + /// than it when headwords were carried in from the previous file, which is + /// how a seam-completed card knows it has no resume point of its own. + private var headwordOffsets: [Int] = [] + private var body: [String] = [] + + public init( + source: any DSLByteSource, + resumingAt offset: Int? = nil, + leadingHeadwords: [String] = [], + chunkSize: Int = DSLByteReader.defaultChunkSize, + ) throws { + lines = try DSLByteReader( + source: source, + resumingAt: offset, + chunkSize: chunkSize, + ) + headwords = leadingHeadwords + } + + /// How the file spells its characters. + public var encoding: DSLByteEncoding { + lines.encoding + } + + /// The next card, or nil at end of file. + public mutating func next() throws -> DSLCardRecord? { + while let line = try lines.next() { + let text = line.text + if text.isEmpty { + continue + } + + if let article = Self.stripIndent(text) { + guard !headwords.isEmpty else { + diagnostics.append(DSLDiagnostic( + kind: .bodyWithoutHeadword, + text: text, + offset: line.offset, + )) + continue + } + body.append(article) + continue + } + + if text.hasPrefix("#") { + header.consume(directive: text) + continue + } + + // A headword. If an article has already been collected, the card + // above this line is complete. + guard body.isEmpty else { + let record = takeCard() + headwords = [text] + headwordOffsets = [line.offset] + return record + } + headwords.append(text) + headwordOffsets.append(line.offset) + } + return finalCard() + } + + // MARK: - Cards + + private mutating func takeCard() -> DSLCardRecord { + let split = Self.readingLineCount(in: body) + let record = DSLCardRecord( + card: DSLCard( + headwords: headwords, + pinyin: split == 1 ? body[0] : "", + body: Array(body.dropFirst(split)), + ), + // Nil when the headwords came from the previous file: there is no + // offset in *this* file that would restart at this card, and + // offering zero would restart at the file's byte-order mark and + // lose the headword entirely. + offset: headwordOffsets.count == headwords.count ? headwordOffsets.first : nil, + ) + headwords.removeAll(keepingCapacity: true) + headwordOffsets.removeAll(keepingCapacity: true) + body.removeAll(keepingCapacity: true) + return record + } + + /// Whether the article's first line is a reading, as 1 or 0. + /// + /// A reading line is a convention of Chinese dictionaries rather than a + /// rule of the format, so it is detected rather than assumed. Two + /// conditions, both measured against all 3,434,224 BKRS cards: + /// + /// - **It contains no `[mN]`.** Zero reading lines in the set do, and an + /// article's first line effectively always does. 287 reading lines *do* + /// carry other markup — `lùzhóu, [i]разг.[/i] liùzhóu` — so the test has + /// to be the sense tag specifically and not markup in general. + /// - **Something follows it.** A card with one indented line and no + /// markup 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. + /// + /// Without the first condition a dictionary that writes no readings — the + /// format does not require them — loses its first line of every article. + static func readingLineCount(in body: [String]) -> Int { + guard body.count > 1, let first = body.first, !containsSenseTag(first) else { return 0 } + return 1 + } + + /// Whether a line opens a sense. Scans scalars, as all markup must: `]` + /// followed by a zero-width non-joiner is a single `Character`. + private static func containsSenseTag(_ line: String) -> Bool { + let scalars = Array(line.unicodeScalars) + guard scalars.count >= 4 else { return false } + for index in 0 ... (scalars.count - 4) { + guard scalars[index] == "[", scalars[index + 1] == "m", + scalars[index + 2].properties.numericType != nil, + scalars[index + 2].isASCII, + scalars[index + 3] == "]" + else { continue } + // An escaped `\[` is a literal bracket, not a tag. + if index > 0, scalars[index - 1] == "\\" { + continue + } + return true + } + return false + } + + /// At end of file: a complete card, or a dangling headword reported and + /// handed to `trailingHeadwords`. + private mutating func finalCard() -> DSLCardRecord? { + guard !headwords.isEmpty else { return nil } + guard !body.isEmpty else { + // Each headword reports its own line. Sharing the first one's + // offset across a run of them points every diagnostic but the + // first at the wrong place in the file. + let carried = headwords.count - headwordOffsets.count + for (index, headword) in headwords.enumerated() { + diagnostics.append(DSLDiagnostic( + kind: .headwordWithoutBody, + text: headword, + offset: index < carried ? nil : headwordOffsets[index - carried], + )) + } + trailingHeadwords = headwords + headwords.removeAll() + headwordOffsets.removeAll() + return nil + } + return takeCard() + } + + /// Returns the line without its indent, or nil if it was not indented. + /// + /// All leading horizontal whitespace goes, not just the first character. + /// DSL carries nesting in `[mN]`, never in indent depth, so a line indented + /// two spaces means exactly what one indented by one means — and leaving + /// the extra space in would put it inside the gloss. + static func stripIndent(_ line: String) -> String? { + guard let first = line.unicodeScalars.first, first == " " || first == "\t" else { + return nil + } + return String(line.drop { $0 == " " || $0 == "\t" }) + } +} + +extension DSLHeader { + /// Reads just the directive block at the top of a file. + /// + /// Stops at the first line that is neither blank nor a directive, so + /// discovering a file set costs one chunk rather than a pass over 116 MB. + public static func read(from source: any DSLByteSource) throws -> DSLHeader { + var reader = try DSLByteReader(source: source) + var header = DSLHeader() + while let line = try reader.next() { + if line.text.isEmpty { + continue + } + guard line.text.hasPrefix("#") else { break } + header.consume(directive: line.text) + } + return header + } +} diff --git a/Sources/HanReaderCore/Format/DSLDiagnostic.swift b/Sources/HanReaderCore/Format/DSLDiagnostic.swift new file mode 100644 index 0000000..095b970 --- /dev/null +++ b/Sources/HanReaderCore/Format/DSLDiagnostic.swift @@ -0,0 +1,49 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation + +/// Something a DSL file said that could not be acted on, reported rather than +/// hidden. +/// +/// The rule the whole importer follows is that nothing is ever deleted. A span +/// the lexer cannot identify is emitted as literal text *and* reported; a line +/// the card reader cannot place is reported *and* kept. A dictionary that +/// silently drops what it does not understand is worse than one that shows a +/// stray bracket, because the reader cannot tell "the dictionary says nothing +/// here" from "the importer ate it". +public struct DSLDiagnostic: Hashable, Sendable { + public enum Kind: Hashable, Sendable { + /// A bracketed span whose name is not in `DSLTagKind`. + case unknownTag(String) + /// A `[` with no matching `]` before the end of the line. + case unterminatedTag + /// An indented line with no headword above it. This is what a file + /// split on a byte boundary rather than a card boundary looks like + /// from the second half. + case bodyWithoutHeadword + /// A headword with no article under it — the other half of the same + /// split. + case headwordWithoutBody + /// An `#INCLUDE` naming a file that is not beside the one including + /// it. + case missingInclude(String) + /// Discovery stopped at its file limit with files still to follow, so + /// the set it returned is incomplete. + case tooManyFiles(limit: Int) + } + + public let kind: Kind + /// The text as it appeared, which is also what was emitted or kept. + public let text: String + /// Byte offset in the file, where the reporting layer knows one. + /// + /// The lexer works a line at a time and does not, so this is nil for its + /// diagnostics and set for everything found at the card level. + public let offset: Int? + + public init(kind: Kind, text: String, offset: Int? = nil) { + self.kind = kind + self.text = text + self.offset = offset + } +} diff --git a/Sources/HanReaderCore/Format/DSLFileSet.swift b/Sources/HanReaderCore/Format/DSLFileSet.swift new file mode 100644 index 0000000..17b5949 --- /dev/null +++ b/Sources/HanReaderCore/Format/DSLFileSet.swift @@ -0,0 +1,210 @@ +// HanReader — MIT licensed. See LICENSE. + +public import Foundation + +/// The files one DSL dictionary is spread across. +/// +/// The BKRS set is three files, and `dabkrs_1.dsl` names the other two in its +/// own header: +/// +/// ``` +/// #NAME "大БКРС - 250920 ( 1 / 3 )" +/// #INCLUDE "dabkrs_2.dsl" +/// #INCLUDE "dabkrs_3.dsl" +/// ``` +/// +/// So the set is discovered by reading the file the reader picked, never by +/// knowing those names. The predecessor prototype hardcoded all three, which +/// means it imports this one dictionary and no other, and stops working if the +/// user renames a file. +/// +/// The three files are include-linked volumes rather than byte chunks: each +/// begins and ends on a complete card. They are parsed one at a time and never +/// concatenated, so a failure in the third volume leaves the first two +/// imported. +public struct DSLFileSet: Hashable, Sendable { + /// How the set was found, which the import screen reports so that + /// "importing 3 files when I picked 1" is explained rather than + /// surprising. + public enum Discovery: Hashable, Sendable { + /// The picked file named the others in `#INCLUDE` directives. + case includeDirectives + /// The picked file named nothing, but numbered siblings sit beside it. + case numberedSiblings + /// One file, standing alone. + case single + } + + /// The files to parse, in order, the one holding the directives first. + public let files: [URL] + /// The master file's header. + public let header: DSLHeader + public let discovery: Discovery + public let diagnostics: [DSLDiagnostic] + + public init( + files: [URL], + header: DSLHeader, + discovery: Discovery, + diagnostics: [DSLDiagnostic] = [], + ) { + self.files = files + self.header = header + self.discovery = discovery + self.diagnostics = diagnostics + } + + /// Follows at most this many `#INCLUDE` hops. A visited set already stops + /// a cycle; this stops a pathological file from being a denial of service. + public static let fileLimit = 64 + + /// Works out which files belong with the one the reader picked. + /// + /// `#INCLUDE` first, and it is authoritative: a master file that names two + /// volumes gets exactly those two, in that order, and no directory scan + /// happens at all. An included file may include further files, which is + /// followed with a visited set so a cycle cannot loop. + /// + /// Only when there are no directives does it look at the directory, and + /// then only for files whose names differ from the picked one by a trailing + /// number. If that turns up a lower-numbered sibling — the reader picked + /// volume 2 of 3 — discovery restarts from the lowest, because that is + /// where the directives live. The restart terminates: the lowest file of a + /// numbered run either has directives or is its own lowest sibling. + public static func discover( + from url: URL, + fileManager: FileManager = .default, + ) throws + -> Self + { + let header = try DSLHeader.read(from: DSLFileBytes(url: url)) + + if !header.includes.isEmpty { + return try following( + header.includes, + from: url, + header: header, + fileManager: fileManager, + ) + } + + var truncated = false + let siblings = numberedSiblings(of: url, fileManager: fileManager, truncated: &truncated) + guard siblings.count > 1 else { + return Self(files: [url.standardizedFileURL], header: header, discovery: .single) + } + if siblings[0] != url.standardizedFileURL { + return try discover(from: siblings[0], fileManager: fileManager) + } + return Self( + files: siblings, + header: header, + discovery: .numberedSiblings, + diagnostics: truncated + ? [DSLDiagnostic( + kind: .tooManyFiles(limit: fileLimit), + text: url.lastPathComponent, + )] + : [], + ) + } + + /// Walks the `#INCLUDE` graph breadth-first from the master file. + /// + /// A visited set rather than a depth limit, so a cycle cannot loop; the + /// file limit is a separate guard against a pathological file, and + /// reaching it is reported rather than hidden. + private static func following( + _ includes: [String], + from url: URL, + header: DSLHeader, + fileManager: FileManager, + ) throws + -> Self + { + var files = [url.standardizedFileURL] + var visited: Set = [url.standardizedFileURL] + var diagnostics: [DSLDiagnostic] = [] + var queue = includes.map { (parent: url, name: $0) } + + while !queue.isEmpty { + guard files.count < fileLimit else { + // 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. + diagnostics.append(DSLDiagnostic( + kind: .tooManyFiles(limit: fileLimit), + text: queue.map(\.name).joined(separator: ", "), + )) + break + } + let (parent, name) = queue.removeFirst() + let target = parent.deletingLastPathComponent() + .appendingPathComponent(name) + .standardizedFileURL + guard fileManager.fileExists(atPath: target.path) else { + diagnostics.append(DSLDiagnostic(kind: .missingInclude(name), text: name)) + continue + } + guard visited.insert(target).inserted else { continue } + files.append(target) + let nested = try DSLHeader.read(from: DSLFileBytes(url: target)) + queue += nested.includes.map { (parent: target, name: $0) } + } + return Self( + files: files, + header: header, + discovery: .includeDirectives, + diagnostics: diagnostics, + ) + } + + /// Files beside `url` whose names differ only in a trailing number. + /// + /// `dabkrs_1.dsl` → `dabkrs_2.dsl`, `dabkrs_3.dsl`. The run is contiguous + /// from 1: a gap ends it, because a set missing its second volume is a set + /// of one, not a set of two with a hole. Zero padding is preserved, so + /// `vol_01` looks for `vol_02` and not `vol_2`. + static func numberedSiblings( + of url: URL, + fileManager: FileManager, + truncated: inout Bool, + ) + -> [URL] + { + let stem = url.deletingPathExtension().lastPathComponent + let digits = stem.reversed().prefix { $0.isASCII && $0.isNumber } + guard !digits.isEmpty else { return [url.standardizedFileURL] } + + let prefix = String(stem.dropLast(digits.count)) + let width = digits.count + let directory = url.deletingLastPathComponent() + let suffix = url.pathExtension.isEmpty ? "" : ".\(url.pathExtension)" + + var found: [URL] = [] + var number = 1 + while true { + let padded = String(number).count >= width + ? String(number) + : String(repeating: "0", count: width - String(number).count) + String(number) + let candidate = directory + .appendingPathComponent(prefix + padded + suffix) + .standardizedFileURL + guard fileManager.fileExists(atPath: candidate.path) else { break } + found.append(candidate) + number += 1 + // The run is capped for the same reason the include walk is, and + // reported the same way: the caller is told the set is longer than + // this rather than handed a silently short one. + if found.count >= fileLimit { + truncated = true + break + } + } + // The picked file must be in the run; otherwise the names matched by + // accident and it is safer to import only what was asked for. + guard found.contains(url.standardizedFileURL) else { return [url.standardizedFileURL] } + return found + } +} diff --git a/Sources/HanReaderCore/Format/DSLLexer.swift b/Sources/HanReaderCore/Format/DSLLexer.swift index d67fee0..aae8bad 100644 --- a/Sources/HanReaderCore/Format/DSLLexer.swift +++ b/Sources/HanReaderCore/Format/DSLLexer.swift @@ -83,26 +83,6 @@ public enum DSLToken: Hashable, Sendable { case close(DSLTagKind) } -/// Something the lexer could not make sense of, reported rather than hidden. -public struct DSLDiagnostic: Hashable, Sendable { - public enum Kind: Hashable, Sendable { - /// A bracketed span whose name is not in `DSLTagKind`. - case unknownTag(String) - /// A `[` with no matching `]` before the end of the line. - case unterminatedTag - } - - public let kind: Kind - /// The text as it appeared, which is also what was emitted. Nothing is - /// dropped on the way to a diagnostic. - public let text: String - - public init(kind: Kind, text: String) { - self.kind = kind - self.text = text - } -} - /// Splits a line of DSL body text into text and markup. /// /// ### Escapes are handled before tags, and that ordering is the whole thing diff --git a/Tests/HanReaderCoreTests/DSLByteReaderTests.swift b/Tests/HanReaderCoreTests/DSLByteReaderTests.swift new file mode 100644 index 0000000..308c824 --- /dev/null +++ b/Tests/HanReaderCoreTests/DSLByteReaderTests.swift @@ -0,0 +1,301 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation +import Testing +@testable import HanReaderCore + +// MARK: - Helpers + +/// Encodes text the way ABBYY writes a DSL file. +private func utf16(_ text: String, bigEndian: Bool = false, bom: Bool = true) -> [UInt8] { + var bytes: [UInt8] = [] + if bom { + bytes += bigEndian ? [0xFE, 0xFF] : [0xFF, 0xFE] + } + for unit in Array(text.utf16) { + let low = UInt8(unit & 0xFF) + let high = UInt8(unit >> 8) + bytes += bigEndian ? [high, low] : [low, high] + } + return bytes +} + +private func lines( + _ bytes: [UInt8], + chunkSize: Int = DSLByteReader.defaultChunkSize, + resumingAt offset: Int? = nil, +) throws + -> [DSLLine] +{ + var reader = try DSLByteReader( + source: DSLMemoryBytes(bytes), + resumingAt: offset, + chunkSize: chunkSize, + ) + var result: [DSLLine] = [] + while let line = try reader.next() { + result.append(line) + } + return result +} + +/// A source that reports its full length but stops delivering part way, which +/// is what a file truncated between being measured and being read looks like. +private struct ShrinkingBytes: DSLByteSource { + let bytes: [UInt8] + let delivering: Int + + var byteCount: Int { + bytes.count + } + + func read(at offset: Int, into buffer: inout [UInt8], from destination: Int) throws -> Int { + guard offset >= 0, offset < delivering, destination < buffer.count else { return 0 } + let count = min(buffer.count - destination, delivering - offset) + buffer.replaceSubrange( + destination ..< (destination + count), + with: bytes[offset ..< (offset + count)], + ) + return count + } +} + +/// The real shape of a BKRS file: two CRLF header lines, a blank line, then +/// LF-terminated cards, ending with a line feed. +private let header = "#NAME \"大БКРС\"\r\n#INDEX_LANGUAGE \"Chinese\"\r\n" + + "#CONTENTS_LANGUAGE \"Russian\"\n#INCLUDE \"dabkrs_2.dsl\"\n\n" +private let cards = "爱\n ài\n [m1]любить[/m]\n了\n le, liǎo\n [m1]частица[/m]\n" + +@Suite("DSL byte reader") +struct DSLByteReaderTests { + // MARK: - The alignment trap + + /// In UTF-16LE a line feed is the code unit `0x000A` — the bytes `0A 00` + /// **at an even offset**. `ਅ` is U+0A05 and `昀` is U+6600, so the two + /// together are `05 0A 00 66`: the pair appears at an odd offset, inside + /// a character. A scanner that searches the raw bytes breaks the line + /// there and splits one character in half. + @Test("A misaligned line-feed byte pair is not a line break") + func misalignedPairIsNotABreak() throws { + let bytes = utf16("x\u{0A05}\u{6600}y\nz\n") + + // The trap is actually present in this input, so the assertion below + // is not vacuous. + let pair = (0 ..< (bytes.count - 1)).first { bytes[$0] == 0x0A && bytes[$0 + 1] == 0x00 } + let trap = try #require(pair) + #expect(!trap.isMultiple(of: 2)) + + let result = try lines(bytes) + #expect(result.map(\.text) == ["x\u{0A05}\u{6600}y", "z"]) + } + + /// Reading the same bytes two at a time puts the trap at the very end of + /// a chunk, which is where a scanner that forgets to realign after a + /// refill goes wrong. + @Test("The alignment holds across chunk boundaries", arguments: [2, 4, 6, 8, 16]) + func misalignedPairAcrossChunks(chunkSize: Int) throws { + let result = try lines(utf16("x\u{0A05}\u{6600}y\nz\n"), chunkSize: chunkSize) + #expect(result.map(\.text) == ["x\u{0A05}\u{6600}y", "z"]) + } + + // MARK: - Line endings + + /// Each BKRS file mixes exactly two CRLF lines in among three million LF + /// ones, so neither terminator is the one to split on. + @Test("Both line endings are handled in the same file") + func mixedLineEndings() throws { + let result = try lines(utf16(header + cards)) + #expect(result.map(\.text) == [ + "#NAME \"大БКРС\"", + "#INDEX_LANGUAGE \"Chinese\"", + "#CONTENTS_LANGUAGE \"Russian\"", + "#INCLUDE \"dabkrs_2.dsl\"", + "", + "爱", " ài", " [m1]любить[/m]", + "了", " le, liǎo", " [m1]частица[/m]", + ]) + } + + /// All three files end with a line feed. Splitting the text yields an + /// empty final element, and at the card level an unindented empty line is + /// indistinguishable from a headword. + @Test("A trailing newline does not produce an empty final line") + func trailingNewline() throws { + #expect(try lines(utf16("a\nb\n")).map(\.text) == ["a", "b"]) + } + + @Test("A file with no trailing newline still yields its last line") + func noTrailingNewline() throws { + #expect(try lines(utf16("a\nb")).map(\.text) == ["a", "b"]) + } + + /// A blank line inside the file is a line, not nothing — the header is + /// separated from the body by one, and the card reader needs to see it. + @Test("A blank line is reported") + func blankLine() throws { + #expect(try lines(utf16("a\n\nb\n")).map(\.text) == ["a", "", "b"]) + } + + @Test("An empty file yields no lines") + func emptyFile() throws { + #expect(try lines([]).isEmpty) + } + + @Test("A file that is nothing but a byte-order mark yields no lines") + func bomOnly() throws { + #expect(try lines(utf16("")).isEmpty) + } + + // MARK: - Offsets + + /// Byte offsets, because that is what a resumed import seeks to. + @Test("Each line carries the byte offset it started at") + func offsets() throws { + // BOM (2) + "a\n" (4) + "bb\n" (6). + let result = try lines(utf16("a\nbb\nc\n")) + #expect(result.map(\.offset) == [2, 6, 12]) + } + + @Test("Offsets are unaffected by the chunk size", arguments: [2, 4, 6, 10, 4096]) + func offsetsAcrossChunks(chunkSize: Int) throws { + let result = try lines(utf16(header + cards), chunkSize: chunkSize) + #expect(try result == lines(utf16(header + cards))) + } + + /// The resume contract: an offset this reader reported, handed back to a + /// new reader, continues from exactly that line. + @Test("Resuming at a reported offset continues from that line") + func resume() throws { + let bytes = utf16(header + cards) + let all = try lines(bytes) + let start = try #require(all.indices.last.map { $0 - 3 }) + let resumed = try lines(bytes, resumingAt: all[start].offset) + #expect(resumed == Array(all[start...])) + } + + /// A resume offset is taken literally rather than being re-sniffed for a + /// byte-order mark, so the first character after a checkpoint is not + /// mistaken for one. + @Test("A resume offset is not re-interpreted as a byte-order mark") + func resumeDoesNotSkipABOM() throws { + let bytes = utf16("\u{FEFF}a\nb\n") + let result = try lines(bytes, resumingAt: 2) + #expect(result.map(\.text) == ["\u{FEFF}a", "b"]) + } + + // MARK: - Malformed files + + /// The last character is cut in half, so every offset past it would be a + /// guess. This is the `kill -9`-during-a-copy case. + @Test("An odd-length UTF-16 file is refused") + func oddByteLength() throws { + var bytes = utf16("a\nb\n") + bytes.removeLast() + #expect(throws: DSLByteError.oddByteLength(byteCount: bytes.count)) { + _ = try lines(bytes) + } + } + + @Test("A resume offset inside a code unit is refused") + func misalignedOffset() throws { + #expect(throws: DSLByteError.misalignedOffset(5)) { + _ = try lines(utf16("a\nb\n"), resumingAt: 5) + } + } + + /// A source that reports one length and then delivers less was shortened + /// while being read. Accepting it means a dictionary quietly missing its + /// tail — and in UTF-16, a final half-character dropped without the + /// odd-length check ever running. + @Test("A file that ends early is refused") + func truncatedWhileReading() throws { + let bytes = utf16("爱\n了\n一\n") + let source = ShrinkingBytes(bytes: bytes, delivering: 8) + var reader = try DSLByteReader(source: source, chunkSize: 4) + #expect(throws: DSLByteError.truncated(read: 8, expected: bytes.count)) { + while try reader.next() != nil {} + } + } + + @Test("A resume offset past the end is refused") + func offsetOutOfRange() throws { + let bytes = utf16("a\n") + #expect(throws: DSLByteError.offsetOutOfRange(offset: 100, byteCount: bytes.count)) { + _ = try lines(bytes, resumingAt: 100) + } + } + + /// Nothing is dropped and nothing throws: one mangled character costs + /// that character, not the card around it. + @Test("An unpaired surrogate becomes a replacement character") + func unpairedSurrogate() throws { + var bytes = utf16("a") + bytes += [0x00, 0xD8] // U+D800, a high surrogate with no low one + bytes += utf16("b\n", bom: false) + #expect(try lines(bytes).map(\.text) == ["a\u{FFFD}b"]) + } + + // MARK: - Encoding detection + + @Test("A UTF-16LE byte-order mark is detected and skipped") + func littleEndianBOM() throws { + var reader = try DSLByteReader(source: DSLMemoryBytes(utf16("爱\n"))) + #expect(reader.encoding == .utf16LittleEndian) + #expect(try reader.next()?.text == "爱") + } + + @Test("A UTF-16BE byte-order mark is detected and skipped") + func bigEndianBOM() throws { + let bytes = utf16("#NAME\n爱\n", bigEndian: true) + var reader = try DSLByteReader(source: DSLMemoryBytes(bytes)) + #expect(reader.encoding == .utf16BigEndian) + #expect(try reader.next()?.text == "#NAME") + #expect(try reader.next()?.text == "爱") + } + + @Test("A UTF-8 byte-order mark is detected and skipped") + func utf8BOM() throws { + var bytes: [UInt8] = [0xEF, 0xBB, 0xBF] + bytes += Array("爱\n ài\n".utf8) + var reader = try DSLByteReader(source: DSLMemoryBytes(bytes)) + #expect(reader.encoding == .utf8) + #expect(try reader.next()?.text == "爱") + #expect(try reader.next()?.text == " ài") + } + + /// Hand-made DSL dictionaries are commonly UTF-8 with no mark, so that is + /// the default rather than a refusal. + @Test("A file with no mark is read as UTF-8") + func bomlessUTF8() throws { + let bytes = Array("爱\n ài\n [m1]любить[/m]\n".utf8) + var reader = try DSLByteReader(source: DSLMemoryBytes(bytes)) + #expect(reader.encoding == .utf8) + #expect(try reader.next()?.text == "爱") + } + + /// UTF-8 text cannot contain a NUL byte, so NUL bytes all at odd offsets + /// can only be the high halves of UTF-16LE code units. Without this a + /// mark-less UTF-16 file decodes to mojibake with no error at all. + @Test("A mark-less UTF-16 file is detected from its NUL bytes", arguments: [false, true]) + func bomlessUTF16(bigEndian: Bool) throws { + let bytes = utf16("#NAME \"x\"\n爱\n", bigEndian: bigEndian, bom: false) + var reader = try DSLByteReader(source: DSLMemoryBytes(bytes)) + #expect(reader.encoding == (bigEndian ? .utf16BigEndian : .utf16LittleEndian)) + #expect(try reader.next()?.text == "#NAME \"x\"") + #expect(try reader.next()?.text == "爱") + } + + /// Chinese characters have no zero byte in either half, so a mark-less + /// UTF-16 file that opens with one cannot be told from UTF-8. The format + /// rules it out — a DSL file opens with `#NAME` — and the sniff is + /// documented as needing that. + @Test("Detection reports where it cannot tell") + func detectionLimits() { + #expect(DSLByteReader.detectEncoding(prefix: []) == (.utf8, 0)) + #expect(DSLByteReader.detectEncoding(prefix: Array("爱 ài".utf8)) == (.utf8, 0)) + // 爱 in UTF-16LE is 1B 72 — no zero byte to sniff. + #expect(DSLByteReader.detectEncoding(prefix: [0x1B, 0x72]) == (.utf8, 0)) + // A zero byte in each half is not a byte order. + #expect(DSLByteReader.detectEncoding(prefix: [0x00, 0x00]) == (.utf8, 0)) + } +} diff --git a/Tests/HanReaderCoreTests/DSLCardReaderTests.swift b/Tests/HanReaderCoreTests/DSLCardReaderTests.swift new file mode 100644 index 0000000..f949d02 --- /dev/null +++ b/Tests/HanReaderCoreTests/DSLCardReaderTests.swift @@ -0,0 +1,507 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation +import Testing +@testable import HanReaderCore + +// MARK: - Helpers + +private func utf16(_ text: String) -> [UInt8] { + var bytes: [UInt8] = [0xFF, 0xFE] + for unit in Array(text.utf16) { + bytes += [UInt8(unit & 0xFF), UInt8(unit >> 8)] + } + return bytes +} + +private func read( + _ text: String, + leadingHeadwords: [String] = [], +) throws + -> (records: [DSLCardRecord], reader: DSLCardReader) +{ + var reader = try DSLCardReader( + source: DSLMemoryBytes(utf16(text)), + leadingHeadwords: leadingHeadwords, + ) + var records: [DSLCardRecord] = [] + while let record = try reader.next() { + records.append(record) + } + return (records, reader) +} + +private func cards(_ text: String) throws -> [DSLCard] { + try read(text).records.map(\.card) +} + +/// A file shaped like a real one: CRLF header lines, a blank separator, then +/// three-line cards. +private let file = """ +#NAME "大БКРС - 250920 ( 1 / 3 )"\r +#INDEX_LANGUAGE "Chinese"\r +#CONTENTS_LANGUAGE "Russian" +#INCLUDE "dabkrs_2.dsl" +#INCLUDE "dabkrs_3.dsl" + +爱 + ài + [m1]любить[/m] +了 + le, liǎo + [m1]частица[/m] + +""" + +@Suite("DSL card reader") +struct DSLCardReaderTests { + // MARK: - Card shape + + @Test("A file becomes its cards") + func wholeFile() throws { + let result = try cards(file) + #expect(result.count == 2) + #expect(result.map(\.headwords) == [["爱"], ["了"]]) + #expect(result.map(\.pinyin) == ["ài", "le, liǎo"]) + #expect(result.map(\.body) == [["[m1]любить[/m]"], ["[m1]частица[/m]"]]) + } + + // MARK: - Telling a reading line from an article line + + /// A reading line is a convention of Chinese dictionaries, not a rule of + /// the format, so it is detected rather than assumed. A dictionary that + /// writes no readings — which DSL permits — would otherwise lose the first + /// line of every article to a reading field. + @Test("A card whose article opens with a sense tag has no reading") + func articleWithoutReading() throws { + let result = try cards("dog\n [m1]animal[/m]\n [m1]пёс[/m]\n") + #expect(result[0].pinyin.isEmpty) + #expect(result[0].body == ["[m1]animal[/m]", "[m1]пёс[/m]"]) + } + + /// 287 reading lines in the set carry markup — an italic register note + /// between two readings — so the test has to be the sense tag + /// specifically, not markup in general. + @Test("A reading line carrying other markup is still a reading") + func readingWithMarkup() throws { + let result = try cards("陆轴\n lùzhóu, [i]разг.[/i] liùzhóu\n [m1]каток[/m]\n") + #expect(result[0].pinyin == "lùzhóu, [i]разг.[/i] liùzhóu") + #expect(result[0].body == ["[m1]каток[/m]"]) + } + + /// One indented line with no markup 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. + @Test("A single indented line is article text, not a reading") + func singleLineIsArticle() throws { + let result = try cards("爱\n любить\n") + #expect(result[0].pinyin.isEmpty) + #expect(result[0].body == ["любить"]) + } + + @Test("An escaped bracket does not look like a sense tag") + func escapedBracketIsNotATag() throws { + let result = try cards("爱\n \\[m1\\] ài\n [m1]любить[/m]\n") + #expect(result[0].pinyin == "\\[m1\\] ài") + } + + /// Exactly one card in the set — 乐芙兰 — breaks a line inside an open + /// `[m1]`, and most other DSL dictionaries write one sense per line. The + /// article is a list of lines; the structure comes from the markup. + @Test("An article spread over several lines keeps all of them") + func multiLineArticle() throws { + let result = try cards("乐芙兰\n _\n [m1]Ле Блан\n (чемпион из Лиги Легенд)[/m]\n") + #expect(result[0].body == ["[m1]Ле Блан", "(чемпион из Лиги Легенд)[/m]"]) + } + + /// And the sense that article belongs to comes out as one gloss with the + /// line break rendered as a space, not as `Ле Блан(чемпион…)`. + @Test("A sense broken across lines reads as one sentence") + func multiLineSense() throws { + let card = try #require( + try cards("乐芙兰\n _\n [m1]Ле Блан\n (чемпион из Лиги Легенд)[/m]\n").first, + ) + let built = DSLCardBuilder(syllableBases: []).entries(from: card) + let senses = try #require(built.entries.first?.senses) + #expect(senses.count == 1) + #expect(senses[0].gloss.text == "Ле Блан (чемпион из Лиги Легенд)") + } + + /// The DSL spec lets several spellings share one article. BKRS never does + /// — all 3,434,224 cards have one headword — but refusing it would be a + /// parser bug waiting for the next dictionary. + @Test("Consecutive unindented lines are one card") + func sharedArticle() throws { + let result = try cards("爱\n愛\n ài\n [m1]любить[/m]\n") + #expect(result.count == 1) + #expect(result[0].headwords == ["爱", "愛"]) + } + + @Test("A blank line between cards is not a headword") + func blankLineBetweenCards() throws { + let result = try cards("爱\n ài\n [m1]x[/m]\n\n了\n le\n [m1]y[/m]\n") + #expect(result.map(\.headwords) == [["爱"], ["了"]]) + } + + @Test("A file with no trailing newline still yields its last card") + func noTrailingNewline() throws { + #expect(try cards("爱\n ài\n [m1]любить[/m]").count == 1) + } + + @Test("A file of nothing but a header yields no cards") + func headerOnly() throws { + let result = try read("#NAME \"x\"\n#INDEX_LANGUAGE \"Chinese\"\n\n") + #expect(result.records.isEmpty) + #expect(result.reader.header.name == "x") + } + + // MARK: - Line kinds + + /// Indentation is tested before the `#`, so an indented line beginning + /// with `#` is article text. The format escapes a literal leading `#` + /// only where it would be ambiguous, which is on an unindented line. + @Test("An indented line starting with a hash is article text") + func indentedHash() throws { + let result = try cards("爱\n ài\n #1 значение\n") + #expect(result[0].body == ["#1 значение"]) + } + + /// Nesting is carried by `[mN]`, never by indent depth, so a line + /// indented by two spaces means what one indented by one means — and + /// leaving the extra space in would put it inside the gloss. + @Test("Indent depth carries no meaning", arguments: [" ", " ", "\t", " \t "]) + func indentDepth(indent: String) throws { + let result = try cards("爱\n\(indent)ài\n\(indent)[m1]любить[/m]\n") + #expect(result[0].pinyin == "ài") + #expect(result[0].body == ["[m1]любить[/m]"]) + } + + @Test("stripIndent reports an unindented line as unindented") + func stripIndentOnUnindented() { + #expect(DSLCardReader.stripIndent("爱") == nil) + #expect(DSLCardReader.stripIndent(" ài") == "ài") + #expect(DSLCardReader.stripIndent("") == nil) + } + + // MARK: - Offsets + + /// Byte offset of the headword line, because a card boundary is the only + /// place an interrupted import can restart without losing a card or + /// writing one twice. + @Test("Each card records the byte offset of its headword line") + func offsets() throws { + let result = try read(file).records + let bytes = utf16(file) + for record in result { + let headword = record.card.headwords[0] + let expected = Array(headword.utf16).flatMap { [UInt8($0 & 0xFF), UInt8($0 >> 8)] } + let offset = try #require(record.offset) + #expect(Array(bytes[offset ..< (offset + expected.count)]) == expected) + } + } + + /// The resume contract, end to end: an offset this reader reported, handed + /// back to a new reader, yields exactly the cards from there on. + @Test("Resuming at a card's offset continues from that card") + func resume() throws { + let all = try read(file).records + let second = try #require(all.last) + var reader = try DSLCardReader( + source: DSLMemoryBytes(utf16(file)), + resumingAt: second.offset, + ) + var resumed: [DSLCardRecord] = [] + while let record = try reader.next() { + resumed.append(record) + } + #expect(resumed == [second]) + } + + // MARK: - Header + + @Test("Directives are read from the file, never assumed") + func header() throws { + let reader = try read(file).reader + #expect(reader.header.name == "大БКРС - 250920 ( 1 / 3 )") + #expect(reader.header.indexLanguage == "Chinese") + #expect(reader.header.contentsLanguage == "Russian") + #expect(reader.header.includes == ["dabkrs_2.dsl", "dabkrs_3.dsl"]) + } + + /// A directive this version has not heard of is still something the + /// dictionary chose to state. + @Test("An unrecognised directive is kept rather than dropped") + func unknownDirective() throws { + let reader = try read("#ICON_FILE \"x.bmp\"\n#SOUND_DICTIONARY\n\n").reader + #expect(reader.header.other == ["ICON_FILE": "x.bmp", "SOUND_DICTIONARY": ""]) + } + + /// The format separates a directive from its value with whitespace, not + /// with a space specifically. Read as an unknown directive, a + /// tab-separated `#INCLUDE` leaves the include list empty and the other + /// volumes undiscovered. + @Test("A directive separated by a tab is still a directive") + func tabSeparatedDirective() throws { + let reader = try read("#INCLUDE\t\"b.dsl\"\n#NAME\t\"A\"\n\n").reader + #expect(reader.header.includes == ["b.dsl"]) + #expect(reader.header.name == "A") + #expect(reader.header.other.isEmpty) + } + + @Test("Reading only the header stops at the first card") + func headerOnlyRead() throws { + let header = try DSLHeader.read(from: DSLMemoryBytes(utf16(file))) + #expect(header.includes.count == 2) + #expect(header.other.isEmpty) + } + + // MARK: - A seam split on the wrong boundary + + /// The BKRS files are include-linked volumes, each beginning and ending on + /// a complete card, so none of this fires for them. It is insurance + /// against a set someone split with `split(1)`. + @Test("An article with no headword above it is reported, not dropped") + func bodyWithoutHeadword() throws { + let result = try read(" ài\n [m1]любить[/m]\n了\n le\n [m1]x[/m]\n") + #expect(result.records.count == 1) + #expect(result.reader.diagnostics.count == 2) + #expect(result.reader.diagnostics.allSatisfy { $0.kind == .bodyWithoutHeadword }) + #expect(result.reader.diagnostics[0].text == " ài") + #expect(result.reader.diagnostics[0].offset == 2) + } + + @Test("A headword with no article is reported and handed on") + func headwordWithoutBody() throws { + let result = try read("爱\n ài\n [m1]x[/m]\n了\n") + #expect(result.records.count == 1) + #expect(result.reader.trailingHeadwords == ["了"]) + #expect(result.reader.diagnostics.map(\.kind) == [.headwordWithoutBody]) + } + + /// Each headword reports its own line. Sharing the first one's offset + /// across a run of them points every diagnostic but the first at the wrong + /// place in the file, which is worse than no offset at all. + @Test("Each dangling headword reports its own line") + func danglingHeadwordOffsets() throws { + let text = "爱\n ài\n [m1]x[/m]\n了\n一\n" + let result = try read(text) + let bytes = utf16(text) + let offsets = result.reader.diagnostics.compactMap(\.offset) + #expect(offsets.count == 2) + #expect(offsets[0] != offsets[1]) + for (offset, headword) in zip(offsets, ["了", "一"]) { + let expected = Array(headword.utf16).flatMap { [UInt8($0 & 0xFF), UInt8($0 >> 8)] } + #expect(Array(bytes[offset ..< (offset + expected.count)]) == expected) + } + } + + /// A card completed across a file seam has no resume point in the file it + /// was completed in: offset zero there restarts at the byte-order mark and + /// loses the carried headword entirely. + @Test("A card completed across a seam offers no resume point") + func seamCardHasNoOffset() throws { + let result = try read( + " le\n [m1]частица[/m]\n一\n yī\n [m1]один[/m]\n", + leadingHeadwords: ["了"], + ) + #expect(result.records.count == 2) + #expect(result.records[0].card.headwords == ["了"]) + #expect(result.records[0].offset == nil) + // The card that does start in this file keeps its offset. + #expect(result.records[1].offset != nil) + } + + /// The repair: the headword left over from the previous file is passed in, + /// and the article that opens this one completes its card. + @Test("A headword carried in from the previous file completes its card") + func leadingHeadwords() throws { + let result = try read(" le\n [m1]частица[/m]\n", leadingHeadwords: ["了"]) + #expect(result.records.count == 1) + #expect(result.records[0].card.headwords == ["了"]) + #expect(result.records[0].card.pinyin == "le") + #expect(result.reader.diagnostics.isEmpty) + } +} + +// MARK: - File-set discovery + +@Suite("DSL file-set discovery") +struct DSLFileSetTests { + /// Writes a throwaway directory of DSL files. + func directory( + _ files: [String: String], + _ body: (URL) throws -> Void, + ) throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("hanreader-dsl-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + for (name, text) in files { + try Data(utf16(text)).write(to: root.appendingPathComponent(name)) + } + try body(root) + } + + /// The master file names its volumes, so the set is read out of the file + /// rather than out of a hardcoded list of names — which is what makes the + /// importer work on a second dictionary, and on a renamed copy of this + /// one. + @Test("The set comes from the master file's include directives") + func includeDirectives() throws { + try directory([ + "a.dsl": "#NAME \"A\"\n#INCLUDE \"b.dsl\"\n#INCLUDE \"c.dsl\"\n\n爱\n ài\n [m1]x[/m]\n", + "b.dsl": "#NAME \"B\"\n\n了\n le\n [m1]y[/m]\n", + "c.dsl": "#NAME \"C\"\n\n一\n yī\n [m1]z[/m]\n", + ]) { root in + let set = try DSLFileSet.discover(from: root.appendingPathComponent("a.dsl")) + #expect(set.discovery == .includeDirectives) + #expect(set.files.map(\.lastPathComponent) == ["a.dsl", "b.dsl", "c.dsl"]) + #expect(set.header.name == "A") + #expect(set.diagnostics.isEmpty) + } + } + + @Test("An include naming a file that is not there is reported") + func missingInclude() throws { + try directory([ + "a.dsl": "#NAME \"A\"\n#INCLUDE \"gone.dsl\"\n\n爱\n ài\n [m1]x[/m]\n", + ]) { root in + let set = try DSLFileSet.discover(from: root.appendingPathComponent("a.dsl")) + #expect(set.files.count == 1) + #expect(set.diagnostics.map(\.kind) == [.missingInclude("gone.dsl")]) + } + } + + @Test("An include cycle does not loop") + func includeCycle() throws { + try directory([ + "a.dsl": "#NAME \"A\"\n#INCLUDE \"b.dsl\"\n\n爱\n ài\n [m1]x[/m]\n", + "b.dsl": "#NAME \"B\"\n#INCLUDE \"a.dsl\"\n\n了\n le\n [m1]y[/m]\n", + ]) { root in + let set = try DSLFileSet.discover(from: root.appendingPathComponent("a.dsl")) + #expect(set.files.map(\.lastPathComponent) == ["a.dsl", "b.dsl"]) + } + } + + /// Only when there are no directives at all. + @Test("Numbered siblings are found when nothing is declared") + func numberedSiblings() throws { + try directory([ + "dict_1.dsl": "#NAME \"1\"\n\n爱\n ài\n [m1]x[/m]\n", + "dict_2.dsl": "#NAME \"2\"\n\n了\n le\n [m1]y[/m]\n", + "dict_3.dsl": "#NAME \"3\"\n\n一\n yī\n [m1]z[/m]\n", + ]) { root in + let set = try DSLFileSet.discover(from: root.appendingPathComponent("dict_1.dsl")) + #expect(set.discovery == .numberedSiblings) + #expect(set.files.map(\.lastPathComponent) == [ + "dict_1.dsl", + "dict_2.dsl", + "dict_3.dsl", + ]) + } + } + + /// Picking volume two has to find volume one, because that is where the + /// directives and the dictionary's own name live. + @Test("Picking a later volume restarts discovery from the first") + func picksLowestSibling() throws { + try directory([ + "dict_1.dsl": "#NAME \"whole thing\"\n#INCLUDE \"dict_2.dsl\"\n\n爱\n ài\n [m1]x[/m]\n", + "dict_2.dsl": "#NAME \"volume 2\"\n\n了\n le\n [m1]y[/m]\n", + ]) { root in + let set = try DSLFileSet.discover(from: root.appendingPathComponent("dict_2.dsl")) + #expect(set.discovery == .includeDirectives) + #expect(set.header.name == "whole thing") + #expect(set.files.map(\.lastPathComponent) == ["dict_1.dsl", "dict_2.dsl"]) + } + } + + /// A set missing its second volume is a set of one, not a set of two with + /// a hole in it. + @Test("A gap in the numbering ends the run") + func gapEndsTheRun() throws { + try directory([ + "dict_1.dsl": "#NAME \"1\"\n\n爱\n ài\n [m1]x[/m]\n", + "dict_3.dsl": "#NAME \"3\"\n\n一\n yī\n [m1]z[/m]\n", + ]) { root in + let set = try DSLFileSet.discover(from: root.appendingPathComponent("dict_1.dsl")) + #expect(set.files.map(\.lastPathComponent) == ["dict_1.dsl"]) + #expect(set.discovery == .single) + } + } + + @Test("Zero padding in the numbering is preserved") + func zeroPadding() throws { + try directory([ + "vol_01.dsl": "#NAME \"1\"\n\n爱\n ài\n [m1]x[/m]\n", + "vol_02.dsl": "#NAME \"2\"\n\n了\n le\n [m1]y[/m]\n", + ]) { root in + let set = try DSLFileSet.discover(from: root.appendingPathComponent("vol_01.dsl")) + #expect(set.files.map(\.lastPathComponent) == ["vol_01.dsl", "vol_02.dsl"]) + } + } + + /// A file whose name happens to end in a digit is not part of a run it is + /// not in; importing files the reader did not pick needs a better reason + /// than a coincidence of naming. + @Test("A file outside the numbered run is imported alone") + func unrelatedNumbering() throws { + try directory([ + "dict_1.dsl": "#NAME \"1\"\n\n爱\n ài\n [m1]x[/m]\n", + "dict_9.dsl": "#NAME \"9\"\n\n了\n le\n [m1]y[/m]\n", + ]) { root in + let set = try DSLFileSet.discover(from: root.appendingPathComponent("dict_9.dsl")) + #expect(set.files.map(\.lastPathComponent) == ["dict_9.dsl"]) + #expect(set.discovery == .single) + } + } + + /// 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. + @Test("Discovery says so when it stops at its file limit") + func tooManyIncludes() throws { + var files: [String: String] = [:] + let includes = (1 ... DSLFileSet.fileLimit + 2) + .map { "#INCLUDE \"v\($0).dsl\"\n" } + .joined() + files["a.dsl"] = "#NAME \"A\"\n" + includes + "\n爱\n ài\n [m1]x[/m]\n" + for index in 1 ... DSLFileSet.fileLimit + 2 { + files["v\(index).dsl"] = "#NAME \"\(index)\"\n\n了\n le\n [m1]y[/m]\n" + } + try directory(files) { root in + let set = try DSLFileSet.discover(from: root.appendingPathComponent("a.dsl")) + #expect(set.files.count == DSLFileSet.fileLimit) + #expect(set.diagnostics.contains { + $0.kind == .tooManyFiles(limit: DSLFileSet.fileLimit) + }) + } + } + + @Test("A numbered run that hits the limit says so too") + func tooManySiblings() throws { + var files: [String: String] = [:] + for index in 1 ... DSLFileSet.fileLimit + 2 { + files["vol_\(index).dsl"] = "#NAME \"\(index)\"\n\n了\n le\n [m1]y[/m]\n" + } + try directory(files) { root in + let set = try DSLFileSet.discover(from: root.appendingPathComponent("vol_1.dsl")) + #expect(set.files.count == DSLFileSet.fileLimit) + #expect(set.diagnostics.contains { + $0.kind == .tooManyFiles(limit: DSLFileSet.fileLimit) + }) + } + } + + @Test("A name with no trailing number stands alone") + func noNumber() throws { + try directory([ + "dict.dsl": "#NAME \"d\"\n\n爱\n ài\n [m1]x[/m]\n", + ]) { root in + let set = try DSLFileSet.discover(from: root.appendingPathComponent("dict.dsl")) + #expect(set.discovery == .single) + #expect(set.files.map(\.lastPathComponent) == ["dict.dsl"]) + } + } +} diff --git a/Tests/HanReaderCoreTests/DSLFixtureTests.swift b/Tests/HanReaderCoreTests/DSLFixtureTests.swift new file mode 100644 index 0000000..f5a160d --- /dev/null +++ b/Tests/HanReaderCoreTests/DSLFixtureTests.swift @@ -0,0 +1,244 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation +import Testing +@testable import HanReaderCore + +/// Loads the committed slice of real 大БКРС data. +/// +/// Real cards rather than hand-written ones, because the shapes that break a +/// parser are the ones nobody thinks to invent — 一's prose sandhi note, the +/// `[*]` block with no `[ex]` inside it, the one card whose article is split +/// across two lines. Extracted by `Scripts/extract-dsl-fixture.py`; the +/// sidecar manifest says why each card is in there. +private enum Fixture { + static let text: String = { + guard let url = Bundle.module.url( + forResource: "Fixtures/bkrs-slice.dsl", + withExtension: "txt", + ), + let data = try? Data(contentsOf: url), + let text = String(data: data, encoding: .utf8) + else { + Issue.record("could not load the BKRS fixture") + return "" + } + return text + }() + + /// The committed bytes: UTF-8, no byte-order mark, so the slice stays + /// diffable in a pull request. + static let utf8: [UInt8] = Array(text.utf8) + + /// The same content as the source files are actually written: UTF-16LE + /// with a byte-order mark. Re-encoded here rather than committed, so the + /// real encoding path is exercised without a binary blob in the + /// repository. + static let utf16: [UInt8] = { + var bytes: [UInt8] = [0xFF, 0xFE] + for unit in Array(text.utf16) { + bytes += [UInt8(unit & 0xFF), UInt8(unit >> 8)] + } + return bytes + }() + + struct Reading { + let records: [DSLCardRecord] + let header: DSLHeader + let diagnostics: [DSLDiagnostic] + } + + static func read(_ bytes: [UInt8]) throws -> Reading { + var reader = try DSLCardReader(source: DSLMemoryBytes(bytes)) + var records: [DSLCardRecord] = [] + while let record = try reader.next() { + records.append(record) + } + return Reading( + records: records, + header: reader.header, + diagnostics: reader.diagnostics, + ) + } + + static func card(_ headword: String, in records: [DSLCardRecord]) -> DSLCard? { + records.first { $0.card.headwords.first == headword }?.card + } + + static func senses(_ headword: String, in records: [DSLCardRecord]) -> [Sense] { + guard let card = card(headword, in: records) else { return [] } + return DSLCardBuilder(syllableBases: []).entries(from: card).entries.first?.senses ?? [] + } +} + +@Suite("DSL fixture") +struct DSLFixtureTests { + /// The committed slice is UTF-8 and the real files are UTF-16LE+BOM. Both + /// have to come out as the same cards, or the fixture is testing a + /// different dictionary from the one the app imports. + @Test("UTF-8 and UTF-16 readings of the same content agree") + func encodingsAgree() throws { + let fromUTF8 = try Fixture.read(Fixture.utf8) + let fromUTF16 = try Fixture.read(Fixture.utf16) + #expect(fromUTF8.records.map(\.card) == fromUTF16.records.map(\.card)) + #expect(fromUTF8.header == fromUTF16.header) + // Offsets differ — two bytes per character against one — which is + // exactly why a resume point is only meaningful against its own file. + #expect(fromUTF8.records[1].offset != fromUTF16.records[1].offset) + } + + /// 203 real cards through the whole stack with nothing the parser cannot + /// account for. The tag set was established by a full scan of all three + /// files; a diagnostic here means the slice found something that scan + /// missed. + @Test("The whole slice parses with no diagnostics") + func noDiagnostics() throws { + let result = try Fixture.read(Fixture.utf16) + #expect(result.records.count == 203) + #expect(result.diagnostics.isEmpty) + + let builder = DSLCardBuilder(syllableBases: []) + var diagnostics: [DSLDiagnostic] = [] + var senses = 0 + var multiSense = 0 + for record in result.records { + let built = builder.entries(from: record.card) + diagnostics += built.diagnostics + let count = built.entries.first?.senses.count ?? 0 + senses += count + if count > 1 { + multiSense += 1 + } + } + #expect(diagnostics.isEmpty) + // Measured over this slice, and asserted exactly rather than as a + // threshold: the fixture is committed and deterministic, so a change + // here is either a parser regression or a deliberate regeneration, + // and both are worth a line in the diff. + #expect(senses == 523) + #expect(multiSense == 67) + } + + @Test("The header is read from the file") + func header() throws { + let header = try Fixture.read(Fixture.utf16).header + #expect(header.indexLanguage == "Chinese") + #expect(header.contentsLanguage == "Russian") + // The fixture is one file, so it declares no includes — and the + // extraction script writes no stray directives. + #expect(header.includes.isEmpty) + #expect(header.other.isEmpty) + } + + // MARK: - The cards the plan names + + @Test("爱 comes out as divisions with numbered senses under them") + func ai() throws { + let senses = try Fixture.senses("爱", in: Fixture.read(Fixture.utf16).records) + #expect(senses.count > 10) + #expect(senses[0].label == "I") + #expect(senses[0].partOfSpeech == ["гл."]) + #expect(senses[1].label == "1)") + #expect(senses[1].level == 1) + #expect(senses[1].gloss.text.hasPrefix("любить")) + // A second division, so the Roman numerals are not being flattened. + #expect(senses.contains { $0.label == "II" && $0.level == 0 }) + } + + /// The predecessor produced one sense for this card, and put the whole + /// string `le, liǎo, liào` above the word on the page. Three readings + /// means three entries sharing the sense run, so the reading-selection + /// rule can choose between them. + @Test("了 has many senses and one entry per reading") + func le() throws { + let records = try Fixture.read(Fixture.utf16).records + let card = try #require(Fixture.card("了", in: records)) + #expect(card.pinyin == "le, liǎo, liào") + let built = DSLCardBuilder(syllableBases: ["le", "liao"]).entries(from: card) + #expect(built.entries.map(\.readingDisplay) == ["le", "liǎo", "liào"]) + #expect(built.entries[0].senses.count >= 20) + #expect(built.entries.allSatisfy { $0.senses == built.entries[0].senses }) + } + + /// 一 carries its own tone-sandhi note as prose at the end of the card. + /// Keeping it is free and faithful; computing sandhi ourselves is not + /// something this app does. + @Test("一 keeps its sandhi note") + func yi() throws { + let senses = try Fixture.senses("一", in: Fixture.read(Fixture.utf16).records) + #expect(senses.count > 40) + #expect(senses.contains { $0.gloss.text.contains("произносится") }) + } + + @Test("打 is the deepest card in the slice") + func da() throws { + let senses = try Fixture.senses("打", in: Fixture.read(Fixture.utf16).records) + #expect(senses.count > 40) + #expect(senses.contains { $0.level >= 2 }) + } + + /// The one card in 3,434,224 whose article breaks a line inside an open + /// `[m1]`. + @Test("乐芙兰's split article reads as one sentence") + func leBlanc() throws { + let records = try Fixture.read(Fixture.utf16).records + let card = try #require(Fixture.card("乐芙兰", in: records)) + #expect(card.body.count == 2) + let senses = Fixture.senses("乐芙兰", in: records) + #expect(senses.count == 1) + #expect(senses[0].gloss.text == "Ле Блан (чемпион из Лиги Легенд)") + } + + // MARK: - Markup the predecessor lost + + /// 55,414 escaped brackets in the set. `stripDSL` treated every `[` as a + /// tag and deleted to the next `]`, so these and their contents vanished. + @Test("Escaped brackets survive as literal brackets") + func escapedBrackets() throws { + let senses = try Fixture.senses("一二", in: Fixture.read(Fixture.utf16).records) + #expect(senses.contains { $0.gloss.text.contains("[целого]") }) + } + + /// 62 `[*]` spans in the set contain no `[ex]`, and that path must neither + /// crash nor lose the text. + @Test("An example block with no example keeps its text") + func blockWithoutExample() throws { + let senses = try Fixture.senses("栓", in: Fixture.read(Fixture.utf16).records) + #expect(!senses.isEmpty) + #expect(senses.allSatisfy { !$0.gloss.text.isEmpty || !$0.examples.isEmpty }) + } + + @Test("A cross-reference records the word it points at") + func reference() throws { + let senses = try Fixture.senses("上海市", in: Fixture.read(Fixture.utf16).records) + #expect(senses.contains { !$0.gloss.references.isEmpty }) + } + + /// `_` means the dictionary has no reading — 77% of the set. An entry + /// claiming its pronunciation is the empty string is worse than one + /// admitting it has none. + @Test("A card with no reading produces an entry with no reading") + func noReading() throws { + let records = try Fixture.read(Fixture.utf16).records + let card = try #require(Fixture.card("碟仙", in: records)) + #expect(card.pinyin == "_") + let built = DSLCardBuilder(syllableBases: []).entries(from: card) + #expect(built.entries.count == 1) + #expect(built.entries[0].reading.isEmpty) + #expect(built.entries[0].readingDisplay.isEmpty) + } + + /// Every card in the slice yields at least one entry: whatever the markup + /// does, a headword the dictionary defines is a headword the reader can + /// look up. + @Test("Every card yields an entry") + func everyCardYieldsAnEntry() throws { + let records = try Fixture.read(Fixture.utf16).records + let builder = DSLCardBuilder(syllableBases: []) + for record in records { + let built = builder.entries(from: record.card) + #expect(!built.entries.isEmpty, "\(record.card.headwords) produced no entry") + #expect(built.entries.allSatisfy { !$0.senses.isEmpty }) + } + } +} diff --git a/Tests/HanReaderCoreTests/Fixtures/README.md b/Tests/HanReaderCoreTests/Fixtures/README.md index cea1551..a389c19 100644 --- a/Tests/HanReaderCoreTests/Fixtures/README.md +++ b/Tests/HanReaderCoreTests/Fixtures/README.md @@ -24,5 +24,39 @@ for the full attribution, and [`../../../Dictionaries/cc-cedict/LICENSE`](../../../Dictionaries/cc-cedict/LICENSE) for the license text. +## `bkrs-slice.dsl.txt` + +203 cards of real **大БКРС** data, used by the DSL importer tests, with +`bkrs-slice.manifest.txt` recording why each one is in there. + +Two kinds of card, because they catch different things. Thirteen are picked +deliberately — 爱, 了, 一 and 打 for their sense structure, plus one card for +each piece of markup the format uses, including the single card in 3,434,224 +whose article breaks a line inside an open `[m1]`. The other 190 are a +contiguous run taken verbatim, which covers the shapes nobody thought to pick +at a realistic density. + +Regenerate with: + +```sh +./Scripts/extract-dsl-fixture.py path/to/dabkrs_1.dsl +``` + +**UTF-8 with no byte-order mark**, where the source files are UTF-16LE+BOM. A +UTF-8 file stays diffable in a pull request; the tests re-encode it to +UTF-16LE+BOM at runtime and assert both readings produce identical cards, so +the committed bytes are reviewable and the real encoding path is still +exercised. DSL has no comment syntax — a `#` line is a directive — which is +why the reasons are in the manifest rather than inline. + +**Licensing.** 大БКРС is **not** freely licensed and is not ours to +redistribute; HanReader ships an importer, not the data. This slice exists +under the one exception +[`CONTRIBUTING.md`](../../../CONTRIBUTING.md#do-not-add-dictionary-data-to-this-repository) +names. Do not +enlarge it, and do not commit the source files. + +--- + Keep fixtures small — CI fails any file here over 256 KB. If a test needs more data than that, it wants a generator, not a bigger fixture. diff --git a/Tests/HanReaderCoreTests/Fixtures/bkrs-slice.dsl.txt b/Tests/HanReaderCoreTests/Fixtures/bkrs-slice.dsl.txt new file mode 100644 index 0000000..4f0729b --- /dev/null +++ b/Tests/HanReaderCoreTests/Fixtures/bkrs-slice.dsl.txt @@ -0,0 +1,614 @@ +#NAME "HanReader test slice of 大БКРС" +#INDEX_LANGUAGE "Chinese" +#CONTENTS_LANGUAGE "Russian" + +一 + yī + [m1][b]I[/b][/m][m2]1) 1, один, единица, один раз[/m][m3][*][ex]一一等于一 одиножды один — один[/ex][/*][/m][m3][*][ex]一败二胜 единожды потерпеть поражение и дважды одержать победу[/ex][/*][/m][m3][*][ex]一氨基酸 моноаминокислота[/ex][/*][/m][m2]2) первый ([i]по порядку[/i]), первый раз[/m][m3][*][ex]一年级 первый курс (класс)[/ex][/*][/m][m3][*][ex]一见如故 встретиться впервые[/ex][/*][/m][m2]3) одинаковый, единый, один и тот же[/m][m3][*][ex]咱们是一家人 мы - одна семья[/ex][/*][/m][m3][*][ex]你们一路走 идите одной дорогой[/ex][/*][/m][m3][*][ex]这不是一码事 это не одно и то же[/ex][/*][/m][m2]4) другой, иной, иначе[/m][m3][*][ex]一波未平,一波又起 волна ещё не улеглась, как уже поднимается другая[/ex][/*][/m][m3][*][ex]番茄一名西红柿 другое название помидора - томат[/ex][/*][/m][m2]5) весь, сплошной, целый, целиком[/m][m3][*][ex]一冬 вся зима[/ex][/*][/m][m3][*][ex]一生 вся жизнь[/ex][/*][/m][m3][*][ex]一身的汗 весь в поту[/ex][/*][/m][m3][*][ex]一家之人 все члены семьи, весь дом, вся семья[/ex][/*][/m][m2]6) каждый, отдельный[/m][m3][*][ex]用十辆卡车, 一车坐五十人 взять 10 грузовиков и на каждый посадить по 50 человек[/ex][/*][/m][m2]7) единство, единение, объединять, едино-, моно-, уни-[/m][m3][*][ex]孰能一之? кто может объединить её ([i]Поднебесную[/i])?[/ex][/*][/m][m3][*][ex]统一 объединение[/ex][/*][/m][m2]8) некий, какой-то, один ([i]показатель неопределённости последующего существительного[/i])[/m][m3][*][ex]一人 некий (какой-то) человек[/ex][/*][/m][m2]9) [p]фил.[/p] один, одно \[единое\], \[нерасчленённое, безатрибутное\] единство, первобытное, всё[/m][m3][*][ex]道生一,一生二 Дао рождает одно (нерасчленённое единство), одно рождает два (раздвоение)[/ex][/*][/m][m3][*][ex]一人 некий (какой-то) человек[/ex][/*][/m][m2]10) \[один\] раз, разок, единожды ([i]о единичном или кратковременном действии или попытке действия, используется а) в удвоенном глаголе из одного иероглифа, б) между глаголом и его счетным словом[/i])[/m][m3][*][ex]等一等 подождать (немного); обожди![/ex][/*][/m][m3][*][ex]看一看 взглянуть; взгляни!; посмотри![/ex][/*][/m][m3][*][ex]笑一声 усмехнуться, хохотнуть[/ex][/*][/m][m3][*][ex]看一眼 взглянуть разок, взглянуть одним глазом[/ex][/*][/m][m2]11) [i]перед глаголом или его счетным словом означает первичное действие, далее по контексту результат этого действия[/i][/m][m3][*][ex]一跳跳了过去 прискакал сюда[/ex][/*][/m][m3][*][ex]他在旁边一站,再也不说什么 он стоял рядом, не произнося ни слова[/ex][/*][/m][m2]12) резко, вдруг, сразу ([i]резкий переход к новому состоянию или действию, выраженному последующим глаголом[/i])[/m][m3][*][ex]天气一凉 резко похолодало[/ex][/*][/m][m3][*][ex]他累得蒙头一睡 он так устал, что едва укрылся с головой, как мгновенно (сразу же) и заснул[/ex][/*][/m][m3][*][ex]他都不管甩手一走 он ни с чем не посчитался и, махнув рукой, пошёл вдруг прочь[/ex][/*][/m][m2]13) раз...; как только...; всякий раз, как (когда)...; если \[бы\] вдруг; если уж ([i]быстрая смена действий, часто с последующим 就, 便, 即[/i])[/m][m3][*][ex]你一叫,我就来 как только ты позовёшь, я приду[/ex][/*][/m][m3][*][ex]一听就懂 как только послушал, так сейчас же и понял[/ex][/*][/m][m3][*][ex]一不小心 по не осторожности[/ex][/*][/m][m2]14) 一... 一... или, или; и; то..., то...[/m][m3][*][ex]一胜一负 то выигрывать, то проигрывать[/ex][/*][/m][m3][*][ex]一红一白 то краснеть, то бледнеть[/ex][/*][/m][m2]15) [i]модальная усилительная частица[/i], очень, весьма[/m][m3][*][ex]一至于此 вот в конце концов до чего дошло![/ex][/*][/m][m3][*][ex]史呼一何怒 насколько же гневно чиновники эти кричали...[/ex][/*][/m][m3][*][ex]余一怪之 я искренне удивился этому[/ex][/*][/m][m1][b]II[/b][/m][m2]1) знак в древних китайских нотах[/m][m2]2) ключ иероглифа ([p]см.[/p] [ref]横[/ref])[/m][m1]-----[/m][i]Примечание: 一 перед иероглифом с нисходящим или нейтральным тоном произносится[/i] [b]yí[/b]; [i]перед иероглифом с ровным, восходящим, нисходяще-восходящим тоном произносится[/i] [b]yì[/b]; [i]тон не изменяется, когда: а) во фразе или в предложении происходит перечисление или указание порядкового номера; б) 一 оказывается в конце фразы.[/i] +一二 + yīèr + [m1]1) один-два, несколько, немного; что-нибудь, сколько-нибудь; хоть немножко[/m][m2][*][ex]除一二老人外,都不认识他 кроме немногих (нескольких) стариков, мало кто его знает[/ex][/*][/m][m2][*][ex]我略知一二 я кое-что слышал; я несколько в курсе дела[/ex][/*][/m][m1]2) частица, небольшая часть \[целого\]; меньшинство[/m][m1]3) один за одним, по порядку[/m][m1]4) лавировать; уклончивый[/m][m1]5) 12[/m][m1]6) двенадцатый месяц, декабрь[/m][m1]7) 2 января[/m] +一般性讨论 + yībānxìng tǎolùn + [m1]общие прения[/m] +三摩呾咤 + sānmódázhà + [m1][p]ист.[/p] Саматата ([i]государство в Вост. Индии в эпоху Тан[/i])[/m] +上海市 + shànghǎi shì + [m1][p]см.[/p] [ref]上海[/ref][/m] +乐芙兰 + _ + [m1]Ле Блан + (чемпион из Лиги Легенд)[/m] +了 + le, liǎo, liào + [m1][b]I[/b] [b]le[/b][/m][m2]1) [i]после глагола или прилагательного означает завершения действия или изменение состояния[/i][/m][m3][*][ex]写完了 написал[/ex][/*][/m][m3][*][ex]水位已经低了两米 уровень воды снизился на два метра[/ex][/*][/m][m2]2) [i]в конце предложения или паузы означает перемену, создание новой ситуации[/i][/m][m3][*][ex]下雨了 пошёл дождь, уже идёт дождь[/ex][/*][/m][m3][*][ex]我现在明白他的意思了 теперь я понимаю о чем он[/ex][/*][/m][m1][b]II[/b] [b]liǎo[/b][/m][m2]1) заканчивать, завершать, улаживать, справляться[/m][m3][*][ex]一了百了 разобравшись в одном — разобраться в остальном[/ex][/*][/m][m3][*][ex]这事儿已经了啦 это дело уже закончилось[/ex][/*][/m][m3][*][ex]这场纠纷还是他出头给了了 этот конфликт был улажен всё же благодаря его вмешательству[/ex][/*][/m][m2]2) [i]после глагола, вместе с 得 или 不 означает соответственно возможность или невозможность данного действия:[/i] \[не\]смочь, \[не\]возможно[/m][m3][*][ex]来不了 не могу прийти[/ex][/*][/m][m3][*][ex]办得了 возможно сделать[/ex][/*][/m][m2]3) понимать, получать ясное представление, ясный[/m][m3][*][ex]明了 отчетливо понять[/ex][/*][/m][m3][*][ex]一目了然 понятный с одного взгляда[/ex][/*][/m][m2]4) в полной мере, абсолютно; совершенно, полностью ([i]с отрицанием[/i])[/m][m3][*][ex]了不相涉 не иметь никакого отношения[/ex][/*][/m][m3][*][ex]了无惧色 не проявлять и тени страха[/ex][/*][/m][m2]5) умный, сообразительный[/m][m3][*][ex]了干 умный и способный[/ex][/*][/m][m2]6) светлый, блестящий[/m][m2]7) в конце \[концов\], в итоге[/m][m3][*][ex]了复何益? в итоге какая польза?[/ex][/*][/m][m1][b]III[/b] [b]liào[/b] [p]упрощ.[/p] [ref]瞭[/ref][/m][m2]1) смотреть вдаль; смотреть издали[/m][m3][*][ex]上高处了一了 посмотреть ввысь[/ex][/*][/m][m2]2) смотреть, коситься[/m] +代表国家集团 + dàibiǎo guójiā jítuán + [m1]делегация, выступающая от имени группы государств[/m] +僾 + ài + [m1][b]I [/b][c][i]вводн. слово[/i][/c][/m][m2]* как будто, вроде, словно бы, похоже, что... ([i]также [/i]僾然)[/m][m1][b]II[/b][c][i] гл.[/i][/c][/m][m2]* терять дыхание, задыхаться[/m][m3][*][ex]如彼遡风, 亦孔之僾 Словно под этим бы ветром противным Тоже дыхание сильно теряешь...[/ex][/*][/m] +单宁植物 + _ + [m1]танидонос[/m] +叆靅 + àifèi + [m1]пасмурный, облачный, сумрачный, тусклый, туманный[/m] +后不久 + hòubùjiǔ + [m1]вскоре после ([i]этого[/i]), спустя немного времени, через короткий промежуток времени[/m] +后不圆音 + hòubùyuányīn + [m1][p]лингв.[/p] нелабилизованные гласные заднего ряда[/m] +后乘 + hòuchéng + [m1]1) экипаж свиты[/m][m1]2) сопровождающие всадники[/m] +后代 + hòudài + [m1]1) последующие эпохи (века); позднейшие времена; поздний период[/m][m1]2) грядущие поколения; потомки, потомство[/m] +后劲 + hòujìn + [m1]1) результат, последствие; последующее действие; постэффект[/m][m1]2) эффект задержки, поздний эффект, отсроченное воздействие[/m][m1]3) выносливость, стойкость, выдержка[/m][m1]4) [p]уст.[/p] сильный резерв (арьергард); мощная поддержка (с тыла)[/m] +后勾儿 + hòugōur + [m1]: 拉后勾儿 [b]lāhòugōur[/b] отложить на время окончание ([c][i]чего-л.[/i][/c]); отложить «на-пот[c red]о[/c]м»[/m] +后半夜儿 + hòubanyèr, hòubànyèr + [m1]во второй половине ночи; глубокой ночью; за полночь[/m] +后半晌儿 + hòubànshǎngr, hòubanshǎngr + [m1]после обеда, после полудня; во второй половине дня[/m] +后半期 + hòubànqī + [m1]вторая половина ([i]отрезка времени[/i])[/m] +后卫 + hòuwèi + [m1]1)[c][i] воен.[/i][/c] тыловое охранение, арьергард; арьергардный[/m][m1]2) [p]спорт[/p] защитник[/m] +后厦 + hòushà + [m1]крытая галерея (навес) позади дома[/m] +后咎 + hòujiù + [m1]дальнейшие дурные последствия; отрицательные результаты в будущем[/m] +后堂 + hòutáng + [m1]1) женская половина; гинекей[/m][m1]2) зал в задней части дома[/m] +后备军 + hòubèijūn + [m1]резервные войска, войска запаса; ополчение; резерв[/m] +后天性免疫 + hòutiānxìng miǎnyì + [m1]приобретённый иммунитет[/m] +后夫 + hòufū + [m1]1) второй муж[/m][m1]2) потомки[/m] +后头叶 + hòutóuxié + [m1][p]анат.[/p] затылочная доля ([i]головного мозга[/i])[/m] +后奏曲 + hòuzòuqǔ + [m1][p]муз.[/p] эпилог[/m] +后婚 + hòuhūn + [m1]вторичный брак[/m] +后学 + hòuxué + [m1]1) последующие исследователи; будущие ученики; последователи (учения)[/m][m1]2)[c][i] вежл.[/c] я,[/i] Ваш ученик[/m] +后尘 + hòuchén + [m1]1) за собой вздымать пыль (по дороге)[/m][m2][*][ex]逸翮后尘,翱翥先路 вздымая пыль стремительными крыльями, летят вперёд[/ex][/*][/m][m1]2) [p]обр.[/p] тот, кто следует за другими; идущий проторенной дорогой[/m][m2][*][ex]步人后尘 [p]обр.[/p] идти по проторенной дорожке[/ex][/*][/m][m2][*][ex]不步后尘 не идти по стопам других[/ex][/*][/m][m2][*][ex]步安敦使者的后尘 те, кто шли по стопам посланников Марка Аврелия[/ex][/*][/m][m1]3) [p]уничижит.[/p] о себе: плетущийся следом, недостойный[/m][m1]余虽不敏,请寻后尘 И хотя я не умён, прошу \[Вас\] следовать за \[мной, \] недостойным[/m] +后尾 + hòuwěi + [m1]1) задняя (хвостовая) часть; корма; хвост ([c][i]напр.[/c] колонны, самолёта[/i]); тыл[/m][m1]2) в хвосте, сзади, позади ([i]послелог[/i])[/m][m2][*][ex]纵队后尾儿 в хвосте колонны, позади колонны[/ex][/*][/m] +后席 + hòuxí + [m1]1) задние места[/m][m1]2) отодвигать циновку назад; отодвигаться назад[/m] +后序 + hòuxù + [m1]послесловие; эпилог[/m] +后庭 + hòutíng + [m1]1) задний двор[/m][m1]2) императорский гарем[/m][m1]3) задний проход, анус[/m] +后开 + hòukāi + [m1][p]эпист.[/p] нижеперечисленные; ниже[/m] +后得智 + hòudézhì + [m1][p]будд.[/p] конкретное (детальное) знание, проистекающее из интуитивного знания[/m] +后心 + hòuxīn + [m1]1) последующая (возникшая позже) идея (мысль, настроение, намерение)[/m][m1]2) часть спины напротив сердца; середина спины; под левой лопаткой; между лопатками[/m] +后怕 + hòupà + [m1]испытывать запоздалый страх; содрогаться при воспоминании о...; испугаться задним числом[/m] +后成河 + hòuchénghé + [m1][p]геол.[/p] последующие реки[/m] +后房水 + hòufángshuǐ + [m1][p]биол.[/p] влага задней камеры глаза; камерная влага[/m] +后手儿 + hòushǒur + [m1]запас, резерв \[времени, места\]; простор; поле \[для дальнейших действий\]; шанс; возможность к отступлению[/m][m2][*][ex]留后手儿 оставить за собой путь к отступлению[/ex][/*][/m] +后把拉 + hòubala + [m1][c][i]сев.-вост. диал.[/c],[c] см.[/i][/c] 后边[/m] +后拒 + hòujù + [m1]арьергард; тыловое охранение; резерв; подкрепление[/m] +后接词 + hòujiēcí + [m1][p]лингв.[/p] проклитика[/m] +后支索 + hòuzhīsuǒ + [m1][p]мор.[/p] бакштаг; бурундук ([i]снасть[/i])[/m] +后日 + hòurì + [m1]послезавтра[/m] +后期 + hòuqī + [m1]1) просрочить время, опоздать[/m][m1]2) поздний период; поздняя стадия; поздний, запоздалый[/m][m1]3) последующий[/m][m1]4) второй срок ([c][i]напр.[/c] второй семестр[/i]); вторая половина; во второй половине, в конце[/m][m1]5) [p]биол.[/p] анафаза[/m][m1]6) постпроцессинг[/m][m1]7) [i]в сложных терминах соответствует морфеме[/i] пост-, после-[/m][m2][*][ex]后期制作 пост-продакшн[/ex][/*][/m] +后来 + hòulái + [m1]1) впоследствии, в дальнейшем, потом, после, затем; последующий[/m][m1]2) прийти после всех (последним); пришедший (полученный) позже[/m] +后果 + hòuguǒ + [m1]последствия, результаты[/m] +后槽 + hòucáo + [m1]1) конюшня[/m][m1]2) конюх[/m] +后檐 + hòuyán + [m1]задняя стреха крыши[/m] +后檐墙 + hòuyánqiáng + [m1]задняя стена дома[/m] +后此 + hòucǐ + [m1]после этого, затем; позднее, позже[/m] +后殿 + hòudiàn + [m1]1) задняя (женская) часть дома; гарем[/m][m1]2) тыловой заслон, арьергард[/m][m1]3) сокровищница в храме[/m] +后淋 + hòulìn + [m1][p]мед.[/p] хронический уретрит[/m] +后溲 + hòusōu + [m1]испражняться[/m] +后灯 + hòudēng + [m1][p]тех.[/p] задний (буферный) фонарь, катафот[/m] +后熟 + hòushú + [m1][c][i]с.-х.[/i][/c] дозревать; дозревание[/m] +后父 + hòufù + [m1]отчим[/m] +后生可畏 + hòu shēng kě wèi + [m1]молодое поколение дышит в затылок; молодой да ранний, умен не по годам[/m] +后甲板 + hòujiǎbǎn + [m1][p]мор.[/p] кормовая палуба, ют, шканцы[/m] +后知 + hòuzhī + [m1]поздно (позднее) познавшие ([c][i]напр.[/c] истину[/i])[/m] +后继 + hòujì + [m1]1) преемник, наследник[/m][m1]2)[c][i] уст. воен.[/i][/c] подкрепление[/m][m1]3) вторая ([i]по второму браку[/i]) жена[/m] +后缀 + hòuzhuì + [m1]1) [p]лингв.[/p] суффикс, постфикс[/m][m1]2) [p]комп.[/p] расширение[/m] +后置性 + hòuzhìxìng + [m1][p]лингв.[/p] постпозитивность[/m] +后肠 + hòucháng + [m1][p]зоол.[/p] задняя кишка[/m] +后脑 + hòunǎo + [m1]1) [c][i]анат.[/i][/c] задний мозг, ромбовидный мозг, ромбэнцефалон[/m][m1]2) [c][i]сокр.[/i][/c] затылок[/m] +后脑杓子 + hòunǎosháozi + [m1]затылок[/m] +后腰 + hòuyāo + [m1]1) поясничная область спины, поясница[/m][m1]2) [p]спорт[/p] опорный полузащитник[/m] +后腿儿 + hòutuǐr + [m1][p]см.[/p] [ref]后腿[/ref][/m] +后膛 + hòutáng + [m1]казённик, казённая часть ([i]ружья, орудия[/i])[/m] +后臼齿 + hòujiùchǐ + [m1][p]см.[/p] [ref]大臼齿[/ref][/m] +后艰 + hòujiān + [m1][p]см.[/p] 后难[/m] +后街 + hòujiē + [m1]переулок (улица) позади дома; задняя ([i]обычно северная[/i]) улица[/m] +后补 + hòubǔ + [m1]вводить дополнительно; кооптировать; кооптированный[/m] +后裔 + hòuyì + [m1]потомки, отпрыски; потомство; младшее поколение[/m] +后见人 + hòujiànrén + [m1][p]яп.[/p] опекун, попечитель ([i]несовершеннолетнего[/i])[/m] +后赶着儿 + hòugǎnzher + [m1]1) следовать за...; вдогонку[/m][m1]2) в спешке, второпях[/m] +后起之秀 + hòuqǐ zhī xiù + [m1]восходящее светило; [p]обр.[/p] молодой талант[/m] +后足 + hòuzú + [m1]задние лапы (ноги)[/m] +后跟 + hòugēn + [m1]задник, каблук ([i]обуви[/i]); пятка[/m] +后身 + hòushēn + [m1]1) сзади, со спины[/m][m1]2) задняя часть ([c][i]напр.[/c] подола одежды[/i]); фалды[/m][m1]3)[c][i] воен.[/i][/c] казённая часть ([i]оружия[/i])[/m][m1]4) за, позади; в глубине помещения (двора, тупика, улицы)[/m][m1]5)[c][i] будд.[/i][/c] перевоплощение в будущей жизни; перерождение[/m][m1]6) последующая форма ([i]организации, учреждения[/i])[/m] +后车 + hòuchē + [m1]1) экипаж свиты государя; колесница охраны[/m][m1]2) задняя повозка, экипаж, идущий сзади[/m] +后轴 + hòuzhóu + [m1]задняя ось[/m] +后辈儿 + hòubèir + [m1]потомки; будущие (грядущие) поколения[/m] +后边儿 + hòubiānr + [m1][p]см.[/p] [ref]后边[/ref][/m] +后进 + hòujìn + [m1]1) подрастающее поколение, молодёжь[/m][m1]2) отстающий, отсталый[/m] +后送 + hòusòng + [m1][p]воен.[/p] отправлять (направлять) в тыл, эвакуировать; эвакуация[/m] +后部 + hòubù + [m1]задняя часть; кормовая часть; зад; задний; донная часть [i](снаряда)[/i][/m] +后锯筋 + hòujùjīn + [m1][p]анат.[/p] задняя зубчатая мышца[/m] +后队 + hòuduì + [m1]замыкающий отряд, тыльный отряд, арьергард[/m] +后院 + hòuyuàn + [m1]1) задний (внутренний) двор[/m][m1]2) [p]перен.[/p] вотчина; сфера влияния[/m][m2][*][ex]拉美地区长期被视为“美国后院” Латинская Америка давно считается «задним двором США».[/ex][/*][/m] +后难 + hòunán + [m1]будущие осложнения (бедствия), предстоящие трудности (лишения), последующие страдания[/m] +后音儿 + hòuyīnr + [m1]заключительная нота (аккорд); отзвук[/m] +后韵 + hòuyùn + [m1][c][i]см.[/c] [/i]后元音[/m] +后项 + hòuxiàng + [m1]1)[c][i] мат.[/i][/c] последующий член отношения; второй член пропорции[/m][m1]2) следующий абзац (параграф)[/m][m1]3) затылок, загривок[/m] +后顾之忧 + hòugùzhīyōu + [m1]1) проблемы внутри (дома, организации); проблемы[/m][m1]2) беспокойство (опасение) за свой тыл[/m] +后首里 + hòushǒuli + [m1]после, потом, затем; впоследствии[/m] +嗳 + ǎi; ài; āi + [m1][b]I[/b] [p]сущ.[/p] [b] ǎi, [/b] [p]книжн.[/p] [b] ài[/b][/m][m2]тяжёлый горячий выдох; отрыжка[/m][m1][b]II [/b] [p]междом[/p][/m][m2]1) [b]ǎi[/b] ну!, м-м!, нет! ([i]междометие отрицания, несогласия[/i])[/m][m3][*][ex]嗳, 话不是那未说! Ну, нет! ничего подобного![/ex][/*][/m][m2]2) [b]ài[/b], [b]āi[/b] ([i]также[/i] 哎) ай!, ох!, эх! ([i]междометие испуга, нетерпения, досады)[/i][/m][m3][*][ex]嗳, 今儿偏偏来了刘老老, 我自己多事, 为她跑了半日 Эх, сегодня как нарочно приходила бабушка Лю, и мне при всей куче своих дел ещё пришлось провозиться с нею полдня![/ex][/*][/m] +土耳其斯坦 + tǔ’ěrqísītǎn + [m1][p]см.[/p] [ref]突厥斯坦[/ref][/m] +处世 + chǔshì + [m1]жить \[на свете\]; общаться с людьми; жизнь, существование; житейский[/m] +处之泰然 + chǔzhī tàirán + [m1]спокойно отнестись; как ни в чём не бывало[/m] +处事 + chǔshì + [m1]1) ведать делами; решать (улаживать) дела; вести дела, управлять делами[/m][m1]2)[c][i] уст.[/i][/c] указательная категория иероглифов[/m] +处以 + chǔyǐ + [m1]приговаривать к...; налагать штраф в размере...[/m][m2][*][ex]处以死刑 приговаривать к смертной казни[/ex][/*][/m] +处决 + chǔjué + [m1]1) приводить в исполнение смертный приговор, казнить[/m][m1]2) окончательно уладить (решить) дело; разделаться; \[окончательное\] решение; резолюция[/m] +处刑 + chǔxíng + [m1]подвергать наказанию, приводить приговор в исполнение; казнить, карать, наказывать; казнь, кара[/m] +处办 + chǔbàn + [m1]решать и приводить в исполнение; распоряжаться; исполнять; исполнение, распоряжение[/m] +处务 + chǔwù + [m1]решать дела, управлять (ведать) делами[/m][m2][*][ex]处务细则[c][i] офиц.[/i][/c] подробная инструкция, правила ([i]работы[/i])[/ex][/*][/m] +处变 + chǔbiàn + [m1]жить в треволнениях (в необычных условиях); попадать в передрягу[/m] +处和 + chǔhé + [m1]примирять, улаживать; быть посредником[/m] +处境 + chǔjìng + [m1]положение, обстановка, ситуация[/m] +处士 + chǔshì + [m1]1) чиновник не у дел; человек, удалившийся от общественной жизни; учёный-затворник; анахорет; оппозиционер ([c][i]напр.[/c] правителю[/i])[/m][m1]2)[c][i] кит. астр.[/i][/c] Чуши ([i]созвездие из четырёх звезд к западу от конфигурации Тайвэй [/i]太微)[/m] +处处 + chùchù + [m1]повсюду, везде, повсеместно; в разных местах; во всех отношениях, во всём[/m] +处女 + chǔnǚ + [m1]1) девственница, дева; девушка; девственный; девственность[/m][m1]2) вернисаж, дебют, премьера; первый, впервые[/m][m2][*][ex]处女表演 дебют, премьера[/ex][/*][/m][m2][*][ex]处女航海 первое плавание[/ex][/*][/m][m1]3) [p]см.[/p] [ref]处女座[/ref][/m] +处女官 + chǔnǚguān + [m1][p]астр.[/p] Дева ([i]6-й из 12 знаков зодиака[/i])[/m] +处女带 + chǔnǚdài + [m1]головная повязка (лента) девушки ([i]вид украшения[/i])[/m] +处女性 + chǔnǚxìng + [m1]девственность[/m] +处女林 + chǔnǚlín + [m1]девственные леса[/m] +处女航 + chǔnǚháng + [m1]первый рейс (судна)[/m] +处子 + chǔzǐ + [m1]1) девственник; девственница, дева; девушка[/m][m2][*][ex]搂处子 сманить (соблазнить) невинную девушку[/ex][/*][/m][m1]2) человек, удалившийся от общественной жизни; анахорет[/m] +处己 + chǔjǐ + [m1]вести себя; управлять собой[/m] +处心积虑 + chǔxīn jīlǜ + [m1]вынашивать, давно замышлять; строить козни; только и думать, что о... ([i]обычно о дурном[/i]), давно питать намерение...[/m] +处所 + chùsuǒ + [m1]место, местонахождение; местопребывание[/m] +处断 + chǔduàn + [m1]\[окончательно\] решать ([i]дело[/i])[i],[/i] выносить решение, приговаривать; принимать решительные меры; решение, определение[/m] +处方 + chǔfāng + [m1]1) рецепт ([i]врача[/i])[/m][m2][*][ex]处方说明 правила пользования ([i]лекарством[/i])[i],[/i] сигнатура[/ex][/*][/m][m1]2) выписывать рецепт, назначать лечение[/m][m2][*][ex]给病人处方退烧药 назначать пациенту жаропонижающие средства[/ex][/*][/m] +处方笺 + chǔfāngjiān + [m1]бланк рецепта, рецепт[/m] +处治 + chǔzhì, chǔzhi + [m1]1)[c][i] см.[/i][/c] 处置[/m][m1]2) приступать к лечению \[больного\][/m] +处理品 + chǔlǐpǐn + [m1]уценённые товары; залежавшиеся товары, требующие реализации; товарные остатки[/m] +处置 + chǔzhì + [m1]1) упорядочивать, налаживать; принимать меры, утилизовать; справляться; меры, шаги к упорядочению, обращение[/m][m1]2) налагать взыскание, подвергать наказанию (штрафу), наказывать; расправляться с ([c][i]кем-л.[/i][/c])[/m][m1]3) располагать, размещать; диспозиция[/m][m1]4) распоряжаться [i](об имуществе)[/i][/m][m2][*][ex]处置上述货物 распоряжаться вышеуказанным товаром[/ex][/*][/m] +处舍 + chǔshè + [m1]лагерь, бивак[/m] +处裁 + chǔcái + [m1]решать, определять[/m] +处身 + chǔshēn + [m1]жить среди людей, блюсти себя; соблюдать правила общежития[/m] +夜 + yè + [m1]ночь; ночью, вечером; ночной, вечерний; поздно[/m][m2][*][ex]深夜 глубокая ночь[/ex][/*][/m][m2][*][ex]他两夜\[儿\]工夫没睡 он не спал две ночи[/ex][/*][/m][m2][*][ex]夜船 ночной пароход[/ex][/*][/m][m2][*][ex]夜礼服 вечерний костюм; смокинг[/ex][/*][/m][m2][*][ex]夜行军 ночной марш (переход)[/ex][/*][/m][m2][*][ex]夜大学 вечерний университет (институт)[/ex][/*][/m][m2][*][ex]夜寝 поздно ложиться[/ex][/*][/m][m1]2) мрак, темнота; тёмный[/m][m2][*][ex]昼夜 день и ночь, светлое и тёмное время суток; сутки[/ex][/*][/m][m2][*][ex]夜室 тёмная комната, мрачное помещение[/ex][/*][/m][m1]3) Е ([i]фамилия[/i])[/m] +夜合 + yèhé + [m1][p]бот.[/p][/m][m1]1) горец многоцветный ([i]лат. Polygonum multiflorum[/i])[/m][m1]2) черёмуха обыкновенная ([i]лат. Padus racemosa[/i])[/m][m1]3) [p]см.[/p] [ref]合欢[/ref]3)[/m] +夜学 + yèxué + [m1]1) вечерняя школа (курсы); вечерние занятия (уроки)[/m][m1]2) учиться (заниматься) по ночам[/m] +夜客 + yèkè + [m1][p]поэт.[/p] ночной гость ([c][i]обр. в знач.:[/c] грабитель, вор-взломщик, бандит[/i])[/m] +夜春 + yèchūn + [m1]ночная весна ([i]хлебороб о погоде, когда днём[/i] ― [i]солнце, а ночью[/i] ― [i]дождь[/i])[/m] +夜格 + yègé + [m1][p]диал.[/p] вчера[/m] +夜泣 + yèqì + [m1]плакать по ночам[/m] +夜猫子 + yèmāozi + [m1]1) [p]диал.[/p] [ref]猫头鹰[/ref][/m][m1]2) полуночник, сова; [p]букв.[/p] полуночный кот [i](о человеке, не спящем ночью или поздно ложащемся спать)[/i][/m] +夜盲 + yèmáng + [m1][p]мед.[/p] гемералопия, куриная слепота[/m] +夜直 + yèzhí + [m1]ночное дежурство (служба); дежурить ночью[/m] +夜郎自大 + yèláng zìdà + [m1]Елан считает себя великим ([i]по преданию эпохи Хань о том, как в княжество Елан, находившееся на юге Китая, приехал китайский посол, и тамошний правитель с высокомерием спросил его, не думает ли тот, что Китай больше его собственного княжества; [p]обр.[/p] невежественная самонадеянность; бесстыдное хвастовство; мания величия; в подполье и мышь геройствует; на своей улочке храбра и курочка[/i])[/m] +夜静 + yèjìng + [m1]ночная тишина ([c][i]обр. в знач.:[/c] глубокая ночь; глубокой ночью; среди ночи[/i])[/m] +够面子 + gòu miànzi + [m1]соблюдать полное достоинство, полностью сохранять лицо (честь, репутацию); \[ваше\] достоинство соблюдено; \[ваше\] самолюбие не страдает[/m] +媚 + mèi + [m1][b]I [/b] [p]гл.[/p][/m][m2]1) льстить, угождать, заискивать, низкопоклонничать, пресмыкаться[/m][m3][*][ex]媚上骄下 заискивать перед высшими и третировать низших[/ex][/*][/m][m4][*][ex]媚富 заискивать перед богатыми[/ex][/*][/m][m2]2) любить, ласкать[/m][m3][*][ex]媚兹一人 любить его одного[/ex][/*][/m][m1][b]II[/b] [p]прил.[/p][/m][m2]1) льстивый, угодливый[/m][m3][*][ex]媚语 льстивые слова, медовые речи[/ex][/*][/m][m2]2) привлекательный, пленительный, миловидный; кокетливый[/m][m3][*][ex]媚眼 кокетливый взгляд[/ex][/*][/m] +布里斯托尔 + bùlǐsītuō’ěr + [m1]Бристоль ([i]город в Англии[/i])[/m] +帕氏刺隆头鱼 + _ + [m1]акантолабрус, колючий губан ([i]лат. Acantholabrus palloni[/i])[/m] +打 + dǎ, dá + [m1][b]I[/b] [p]гл.[/p] [b]dǎ[/b][/m][m2]1) бить, ударять; колотить; драться[/m][m3][*][ex]不许打人 нельзя прибегать к побоям[/ex][/*][/m][m3][*][ex]用枪托一打 ударить прикладом[/ex][/*][/m][m3][*][ex]打锣鼓 бить в гонги и барабаны[/ex][/*][/m][m3][*][ex]两个小孩儿打起来了 два мальчугана подрались[/ex][/*][/m][m2]2) бить, разбивать; раздроблять; портить[/m][m3][*][ex]打了一个碗 разбить \[одну\] пиалу[/ex][/*][/m][m3][*][ex]打玻璃 бить стёкла[/ex][/*][/m][m3][*][ex]帽子叫虫子打了 шапка изъедена молью[/ex][/*][/m][m2]3) отбить, прозвонить; подать сигнал \[звоном\][/m][m3][*][ex]钟打六点 часы бьют шесть[/ex][/*][/m][m2]4) избивать; убивать[/m][m3][*][ex]这只野鸭子是怎么打的 как была убита эта утка?[/ex][/*][/m][m2]5) акробатика [i](в Пекинской опере)[/i][/m][m2]6) [i]в сочетании с дополнением-существительным выступает в качестве слова-заместителя целого ряда глаголов с весьма широким значением. Для примера здесь могут быть выделены следующие смысловые рубрики[/i][/m][m2]а) делать, вырабатывать; работать; изготовлять; приготовлять, подготавливать[/m][m3][*][ex]打了两辆车 изготовить две повозки[/ex][/*][/m][m3][*][ex]打刀 сделать нож[/ex][/*][/m][m3][*][ex]夜打 работать ночью ([i]по ночам[/i])[/ex][/*][/m][m2]б) извлекать \[жидкость\]; вынимать, доставать; раздобывать; приобретать, покупать[/m][m3][*][ex]打了不少的鱼 выловил немало рыбы[/ex][/*][/m][m3][*][ex]我给你打了四两酒 я раздобыл для тебя 4 ляна вина[/ex][/*][/m][m3][*][ex]打油 купить (раздобыть) \[жидкого\] масла[/ex][/*][/m][m3][*][ex]打火车票 приобрести железнодорожный билет[/ex][/*][/m][m2]в) вести; организовать, создавать, устраивать[/m][m3][*][ex]打掩护 создать прикрытие, организовать заслон[/ex][/*][/m][m3][*][ex]打游击\[战\] вести партизанскую войну, партизанить[/ex][/*][/m][m2]г) поднимать, высоко держать[/m][m3][*][ex]打旗子 высоко поднять флаг[/ex][/*][/m][m3][*][ex]打灯笼 держать в руке \[зажжённый\] фонарь ([i]бумажный или из ткани[/i])[/ex][/*][/m][m2]д) снимать, собирать (урожай)[/m][m3][*][ex]一亩地打多少粮食? какой урожай снимается с одного му земли?[/ex][/*][/m][m2]е) быть; становиться; являться ([c][i]кем-л.[/i][/c])[/m][m3][*][ex]他还是打光棍的 он ещё ходит в холостяках[/ex][/*][/m][m2]ж) производить ([i]действие[/i]) [i],[/i] пускать ([c][i]что-л.[/i][/c]) по назначению, пустить в ход[/m][m3][*][ex]打电话 позвонить по телефону[/ex][/*][/m][m3][*][ex]打电\[报\] отправить телеграмму[/ex][/*][/m][m3][*][ex]打主意 принять решение[/ex][/*][/m][m3][*][ex]打路条 выправить пропуск ([i]для проезда из одного района в другой[/i])[/ex][/*][/m][m3][*][ex]打短工 работать подённо[/ex][/*][/m][m3][*][ex]打基础 закладывать фундамент[/ex][/*][/m][m3][*][ex]打交道 сталкиваться, иметь связь[/ex][/*][/m][m3][*][ex]打走 изгнать, выбить ([c][i]напр.[/c] неприятеля[/i])[/ex][/*][/m][m2]6) играть ([c][i]на чем-л.[/c], во[c] что-л.[/i][/c])[/m][m3][*][ex]打麻将 играть в мацзян[/ex][/*][/m][m3][*][ex]打钢琴 играть на рояле[/ex][/*][/m][m1][b]II [/b] [c][i]счётное слово[/i][/c] [b] dá[/b][/m][m2][p]сокр.[/p] ([i]от[c] англ.[/c][/i] dozen) дюжина[/m][m3][*][ex]买一打手帕 купить дюжину носовых платков[/ex][/*][/m][m1][b]III [/b] [p]предлог[/p] [b]dǎ[/b][/m][m2]1) [i]указывает на исходное место действия:[/i] из, от, с[/m][m3][*][ex]刚打天津 только что прибыть из Тянь-цзиня[/ex][/*][/m][m3][*][ex]打这里\[儿\] отсюда[/ex][/*][/m][m3][*][ex]打这儿走 проходите здесь[/ex][/*][/m][m2]2) [i]указывает на исходное время действия:[/i] с[/m][m3][*][ex]打今天算起 считать (считая) с сегодняшнего дня[/ex][/*][/m] +打睡一觉 + dǎshuì yījiào + [m1]поспать немного[/m] +操弄 + cāonòng + [m1]манипуляции; манипулировать[/m] +支农工业 + zhīnónggōngyè + [m1]работающие на сельское хозяйство отрасли промышленности[/m] +改性橡碗栲胶 + _ + [m1]изомеризованный экстракт валонеи[/m] +有害做法 + yǒuhài zuòfa + [m1]порочная практика[/m] +杠铃架 + gànglíngjià + [m1]стойка под штангу[/m] +柴郡 + cháijùn + [m1][p]графство[/p] Чешир ([p]столица[/p] — Честер, [c][i]крупн. город[/i][/c] — Уоррингтон) см. [ref]柴郡猫[/ref][/m] +栓 + shuān + [m1]1) деревянная затычка; втулка; пробка ([i]напр.[/i] бутылки); вентиль, кран ([i]напр.[/i] пожарный)[/m][m1][*] - [ref]木栓[/ref][/*][/m][m1]2) затвор ([i]напр.[/i] ружейный)[/m][m1]3) [p]мед.[/p] тромб, эмбол[/m] +滥赏 + lànshàng + [m1]засып[c red]а[/c]ть наградами, неумеренно награждать[/m] +爱 + ài + [m1][b]I[/b] [p]гл.[/p][/m][m2]1) любить, быть влюбленным в ([i]кого-л.[/i]), быть привязанным, расположенным к ([i]кому-л.[/i])[/m][m3][*][ex]爱儿童 любить ребёнка (детей)[/ex][/*][/m][m3][*][ex]他说他爱我 он говорит, что любит меня[/ex][/*][/m][m2]2) пристраститься к ([i]чему-л.[/i]), иметь пристрастие к..., находить удовольствие в...; любить; дорожить; нравится ([i]что-л.[/i])[/m][m3][*][ex]爱酒 пристраститься к вину[/ex][/*][/m][m3][*][ex]我爱钓鱼 я люблю (мне нравится) ловить рыбу[/ex][/*][/m][m3][*][ex]我不爱这个 мне не нравится это[/ex][/*][/m][m3][*][ex]尔爱其羊, 我爱其礼 тебе дорога овца, мне же дорог этикет (ритуал)[/ex][/*][/m][m2]3) беречь, оберегать; щадить; экономить; дорожить, ценить[/m][m3][*][ex]爱公物 беречь общественное имущество[/ex][/*][/m][m3][*][ex]爱名誉 дорожить славой (репутацией)[/ex][/*][/m][m2]4) легко (быстро, скоро) поддаваться, ([i]чему-л.[/i]), быть склонным[/m][m3][*][ex]他爱害臊 очень застенчив[/ex][/*][/m][m3][*][ex]这个东西爱坏 эта вещь легко ломается ([i]очень хрупка[/i])[/ex][/*][/m][m3][*][ex]天冷, 花就爱死 в холодную погоду цветы быстро вянут[/ex][/*][/m][m2]5) жалеть ([i]что-л.[/i]), быть жадным до ([i]чего-л.[/i]); скупиться; скупой, жадный[/m][m3][*][ex]不爱钱 не скупиться на деньги, не быть жадным до денег[/ex][/*][/m][m3][*][ex]百姓皆以王为爱也 весь народ считает вас, государь, скупым[/ex][/*][/m][m1][b]II[/b] [p]сущ.[/p][/m][m2]1) любовь, страсть; привязанность; любимый, дорогой, милый[/m][m3][*][ex]吾爱 любовь моя ([i]обращение[/i])[/ex][/*][/m][m3][*][ex]幸爱 фаворит, любимец[/ex][/*][/m][m2]2) * доброта, милость, благодеяние, великодушие, милосердие, сострадание, гуманность[/m][m3][*][ex]古之遗爱也 \[обладать\] великодушием, наследованным от древних[/ex][/*][/m][m2]3) [p]будд.[/p] тяга (страсть) к мирскому; жажда мирских благ[/m][m2]4) * [p]вм.[/p] 嫒 ([i]\[Ваша\] дочь[/i])[/m][m1][b]III [/b] [p]вводн. слово[/p][/m][m2]* [p]вм.[/p] 僾 ([i]как будто, словно[/i])[/m][m1][b]IV[/b] [p]собств.[/p][/m][m2]1) [p]сокр.[/p] Эйре, Ирландия[/m][m2]2) Ай ([i]фамилия[/i])[/m] +爱乐 + àiyuè + [m1]любить музыку, филармонический[/m][m2][*][ex]柏林爱乐音乐厅 Берлинская филармония[/ex][/*][/m] +爱人 + àiren, àirén + [m1][b]I, àiren[/b][/m][m2]1) муж, жена[/m][m2]2) возлюбленный, возлюбленная[/m][m2]3) любовник, любовница[/m][m1][b]II, ài rén[/b][/m][m2]любить людей[/m] +爱他主义 + àitāzhǔyì + [m1]альтруизм[/m] +爱克司射线 + àikèsīshèxiàn + [m1][p]физ.[/p] Х-лучи, рентген[/m] +爱克斯射线 + àikèsīshèxiàn + [m1][p]физ.[/p] Х-лучи, рентген[/m] +爱冷 + àilěng + [m1]любить холод; морозоустойчивый[/m] +爱别离苦 + àibiélíkǔ + [m1][p]будд.[/p] страдания разлуки \[с любимым человеком\] ([i]одно из восьми страданий,[/i] 八苦)[/m] +爱力 + àilì + [m1]1)[c][i] хим.[/i][/c] сродство[/m][m1]2) беречь силы (энергию)[/m] +爱堉 + àiyù + [m1]любимый зять, зятёк[/m] +爱女 + àinǚ + [m1]1) любимая дочь; дочь-любимица[/m][m1]2) 贪爱女色[/m] +爱好 + àihào, àihǎo + [m1]I [b]àihào[/b][/m][m2]1) любить; иметь влечение к...; симпатизировать, симпатия[/m][m3][*][ex]爱好和平 миролюбивый[/ex][/*][/m][m2]2) хобби, любимое занятие; увлечение, пристрастия, вкусы[/m][m1]II [b]àihǎo[/b][/m][m2]1) [p]диал.[/p] любить хорошо выглядеть, любить хорошо одеваться[/m][m2]2) по-хорошему, полюбовно[/m][m3][*][ex]爱好做亲 жить вместе по-дружески[/ex][/*][/m] +爱妻 + àiqī + [m1]1) любимая жена[/m][m1]2) любить жену[/m] +爱少 + àishào + [m1]любить детей (молодое поколение); любовь к детям (молодёжи)[/m] +爱尔兰自治 + àiěrlánzìzhì + [m1][c][i]полит.[/c],[c] ист.[/i][/c] гомруль[/m] +爱尚 + àishàng + [m1]ценить, отдавать предпочтение, предпочитать, любить; нравится ([c][i]что-л.[/i][/c])[/m] +爱己 + àijǐ + [m1]любить себя; себялюбивый, эгоистичный; самовлюблённый[/m] +爱巴物儿 + àibāwùr + [m1]любимая вещь (игрушка, безделушка)[/m] +爱幸 + àixìng + [m1]1) фаворит, любимец ([i]государя[/i])[/m][m1]2) любить, жаловать; быть привязанным к ([c][i]кому-л.[/i][/c])[i],[/i] привязаться; любовь, привязанность[/m] +爱异 + àiyì + [m1]любить и отличать, сильно любить и предпочитать другим; сильная любовь[/m] +爱情 + àiqíng + [m1]1) любовь, чувство любви; привязанность[/m][m1]2) мелодрама (жанр)[/m] +爱惠 + àihuì + [m1]1) благодеяние, милость, расположение[/m][m1]2) быть тронутым ([i]чьей-л.[/i]) добротой (милостью)[/m] +爱意 + àiyì + [m1]1) с радостью (охотно) соглашаться; желать[/m][m1]2) любовь, чувство любви[/m][m2][*][ex]他向她表白了爱意 он объяснился ей в любви[/ex][/*][/m] +爱慕 + àimù + [m1]1) радоваться и восхищаться; восхищённый[/m][m1]2) нравиться; любить; иметь чувства[/m] +爱护 + àihù + [m1]заботиться, ухаживать, беречь, ревниво оберегать, лелеять, пестовать, любить ([c][i]напр.[/c] родину[/i])[i],[/i] ревностно присматривать за ([c][i]кем-л.[/i][/c]); сбережение, уход[/m][m2][*][ex]爱护环境 беречь окружающую среду[/ex][/*][/m][m2][*][ex]爱护花草树木 ухаживать за растениями[/ex][/*][/m][m2][*][ex]爱护老年人 заботиться о стариках[/ex][/*][/m] +爱服盖独氏定律 + àifúgàidúshìdìnglǜ + [m1][p]хим.[/p] закон Авогадро[/m] +爱根 + àigēn + [m1][p]будд.[/p] корень желаний, основа страстей ([i]как причина всех бед[/i])[/m] +爱焰 + àiyàn + [m1][p]см.[/p] 爱火 2)[/m] +爱照 + àizhào + [m1]любовно заботиться[/m] +爱玛克 + àimǎkè + [m1][p]монг.[/p] аймак[/m] +爱玩 + àiwán + [m1]2) любить развлекаться; предаваться любимым забавам; питать страсть, склонность; любимый конёк[/m][m1]1) вести любовную игру, забавляться любовью, флиртовать[/m] +爱神 + àishén + [m1][p]миф.[/p] бог любви, Амур, Купидон; Эрот; Эрос[/m] +爱神星 + àishénxīng + [m1]Эрос, Эрот ([i]околоземный астероид из группы Амура[/i])[/m] +爱美 + àiměi + [m1]1) любить красоту (прекрасное); быть эстетом[/m][m1]2) любить наряжаться[/m] +爱美剧 + àiměijù + [m1]1) любительское представление, любительский спектакль[/m][m2][*][ex]爱美剧运动 движение за обновление театра через любительские постановки ([i]Китай, 20-е годы XX в.[/i])[/ex][/*][/m][m1]2) [b]ài měijù[/b] любить американские телесериалы[/m] +爱视 + àishì + [m1]благоволить, отдавать предпочтение, быть расположенным (к[c][i] кому-л.[/i][/c]); расположение, предпочтение, благоволение[/m] +爱说 + àiyuè + [m1]любить; нравится ([c][i]что-л.[/i][/c]); быть расположенным к; плениться, наслаждаться ([i]чем-л.[/i])[/m] +瑷珲条约 + àihún tiáoyuē + [m1]Айгунский договор, Айгуньский договор ([i]1858 г.[/i])[/m] +碟仙 + _ + [m1]碟仙:顾名思义就是在碟子上的神仙。这是一种比喻的称谓,阴阳不测为之神·《易经》,人们在用碟子占卜时,发现碟子会自我移动,回答问题,变化莫测,因为不知道其中的原因,故称为碟仙。[/m] +薆薆 + àiài + [m1]1) пышный, густой[/m][m1]2) тенистый; скрытый[/m] +薆障 + àizhàng + [m1]закрывать, заслонять, скрывать[/m] +铃片架 + língpiànjià + [m1]стойка для блинов [i](штанги)[/i][/m] +雨密度 + yǔmìdù + [m1][p]метео.[/p] плотность дождя[/m] diff --git a/Tests/HanReaderCoreTests/Fixtures/bkrs-slice.manifest.txt b/Tests/HanReaderCoreTests/Fixtures/bkrs-slice.manifest.txt new file mode 100644 index 0000000..584b873 --- /dev/null +++ b/Tests/HanReaderCoreTests/Fixtures/bkrs-slice.manifest.txt @@ -0,0 +1,208 @@ +Why each card is in bkrs-slice.dsl.txt. + +Generated by Scripts/extract-dsl-fixture.py; do not hand-edit. DSL has no +comment syntax, so this cannot live inside the fixture itself. + +一 57 senses; carries its own tone-sandhi note as prose +一二 escaped \[ — 55,414 in the set; stripDSL ate them +一般性讨论 consecutive run after 爱 +三摩呾咤 a [p] field marker rather than a part of speech +上海市 [ref] cross-reference +乐芙兰 article spans two lines inside an open [m1] +了 two readings, le and liǎo; 30 senses +代表国家集团 consecutive run after 爱 +僾 consecutive run after 爱 +单宁植物 consecutive run after 爱 +叆靅 consecutive run after 爱 +后不久 consecutive run after 爱 +后不圆音 consecutive run after 爱 +后乘 consecutive run after 爱 +后代 consecutive run after 爱 +后劲 consecutive run after 爱 +后勾儿 consecutive run after 爱 +后半夜儿 consecutive run after 爱 +后半晌儿 consecutive run after 爱 +后半期 consecutive run after 爱 +后卫 consecutive run after 爱 +后厦 consecutive run after 爱 +后咎 consecutive run after 爱 +后堂 consecutive run after 爱 +后备军 consecutive run after 爱 +后天性免疫 consecutive run after 爱 +后夫 consecutive run after 爱 +后头叶 consecutive run after 爱 +后奏曲 consecutive run after 爱 +后婚 consecutive run after 爱 +后学 consecutive run after 爱 +后尘 consecutive run after 爱 +后尾 consecutive run after 爱 +后席 consecutive run after 爱 +后序 consecutive run after 爱 +后庭 consecutive run after 爱 +后开 consecutive run after 爱 +后得智 consecutive run after 爱 +后心 consecutive run after 爱 +后怕 consecutive run after 爱 +后成河 consecutive run after 爱 +后房水 consecutive run after 爱 +后手儿 consecutive run after 爱 +后把拉 consecutive run after 爱 +后拒 consecutive run after 爱 +后接词 consecutive run after 爱 +后支索 consecutive run after 爱 +后日 consecutive run after 爱 +后期 consecutive run after 爱 +后来 consecutive run after 爱 +后果 consecutive run after 爱 +后槽 consecutive run after 爱 +后檐 consecutive run after 爱 +后檐墙 consecutive run after 爱 +后此 consecutive run after 爱 +后殿 consecutive run after 爱 +后淋 consecutive run after 爱 +后溲 consecutive run after 爱 +后灯 consecutive run after 爱 +后熟 consecutive run after 爱 +后父 consecutive run after 爱 +后生可畏 consecutive run after 爱 +后甲板 consecutive run after 爱 +后知 consecutive run after 爱 +后继 consecutive run after 爱 +后缀 consecutive run after 爱 +后置性 consecutive run after 爱 +后肠 consecutive run after 爱 +后脑 consecutive run after 爱 +后脑杓子 consecutive run after 爱 +后腰 consecutive run after 爱 +后腿儿 consecutive run after 爱 +后膛 consecutive run after 爱 +后臼齿 consecutive run after 爱 +后艰 consecutive run after 爱 +后街 consecutive run after 爱 +后补 consecutive run after 爱 +后裔 consecutive run after 爱 +后见人 consecutive run after 爱 +后赶着儿 consecutive run after 爱 +后起之秀 consecutive run after 爱 +后足 consecutive run after 爱 +后跟 consecutive run after 爱 +后身 consecutive run after 爱 +后车 consecutive run after 爱 +后轴 consecutive run after 爱 +后辈儿 consecutive run after 爱 +后边儿 consecutive run after 爱 +后进 consecutive run after 爱 +后送 consecutive run after 爱 +后部 consecutive run after 爱 +后锯筋 consecutive run after 爱 +后队 consecutive run after 爱 +后院 consecutive run after 爱 +后难 consecutive run after 爱 +后音儿 consecutive run after 爱 +后韵 consecutive run after 爱 +后项 consecutive run after 爱 +后顾之忧 consecutive run after 爱 +后首里 consecutive run after 爱 +嗳 consecutive run after 爱 +土耳其斯坦 run-together reading with a U+2019 syllable boundary +处世 consecutive run after 爱 +处之泰然 consecutive run after 爱 +处事 consecutive run after 爱 +处以 consecutive run after 爱 +处决 consecutive run after 爱 +处刑 consecutive run after 爱 +处办 consecutive run after 爱 +处务 consecutive run after 爱 +处变 consecutive run after 爱 +处和 consecutive run after 爱 +处境 consecutive run after 爱 +处士 consecutive run after 爱 +处处 consecutive run after 爱 +处女 consecutive run after 爱 +处女官 consecutive run after 爱 +处女带 consecutive run after 爱 +处女性 consecutive run after 爱 +处女林 consecutive run after 爱 +处女航 consecutive run after 爱 +处子 consecutive run after 爱 +处己 consecutive run after 爱 +处心积虑 consecutive run after 爱 +处所 consecutive run after 爱 +处断 consecutive run after 爱 +处方 consecutive run after 爱 +处方笺 consecutive run after 爱 +处治 consecutive run after 爱 +处理品 consecutive run after 爱 +处置 consecutive run after 爱 +处舍 consecutive run after 爱 +处裁 consecutive run after 爱 +处身 consecutive run after 爱 +夜 consecutive run after 爱 +夜合 consecutive run after 爱 +夜学 consecutive run after 爱 +夜客 consecutive run after 爱 +夜春 consecutive run after 爱 +夜格 consecutive run after 爱 +夜泣 consecutive run after 爱 +夜猫子 consecutive run after 爱 +夜盲 consecutive run after 爱 +夜直 consecutive run after 爱 +夜郎自大 consecutive run after 爱 +夜静 consecutive run after 爱 +够面子 consecutive run after 爱 +媚 [m4]: 344 in the set, the deepest level used +布里斯托尔 consecutive run after 爱 +帕氏刺隆头鱼 consecutive run after 爱 +打 57 senses, the deepest nesting in the set +打睡一觉 consecutive run after 爱 +操弄 consecutive run after 爱 +支农工业 consecutive run after 爱 +改性橡碗栲胶 consecutive run after 爱 +有害做法 consecutive run after 爱 +杠铃架 consecutive run after 爱 +柴郡 consecutive run after 爱 +栓 [*] with no [ex] inside — 62 in the set, and that path must not crash +滥赏 [c brown]: the only tag taking an argument +爱 division + numbered senses + example; the plan's worked example +爱乐 consecutive run after 爱 +爱人 consecutive run after 爱 +爱他主义 consecutive run after 爱 +爱克司射线 consecutive run after 爱 +爱克斯射线 consecutive run after 爱 +爱冷 consecutive run after 爱 +爱别离苦 consecutive run after 爱 +爱力 consecutive run after 爱 +爱堉 consecutive run after 爱 +爱女 consecutive run after 爱 +爱好 consecutive run after 爱 +爱妻 consecutive run after 爱 +爱少 consecutive run after 爱 +爱尔兰自治 consecutive run after 爱 +爱尚 consecutive run after 爱 +爱己 consecutive run after 爱 +爱巴物儿 consecutive run after 爱 +爱幸 consecutive run after 爱 +爱异 consecutive run after 爱 +爱情 consecutive run after 爱 +爱惠 consecutive run after 爱 +爱意 consecutive run after 爱 +爱慕 consecutive run after 爱 +爱护 consecutive run after 爱 +爱服盖独氏定律 consecutive run after 爱 +爱根 consecutive run after 爱 +爱焰 consecutive run after 爱 +爱照 consecutive run after 爱 +爱玛克 consecutive run after 爱 +爱玩 consecutive run after 爱 +爱神 consecutive run after 爱 +爱神星 consecutive run after 爱 +爱美 consecutive run after 爱 +爱美剧 consecutive run after 爱 +爱视 consecutive run after 爱 +爱说 consecutive run after 爱 +瑷珲条约 consecutive run after 爱 +碟仙 `_` for the reading — 77% of the set +薆薆 consecutive run after 爱 +薆障 consecutive run after 爱 +铃片架 consecutive run after 爱 +雨密度 consecutive run after 爱