From 2331f5eef15e393532b5e376fad9b49978f71caf Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:26:03 +0900 Subject: [PATCH] feat: prepare jlreq 0.1.0 for release --- .cargo/mutants.toml | 18 + .github/CODEOWNERS | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/ci.yml | 37 +- .github/workflows/fuzz-scheduled.yml | 33 + .github/workflows/mutants.yml | 87 +- .github/workflows/release-check.yml | 143 + .github/workflows/release.yml | 150 + ARCHITECTURE.md | 45 +- CHANGELOG.md | 1802 +------- CONTRIBUTING.md | 28 +- Cargo.lock | 6 +- Cargo.toml | 2 +- DEVELOPMENT-HISTORY.md | 1780 ++++++++ Justfile | 146 +- README.md | 50 +- REUSE.toml | 2 +- ROADMAP.md | 39 +- SECURITY.md | 13 +- crates/jlreq-conformance/Cargo.toml | 18 +- crates/jlreq-conformance/LICENSE-APACHE | 73 + crates/jlreq-conformance/LICENSE-MIT | 18 + crates/jlreq-conformance/README.md | 18 +- crates/jlreq-conformance/src/main.rs | 1212 +++++- crates/jlreq-conformance/src/sample_engine.rs | 226 +- crates/jlreq-conformance/src/validation.rs | 221 + .../tests/builtin_protocol.rs | 82 + .../tests/reference_integration.rs | 5 +- crates/jlreq-conformance/tests/transport.rs | 274 ++ crates/jlreq/Cargo.toml | 10 +- crates/jlreq/LICENSE-APACHE | 73 + crates/jlreq/LICENSE-MIT | 18 + crates/jlreq/README.md | 31 +- crates/jlreq/examples/composer.rs | 39 + crates/jlreq/examples/minimal.rs | 26 + crates/jlreq/examples/vertical.rs | 24 + crates/jlreq/src/generated.rs | 685 ++- crates/jlreq/src/generated/appendix_a.rs | 2 +- crates/jlreq/src/generated/folding.rs | 2 +- crates/jlreq/src/generated/ideograph.rs | 2 +- crates/jlreq/src/generated/script.rs | 2 +- crates/jlreq/src/generated/table1.rs | 2 +- crates/jlreq/src/generated/table2.rs | 2 +- crates/jlreq/src/generated/table3.rs | 2 +- crates/jlreq/src/generated/table4.rs | 2 +- crates/jlreq/src/generated/table5.rs | 2 +- crates/jlreq/src/generated/table6.rs | 2 +- crates/jlreq/src/layout.rs | 40 + crates/jlreq/src/lib.rs | 8 +- crates/jlreq/src/limits.rs | 245 ++ crates/jlreq/src/model.rs | 21 + crates/jlreq/src/normalize.rs | 130 +- crates/jlreq/src/paragraph.rs | 521 ++- crates/jlreq/src/pipeline.rs | 3672 +++++++++++++++-- crates/jlreq/src/spec.rs | 441 +- crates/jlreq/src/style.rs | 42 + crates/jlreq/tests/public_api.rs | 274 +- data/manifest.toml | 34 +- docs/RELEASING.md | 89 + docs/design/api-spine.md | 35 +- docs/design/conformance.md | 29 +- docs/design/generation.md | 4 +- docs/error-codes.md | 74 + docs/generated/conformance-summary.md | 20 + docs/mutation-ledger.toml | 136 + docs/{api-1.0.toml => public-api.toml} | 14 +- engines/census-all.sh | 106 + engines/ocaml/lib/paragraph.ml | 94 +- engines/ocaml/lib/pipeline.ml | 59 +- engines/ocaml/probe/census.ml | 7 + engines/ocaml/proto/protocol.ml | 2 +- engines/ocaml/test/test_pipeline.ml | 10 +- engines/racket/compose.rkt | 44 +- engines/racket/info.rkt | 2 +- engines/racket/protocol.rkt | 2 +- engines/racket/tabs.rkt | 18 +- engines/racket/tests/test-compose.rkt | 24 +- fuzz/Cargo.lock | 100 +- fuzz/Cargo.toml | 21 +- fuzz/README.md | 28 +- fuzz/fuzz_targets/composition.rs | 185 + .../{public_api.rs => input_validation.rs} | 56 +- fuzz/fuzz_targets/protocol_parser.rs | 33 + .../composition/extreme-capped-remainder | Bin 0 -> 28 bytes fuzz/seeds/composition/extreme-zero-width | 1 + fuzz/seeds/composition/mixed-vertical-tab | 1 + .../input_validation}/appendix-pair-split | 0 .../construct-internal-break | 0 .../input_validation}/crossing-constructs | 0 .../input_validation}/extreme-arithmetic | 0 .../input_validation}/invalid-utf8-boundary | 0 .../input_validation}/overlapping-clusters | 0 fuzz/seeds/protocol_parser/malformed.ndjson | 1 + .../protocol_parser/valid-request.ndjson | 1 + mise.toml | 4 + release-plz.toml | 5 +- scripts/check-semver.sh | 79 + scripts/package-binaries.sh | 91 + scripts/verify-crates.sh | 69 + scripts/verify-mutation-ledger.sh | 175 + scripts/verify-release-state.sh | 33 + typos.toml | 4 + xtask/src/api.rs | 74 +- xtask/src/direction.rs | 6 + xtask/src/generate.rs | 4 +- xtask/src/repository.rs | 93 +- 106 files changed, 11704 insertions(+), 3015 deletions(-) create mode 100644 .cargo/mutants.toml create mode 100644 .github/workflows/fuzz-scheduled.yml create mode 100644 .github/workflows/release-check.yml create mode 100644 .github/workflows/release.yml create mode 100644 DEVELOPMENT-HISTORY.md create mode 100644 crates/jlreq-conformance/LICENSE-APACHE create mode 100644 crates/jlreq-conformance/LICENSE-MIT create mode 100644 crates/jlreq-conformance/tests/transport.rs create mode 100644 crates/jlreq/LICENSE-APACHE create mode 100644 crates/jlreq/LICENSE-MIT create mode 100644 crates/jlreq/examples/composer.rs create mode 100644 crates/jlreq/examples/minimal.rs create mode 100644 crates/jlreq/examples/vertical.rs create mode 100644 crates/jlreq/src/limits.rs create mode 100644 docs/RELEASING.md create mode 100644 docs/error-codes.md create mode 100644 docs/generated/conformance-summary.md create mode 100644 docs/mutation-ledger.toml rename docs/{api-1.0.toml => public-api.toml} (89%) create mode 100755 engines/census-all.sh create mode 100644 fuzz/fuzz_targets/composition.rs rename fuzz/fuzz_targets/{public_api.rs => input_validation.rs} (83%) create mode 100644 fuzz/fuzz_targets/protocol_parser.rs create mode 100644 fuzz/seeds/composition/extreme-capped-remainder create mode 100644 fuzz/seeds/composition/extreme-zero-width create mode 100644 fuzz/seeds/composition/mixed-vertical-tab rename fuzz/{corpus/public_api => seeds/input_validation}/appendix-pair-split (100%) rename fuzz/{corpus/public_api => seeds/input_validation}/construct-internal-break (100%) rename fuzz/{corpus/public_api => seeds/input_validation}/crossing-constructs (100%) rename fuzz/{corpus/public_api => seeds/input_validation}/extreme-arithmetic (100%) rename fuzz/{corpus/public_api => seeds/input_validation}/invalid-utf8-boundary (100%) rename fuzz/{corpus/public_api => seeds/input_validation}/overlapping-clusters (100%) create mode 100644 fuzz/seeds/protocol_parser/malformed.ndjson create mode 100644 fuzz/seeds/protocol_parser/valid-request.ndjson create mode 100644 scripts/check-semver.sh create mode 100755 scripts/package-binaries.sh create mode 100755 scripts/verify-crates.sh create mode 100644 scripts/verify-mutation-ledger.sh create mode 100755 scripts/verify-release-state.sh diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 0000000..4739ee8 --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2026 jlreq contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# These files are emitted from attested specification data. `just generate-check` and +# `just attest` validate them byte-for-byte; mutation testing covers the adjacent, +# handwritten `generated.rs` integrity checks. +exclude_globs = ["crates/jlreq/src/generated/**"] + +# Each proven-equivalent mutant is pinned to the source hash and justified individually +# in docs/mutation-ledger.toml. Anchoring the full cargo-mutants name prevents a broad +# expression class from being hidden by accident. +exclude_re = [ + '^crates/jlreq/src/generated[.]rs:35:5: replace [|] with \^$', + '^crates/jlreq/src/generated[.]rs:36:5: replace [|] with \^$', + '^crates/jlreq/src/generated[.]rs:37:5: replace [|] with \^$', + '^crates/jlreq/src/generated[.]rs:38:5: replace [|] with \^$', + '^crates/jlreq/src/generated[.]rs:55:41: replace < with <= in ascends$', +] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7ca2a53..ca383d5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -15,5 +15,5 @@ /xtask/ @P4suta # The release controls the gates read. Relaxing one is a review, never an edit in passing. -/docs/api-1.0.toml @P4suta +/docs/public-api.toml @P4suta /docs/conformance-deferrals.toml @P4suta diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9a7bae0..dd7d732 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -34,6 +34,6 @@ body: id: version attributes: label: Version - placeholder: "jlreq 0.0.0 (git revision), rustc 1.85.0, x86_64-unknown-linux-gnu" + placeholder: "jlreq 0.1.0, rustc 1.85.0, x86_64-unknown-linux-gnu" validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a0488c..32563bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,7 +164,7 @@ jobs: - run: just wasm fuzz: - name: fuzz (public API invariants) + name: fuzz (validation, composition, protocol) runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -180,6 +180,21 @@ jobs: workspaces: fuzz -> target - run: just fuzz-check-linux-ci + coverage: + name: coverage (handwritten products) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + components: llvm-tools-preview + - uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2.86.5 + with: + tool: just@1.50.0,cargo-llvm-cov@0.9.0 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - run: just coverage + purity: name: purity (no std, I/O, font, or float in the core) runs-on: ubuntu-latest @@ -217,6 +232,18 @@ jobs: # it. - run: just design + semver: + name: semver compatibility (0.1.x) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + - uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2.86.5 + with: + tool: just@1.50.0,cargo-semver-checks@0.50.0 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - run: just semver + conform-ocaml: # The independent OCaml reference engine (engines/ocaml/README.md) answering the # conformance protocol from an implementation that shares no code with the Rust one @@ -361,13 +388,15 @@ jobs: - run: just shear actionlint: - name: actionlint + zizmor (workflow self-check) + name: shellcheck + actionlint + zizmor (automation self-check) runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2.86.5 with: - tool: just@1.50.0,zizmor@1.28.0 + tool: just@1.50.0,shellcheck@0.11.0,zizmor@1.28.0 + - name: shellcheck + run: just shellcheck - name: actionlint env: ACTIONLINT_VERSION: 1.7.12 @@ -400,8 +429,10 @@ jobs: - no_std - wasm - fuzz + - coverage - purity - design + - semver - conform-ocaml - conform-racket - doc diff --git a/.github/workflows/fuzz-scheduled.yml b/.github/workflows/fuzz-scheduled.yml new file mode 100644 index 0000000..3ac0844 --- /dev/null +++ b/.github/workflows/fuzz-scheduled.yml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2026 jlreq contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +name: Scheduled fuzz + +on: + workflow_dispatch: + schedule: + - cron: "19 2 * * 6" + +permissions: + contents: read + +concurrency: + group: scheduled-fuzz + cancel-in-progress: true + +jobs: + fuzz: + runs-on: ubuntu-latest + timeout-minutes: 55 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # nightly + with: + toolchain: nightly + - uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2.86.5 + with: + tool: just@1.50.0,cargo-fuzz@0.13.2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: fuzz -> target + - run: just fuzz-scheduled diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index eb01d30..e7e7b9a 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -1,43 +1,67 @@ # SPDX-FileCopyrightText: 2026 jlreq contributors -# # SPDX-License-Identifier: MIT OR Apache-2.0 -# Mutation testing (cargo-mutants) for the sole public Rust library. xtask is tooling and -# jlreq-conformance is tested through its process protocol. -# -# This is a scheduled report rather than a required gate: a complete mutation run is slow, -# and surviving mutants are evidence for the next independently-authored test rather than a -# reason to hide the report behind a permanently red workflow. The command outcome and full -# report remain visible in the job summary and artifact. -# -# Each matrix leg runs `just mutants `, scoped to one package the way `-p` scopes -# every other per-crate gate in this repository (see `just msrv`). +# Pull requests smoke-test changed Rust code. Weekly/manual runs cover both handwritten +# products in four shards. Generated tables and five exact, proof-backed equivalent mutants +# are the only configured exclusions and are pinned in docs/mutation-ledger.toml; a missed +# or timed-out mutant fails the workflow. name: Mutants on: + pull_request: + paths: + - "**/*.rs" + - "**/Cargo.toml" + - "Justfile" + - ".cargo/mutants.toml" + - "docs/mutation-ledger.toml" + - "scripts/verify-mutation-ledger.sh" + - ".github/workflows/mutants.yml" workflow_dispatch: schedule: - # Weekly (Thu 04:38 UTC, offset from codeql.yml's Mon 03:27 UTC) so the baseline tracks - # the test suite even between direct changes to the public library. - cron: "38 4 * * 4" permissions: contents: read concurrency: - group: ${{ github.workflow }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - mutants: - name: mutants (${{ matrix.crate }}) + smoke: + name: changed-surface smoke + if: github.event_name == 'pull_request' runs-on: ubuntu-latest - # The full library has a broad mutation surface; keep generous runner headroom. - timeout-minutes: 60 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + - uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2.86.5 + with: + tool: just@1.50.0,cargo-mutants@27.1.0,nextest@0.9.140 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - run: just mutants-smoke '${{ github.event.pull_request.base.sha }}' + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mutants-smoke + path: mutants.out/ + if-no-files-found: ignore + retention-days: 14 + + full: + name: full (${{ matrix.crate }}, ${{ matrix.shard }}) + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 90 strategy: fail-fast: false matrix: - crate: [jlreq] + crate: [jlreq, jlreq-conformance] + shard: [1, 2, 3, 4] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable @@ -45,28 +69,11 @@ jobs: with: tool: just@1.50.0,cargo-mutants@27.1.0,nextest@0.9.140 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - # Deliberately no `RUSTFLAGS: -D warnings` (unlike clippy/test/design/etc. in - # ci.yml): that env var, not the workspace lint levels themselves, is what turns a - # warning into a build failure elsewhere in this repo, and mutated code that merely - # provokes a new lint should still build and run against the tests here. See the - # Justfile's `mutants` recipe for the fuller version of this reasoning. - - name: cargo mutants - id: cargo_mutants - continue-on-error: true - run: just mutants ${{ matrix.crate }} - - name: summarize mutation result - if: always() - env: - MUTANTS_OUTCOME: ${{ steps.cargo_mutants.outcome }} - run: |- - echo "## cargo-mutants: $MUTANTS_OUTCOME" >> "$GITHUB_STEP_SUMMARY" - if [ "$MUTANTS_OUTCOME" != "success" ]; then - echo "Surviving mutants or a runner error were reported; inspect the artifact." >> "$GITHUB_STEP_SUMMARY" - fi - - name: upload mutants.out - if: always() + - run: just mutants '${{ matrix.crate }}' '${{ matrix.shard }}/4' + - if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: mutants.out-${{ matrix.crate }} + name: mutants-${{ matrix.crate }}-${{ matrix.shard }}-of-4 path: mutants.out/ + if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml new file mode 100644 index 0000000..f713d0d --- /dev/null +++ b/.github/workflows/release-check.yml @@ -0,0 +1,143 @@ +# SPDX-FileCopyrightText: 2026 jlreq contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +name: Release check + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-check-${{ github.ref }} + cancel-in-progress: false + +jobs: + acceptance: + name: complete non-publishing acceptance + runs-on: ubuntu-latest + timeout-minutes: 360 + env: + RACKET_VERSION: "9.3" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + components: clippy,llvm-tools-preview,rustfmt + targets: thumbv7em-none-eabi,wasm32-unknown-unknown + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # nightly + with: + toolchain: nightly + - uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2.86.5 + with: + tool: >- + just@1.50.0,nextest@0.9.140,cargo-hack@0.6.45,cargo-deny@0.20.2, + cargo-shear@1.13.3,cargo-msrv@0.19.3,cargo-fuzz@0.13.2, + cargo-llvm-cov@0.9.0,cargo-mutants@27.1.0,taplo-cli@0.10.0, + cargo-semver-checks@0.50.0,typos-cli@1.48.0, + actionlint@1.7.12,shellcheck@0.11.0,zizmor@1.28.0 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: 0.11.32 + - uses: ocaml/setup-ocaml@f92e0606b7ae4873dd1238465ea4bf6f8e40d85c # v3.7.2 + with: + ocaml-compiler: "5.5.0" + dune-cache: true + - uses: Bogdanp/setup-racket@2466913449df77df2bad149d1f2fc4e1ea4795dd # v1.15 + with: + version: ${{ env.RACKET_VERSION }} + variant: CS + distribution: minimal + - name: install Racket build and test collections + run: raco pkg install --skip-installed --batch --auto --no-docs compiler-lib rackunit-lib + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: run every non-publishing release gate + run: opam exec -- just release-check + + binaries: + name: binaries (${{ matrix.target }}) + needs: acceptance + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + - os: windows-latest + target: x86_64-pc-windows-msvc + - os: macos-latest + target: x86_64-apple-darwin + - os: macos-latest + target: aarch64-apple-darwin + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + targets: ${{ matrix.target }} + - uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2.86.5 + with: + tool: cargo-cyclonedx@0.5.9 + - name: install Linux cross linker + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: sudo apt-get update && sudo apt-get install --yes gcc-aarch64-linux-gnu + - name: install musl linker + if: matrix.target == 'x86_64-unknown-linux-musl' + run: sudo apt-get update && sudo apt-get install --yes musl-tools + - name: build and inspect archive + shell: bash + run: sh scripts/package-binaries.sh '${{ matrix.target }}' + - name: generate CycloneDX SBOM + shell: bash + run: |- + cargo cyclonedx --format json --spec-version 1.5 --license-strict --all-features \ + --target '${{ matrix.target }}' \ + --manifest-path crates/jlreq-conformance/Cargo.toml + sbom=$(find crates/jlreq-conformance -maxdepth 1 -name '*.cdx.json' -print -quit) + test -n "$sbom" + jq -e --arg target '${{ matrix.target }}' ' + .bomFormat == "CycloneDX" and + .specVersion == "1.5" and + .metadata.component.name == "jlreq-conformance" and + .metadata.component.version == "0.1.0" and + .metadata.component.licenses == [{"expression":"MIT OR Apache-2.0"}] and + any(.metadata.properties[]?; + .name == "cdx:rustc:sbom:target:triple" and .value == $target) + ' "$sbom" + cp "$sbom" "target/dist/jlreq-0.1.0-${{ matrix.target }}.cdx.json" + - name: attest build provenance + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: |- + target/dist/*.tar.gz + target/dist/*.zip + - name: attest CycloneDX SBOM + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: |- + target/dist/*.tar.gz + target/dist/*.zip + sbom-path: target/dist/jlreq-0.1.0-${{ matrix.target }}.cdx.json + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-${{ matrix.target }} + path: |- + target/dist/*.tar.gz + target/dist/*.zip + target/dist/*.sha256 + target/dist/*.cdx.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..73d675c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: 2026 jlreq contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# This workflow is intentionally manual and protected by the `release` environment. Merely +# merging it performs no external mutation. See docs/RELEASING.md before approving a run. +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: Version already finalized in Cargo.toml and CHANGELOG.md + required: true + default: 0.1.0 + type: string + commit: + description: Full candidate commit SHA that passed Release check + required: true + type: string + release_check_run_id: + description: Successful Release check workflow run ID for that commit + required: true + type: string + authentication: + description: First release uses a scoped token; later releases use OIDC + required: true + default: initial-token + type: choice + options: + - initial-token + - trusted-publishing + confirmation: + description: Type "publish jlreq VERSION" to acknowledge irreversible uploads + required: true + type: string + +permissions: + contents: write + id-token: write + actions: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + publish: + name: publish crates, tag, and GitHub Release + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: release + env: + VERSION: ${{ inputs.version }} + CANDIDATE_COMMIT: ${{ inputs.commit }} + RELEASE_CHECK_RUN_ID: ${{ inputs.release_check_run_id }} + GH_TOKEN: ${{ github.token }} + steps: + - name: validate explicit authorization + env: + CONFIRMATION: ${{ inputs.confirmation }} + run: |- + test "$CONFIRMATION" = "publish jlreq $VERSION" + printf '%s' "$CANDIDATE_COMMIT" | grep -Eq '^[0-9a-f]{40}$' + printf '%s' "$RELEASE_CHECK_RUN_ID" | grep -Eq '^[0-9]+$' + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.commit }} + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + - uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2.86.5 + with: + tool: just@1.50.0 + - name: verify candidate identity and finalized notes + run: |- + test "$(git rev-parse HEAD)" = "$CANDIDATE_COMMIT" + grep -F "version = \"$VERSION\"" Cargo.toml >/dev/null + grep -Eq "^## \[$VERSION\] - 20[0-9]{2}-[0-9]{2}-[0-9]{2}$" CHANGELOG.md + run=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RELEASE_CHECK_RUN_ID") + test "$(jq -r .name <<<"$run")" = "Release check" + test "$(jq -r .conclusion <<<"$run")" = "success" + test "$(jq -r .head_sha <<<"$run")" = "$CANDIDATE_COMMIT" + test -z "$(git ls-remote --tags origin "refs/tags/v$VERSION")" + - name: download attested release-check artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: release-* + path: target/release-artifacts + merge-multiple: true + run-id: ${{ inputs.release_check_run_id }} + github-token: ${{ github.token }} + - name: verify binary checksums and crate dry-runs + run: |- + for checksum in target/release-artifacts/*.sha256; do + (cd "$(dirname "$checksum")" && sha256sum --check "$(basename "$checksum")") + done + just package + just publish-dry-run + - name: obtain Trusted Publishing token + if: inputs.authentication == 'trusted-publishing' + id: crates_io_auth + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1 + - name: publish jlreq with first-release token + if: inputs.authentication == 'initial-token' + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} + run: cargo publish --locked -p jlreq + - name: publish jlreq with Trusted Publishing + if: inputs.authentication == 'trusted-publishing' + env: + CARGO_REGISTRY_TOKEN: ${{ steps.crates_io_auth.outputs.token }} + run: cargo publish --locked -p jlreq + - name: wait for jlreq in the crates.io index + run: |- + for attempt in $(seq 1 30); do + if cargo info "jlreq@$VERSION" >/dev/null 2>&1; then + exit 0 + fi + echo "jlreq $VERSION is not visible yet (attempt $attempt/30)" + sleep 10 + done + echo "jlreq $VERSION was uploaded but did not become visible; inspect crates.io before retrying" >&2 + exit 1 + - name: publish jlreq-conformance with first-release token + if: inputs.authentication == 'initial-token' + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} + run: cargo publish --locked -p jlreq-conformance + - name: publish jlreq-conformance with Trusted Publishing + if: inputs.authentication == 'trusted-publishing' + env: + CARGO_REGISTRY_TOKEN: ${{ steps.crates_io_auth.outputs.token }} + run: cargo publish --locked -p jlreq-conformance + - name: create tag and GitHub Release after both uploads + run: |- + awk -v version="$VERSION" ' + $0 ~ "^## \\[" version "\\]" { capture = 1; next } + capture && $0 ~ "^## \\[" { exit } + capture { print } + ' CHANGELOG.md > target/release-notes.md + git config user.name github-actions + git config user.email github-actions@github.com + git tag -a "v$VERSION" -m "jlreq $VERSION" + git push origin "v$VERSION" + gh release create "v$VERSION" \ + target/release-artifacts/* \ + target/package/jlreq-"$VERSION".crate \ + target/package/jlreq-conformance-"$VERSION".crate \ + --target "$CANDIDATE_COMMIT" \ + --title "jlreq $VERSION" \ + --notes-file target/release-notes.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3285249..25f96f5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,6 @@ # Architecture -This document describes the candidate 1.0 implementation in the unreleased `0.0.0` -workspace and its mechanically enforced invariants. +This document describes the 0.1.0 implementation and its mechanically enforced invariants. ## Boundary in the text stack @@ -39,7 +38,7 @@ gate whose scope is the Cargo graph (`purity`, `api`, `direction`, `derive`, `ge The implementation is one directional pipeline: ```text -model → spec → normalize/rules → construct → compose → place → pipeline → API +model/style/limits → spec → normalize/rules → construct → compose → place → pipeline → API ``` The present source groups some adjacent stages into files, but ownership follows this @@ -47,6 +46,7 @@ direction: - `model` owns caller-unit sizes, frames, writing modes, shaped clusters, and input errors; - `style` owns the 22 typed decisions and dated profiles; +- `limits` owns deterministic composition resource bounds and their typed failure; - `generated` contains reproducible tables, while `spec` gives them private queries; - `normalize` validates shaped text and joins Appendix A two-code-point keys without losing original cluster attribution; @@ -55,7 +55,7 @@ direction: - `layout` owns renderer-facing read-only result views; - `pipeline` performs private classification, spacing, construct lowering, optimal composition, and placement; -- `lib` is the only API layer and re-exports exactly `docs/api-1.0.toml`. +- `lib` is the only API layer and re-exports exactly `docs/public-api.toml`. No classification, seam, adjustment stage, feasibility score, ladder, badness, or rule ID is public. A caller builds `ShapedText`, validates a `Paragraph`, calls `compose`, and draws the @@ -81,16 +81,25 @@ the internal key indivisible while placements still point back to the original s clusters. Breaks and constructs cannot split such a key. `ParagraphBuilder` jointly validates the line extent, indent, break kinds, nested/disjoint -construct ranges, ruby runs, tab stops, widow policy, alignment, and writing mode. Once a -`Paragraph` exists, `compose` is infallible. Unsatisfied fit and quality constraints are -represented by placements plus stable diagnostics, not by a late error. +construct ranges, ruby runs, line-tab stops, widow policy, alignment, and writing mode. +`compose` returns a complete exact layout or a typed resource error. Unsatisfied fit and +quality constraints are represented by placements plus stable diagnostics; exhausting a +caller-visible resource limit is atomic and returns no partial layout. ## Composition and placement -The only search policy is whole-paragraph optimization. Mandatory breaks partition the -search; discretionary breaks carry a cost; ordinary break opportunities are filtered by -Japanese line-start and line-end rules. First-fit, feasibility objects, ladders, badness, -and tuning knobs are implementation details. +The only search policy is exact whole-paragraph optimization. A prepared paragraph caches +cluster ordinals, construct ownership, legal breaks, mandatory partitions, widths, and +adjustment capacities. Mandatory breaks partition dynamic programming; prefix/range +queries make ordinary edges constant-time, while tabs and annotation structures charge +work proportional to the special elements they touch. Integer lower bounds prune only +provably dominated edges. There is no approximate or first-fit fallback. + +`CompositionLimits` bounds clusters, break candidates, constructs, tab stops, and charged +search transitions. Defaults are 65,536 clusters and break candidates, 4,096 constructs +and tab stops, and 8,000,000 transitions. These bounds make memory and CPU refusal +deterministic for hostile input while leaving ordinary 10,000-cluster paragraphs well +inside the transition budget. Tabs take part in measurement and placement rather than being a second line API. Their alignment is expressed on the logical inline axis, so the same stops work in horizontal and @@ -110,7 +119,7 @@ combinations. Generic string settings, public `Question`/`Choice` values, and in IDs are excluded. `Style::default()` is permanently `Style::jlreq_2020()`. A future JLReq revision adds a new -dated profile; it does not alter an existing profile. `docs/api-1.0.toml` maps all 22 enum +dated profile; it does not alter an existing profile. `docs/public-api.toml` maps all 22 enum names and counts back to generated specification data, and the API gate compares that mapping in both directions. @@ -130,17 +139,21 @@ cannot accidentally be judged against a new suite. - `purity`: no `std`, I/O, font dependency, floating point, or undeclared dependency edge in the core; -- `api`: candidate 1.0 exports and the 22 typed Style mappings; +- `api`: 0.1.0 exports and the 22 typed Style mappings; - `direction`: the private module graph follows the declared one-way layers; - `placeholder`: no unwritten body or lint suppression in core code; - `derive`, `generate`, `attest`: reproducible specification derivation and transcription provenance; - `conform`: every observable inventoried rule has a protocol-v1 black-box case, and every excluded rule has an evidence-backed editorial/non-observable classification; -- `repository`: packages remain unpublished at `0.0.0`, tracked UTF-8 files use LF, and - every local Markdown link resolves; +- `repository`: packages remain publishable at `0.1.0` while external release actions stay + disabled, stable code documentation matches product literals, tracked UTF-8 files use + LF, and every local Markdown link resolves; +- `coverage`: handwritten product code stays above 90% line and 85% region coverage; +- `mutants`: generated artifacts are ledgered out and no handwritten mutant is missed or + times out; - normal Rust tests: invalid input, all style choices, all constructs in both directions, paragraph search, placement, and protocol behavior. The gate rejects additions and removals from the public surface unless -`docs/api-1.0.toml` changes deliberately. +`docs/public-api.toml` changes deliberately. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d03e79..f22f409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1777 +1,49 @@ -# Changelog + -All notable changes to this project are documented in this file. +# Changelog -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this -project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +All notable user-facing changes are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project follows +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). Detailed pre-release development +chronology is archived in [DEVELOPMENT-HISTORY.md](DEVELOPMENT-HISTORY.md). ## [Unreleased] -### Fixed +These are the completed 0.1.0 release notes. Publication will replace this heading with the +approved release date; no crate upload, tag, or GitHub Release has occurred yet. + +### Added -- §3.8.3's ladder no longer reaches the seam where a warichu's two sublines meet, which - closes [#26](https://github.com/P4suta/jlreq/issues/26) — the first coordinate at which - two reference engines disagreed with *each other* rather than one lagging the other two, - and the one the entry below left out of the census on purpose. - `crates/jlreq/src/pipeline.rs` offered `reduction_sites` every interior boundary of a - stacked structure, the seam included, so a line carrying `※〈〉あ※` at a three-em measure - divided its 250 units of arrears over the one boundary it was composed from and one - inside the block that carries nothing: it gave back 125, ended 125 units over its - measure, and reported `layout.overfull` about a line it could have made fit. The - expansion ladder already stopped at the block, and the reduction ladder now asks the same - question. `docs/decisions/stacked-structure-geometry.md` publishes the reading the OCaml - and Racket engines already had: the seam is the block's like every other boundary inside - it, because what makes a boundary the line's is that the line was composed from it and at - the seam no engine places anything — the character that ends a subline reports its body - alone. No public API changed, and no other engine did. -- The Racket reference engine now lays a furawake's columns out from the advances the - structure was composed from and reads the boundary after the block against the block's - own last character, which closes - [#27](https://github.com/P4suta/jlreq/issues/27). `engines/racket/compose.rkt` made one - item of a furawake and measured each of its columns from bare cluster advances, so a - member standing before a Table 1 amount inside its column — `cl-02` then `cl-01`, half of - the member's own em — reported 1000 where the same engine placed the next member 1000 - units on and charged the line 1500 for the step; and it gave that one item the class of - its *first* cluster, so the boundary after the block was answered at the wrong end of it - and the amount missing from the column reappeared beside the structure. The two mistakes - cancel in the line's own width wherever the two amounts are equal, which is why the shape - had to be swept over every class pair to be seen. A furawake's members are now items of - their own for the purpose Table 1 is asked at, and the `item` struct carries a `trailing` - edge — the occurrence a boundary *after* the item is read against — which is the item - itself for everything one character wide and the block's last character for a furawake. - A tate-chu-yoko run carries none: §3.2.5 makes it cl-30 at both of its edges. No public - API and no other engine changed. -- Both readings above are reached at every class pair now. - `engines/ocaml/probe/census.ml` adds three `constructs` variants — - `warichu-pair-inside-reduced`, the sixth shape the entry below deliberately withheld, and - `furawake-pair-inside` with its vertical mirror — so the `constructs` census is 20,102 - requests and all ten censuses are at zero differences across the three engines over - 122,199. Against the previous engines the same census reports 15 differing responses for - the Rust one and 434 for the Racket one. -- The Racket reference engine now answers the two coordinates inside a warichu block that - the other two engines already agreed on, which closes - [#23](https://github.com/P4suta/jlreq/issues/23) and - [#24](https://github.com/P4suta/jlreq/issues/24). `engines/racket/compose.rkt` reported a - line's `block_extent` as the sum of the note's own subline heights where that was larger - than the paragraph's own block size, so a note set at the paragraph's em rather than at - §3.4.2's half one made the line twice as deep as the paragraph set it; and it reported a - member's advance as its bare body, so a member standing before a Table 1 amount inside - its own subline — `cl-02` then `cl-01`, half of the note's own em — reported 500 where the - same engine placed the next member 750 units on. Both are one reading: - `docs/decisions/stacked-structure-geometry.md` now states what a block does on the block - axis (its sublines run *beside* the line, so their depth is not a depth the line reports) - and what Table 1 does inside one (a note's text is ordinary text, so the boundary between - two characters of one subline carries the ordinary amount, and a member's advance is the - step that reaches the member after it). The same reading closes §3.8.3's half of it: a - boundary the line was not composed from is not one the line may take space back from, so - the reduction ladder no longer reaches inside a block — the expansion ladder already did - not. No public API and no other engine changed. `engines/ocaml/probe/census.ml` reaches - both shapes at every class pair now: five further `constructs` variants - (`warichu-full-size` and its vertical mirror, `warichu-pair-inside`, - `warichu-pair-inside-row` and `warichu-pair-inside-justified`) and two further `tabs` - variants (a sign inside a note set at the paragraph's own em, at a stop the line reaches - and at one it has passed) — so the `constructs` census is 18,515 requests, `tabs` is - 31,211, and all ten censuses are at zero differences across the three engines over - 120,612. A sixth `constructs` variant is deliberately absent: a line that has to *give - space back* beside a note whose two sublines meet at a Table 1 amount is a coordinate the - Rust and OCaml engines do not yet agree on themselves — the Rust engine divides the - line's arrears over that in-block boundary and lays nothing down there, the OCaml engine - does not offer it at all — and a census asks the settled question. -- The Racket reference engine now reads §3.6.3's tab round the way - `docs/decisions/tab-line-correspondence.md` publishes it, which closes - [#19](https://github.com/P4suta/jlreq/issues/19) and lifts the census exclusion the entry - below records. `engines/racket/compose.rkt` gave a tab sign standing inside a warichu or a - furawake one em of the paragraph's own size instead of the advance it was shaped with — - which moves the block's own geometry and the whole line with it — and reached that branch - only for a sign *strictly* inside the structure, so a sign that opened one still fell - through to §3.6.3's cut and ended the line before a sign that is not a sign of the line. - A new `line-sign?` decides the question once, off the geometry rather than off a list of - constructs: a sign a stacking structure contains, its first character included, takes no - stop, keeps the advance it was shaped with, ends no string a stop aligns, and offers the - line no boundary. A sign inside a jidori or an emphasis run is unaffected — those set - their characters along the line, one position each. No public API and no other engine - changed. `engines/ocaml/probe/census.ml` covers the shape again at nine further variants - — a sign inside a warichu and inside a furawake, a sign that opens either, and a sign of - the line standing after such a structure, whose stop has to be measured from a walk in - which the whole block is one step — so the `tabs` census is 30,153 requests and all ten - censuses are at zero differences across the three engines over 116,909. -- §3.6.3's tab round now gives one answer to "is this a sign of the line" instead of two. - A tab sign standing inside a structure that stacks its text off the line — a - tate-chu-yoko run, which runs across it, or a warichu's and a furawake's sublines, which - run beside it — is not a sign of the line: it takes no stop, sets the advance it was - shaped with, and never chooses §3.6.3's cut, the structure's *first* character included. - Two coordinates where `crates/jlreq/src/pipeline.rs` contradicted itself are closed by - this, both found by the independent OCaml and Racket engines and filed as - [#12](https://github.com/P4suta/jlreq/issues/12) and - [#13](https://github.com/P4suta/jlreq/issues/13). A sign that *opened* a tate-chu-yoko run - ended the line — which only §3.6.3's fourth case does, and only to a sign of the line — - and was then set as a member of the run on the next line; `validate_breaks` in - `crates/jlreq/src/paragraph.rs` no longer offers §3.6.3's cut there. Inside a warichu or a - furawake, `apply_tabs` stepped a tab cursor once per character of the block while the line - set the block at one position, so the *next* sign's advance was measured from a cursor - that was not where the sign stood; the tab cursor and the placement cursor are now one - walk, in which a structure is one step, and a line the engine measures wider than it sets - — and therefore reduces when it need not — is no longer possible at these shapes. The - reading is published in `docs/decisions/tab-line-correspondence.md`, argued from §3.6.3 - and from the geometry §3.2.5, §3.4.2 and §3.7.2 give those structures rather than from - what any engine answered. No public API changed. All ten censuses stay at zero differences - across the three engines over 112,148 requests, the `tabs` census now covering a sign that - opens a tate-chu-yoko run at 1,058 further requests; a sign inside a warichu stays out of - it because the Racket engine has not reached the published reading - ([#19](https://github.com/P4suta/jlreq/issues/19)). +- A dependency-free `no_std + alloc` `jlreq` library for exact integer Japanese paragraph + composition over caller-shaped UTF-8 clusters. +- Horizontal and vertical placement, nine inline construct types, mono/group/jukugo ruby, + tabs, widow control, diagnostics, and 22 typed JLReq 2020 style choices. +- Deterministic `CompositionLimits`, reusable `Composer`, and typed `ComposeError` with + stable code, resource, limit, and observed-count fields. +- The binary-only `jlreq-conformance` protocol-v1 runner, built-in suite, JSON Schema, and + sample engine, with streaming transport, bounded input/output, inactivity timeout, and + order-independent response matching by unique case ID. +- Independent OCaml and Racket engines plus ten generated three-way censuses containing + 122,199 cases with zero expected differences. +- Reproducible specification generation and attestation, API/error-code controls, coverage, + mutation, fuzzing, package, MSRV, `no_std`, WASM, and release-artifact gates. ### Changed -- Renamed the project from `kumihan` to `jlreq`, while the workspace is still unreleased at - `0.0.0` and every package still declares `publish = false`: the crates `kumihan` → - `jlreq`, `kumihan-conformance` → `jlreq-conformance` and `kumihan-fuzz` → `jlreq-fuzz`, - the binaries `kumihan-conformance` → `jlreq-conformance` and `kumihan-sample-engine` → - `jlreq-sample-engine`, the conformance protocol identifier `kumihan.conformance/1` → - `jlreq.conformance/1` (version `1` unchanged — no message or field changed), the SPDX - copyright `2026 kumihan contributors` → `2026 jlreq contributors`, and the repository and - documentation URLs. `SPECIFICATION` (`jlreq-2020-08-11+unicode-17.0.0`), `Style::jlreq_2020` - and the `jlreq-2020` profile are JLReq revision identifiers and are unchanged. The - copyright line is an input the generation ledger hashes, so all ten `spec/derived/*.tsv` - files, all ten `crates/jlreq/src/generated/*.rs` modules and `data/manifest.toml` were - regenerated by `just derive` and `just generate`. Recorded in - [ADR 0023](docs/adr/0023-the-project-is-named-jlreq.md), which amends - [ADR 0022](docs/adr/0022-unified-public-crate-and-process-conformance.md) in name only — - its crate topology is unchanged. The entries below this one keep the names they were - written with: this changelog records the reasoning as it was reasoned, and citations of - retired code (`crates/jlreq-conform/src/kumihan.rs`, the `Kumihan` type) stay accurate by - staying as they were. -- Updated security support, issue forms, mutation reporting, current decision ownership, - and CI wording to describe the 1.0 repository rather than its retired crate graph. -- Replaced the pre-1.0 multi-crate facade with the dependency-free `no_std + alloc` - `kumihan` library and its single validated paragraph composition pipeline. -- Added all nine inline constructs, horizontal and vertical placement, optimal paragraph - breaking, integrated tabs, diagnostics, and all 22 typed JLReq 2020 Style choices. -- Added the binary-only `kumihan-conformance` CLI, versioned NDJSON protocol, JSON Schema, - sample engine, and 88 black-box cases covering all 100 observable inventoried rules. -- Removed the eight unpublished legacy crates and their compatibility-only controls. - -### Added +- The final package and binary names are `jlreq`, `jlreq-conformance`, and + `jlreq-sample-engine`; older experimental multi-crate names are not supported. +- `compose` and `Composer::compose` return `Result`. Callers must + handle deterministic resource refusal; successful values are always complete exact + layouts. There is no infallible compatibility wrapper. -- Restored the §3.3.6 single-character `flush` group-ruby reading as an explicit - protocol-v1 black-box case, bringing the built-in suite to 89 cases. -- Added a repository gate for broken local Markdown links, publishable-package checks in CI, - crate-specific package READMEs, and ADR 0022 for the unified 1.0 product boundary. -- Workspace bootstrap: crate skeletons, quality gates, and the day-one architectural - decision records. No layout logic yet. -- Fourteen further decision records (0007 through 0020) and the three design notes they - were argued from: the API spine, the specification-data generation pipeline, and the - conformance suite format. -- `jlreq-unit`, the quantity and item vocabulary every later layer speaks through. Two - kinds of length that never mix — a stated fraction of the ideographic em (全角, zenkaku) - in a 1/720 fixed-point unit, and a caller-supplied advance — inline and block axes with - no conversion between them, and the item, run, and seam types. No `core::ops` trait is - implemented for any of them, so a bare `+` on a length is a compile error rather than a - lint finding. -- `jlreq-spec`, the specification-reference vocabulary: the address grammar JLReq's own - numbering is written in, the provenance an answer carries, and a policy space that - refuses a self-contradictory policy at construction rather than at every entry point. -- Eight design gates beside `purity` — `ops`, `placeholder`, `api`, `spec-links`, - `direction`, `generate-check`, `attest`, and `conform` — run together as `just design` - in the loop and as one CI job. Each reports which of its checks had no data to run over - instead of reporting a pass, so a gate awaiting the generated tables never states that a - check it could not run held. -- The control files those gates read — `docs/api-frozen.toml`, `docs/direction-sites.toml` - and `docs/scalar-sites.toml` — each guarded by `CODEOWNERS`, which is what makes them - controls rather than documentation. -- The vendored specification: the W3C published rendering of JLReq at - `spec/snapshot/index.html`, the three Unicode Character Database extracts it is read - against, and `spec/PROVENANCE.toml` recording where each was retrieved and its SHA-256. - `just attest` verifies the files on disk against those digests, so every table below - names the bytes it was read from rather than a URL that may since have moved. -- Stage 1 of the specification-data pipeline, `just derive`: a `std`-only scanner that - reads the bilingual snapshot into eight tab-separated files under `spec/derived/` — - Appendix A, the class list, the ideograph predicate, the compatibility folding, the two - kana scripts, the document skeleton, the rule inventory and the appendix notes. Each - derived file states the digest of every source it was read from *and* of the modules that - read it, because a semantic column is the reader's reading of the document rather than a - column of it. `just derive-check` fails when rereading the snapshot would change a byte. -- The rule inventory ADR 0013 addresses: 106 rules generated into `jlreq-spec`, numbered - from the document's own rendered numbering and never from an anchor slug, which is off by - one for the appendix legends. `spec-links`, `direction` and `conform` now close over that - data instead of reporting that they had none to close over. -- `jlreq-class`, complete for M0: Appendix A's 1133 keys as 1686 listings, 473 of them named - by more than one class — the measurement ADR 0008 turns on, since it is why no total - function from a code point to a class exists to write. Classification takes an occurrence; - a key is an ordered code-point sequence matched longest-first, because 25 of Appendix A's - rows key on a pair; and `Text::new` refuses a stream this crate could not answer for - rather than guessing at it. `Text`, `classify`, `resolve`, `members`, `usage` and the - thirty class names of §3.9.2 are implemented to `docs/design/api-spine.md`. -- `crates/jlreq-conform/cases.schema.json`, the conformance case format contract. The suite - is written milestone by milestone; the format it validates against is fixed now, so the - cases and the implementation can be authored independently. -- `docs/decisions/`, with the first three readings this project publishes where JLReq is - silent: an unlisted code point, an ambiguous context, and the compatibility ideographs. - Each carries a standing other than `Normative`, so an answer resting on one says so. -- `spec/derived/questions.tsv`, the policy space as data: the twenty-one places JLReq permits - more than one answer, each with the address that permits it, the sentence it rests on - quoted from the rendering it names, the answers, and the one `Policy::JLREQ` selects. A - `permission` column records *why* an alternative is permitted — fourteen `stated`, six - `silent`, one `contradictory` — because that is the distinction ADR 0009 exists for and the - one prose loses first: only `stated` is a permission JLReq grants, and the column is what - stops the others being laundered into it. The reading is a table in `xtask/src/policy.rs` - and the derivation refuses to emit a row whose quoted sentence is not verbatim in the - section or note it addresses, so a revision that resolves a question fails the build with - the row named. `docs/api-frozen.toml` states the size of every one of the twenty-one answer - sets and the `api` gate holds the derived counts and the published `Question` constants to - it in both directions. Stage 2, which turns the file into `jlreq_spec::QUESTIONS`, is still - to come: `Question::ALL` remains empty. -- `spec/derived/defects.tsv`, the twelve recorded defects of the published document, each - with the measurement that must still find it. Being derived rather than transcribed is a - claim with teeth: twelve sentences in a constant printed into a file would be an - attestation wearing a derivation's header, so every defect carries a detector over the - rendering, the row's `evidence` is composed from what that detector measured down to the - line numbers, and a defect fixed upstream fails `derive` and prints the review procedure. - `attest` holds the file's identifiers against its own list — two lists, because the gate - that checks the file is not the program that writes it. -- The Appendix A conformance cases: 30 files, 391 cases, 27 inventoried rules. Every case is - a published artefact rather than an internal test (ADR 0006), so each names the - specification address it turns on, states its input as an occurrence with a declared frame - and role, and records both readings under `permitted` wherever JLReq decides nothing. - `jlreq-conform` gains the reader, the `Compose` trait, `run`, `run_file` and `Report`, and - a `Kumihan` implementation that answers the classification question and reports the other - two as not attempted — which is the non-obligation ADR 0006 is built on, measured rather - than described. -- `docs/conformance-deferrals.toml`, the coverage ledger, guarded by `CODEOWNERS`. The rule - inventory is generated whole and the suite is written milestone by milestone, so - "every rule has a case" has a remainder that is nothing but the schedule. An inventoried - rule is now in exactly one of three states — covered, deferred to a named milestone with a - reason, or uncovered, which fails — and `conform` prints the census on every run: 27 - covered, 79 deferred (M1 37, M2 16, M3 1, M4 23, M5 2), 0 neither. A `[[deferred]]` entry - expires by itself, because the moment a case covers the rule the entry is a violation; and - an `[[owned]]` entry is held to the opposite invariant, so a case cannot credit a rule to - nobody. `spec-links` subtracts the same file, which is the same debt seen from the citation - side. -- `docs/decisions/grouped-numeral-qualification.md`, the fourth published reading: whether - the width or the job §A.24's Remarks cell names is what reaches cl-24. The cell states - both, §3.9.2 scopes the class by the job alone, and an occurrence with the width and not - the job — a quarter-em comma between two hiragana — is described by neither and excluded by - neither. -- The `jlreq` facade re-exports the three layers that exist, so a caller depends on one crate - and names one path for a type wherever it lives. -- Appendices B through E's six matrices — Table 1 (spacing), Table 2 (line-breaking), Tables - 3 through 5 (reduction priority: JLReq's own, JIS X 4051's, and book practice's) and Table 6 - (expansion) — transcribed independently from the English and Japanese PDF renderings into - `spec/captured/table1.en.tsv` through `table6.ja.tsv`, the one CAPTURED (attested) category - ADR 0009 carves out for data W3C publishes only as PDF. `xtask attest` cross-checks the two - locales cell for cell, requires every cell's provenance (source PDF, table number, row and - column label, legend token), and holds the transcription against the cross-table invariants - `docs/design/generation.md` derives from prose that *is* machine-readable: 4,932 cells - double-entered across the six tables, 841 of 961 in Table 1, 3, 4 and 5's 31 × 31 grid and - 784 of 900 in Table 2 and 6's 30 × 30. One invariant retired on measurement rather than kept - unchecked: cl-28 and cl-29 were assumed to track cl-01 and cl-02 except for scattered - per-cell exceptions, and the landed data holds roughly 311 unnoted disagreements across the - six tables — a class-level license §3.9.2's own prose states once for the pair, not a - per-cell footnote, so the invariant is removed and the measurement is recorded in its place. -- Stage 2 of the policy-space derivation. `spec/derived/questions.tsv` now carries, for each - of twenty-two places JLReq permits more than one answer — one more than at M0: - `spacing.line_end_full_stop_comma`, §B.2 note 6's own preferred/JIS split for full stops and - commas at the line end, distinct from §B.2 note 2's closing-bracket question beside it — - every answer's own sentence and citing rule, whether JLReq calls one preferred, the answer - each of the five presets selects, and the exclusions between answers. `xtask generate` turns - the file into `crates/jlreq-spec/src/generated/policy.rs`, closing `jlreq_spec::QUESTIONS` - and `Question::ALL`, both empty since M0. `Policy::BOOK`, `MAGAZINE`, `NEWSPAPER` and - `JIS_READING` are no longer four names for one empty answer set; each now diverges from - `Policy::JLREQ` at exactly its documented questions and nowhere else. -- `jlreq-spacing`, the mojikumi (文字組み) evaluator ADR 0014 specifies: - `ConditionalSpace`, `Boundary` and `evaluate::boundary`, which answer one adjacency of two - character classes against everything Table 1, Table 2 and Appendix D/E's reduction and - expansion ladders state about it. The atom is the conditional space per referent (`be`/`af`) - and not the table cell, so a note like §B.2#3's middle-dot pair — two quarter-em - contributions from two different characters' ems, at two different reduction priorities in - Appendix D — is two `ConditionalSpace` values on one `Boundary` rather than one number. - §3.1.3's vertical-writing withdrawal of the conditional space around an ideographic comma - used as a digit separator and a katakana middle dot used as a decimal point is the crate's - one direction-conditional site, registered in `docs/direction-sites.toml`. §3.7.4's - math-formula spacing (cl-17, cl-18) is out of scope: neither class appears in any of the six - matrices by the specification's own axis, so the crate answers "no table constrains this" - rather than the quarter-em §3.7.4 states in prose. Kinsoku relaxation and line breaking - proper stay `jlreq-line`'s, the next milestone. -- `jlreq-class` applies §C.2's three reclassification notes, dormant since M0-b published - `RECLASSIFICATIONS` empty pending the policy space: note 1 moves `々` alone into cl-19 under - `kinsoku.iteration_mark_at_line_head = permitted`; note 2 moves every prolonged sound mark - (cl-10) into katakana (cl-16), and note 3 moves every small kana (cl-11) into hiragana or - katakana by its own Unicode script, both under `kinsoku.relaxation_mechanism = reclassify` — - `Policy::JLREQ`'s own default for both. `Subject::ClassInScript` is the new variant note 3 - needed: one subject class with two destinations picked by the member's own script, which no - existing `Subject` shape could state. -- The mutation-testing gate, baseline only: `.github/workflows/mutants.yml` runs - `cargo-mutants` weekly and on demand over the four crates with logic to mutate today - (`jlreq-unit`, `jlreq-spec`, `jlreq-class`, `jlreq-spacing`), and `just mutants` runs the - same thing locally. Neither `check` nor `ci` runs it yet: it is a report, not a - kill-everything threshold, until the next milestone's independently-authored cases give - kinsoku and line adjustment the discipline classification already has. -- `jlreq-line` fills §C.2 notes 6 through 8 and 13's same-run break refusal: - `feasible::same_run_refusal` reads a caller-declared `jlreq_unit::Runs` overlay directly, - refusing a break inside one ornamented complex (cl-21), one simple-ruby complex (cl-22), - one tate-chu-yoko run (cl-30), and one jukugo-ruby base-and-ruby group (cl-23, at the - level `jlreq_unit::Construct::group` carries below the run), and permitting one between - two different runs or two different groups. An occurrence with no declared group is this - pass's own adjudication — permitted, absent positive evidence of shared indivisibility — - recorded as a published reading in `docs/decisions/jukugo-ruby-unset-group.md`. Scope - limit: reachable today only through the public `Feasible::compute`, called directly with - a real overlay; `crate::compose::compose` still composes plain text, passing - `Runs::none()` unconditionally. -- `jlreq_spacing::Boundary::expansion_rule() -> Option`, the citation Table 6's own - row states for a boundary's expansion opportunity, carried independently of - `Boundary::expansion` because `Expansion` is a kind and not a record (ADR 0010): `None` - when no Table 6 row exists at this coordinate, `Some` when one does — including when what - the row states is `Expansion::None`, a note's own denial of an opportunity rather than the - table's silence about the coordinate. `rules_fired` reports it too, in a new sixth slot; - an earlier revision of that function advanced its running index past every write except - the delegation's, which the new slot would have silently overwritten at a boundary - carrying both a delegation and two conditional spaces — the fix and its own regression - test (`rules_fired_reports_two_spaces_a_delegation_and_an_expansion_without_clobbering_ - any_of_them`) land together. `crates/jlreq-conform`'s `CaseExpansion` and `ExpectExpansion` - carry the identical citation as `rule: Option`, and `check_expansion` compares it - under its own semantics: silent when the expectation states no `rule`, passed over — never - failed — when the expectation states one and the answer publishes none, and a real - disagreement only when both sides publish different addresses at the same coordinate — the - identical right `check_class`'s own doc already grants a classification answer's whole - provenance chain, now extended to one field of a boundary answer instead. - `docs/adr/0021-table-6s-expansion-belongs-to-the-boundary.md` records the decision as an - amendment to its own original text rather than a new ADR, because the carrier this - amendment gives the citation is the identical boundary-level carrier that ADR's own - Decision already gave the amount. Two further citation surfaces stay out of this round's - scope, and unwired for two different reasons rather than one: `ExpectBoundary.rules` - already has an answer to compare against — `CaseBoundary.rules` is populated from - `jlreq::rules_fired` — but `check_boundary` never reads either side's `rules` field, so - only the comparison itself is missing there; `ExpectSpace.rule` has no answer-side value - to compare against yet at all, because `CaseSpace` carries no `rule` field for - `check_spaces` to read. `docs/conformance-deferrals.toml`'s `B.2#13`, `B.2#17` and `3.1.6` - entries already name these same two holes as their blocker, unchanged by this round. -- Three `E.2.json` cases closing over §E.2 notes 8 and 9's boundary coordinates now that - `Boundary::expansion_rule` publishes their citation: - `E.2/grouped-numeral-percent/the-main-clause-denies-expansion` and - `E.2/grouped-numeral-degree-celsius/the-alternative-is-scoped-to-the-percent-sign-alone` - read the cl-24-against-cl-13 coordinate at the two fixtures - `A.13/grouped-numeral-percent/line-break` and `A.13/grouped-numeral-degree-celsius/ - line-break` already use for the breakability question, each answering `expansion: { - kind: "none", rule: "E.2#8" }` from Table 6's own `(24, 13)` cell; `E.2/grouped-numeral- - then-western-character/the-alternative-is-an-unfilled-policy-slot` reads the - cl-24-against-cl-27 coordinate and answers the identical shape citing `E.2#9`. All three - are `standing: "normative"`, because `spec/derived/questions.tsv` addresses no question to - either note, and all three carry a `forbidden` entry naming the ceiling a reading of the - note's own alternative clause alone — without checking Table 6's captured cell or, for - the percent-sign case, this workspace's own reclassification path — would wrongly publish. - The percent-sign case's own alternative rung does not ship: drafted as a three-rung ladder - mirroring `A.13/percent-sign/kinsoku-loose-reclassification`'s own classify-side one, it - was cut once checking whether this workspace could produce a cl-19 reading here found two - independent reasons it cannot on any policy — `crates/jlreq-class/src/classify.rs`'s own - `RECLASSIFICATIONS` table carries no percent-sign entry, and independently, - `crates/jlreq-spacing/src/evaluate.rs`'s own `class_of` resolves every item's class under - a hardcoded `Policy::JLREQ` rather than the `policy` parameter `boundary()` itself - receives, confirmed directly by a scratch probe (`boundary(adjacency, Policy::MAGAZINE)` - over the fixture, run and removed before commit) that still answers `Expansion::None` - citing `E.2#8`. `A.13/percent-sign/kinsoku-loose-reclassification`'s own second and third - rungs publish the identical, now-verified-unreachable reading on the classify side, where - the reference suite's own single-declared-policy run never exercises a non-default rung - and so never caught it — this round's own cases do not repeat the claim. `crates/jlreq- - conform/tests/suite.rs`'s `appendix_e_2` count rises from `[4 attempted, 0 not attempted]` - to `[7 attempted, 0 not attempted]`. -- `Question::LINE_HEAD_OPENING_BRACKET` reads a policy for the first time anywhere in this - workspace: `jlreq_spacing::evaluate::boundary`'s new `line_head_opening_bracket_space`, - called from `spaces_of` outside the per-term loop for the identical structural reason - `sentence_medial_dividing_mark_spaces` already is, synthesizes a half em at Table 1's `(0, - 1)` coordinate — the line head before an opening bracket, cl-01 — when the question answers - `pattern-2`, and answers nothing under `pattern-1`, `pattern-3` or no override at all. §B.2 - note 17's own parenthetical names §3.1.5 by section title as the place its "conditional - half em spacing" alternative is laid out ("see § 3.1.5 Positioning of Opening Brackets at - Line Head including methods of positioning of opening brackets at the beginning of - paragraphs"), which is what identifies the amount: Figure 71 pattern ②'s own wrapped-line-head - half, 折返し行頭の字下げは二分アキ, is that alternative, and patterns ① and ③ are both the - note's own preferred zero. No built-in preset answers `pattern-2` (`Policy::BOOK` answers - `pattern-3`, every other preset `pattern-1`), so no existing test or case can regress; the - half em is reachable only through an explicit `Policy::with` override. - `docs/decisions/line-head-opening-bracket.md` records the three things this synthesis had to - adjudicate rather than read verbatim — the referent (`Referent::Trailing`, the bracket being - this boundary's only possible neighbor), the reduction (`Reduction::Rigid`, stated directly - rather than routed through Appendix D's own reduction tables, whose `(0, 1)` row is checked - directly against the generated data and found to be the tables' own total-29-by-29-grid - boilerplate — the same generic citation 833 to 834 of each table's 841 rows carry — not a - stated schedule for a term Table 1 itself never states), and the citation - (`RuleId::POSITIONING_OF_OPENING_BRACKETS_AT_LINE_HEAD`, not `RuleId::B_2_NOTE_17`, because a - scratch probe run and discarded before this round's own gate battery confirmed the latter - already reaches `rules_fired` through `boundary`'s own `placement` provenance regardless of - this synthesis, while the former had zero readers anywhere in this workspace before this - round — checked directly, its only two occurrences were its own generated constant and its - own row of `spec/derived/rules.tsv`). `docs/conformance-deferrals.toml`'s `3.1.5` and - `B.2#17` entries are rewritten to state precisely what is now reachable — the wrapped line - head's own two distinguishable answers, and both paired addresses now firing in `rules_fired` - at that one coordinate — and what still is not: the paragraph-first-line half (改行行頭) of - Figure 71, entirely unread by `jlreq-line`; the citation itself, still unassertable through - `crates/jlreq-conform/src/run.rs`'s own comparison surface at either granularity — - `check_boundary` never reads an expectation's `rules` field (a fact restated more precisely - than before: as of round 13 `check_boundary` also compares `expansion`'s own conditional - `rule`, which cannot stand in here because §E.1 states Table 6 carries no line-edge cells at - all), and `check_spaces` never reads a space expectation's own `rule` field either, because - the answer side, `CaseSpace`, carries no `rule` field to compare it against at all; and the - same single-declared-policy limit `3.1.6`'s own entry already states for its own alternative-keyed - entries — `Kumihan::default()` declares `Policy::JLREQ`, whose own answer here is `pattern-1`, - so a `pattern-2`-keyed reading is a statement to an implementation that declares that - alternative, not a coordinate `cargo nextest`'s own default run exercises. Both rules stay - `[[deferred]]`; nothing moves to `[[owned]]` this round (ADR 0006). `crates/jlreq-line/src/ - lib.rs`'s own "Slots" section gains a third entry for the paragraph-first-line half: wiring - it would compose correctly for patterns 1 and 2 (whose first-line indents are the ordinary - one em plus the wrapped line head's own answer, zero and a half em respectively) but not for - pattern 3, whose own half-em first line replaces the ordinary indent rather than adding to - it, which `Paragraph::with_first_line_indent`'s purely additive `InlineExtent` cannot - express — stated plainly rather than buried, since `Policy::BOOK` answers `pattern-3` and is - this project's own default book preset. -- `ExpectBoundary::rules` is compared for the first time: `crates/jlreq-conform/src/run.rs`'s - new `check_rules`, called from `check_boundary` whenever a case declares the field, reads it - as a *subset* of `CaseBoundary::rules` — every address the case names must appear somewhere - among the ones the answer published, never their equality and never their order, and a - declared address met by an empty answered list is passed over rather than failed, the - identical third state `check_expansion`'s own conditional `rule` field already gave one - provenance comparison. The asymmetry is argued rather than assumed: - `jlreq_spacing::evaluate::rules_fired`'s own fixed 6-slot array repeats the identical - fallback address in its first two slots and orders every slot by internal layout rather - than by anything the specification states, so holding a case to that order or to that - repetition would be exactly the "reproduce our chain of specification addresses" demand ADR - 0006 exists to keep the suite from making of a foreign implementation. `check_class`'s own - doc, which argues that classification provenance is *not* compared, is amended to name this - second exception and answer its own three grounds for it directly — the first now - discriminates *for* the boundary comparison (three `docs/conformance-deferrals.toml` entries - name `check_boundary`'s own prior gap directly and a fourth, `D.2#4`, names the same absence - one layer further upstream, in `rules_fired` itself, a gap this round does not close; zero - name classification provenance), the second is answered by the - subset semantics being materially weaker than the exact-sequence reproduction the second - ground actually rejects, and the third by scale: the twelve pre-existing boundary-level - `rules` declarations (five in `A.16.json`, seven in `A.22.json`) were individually - re-verified this round before the comparison went live, against `ExpectClass::rules`'s own - 413, unaudited. All twelve are `declined` today — every one sits on a boundary where at - least one neighbor is covered by a ruby construct `jlreq-inline` (M4) does not yet exist to - answer, confirmed against `crates/jlreq-conform/tests/suite.rs`'s own committed census - (`A.16`'s `[25 attempted, 1 not attempted]`, `A.22`'s `[1 attempted, 11 not attempted]`) - rather than assumed — so this round changes nothing observable for any of them; none needed - correcting. `crates/jlreq-conform/cases.schema.json`'s own `boundary.rules` gains the - description it was the only field of `boundary`'s eight to be missing. - `docs/conformance-deferrals.toml`'s `3.1.5` and `B.2#17` entries are rewritten to state what - a case can now positively assert under the default policy — `rules: ["B.2#17"]` at cl-01's - line-head boundary, checked on every `cargo nextest` run, since `rules_fired` puts that - citation into its own placement slot regardless of `spacing.line_head_opening_bracket`'s own - answer — while keeping `check_spaces`'s own unread `ExpectSpace::rule` (and `CaseSpace`'s own - missing `rule` field) stated as still open, a published API-surface change and a round of its - own. `B.2#13`'s entry is rewritten the identical way — its own placement citation, - unconditionally read regardless of Table 1's empty terms at cl-26's line-head and line-end - coordinates, is now assertable too — but `D.2#4`'s is not: that note's own citation lives - only in a reduction table's per-term loop, which never runs where no term exists, so - `rules_fired` never puts it in any slot at all and this round's comparison has nothing there - to reach. Coverage stays at 67/106; no rule moves from `[[deferred]]` to `[[owned]]`. -- `crates/jlreq-conform/cases/3.1.5.json` (new) and `crates/jlreq-conform/cases/B.2.json` - (one case appended) are the independent case phase task #42 (round 15) and task #44 - (round 16) were both forbidden from writing, ADR 0006's own discipline: derived from - §3.1.5's and §B.2 note 17's own words and from the generated tables before this round's - own suite run, not from what the evaluator was already known to answer. `3.1.5`'s own - three cases pin Figure 71's own wrapped-line-head pattern and its own scope to opening - brackets (cl-01) at a line head, neither a different class nor an interior boundary; - `B.2/opening-bracket-at-line-head/the-preferred-zero-and-the-retained-half-em` pins the - note's own amount. Both rules' `{}` entries assert `spaces: []` together with `rules: - ["B.2#17"]` at the line-head boundary before cl-01 — the round's own load-bearing - measurement, since an empty `spaces` alone is the identical answer any blank cell gives. - Reading both locales of the note's own alternative settles the one open discriminator: the - English's "not to remove a conditional half em spacing accompanying the characters" reads - as retaining cl-01's own class-level half em (`spec/captured/table1.en.tsv`'s own cl-01 - column carries a trailing `1/2 af` at essentially every `before` class) rather than - synthesizing an unrelated one, while the Japanese states only a plain amount with no verb - of retention at all — a locale framing difference this round records rather than resolves. - Whether the retained half em is reducible does not follow from that reading alone, and is - where this round corrects round 15's own ground rather than its answer — though not, on a - second pass, all the way to the categorical claim first drafted for it: Appendix D's own - preamble scopes the whole reduction mechanism to an opportunity "between two adjacent - characters" (`spec/derived/rules.tsv`'s row for rule `D`), but a line end has the identical - single-neighbor structure and Appendix D genuinely does reduce real terms there (§D.1's own - legend; `3.1.9`'s and `B.2#2`'s own cases), so "only one real neighbor" cannot itself be the - exclusion. What actually holds, confirmed rather than assumed, is narrower and purely - empirical: the line-head row specifically, not line edges in general, is uniformly rigid - across Tables 3, 4 and 5 — `xtask/src/attest.rs`'s own `no_reduction_at_the_line_head` - invariant, a `Check::Whole` run over the full transcription, reports zero violations there - (`docs/design/generation.md`'s own cross-table invariant 4). `Reduction::Rigid` is - consequently still the corrected answer, agreeing with round 15's own value while replacing - the narrower ground that round's own doc gave (an absent-term row being the tables' - total-grid boilerplate, true of the one cell but not the reason the amount cannot move). `docs/decisions/line-head-opening-bracket.md` is left - untouched, per ADR 0006's own separation of phases; the corrected ground is recorded in the - new cases and in `docs/conformance-deferrals.toml` instead. Both rules move from - `[[deferred]]` to `[[owned]]`; coverage rises to 69/106, 37 deferred, 0 uncovered. - `B.2#13`'s own entry is read again rather than moved: its note states one unified fact - about the suppression of the cl-26 item's own supplied advance at four positions, a fact - no crate in this workspace computes at any milestone — `jlreq-spacing` only ever produces - an inter-character `ConditionalSpace` between two neighbors, never a change to an item's - own advance (`cases.schema.json`'s own `item.advance`, ADR 0002) — so the entry's own - `milestone` moves from `M2` to `M4`, the same label its warichu half already carried, - rather than being repaired in place; no case is authored for it this round. - `crates/jlreq-conform/tests/suite.rs` gains `section_3_1_5 => "3.1.5" [3 attempted, 0 not - attempted]` and bumps `appendix_b_2` to `[7 attempted, 0 not attempted]`. -- `Search::Optimal { tolerance: Badness }`, M3's first slice: the whole-paragraph break - search `docs/design/api-spine.md` froze the shape of at M1, implemented in - `crates/jlreq-line/src/compose.rs` as a forward dynamic program (`run_dp`) over the same - `Feasible::compute` break set and the same `geometry_of` → `Ladder::of` → `adjust_line` → - `apply_adjustment` → `demerits_of` pipeline `Search::FirstFit` already ran, minimizing the - paragraph's own summed `Demerits` under `Preference::compare` rather than committing to one - candidate per line before the ladder runs. `compose`'s own greedy loop moves, unmodified, - into a new `compose_first_fit`, and `Search::Optimal` gets its own `compose_optimal` - alongside it — two separately written pipelines sharing only the feasibility computation, - never a per-line evaluator, so a defect in one cannot silently reach the other's already - verified answers (this round's own C6, checked directly: all 827 existing tests and all 466 - conformance cases produce byte-identical results before and after). - - The DP's correctness rests on lexicographic order over `Demerits`' six `u32` components - being translation-invariant under `Demerits::add_sat` — stated and bounded in `run_dp`'s - own doc: every component but `badness` saturates far later than `badness`'s own - `u32::MAX / 10_000 ≈ 429_496`-line bound, which no paragraph this milestone composes - reaches (this round's own C2). - - `first_line` and `is_last_line` are read from an edge's own two ends - (`start.get() == 0`, `end.get() >= item_count`), never from where the DP's own - reconstruction happens to be, so a line's cost is identical whichever predecessor reaches - it (C3, covered by a dedicated first-line-indent test). - - The scan per candidate start stops after the first ladder-drained `Overfull` result, never - on `ExpansionExhausted` (a short line the ladder could still save by growing), with the - stated reason recorded in `run_dp`'s own doc rather than left as a magic constant (C5). - - `tolerance` filters which edges the DP may use, exactly "discarding any line worse than - tolerance": given this milestone's own zero-flex `Badness::of` reading (a feasible line is - always `Badness::ZERO`, an infeasible one always `Badness::WORST`), `tolerance` has - exactly two reachable settings, `Badness::WORST` (neutral, matching `FirstFit`'s own - leniency) and everything below it (admitting only feasible lines) — stated in - `Search::Optimal`'s own doc rather than left for a caller to discover empirically. - Tolerance exhaustion (no complete arrangement stays within it) re-minimizes once more over - the full, un-pruned edge set rather than panicking or inventing a forbidden break - (ADR-0010); the reading is published as `docs/decisions/tolerance-exhaustion.md` - (`Standing::Unstated`, added to `docs/decisions/README.md`'s own table) rather than left a - `Slots` entry, because the search itself is filled — only this one open design choice was. - - `Line::pull_up` is populated under `Search::Optimal`: `Some` exactly when a shorter, - evaluated alternative existed for the same line's own start and the chosen, longer line - needed real reduction to fit it — the reduction-preferring comparison ADR-0010 describes, - applied to two candidate breaks that both actually existed, never reverse-engineered from - what "should" look right (`compose_optimal`'s own `pull_up_of`, covered by a direct unit - test independent of any full composition). - - The round's own required experiment: actively constructing a paragraph on which - `FirstFit` and `Optimal` disagree, rather than assuming the two published claims that - they cannot. Both are now falsified and repaired. `docs/design/api-spine.md`'s former - "[`Preference`] reaches the same answer by comparison, which is why the two searches - agree" held only *per line, given an identical range* (both drain the identical ladder in - the identical order once a range is fixed) — a constructed three-ideograph paragraph - (`least_adjustment_prefers_the_shallow_but_overfull_arrangement` / - `even_texture_prefers_the_feasible_arrangement` in `compose.rs`'s own test module) shows - `least-adjustment` preferring a single, violating line over `FirstFit`'s own two-line, - fully expanded answer, because `least-adjustment` ranks `expansion_depth` ahead of - `badness`. `ROADMAP.md`'s former "the greedy search and the optimal one cannot disagree - about when a character hangs" is narrowed the same way and repaired with a second - constructed pair - (`firstfit_and_optimal_disagree_about_whether_a_trailing_full_stop_hangs`): a trailing - full stop hangs under `Optimal` (which keeps it on one line with both ideographs, needing - only reduction and hanging to fit) but never reaches `ladder::hang` at all under - `FirstFit` (which puts it alone on a short, exempt last line first). Both fixtures are - hand-verified and their numbers checked against the actual test run, not asserted from - hand math alone. - - `crates/jlreq-line/src/lib.rs`'s own `# Status` states why `Search::Optimal` is named in - neither the "Wired, not slotted" nor the "Slots" list — it is new logic, not another - crate's rule table read through, and it is filled rather than an unfilled seam — and - restates that §3.5.4's widow threshold stays a real, named gap beside it: `Search::Optimal` - does not read `Paragraph::with_widow_threshold`, and §3.5.4 stays `[[deferred]]` to a later - M3 round in `docs/conformance-deferrals.toml`, unchanged by this one. Every prior claim - that `Optimal` did not yet exist — in `compose.rs`, `objective.rs` (including - `Badness::of`'s own stale "the second value is never reached in practice because - `crate::Fit` classifies that line infeasible first", corrected: `crate::Fit` is never - constructed anywhere in this crate, and `compose`'s own `demerits_of` reaches - `Badness::WORST` on every violating line either search composes) and `lib.rs` — is repaired - in place rather than left to mislead a reader who trusts the prose over the code. -- The conformance suite can now ask `Search::Optimal` a question at all, and one case does, - per ADR-0006's independent-phase discipline: authored against §3.1.12's own words, not - against what `compose_optimal` happens to produce. - - `cases.schema.json` gains `input.search` (a `compose` case's chosen search — absent - reads as `Search::FirstFit`, exactly what every one of the 466 prior cases already - assumed, so none of them changed answer) and `line.pull_up` (`jlreq_line::PullUp`'s - three fields). `pull_up` is the one field on `line` whose *absence* is a checked - assertion — `Line::pull_up` is `None` — rather than "unchecked", on task #44 (round - 16)'s own precedent for `ExpectBoundary::rules`: the reading is safe applied - retroactively because `Search::FirstFit`'s own doc already guarantees `None` on every - line that search composes, so turning the comparison on changes what no pre-existing - case is measured against. `crates/jlreq-conform/src/case.rs`, `run.rs` and - `kumihan.rs` read and act on both fields; `xtask/src/conform.rs` gained its own - hand-written validation of `search` (`cases.schema.json` is a contract stated twice, - and this is the half a JSON-schema library does not run) and the `conform` census now - reports how many compose cases name a non-default search. - - `3.1.12/two-worked-examples/optimal-search-reports-the-pull-up-reduction-makes-available` - is the new case, in `crates/jlreq-conform/cases/3.1.12.json` beside the two existing - ones rather than in a file of its own: §3.1.12 ④ states the ideal, reduction-based - repair in the same breath as the one it excuses ("Ideally, a full width spacing - reduction would be applied, and the character... would be moved onto the first - line... In that way, the problem could be avoided"), and the sibling case immediately - above is deliberately built so that repair is unavailable. This entry is the missing - half: a six-item paragraph and four candidates in which the nearer break admits no - complete arrangement at all (verified directly, both by composing the remainder alone - and by withholding the farther candidate) and the farther one is consequently the only - admitted arrangement, not one preferred over a competing feasible one — the - discriminating test this round's own brief states, applied and passed rather than - asserted. Neither of the two published `Question::ADJUSTMENT_PREFERENCE` readings is - named, because neither changes the outcome. `docs/conformance-deferrals.toml`'s - `3.1.12` entry is updated to state what this case now covers; rule `3.1.12` stays - `[[owned]]`, and no rule moves from `[[deferred]]` to `[[owned]]` this round. - - Two stale claims this round's own work falsifies are repaired: `crates/jlreq-conform/ - src/kumihan.rs`'s module doc and its `compose` method no longer say `compose` asks only - `Search::FirstFit`, now that a case can ask for `Search::Optimal` instead; and - `docs/conformance-deferrals.toml`'s `3.5.4` entry no longer says widow adjustment - "arrives with the objective" — the objective has arrived and 3.5.4 is still deferred, - so the entry now states the real, checkable blocker instead - (`Paragraph::with_widow_threshold`'s own doc: neither search reads the field it - stores). A third, pre-existing claim is repaired on the same finding: - `docs/design/conformance.md`'s "Cross-search agreement" section described, in the - present tense, a gate that runs every case under both searches and compares them — - true of nothing in `jlreq-conform` today, sharper now that cases naming `Search:: - Optimal` actually exist and are not run under `Search::FirstFit` too. The section is - rewritten to say so plainly, keeping the design reasoning for when the gate is built - rather than deleting it. A fourth, adjacent claim in the same section — - "direction parity" composing every case both ways — was found false by the identical - method (no such loop exists in `jlreq-conform` either) but is unrelated to this - round's own changes and is left for the round that owns it, reported rather than - fixed here. -- §3.5.4's widow adjustment, wired: `Paragraph::with_widow_threshold`'s own field is read - for the first time, by `crates/jlreq-line/src/compose.rs`'s own `demerits_of` — the one - cost function `compose_first_fit` and `evaluate_edge` both call, so `Search::FirstFit` - and `Search::Optimal` are scored by one formula rather than two that could quietly - disagree (this round's own reuse of the M3 round 19 C1 argument). `demerits_of` grows - three parameters (`line: Range`, `is_last_line: bool`, `widow_threshold: - u16`), threaded the same way `adjust_line`'s own signature, one call earlier in the - identical pipeline, already threads the first two. Its own `..Demerits::ZERO` - struct-update tail is dropped rather than kept: once `structural` is computed rather - than left at its base value, all six of `Demerits`'s own fields are named explicitly, - and `clippy::needless_update` (part of the default `complexity` group, not only - `pedantic`) refuses a base that supplies nothing a literal does not already state — - a deviation from the round's own brief, which asked for the idiom kept, stated here - because a lint that fires is not optional. - - A new private `WidowFacts`/`widow_facts_of` pair reads the paragraph's own last line - (`is_last_line`, already derived identically at both call sites — `evaluate_edge`'s own - C3) and reports how many items it carries and how far short of the threshold that - falls, `u32::from(threshold).saturating_sub(have)` — shortfall-proportional, so an - unsatisfiable threshold still discriminates between a nearer miss and a farther one - rather than tying every violating arrangement. "A character" reads as an item - (ADR-0008), and a last item `crate::ladder::hang` let hang past the measure is still - counted: `hang`'s own `last` sits inside the line's own range, never past it. - - `demerits_of` adds the shortfall to `Demerits::structural` on exactly the last line; - `structural` already ranked first in both of `docs/decisions/adjustment-preference.md`'s - own orderings, so `Search::Optimal` genuinely steers toward a widow-free last line - when more than one arrangement admits one, ahead of every other component regardless - of how much worse it scores there — proved by a constructed fixture - (`optimal_steers_toward_a_widow_free_last_line_even_when_every_other_component_is_worse`) - where the search takes an arrangement carrying two ladder violations (`badness = - 20_000`) over a fully feasible one, purely because the feasible one's own last line - falls one item short. `Search::FirstFit` cannot do the same — it commits to one - candidate per line and never compares arrangements — so it only ever reports the - shortfall of the line it already greedily chose - (`first_fit_reports_a_widow_but_never_moves_the_break_to_avoid_it`, pinning the - asymmetry directly: the chosen breaks are byte-identical with and without a - threshold). - - A new `ViolationKind::Widow { have: u32, want: u16 }` variant (a minor addition under - `#[non_exhaustive]`, ADR-0012) is pushed, once, for the last line only, in both - `compose_first_fit`'s loop and `compose_optimal`'s own reconstruction loop, through a - shared `push_widow_violation` so the check is written once rather than twice. - `Violation::rule` names `RuleId::WIDOW_ADJUSTMENT_OF_PARAGRAPHS` — cited by no code - anywhere in this workspace before this round — rather than the generic line-breaking - rule every other violation in these two loops hardcodes, and `Violation::at` is the - last line's own start, the break that could have moved, not the paragraph's own end, - which is identical for every arrangement and says nothing. The violation is the point - and not a garnish on the demerit: `Demerits` is this crate's own invented objective and - a conformance case may never assert a demerit value as if it were JLReq's own answer - (round 8's own brief), so `structural` alone would leave round 22 nothing JLReq-shaped - to assert for the unsatisfiable case. - - `docs/decisions/widow-threshold.md` (new), modeled on `tolerance-exhaustion.md`'s own - four headings, publishes the four readings §3.5.4's silence forces and - `docs/decisions/README.md` gains its row: what counts as "a character" (an item); - whether a one-line paragraph can have a widow (yes, read literally — the exempting - reading would add a condition the specification does not state, and the reading costs - nothing because a constant addend across one candidate changes no comparison, - `run_dp`'s own C2); the penalty's own shape (shortfall-proportional); and what an - unsatisfiable threshold means (both remaining ADR-0010-licensed mechanisms together — - graceful degradation through `structural`, plus the reported violation — never a - refusal, and never the schedule-inventing relaxation `tolerance-exhaustion.md` already - rejected by name for the identical reason). Seven new unit tests in `compose.rs`'s own - `#[cfg(test)] mod tests` pin all of the above, including the threshold-0 no-op that is - this round's own regression guard for all 834 pre-existing tests and all 467 - conformance cases, and the zero-item paragraph, checked directly rather than assumed, - never growing a widow violation with nothing to report `have`/`want` for. - - Every stale claim this round's own work falsifies is repaired in place. `compose.rs`'s - `run_dp` doc no longer names `structural` "always 0 at this milestone" as a premise of - its saturation bound — the replacement premise is stronger, not merely updated: - `structural` cannot saturate at all, for any input, because the widow term lands on - exactly one edge of any complete path and is bounded at `u16::MAX = 65,535`, so - `badness` remains the one component the bound has to name. - `Paragraph::with_widow_threshold`'s own doc no longer opens "Stored and still not - read." `objective.rs`'s `Demerits::structural` field doc no longer says "Always zero - even now that `Search::Optimal` exists." `crates/jlreq-line/src/lib.rs`'s own - `# Status` no longer names the widow threshold "a real, named gap" beside - `Search::Optimal` — it moves from the gap list to the filled, "neither Wired-not- - slotted-nor-a-Slot" list `Search::Optimal` itself already occupies, and the four - published readings replace it as what is honestly still open. - `docs/conformance-deferrals.toml`'s `3.5.4` entry no longer blames the DP for not - reading the threshold — it does now — and states the real, current blocker instead: - coverage, not implementation, per ADR-0006's independently authored phases. - `docs/design/api-spine.md` gains the new `ViolationKind::Widow` variant at its own - frozen enum listing and a sharper one-line doc for `with_widow_threshold`. - `docs/decisions/adjustment-preference.md` gains one sentence noting `structural`'s own - first-rank position is now reachable rather than reserved, without reopening the - ranking itself, which this round does not revisit. `compose_optimal`'s own - reachability doc, `Search::Optimal`'s own "read by both search variants alike" - sentence, and `tolerance-exhaustion.md`'s own FirstFit-comparability sentence all - survive verbatim, checked rather than assumed: all three rest on `demerits_of` being - the one shared cost function, a fact this round's wiring preserves rather than forks. - - One stale claim this round's own sweep found and did not fix, on the same finding - method as prior rounds' "direction parity" precedent: `docs/design/api-spine.md`'s own - `ComposeError` block lists two variants (`OutOfRange`, `CandidateOutOfRange`) while the - real enum has three (`crates/jlreq-line/src/compose.rs`'s own `InsufficientTabStops`, - from the §3.6 tab-setting round) — pre-existing drift, unrelated to this round's own - changes, checked directly (`api` gate parses the spine only for `Question` constant - counts, never for enum variant listings, so nothing catches this mechanically) and left - for the round that owns `ComposeError`, reported rather than fixed here. - - **Phase discipline held**: no file under `crates/jlreq-conform/` changes this round, - no conformance case is authored, and rule 3.5.4 stays `[[deferred]]` to M3 — task #58's - entire reason to exist, per ADR-0006. -- Task #58 (round 22) is that independently authored phase, and closes M3's deferral list to - zero. - - `input.widow_threshold` (a non-negative integer, absent reading as `0`) reaches the case - format for the first time: `cases.schema.json` gains its own description in `search`'s - own long voice, `crates/jlreq-conform/src/case.rs`'s `CaseInput` gains the field, and - `crates/jlreq-conform/src/kumihan.rs`'s `compose` reads it through a plain `if let` - guard — not paired with `head_indent`/`end_indent`'s own `with_indents` call, because - `Paragraph::with_widow_threshold` takes one field and has no sibling for a case to state - without it, the doc now says so rather than leaving a reader to wonder. `xtask/src/ - conform.rs` gains `check_widow_threshold`, bounding a stated value at `0..=u16::MAX` — - mirroring `check_search`'s own bound on `tolerance` — so a threshold this reader cannot - hold declines `conform --check` rather than silently declining the case at runtime. No - census line: unlike `Search::Optimal`, a whole second search variant that earned its own - `optimal_search_census` line at round 20, this field is a scalar parameter the same shape - as `first_line_indent`, `head_indent` and `end_indent`, none of which has one either. - - `crates/jlreq-conform/cases/3.5.4.json` (new), three cases, derived from §3.5.4's own - sentence and `docs/decisions/widow-threshold.md`'s own four published readings before - this round's own suite run, on the same discipline round 20's `3.1.12.json` states in - full. Q2 (a one-line paragraph can have a widow): a two-item, one-line paragraph - composed with an empty `candidates` array, threshold above its own item count; the - exempting alternative is `forbidden`. Q1 (a character is an item): a threshold-equal / - threshold-past-the-count pair over a last line built from two Western-letter clusters on - the proportional frame (`fi`, `if` — ADR-0018's own ligature exception), so the last - line's item count (2) diverges from both its code-point count and its byte count (4 and - 4), discriminating item-counting from either. Q4 (violation, never refusal) is carried - through the same channel both cases already use — a non-empty `lines` beside a real - violation — and the Q1 pair's own discriminating case additionally rejects the - relaxation alternative in `forbidden`, by name. Every case is `standing: "normative"`, - not `"unstated"`: `conform --check`'s own `check_standing` requires `permitted` to carry - more than one reading whenever standing is `unstated`, `adjudicated` or `alternative`, - and none of these three cases has a second `Policy`-reachable answer for a second entry - to name — the rejected alternative lives in `forbidden` instead, which carries no such - requirement. `crates/jlreq-conform/tests/suite.rs` gains `section_3_5_4 => "3.5.4" [3 - attempted, 0 not attempted]`; all three agree with this workspace on the first run, zero - disagreements, arithmetic derived by hand from `table1.rs`'s own cl-27×cl-27 and - cl-19×line-end/line-head×cl-27 cells (all blank) before the suite ever ran. - - `docs/conformance-deferrals.toml`'s `3.5.4` entry moves from `[[deferred]]` to - `[[owned]]` at M3 (M3's deferred count: 1 → 0), with an honest scope limit rather than a - claim of full coverage: it states which of the four readings a case reaches (Q1, Q2, Q4) - and names the two it does not. Q3 (the penalty's own shape) is reported unconstructible - for a reason sharper than difficulty deriving one fixture by hand — no `Policy` question - selects a flat penalty, so no case in this format can compare the proportional reading - against a reachable alternative, a limit of the format itself. The hanging wrinkle - requires `adjustment.hanging_punctuation = hanging`, which `Policy::JLREQ` does not - select, so a case exercising it would be published but never attempted, `3.8.2.json`'s - own already-stated standing for a different rule; this round declines to publish one for - that reason. The entry also states that this suite checks `ViolationKind::Widow`'s own - address only, never its `have`/`want`, because `kumihan.rs`'s own `compose` discards both - before the case format ever sees them. - - Every stale claim this round's own work falsifies is repaired in place, on the same - sweep discipline round 21's own entry above states: `objective.rs`'s - `Demerits::structural` field doc and `crates/jlreq-line/src/lib.rs`'s own `# Status` no - longer say §3.5.4 "stays `[[deferred]]`"; `docs/decisions/widow-threshold.md`'s own - closing paragraph no longer says the suite carries none of the four readings — it now - states which two it carries and which two it does not, in the same terms the ledger - entry above uses. -- M1 round 11: the published conformance format's sixth `kind`, `feasible`, and the `Runs` - overlay that answers it — closing §C.2#13's own deferral. - - `crates/jlreq-conform/cases.schema.json` gains `"feasible"` in `input.kind`'s enum and a - `feasible` `$def` for `expect.feasible` (`candidate`, `breakable`, `rules`), in the long - voice `search`'s and `ruby`'s own descriptions use: which of the caller's own UAX #14 - candidates kinsoku permits and which rule refused each of the rest; why it is a separate - kind rather than a `boundary` field (a `boundary` answer is Tables 1 and 2 at one - adjacency, a candidate's survival is `jlreq-line`'s own refusal layer, which additionally - reads a construct overlay no table cell can express — §C.2#6 through #8 and #13); and - that `constructs` is the one field this kind reads as load-bearing rather than declining - on account of, unlike every other kind. - - `crates/jlreq-conform/src/case.rs`: `KINDS` grows to six. `Expect` gains - `feasible: Option`, read by `read_feasible` on `read_boundary`'s own - "every field optional" convention, and `Expect::is_silent` now checks it too — the quiet - bug this round's own review caught before the gate battery could hide it: without that - one line, every `forbidden` entry this round writes would have excluded nothing. - `CaseConstruct` gains `style`, read from a `ruby` entry's own field, which the adapter - needs to choose between `NonJukugoRuby` and `JukugoRuby`. - - `crates/jlreq-conform/src/run.rs`: `Compose` gains a sixth method, `feasible`, required - rather than defaulted for the identical reason `align` and `tab` already are. - `CaseFeasible` (`breakable`, `rules`) answers it; `Answer::Feasible` and `ask`'s own - `"feasible" =>` arm route it, distinctly from `align`'s and `tab`'s reuse of - `Answer::Composed` — a candidate's own survival is nothing like a composed line. - `check_feasible` reuses `check_boundary`'s own rules comparison, `check_rules`, - generalized from `&CaseBoundary` to `&[String]` on both sides rather than duplicated, for - the identical subset-not-equality reasoning stated once. - - `crates/jlreq-conform/src/kumihan.rs`: `Compose::feasible` is the one method of the six - that builds a real, non-`Runs::none()` overlay. The private `overlay_of` converts a - case's declared `constructs` into one slot per item of the base stream, honestly and - totally over the schema's nine construct arrays: `ornaments` and `tate_chu_yoko` convert - unconditionally, `ruby` converts when `style` is `"mono"`, `"group"` or `"jukugo"`; - `emphasis`, `jidori`, `formulae`, `warichu`, `furiwake` and `reference_marks` all decline, - each for a reason its own doc states — no `ConstructKind` variant, an undeclarable - `FormulaSetting`, or a declared range the schema does not pin to mean what the matching - variant means. Every slot's `group` stays `None` (§C.2#8's own level below the run needs - `ruby.runs`, not read this round), which `docs/decisions/jukugo-ruby-unset-group.md`'s - own reading already treats as permitted rather than refused. One inconvertible construct - anywhere in a case fails the whole conversion rather than leaving a silent gap in the - overlay. The module doc's own "All five methods... every `Runs` this crate builds is - `Runs::none()`" claims are repaired to name the sixth method and the one place a real - overlay now exists. - - `xtask/src/conform.rs`: `check_input` requires `candidates` (never `measure`) of a - `feasible` case, and its `kind` match is now checked against an explicit `INPUT_KINDS` - list instead of falling through a silent wildcard — an unrecognized `kind` is a - violation now, rather than being quietly asked `compose`'s own required fields. - `check_question` holds a `feasible` case to the identical "one input, one question" - invariant `classify` and `boundary` already are. `Suite::census`'s own kind-counting line - — extracted to `kind_census`, alongside `optimal_search_census`, to stay under - `clippy::too_many_lines` — reports the new kind's count. - - `docs/design/conformance.md` gains the sixth trait method and `CaseFeasible` in the same - voice as the rest of the document; every stale "five methods"/"five questions" claim - across `crates/jlreq-conform` and this document is repaired to six, and - `crates/jlreq-conform/src/lib.rs` newly re-exports `CaseFeasible` and `ExpectFeasible` at - the crate root — without which no implementation outside this crate could even name - `Compose::feasible`'s own return type in its own `impl`. - - `crates/jlreq-conform/cases/C.2.json` gains two `feasible` cases, derived independently - from §C.2#13's own two sentences rather than from `jlreq_line::feasible:: - same_run_refusal`'s own match arms or its test module's fixtures (ADR 0006's own hazard - for this route, read twice before writing either case): - `two-characters-in-one-tate-chu-yoko-run/no-break-inside` (interior of one declared run, - refused, citing `C.2#13`) and `two-tate-chu-yoko-runs-adjacent/break-permitted` (the - boundary between two declared runs with nothing between them, permitted). Both sit at - cl-15 against cl-15 (ordinary hiragana), a coordinate independently verified blank in - Table 1, Table 2, Table 3's line-end row and Table 4's line-head row - (`spec/captured/table1.en.tsv` through `table4.en.tsv`, read directly rather than - inferred from this evaluator's own answer), so the refusal and the permission each case - asserts can only be `same_run_refusal`'s own citation, never a class-pair prohibition - coinciding by accident. `crates/jlreq-conform/tests/suite.rs`'s own committed census for - `C.2` moves from `[8 attempted, 0 not attempted]` to `[10 attempted, 0 not attempted]`. - - `docs/conformance-deferrals.toml`: §C.2#13 moves from `[[deferred]]` to `[[owned]]` at M1 - (M1's deferred count: 5 → 4), stating which two cases now measure it and by what - mechanism. §C.2#6's and §C.2#7's existing `[[owned]]` entries are repaired: both - previously said only that a boundary answer was published and "none receives yet" or - "published as two boundary cases," without saying which cases or why none is answered; - both now name the specific cases (`A.21/inside-one-complex/*` and - `A.21/between-two-complexes/break-opportunity` for §C.2#6; `A.22/same-complex/ - no-break-inside` and `A.22/distinct-complexes/break-and-solid` for §C.2#7) and state - plainly that every one of them declares a construct `Kumihan::boundary` declines per - item, so this workspace answers none of them today — the ledger's own header already - provides for exactly this state. §C.2#8 stays `[[deferred]]`, its own `why` rewritten to - name the one gap this round did not close (`ruby.runs`'s own group reading in - `read_constructs`) rather than the longer list of blockers this round's own overlay - machinery removed. - - The route not taken, and why: threading a caller-supplied `Runs` into - `jlreq_line::compose` itself was rejected in favor of the `feasible` kind, for the three - reasons `crates/jlreq-line/src/compose.rs`'s own `Runs::none()` comment and this round's - own design already state — `jlreq_spacing::evaluate::delegation_of` would silently - switch on §B.2#10/#11 delegation with no case behind it, `jlreq_class::resolve` stays - construct-blind so a spacing amount inside a construct run would still answer the items' - bare classes, and a same-run refusal is only ever observable as a differently-placed - break, never as a cited rule — exactly the citable fact ADR 0006 needs a case to assert. -- M1 round 12: six `feasible` cases over §C.2 notes 6, 7 and 8, and the retraction of the - former `C.2#8` deferral's own stated blocker. - - `crates/jlreq-conform/src/kumihan.rs`: `overlay_of`'s own doc gains a new section, - `` `ruby.runs` is a declared slot this function does not read ``, in the "Slots" sense - `crates/jlreq-line/src/lib.rs`'s own module doc names — a seam a later, independently - authored phase fills, not a gap this round left behind. No field is added; the paragraph - states three facts as the reason the schema-required `annotation` and `runs` stay unread: - a declared `GroupId` changes exactly one downstream answer (`same_run_refusal`'s own - `JukugoRuby` arm); §C.2#8's own group is one base character and its own accompanying - reading, never a span across two, so two adjacent base characters of one complex are - never one group (§3.3.7's own body and §3.1.10 item 8's own Note, both quoted); and - `Feasible::compute` sees the base item stream alone, so the level the note's third - sentence is about is unreachable from this crate before `jlreq-inline` exists (M4-a). This - is the only Rust change of the round — `crates/jlreq-line/**` and - `crates/jlreq-conform/src/case.rs` are untouched, and `cases.schema.json` is unchanged, - since `feasible`, `ruby`, `run` and `constructs` were already adequate. - - `crates/jlreq-conform/cases/C.2.json` gains six `feasible` cases, authored as the - independently authored phase ADR 0006 requires — verified against the note's own English - sentences and the captured tables before being checked against this workspace's own - answer, not derived from `same_run_refusal`'s own match arms: two for §C.2#6 - (`two-characters-in-one-ornamented-complex/no-break-inside`, - `two-ornamented-complexes-adjacent/break-permitted`), two for §C.2#7 - (`two-base-characters-in-one-simple-ruby-complex/no-break-inside`, - `two-simple-ruby-complexes-adjacent/break-permitted`), and two for §C.2#8 - (`two-jukugo-ruby-complexes-adjacent/break-permitted`, - `two-base-characters-in-one-jukugo-ruby-complex/break-permitted`). Every one of the six - sits at cl-19 against cl-19, independently verified `blank` in Tables 1 through 4 - (`spec/captured/table1.en.tsv` through `table4.en.tsv`; Table 6's own cell there, - `0-1/4 stage 3`, is a third-order expansion opportunity named and set aside rather than - silently omitted), so `jlreq_line::feasible::same_run_refusal`'s own citation is the only - thing any of the six answers can be. The load-bearing pair reuses two existing fixtures - verbatim with `kind` changed to `feasible` and one candidate added: - `A.23/simple-ruby-complex/mono-ruby-twin`'s own input for the simple-ruby refusal and - `A.23/jukugo-ruby-complex/first-base`'s own input for the jukugo-ruby permission, which - (per `A.23/simple-ruby-complex/mono-ruby-twin`'s own rationale) differ in exactly one - declared field, `style` — so the two new cases' answers, `breakable: false` against - `breakable: true`, diverge over that one field alone, and an implementation that gave - every same-run `ruby` construct one always-refuse rule fails the second while passing the - first. `crates/jlreq-conform/tests/suite.rs`'s own committed census for `C.2` moves from - `[10 attempted, 0 not attempted]` to `[16 attempted, 0 not attempted]`. - - `docs/decisions/jukugo-ruby-unset-group.md`'s own closing paragraph is rewritten: - `C.2/two-base-characters-in-one-jukugo-ruby-complex/break-permitted` now exercises this - reading's own permissive outcome (both sides carry no group, exactly as `overlay_of` - always builds them, and the case asserts the break permitted), corroborating it rather - than merely being covered by the unit test the old paragraph named alone — but the - refusing half of the reading, two occurrences with *equal, declared* groups, still has no - case that can exercise it, since no case in this suite can declare a `GroupId` at all. - - `docs/conformance-deferrals.toml`: `C.2#8` moves from `[[deferred]]` to `[[owned]]` at M1 - (M1's deferred count: 4 → 3, M1's owned count: 40 → 41), naming the two new cases and - retracting the former entry's own stated blocker outright rather than merely closing it — - that entry named `ruby.runs`'s unread `base`/`annotation` pairing as what stood between - this note and a case; the actual reason is that the group level it would populate answers - a question no base-to-base candidate this crate can construct is asking at all, verified - against §3.3.7 and §3.1.10 item 8 directly rather than assumed from the prior entry's own - words. The new entry states the scope limit honestly: the note's own third sentence, - ruby-to-ruby indivisibility, is not measured and cannot be until `jlreq-inline` (M4-a). - `C.2#6`'s and `C.2#7`'s own `[[owned]]` entries are rewritten to name the four new cases - and keep the honest half each already carried — the A.21 and A.22 boundary cases they - named before remain unanswered, for the identical `CaseInput::construct_covers` reason. - `C.2#13`'s own entry, which this round also falsifies, is trimmed: its closing sentence - used to say `ornaments` had no `feasible` case and that §C.2#8 stayed deferred for - `ruby.runs`; both clauses are now false and are replaced with a pointer to the three - entries above that now state their own current answer directly. -- `jlreq-inline`, M4-a round 1: mono-ruby lowering, the first coherent slice of M4-a. The - crate is no longer a bootstrap; it depends on `jlreq-class`, `jlreq-spec` and `jlreq-unit` - (`ARCHITECTURE.md`'s own declared row) and declares `ruby.rs`, `lower.rs` and `tcy.rs`. - `Ruby::new` takes both the annotated text and the reading, validating that the base range - lies inside the text, every declared `RubyRun`'s base and annotation ranges lie inside - their own streams, the runs cover both in order without overlap, and the run count - matches what `RubyStyle::MonoRuby`, `RubyStyle::GroupRuby` or `RubyStyle::JukugoRuby` - requires; `Ruby::with_alignment` overrides `Question::RUBY_ALIGNMENT` per construct - (ADR 0019's precedence rule). `Constructs::over`/`with_ruby`, `Lowered` and `Contribution` - stand up the seam-facing half of `docs/design/api-spine.md`'s `jlreq-inline` section, and - `lower` genuinely computes all four of `Contribution`'s outputs for `RubyStyle::MonoRuby`: - a fresh `RunId` per base item (§3.3.5, §3.3.1's note — this is what gives two adjacent - annotated bases §E.2 note 6's own quarter-em expansion opportunity), a `BlockDemand` per - declared run from `Annotation::size_of` on the block-start side (§3.3.4), and a - `Separation` wherever a base's reading is genuinely longer than its own supplied advance - and the neighbor it would otherwise overhang resolves to cl-19 (§3.3.8 rule 1) — the - surplus split evenly between the run's two boundaries and, where two runs' own shares - land on one shared boundary, merged by the greater of the two rather than their sum - (`docs/decisions/mono-ruby-separation-split.md`, a new published reading: §3.3.5(a)'s own - centered geometry for nakatsuki, and, for katatsuki, its own second method's asymmetric - hangover choice has nothing left to choose among once every reachable neighbor is cl-19, - so the identical symmetric split survives under either alignment for this seam output). - `RubyStyle::GroupRuby` and `RubyStyle::JukugoRuby` get real run identity — one shared - `RunId` across a group-ruby's whole base range, one shared `RunId` across a jukugo-ruby - compound with a fresh `GroupId` per base item inside it (§B.2#11, §C.2#8) — and real block - demand, but no `Separation`: `Question::GROUP_RUBY_DISTRIBUTION` and - `Question::JUKUGO_RUBY_LAYOUT` (with Appendix F) are named as unfilled slots rather than a - citable zero. `Question::RUBY_OVERHANG_KANA` and `Question::RUBY_OVERHANG_INDENT` are - unfilled slots too, for mono-ruby's own narrower scope: only rule 1's absolute cl-19 - prohibition is answered, never the permitted overhang those two questions govern. - `lower` also resolves whether a per-construct or policy-default alignment is katatsuki in - horizontal writing — §3.3.5's own direction-conditional recommendation, honored regardless - and never refused (ADR 0011) — which is why it is the allowlisted `[[site]]` for §3.3.5 in - `docs/direction-sites.toml`, retiring that file's own `[[pending]]` entry for it. The - resolution is recorded rather than read once and dropped: `Contribution::alignment_of` and - `Contribution::alignment_discouraged` are this round's own carrier of ADR 0019's "every - answer records which of the two applied", pending a later round's `place()` or - `jlreq::diagnose`'s own `AlignmentDiscouraged` to make it a caller-facing report. - `TateChuYoko::new` states §3.2.5's own availability fact alone — no horizontal - tate-chu-yoko exists to refuse into `NotAvailable` otherwise — added specifically because - `docs/direction-sites.toml`'s own `[[pending]]` mechanism keys on whether a crate has - declared *anything*, not on which item will do the reading, so the moment `ruby.rs` - declared a `struct` both of `jlreq-inline`'s pending entries went stale at once; this round - retires the §3.2.5 one honestly, by implementing the one sentence of tate-chu-yoko that is - genuinely self-contained, rather than leaving it to lapse unrepaired or implementing the - segment `Constructs::with_tate_chu_yoko` would need, which stays absent — an - accepted-and-ignored `with_*` would be worse than the absence, so no such method exists and - `lower` never sees a `TateChuYoko`. `place()`, `Attachment`/`Attachments`, `RubyOverhang` - resolution, and the other eight constructs `docs/design/api-spine.md` names are unstarted, - named as such in `crates/jlreq-inline/src/lib.rs`'s own rewritten `# Status`. - `docs/conformance-deferrals.toml`'s `3.3.2`, `3.3.5`, `E.2#6`, `E.2#7` and `3.3.8` entries - are rewritten against this reality: §3.3.2 and half of §3.3.5 are genuinely read now but - still have no conformance case (no kind in this suite observes a `Contribution`, task #74); - the other half of §3.3.5, and all of §3.3.6/§3.3.7's own distribution, remain `place()`'s - later work; `E.2#6` and `E.2#7`'s own prior entries are corrected independently of this - round's own reachability, not only extended by it — `jlreq_class::resolve` never took a - construct parameter at all, so it was never accurate to say it "computes no run overlay - until `jlreq-inline` places ruby," and `crates/jlreq-conform`'s own `Compose::boundary` and - `Compose::compose` decline unconditionally over any declared construct regardless of run - identity, which is the actual reason neither note's own Table 6 coordinate is reachable by - a case yet; and `3.3.8`'s own `[[owned]]` entry now distinguishes its two halves — rule 1's - forced separation is a real, tested evaluator mechanism as of this round, rules 2 through 6 - remain entirely unattempted. `clippy.toml` gains `enum-variant-name-threshold = 4`, - reviewed and documented, so `RubyStyle`'s three JLReq-named variants (`MonoRuby`, - `GroupRuby`, `JukugoRuby`) do not trip `clippy::enum_variant_names` at the workspace's own - three-variant default. -- M4-a round 2: the `jlreq` → `jlreq-inline` facade edge, and the published conformance - format's seventh `kind`, `lower` — closing §3.3.5's own deferral and giving §3.3.8's own - `[[owned]]` entry its first genuine cases. - - `crates/jlreq/Cargo.toml` gains `jlreq-inline` as a dependency, and `crates/jlreq/src/ - lib.rs` re-exports its whole public surface (`Constructs`, `Contribution`, `LowerError`, - `Lowered`, `Ruby`, `RubyAlignment`, `RubyError`, `RubyRun`, `RubyStyle`, `TateChuYoko`, - `NotAvailable`, `lower`) in the same `pub use jlreq_*::{…}` shape the other five layers - already get — an edge `xtask/src/purity.rs`'s own `CRATE_GRAPH` and `ARCHITECTURE.md`'s - own crate-boundary table already sanctioned, so this is the edge existing, not a gate - changing. The crate's own `# What is here today` and `# Status` sections are rewritten - against what is actually true now: six layers rather than five are re-exported; the - reduction, hanging and expansion ladders `jlreq_line::ladder` implements are no longer - named as unfilled slots; "every construct-bearing input is `jlreq-inline`'s, which does - not exist yet" is repaired to state precisely what is real (mono-ruby lowering) and what - is not (placement, the other eight constructs); and the `diagnose` sentence, which used - to read as though the function should exist now that the crate that carries the - constructs has arrived, is repaired to name it as still unwritten. - - `crates/jlreq-conform/cases.schema.json`: `input.kind`'s enum gains `"lower"`, with a - paragraph beside `feasible`'s own stating what a `lower` case asks — not a line-layer - question at all, but what `jlreq_inline::lower` resolved for one declared `ruby` - construct — and that it requires `constructs` and reads none of `measure`, `candidates`, - `alignment`, `tab_starts` or `tab_stops`. A new `$defs/lower` (`construct`, `same_run`, - `separations`, `alignment`, `alignment_discouraged`, `rules`) sits beside `$defs/ - feasible`, with `$defs/same_run` and `$defs/lower_separation` beside it — `same_run` an - object (`{ "items": [i, j], "same": bool }`) rather than a bare triple, this format's own - established practice; `separations` a *total* list, `boundary.spaces`'s own convention, - so a case stating one entry asserts both that it exists and that the answer carries no - other; `least` a bare unit count rather than a `$defs/amount` fraction, because unlike - Table 1's own terms this amount is not a fraction of an em JLReq states anywhere. No - alignment-override field is added to `$defs/ruby`: ADR-0019's per-construct-beats-policy - precedence is this workspace's own bookkeeping, not something JLReq states, and a case - asserting it would measure kumihan's own API rather than the specification — it stays - covered by `crates/jlreq-inline/src/lower.rs`'s own unit tests, and a `lower` case - selects between the two alignments through `permitted[].policy`'s own `ruby.alignment` - overlay instead. `$defs/constructs`'s and `boundary.rules`'s own descriptions are - repaired: `lower` joins `feasible` as a kind `constructs` is load-bearing for, and the - twelve pre-existing `A.16.json`/`A.22.json` boundary-`rules` declarations are still not - live — not because `jlreq-inline` does not exist, which is no longer true, but because - `Compose::boundary` still declines outright over any construct-covered item, exactly as - it did before this round and for an unrelated reason. - - `crates/jlreq-conform/src/case.rs`: `KINDS` grows to seven. `Expect` gains - `lower: Option`, read by `read_lower`, and `Expect::is_silent` checks it - too. `ExpectLower`, `ExpectSameRun` and `ExpectLowerSeparation` are the new types. - `CaseConstruct` gains `annotation` and `runs` (a new `CaseRun`), read from a `ruby` - entry's own fields — the part `Compose::lower`'s own adapter needs and `Compose:: - feasible`'s never did, on this module's own "read here rather than reach into the raw - JSON a second time" principle. - - `crates/jlreq-conform/src/run.rs`: `Compose` gains a seventh method, `lower`, required - rather than defaulted for the identical reason `feasible` already is — a breaking change - to a published trait, exactly as adding `feasible` was. `CaseLower` (`runs`, - `separations`, `alignment`, `alignment_discouraged`, `rules`) answers it; `Answer::Lower` - and `ask`'s own `"lower" =>` arm route it, distinctly from `align`'s and `tab`'s reuse of - `Answer::Composed` — one construct's own run identity, forced spacing and resolved - alignment is nothing like a composed line. `check_lower` compares `same_run` against the - answer's own per-item run identity, `separations` as a total list (`check_spaces`'s own - convention), `alignment`/`alignment_discouraged` by equality when stated, and `rules` - through the same `check_rules` `boundary.rules` and `feasible.rules` already share. Every - "six methods"/"six questions" claim in this module's own docs, including the `Answer` - enum's own "four variants for six questions" and the wildcard-arm hazard prose in `ask`'s - own doc, is repaired to seven and five respectively, with `lower`'s own hazard stated - beside `feasible`'s. - - `crates/jlreq-conform/src/kumihan.rs`: `Compose::lower` is the second method that does - not inherit the construct-blindness `classify`, `boundary`, `compose`, `align` and `tab` - all share, and it is not a milder version of `feasible`'s own exception — it never calls - `jlreq_class::resolve` or any `jlreq_line` entry point at all, only `jlreq::lower` - (`jlreq_inline::lower`) directly. Three new staged helpers build the real `jlreq::Ruby` - slice a case's declared `constructs.ruby` describe — `annotation_streams_of`/ - `annotations_of` (a two-phase read into `jlreq_class::Annotation`, staged because an - `Annotation` borrows its items and scales and a temporary cannot outlive it) and - `ruby_runs_of`/`rubies_of` (the identical staging for `jlreq::RubyRun`, which `jlreq:: - Ruby::new` also borrows) — declining the whole case the moment any declared construct is - not `ruby`, `jlreq::Ruby::new` refuses one (`RubyError`), or `jlreq::lower` itself refuses - the result (`LowerError`). The module's own doc is rewritten: "every kind but `feasible` - either declines outright... or declines per item" is repaired to name `lower` as the - second exception, and states precisely why its own exception is a different shape from - `feasible`'s rather than a milder version of it. - - `xtask/src/conform.rs`: `INPUT_KINDS` grows to seven; `check_input` requires - `constructs` (never `measure` or `candidates`) of a `lower` case; `check_question` holds - it to the identical "one input, one question" invariant `classify`, `boundary` and - `feasible` already are, keyed on `expect.lower.construct`. `kind_census` reports the new - kind's count, and a `MINIMAL_LOWER` fixture plus - `the_kind_census_line_counts_a_lower_case_by_its_own_kind` mirror the identical `feasible` - precedent (round 20's own `optimal_search_census` pattern, applied a third time). - - `crates/jlreq-conform/tests/suite.rs` gains `section_3_3_5` (`[2 attempted, 0 not - attempted]`) and `section_3_3_8` (`[2 attempted, 0 not attempted]`). - - `docs/design/conformance.md` gains the seventh trait method and `CaseLower` in the same - voice as the rest of the document; every stale "six methods"/"six questions" claim is - repaired to seven, and `crates/jlreq-conform/src/lib.rs` newly re-exports `CaseLower`, - `ExpectLower`, `ExpectLowerSeparation`, `ExpectSameRun` and `CaseRun` at the crate root. - - `crates/jlreq-conform/cases/3.3.5.json` (new): two `lower` cases closing §3.3.5's own - deferral. `ruby-alignment/policy-selects-nakatsuki-or-katatsuki` asserts - `Contribution::alignment_of` against both of `Question::RUBY_ALIGNMENT`'s choices; - `katatsuki-in-horizontal-writing/discouraged-but-honored` asserts `Contribution:: - alignment_discouraged` against the section's own "should not be adopted" recommendation - — honored and reported, never refused (ADR-0011), with the resolved alignment staying - katatsuki rather than silently reverting. Both cases' own katatsuki-selecting entries are - published and checked but not genuinely exercised by this workspace's own committed run: - `crates/jlreq-conform/tests/suite.rs` only ever constructs `Kumihan::default()`, whose - declared `ruby.alignment` is `Policy::JLREQ`'s own nakatsuki default, so the katatsuki - entries are statements to another implementation that declares the alternative (ADR - 0006) — `crates/jlreq-inline/src/lower.rs`'s own `katatsuki_is_honored_and_discouraged_ - only_in_horizontal_writing` unit test is what exercises them directly. `docs/ - conformance-deferrals.toml` moves §3.3.5 from `[[deferred]]` to `[[owned]]` at M4, - stating this scope limit plainly and naming `place()` (task #78) as the section's own - remaining, unstarted half. - - `crates/jlreq-conform/cases/3.3.8.json` (new): two `lower` cases giving §3.3.8's own - `[[owned]]` entry its first genuine coverage of rule 1's forced separation. - `forced-separation/only-beside-ideographic-neighbors` (`standing: "normative"`) asserts - existence and absence together — one oversized mono-ruby construct beside a cl-19 - neighbor forces a separation, an equally oversized construct beside a hiragana neighbor - forces none — with no asserted amount, since rule 1 states the prohibition and no - arithmetic. `forced-separation/even-split-by-remainder-policy` (`standing: "unstated"`) - asserts the even-split amount `docs/decisions/mono-ruby-separation-split.md` reads, one - mono-ruby construct between two cl-19 neighbors with an odd surplus, both `adjustment. - remainder` readings published side by side and neither asserted as JLReq's own - requirement — the discipline the §E.2#11 deferral already argues for a different - coordinate, applied here on purpose. `docs/decisions/mono-ruby-separation-split.md`'s - own closing sentence, which promised this task as the phase that would first exercise - its reading against a published case, now names both cases by id in the past tense. - - `docs/conformance-deferrals.toml`'s other stale `why` fields, repaired against what this - round's own reading of `spec/snapshot/index.html` and `crates/jlreq-unit/src/seam.rs` - finds rather than against what a prior round assumed: §3.3.2's own former reasoning (that - the only blocker was no conformance kind observing a `Contribution`) is retracted rather - than merely closed — the section's own body is entirely editorial choices an author makes - before ever declaring a `Ruby` (general-ruby, para-ruby, and para-ruby's own first- - instance variants), upstream of anything `lower` computes, with one mechanizable residue - (the compound-word recommendation) that needs jukugo lowering and `jlreq::diagnose`, - neither of which exists. §3.3.4's own former reasoning (that the physical side was "one - answer `jlreq-inline` produces… from a single rule") is replaced with the actual, verified - blocker: `jlreq_unit::BlockDemand::new`'s own doc defines its first extent as - direction-abstract — "toward the ruby side," never "above" or "to the right" — and `lower` - calls it identically at all three of its own call sites for every ruby style, so "start - extent non-zero, end extent zero" is structurally true of every demand this crate will - ever emit regardless of whether §3.3.4 was ever read, an observable that would pass - whether or not the mechanism existed. §E.2#6's entry is repaired to say that two kinds now - read a case-declared overlay (`feasible` since M1 round 11, `lower` new this round), not - one, while stating precisely why neither reaches Table 6's own expansion amount, which is - what the note is about. §3.3.8's `[[owned]]` entry states which two cases now measure rule - 1's forced separation, replacing the "no kind in this suite observes one" clause this - round falsifies. -- M4-a round 3: `jlreq_inline::place`, the placement half of §3.3.5 (task #78). New, - additive `pub fn place`, `Attachment` and `Attachments` on `jlreq-inline`, re-exported - from `jlreq`; neither `Lowered` nor `Constructs` changes shape in a way any existing - caller can observe (`Lowered` gains two `pub(crate)` buffers `lower()` never touches). - - `place()` genuinely computes three of §3.3.5's four positioning cases for - `RubyStyle::MonoRuby`: nakatsuki (中付き) centering, including a run genuinely longer - than its base, where the centering difference and its two shares go negative and the - run starts before its own base's placement; and katatsuki (肩付き) start-alignment - where the run is not longer than the base. §3.3.5(a)'s own two-hiragana-exactly-fills- - the-base case is not a fifth branch — at that ratio both alignments agree without - either reading a character count, and a unit test demonstrates the agreement falling - out rather than being special-cased. - - §3.3.5(c)'s own katatsuki-with-overflow choice — the section states two methods for it - in so many words, and no `Question` in `spec/derived/questions.tsv`'s own §3.3.5 - neighborhood resolves between them — is genuinely declined rather than guessed at: - `place()` emits no `Attachment` for such a run and reports it through the new - `Attachments::declined` instead. Giving that choice a policy `Question` is task #81, a - round of its own by design. - - `docs/design/api-spine.md`'s own `overhang: &[RubyOverhang]` parameter is a deliberate - omission this round, argued in `jlreq_inline::place`'s own module doc and reflected - back into the spine's sketch: nothing this round's three positioning cases reads a - per-boundary allowance, and an accepted-and-unread parameter is the silent defect this - crate already refuses elsewhere. The parameter returns at task #81, its first genuine - consumer. - - `Attachment::side` answers `Side::BlockStart` for every attachment this round produces, - and `Attachment::block` answers `BlockOffset::ZERO` for a different reason — this - signature carries no block-axis reference frame at all — and both accessors' own docs - say so plainly rather than let the constant answer read as §3.3.4 settled. §3.3.4 - stays deferred to M4; its `docs/conformance-deferrals.toml` entry is repaired to say - that `place()` now exists and still cannot state a physical side, the structurally- - constant trap the entry already predicted rather than one it has now closed. - - No `place` conformance kind and no case JSON this round (ADR-0006: implementation and - conformance are separately authored phases; task #80 is the latter). - `docs/conformance-deferrals.toml`'s §3.3.5 `[[owned]]` entry is repaired to state - precisely what moved — three of four positioning cases implemented, one declined and - why, and that no conformance case observes any of the placement half yet — rather than - naming `place()` as unstarted, which this round falsifies. - - `docs/conformance-deferrals.toml`'s §3.3.8 `[[owned]]` entry closed with a forward - reference to "placement's own later work (task #78)" for rules 2 through 6's own - overhang permissions over kana, half-em spaces, inseparable characters and brackets - (`Question::RUBY_OVERHANG_KANA`, `Question::RUBY_OVERHANG_INDENT`). That reference is - repaired now that task #78 has shipped and, per `place()`'s own module doc, deliberately - reads neither question — the identical falsified-forward-reference repair its sibling - §3.3.4 entry already received, missed for this entry the first time through. - - `docs/decisions/mono-ruby-separation-split.md`'s own "Applies to" line now names - `jlreq_inline::place` alongside `jlreq_inline::lower`: the centering difference - `place()` splits and the §3.3.8 rule 1 surplus `lower()` splits are the identical - `distribute(_, &[one(), one()], _)` question asked of two different inputs, not two - readings, so `place()` cites this file rather than arguing the point a second time. -- M4-a round 4: the published conformance format's eighth `kind`, `place` — the independent - conformance phase for `jlreq_inline::place` (task #80) — plus the falsifiable `same_run` - `lower` case the harness carried unexercised since M4-a round 2. - - `crates/jlreq-conform/cases.schema.json`: `input.kind`'s enum gains `"place"`, with a - paragraph stating what a `place` case asks — not `lower`'s own alignment question - restated, but what `jlreq_inline::place` computes once that alignment is read and - consumed — and that the line layout `place` positions each attachment against is - *derived* from the case's own declared item advances and `lower`'s own forced §3.3.8 - rule 1 separations rather than accepted as a further caller-declared field, stating why - in full: a caller-declared `placements` array could assert a relationship between two - numbers the case itself invented, with nothing in the format able to catch the two - disagreeing — the "measuring nothing" failure §D.2#4 forbids, and a subtler one than a - stated scope limit because it would look like a stronger assertion than it is. A new - `$defs/place` (`attachments`, `declined`) and `$defs/attachment` (`inline`, `item`) sit - beside `$defs/lower`; `$defs/place` states plainly that it carries no `rules` field, - because `Attachments` publishes none (ADR-0019), so a later reader does not add one back - as an oversight. `$defs/constructs`'s own description repairs "the two kinds this object - is load-bearing for" to three. - - `crates/jlreq-conform/src/case.rs`: `KINDS` grows to eight. `Expect` gains - `place: Option`, read by `read_place`, and `Expect::is_silent` checks it - too. `ExpectPlace` and `ExpectAttachment` are the new types, both `size`/`side`/`run`/ - `construct`-free by design — `cases.schema.json`'s own `attachment` description states - why each is left out. - - `crates/jlreq-conform/src/run.rs`: `Compose` gains an eighth method, `place`, required - for the identical reason `lower` already is — but shaped like `align`/`tab`/`compose` - rather than `boundary`/`feasible`/`lower`: `place` answers the whole call, not one - occurrence of it, so it takes no ordinal, and its own doc states why inventing one would - invent a selector `place()` does not have. `CasePlace` and `CaseAttachment` answer it; - `Answer::Place` and `ask`'s own `"place" =>` arm route it, and a misrouting regression - pair — `a_place_case_reaches_compose_place_and_not_compose_compose`, - `a_place_case_with_no_place_answer_is_not_attempted_even_though_compose_has_one` — - proves the hazard the identical pair already proved for `lower`. `check_place` compares - `attachments` as a total list (`check_lower_separations`'s own convention) and `declined` - by full-list equality, asserting the specific declined construct ordinal rather than - merely its non-emptiness — `a_place_declined_expectation_names_the_specific_construct_ - ordinal` pins it. Every stale "seven methods"/"seven questions" claim in this module's - own docs is repaired to eight. - - `crates/jlreq-conform/src/kumihan.rs`: `Compose::place` is the third method that does not - inherit `classify`'s, `boundary`'s, `compose`'s, `align`'s and `tab`'s construct- - blindness, reusing `lower`'s own front half verbatim through an identical `jlreq::lower` - call, then deriving the line layout `jlreq::place` positions against: `derived_placements` - sums the declared item advances and every forced separation that `lower` call resolved - before each item, honest to the case's own data rather than a caller-declared restatement - of it — its own doc states the derivation's honesty requirement (faithful only where every - interior boundary of the declared stream is Table 1 `blank`) and that no case this round - publishes exercises its own separations term. `docs/scalar-sites.toml` gains one entry for - it, the bridge from a case's own plain unit counts to the `InlineOffset` sequence - `jlreq::place` reads as `placements`. - - `xtask/src/conform.rs`: `INPUT_KINDS` grows to eight; `check_input` requires `constructs` - of a `place` case, merged into `lower`'s own existing match arm since the two share the - identical requirement (`clippy::match_same_arms`); `check_question` holds `place` to its - own `expect.place` field with no ordinal, `align`'s and `tab`'s own empty-ordinal shape - rather than `boundary`'s, `feasible`'s and `lower`'s. `kind_census` reports the new kind's - count. - - `crates/jlreq-conform/cases/3.3.5.json` gains four `place` cases closing the placement - half of §3.3.5's own `[[owned]]` entry: `one-character-nakatsuki-vs-katatsuki` (§3.3.5(b), - the load-bearing pair — the same run, two resolved offsets, one base item so the interior - boundary is vacuous), `two-characters-exactly-filling-the-base` (§3.3.5(a), - alignment-independent by construction, published as one `permitted` entry rather than two - identical ones), `three-characters-longer-than-the-base` (§3.3.5(c), both nakatsuki's own - negative-share centering over a verified-blank cl-15/cl-19 Table 1 coordinate chosen so no - §3.3.8 rule 1 separation entangles the derivation, and katatsuki's own decline, asserting - the specific declined construct ordinal), and `group-ruby-placement/produces-no- - attachment-and-is-not-declined` (the boundary between this rule's own reach and §3.3.6's, - measured from outside it). The two existing `lower` cases' own rationales are repaired: - both once asserted their katatsuki entry "is not genuinely exercised by this workspace's - own committed test run," falsified by this round's own `crates/jlreq-conform/tests/ - suite.rs` addition below. - - `crates/jlreq-conform/tests/suite.rs` factors `measure` into `measure`/`measure_against` - and adds `section_3_3_5_is_also_measured_under_katatsuki`, a second run of `3.3.5.json` - against a `Kumihan::new(Policy)` declaring `ruby.alignment: katatsuki` — under which every - katatsuki `permitted` entry in that file is the selected reading rather than `{}`, - genuinely exercised rather than only published. `section_3_3_5`'s own row moves to - `[6 attempted, 0 not attempted]` (the two existing `lower` cases plus the four new `place` - cases, none of which decline under either policy — decline conditions read no policy at - all, so the census is identical under both runs and only which entry is selected moves). - - `crates/jlreq-conform/cases/A.22.json` gains `run-identity/group-ruby-shares-a-run-mono- - ruby-does-not`, the falsifiable `same_run` `lower` case the harness carried unexercised - since `lower.same_run`, its reader and `check_same_run` first shipped (M4-a round 2) — - grounded in §B.2 note 10's own "the same... run" / "two distinct... runs" language for - cl-22 (simple-ruby, mono-ruby together with group-ruby, §3.3.7's own closing Note), not in - §3.3.5, whose own subject this fact is not: `RubyStyle::MonoRuby` allocates a fresh - `RunId` per base character by definition (§3.3.1's own note, the E.2#6 quarter-em - opportunity between 鬼 and 門), so two adjacent base characters of a *declared mono-ruby - construct* never share a run, whichever JSON shape declares them — only group-ruby (or - jukugo-ruby) allocates one shared run across a base range. The case's one input carries - both halves at once: two items under one `group`-ruby construct (`same: true`), two more - under two separate `mono`-ruby constructs (`same: false`). Neither `B.2#10` nor `C.2#7` - moves off `[[owned]]`; both were already there. `appendix_a_22`'s own row moves to - `[2 attempted, 11 not attempted]`. - - `docs/conformance-deferrals.toml`'s §3.3.5 `[[owned]]` entry is rewritten a second time: - the alignment question is now genuinely *exercised* under both readings, not only - published and checked; three of §3.3.5's four positioning cases are now cased, and the - fourth — §3.3.5(c)'s own katatsuki-with-overflow choice — is cased as a decline, - asserting the specific construct ordinal, pending task #81 for the `Question` that would - resolve it in full. - - `crates/jlreq-inline/src/place.rs`'s own "What is not here" paragraph and - `crates/jlreq-inline/src/lib.rs`'s own `# Status` are both repaired: the `place` - conformance kind this round authors is no longer a forward reference to task #80, and - both name the four cases and the `Attachments` observable directly. -- M4-a round 5: `RubyStyle::GroupRuby` placement, §3.3.6 paragraphs 1 and 2 (task #84). - `jlreq_inline::place` gains a real `RubyStyle::GroupRuby` branch — additive, no change of - shape to any existing public item, and every existing mono-ruby offset unchanged, because - the new geometry lives in sibling functions (`place_group_run`, `place_group_solid_run`) - rather than in a generalization of `place_solid_run`. - - `place_group_run` genuinely computes §3.3.6's own ruby-not-longer-than-base half, over - both of `Question::GROUP_RUBY_DISTRIBUTION`'s answers: `jis`, a `[1, 2, 2, …, 2, 1]` - proportional split over `n + 1` sites (`group_jis_weights`), read as §3.3.6's own "2 - units of inter-character spacing... 1 unit" ratio; and `flush`, a fixed - `InlineExtent::ZERO` leading offset with an equal split over the `n - 1` interior sites - alone (`group_flush_weights`), honoring the method's own leading clause by construction - rather than by a zero-weight site — `jlreq_unit::distribute`'s own remainder machinery - hands units out across every site a weights slice names, zero-weighted or not, so a - zero-weight site would not have stayed zero. Both methods read the base run's own extent - from a composed line's own `placements` (`extent_between`, a new `docs/scalar-sites.toml` - entry), not from a re-derived sum of item advances, so the two never silently disagree - when composition has genuinely widened the base elsewhere on the line. Paragraph 1 (equal - length) is not a third branch — at zero surplus both methods place the run flush with the - base's own start regardless of weight shape, the ratio paragraph 2's own arithmetic - degenerates to. An unrecognised `Question::GROUP_RUBY_DISTRIBUTION` answer name falls to - `jis`, every one of `Policy`'s five presets' own answer. - - Paragraph 3 (ruby longer than base) is declined, not implemented: both of its own methods - spread the *base* characters apart, which `place` structurally cannot do — `placements` - is already fixed by the time `place` runs, and it emits `Attachment`s for annotation items - only. A `RubyStyle::GroupRuby` run whose ruby is genuinely longer than its base is - reported through `Attachments::declined` instead, exactly the discipline §3.3.5(c)'s own - katatsuki-with-overflow choice already established; the fix belongs to - `jlreq_inline::lower::lower_group`, which would need to emit forced `Separation`s before - composition ever sees the base run, the mono-ruby analogue `collect_mono_separation` - already performs for §3.3.8 rule 1 — a future round's work, not this one's. - `Attachments::declined`'s own published meaning widens accordingly: it is no longer - reserved for §3.3.5(c)'s choice alone, and its own doc, and `crate::place`'s own module - doc, both now enumerate the two reasons a run reaches it. Jukugo-ruby remains a *third*, - different kind of absence — never placed at all, never declined, because no weighing ever - happened for a style this round's code simply does not touch. - - The Note attached to §3.3.6 paragraph 2 — a criterion capping the leading/trailing - spacing at one to one-and-a-half ruby ems before `jis`'s own appearance turns misleading — - states two thresholds in one parenthesis rather than one, so it is named as a declared - slot in `crate::place`'s own module doc rather than wired to an invented number; closing - it needs a policy `Question` of its own or a `docs/decisions/` reading, neither built yet. - - `docs/decisions/group-ruby-flush-single-character.md`, a new published reading - (`Standing::Unstated`): what `flush` does for a run of exactly one ruby character, whose - leading and trailing clauses name the same character at once and whose "rest" to space is - empty. The reading holds that the run starts at the base's own start with the surplus - applied nowhere — falling out of `group_flush_weights`' own empty slice at `count == 1` - rather than a special case — and argues against falling back to `jis`'s own centering, - which would erase the very divergence between the two methods §3.3.6 states them for. - Confirmed direction-independent; no `docs/direction-sites.toml` entry follows. - - `docs/scalar-sites.toml` gains two `jlreq-inline` entries: `two` (`lower.rs`, the `jis` - method's own interior weight, twice `one`'s own — a different item from `one`, so it - needs its own reviewed entry) and `extent_between` (`place.rs`, the base run's own extent - read back from two already-resolved placements, `jlreq_line::tab::distance_to`'s own - crossing one crate over). - - `crates/jlreq-conform/cases/3.3.5.json` loses `3.3.5/group-ruby-placement/produces-no- - attachment-and-is-not-declined`, deleted rather than retargeted: its own fixture (a - 1000-unit base against two 500-unit ruby characters, surplus exactly zero) now places two - real attachments under this round's own §3.3.6 paragraph 1 arithmetic, falsifying the - case's own premise that group-ruby produces no attachment. `crates/jlreq-conform/tests/ - suite.rs`'s own `section_3_3_5` and `section_3_3_5_is_also_measured_under_katatsuki` move - from `[6 attempted, 0 not attempted]` to `[5 attempted, 0 not attempted]`. Retargeting the - case, or authoring §3.3.6's own cases, is task #85's — ADR-0006's separately-authored - conformance phase, not this implementation round's; §3.3.6 stays `[[deferred]]` in - `docs/conformance-deferrals.toml`, whose own entry is rewritten to state exactly what - moved (the implementation) and exactly what did not (a conformance case naming 3.3.6). - `docs/conformance-deferrals.toml`'s own §3.3.5 `[[owned]]` entry is repaired to match: - three of task #80's own four cases survive unchanged, and the fourth's own deletion is - stated and reasoned rather than silently dropped from the count. - - Every stale "unfilled slot" claim about `Question::GROUP_RUBY_DISTRIBUTION` this round - falsifies is repaired: `crates/jlreq-inline/src/lower.rs`'s own module doc, `one`'s own - doc (now naming its four consumers rather than two), `sum_advances`' own doc (three - questions rather than two), `Lowered::declined`'s own field doc, `lower_group`'s own doc - and its "Recording §3.3.6 here..." comment (reworded to state that `lower_group` still - computes none of this — placement does — rather than that the geometry does not exist, - and that `place` itself still records no `RuleId` either, ADR-0019), and its own test's - assertion messages; `crates/jlreq-inline/src/ruby.rs`'s own `RubyStyle::GroupRuby` doc; - `crates/jlreq-inline/src/lib.rs`'s own `# Status`; and - `docs/design/api-spine.md`'s own `Attachments` sketch, whose `declined` description named - only §3.3.5(c) and now names both reasons. -- M4-a round 6: the §3.3.6 group-ruby placement conformance cases (task #85), ADR-0006's own - separately-authored phase for M4-a round 5's own implementation. No logic change to - `crates/jlreq-inline/src/place.rs` — every number below was derived by hand from §3.3.6's - own words and this round's own fixture advances, never read out of the implementation, its - `#[cfg(test)]` module or a debug run. - - `crates/jlreq-conform/cases/3.3.6.json`, four cases naming rule `3.3.6`: - `group-ruby-placement/equal-length-both-methods-agree` (paragraph 1, one `permitted` entry - because `jis` and `flush` are not two readings at zero surplus but one, the deleted - `3.3.5/group-ruby-placement/produces-no-attachment-and-is-not-declined` case's own - fixture reused as this section's own affirmative case); `group-ruby-placement/jis-versus- - flush-distribution` (paragraph 2 at four ruby characters over a two-item, cl-19/cl-19 - Table-1-verified-blank base — `spec/captured/table1.en.tsv` line 485 — every one of the - run's four offsets genuinely differing between the two methods, the load-bearing pair this - file exists to publish); `group-ruby-placement/single-ruby-character-jis-vs-flush` - (paragraph 2 at exactly one ruby character, standing `unstated` rather than `alternative`: - `jis` is still directly derivable from the ratio sentence, but `flush` is not, and rests on - `docs/decisions/group-ruby-flush-single-character.md`'s own published reading instead); - and `group-ruby-placement/ruby-longer-than-the-base-declines` (paragraph 3, asserting - `declined: [0]` rather than merely `attachments: []`, naming the specific declined - construct ordinal the way `3.3.5/mono-ruby-placement/three-characters-longer-than-the-base` - already does). Every fixture's per-end surplus stays comfortably under one ruby em, clear - of paragraph 2's own unimplemented Note. - - `crates/jlreq-conform/tests/suite.rs` gains a `section_3_3_6` `per_section!` row and a - second test, `section_3_3_6_is_also_measured_under_flush`, on `section_3_3_5_is_also_ - measured_under_katatsuki`'s exact model: a second `Kumihan::new(Policy)` declaring `ruby. - group_distribution: flush`, under which the runner's own selection rule picks the `flush` - entry of every case naming one. Both tests carry an identical `[4 attempted, 0 not - attempted]` census — `place()`'s own decline conditions are extent comparisons made before - either alignment question is ever read, so no case becomes unanswerable under either - policy, and only which permitted entry is selected moves. - - `docs/conformance-deferrals.toml`: `3.3.6` moves from `[[deferred]]` to `[[owned]]`, its - `why` naming the four cases, the genuinely-exercised `flush` reading, and the honest scope - limit — paragraph 3 stays a cased decline rather than an implementation, and paragraph 2's - own Note stays cased nowhere, because its own parenthesis states two thresholds rather - than one and closing it needs a policy `Question` or a `docs/decisions/` reading that - does not exist yet. `3.3.5`'s own `[[owned]]` `why` is repaired to match: the dangling - promise that task #85 might author a jukugo-shaped replacement for the deleted fourth case - is resolved, and it resolves to a decline — `crates/jlreq-inline/src/place.rs`'s own - `RubyStyle::JukugoRuby` dispatch still never reaches `place_mono_run` or `place_group_run` - and never appears in `Attachments::declined` either, verified against the code rather than - assumed, so a jukugo-shaped `place` case would assert `attachments: []` alongside - `declined: []` — satisfiable by an implementation that never implemented anything at all, - the exact §D.2#4 trap this project's own discipline refuses to publish as coverage. What - such a case would have asserted belongs to §3.3.7's own deferral instead, not to §3.3.5 or - §3.3.6. - - Every stale claim this round falsifies is repaired in place, description text only, no - field or type changed: `crates/jlreq-conform/cases.schema.json`'s own `place` `$def`, its - `kind` description and its `declined` property description all once said `Attachments:: - declined` names only a mono-ruby run's own katatsuki-with-overflow choice; all three now - name group-ruby's own ruby-longer-than-base half too, which this round's own fourth case is - the first published case to exercise. `crates/jlreq-conform/src/run.rs`'s own `Compose:: - place` doc, `CasePlace`'s own doc and `CasePlace::declined`'s own field doc, and `crates/ - jlreq-conform/src/case.rs`'s own `ExpectPlace` doc and `ExpectPlace::declined`'s own field - doc, are repaired the same way. `crates/jlreq-inline/src/place.rs`'s own "What is not - here" section is rewritten to record that §3.3.6 now has a conformance case and moved to - `[[owned]]`, rather than stating it still does not. `crates/jlreq-conform/src/kumihan.rs`'s - own module doc is repaired in two places: `place` is credited with §3.3.6's own geometry - alongside §3.3.5's, and the "every multi-item `place` case this round publishes" and "no - case this round publishes" sentences are reworded to name the suite rather than a round — - durable now that `3.3.6.json`'s own second case is this suite's second multi-item `place` - fixture, and still true that none exercises the separations term of the derivation: - group-ruby's own base boundary here is independently blank in Table 1 *and* `lower_group` - itself still emits no `Separation` for a group-ruby run against any neighbor, either fact - alone already sufficient. `crates/jlreq-inline/src/lib.rs`'s own `# Status` carried the - identical stale claim as `place.rs`'s "What is not here" section above and was missed in - the first pass — this round's own review caught it before the gate battery could hide it — - so it is now repaired the same way, stating that task #85 has since run and named cases and - moved the rule to `[[owned]]` rather than that this round's own group-ruby geometry is - implemented but not yet cased. -- M4-a round 7: `RubyStyle::JukugoRuby` placement, both of §3.3.7's own paragraphs, wiring - `Question::JUKUGO_RUBY_LAYOUT` (task #88). No conformance case authored; §3.3.7 stays - `[[deferred]]`, on ADR-0006's own discipline that an implementation round does not move - its own rule to `[[owned]]`. - - Paragraph 1 ("two or fewer ruby characters per base") delegates each declared run, - unmodified, to the identical `place_mono_run` a `RubyStyle::MonoRuby` construct itself - calls — decline included, so a jukugo run whose ≤2-character reading still overflows its - base under katatsuki declines exactly as an ordinary mono-ruby run does. The ≤2 count is - read directly off each run's own declared annotation width, a genuine character count - rather than an extent comparison: unlike §3.3.5(a)-through-(c), paragraph 2's own - condition is "needs three or more ruby characters," not "is longer than its base," so a - wide-enough base character could carry three narrow ruby characters without ever - outrunning it. - - `crates/jlreq-inline/src/lower.rs`'s own alignment resolution is hoisted to cover - `RubyStyle::JukugoRuby` alongside `RubyStyle::MonoRuby`: without this, `Contribution:: - alignment_of` would answer `None` for a jukugo construct and `place_mono_run`'s own - `let Some(alignment) = ... else { return; }` would place nothing at all, silently, the - moment paragraph 1's own condition held. The `RuleId::POSITIONING_OF_MONO_RUBY_WITH_ - RESPECT_TO_BASE_CHARACTERS` citation stays mono-only — that citation is `crate::place`'s - to give once it has actually decided paragraph 1 governs a construct, a decision `lower` - never makes. Settled along the way: §3.3.5's own discouraged-katatsuki-in-horizontal- - writing flag transfers to a jukugo construct wholesale, on paragraph 1's own delegation to - "the method described in § 3.3.5" without qualification — §F's own stated assumption of a - katatsuki baseline governs a different method (the `phonetic` answer, declined below) and - has nothing to unsettle for a paragraph-1 construct. - - Paragraph 2 ("attach the ruby text to the kanji compound word as a whole") builds one - compound-wide synthetic `RubyRun` — the whole declared base range, against the first - declared run's own annotation start through the last's own end, `Ruby::new`'s own - `check_runs` contiguity invariant guaranteeing the span is the compound's whole reading — - and hands it to `place_group_run`, which gains an explicit `jis: bool` parameter in place - of its own former internal `Question::GROUP_RUBY_DISTRIBUTION` read (moved to its one - prior call site, `RubyStyle::GroupRuby`'s own arm in `place`, so that style's own - behavior is unchanged, byte for byte). `Question::JUKUGO_RUBY_LAYOUT`'s own `group` - answer passes `true` unconditionally — forcing `jis` regardless of the document's own - `Question::GROUP_RUBY_DISTRIBUTION` answer — the published reading of a genuinely - unstated question (`docs/decisions/jukugo-group-layout-distribution.md`): §3.3.6 itself - names exactly one of its own two methods "the method specified in JIS X 4051," twice, and - never its own "another way"; §3.3.7¶2's own "the layout as specified in JIS X 4051" cites - that identical, specific method, and its own "which is similar to the group-ruby method - described in § 3.3.6" is a comparison orienting the reader, not a second instruction - reopening the choice the first clause already closed by name. Reusing `place_group_run` - reuses its own ruby-longer-than-base decline too — the jukugo analogue of §3.3.6 - paragraph 3's own base-spreading blocker, structurally unclosable from `place` for the - identical reason group-ruby's own half is. `Question::JUKUGO_RUBY_LAYOUT`'s own - `phonetic` answer declines every compound it reaches, unconditionally: §F's own - phonetic-structure distribution is not implemented this round, not one part of it. - - A jukugo compound's own base range can straddle one `place` call's own `items` in a way - `RubyStyle::GroupRuby`'s own base range structurally cannot — §C.2#8's own second - sentence permits a break between two base characters of one jukugo complex, and - `lower_jukugo` gives the compound one shared `RunId` but a *fresh* `GroupId` per base - item precisely so that break survives (`docs/decisions/jukugo-ruby-unset-group.md`'s own - reading of `same_run_refusal` is what confirms `jlreq-line` actually permits it). Such a - straddle declines rather than silently skipping the way an ordinary out-of-range - group-ruby run does: paragraph 2's own "as a whole" instruction has no whole left to - attach once the line has split the compound, and JLReq states no method for that case. A - compound split across two lines is consequently declined twice, once by each partially- - covering `place` call — the correct per-line answer, not a double-report defect. This - decline is unit-test-only observable for this suite, permanently: `Compose::place`'s own - adapter always derives `items` as the case's whole declared base stream, so no - conformance case can ever construct the straddle at all. - - `Attachments::declined` widens from two stated reasons to four: §3.3.5(c)'s own - katatsuki-with-overflow choice and §3.3.6 paragraph 3's own base-spreading method each - now also catch a jukugo construct routed through the identical code, alongside the two - new jukugo-only reasons above. Its own doc, `crate::place`'s own module doc, `crates/ - jlreq-conform/cases.schema.json`'s `kind`/`lower`/`place` descriptions, and `crates/ - jlreq-conform/src/case.rs`'s `ExpectLower`/`ExpectPlace` docs are all repaired to state - the new count rather than the old one. - - `docs/decisions/jukugo-group-layout-distribution.md`, a new published reading - (`Standing::Unstated`) as argued above, with a matching `docs/decisions/README.md` row. - Confirmed direction-independent; no `docs/direction-sites.toml` entry follows, though the - existing `jlreq-inline`/`lower`/`3.3.5` entry gains one clause noting its read now also - resolves a jukugo construct's alignment, the identical code path rather than a second one. - - Four new `#[cfg(test)]` cases in `crates/jlreq-inline/src/place.rs`: a paragraph-1 - compound placing per base under both alignments; a paragraph-2 compound placing as one - `jis`-weighted group, measured under *both* `Policy::JLREQ` and a policy answering - `flush` for `Question::GROUP_RUBY_DISTRIBUTION` to make the forcing itself observable - (base 2000, reading 1600 over one-then-three ruby characters, surplus 400 dividing `jis`'s - own eight-unit weight sum exactly — offsets `[50, 550, 1050, 1550]` under either policy); - the same compound declined under a `phonetic`-answering policy; and the same compound - declined again with an `items` range covering only its first base item, exercising the - straddle no conformance case can reach. - - Every stale "unfilled slot" or "two reasons" claim this round falsifies is repaired: - `crates/jlreq-inline/src/lower.rs`'s own module doc, `Lowered::alignments` and `Lowered:: - declined`'s own field docs, `Contribution::alignment_of` and `Contribution:: - alignment_discouraged`'s own docs, `lower`'s own doc, `lower_jukugo`'s own doc and its - "Recording §3.3.7 here..." comment, `two`'s own doc, and a test assertion message that - described `lower` as computing no discrimination for a reason no longer accurate (it - computes none; `place` now does, and neither records a `RuleId`, for the reason `crate:: - lower`'s own module doc already argues for §3.3.6); `crates/jlreq-inline/src/ruby.rs`'s - own `RubyStyle::JukugoRuby` doc; `crates/jlreq-inline/src/lib.rs`'s own `# Status`; - `docs/design/api-spine.md`'s own `Attachments` sketch; `crates/jlreq-conform/cases. - schema.json`'s four spots named above; and `crates/jlreq-conform/src/case.rs`'s two. - `docs/conformance-deferrals.toml`'s own `3.3.7` entry is rewritten on §3.3.6's own - round-5-through-6 precedent — the blocker is now the absence of a case naming `3.3.7`, - not the absence of an implementation — stating exactly what landed and exactly what did - not; `F`, `F.1`, `F.2`, `F.3` and `F.4`'s own entries each gain a clause distinguishing - "`jlreq-inline` places jukugo ruby" from "applies §F's own distribution," so their own - unchanged wording cannot be misread as claiming §F landed; `3.3.2`'s own entry, which - cited `Question::JUKUGO_RUBY_LAYOUT` as an unfilled slot `lower`'s own module doc named, - is corrected to name §F alone, now that the question itself is real, read by `place`. -- M4-a round 8: the §3.3.7 jukugo-ruby placement conformance cases (task #90), ADR-0006's own - separately-authored phase for M4-a round 7's own implementation, closing coverage at 75/106 - inventoried rules (up from 74/106). No logic change to `crates/jlreq-inline/src/place.rs` — - every number below was derived by hand from §3.3.7's own two paragraphs and this round's own - fixture advances, never read out of the implementation, its `#[cfg(test)]` module or a debug - run. - - `crates/jlreq-conform/cases/3.3.7.json`, three cases naming rule `3.3.7`. The first two - share the identical base (`亜亜`, two 720-unit cl-19 items, the cl-19/cl-19 boundary - independently verified blank at `spec/captured/table1.en.tsv` line 485) and the identical - four-character reading (`かかかか`, 300 units each), differing in exactly one declared - field — how `runs` partitions the reading across the two base characters — which isolates - §3.3.7's own discriminator as a ruby-character *count* per base character rather than the - extent comparison §3.3.5(a)-through-(c)'s own three cases reduce to: - `jukugo-ruby-placement/paragraph-one-per-base-mono-delegation` (2 and 2, paragraph 1, - delegating per run to `place_mono_run` under both of `Question::RUBY_ALIGNMENT`'s - answers, sized so neither run outruns its base and re-cases task #81's still-open choice) - and `jukugo-ruby-placement/paragraph-two-whole-compound-attachment` (1 and 3, paragraph 2, - the whole compound attached as one `jis`-weighted unit — offsets `[30, 390, 750, 1110]`, - the identical arithmetic `3.3.6/group-ruby-placement/jis-versus-flush-distribution`'s own - rationale already derives, reused here as §3.3.7¶2's own forced reading — over three - `permitted` entries with totally-ordered key sets: the default `jis` geometry, a decline - under `ruby.jukugo_layout: phonetic`, and the *identical* `jis` geometry again under - `ruby.jukugo_layout: group` with `ruby.group_distribution: flush` — `decision:jukugo- - group-layout-distribution`'s own forcing, published as a named contradiction of the - expectation a reader would otherwise form at this non-zero surplus, where `jis` and - `flush` genuinely diverge for an ordinary group-ruby run; the file's own first entry, - matching every policy, would already assert the identical numbers under a flush-declaring - policy even without this third entry, so its own second-run test is not itself proof the - third entry was selected, unlike the `phonetic` run's). The third, - `jukugo-ruby-alignment/katatsuki-discouraged- - carries-through-the-delegation`, is a `lower` case for the one fact no `place` case can - observe — `Contribution::alignment_discouraged` for a jukugo construct in horizontal - writing — and asserts `rules: ["3.3.4"]`, not `["3.3.5"]` or `["3.3.7"]`: `lower`'s own - alignment-hoist records §3.3.5's citation only under an explicit mono-ruby style guard, so - a jukugo construct's own `lower` answer publishes only `RuleId::CHOICE_OF_SIDES_FOR_RUBY_ - WITH_RESPECT_TO_BASE_CHARACTERS` (§3.3.4), never §3.3.7, which belongs to `place` once it - has actually decided which paragraph governs. - - `crates/jlreq-conform/tests/suite.rs` gains a `section_3_3_7` `per_section!` row and three - second-run tests — `_is_also_measured_under_phonetic`, `_under_flush` and `_under_ - katatsuki` — on `section_3_3_5_is_also_measured_under_katatsuki`'s and `section_3_3_6_is_ - also_measured_under_flush`'s exact model: a second `Kumihan::new(Policy)` apiece, under - which the runner's own selection rule picks the entry naming that question rather than - `{}`. All four runs (including the default) carry an identical `[3 attempted, 0 not - attempted]` census — none of `place`'s own decline conditions or `lower`'s own alignment - resolution reads a policy this file's three cases do not already publish an entry for, so - only which permitted entry is selected moves. - - `docs/conformance-deferrals.toml`: `3.3.7` moves from `[[deferred]]` to `[[owned]]`, its - `why` naming the three cases, the three genuinely-exercised readings, and the honest scope - limit — §F entire (§F.1 through §F.4) stays uncased because it stays unimplemented, - paragraph 2's own fourth-sentence two-threshold overhang ceiling stays a declared slot - doubly moot behind the `phonetic` decline, `lower_jukugo`'s own absent `Separation` for a - jukugo compound's surplus is stated in both `place` cases' own rationale, and the - straddled-compound decline stays unit-test-only observable because `Compose::place`'s own - adapter always derives `items` as a case's whole declared base stream, so this round did - not spend effort hunting for a fixture that cannot exist. The `3.3.2` and `F` entries' - own "`3.3.7`'s own entry above" pointers are corrected to "below," now that `3.3.7` sits - in `[[owned]]`, past the `[[deferred]]` table both entries live in. - - Every stale "task #90 has not yet run" claim this round falsifies is repaired in place, - prose only, no field or type changed: `crates/jlreq-inline/src/lib.rs`'s own `# Status`, - `crates/jlreq-inline/src/place.rs`'s own "What is not here" section, and `docs/decisions/ - jukugo-group-layout-distribution.md`'s own closing section (which had promised task #90 - would publish exactly the `flush`-forcing case this round's second `place` case's own - third `permitted` entry now does) are all rewritten to state that the phase has run, name - the cases it published, and state the same honest residue the ledger's own new `why` - states. +### Security -### Changed +- Exact search is charged against a transition budget; parser/message/suite/case limits, + concurrent stderr draining, bounded stderr retention, watchdog termination, and process + cleanup prevent untrusted input or engines from causing unbounded work or pipe deadlock. -- The layout core is seven crates rather than five. `just purity` now checks the crate - graph as adjacency rather than as membership, so a permitted core crate reaching another - core crate it has no row for is a failure. -- Documents corrected against the frozen design: a character class is a property of an - occurrence rather than of a code point, a spacing amount is not a function of the two - adjacent classes alone, and ruby overhang is placed after line adjustment rather than - resolved before it. ADR 0001 and ADR 0005 carry superseded-in-part notes. -- Stage 1 of the generation pipeline lives in `xtask` rather than in `tools/jlreq-gen`, a - workspace excluded from the root. The scanner reads the snapshot with `std` alone, so - there is no dependency tree to keep out — and everything outside the workspace escapes - Clippy, `rustfmt`, `cargo-msrv` and, decisively, `cargo nextest`. - `docs/design/generation.md` records the change and the reasoning. -- The CI design job runs `just design` rather than the gates enumerated by hand. The two had - already drifted: `derive-check`, the only gate binding `spec/derived/` to the vendored - document, was in the aggregate and not in the list. -- `conform` treats an absent case directory as an operand that does not exist rather than as - an empty one, so declared coverage is reported as a check that did not run — naming how - many rules it would have closed over — instead of failing on a schedule. Creating the - directory turns it on, empty or not. -- The `typos` pre-commit hook passes `--force-exclude`. Without it `typos` ignores the - exclusions in `typos.toml` for paths named on the command line, and `{staged_files}` names - every path, so `--write-changes` would have "corrected" the vendored specification and - broken the digests that prove it is upstream's. -- Twelve recorded upstream defects rather than ten: the cl-24 Remarks role stated only in - Japanese, and §3.1.6's fourth Note, whose English leaves a cross-reference as the literal - placeholder the Japanese resolves to §B. Which Note it is was an unmeasured ordinal until - the detector counted them. -- **§D.2 note 5 is not a contradiction, and this project said it was.** The note gives the - middle-dot conditional space the third priority in Table 3 where notes 1 to 3 give it the - fourth, and §3.8.3 lists the line-end reduction and the mid-line one as separate steps: note - 5 is the first and notes 1 to 3 are the second. What is defective is one locale of one - sentence — note 5's English half drops the 行末に配置する its Japanese half states — so the - row is `d2-note-5-line-end-qualifier-omitted-in-english` and not - `d2-note-5-priority-contradiction`. `generation.md` had pre-committed the rule to - `Standing::Adjudicated` and `conformance.md` had it as a worked example of a case carrying - both readings; a case written to either would have published an alternative JLReq does not - permit. ADR 0009, `api-spine.md`, `generation.md` and `conformance.md` are corrected. -- Classification narrows on one more axis, and the axis is Appendix A's own. Where a key is - listed under several classes and the caller has declared the frame, a Remarks cell that - states that frame is describing this occurrence and a cell that states none is describing a - different one — which is what makes `proportionally-spaced` mean anything for the 469 keys - §A.27 shares with a lower-numbered class, and which Appendix A prints in its Character - column too, for the 92 keys where exactly one listing is qualified: ( against `(`, % - against `%`. Without it a declared frame was read only against §3.1.2's five classes, so a - proportional `U+0028` answered cl-01. The rule reproduces §3.1.3's and §3.2.6's three stated - answers for a European numeral — full-width cl-19, half-width cl-24, proportional cl-27 — - without being told them, and §3.2.6's Note is now read for the cl-24 arm it states in so - many words rather than for the cl-27 arm alone. -- A narrowing may no longer answer a question nobody asked. Removing §3.1.2's five classes on - a proportional advance had left `U+3014` alone in cl-28 and told the caller that JLReq had - decided their bracket surrounds a warichu (割注); a removal whose survivors are all - membership in a construct that no Remarks cell states the declared frame for is refused, and - `AxisSet::CONSTRUCT` reports the axis nobody supplied. -- `docs/decisions/ambiguous-context.md` publishes the tie-break the implementation had always - applied: the lowest-numbered surviving class **the supplied facts can reach**, passing over - membership in a construct the caller never declared. Nine of the thirty classes are such - memberships and four are numbered below cl-27, so the unqualified wording answered "inside a - unit symbol" for every proportional Latin letter in a Japanese document. Two conformance - cases were written against the wording before the correction, which is the measurement: a - published reading an implementer cannot reproduce from the document is the defect. -- `docs/design/conformance.md` is written in the tense the code is in. There is no `judge` - binary, no `answers.schema.json`, no `answers/` and no `src/bin`; every sentence describing - them now says so, at the top of the document rather than four hundred lines down, and ADR - 0006's ecosystem claim is stated as not yet met. The three ADR 0018 input refusals are - likewise not published as cases, because the format has no way to say that an input is - expected to be refused — a requirement on the format first, held meanwhile by - `jlreq-class`'s own tests over `Text::new`. -- ADR 0018's two `input` properties are checked by `jlreq-conform`'s own test rather than by - `conform --check`. `Text::new` *is* that reader, and a second reader inside a gate that does - not carry Appendix A would be a second answer to a question that already has one; the two - had already parted, which surfaced when the first case the gate accepted and the constructor - refused reached the runner. -- `spec/derived/defects.tsv` is derived rather than captured. `generation.md` had put it on - the captured side on the reasoning that most of its rows are defects of the matrices; - measured, not one of the twelve is — every one is a property of the HTML snapshot. -- `Report` carries `unselectable`, the count of permitted entries no declared policy of a run - could select. A published reading nothing can select is evaluated by nothing, and the number - is what stops that being a silence on a green run. -- **The M0 policy-space entry above is stale, and this is the correction rather than a silent - edit of it.** "The twenty-one places" is twenty-two, and "Stage 2 ... is still to come: - `Question::ALL` remains empty" is no longer true — see this milestone's Added entries for - what stage 2 generated and what `jlreq-class` and `jlreq-spacing` now read from it. -- `crates/jlreq-conform/tests/suite.rs`'s `UNSELECTABLE` fell from 170 to 0. Every permitted - reading a published case names was, at M0, a reading naming a question the policy space did - not have yet; now that `jlreq_spec::QUESTIONS` holds all twenty-two, every one of those - readings is a `Choice` this workspace can evaluate, and the count that used to state how - much of the suite nothing could measure now states that nothing is in that position. -- The five conformance-case declarations that named a Table 6 citation before anything read - it — `E.2.json`'s `E.2/em-dash-then-horizontal-ellipsis/two-kinds-open-a-third-stage- - quarter-em` (`"rule": "E.2#4"`, cl-08 x cl-08) and `E.2/western-character-then-postfixed- - abbreviation/the-general-rule-opens-a-third-stage-quarter-em` (`"rule": "E.2#10"`, cl-27 x - cl-13), and `E.json`'s `E/dividing-punctuation-then-western/the-boundary-carries-an- - independent-reduction-and-expansion`'s three `permitted` entries (`"rule": "E"`, cl-04 x - cl-27) — were audited against `crates/jlreq-spacing/src/generated/table6.rs`'s own rows at - those exact coordinates now that `check_expansion` reads them. All five agree with the - generated cell's own `rule` field: `(8, 8)` cites `RuleId::E_2_NOTE_4`, `(27, 13)` cites - `RuleId::E_2_NOTE_10`, and `(4, 27)` cites the bare `RuleId::OPPORTUNITIES_FOR_INTER_ - CHARACTER_SPACE_EXPANSION_DURING_LINE_ADJUSTMENT` (rendered `"E"`, the same generic - citation an unnoted Table 1 cell renders `"B"` and an unnoted Table 2 cell renders `"C"`). - No case needed correction and no generator defect was found. -- `docs/conformance-deferrals.toml`'s own `E.2#8`, `E.2#9` and `E.2#11` entries are rewritten. - Their stated blocker — a coordinate answering `Expansion::None` being "indistinguishable - from a bare absence" — no longer holds: `Boundary::expansion_rule()` now reads - `Some(RuleId::E_2_NOTE_8)` at cl-24 x cl-13, `Some(RuleId::E_2_NOTE_9)` at cl-24 x cl-27, - and `Some(RuleId::E_2_NOTE_11)` at cl-27 x cl-27, in every case regardless of what - `expansion()` itself answers there. All three stay `[[deferred]]` at M1, because publishing - a citation is not the same act as authoring the case that measures it (ADR 0006's own phase - split) — no case is added and none is moved to `[[owned]]` by this entry. `E.2#11`'s own - rewritten entry additionally records that whether its own alternative reading is worth a - case at all is still an open question for that later phase, not answered here: §3.8.4 step - (d)'s own Note calls the alternative 処理系定義 (implementation-defined) under JIS, so a case - asserting `kind: "none"` there might measure this workspace's own bookkeeping rather than - anything JLReq itself requires. -- `docs/conformance-deferrals.toml`'s own `E.2#8` and `E.2#9` entries move to `[[owned]]`, - naming the cases added above and the percent-sign scope limit as a stated fact rather than - an open question. `E.2#11`'s own entry stays `[[deferred]]`, and its "why" is rewritten - rather than left pointing at a future round: the decision is taken this round, and no case - is authored. The two rejected-alternative coordinates read alike at first — both `E.2#8` - and `E.2#9` state a captured `limit: None` default with a note offering an unselectable - alternative — but `E.2#11`'s own alternative is JLReq's fourth, residual expansion stage - with no stated ceiling, and §3.8.4 step (d)'s own Note (the only other sentence anywhere in - the document discussing a fourth-order opportunity at cl-27-against-cl-27) attributes that - residual stage to a JIS X 4051 provision JIS itself calls 処理系定義 — a genuinely different - kind of silence from E.2#8's and E.2#9's own concrete, merely-unselectable ceilings, and - the entry's own "why" now says so instead of naming the judgment as still open. -- `E.2/quantity-symbol-then-postfixed-abbreviation/a-declared-role-withdraws-the-opportunity`'s - expectation gains `rule: "E.2#10"` beside `kind: "none"`: `note_governed_expansion`'s own - doc and its literal `RuleId::E_2_NOTE_10` for this coordinate, corroborated independently - by Table 6's own `(27, 13)` cell carrying the identical citation, are what the note's own - denial cites even while withdrawing the opportunity it would otherwise state — a - strengthening the specification itself warrants, not a correction made to match code. No - other field of this case, and no other of `E.2.json`'s pre-existing four cases, changes. -- `conformance-cases-agree-with-the-cells` (ADR 0006) now runs: `xtask::attest` reports 16 of - 18 registered invariants running, up from 15. A boundary case may declare which captured - cells it exercises through `cells`, a new case-level, optional, list-valued field of - `{table, before, after}` objects — `crates/jlreq-conform/cases.schema.json`'s own - `matrix_cell`, validated by `conform`'s own `check_cells` and added to `CASE_OPTIONAL`. - Deliberately not the `address` grammar's `@` suffix: §D.1 is the legend of three matrices - at once ("Legend of Tables 3, 4 and 5"), so `D.1@cl-02,line-end` never named one captured - cell, and `spec/derived/rules.tsv` does not inventory the natural per-table alternative - either — it has `D.1` but never `B.1`, `C.1` or `E.1`. The checker (`Evidence`, threaded - through `Check::Whole` and `Check::Partial` uniformly rather than bolted onto `Capture` or - carried by a nineteenth `Check` variant) asserts existence — every declared coordinate is - one the agreed transcription has, at every table alike — and, for Table 1 alone, that a - case's default-policy (`policy: {}`) boundary answer agrees in units with the captured - cell. 21 of the suite's 72 boundary cases now declare a coordinate this way — every - `B.json` and `B.2.json` case, `D.1.json`'s one case, every `D.2.json` case, and `E.json`'s - and `E.2.json`'s cases, 43 coordinates in all, derived from each case's own quote and - rationale rather than read back off the transcription (ADR 0006) — and the run reports - zero disagreements. The remaining 51 boundary cases (`C.json`'s and `C.2.json`'s own Table - 2 coordinates, which carry no amount to compare, and every `A.*` and `3.x` boundary case, - whose own coordinate a checker here would have to derive by classifying `text` — a second - implementation of Appendix A, which ADR 0019 forbids) are the invariant's own named - remainder rather than a silence. `conform --check`'s own census is unchanged apart from a - new line reporting the count declared; declared coverage, the rule and address counts, and - every other number stay 56 files, 466 cases, 69 rule addresses, 373/72/10/2/9 by kind, - 69 owned / 37 deferred / 0 uncovered. -- Two stale claims this invariant's own absence had left standing are repaired. `xtask:: - attest`'s module doc no longer states that `B.1@cl-02,line-end` is a working matrix-cell - address — `B.1` is not an inventoried rule any more than `D.1` is, and the corrected - example, `B@cl-05,cl-05`, is the one `docs/design/address-corpus.tsv` actually validates. - `docs/design/conformance.md` no longer attributes the absence of table cells from - `spec/derived/rules.tsv` to `spec/captured/` being empty — the matrices have been - transcribed since the round that landed them; the real reason `covers` still has no user - is that `derive` has never been extended to walk a matrix into rule addresses at all, - independent of whether the transcription exists. +[Unreleased]: https://github.com/P4suta/jlreq/compare/v0.1.0...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4cef1d..3738927 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,10 +33,15 @@ answer is not the answer CI gets, which makes it worthless in both directions. `mise exec -- just ci` is also the pre-push hook. If it passes locally it passes in CI; if it fails, fix the cause rather than narrowing the gate. -`just fuzz-check` compiles the separate nightly public-API harness on Windows and runs a -bounded libFuzzer plus sanitizer workload on Unix. The required Linux CI job always takes -the latter path; continue an exploratory run with `cargo +nightly fuzz run public_api` -from `fuzz/`. +`just fuzz-check` compiles the three separate nightly harnesses on Windows and runs each for +30 seconds with libFuzzer and sanitizers on Unix. They isolate input validation, +composition/arithmetic, and protocol parsing. Curated inputs live under `fuzz/seeds/`; +runtime corpora live under `target/fuzz-corpus/` and never dirty the source tree. + +Full mutation runs cover both handwritten products. Only generated table files and exact +mutants proven equivalent in [docs/mutation-ledger.toml](docs/mutation-ledger.toml) may be +excluded. `just mutation-ledger` binds every such entry to its source SHA-256 and rejects an +undocumented or broad cargo-mutants exclusion. ## Rules that are not negotiable @@ -109,13 +114,20 @@ English translation instead carries the kanji and the romanization — "hanging Use `ADR-0013` inside source comments and a Markdown link such as `docs/adr/0013` in prose. -The workspace is an unreleased `0.0.0` development snapshot. Both product manifests keep -`publish = false`, release automation stays inert, and changelog entries stay under -`Unreleased` until a maintainer makes a separate, explicit release decision. +The workspace is prepared at `0.1.0` and both product manifests are publishable. Release +automation remains externally inert: ordinary development must not publish a crate, create +a tag or GitHub Release, configure Trusted Publishing, or change repository settings. +`just release-check` performs the full non-publishing acceptance suite on a clean candidate. + +Run `just semver` for public API changes. At 0.1.0 it verifies the network-free release +contract in `docs/public-api.toml`; after the initial publication it additionally compares +each 0.1.x candidate with the latest published jlreq release in patch-compatibility mode. +Changing `baseline_version` or `compatible_series` is a release-policy change, not a way to +waive an individual finding. Tracked UTF-8 files use LF, including on Windows, and local links in tracked Markdown are part of the repository contract. Keep links relative so they work in a checkout and run -`just repository`; the gate holds the unreleased state and rejects CR bytes, missing +`just repository`; the gate holds the release-ready state and rejects CR bytes, missing targets, and links that escape the repository while leaving binary files, external URLs, and in-page anchors alone. Use canonical JLReq addresses from `spec/derived/rules.tsv` in protocol case metadata. diff --git a/Cargo.lock b/Cargo.lock index 307d0b3..86e8cba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,11 +164,11 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jlreq" -version = "0.0.0" +version = "0.1.0" [[package]] name = "jlreq-conformance" -version = "0.0.0" +version = "0.1.0" dependencies = [ "harfrust", "icu_segmenter", @@ -359,7 +359,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xtask" -version = "0.0.0" +version = "0.1.0" [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index 4de1e78..1aa6c36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/jlreq", "crates/jlreq-conformance", "xtask"] exclude = ["fuzz"] [workspace.package] -version = "0.0.0" +version = "0.1.0" edition = "2024" # Floor imposed by edition 2024. rust-version = "1.85" diff --git a/DEVELOPMENT-HISTORY.md b/DEVELOPMENT-HISTORY.md new file mode 100644 index 0000000..7adb424 --- /dev/null +++ b/DEVELOPMENT-HISTORY.md @@ -0,0 +1,1780 @@ +# Pre-0.1 development history + +This archival log preserves the detailed implementation chronology written before the +0.1.0 release candidate. User-facing release notes now live in [CHANGELOG.md](CHANGELOG.md). + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed + +- §3.8.3's ladder no longer reaches the seam where a warichu's two sublines meet, which + closes [#26](https://github.com/P4suta/jlreq/issues/26) — the first coordinate at which + two reference engines disagreed with *each other* rather than one lagging the other two, + and the one the entry below left out of the census on purpose. + `crates/jlreq/src/pipeline.rs` offered `reduction_sites` every interior boundary of a + stacked structure, the seam included, so a line carrying `※〈〉あ※` at a three-em measure + divided its 250 units of arrears over the one boundary it was composed from and one + inside the block that carries nothing: it gave back 125, ended 125 units over its + measure, and reported `layout.overfull` about a line it could have made fit. The + expansion ladder already stopped at the block, and the reduction ladder now asks the same + question. `docs/decisions/stacked-structure-geometry.md` publishes the reading the OCaml + and Racket engines already had: the seam is the block's like every other boundary inside + it, because what makes a boundary the line's is that the line was composed from it and at + the seam no engine places anything — the character that ends a subline reports its body + alone. No public API changed, and no other engine did. +- The Racket reference engine now lays a furawake's columns out from the advances the + structure was composed from and reads the boundary after the block against the block's + own last character, which closes + [#27](https://github.com/P4suta/jlreq/issues/27). `engines/racket/compose.rkt` made one + item of a furawake and measured each of its columns from bare cluster advances, so a + member standing before a Table 1 amount inside its column — `cl-02` then `cl-01`, half of + the member's own em — reported 1000 where the same engine placed the next member 1000 + units on and charged the line 1500 for the step; and it gave that one item the class of + its *first* cluster, so the boundary after the block was answered at the wrong end of it + and the amount missing from the column reappeared beside the structure. The two mistakes + cancel in the line's own width wherever the two amounts are equal, which is why the shape + had to be swept over every class pair to be seen. A furawake's members are now items of + their own for the purpose Table 1 is asked at, and the `item` struct carries a `trailing` + edge — the occurrence a boundary *after* the item is read against — which is the item + itself for everything one character wide and the block's last character for a furawake. + A tate-chu-yoko run carries none: §3.2.5 makes it cl-30 at both of its edges. No public + API and no other engine changed. +- Both readings above are reached at every class pair now. + `engines/ocaml/probe/census.ml` adds three `constructs` variants — + `warichu-pair-inside-reduced`, the sixth shape the entry below deliberately withheld, and + `furawake-pair-inside` with its vertical mirror — so the `constructs` census is 20,102 + requests and all ten censuses are at zero differences across the three engines over + 122,199. Against the previous engines the same census reports 15 differing responses for + the Rust one and 434 for the Racket one. +- The Racket reference engine now answers the two coordinates inside a warichu block that + the other two engines already agreed on, which closes + [#23](https://github.com/P4suta/jlreq/issues/23) and + [#24](https://github.com/P4suta/jlreq/issues/24). `engines/racket/compose.rkt` reported a + line's `block_extent` as the sum of the note's own subline heights where that was larger + than the paragraph's own block size, so a note set at the paragraph's em rather than at + §3.4.2's half one made the line twice as deep as the paragraph set it; and it reported a + member's advance as its bare body, so a member standing before a Table 1 amount inside + its own subline — `cl-02` then `cl-01`, half of the note's own em — reported 500 where the + same engine placed the next member 750 units on. Both are one reading: + `docs/decisions/stacked-structure-geometry.md` now states what a block does on the block + axis (its sublines run *beside* the line, so their depth is not a depth the line reports) + and what Table 1 does inside one (a note's text is ordinary text, so the boundary between + two characters of one subline carries the ordinary amount, and a member's advance is the + step that reaches the member after it). The same reading closes §3.8.3's half of it: a + boundary the line was not composed from is not one the line may take space back from, so + the reduction ladder no longer reaches inside a block — the expansion ladder already did + not. No public API and no other engine changed. `engines/ocaml/probe/census.ml` reaches + both shapes at every class pair now: five further `constructs` variants + (`warichu-full-size` and its vertical mirror, `warichu-pair-inside`, + `warichu-pair-inside-row` and `warichu-pair-inside-justified`) and two further `tabs` + variants (a sign inside a note set at the paragraph's own em, at a stop the line reaches + and at one it has passed) — so the `constructs` census is 18,515 requests, `tabs` is + 31,211, and all ten censuses are at zero differences across the three engines over + 120,612. A sixth `constructs` variant is deliberately absent: a line that has to *give + space back* beside a note whose two sublines meet at a Table 1 amount is a coordinate the + Rust and OCaml engines do not yet agree on themselves — the Rust engine divides the + line's arrears over that in-block boundary and lays nothing down there, the OCaml engine + does not offer it at all — and a census asks the settled question. +- The Racket reference engine now reads §3.6.3's tab round the way + `docs/decisions/tab-line-correspondence.md` publishes it, which closes + [#19](https://github.com/P4suta/jlreq/issues/19) and lifts the census exclusion the entry + below records. `engines/racket/compose.rkt` gave a tab sign standing inside a warichu or a + furawake one em of the paragraph's own size instead of the advance it was shaped with — + which moves the block's own geometry and the whole line with it — and reached that branch + only for a sign *strictly* inside the structure, so a sign that opened one still fell + through to §3.6.3's cut and ended the line before a sign that is not a sign of the line. + A new `line-sign?` decides the question once, off the geometry rather than off a list of + constructs: a sign a stacking structure contains, its first character included, takes no + stop, keeps the advance it was shaped with, ends no string a stop aligns, and offers the + line no boundary. A sign inside a jidori or an emphasis run is unaffected — those set + their characters along the line, one position each. No public API and no other engine + changed. `engines/ocaml/probe/census.ml` covers the shape again at nine further variants + — a sign inside a warichu and inside a furawake, a sign that opens either, and a sign of + the line standing after such a structure, whose stop has to be measured from a walk in + which the whole block is one step — so the `tabs` census is 30,153 requests and all ten + censuses are at zero differences across the three engines over 116,909. +- §3.6.3's tab round now gives one answer to "is this a sign of the line" instead of two. + A tab sign standing inside a structure that stacks its text off the line — a + tate-chu-yoko run, which runs across it, or a warichu's and a furawake's sublines, which + run beside it — is not a sign of the line: it takes no stop, sets the advance it was + shaped with, and never chooses §3.6.3's cut, the structure's *first* character included. + Two coordinates where `crates/jlreq/src/pipeline.rs` contradicted itself are closed by + this, both found by the independent OCaml and Racket engines and filed as + [#12](https://github.com/P4suta/jlreq/issues/12) and + [#13](https://github.com/P4suta/jlreq/issues/13). A sign that *opened* a tate-chu-yoko run + ended the line — which only §3.6.3's fourth case does, and only to a sign of the line — + and was then set as a member of the run on the next line; `validate_breaks` in + `crates/jlreq/src/paragraph.rs` no longer offers §3.6.3's cut there. Inside a warichu or a + furawake, `apply_tabs` stepped a tab cursor once per character of the block while the line + set the block at one position, so the *next* sign's advance was measured from a cursor + that was not where the sign stood; the tab cursor and the placement cursor are now one + walk, in which a structure is one step, and a line the engine measures wider than it sets + — and therefore reduces when it need not — is no longer possible at these shapes. The + reading is published in `docs/decisions/tab-line-correspondence.md`, argued from §3.6.3 + and from the geometry §3.2.5, §3.4.2 and §3.7.2 give those structures rather than from + what any engine answered. No public API changed. All ten censuses stay at zero differences + across the three engines over 112,148 requests, the `tabs` census now covering a sign that + opens a tate-chu-yoko run at 1,058 further requests; a sign inside a warichu stays out of + it because the Racket engine has not reached the published reading + ([#19](https://github.com/P4suta/jlreq/issues/19)). + +### Changed + +- Renamed the project from `kumihan` to `jlreq`, while the workspace is still unreleased at + `0.0.0` and every package still declares `publish = false`: the crates `kumihan` → + `jlreq`, `kumihan-conformance` → `jlreq-conformance` and `kumihan-fuzz` → `jlreq-fuzz`, + the binaries `kumihan-conformance` → `jlreq-conformance` and `kumihan-sample-engine` → + `jlreq-sample-engine`, the conformance protocol identifier `kumihan.conformance/1` → + `jlreq.conformance/1` (version `1` unchanged — no message or field changed), the SPDX + copyright `2026 kumihan contributors` → `2026 jlreq contributors`, and the repository and + documentation URLs. `SPECIFICATION` (`jlreq-2020-08-11+unicode-17.0.0`), `Style::jlreq_2020` + and the `jlreq-2020` profile are JLReq revision identifiers and are unchanged. The + copyright line is an input the generation ledger hashes, so all ten `spec/derived/*.tsv` + files, all ten `crates/jlreq/src/generated/*.rs` modules and `data/manifest.toml` were + regenerated by `just derive` and `just generate`. Recorded in + [ADR 0023](docs/adr/0023-the-project-is-named-jlreq.md), which amends + [ADR 0022](docs/adr/0022-unified-public-crate-and-process-conformance.md) in name only — + its crate topology is unchanged. The entries below this one keep the names they were + written with: this changelog records the reasoning as it was reasoned, and citations of + retired code (`crates/jlreq-conform/src/kumihan.rs`, the `Kumihan` type) stay accurate by + staying as they were. +- Updated security support, issue forms, mutation reporting, current decision ownership, + and CI wording to describe the 1.0 repository rather than its retired crate graph. +- Replaced the pre-1.0 multi-crate facade with the dependency-free `no_std + alloc` + `kumihan` library and its single validated paragraph composition pipeline. +- Added all nine inline constructs, horizontal and vertical placement, optimal paragraph + breaking, integrated tabs, diagnostics, and all 22 typed JLReq 2020 Style choices. +- Added the binary-only `kumihan-conformance` CLI, versioned NDJSON protocol, JSON Schema, + sample engine, and 88 black-box cases covering all 100 observable inventoried rules. +- Removed the eight unpublished legacy crates and their compatibility-only controls. + +### Added + +- Restored the §3.3.6 single-character `flush` group-ruby reading as an explicit + protocol-v1 black-box case, bringing the built-in suite to 89 cases. +- Added a repository gate for broken local Markdown links, publishable-package checks in CI, + crate-specific package READMEs, and ADR 0022 for the unified 1.0 product boundary. +- Workspace bootstrap: crate skeletons, quality gates, and the day-one architectural + decision records. No layout logic yet. +- Fourteen further decision records (0007 through 0020) and the three design notes they + were argued from: the API spine, the specification-data generation pipeline, and the + conformance suite format. +- `jlreq-unit`, the quantity and item vocabulary every later layer speaks through. Two + kinds of length that never mix — a stated fraction of the ideographic em (全角, zenkaku) + in a 1/720 fixed-point unit, and a caller-supplied advance — inline and block axes with + no conversion between them, and the item, run, and seam types. No `core::ops` trait is + implemented for any of them, so a bare `+` on a length is a compile error rather than a + lint finding. +- `jlreq-spec`, the specification-reference vocabulary: the address grammar JLReq's own + numbering is written in, the provenance an answer carries, and a policy space that + refuses a self-contradictory policy at construction rather than at every entry point. +- Eight design gates beside `purity` — `ops`, `placeholder`, `api`, `spec-links`, + `direction`, `generate-check`, `attest`, and `conform` — run together as `just design` + in the loop and as one CI job. Each reports which of its checks had no data to run over + instead of reporting a pass, so a gate awaiting the generated tables never states that a + check it could not run held. +- The control files those gates read — `docs/api-frozen.toml`, `docs/direction-sites.toml` + and `docs/scalar-sites.toml` — each guarded by `CODEOWNERS`, which is what makes them + controls rather than documentation. +- The vendored specification: the W3C published rendering of JLReq at + `spec/snapshot/index.html`, the three Unicode Character Database extracts it is read + against, and `spec/PROVENANCE.toml` recording where each was retrieved and its SHA-256. + `just attest` verifies the files on disk against those digests, so every table below + names the bytes it was read from rather than a URL that may since have moved. +- Stage 1 of the specification-data pipeline, `just derive`: a `std`-only scanner that + reads the bilingual snapshot into eight tab-separated files under `spec/derived/` — + Appendix A, the class list, the ideograph predicate, the compatibility folding, the two + kana scripts, the document skeleton, the rule inventory and the appendix notes. Each + derived file states the digest of every source it was read from *and* of the modules that + read it, because a semantic column is the reader's reading of the document rather than a + column of it. `just derive-check` fails when rereading the snapshot would change a byte. +- The rule inventory ADR 0013 addresses: 106 rules generated into `jlreq-spec`, numbered + from the document's own rendered numbering and never from an anchor slug, which is off by + one for the appendix legends. `spec-links`, `direction` and `conform` now close over that + data instead of reporting that they had none to close over. +- `jlreq-class`, complete for M0: Appendix A's 1133 keys as 1686 listings, 473 of them named + by more than one class — the measurement ADR 0008 turns on, since it is why no total + function from a code point to a class exists to write. Classification takes an occurrence; + a key is an ordered code-point sequence matched longest-first, because 25 of Appendix A's + rows key on a pair; and `Text::new` refuses a stream this crate could not answer for + rather than guessing at it. `Text`, `classify`, `resolve`, `members`, `usage` and the + thirty class names of §3.9.2 are implemented to `docs/design/api-spine.md`. +- `crates/jlreq-conform/cases.schema.json`, the conformance case format contract. The suite + is written milestone by milestone; the format it validates against is fixed now, so the + cases and the implementation can be authored independently. +- `docs/decisions/`, with the first three readings this project publishes where JLReq is + silent: an unlisted code point, an ambiguous context, and the compatibility ideographs. + Each carries a standing other than `Normative`, so an answer resting on one says so. +- `spec/derived/questions.tsv`, the policy space as data: the twenty-one places JLReq permits + more than one answer, each with the address that permits it, the sentence it rests on + quoted from the rendering it names, the answers, and the one `Policy::JLREQ` selects. A + `permission` column records *why* an alternative is permitted — fourteen `stated`, six + `silent`, one `contradictory` — because that is the distinction ADR 0009 exists for and the + one prose loses first: only `stated` is a permission JLReq grants, and the column is what + stops the others being laundered into it. The reading is a table in `xtask/src/policy.rs` + and the derivation refuses to emit a row whose quoted sentence is not verbatim in the + section or note it addresses, so a revision that resolves a question fails the build with + the row named. `docs/api-frozen.toml` states the size of every one of the twenty-one answer + sets and the `api` gate holds the derived counts and the published `Question` constants to + it in both directions. Stage 2, which turns the file into `jlreq_spec::QUESTIONS`, is still + to come: `Question::ALL` remains empty. +- `spec/derived/defects.tsv`, the twelve recorded defects of the published document, each + with the measurement that must still find it. Being derived rather than transcribed is a + claim with teeth: twelve sentences in a constant printed into a file would be an + attestation wearing a derivation's header, so every defect carries a detector over the + rendering, the row's `evidence` is composed from what that detector measured down to the + line numbers, and a defect fixed upstream fails `derive` and prints the review procedure. + `attest` holds the file's identifiers against its own list — two lists, because the gate + that checks the file is not the program that writes it. +- The Appendix A conformance cases: 30 files, 391 cases, 27 inventoried rules. Every case is + a published artifact rather than an internal test (ADR 0006), so each names the + specification address it turns on, states its input as an occurrence with a declared frame + and role, and records both readings under `permitted` wherever JLReq decides nothing. + `jlreq-conform` gains the reader, the `Compose` trait, `run`, `run_file` and `Report`, and + a `Kumihan` implementation that answers the classification question and reports the other + two as not attempted — which is the non-obligation ADR 0006 is built on, measured rather + than described. +- `docs/conformance-deferrals.toml`, the coverage ledger, guarded by `CODEOWNERS`. The rule + inventory is generated whole and the suite is written milestone by milestone, so + "every rule has a case" has a remainder that is nothing but the schedule. An inventoried + rule is now in exactly one of three states — covered, deferred to a named milestone with a + reason, or uncovered, which fails — and `conform` prints the census on every run: 27 + covered, 79 deferred (M1 37, M2 16, M3 1, M4 23, M5 2), 0 neither. A `[[deferred]]` entry + expires by itself, because the moment a case covers the rule the entry is a violation; and + an `[[owned]]` entry is held to the opposite invariant, so a case cannot credit a rule to + nobody. `spec-links` subtracts the same file, which is the same debt seen from the citation + side. +- `docs/decisions/grouped-numeral-qualification.md`, the fourth published reading: whether + the width or the job §A.24's Remarks cell names is what reaches cl-24. The cell states + both, §3.9.2 scopes the class by the job alone, and an occurrence with the width and not + the job — a quarter-em comma between two hiragana — is described by neither and excluded by + neither. +- The `jlreq` facade re-exports the three layers that exist, so a caller depends on one crate + and names one path for a type wherever it lives. +- Appendices B through E's six matrices — Table 1 (spacing), Table 2 (line-breaking), Tables + 3 through 5 (reduction priority: JLReq's own, JIS X 4051's, and book practice's) and Table 6 + (expansion) — transcribed independently from the English and Japanese PDF renderings into + `spec/captured/table1.en.tsv` through `table6.ja.tsv`, the one CAPTURED (attested) category + ADR 0009 carves out for data W3C publishes only as PDF. `xtask attest` cross-checks the two + locales cell for cell, requires every cell's provenance (source PDF, table number, row and + column label, legend token), and holds the transcription against the cross-table invariants + `docs/design/generation.md` derives from prose that *is* machine-readable: 4,932 cells + double-entered across the six tables, 841 of 961 in Table 1, 3, 4 and 5's 31 × 31 grid and + 784 of 900 in Table 2 and 6's 30 × 30. One invariant retired on measurement rather than kept + unchecked: cl-28 and cl-29 were assumed to track cl-01 and cl-02 except for scattered + per-cell exceptions, and the landed data holds roughly 311 unnoted disagreements across the + six tables — a class-level license §3.9.2's own prose states once for the pair, not a + per-cell footnote, so the invariant is removed and the measurement is recorded in its place. +- Stage 2 of the policy-space derivation. `spec/derived/questions.tsv` now carries, for each + of twenty-two places JLReq permits more than one answer — one more than at M0: + `spacing.line_end_full_stop_comma`, §B.2 note 6's own preferred/JIS split for full stops and + commas at the line end, distinct from §B.2 note 2's closing-bracket question beside it — + every answer's own sentence and citing rule, whether JLReq calls one preferred, the answer + each of the five presets selects, and the exclusions between answers. `xtask generate` turns + the file into `crates/jlreq-spec/src/generated/policy.rs`, closing `jlreq_spec::QUESTIONS` + and `Question::ALL`, both empty since M0. `Policy::BOOK`, `MAGAZINE`, `NEWSPAPER` and + `JIS_READING` are no longer four names for one empty answer set; each now diverges from + `Policy::JLREQ` at exactly its documented questions and nowhere else. +- `jlreq-spacing`, the mojikumi (文字組み) evaluator ADR 0014 specifies: + `ConditionalSpace`, `Boundary` and `evaluate::boundary`, which answer one adjacency of two + character classes against everything Table 1, Table 2 and Appendix D/E's reduction and + expansion ladders state about it. The atom is the conditional space per referent (`be`/`af`) + and not the table cell, so a note like §B.2#3's middle-dot pair — two quarter-em + contributions from two different characters' ems, at two different reduction priorities in + Appendix D — is two `ConditionalSpace` values on one `Boundary` rather than one number. + §3.1.3's vertical-writing withdrawal of the conditional space around an ideographic comma + used as a digit separator and a katakana middle dot used as a decimal point is the crate's + one direction-conditional site, registered in `docs/direction-sites.toml`. §3.7.4's + math-formula spacing (cl-17, cl-18) is out of scope: neither class appears in any of the six + matrices by the specification's own axis, so the crate answers "no table constrains this" + rather than the quarter-em §3.7.4 states in prose. Kinsoku relaxation and line breaking + proper stay `jlreq-line`'s, the next milestone. +- `jlreq-class` applies §C.2's three reclassification notes, dormant since M0-b published + `RECLASSIFICATIONS` empty pending the policy space: note 1 moves `々` alone into cl-19 under + `kinsoku.iteration_mark_at_line_head = permitted`; note 2 moves every prolonged sound mark + (cl-10) into katakana (cl-16), and note 3 moves every small kana (cl-11) into hiragana or + katakana by its own Unicode script, both under `kinsoku.relaxation_mechanism = reclassify` — + `Policy::JLREQ`'s own default for both. `Subject::ClassInScript` is the new variant note 3 + needed: one subject class with two destinations picked by the member's own script, which no + existing `Subject` shape could state. +- The mutation-testing gate, baseline only: `.github/workflows/mutants.yml` runs + `cargo-mutants` weekly and on demand over the four crates with logic to mutate today + (`jlreq-unit`, `jlreq-spec`, `jlreq-class`, `jlreq-spacing`), and `just mutants` runs the + same thing locally. Neither `check` nor `ci` runs it yet: it is a report, not a + kill-everything threshold, until the next milestone's independently-authored cases give + kinsoku and line adjustment the discipline classification already has. +- `jlreq-line` fills §C.2 notes 6 through 8 and 13's same-run break refusal: + `feasible::same_run_refusal` reads a caller-declared `jlreq_unit::Runs` overlay directly, + refusing a break inside one ornamented complex (cl-21), one simple-ruby complex (cl-22), + one tate-chu-yoko run (cl-30), and one jukugo-ruby base-and-ruby group (cl-23, at the + level `jlreq_unit::Construct::group` carries below the run), and permitting one between + two different runs or two different groups. An occurrence with no declared group is this + pass's own adjudication — permitted, absent positive evidence of shared indivisibility — + recorded as a published reading in `docs/decisions/jukugo-ruby-unset-group.md`. Scope + limit: reachable today only through the public `Feasible::compute`, called directly with + a real overlay; `crate::compose::compose` still composes plain text, passing + `Runs::none()` unconditionally. +- `jlreq_spacing::Boundary::expansion_rule() -> Option`, the citation Table 6's own + row states for a boundary's expansion opportunity, carried independently of + `Boundary::expansion` because `Expansion` is a kind and not a record (ADR 0010): `None` + when no Table 6 row exists at this coordinate, `Some` when one does — including when what + the row states is `Expansion::None`, a note's own denial of an opportunity rather than the + table's silence about the coordinate. `rules_fired` reports it too, in a new sixth slot; + an earlier revision of that function advanced its running index past every write except + the delegation's, which the new slot would have silently overwritten at a boundary + carrying both a delegation and two conditional spaces — the fix and its own regression + test (`rules_fired_reports_two_spaces_a_delegation_and_an_expansion_without_clobbering_ + any_of_them`) land together. `crates/jlreq-conform`'s `CaseExpansion` and `ExpectExpansion` + carry the identical citation as `rule: Option`, and `check_expansion` compares it + under its own semantics: silent when the expectation states no `rule`, passed over — never + failed — when the expectation states one and the answer publishes none, and a real + disagreement only when both sides publish different addresses at the same coordinate — the + identical right `check_class`'s own doc already grants a classification answer's whole + provenance chain, now extended to one field of a boundary answer instead. + `docs/adr/0021-table-6s-expansion-belongs-to-the-boundary.md` records the decision as an + amendment to its own original text rather than a new ADR, because the carrier this + amendment gives the citation is the identical boundary-level carrier that ADR's own + Decision already gave the amount. Two further citation surfaces stay out of this round's + scope, and unwired for two different reasons rather than one: `ExpectBoundary.rules` + already has an answer to compare against — `CaseBoundary.rules` is populated from + `jlreq::rules_fired` — but `check_boundary` never reads either side's `rules` field, so + only the comparison itself is missing there; `ExpectSpace.rule` has no answer-side value + to compare against yet at all, because `CaseSpace` carries no `rule` field for + `check_spaces` to read. `docs/conformance-deferrals.toml`'s `B.2#13`, `B.2#17` and `3.1.6` + entries already name these same two holes as their blocker, unchanged by this round. +- Three `E.2.json` cases closing over §E.2 notes 8 and 9's boundary coordinates now that + `Boundary::expansion_rule` publishes their citation: + `E.2/grouped-numeral-percent/the-main-clause-denies-expansion` and + `E.2/grouped-numeral-degree-celsius/the-alternative-is-scoped-to-the-percent-sign-alone` + read the cl-24-against-cl-13 coordinate at the two fixtures + `A.13/grouped-numeral-percent/line-break` and `A.13/grouped-numeral-degree-celsius/ + line-break` already use for the breakability question, each answering `expansion: { + kind: "none", rule: "E.2#8" }` from Table 6's own `(24, 13)` cell; `E.2/grouped-numeral- + then-western-character/the-alternative-is-an-unfilled-policy-slot` reads the + cl-24-against-cl-27 coordinate and answers the identical shape citing `E.2#9`. All three + are `standing: "normative"`, because `spec/derived/questions.tsv` addresses no question to + either note, and all three carry a `forbidden` entry naming the ceiling a reading of the + note's own alternative clause alone — without checking Table 6's captured cell or, for + the percent-sign case, this workspace's own reclassification path — would wrongly publish. + The percent-sign case's own alternative rung does not ship: drafted as a three-rung ladder + mirroring `A.13/percent-sign/kinsoku-loose-reclassification`'s own classify-side one, it + was cut once checking whether this workspace could produce a cl-19 reading here found two + independent reasons it cannot on any policy — `crates/jlreq-class/src/classify.rs`'s own + `RECLASSIFICATIONS` table carries no percent-sign entry, and independently, + `crates/jlreq-spacing/src/evaluate.rs`'s own `class_of` resolves every item's class under + a hardcoded `Policy::JLREQ` rather than the `policy` parameter `boundary()` itself + receives, confirmed directly by a scratch probe (`boundary(adjacency, Policy::MAGAZINE)` + over the fixture, run and removed before commit) that still answers `Expansion::None` + citing `E.2#8`. `A.13/percent-sign/kinsoku-loose-reclassification`'s own second and third + rungs publish the identical, now-verified-unreachable reading on the classify side, where + the reference suite's own single-declared-policy run never exercises a non-default rung + and so never caught it — this round's own cases do not repeat the claim. `crates/jlreq- + conform/tests/suite.rs`'s `appendix_e_2` count rises from `[4 attempted, 0 not attempted]` + to `[7 attempted, 0 not attempted]`. +- `Question::LINE_HEAD_OPENING_BRACKET` reads a policy for the first time anywhere in this + workspace: `jlreq_spacing::evaluate::boundary`'s new `line_head_opening_bracket_space`, + called from `spaces_of` outside the per-term loop for the identical structural reason + `sentence_medial_dividing_mark_spaces` already is, synthesizes a half em at Table 1's `(0, + 1)` coordinate — the line head before an opening bracket, cl-01 — when the question answers + `pattern-2`, and answers nothing under `pattern-1`, `pattern-3` or no override at all. §B.2 + note 17's own parenthetical names §3.1.5 by section title as the place its "conditional + half em spacing" alternative is laid out ("see § 3.1.5 Positioning of Opening Brackets at + Line Head including methods of positioning of opening brackets at the beginning of + paragraphs"), which is what identifies the amount: Figure 71 pattern ②'s own wrapped-line-head + half, 折返し行頭の字下げは二分アキ, is that alternative, and patterns ① and ③ are both the + note's own preferred zero. No built-in preset answers `pattern-2` (`Policy::BOOK` answers + `pattern-3`, every other preset `pattern-1`), so no existing test or case can regress; the + half em is reachable only through an explicit `Policy::with` override. + `docs/decisions/line-head-opening-bracket.md` records the three things this synthesis had to + adjudicate rather than read verbatim — the referent (`Referent::Trailing`, the bracket being + this boundary's only possible neighbor), the reduction (`Reduction::Rigid`, stated directly + rather than routed through Appendix D's own reduction tables, whose `(0, 1)` row is checked + directly against the generated data and found to be the tables' own total-29-by-29-grid + boilerplate — the same generic citation 833 to 834 of each table's 841 rows carry — not a + stated schedule for a term Table 1 itself never states), and the citation + (`RuleId::POSITIONING_OF_OPENING_BRACKETS_AT_LINE_HEAD`, not `RuleId::B_2_NOTE_17`, because a + scratch probe run and discarded before this round's own gate battery confirmed the latter + already reaches `rules_fired` through `boundary`'s own `placement` provenance regardless of + this synthesis, while the former had zero readers anywhere in this workspace before this + round — checked directly, its only two occurrences were its own generated constant and its + own row of `spec/derived/rules.tsv`). `docs/conformance-deferrals.toml`'s `3.1.5` and + `B.2#17` entries are rewritten to state precisely what is now reachable — the wrapped line + head's own two distinguishable answers, and both paired addresses now firing in `rules_fired` + at that one coordinate — and what still is not: the paragraph-first-line half (改行行頭) of + Figure 71, entirely unread by `jlreq-line`; the citation itself, still unassertable through + `crates/jlreq-conform/src/run.rs`'s own comparison surface at either granularity — + `check_boundary` never reads an expectation's `rules` field (a fact restated more precisely + than before: as of round 13 `check_boundary` also compares `expansion`'s own conditional + `rule`, which cannot stand in here because §E.1 states Table 6 carries no line-edge cells at + all), and `check_spaces` never reads a space expectation's own `rule` field either, because + the answer side, `CaseSpace`, carries no `rule` field to compare it against at all; and the + same single-declared-policy limit `3.1.6`'s own entry already states for its own alternative-keyed + entries — `Kumihan::default()` declares `Policy::JLREQ`, whose own answer here is `pattern-1`, + so a `pattern-2`-keyed reading is a statement to an implementation that declares that + alternative, not a coordinate `cargo nextest`'s own default run exercises. Both rules stay + `[[deferred]]`; nothing moves to `[[owned]]` this round (ADR 0006). `crates/jlreq-line/src/ + lib.rs`'s own "Slots" section gains a third entry for the paragraph-first-line half: wiring + it would compose correctly for patterns 1 and 2 (whose first-line indents are the ordinary + one em plus the wrapped line head's own answer, zero and a half em respectively) but not for + pattern 3, whose own half-em first line replaces the ordinary indent rather than adding to + it, which `Paragraph::with_first_line_indent`'s purely additive `InlineExtent` cannot + express — stated plainly rather than buried, since `Policy::BOOK` answers `pattern-3` and is + this project's own default book preset. +- `ExpectBoundary::rules` is compared for the first time: `crates/jlreq-conform/src/run.rs`'s + new `check_rules`, called from `check_boundary` whenever a case declares the field, reads it + as a *subset* of `CaseBoundary::rules` — every address the case names must appear somewhere + among the ones the answer published, never their equality and never their order, and a + declared address met by an empty answered list is passed over rather than failed, the + identical third state `check_expansion`'s own conditional `rule` field already gave one + provenance comparison. The asymmetry is argued rather than assumed: + `jlreq_spacing::evaluate::rules_fired`'s own fixed 6-slot array repeats the identical + fallback address in its first two slots and orders every slot by internal layout rather + than by anything the specification states, so holding a case to that order or to that + repetition would be exactly the "reproduce our chain of specification addresses" demand ADR + 0006 exists to keep the suite from making of a foreign implementation. `check_class`'s own + doc, which argues that classification provenance is *not* compared, is amended to name this + second exception and answer its own three grounds for it directly — the first now + discriminates *for* the boundary comparison (three `docs/conformance-deferrals.toml` entries + name `check_boundary`'s own prior gap directly and a fourth, `D.2#4`, names the same absence + one layer further upstream, in `rules_fired` itself, a gap this round does not close; zero + name classification provenance), the second is answered by the + subset semantics being materially weaker than the exact-sequence reproduction the second + ground actually rejects, and the third by scale: the twelve pre-existing boundary-level + `rules` declarations (five in `A.16.json`, seven in `A.22.json`) were individually + re-verified this round before the comparison went live, against `ExpectClass::rules`'s own + 413, unaudited. All twelve are `declined` today — every one sits on a boundary where at + least one neighbor is covered by a ruby construct `jlreq-inline` (M4) does not yet exist to + answer, confirmed against `crates/jlreq-conform/tests/suite.rs`'s own committed census + (`A.16`'s `[25 attempted, 1 not attempted]`, `A.22`'s `[1 attempted, 11 not attempted]`) + rather than assumed — so this round changes nothing observable for any of them; none needed + correcting. `crates/jlreq-conform/cases.schema.json`'s own `boundary.rules` gains the + description it was the only field of `boundary`'s eight to be missing. + `docs/conformance-deferrals.toml`'s `3.1.5` and `B.2#17` entries are rewritten to state what + a case can now positively assert under the default policy — `rules: ["B.2#17"]` at cl-01's + line-head boundary, checked on every `cargo nextest` run, since `rules_fired` puts that + citation into its own placement slot regardless of `spacing.line_head_opening_bracket`'s own + answer — while keeping `check_spaces`'s own unread `ExpectSpace::rule` (and `CaseSpace`'s own + missing `rule` field) stated as still open, a published API-surface change and a round of its + own. `B.2#13`'s entry is rewritten the identical way — its own placement citation, + unconditionally read regardless of Table 1's empty terms at cl-26's line-head and line-end + coordinates, is now assertable too — but `D.2#4`'s is not: that note's own citation lives + only in a reduction table's per-term loop, which never runs where no term exists, so + `rules_fired` never puts it in any slot at all and this round's comparison has nothing there + to reach. Coverage stays at 67/106; no rule moves from `[[deferred]]` to `[[owned]]`. +- `crates/jlreq-conform/cases/3.1.5.json` (new) and `crates/jlreq-conform/cases/B.2.json` + (one case appended) are the independent case phase task #42 (round 15) and task #44 + (round 16) were both forbidden from writing, ADR 0006's own discipline: derived from + §3.1.5's and §B.2 note 17's own words and from the generated tables before this round's + own suite run, not from what the evaluator was already known to answer. `3.1.5`'s own + three cases pin Figure 71's own wrapped-line-head pattern and its own scope to opening + brackets (cl-01) at a line head, neither a different class nor an interior boundary; + `B.2/opening-bracket-at-line-head/the-preferred-zero-and-the-retained-half-em` pins the + note's own amount. Both rules' `{}` entries assert `spaces: []` together with `rules: + ["B.2#17"]` at the line-head boundary before cl-01 — the round's own load-bearing + measurement, since an empty `spaces` alone is the identical answer any blank cell gives. + Reading both locales of the note's own alternative settles the one open discriminator: the + English's "not to remove a conditional half em spacing accompanying the characters" reads + as retaining cl-01's own class-level half em (`spec/captured/table1.en.tsv`'s own cl-01 + column carries a trailing `1/2 af` at essentially every `before` class) rather than + synthesizing an unrelated one, while the Japanese states only a plain amount with no verb + of retention at all — a locale framing difference this round records rather than resolves. + Whether the retained half em is reducible does not follow from that reading alone, and is + where this round corrects round 15's own ground rather than its answer — though not, on a + second pass, all the way to the categorical claim first drafted for it: Appendix D's own + preamble scopes the whole reduction mechanism to an opportunity "between two adjacent + characters" (`spec/derived/rules.tsv`'s row for rule `D`), but a line end has the identical + single-neighbor structure and Appendix D genuinely does reduce real terms there (§D.1's own + legend; `3.1.9`'s and `B.2#2`'s own cases), so "only one real neighbor" cannot itself be the + exclusion. What actually holds, confirmed rather than assumed, is narrower and purely + empirical: the line-head row specifically, not line edges in general, is uniformly rigid + across Tables 3, 4 and 5 — `xtask/src/attest.rs`'s own `no_reduction_at_the_line_head` + invariant, a `Check::Whole` run over the full transcription, reports zero violations there + (`docs/design/generation.md`'s own cross-table invariant 4). `Reduction::Rigid` is + consequently still the corrected answer, agreeing with round 15's own value while replacing + the narrower ground that round's own doc gave (an absent-term row being the tables' + total-grid boilerplate, true of the one cell but not the reason the amount cannot move). `docs/decisions/line-head-opening-bracket.md` is left + untouched, per ADR 0006's own separation of phases; the corrected ground is recorded in the + new cases and in `docs/conformance-deferrals.toml` instead. Both rules move from + `[[deferred]]` to `[[owned]]`; coverage rises to 69/106, 37 deferred, 0 uncovered. + `B.2#13`'s own entry is read again rather than moved: its note states one unified fact + about the suppression of the cl-26 item's own supplied advance at four positions, a fact + no crate in this workspace computes at any milestone — `jlreq-spacing` only ever produces + an inter-character `ConditionalSpace` between two neighbors, never a change to an item's + own advance (`cases.schema.json`'s own `item.advance`, ADR 0002) — so the entry's own + `milestone` moves from `M2` to `M4`, the same label its warichu half already carried, + rather than being repaired in place; no case is authored for it this round. + `crates/jlreq-conform/tests/suite.rs` gains `section_3_1_5 => "3.1.5" [3 attempted, 0 not + attempted]` and bumps `appendix_b_2` to `[7 attempted, 0 not attempted]`. +- `Search::Optimal { tolerance: Badness }`, M3's first slice: the whole-paragraph break + search `docs/design/api-spine.md` froze the shape of at M1, implemented in + `crates/jlreq-line/src/compose.rs` as a forward dynamic program (`run_dp`) over the same + `Feasible::compute` break set and the same `geometry_of` → `Ladder::of` → `adjust_line` → + `apply_adjustment` → `demerits_of` pipeline `Search::FirstFit` already ran, minimizing the + paragraph's own summed `Demerits` under `Preference::compare` rather than committing to one + candidate per line before the ladder runs. `compose`'s own greedy loop moves, unmodified, + into a new `compose_first_fit`, and `Search::Optimal` gets its own `compose_optimal` + alongside it — two separately written pipelines sharing only the feasibility computation, + never a per-line evaluator, so a defect in one cannot silently reach the other's already + verified answers (this round's own C6, checked directly: all 827 existing tests and all 466 + conformance cases produce byte-identical results before and after). + - The DP's correctness rests on lexicographic order over `Demerits`' six `u32` components + being translation-invariant under `Demerits::add_sat` — stated and bounded in `run_dp`'s + own doc: every component but `badness` saturates far later than `badness`'s own + `u32::MAX / 10_000 ≈ 429_496`-line bound, which no paragraph this milestone composes + reaches (this round's own C2). + - `first_line` and `is_last_line` are read from an edge's own two ends + (`start.get() == 0`, `end.get() >= item_count`), never from where the DP's own + reconstruction happens to be, so a line's cost is identical whichever predecessor reaches + it (C3, covered by a dedicated first-line-indent test). + - The scan per candidate start stops after the first ladder-drained `Overfull` result, never + on `ExpansionExhausted` (a short line the ladder could still save by growing), with the + stated reason recorded in `run_dp`'s own doc rather than left as a magic constant (C5). + - `tolerance` filters which edges the DP may use, exactly "discarding any line worse than + tolerance": given this milestone's own zero-flex `Badness::of` reading (a feasible line is + always `Badness::ZERO`, an infeasible one always `Badness::WORST`), `tolerance` has + exactly two reachable settings, `Badness::WORST` (neutral, matching `FirstFit`'s own + leniency) and everything below it (admitting only feasible lines) — stated in + `Search::Optimal`'s own doc rather than left for a caller to discover empirically. + Tolerance exhaustion (no complete arrangement stays within it) re-minimizes once more over + the full, un-pruned edge set rather than panicking or inventing a forbidden break + (ADR-0010); the reading is published as `docs/decisions/tolerance-exhaustion.md` + (`Standing::Unstated`, added to `docs/decisions/README.md`'s own table) rather than left a + `Slots` entry, because the search itself is filled — only this one open design choice was. + - `Line::pull_up` is populated under `Search::Optimal`: `Some` exactly when a shorter, + evaluated alternative existed for the same line's own start and the chosen, longer line + needed real reduction to fit it — the reduction-preferring comparison ADR-0010 describes, + applied to two candidate breaks that both actually existed, never reverse-engineered from + what "should" look right (`compose_optimal`'s own `pull_up_of`, covered by a direct unit + test independent of any full composition). + - The round's own required experiment: actively constructing a paragraph on which + `FirstFit` and `Optimal` disagree, rather than assuming the two published claims that + they cannot. Both are now falsified and repaired. `docs/design/api-spine.md`'s former + "[`Preference`] reaches the same answer by comparison, which is why the two searches + agree" held only *per line, given an identical range* (both drain the identical ladder in + the identical order once a range is fixed) — a constructed three-ideograph paragraph + (`least_adjustment_prefers_the_shallow_but_overfull_arrangement` / + `even_texture_prefers_the_feasible_arrangement` in `compose.rs`'s own test module) shows + `least-adjustment` preferring a single, violating line over `FirstFit`'s own two-line, + fully expanded answer, because `least-adjustment` ranks `expansion_depth` ahead of + `badness`. `ROADMAP.md`'s former "the greedy search and the optimal one cannot disagree + about when a character hangs" is narrowed the same way and repaired with a second + constructed pair + (`firstfit_and_optimal_disagree_about_whether_a_trailing_full_stop_hangs`): a trailing + full stop hangs under `Optimal` (which keeps it on one line with both ideographs, needing + only reduction and hanging to fit) but never reaches `ladder::hang` at all under + `FirstFit` (which puts it alone on a short, exempt last line first). Both fixtures are + hand-verified and their numbers checked against the actual test run, not asserted from + hand math alone. + - `crates/jlreq-line/src/lib.rs`'s own `# Status` states why `Search::Optimal` is named in + neither the "Wired, not slotted" nor the "Slots" list — it is new logic, not another + crate's rule table read through, and it is filled rather than an unfilled seam — and + restates that §3.5.4's widow threshold stays a real, named gap beside it: `Search::Optimal` + does not read `Paragraph::with_widow_threshold`, and §3.5.4 stays `[[deferred]]` to a later + M3 round in `docs/conformance-deferrals.toml`, unchanged by this one. Every prior claim + that `Optimal` did not yet exist — in `compose.rs`, `objective.rs` (including + `Badness::of`'s own stale "the second value is never reached in practice because + `crate::Fit` classifies that line infeasible first", corrected: `crate::Fit` is never + constructed anywhere in this crate, and `compose`'s own `demerits_of` reaches + `Badness::WORST` on every violating line either search composes) and `lib.rs` — is repaired + in place rather than left to mislead a reader who trusts the prose over the code. +- The conformance suite can now ask `Search::Optimal` a question at all, and one case does, + per ADR-0006's independent-phase discipline: authored against §3.1.12's own words, not + against what `compose_optimal` happens to produce. + - `cases.schema.json` gains `input.search` (a `compose` case's chosen search — absent + reads as `Search::FirstFit`, exactly what every one of the 466 prior cases already + assumed, so none of them changed answer) and `line.pull_up` (`jlreq_line::PullUp`'s + three fields). `pull_up` is the one field on `line` whose *absence* is a checked + assertion — `Line::pull_up` is `None` — rather than "unchecked", on task #44 (round + 16)'s own precedent for `ExpectBoundary::rules`: the reading is safe applied + retroactively because `Search::FirstFit`'s own doc already guarantees `None` on every + line that search composes, so turning the comparison on changes what no pre-existing + case is measured against. `crates/jlreq-conform/src/case.rs`, `run.rs` and + `kumihan.rs` read and act on both fields; `xtask/src/conform.rs` gained its own + hand-written validation of `search` (`cases.schema.json` is a contract stated twice, + and this is the half a JSON-schema library does not run) and the `conform` census now + reports how many compose cases name a non-default search. + - `3.1.12/two-worked-examples/optimal-search-reports-the-pull-up-reduction-makes-available` + is the new case, in `crates/jlreq-conform/cases/3.1.12.json` beside the two existing + ones rather than in a file of its own: §3.1.12 ④ states the ideal, reduction-based + repair in the same breath as the one it excuses ("Ideally, a full width spacing + reduction would be applied, and the character... would be moved onto the first + line... In that way, the problem could be avoided"), and the sibling case immediately + above is deliberately built so that repair is unavailable. This entry is the missing + half: a six-item paragraph and four candidates in which the nearer break admits no + complete arrangement at all (verified directly, both by composing the remainder alone + and by withholding the farther candidate) and the farther one is consequently the only + admitted arrangement, not one preferred over a competing feasible one — the + discriminating test this round's own brief states, applied and passed rather than + asserted. Neither of the two published `Question::ADJUSTMENT_PREFERENCE` readings is + named, because neither changes the outcome. `docs/conformance-deferrals.toml`'s + `3.1.12` entry is updated to state what this case now covers; rule `3.1.12` stays + `[[owned]]`, and no rule moves from `[[deferred]]` to `[[owned]]` this round. + - Two stale claims this round's own work falsifies are repaired: `crates/jlreq-conform/ + src/kumihan.rs`'s module doc and its `compose` method no longer say `compose` asks only + `Search::FirstFit`, now that a case can ask for `Search::Optimal` instead; and + `docs/conformance-deferrals.toml`'s `3.5.4` entry no longer says widow adjustment + "arrives with the objective" — the objective has arrived and 3.5.4 is still deferred, + so the entry now states the real, checkable blocker instead + (`Paragraph::with_widow_threshold`'s own doc: neither search reads the field it + stores). A third, pre-existing claim is repaired on the same finding: + `docs/design/conformance.md`'s "Cross-search agreement" section described, in the + present tense, a gate that runs every case under both searches and compares them — + true of nothing in `jlreq-conform` today, sharper now that cases naming `Search:: + Optimal` actually exist and are not run under `Search::FirstFit` too. The section is + rewritten to say so plainly, keeping the design reasoning for when the gate is built + rather than deleting it. A fourth, adjacent claim in the same section — + "direction parity" composing every case both ways — was found false by the identical + method (no such loop exists in `jlreq-conform` either) but is unrelated to this + round's own changes and is left for the round that owns it, reported rather than + fixed here. +- §3.5.4's widow adjustment, wired: `Paragraph::with_widow_threshold`'s own field is read + for the first time, by `crates/jlreq-line/src/compose.rs`'s own `demerits_of` — the one + cost function `compose_first_fit` and `evaluate_edge` both call, so `Search::FirstFit` + and `Search::Optimal` are scored by one formula rather than two that could quietly + disagree (this round's own reuse of the M3 round 19 C1 argument). `demerits_of` grows + three parameters (`line: Range`, `is_last_line: bool`, `widow_threshold: + u16`), threaded the same way `adjust_line`'s own signature, one call earlier in the + identical pipeline, already threads the first two. Its own `..Demerits::ZERO` + struct-update tail is dropped rather than kept: once `structural` is computed rather + than left at its base value, all six of `Demerits`'s own fields are named explicitly, + and `clippy::needless_update` (part of the default `complexity` group, not only + `pedantic`) refuses a base that supplies nothing a literal does not already state — + a deviation from the round's own brief, which asked for the idiom kept, stated here + because a lint that fires is not optional. + - A new private `WidowFacts`/`widow_facts_of` pair reads the paragraph's own last line + (`is_last_line`, already derived identically at both call sites — `evaluate_edge`'s own + C3) and reports how many items it carries and how far short of the threshold that + falls, `u32::from(threshold).saturating_sub(have)` — shortfall-proportional, so an + unsatisfiable threshold still discriminates between a nearer miss and a farther one + rather than tying every violating arrangement. "A character" reads as an item + (ADR-0008), and a last item `crate::ladder::hang` let hang past the measure is still + counted: `hang`'s own `last` sits inside the line's own range, never past it. + - `demerits_of` adds the shortfall to `Demerits::structural` on exactly the last line; + `structural` already ranked first in both of `docs/decisions/adjustment-preference.md`'s + own orderings, so `Search::Optimal` genuinely steers toward a widow-free last line + when more than one arrangement admits one, ahead of every other component regardless + of how much worse it scores there — proved by a constructed fixture + (`optimal_steers_toward_a_widow_free_last_line_even_when_every_other_component_is_worse`) + where the search takes an arrangement carrying two ladder violations (`badness = + 20_000`) over a fully feasible one, purely because the feasible one's own last line + falls one item short. `Search::FirstFit` cannot do the same — it commits to one + candidate per line and never compares arrangements — so it only ever reports the + shortfall of the line it already greedily chose + (`first_fit_reports_a_widow_but_never_moves_the_break_to_avoid_it`, pinning the + asymmetry directly: the chosen breaks are byte-identical with and without a + threshold). + - A new `ViolationKind::Widow { have: u32, want: u16 }` variant (a minor addition under + `#[non_exhaustive]`, ADR-0012) is pushed, once, for the last line only, in both + `compose_first_fit`'s loop and `compose_optimal`'s own reconstruction loop, through a + shared `push_widow_violation` so the check is written once rather than twice. + `Violation::rule` names `RuleId::WIDOW_ADJUSTMENT_OF_PARAGRAPHS` — cited by no code + anywhere in this workspace before this round — rather than the generic line-breaking + rule every other violation in these two loops hardcodes, and `Violation::at` is the + last line's own start, the break that could have moved, not the paragraph's own end, + which is identical for every arrangement and says nothing. The violation is the point + and not a garnish on the demerit: `Demerits` is this crate's own invented objective and + a conformance case may never assert a demerit value as if it were JLReq's own answer + (round 8's own brief), so `structural` alone would leave round 22 nothing JLReq-shaped + to assert for the unsatisfiable case. + - `docs/decisions/widow-threshold.md` (new), modeled on `tolerance-exhaustion.md`'s own + four headings, publishes the four readings §3.5.4's silence forces and + `docs/decisions/README.md` gains its row: what counts as "a character" (an item); + whether a one-line paragraph can have a widow (yes, read literally — the exempting + reading would add a condition the specification does not state, and the reading costs + nothing because a constant addend across one candidate changes no comparison, + `run_dp`'s own C2); the penalty's own shape (shortfall-proportional); and what an + unsatisfiable threshold means (both remaining ADR-0010-licensed mechanisms together — + graceful degradation through `structural`, plus the reported violation — never a + refusal, and never the schedule-inventing relaxation `tolerance-exhaustion.md` already + rejected by name for the identical reason). Seven new unit tests in `compose.rs`'s own + `#[cfg(test)] mod tests` pin all of the above, including the threshold-0 no-op that is + this round's own regression guard for all 834 pre-existing tests and all 467 + conformance cases, and the zero-item paragraph, checked directly rather than assumed, + never growing a widow violation with nothing to report `have`/`want` for. + - Every stale claim this round's own work falsifies is repaired in place. `compose.rs`'s + `run_dp` doc no longer names `structural` "always 0 at this milestone" as a premise of + its saturation bound — the replacement premise is stronger, not merely updated: + `structural` cannot saturate at all, for any input, because the widow term lands on + exactly one edge of any complete path and is bounded at `u16::MAX = 65,535`, so + `badness` remains the one component the bound has to name. + `Paragraph::with_widow_threshold`'s own doc no longer opens "Stored and still not + read." `objective.rs`'s `Demerits::structural` field doc no longer says "Always zero + even now that `Search::Optimal` exists." `crates/jlreq-line/src/lib.rs`'s own + `# Status` no longer names the widow threshold "a real, named gap" beside + `Search::Optimal` — it moves from the gap list to the filled, "neither Wired-not- + slotted-nor-a-Slot" list `Search::Optimal` itself already occupies, and the four + published readings replace it as what is honestly still open. + `docs/conformance-deferrals.toml`'s `3.5.4` entry no longer blames the DP for not + reading the threshold — it does now — and states the real, current blocker instead: + coverage, not implementation, per ADR-0006's independently authored phases. + `docs/design/api-spine.md` gains the new `ViolationKind::Widow` variant at its own + frozen enum listing and a sharper one-line doc for `with_widow_threshold`. + `docs/decisions/adjustment-preference.md` gains one sentence noting `structural`'s own + first-rank position is now reachable rather than reserved, without reopening the + ranking itself, which this round does not revisit. `compose_optimal`'s own + reachability doc, `Search::Optimal`'s own "read by both search variants alike" + sentence, and `tolerance-exhaustion.md`'s own FirstFit-comparability sentence all + survive verbatim, checked rather than assumed: all three rest on `demerits_of` being + the one shared cost function, a fact this round's wiring preserves rather than forks. + - One stale claim this round's own sweep found and did not fix, on the same finding + method as prior rounds' "direction parity" precedent: `docs/design/api-spine.md`'s own + `ComposeError` block lists two variants (`OutOfRange`, `CandidateOutOfRange`) while the + real enum has three (`crates/jlreq-line/src/compose.rs`'s own `InsufficientTabStops`, + from the §3.6 tab-setting round) — pre-existing drift, unrelated to this round's own + changes, checked directly (`api` gate parses the spine only for `Question` constant + counts, never for enum variant listings, so nothing catches this mechanically) and left + for the round that owns `ComposeError`, reported rather than fixed here. + - **Phase discipline held**: no file under `crates/jlreq-conform/` changes this round, + no conformance case is authored, and rule 3.5.4 stays `[[deferred]]` to M3 — task #58's + entire reason to exist, per ADR-0006. +- Task #58 (round 22) is that independently authored phase, and closes M3's deferral list to + zero. + - `input.widow_threshold` (a non-negative integer, absent reading as `0`) reaches the case + format for the first time: `cases.schema.json` gains its own description in `search`'s + own long voice, `crates/jlreq-conform/src/case.rs`'s `CaseInput` gains the field, and + `crates/jlreq-conform/src/kumihan.rs`'s `compose` reads it through a plain `if let` + guard — not paired with `head_indent`/`end_indent`'s own `with_indents` call, because + `Paragraph::with_widow_threshold` takes one field and has no sibling for a case to state + without it, the doc now says so rather than leaving a reader to wonder. `xtask/src/ + conform.rs` gains `check_widow_threshold`, bounding a stated value at `0..=u16::MAX` — + mirroring `check_search`'s own bound on `tolerance` — so a threshold this reader cannot + hold declines `conform --check` rather than silently declining the case at runtime. No + census line: unlike `Search::Optimal`, a whole second search variant that earned its own + `optimal_search_census` line at round 20, this field is a scalar parameter the same shape + as `first_line_indent`, `head_indent` and `end_indent`, none of which has one either. + - `crates/jlreq-conform/cases/3.5.4.json` (new), three cases, derived from §3.5.4's own + sentence and `docs/decisions/widow-threshold.md`'s own four published readings before + this round's own suite run, on the same discipline round 20's `3.1.12.json` states in + full. Q2 (a one-line paragraph can have a widow): a two-item, one-line paragraph + composed with an empty `candidates` array, threshold above its own item count; the + exempting alternative is `forbidden`. Q1 (a character is an item): a threshold-equal / + threshold-past-the-count pair over a last line built from two Western-letter clusters on + the proportional frame (`fi`, `if` — ADR-0018's own ligature exception), so the last + line's item count (2) diverges from both its code-point count and its byte count (4 and + 4), discriminating item-counting from either. Q4 (violation, never refusal) is carried + through the same channel both cases already use — a non-empty `lines` beside a real + violation — and the Q1 pair's own discriminating case additionally rejects the + relaxation alternative in `forbidden`, by name. Every case is `standing: "normative"`, + not `"unstated"`: `conform --check`'s own `check_standing` requires `permitted` to carry + more than one reading whenever standing is `unstated`, `adjudicated` or `alternative`, + and none of these three cases has a second `Policy`-reachable answer for a second entry + to name — the rejected alternative lives in `forbidden` instead, which carries no such + requirement. `crates/jlreq-conform/tests/suite.rs` gains `section_3_5_4 => "3.5.4" [3 + attempted, 0 not attempted]`; all three agree with this workspace on the first run, zero + disagreements, arithmetic derived by hand from `table1.rs`'s own cl-27×cl-27 and + cl-19×line-end/line-head×cl-27 cells (all blank) before the suite ever ran. + - `docs/conformance-deferrals.toml`'s `3.5.4` entry moves from `[[deferred]]` to + `[[owned]]` at M3 (M3's deferred count: 1 → 0), with an honest scope limit rather than a + claim of full coverage: it states which of the four readings a case reaches (Q1, Q2, Q4) + and names the two it does not. Q3 (the penalty's own shape) is reported unconstructible + for a reason sharper than difficulty deriving one fixture by hand — no `Policy` question + selects a flat penalty, so no case in this format can compare the proportional reading + against a reachable alternative, a limit of the format itself. The hanging wrinkle + requires `adjustment.hanging_punctuation = hanging`, which `Policy::JLREQ` does not + select, so a case exercising it would be published but never attempted, `3.8.2.json`'s + own already-stated standing for a different rule; this round declines to publish one for + that reason. The entry also states that this suite checks `ViolationKind::Widow`'s own + address only, never its `have`/`want`, because `kumihan.rs`'s own `compose` discards both + before the case format ever sees them. + - Every stale claim this round's own work falsifies is repaired in place, on the same + sweep discipline round 21's own entry above states: `objective.rs`'s + `Demerits::structural` field doc and `crates/jlreq-line/src/lib.rs`'s own `# Status` no + longer say §3.5.4 "stays `[[deferred]]`"; `docs/decisions/widow-threshold.md`'s own + closing paragraph no longer says the suite carries none of the four readings — it now + states which two it carries and which two it does not, in the same terms the ledger + entry above uses. +- M1 round 11: the published conformance format's sixth `kind`, `feasible`, and the `Runs` + overlay that answers it — closing §C.2#13's own deferral. + - `crates/jlreq-conform/cases.schema.json` gains `"feasible"` in `input.kind`'s enum and a + `feasible` `$def` for `expect.feasible` (`candidate`, `breakable`, `rules`), in the long + voice `search`'s and `ruby`'s own descriptions use: which of the caller's own UAX #14 + candidates kinsoku permits and which rule refused each of the rest; why it is a separate + kind rather than a `boundary` field (a `boundary` answer is Tables 1 and 2 at one + adjacency, a candidate's survival is `jlreq-line`'s own refusal layer, which additionally + reads a construct overlay no table cell can express — §C.2#6 through #8 and #13); and + that `constructs` is the one field this kind reads as load-bearing rather than declining + on account of, unlike every other kind. + - `crates/jlreq-conform/src/case.rs`: `KINDS` grows to six. `Expect` gains + `feasible: Option`, read by `read_feasible` on `read_boundary`'s own + "every field optional" convention, and `Expect::is_silent` now checks it too — the quiet + bug this round's own review caught before the gate battery could hide it: without that + one line, every `forbidden` entry this round writes would have excluded nothing. + `CaseConstruct` gains `style`, read from a `ruby` entry's own field, which the adapter + needs to choose between `NonJukugoRuby` and `JukugoRuby`. + - `crates/jlreq-conform/src/run.rs`: `Compose` gains a sixth method, `feasible`, required + rather than defaulted for the identical reason `align` and `tab` already are. + `CaseFeasible` (`breakable`, `rules`) answers it; `Answer::Feasible` and `ask`'s own + `"feasible" =>` arm route it, distinctly from `align`'s and `tab`'s reuse of + `Answer::Composed` — a candidate's own survival is nothing like a composed line. + `check_feasible` reuses `check_boundary`'s own rules comparison, `check_rules`, + generalized from `&CaseBoundary` to `&[String]` on both sides rather than duplicated, for + the identical subset-not-equality reasoning stated once. + - `crates/jlreq-conform/src/kumihan.rs`: `Compose::feasible` is the one method of the six + that builds a real, non-`Runs::none()` overlay. The private `overlay_of` converts a + case's declared `constructs` into one slot per item of the base stream, honestly and + totally over the schema's nine construct arrays: `ornaments` and `tate_chu_yoko` convert + unconditionally, `ruby` converts when `style` is `"mono"`, `"group"` or `"jukugo"`; + `emphasis`, `jidori`, `formulae`, `warichu`, `furiwake` and `reference_marks` all decline, + each for a reason its own doc states — no `ConstructKind` variant, an undeclarable + `FormulaSetting`, or a declared range the schema does not pin to mean what the matching + variant means. Every slot's `group` stays `None` (§C.2#8's own level below the run needs + `ruby.runs`, not read this round), which `docs/decisions/jukugo-ruby-unset-group.md`'s + own reading already treats as permitted rather than refused. One inconvertible construct + anywhere in a case fails the whole conversion rather than leaving a silent gap in the + overlay. The module doc's own "All five methods... every `Runs` this crate builds is + `Runs::none()`" claims are repaired to name the sixth method and the one place a real + overlay now exists. + - `xtask/src/conform.rs`: `check_input` requires `candidates` (never `measure`) of a + `feasible` case, and its `kind` match is now checked against an explicit `INPUT_KINDS` + list instead of falling through a silent wildcard — an unrecognized `kind` is a + violation now, rather than being quietly asked `compose`'s own required fields. + `check_question` holds a `feasible` case to the identical "one input, one question" + invariant `classify` and `boundary` already are. `Suite::census`'s own kind-counting line + — extracted to `kind_census`, alongside `optimal_search_census`, to stay under + `clippy::too_many_lines` — reports the new kind's count. + - `docs/design/conformance.md` gains the sixth trait method and `CaseFeasible` in the same + voice as the rest of the document; every stale "five methods"/"five questions" claim + across `crates/jlreq-conform` and this document is repaired to six, and + `crates/jlreq-conform/src/lib.rs` newly re-exports `CaseFeasible` and `ExpectFeasible` at + the crate root — without which no implementation outside this crate could even name + `Compose::feasible`'s own return type in its own `impl`. + - `crates/jlreq-conform/cases/C.2.json` gains two `feasible` cases, derived independently + from §C.2#13's own two sentences rather than from `jlreq_line::feasible:: + same_run_refusal`'s own match arms or its test module's fixtures (ADR 0006's own hazard + for this route, read twice before writing either case): + `two-characters-in-one-tate-chu-yoko-run/no-break-inside` (interior of one declared run, + refused, citing `C.2#13`) and `two-tate-chu-yoko-runs-adjacent/break-permitted` (the + boundary between two declared runs with nothing between them, permitted). Both sit at + cl-15 against cl-15 (ordinary hiragana), a coordinate independently verified blank in + Table 1, Table 2, Table 3's line-end row and Table 4's line-head row + (`spec/captured/table1.en.tsv` through `table4.en.tsv`, read directly rather than + inferred from this evaluator's own answer), so the refusal and the permission each case + asserts can only be `same_run_refusal`'s own citation, never a class-pair prohibition + coinciding by accident. `crates/jlreq-conform/tests/suite.rs`'s own committed census for + `C.2` moves from `[8 attempted, 0 not attempted]` to `[10 attempted, 0 not attempted]`. + - `docs/conformance-deferrals.toml`: §C.2#13 moves from `[[deferred]]` to `[[owned]]` at M1 + (M1's deferred count: 5 → 4), stating which two cases now measure it and by what + mechanism. §C.2#6's and §C.2#7's existing `[[owned]]` entries are repaired: both + previously said only that a boundary answer was published and "none receives yet" or + "published as two boundary cases," without saying which cases or why none is answered; + both now name the specific cases (`A.21/inside-one-complex/*` and + `A.21/between-two-complexes/break-opportunity` for §C.2#6; `A.22/same-complex/ + no-break-inside` and `A.22/distinct-complexes/break-and-solid` for §C.2#7) and state + plainly that every one of them declares a construct `Kumihan::boundary` declines per + item, so this workspace answers none of them today — the ledger's own header already + provides for exactly this state. §C.2#8 stays `[[deferred]]`, its own `why` rewritten to + name the one gap this round did not close (`ruby.runs`'s own group reading in + `read_constructs`) rather than the longer list of blockers this round's own overlay + machinery removed. + - The route not taken, and why: threading a caller-supplied `Runs` into + `jlreq_line::compose` itself was rejected in favor of the `feasible` kind, for the three + reasons `crates/jlreq-line/src/compose.rs`'s own `Runs::none()` comment and this round's + own design already state — `jlreq_spacing::evaluate::delegation_of` would silently + switch on §B.2#10/#11 delegation with no case behind it, `jlreq_class::resolve` stays + construct-blind so a spacing amount inside a construct run would still answer the items' + bare classes, and a same-run refusal is only ever observable as a differently-placed + break, never as a cited rule — exactly the citable fact ADR 0006 needs a case to assert. +- M1 round 12: six `feasible` cases over §C.2 notes 6, 7 and 8, and the retraction of the + former `C.2#8` deferral's own stated blocker. + - `crates/jlreq-conform/src/kumihan.rs`: `overlay_of`'s own doc gains a new section, + `` `ruby.runs` is a declared slot this function does not read ``, in the "Slots" sense + `crates/jlreq-line/src/lib.rs`'s own module doc names — a seam a later, independently + authored phase fills, not a gap this round left behind. No field is added; the paragraph + states three facts as the reason the schema-required `annotation` and `runs` stay unread: + a declared `GroupId` changes exactly one downstream answer (`same_run_refusal`'s own + `JukugoRuby` arm); §C.2#8's own group is one base character and its own accompanying + reading, never a span across two, so two adjacent base characters of one complex are + never one group (§3.3.7's own body and §3.1.10 item 8's own Note, both quoted); and + `Feasible::compute` sees the base item stream alone, so the level the note's third + sentence is about is unreachable from this crate before `jlreq-inline` exists (M4-a). This + is the only Rust change of the round — `crates/jlreq-line/**` and + `crates/jlreq-conform/src/case.rs` are untouched, and `cases.schema.json` is unchanged, + since `feasible`, `ruby`, `run` and `constructs` were already adequate. + - `crates/jlreq-conform/cases/C.2.json` gains six `feasible` cases, authored as the + independently authored phase ADR 0006 requires — verified against the note's own English + sentences and the captured tables before being checked against this workspace's own + answer, not derived from `same_run_refusal`'s own match arms: two for §C.2#6 + (`two-characters-in-one-ornamented-complex/no-break-inside`, + `two-ornamented-complexes-adjacent/break-permitted`), two for §C.2#7 + (`two-base-characters-in-one-simple-ruby-complex/no-break-inside`, + `two-simple-ruby-complexes-adjacent/break-permitted`), and two for §C.2#8 + (`two-jukugo-ruby-complexes-adjacent/break-permitted`, + `two-base-characters-in-one-jukugo-ruby-complex/break-permitted`). Every one of the six + sits at cl-19 against cl-19, independently verified `blank` in Tables 1 through 4 + (`spec/captured/table1.en.tsv` through `table4.en.tsv`; Table 6's own cell there, + `0-1/4 stage 3`, is a third-order expansion opportunity named and set aside rather than + silently omitted), so `jlreq_line::feasible::same_run_refusal`'s own citation is the only + thing any of the six answers can be. The load-bearing pair reuses two existing fixtures + verbatim with `kind` changed to `feasible` and one candidate added: + `A.23/simple-ruby-complex/mono-ruby-twin`'s own input for the simple-ruby refusal and + `A.23/jukugo-ruby-complex/first-base`'s own input for the jukugo-ruby permission, which + (per `A.23/simple-ruby-complex/mono-ruby-twin`'s own rationale) differ in exactly one + declared field, `style` — so the two new cases' answers, `breakable: false` against + `breakable: true`, diverge over that one field alone, and an implementation that gave + every same-run `ruby` construct one always-refuse rule fails the second while passing the + first. `crates/jlreq-conform/tests/suite.rs`'s own committed census for `C.2` moves from + `[10 attempted, 0 not attempted]` to `[16 attempted, 0 not attempted]`. + - `docs/decisions/jukugo-ruby-unset-group.md`'s own closing paragraph is rewritten: + `C.2/two-base-characters-in-one-jukugo-ruby-complex/break-permitted` now exercises this + reading's own permissive outcome (both sides carry no group, exactly as `overlay_of` + always builds them, and the case asserts the break permitted), corroborating it rather + than merely being covered by the unit test the old paragraph named alone — but the + refusing half of the reading, two occurrences with *equal, declared* groups, still has no + case that can exercise it, since no case in this suite can declare a `GroupId` at all. + - `docs/conformance-deferrals.toml`: `C.2#8` moves from `[[deferred]]` to `[[owned]]` at M1 + (M1's deferred count: 4 → 3, M1's owned count: 40 → 41), naming the two new cases and + retracting the former entry's own stated blocker outright rather than merely closing it — + that entry named `ruby.runs`'s unread `base`/`annotation` pairing as what stood between + this note and a case; the actual reason is that the group level it would populate answers + a question no base-to-base candidate this crate can construct is asking at all, verified + against §3.3.7 and §3.1.10 item 8 directly rather than assumed from the prior entry's own + words. The new entry states the scope limit honestly: the note's own third sentence, + ruby-to-ruby indivisibility, is not measured and cannot be until `jlreq-inline` (M4-a). + `C.2#6`'s and `C.2#7`'s own `[[owned]]` entries are rewritten to name the four new cases + and keep the honest half each already carried — the A.21 and A.22 boundary cases they + named before remain unanswered, for the identical `CaseInput::construct_covers` reason. + `C.2#13`'s own entry, which this round also falsifies, is trimmed: its closing sentence + used to say `ornaments` had no `feasible` case and that §C.2#8 stayed deferred for + `ruby.runs`; both clauses are now false and are replaced with a pointer to the three + entries above that now state their own current answer directly. +- `jlreq-inline`, M4-a round 1: mono-ruby lowering, the first coherent slice of M4-a. The + crate is no longer a bootstrap; it depends on `jlreq-class`, `jlreq-spec` and `jlreq-unit` + (`ARCHITECTURE.md`'s own declared row) and declares `ruby.rs`, `lower.rs` and `tcy.rs`. + `Ruby::new` takes both the annotated text and the reading, validating that the base range + lies inside the text, every declared `RubyRun`'s base and annotation ranges lie inside + their own streams, the runs cover both in order without overlap, and the run count + matches what `RubyStyle::MonoRuby`, `RubyStyle::GroupRuby` or `RubyStyle::JukugoRuby` + requires; `Ruby::with_alignment` overrides `Question::RUBY_ALIGNMENT` per construct + (ADR 0019's precedence rule). `Constructs::over`/`with_ruby`, `Lowered` and `Contribution` + stand up the seam-facing half of `docs/design/api-spine.md`'s `jlreq-inline` section, and + `lower` genuinely computes all four of `Contribution`'s outputs for `RubyStyle::MonoRuby`: + a fresh `RunId` per base item (§3.3.5, §3.3.1's note — this is what gives two adjacent + annotated bases §E.2 note 6's own quarter-em expansion opportunity), a `BlockDemand` per + declared run from `Annotation::size_of` on the block-start side (§3.3.4), and a + `Separation` wherever a base's reading is genuinely longer than its own supplied advance + and the neighbor it would otherwise overhang resolves to cl-19 (§3.3.8 rule 1) — the + surplus split evenly between the run's two boundaries and, where two runs' own shares + land on one shared boundary, merged by the greater of the two rather than their sum + (`docs/decisions/mono-ruby-separation-split.md`, a new published reading: §3.3.5(a)'s own + centered geometry for nakatsuki, and, for katatsuki, its own second method's asymmetric + hangover choice has nothing left to choose among once every reachable neighbor is cl-19, + so the identical symmetric split survives under either alignment for this seam output). + `RubyStyle::GroupRuby` and `RubyStyle::JukugoRuby` get real run identity — one shared + `RunId` across a group-ruby's whole base range, one shared `RunId` across a jukugo-ruby + compound with a fresh `GroupId` per base item inside it (§B.2#11, §C.2#8) — and real block + demand, but no `Separation`: `Question::GROUP_RUBY_DISTRIBUTION` and + `Question::JUKUGO_RUBY_LAYOUT` (with Appendix F) are named as unfilled slots rather than a + citable zero. `Question::RUBY_OVERHANG_KANA` and `Question::RUBY_OVERHANG_INDENT` are + unfilled slots too, for mono-ruby's own narrower scope: only rule 1's absolute cl-19 + prohibition is answered, never the permitted overhang those two questions govern. + `lower` also resolves whether a per-construct or policy-default alignment is katatsuki in + horizontal writing — §3.3.5's own direction-conditional recommendation, honored regardless + and never refused (ADR 0011) — which is why it is the allowlisted `[[site]]` for §3.3.5 in + `docs/direction-sites.toml`, retiring that file's own `[[pending]]` entry for it. The + resolution is recorded rather than read once and dropped: `Contribution::alignment_of` and + `Contribution::alignment_discouraged` are this round's own carrier of ADR 0019's "every + answer records which of the two applied", pending a later round's `place()` or + `jlreq::diagnose`'s own `AlignmentDiscouraged` to make it a caller-facing report. + `TateChuYoko::new` states §3.2.5's own availability fact alone — no horizontal + tate-chu-yoko exists to refuse into `NotAvailable` otherwise — added specifically because + `docs/direction-sites.toml`'s own `[[pending]]` mechanism keys on whether a crate has + declared *anything*, not on which item will do the reading, so the moment `ruby.rs` + declared a `struct` both of `jlreq-inline`'s pending entries went stale at once; this round + retires the §3.2.5 one honestly, by implementing the one sentence of tate-chu-yoko that is + genuinely self-contained, rather than leaving it to lapse unrepaired or implementing the + segment `Constructs::with_tate_chu_yoko` would need, which stays absent — an + accepted-and-ignored `with_*` would be worse than the absence, so no such method exists and + `lower` never sees a `TateChuYoko`. `place()`, `Attachment`/`Attachments`, `RubyOverhang` + resolution, and the other eight constructs `docs/design/api-spine.md` names are unstarted, + named as such in `crates/jlreq-inline/src/lib.rs`'s own rewritten `# Status`. + `docs/conformance-deferrals.toml`'s `3.3.2`, `3.3.5`, `E.2#6`, `E.2#7` and `3.3.8` entries + are rewritten against this reality: §3.3.2 and half of §3.3.5 are genuinely read now but + still have no conformance case (no kind in this suite observes a `Contribution`, task #74); + the other half of §3.3.5, and all of §3.3.6/§3.3.7's own distribution, remain `place()`'s + later work; `E.2#6` and `E.2#7`'s own prior entries are corrected independently of this + round's own reachability, not only extended by it — `jlreq_class::resolve` never took a + construct parameter at all, so it was never accurate to say it "computes no run overlay + until `jlreq-inline` places ruby," and `crates/jlreq-conform`'s own `Compose::boundary` and + `Compose::compose` decline unconditionally over any declared construct regardless of run + identity, which is the actual reason neither note's own Table 6 coordinate is reachable by + a case yet; and `3.3.8`'s own `[[owned]]` entry now distinguishes its two halves — rule 1's + forced separation is a real, tested evaluator mechanism as of this round, rules 2 through 6 + remain entirely unattempted. `clippy.toml` gains `enum-variant-name-threshold = 4`, + reviewed and documented, so `RubyStyle`'s three JLReq-named variants (`MonoRuby`, + `GroupRuby`, `JukugoRuby`) do not trip `clippy::enum_variant_names` at the workspace's own + three-variant default. +- M4-a round 2: the `jlreq` → `jlreq-inline` facade edge, and the published conformance + format's seventh `kind`, `lower` — closing §3.3.5's own deferral and giving §3.3.8's own + `[[owned]]` entry its first genuine cases. + - `crates/jlreq/Cargo.toml` gains `jlreq-inline` as a dependency, and `crates/jlreq/src/ + lib.rs` re-exports its whole public surface (`Constructs`, `Contribution`, `LowerError`, + `Lowered`, `Ruby`, `RubyAlignment`, `RubyError`, `RubyRun`, `RubyStyle`, `TateChuYoko`, + `NotAvailable`, `lower`) in the same `pub use jlreq_*::{…}` shape the other five layers + already get — an edge `xtask/src/purity.rs`'s own `CRATE_GRAPH` and `ARCHITECTURE.md`'s + own crate-boundary table already sanctioned, so this is the edge existing, not a gate + changing. The crate's own `# What is here today` and `# Status` sections are rewritten + against what is actually true now: six layers rather than five are re-exported; the + reduction, hanging and expansion ladders `jlreq_line::ladder` implements are no longer + named as unfilled slots; "every construct-bearing input is `jlreq-inline`'s, which does + not exist yet" is repaired to state precisely what is real (mono-ruby lowering) and what + is not (placement, the other eight constructs); and the `diagnose` sentence, which used + to read as though the function should exist now that the crate that carries the + constructs has arrived, is repaired to name it as still unwritten. + - `crates/jlreq-conform/cases.schema.json`: `input.kind`'s enum gains `"lower"`, with a + paragraph beside `feasible`'s own stating what a `lower` case asks — not a line-layer + question at all, but what `jlreq_inline::lower` resolved for one declared `ruby` + construct — and that it requires `constructs` and reads none of `measure`, `candidates`, + `alignment`, `tab_starts` or `tab_stops`. A new `$defs/lower` (`construct`, `same_run`, + `separations`, `alignment`, `alignment_discouraged`, `rules`) sits beside `$defs/ + feasible`, with `$defs/same_run` and `$defs/lower_separation` beside it — `same_run` an + object (`{ "items": [i, j], "same": bool }`) rather than a bare triple, this format's own + established practice; `separations` a *total* list, `boundary.spaces`'s own convention, + so a case stating one entry asserts both that it exists and that the answer carries no + other; `least` a bare unit count rather than a `$defs/amount` fraction, because unlike + Table 1's own terms this amount is not a fraction of an em JLReq states anywhere. No + alignment-override field is added to `$defs/ruby`: ADR-0019's per-construct-beats-policy + precedence is this workspace's own bookkeeping, not something JLReq states, and a case + asserting it would measure kumihan's own API rather than the specification — it stays + covered by `crates/jlreq-inline/src/lower.rs`'s own unit tests, and a `lower` case + selects between the two alignments through `permitted[].policy`'s own `ruby.alignment` + overlay instead. `$defs/constructs`'s and `boundary.rules`'s own descriptions are + repaired: `lower` joins `feasible` as a kind `constructs` is load-bearing for, and the + twelve pre-existing `A.16.json`/`A.22.json` boundary-`rules` declarations are still not + live — not because `jlreq-inline` does not exist, which is no longer true, but because + `Compose::boundary` still declines outright over any construct-covered item, exactly as + it did before this round and for an unrelated reason. + - `crates/jlreq-conform/src/case.rs`: `KINDS` grows to seven. `Expect` gains + `lower: Option`, read by `read_lower`, and `Expect::is_silent` checks it + too. `ExpectLower`, `ExpectSameRun` and `ExpectLowerSeparation` are the new types. + `CaseConstruct` gains `annotation` and `runs` (a new `CaseRun`), read from a `ruby` + entry's own fields — the part `Compose::lower`'s own adapter needs and `Compose:: + feasible`'s never did, on this module's own "read here rather than reach into the raw + JSON a second time" principle. + - `crates/jlreq-conform/src/run.rs`: `Compose` gains a seventh method, `lower`, required + rather than defaulted for the identical reason `feasible` already is — a breaking change + to a published trait, exactly as adding `feasible` was. `CaseLower` (`runs`, + `separations`, `alignment`, `alignment_discouraged`, `rules`) answers it; `Answer::Lower` + and `ask`'s own `"lower" =>` arm route it, distinctly from `align`'s and `tab`'s reuse of + `Answer::Composed` — one construct's own run identity, forced spacing and resolved + alignment is nothing like a composed line. `check_lower` compares `same_run` against the + answer's own per-item run identity, `separations` as a total list (`check_spaces`'s own + convention), `alignment`/`alignment_discouraged` by equality when stated, and `rules` + through the same `check_rules` `boundary.rules` and `feasible.rules` already share. Every + "six methods"/"six questions" claim in this module's own docs, including the `Answer` + enum's own "four variants for six questions" and the wildcard-arm hazard prose in `ask`'s + own doc, is repaired to seven and five respectively, with `lower`'s own hazard stated + beside `feasible`'s. + - `crates/jlreq-conform/src/kumihan.rs`: `Compose::lower` is the second method that does + not inherit the construct-blindness `classify`, `boundary`, `compose`, `align` and `tab` + all share, and it is not a milder version of `feasible`'s own exception — it never calls + `jlreq_class::resolve` or any `jlreq_line` entry point at all, only `jlreq::lower` + (`jlreq_inline::lower`) directly. Three new staged helpers build the real `jlreq::Ruby` + slice a case's declared `constructs.ruby` describe — `annotation_streams_of`/ + `annotations_of` (a two-phase read into `jlreq_class::Annotation`, staged because an + `Annotation` borrows its items and scales and a temporary cannot outlive it) and + `ruby_runs_of`/`rubies_of` (the identical staging for `jlreq::RubyRun`, which `jlreq:: + Ruby::new` also borrows) — declining the whole case the moment any declared construct is + not `ruby`, `jlreq::Ruby::new` refuses one (`RubyError`), or `jlreq::lower` itself refuses + the result (`LowerError`). The module's own doc is rewritten: "every kind but `feasible` + either declines outright... or declines per item" is repaired to name `lower` as the + second exception, and states precisely why its own exception is a different shape from + `feasible`'s rather than a milder version of it. + - `xtask/src/conform.rs`: `INPUT_KINDS` grows to seven; `check_input` requires + `constructs` (never `measure` or `candidates`) of a `lower` case; `check_question` holds + it to the identical "one input, one question" invariant `classify`, `boundary` and + `feasible` already are, keyed on `expect.lower.construct`. `kind_census` reports the new + kind's count, and a `MINIMAL_LOWER` fixture plus + `the_kind_census_line_counts_a_lower_case_by_its_own_kind` mirror the identical `feasible` + precedent (round 20's own `optimal_search_census` pattern, applied a third time). + - `crates/jlreq-conform/tests/suite.rs` gains `section_3_3_5` (`[2 attempted, 0 not + attempted]`) and `section_3_3_8` (`[2 attempted, 0 not attempted]`). + - `docs/design/conformance.md` gains the seventh trait method and `CaseLower` in the same + voice as the rest of the document; every stale "six methods"/"six questions" claim is + repaired to seven, and `crates/jlreq-conform/src/lib.rs` newly re-exports `CaseLower`, + `ExpectLower`, `ExpectLowerSeparation`, `ExpectSameRun` and `CaseRun` at the crate root. + - `crates/jlreq-conform/cases/3.3.5.json` (new): two `lower` cases closing §3.3.5's own + deferral. `ruby-alignment/policy-selects-nakatsuki-or-katatsuki` asserts + `Contribution::alignment_of` against both of `Question::RUBY_ALIGNMENT`'s choices; + `katatsuki-in-horizontal-writing/discouraged-but-honored` asserts `Contribution:: + alignment_discouraged` against the section's own "should not be adopted" recommendation + — honored and reported, never refused (ADR-0011), with the resolved alignment staying + katatsuki rather than silently reverting. Both cases' own katatsuki-selecting entries are + published and checked but not genuinely exercised by this workspace's own committed run: + `crates/jlreq-conform/tests/suite.rs` only ever constructs `Kumihan::default()`, whose + declared `ruby.alignment` is `Policy::JLREQ`'s own nakatsuki default, so the katatsuki + entries are statements to another implementation that declares the alternative (ADR + 0006) — `crates/jlreq-inline/src/lower.rs`'s own `katatsuki_is_honored_and_discouraged_ + only_in_horizontal_writing` unit test is what exercises them directly. `docs/ + conformance-deferrals.toml` moves §3.3.5 from `[[deferred]]` to `[[owned]]` at M4, + stating this scope limit plainly and naming `place()` (task #78) as the section's own + remaining, unstarted half. + - `crates/jlreq-conform/cases/3.3.8.json` (new): two `lower` cases giving §3.3.8's own + `[[owned]]` entry its first genuine coverage of rule 1's forced separation. + `forced-separation/only-beside-ideographic-neighbors` (`standing: "normative"`) asserts + existence and absence together — one oversized mono-ruby construct beside a cl-19 + neighbor forces a separation, an equally oversized construct beside a hiragana neighbor + forces none — with no asserted amount, since rule 1 states the prohibition and no + arithmetic. `forced-separation/even-split-by-remainder-policy` (`standing: "unstated"`) + asserts the even-split amount `docs/decisions/mono-ruby-separation-split.md` reads, one + mono-ruby construct between two cl-19 neighbors with an odd surplus, both `adjustment. + remainder` readings published side by side and neither asserted as JLReq's own + requirement — the discipline the §E.2#11 deferral already argues for a different + coordinate, applied here on purpose. `docs/decisions/mono-ruby-separation-split.md`'s + own closing sentence, which promised this task as the phase that would first exercise + its reading against a published case, now names both cases by id in the past tense. + - `docs/conformance-deferrals.toml`'s other stale `why` fields, repaired against what this + round's own reading of `spec/snapshot/index.html` and `crates/jlreq-unit/src/seam.rs` + finds rather than against what a prior round assumed: §3.3.2's own former reasoning (that + the only blocker was no conformance kind observing a `Contribution`) is retracted rather + than merely closed — the section's own body is entirely editorial choices an author makes + before ever declaring a `Ruby` (general-ruby, para-ruby, and para-ruby's own first- + instance variants), upstream of anything `lower` computes, with one mechanizable residue + (the compound-word recommendation) that needs jukugo lowering and `jlreq::diagnose`, + neither of which exists. §3.3.4's own former reasoning (that the physical side was "one + answer `jlreq-inline` produces… from a single rule") is replaced with the actual, verified + blocker: `jlreq_unit::BlockDemand::new`'s own doc defines its first extent as + direction-abstract — "toward the ruby side," never "above" or "to the right" — and `lower` + calls it identically at all three of its own call sites for every ruby style, so "start + extent non-zero, end extent zero" is structurally true of every demand this crate will + ever emit regardless of whether §3.3.4 was ever read, an observable that would pass + whether or not the mechanism existed. §E.2#6's entry is repaired to say that two kinds now + read a case-declared overlay (`feasible` since M1 round 11, `lower` new this round), not + one, while stating precisely why neither reaches Table 6's own expansion amount, which is + what the note is about. §3.3.8's `[[owned]]` entry states which two cases now measure rule + 1's forced separation, replacing the "no kind in this suite observes one" clause this + round falsifies. +- M4-a round 3: `jlreq_inline::place`, the placement half of §3.3.5 (task #78). New, + additive `pub fn place`, `Attachment` and `Attachments` on `jlreq-inline`, re-exported + from `jlreq`; neither `Lowered` nor `Constructs` changes shape in a way any existing + caller can observe (`Lowered` gains two `pub(crate)` buffers `lower()` never touches). + - `place()` genuinely computes three of §3.3.5's four positioning cases for + `RubyStyle::MonoRuby`: nakatsuki (中付き) centering, including a run genuinely longer + than its base, where the centering difference and its two shares go negative and the + run starts before its own base's placement; and katatsuki (肩付き) start-alignment + where the run is not longer than the base. §3.3.5(a)'s own two-hiragana-exactly-fills- + the-base case is not a fifth branch — at that ratio both alignments agree without + either reading a character count, and a unit test demonstrates the agreement falling + out rather than being special-cased. + - §3.3.5(c)'s own katatsuki-with-overflow choice — the section states two methods for it + in so many words, and no `Question` in `spec/derived/questions.tsv`'s own §3.3.5 + neighborhood resolves between them — is genuinely declined rather than guessed at: + `place()` emits no `Attachment` for such a run and reports it through the new + `Attachments::declined` instead. Giving that choice a policy `Question` is task #81, a + round of its own by design. + - `docs/design/api-spine.md`'s own `overhang: &[RubyOverhang]` parameter is a deliberate + omission this round, argued in `jlreq_inline::place`'s own module doc and reflected + back into the spine's sketch: nothing this round's three positioning cases reads a + per-boundary allowance, and an accepted-and-unread parameter is the silent defect this + crate already refuses elsewhere. The parameter returns at task #81, its first genuine + consumer. + - `Attachment::side` answers `Side::BlockStart` for every attachment this round produces, + and `Attachment::block` answers `BlockOffset::ZERO` for a different reason — this + signature carries no block-axis reference frame at all — and both accessors' own docs + say so plainly rather than let the constant answer read as §3.3.4 settled. §3.3.4 + stays deferred to M4; its `docs/conformance-deferrals.toml` entry is repaired to say + that `place()` now exists and still cannot state a physical side, the structurally- + constant trap the entry already predicted rather than one it has now closed. + - No `place` conformance kind and no case JSON this round (ADR-0006: implementation and + conformance are separately authored phases; task #80 is the latter). + `docs/conformance-deferrals.toml`'s §3.3.5 `[[owned]]` entry is repaired to state + precisely what moved — three of four positioning cases implemented, one declined and + why, and that no conformance case observes any of the placement half yet — rather than + naming `place()` as unstarted, which this round falsifies. + - `docs/conformance-deferrals.toml`'s §3.3.8 `[[owned]]` entry closed with a forward + reference to "placement's own later work (task #78)" for rules 2 through 6's own + overhang permissions over kana, half-em spaces, inseparable characters and brackets + (`Question::RUBY_OVERHANG_KANA`, `Question::RUBY_OVERHANG_INDENT`). That reference is + repaired now that task #78 has shipped and, per `place()`'s own module doc, deliberately + reads neither question — the identical falsified-forward-reference repair its sibling + §3.3.4 entry already received, missed for this entry the first time through. + - `docs/decisions/mono-ruby-separation-split.md`'s own "Applies to" line now names + `jlreq_inline::place` alongside `jlreq_inline::lower`: the centering difference + `place()` splits and the §3.3.8 rule 1 surplus `lower()` splits are the identical + `distribute(_, &[one(), one()], _)` question asked of two different inputs, not two + readings, so `place()` cites this file rather than arguing the point a second time. +- M4-a round 4: the published conformance format's eighth `kind`, `place` — the independent + conformance phase for `jlreq_inline::place` (task #80) — plus the falsifiable `same_run` + `lower` case the harness carried unexercised since M4-a round 2. + - `crates/jlreq-conform/cases.schema.json`: `input.kind`'s enum gains `"place"`, with a + paragraph stating what a `place` case asks — not `lower`'s own alignment question + restated, but what `jlreq_inline::place` computes once that alignment is read and + consumed — and that the line layout `place` positions each attachment against is + *derived* from the case's own declared item advances and `lower`'s own forced §3.3.8 + rule 1 separations rather than accepted as a further caller-declared field, stating why + in full: a caller-declared `placements` array could assert a relationship between two + numbers the case itself invented, with nothing in the format able to catch the two + disagreeing — the "measuring nothing" failure §D.2#4 forbids, and a subtler one than a + stated scope limit because it would look like a stronger assertion than it is. A new + `$defs/place` (`attachments`, `declined`) and `$defs/attachment` (`inline`, `item`) sit + beside `$defs/lower`; `$defs/place` states plainly that it carries no `rules` field, + because `Attachments` publishes none (ADR-0019), so a later reader does not add one back + as an oversight. `$defs/constructs`'s own description repairs "the two kinds this object + is load-bearing for" to three. + - `crates/jlreq-conform/src/case.rs`: `KINDS` grows to eight. `Expect` gains + `place: Option`, read by `read_place`, and `Expect::is_silent` checks it + too. `ExpectPlace` and `ExpectAttachment` are the new types, both `size`/`side`/`run`/ + `construct`-free by design — `cases.schema.json`'s own `attachment` description states + why each is left out. + - `crates/jlreq-conform/src/run.rs`: `Compose` gains an eighth method, `place`, required + for the identical reason `lower` already is — but shaped like `align`/`tab`/`compose` + rather than `boundary`/`feasible`/`lower`: `place` answers the whole call, not one + occurrence of it, so it takes no ordinal, and its own doc states why inventing one would + invent a selector `place()` does not have. `CasePlace` and `CaseAttachment` answer it; + `Answer::Place` and `ask`'s own `"place" =>` arm route it, and a misrouting regression + pair — `a_place_case_reaches_compose_place_and_not_compose_compose`, + `a_place_case_with_no_place_answer_is_not_attempted_even_though_compose_has_one` — + proves the hazard the identical pair already proved for `lower`. `check_place` compares + `attachments` as a total list (`check_lower_separations`'s own convention) and `declined` + by full-list equality, asserting the specific declined construct ordinal rather than + merely its non-emptiness — `a_place_declined_expectation_names_the_specific_construct_ + ordinal` pins it. Every stale "seven methods"/"seven questions" claim in this module's + own docs is repaired to eight. + - `crates/jlreq-conform/src/kumihan.rs`: `Compose::place` is the third method that does not + inherit `classify`'s, `boundary`'s, `compose`'s, `align`'s and `tab`'s construct- + blindness, reusing `lower`'s own front half verbatim through an identical `jlreq::lower` + call, then deriving the line layout `jlreq::place` positions against: `derived_placements` + sums the declared item advances and every forced separation that `lower` call resolved + before each item, honest to the case's own data rather than a caller-declared restatement + of it — its own doc states the derivation's honesty requirement (faithful only where every + interior boundary of the declared stream is Table 1 `blank`) and that no case this round + publishes exercises its own separations term. `docs/scalar-sites.toml` gains one entry for + it, the bridge from a case's own plain unit counts to the `InlineOffset` sequence + `jlreq::place` reads as `placements`. + - `xtask/src/conform.rs`: `INPUT_KINDS` grows to eight; `check_input` requires `constructs` + of a `place` case, merged into `lower`'s own existing match arm since the two share the + identical requirement (`clippy::match_same_arms`); `check_question` holds `place` to its + own `expect.place` field with no ordinal, `align`'s and `tab`'s own empty-ordinal shape + rather than `boundary`'s, `feasible`'s and `lower`'s. `kind_census` reports the new kind's + count. + - `crates/jlreq-conform/cases/3.3.5.json` gains four `place` cases closing the placement + half of §3.3.5's own `[[owned]]` entry: `one-character-nakatsuki-vs-katatsuki` (§3.3.5(b), + the load-bearing pair — the same run, two resolved offsets, one base item so the interior + boundary is vacuous), `two-characters-exactly-filling-the-base` (§3.3.5(a), + alignment-independent by construction, published as one `permitted` entry rather than two + identical ones), `three-characters-longer-than-the-base` (§3.3.5(c), both nakatsuki's own + negative-share centering over a verified-blank cl-15/cl-19 Table 1 coordinate chosen so no + §3.3.8 rule 1 separation entangles the derivation, and katatsuki's own decline, asserting + the specific declined construct ordinal), and `group-ruby-placement/produces-no- + attachment-and-is-not-declined` (the boundary between this rule's own reach and §3.3.6's, + measured from outside it). The two existing `lower` cases' own rationales are repaired: + both once asserted their katatsuki entry "is not genuinely exercised by this workspace's + own committed test run," falsified by this round's own `crates/jlreq-conform/tests/ + suite.rs` addition below. + - `crates/jlreq-conform/tests/suite.rs` factors `measure` into `measure`/`measure_against` + and adds `section_3_3_5_is_also_measured_under_katatsuki`, a second run of `3.3.5.json` + against a `Kumihan::new(Policy)` declaring `ruby.alignment: katatsuki` — under which every + katatsuki `permitted` entry in that file is the selected reading rather than `{}`, + genuinely exercised rather than only published. `section_3_3_5`'s own row moves to + `[6 attempted, 0 not attempted]` (the two existing `lower` cases plus the four new `place` + cases, none of which decline under either policy — decline conditions read no policy at + all, so the census is identical under both runs and only which entry is selected moves). + - `crates/jlreq-conform/cases/A.22.json` gains `run-identity/group-ruby-shares-a-run-mono- + ruby-does-not`, the falsifiable `same_run` `lower` case the harness carried unexercised + since `lower.same_run`, its reader and `check_same_run` first shipped (M4-a round 2) — + grounded in §B.2 note 10's own "the same... run" / "two distinct... runs" language for + cl-22 (simple-ruby, mono-ruby together with group-ruby, §3.3.7's own closing Note), not in + §3.3.5, whose own subject this fact is not: `RubyStyle::MonoRuby` allocates a fresh + `RunId` per base character by definition (§3.3.1's own note, the E.2#6 quarter-em + opportunity between 鬼 and 門), so two adjacent base characters of a *declared mono-ruby + construct* never share a run, whichever JSON shape declares them — only group-ruby (or + jukugo-ruby) allocates one shared run across a base range. The case's one input carries + both halves at once: two items under one `group`-ruby construct (`same: true`), two more + under two separate `mono`-ruby constructs (`same: false`). Neither `B.2#10` nor `C.2#7` + moves off `[[owned]]`; both were already there. `appendix_a_22`'s own row moves to + `[2 attempted, 11 not attempted]`. + - `docs/conformance-deferrals.toml`'s §3.3.5 `[[owned]]` entry is rewritten a second time: + the alignment question is now genuinely *exercised* under both readings, not only + published and checked; three of §3.3.5's four positioning cases are now cased, and the + fourth — §3.3.5(c)'s own katatsuki-with-overflow choice — is cased as a decline, + asserting the specific construct ordinal, pending task #81 for the `Question` that would + resolve it in full. + - `crates/jlreq-inline/src/place.rs`'s own "What is not here" paragraph and + `crates/jlreq-inline/src/lib.rs`'s own `# Status` are both repaired: the `place` + conformance kind this round authors is no longer a forward reference to task #80, and + both name the four cases and the `Attachments` observable directly. +- M4-a round 5: `RubyStyle::GroupRuby` placement, §3.3.6 paragraphs 1 and 2 (task #84). + `jlreq_inline::place` gains a real `RubyStyle::GroupRuby` branch — additive, no change of + shape to any existing public item, and every existing mono-ruby offset unchanged, because + the new geometry lives in sibling functions (`place_group_run`, `place_group_solid_run`) + rather than in a generalization of `place_solid_run`. + - `place_group_run` genuinely computes §3.3.6's own ruby-not-longer-than-base half, over + both of `Question::GROUP_RUBY_DISTRIBUTION`'s answers: `jis`, a `[1, 2, 2, …, 2, 1]` + proportional split over `n + 1` sites (`group_jis_weights`), read as §3.3.6's own "2 + units of inter-character spacing... 1 unit" ratio; and `flush`, a fixed + `InlineExtent::ZERO` leading offset with an equal split over the `n - 1` interior sites + alone (`group_flush_weights`), honoring the method's own leading clause by construction + rather than by a zero-weight site — `jlreq_unit::distribute`'s own remainder machinery + hands units out across every site a weights slice names, zero-weighted or not, so a + zero-weight site would not have stayed zero. Both methods read the base run's own extent + from a composed line's own `placements` (`extent_between`, a new `docs/scalar-sites.toml` + entry), not from a re-derived sum of item advances, so the two never silently disagree + when composition has genuinely widened the base elsewhere on the line. Paragraph 1 (equal + length) is not a third branch — at zero surplus both methods place the run flush with the + base's own start regardless of weight shape, the ratio paragraph 2's own arithmetic + degenerates to. An unrecognized `Question::GROUP_RUBY_DISTRIBUTION` answer name falls to + `jis`, every one of `Policy`'s five presets' own answer. + - Paragraph 3 (ruby longer than base) is declined, not implemented: both of its own methods + spread the *base* characters apart, which `place` structurally cannot do — `placements` + is already fixed by the time `place` runs, and it emits `Attachment`s for annotation items + only. A `RubyStyle::GroupRuby` run whose ruby is genuinely longer than its base is + reported through `Attachments::declined` instead, exactly the discipline §3.3.5(c)'s own + katatsuki-with-overflow choice already established; the fix belongs to + `jlreq_inline::lower::lower_group`, which would need to emit forced `Separation`s before + composition ever sees the base run, the mono-ruby analogue `collect_mono_separation` + already performs for §3.3.8 rule 1 — a future round's work, not this one's. + `Attachments::declined`'s own published meaning widens accordingly: it is no longer + reserved for §3.3.5(c)'s choice alone, and its own doc, and `crate::place`'s own module + doc, both now enumerate the two reasons a run reaches it. Jukugo-ruby remains a *third*, + different kind of absence — never placed at all, never declined, because no weighing ever + happened for a style this round's code simply does not touch. + - The Note attached to §3.3.6 paragraph 2 — a criterion capping the leading/trailing + spacing at one to one-and-a-half ruby ems before `jis`'s own appearance turns misleading — + states two thresholds in one parenthesis rather than one, so it is named as a declared + slot in `crate::place`'s own module doc rather than wired to an invented number; closing + it needs a policy `Question` of its own or a `docs/decisions/` reading, neither built yet. + - `docs/decisions/group-ruby-flush-single-character.md`, a new published reading + (`Standing::Unstated`): what `flush` does for a run of exactly one ruby character, whose + leading and trailing clauses name the same character at once and whose "rest" to space is + empty. The reading holds that the run starts at the base's own start with the surplus + applied nowhere — falling out of `group_flush_weights`' own empty slice at `count == 1` + rather than a special case — and argues against falling back to `jis`'s own centering, + which would erase the very divergence between the two methods §3.3.6 states them for. + Confirmed direction-independent; no `docs/direction-sites.toml` entry follows. + - `docs/scalar-sites.toml` gains two `jlreq-inline` entries: `two` (`lower.rs`, the `jis` + method's own interior weight, twice `one`'s own — a different item from `one`, so it + needs its own reviewed entry) and `extent_between` (`place.rs`, the base run's own extent + read back from two already-resolved placements, `jlreq_line::tab::distance_to`'s own + crossing one crate over). + - `crates/jlreq-conform/cases/3.3.5.json` loses `3.3.5/group-ruby-placement/produces-no- + attachment-and-is-not-declined`, deleted rather than retargeted: its own fixture (a + 1000-unit base against two 500-unit ruby characters, surplus exactly zero) now places two + real attachments under this round's own §3.3.6 paragraph 1 arithmetic, falsifying the + case's own premise that group-ruby produces no attachment. `crates/jlreq-conform/tests/ + suite.rs`'s own `section_3_3_5` and `section_3_3_5_is_also_measured_under_katatsuki` move + from `[6 attempted, 0 not attempted]` to `[5 attempted, 0 not attempted]`. Retargeting the + case, or authoring §3.3.6's own cases, is task #85's — ADR-0006's separately-authored + conformance phase, not this implementation round's; §3.3.6 stays `[[deferred]]` in + `docs/conformance-deferrals.toml`, whose own entry is rewritten to state exactly what + moved (the implementation) and exactly what did not (a conformance case naming 3.3.6). + `docs/conformance-deferrals.toml`'s own §3.3.5 `[[owned]]` entry is repaired to match: + three of task #80's own four cases survive unchanged, and the fourth's own deletion is + stated and reasoned rather than silently dropped from the count. + - Every stale "unfilled slot" claim about `Question::GROUP_RUBY_DISTRIBUTION` this round + falsifies is repaired: `crates/jlreq-inline/src/lower.rs`'s own module doc, `one`'s own + doc (now naming its four consumers rather than two), `sum_advances`' own doc (three + questions rather than two), `Lowered::declined`'s own field doc, `lower_group`'s own doc + and its "Recording §3.3.6 here..." comment (reworded to state that `lower_group` still + computes none of this — placement does — rather than that the geometry does not exist, + and that `place` itself still records no `RuleId` either, ADR-0019), and its own test's + assertion messages; `crates/jlreq-inline/src/ruby.rs`'s own `RubyStyle::GroupRuby` doc; + `crates/jlreq-inline/src/lib.rs`'s own `# Status`; and + `docs/design/api-spine.md`'s own `Attachments` sketch, whose `declined` description named + only §3.3.5(c) and now names both reasons. +- M4-a round 6: the §3.3.6 group-ruby placement conformance cases (task #85), ADR-0006's own + separately-authored phase for M4-a round 5's own implementation. No logic change to + `crates/jlreq-inline/src/place.rs` — every number below was derived by hand from §3.3.6's + own words and this round's own fixture advances, never read out of the implementation, its + `#[cfg(test)]` module or a debug run. + - `crates/jlreq-conform/cases/3.3.6.json`, four cases naming rule `3.3.6`: + `group-ruby-placement/equal-length-both-methods-agree` (paragraph 1, one `permitted` entry + because `jis` and `flush` are not two readings at zero surplus but one, the deleted + `3.3.5/group-ruby-placement/produces-no-attachment-and-is-not-declined` case's own + fixture reused as this section's own affirmative case); `group-ruby-placement/jis-versus- + flush-distribution` (paragraph 2 at four ruby characters over a two-item, cl-19/cl-19 + Table-1-verified-blank base — `spec/captured/table1.en.tsv` line 485 — every one of the + run's four offsets genuinely differing between the two methods, the load-bearing pair this + file exists to publish); `group-ruby-placement/single-ruby-character-jis-vs-flush` + (paragraph 2 at exactly one ruby character, standing `unstated` rather than `alternative`: + `jis` is still directly derivable from the ratio sentence, but `flush` is not, and rests on + `docs/decisions/group-ruby-flush-single-character.md`'s own published reading instead); + and `group-ruby-placement/ruby-longer-than-the-base-declines` (paragraph 3, asserting + `declined: [0]` rather than merely `attachments: []`, naming the specific declined + construct ordinal the way `3.3.5/mono-ruby-placement/three-characters-longer-than-the-base` + already does). Every fixture's per-end surplus stays comfortably under one ruby em, clear + of paragraph 2's own unimplemented Note. + - `crates/jlreq-conform/tests/suite.rs` gains a `section_3_3_6` `per_section!` row and a + second test, `section_3_3_6_is_also_measured_under_flush`, on `section_3_3_5_is_also_ + measured_under_katatsuki`'s exact model: a second `Kumihan::new(Policy)` declaring `ruby. + group_distribution: flush`, under which the runner's own selection rule picks the `flush` + entry of every case naming one. Both tests carry an identical `[4 attempted, 0 not + attempted]` census — `place()`'s own decline conditions are extent comparisons made before + either alignment question is ever read, so no case becomes unanswerable under either + policy, and only which permitted entry is selected moves. + - `docs/conformance-deferrals.toml`: `3.3.6` moves from `[[deferred]]` to `[[owned]]`, its + `why` naming the four cases, the genuinely-exercised `flush` reading, and the honest scope + limit — paragraph 3 stays a cased decline rather than an implementation, and paragraph 2's + own Note stays cased nowhere, because its own parenthesis states two thresholds rather + than one and closing it needs a policy `Question` or a `docs/decisions/` reading that + does not exist yet. `3.3.5`'s own `[[owned]]` `why` is repaired to match: the dangling + promise that task #85 might author a jukugo-shaped replacement for the deleted fourth case + is resolved, and it resolves to a decline — `crates/jlreq-inline/src/place.rs`'s own + `RubyStyle::JukugoRuby` dispatch still never reaches `place_mono_run` or `place_group_run` + and never appears in `Attachments::declined` either, verified against the code rather than + assumed, so a jukugo-shaped `place` case would assert `attachments: []` alongside + `declined: []` — satisfiable by an implementation that never implemented anything at all, + the exact §D.2#4 trap this project's own discipline refuses to publish as coverage. What + such a case would have asserted belongs to §3.3.7's own deferral instead, not to §3.3.5 or + §3.3.6. + - Every stale claim this round falsifies is repaired in place, description text only, no + field or type changed: `crates/jlreq-conform/cases.schema.json`'s own `place` `$def`, its + `kind` description and its `declined` property description all once said `Attachments:: + declined` names only a mono-ruby run's own katatsuki-with-overflow choice; all three now + name group-ruby's own ruby-longer-than-base half too, which this round's own fourth case is + the first published case to exercise. `crates/jlreq-conform/src/run.rs`'s own `Compose:: + place` doc, `CasePlace`'s own doc and `CasePlace::declined`'s own field doc, and `crates/ + jlreq-conform/src/case.rs`'s own `ExpectPlace` doc and `ExpectPlace::declined`'s own field + doc, are repaired the same way. `crates/jlreq-inline/src/place.rs`'s own "What is not + here" section is rewritten to record that §3.3.6 now has a conformance case and moved to + `[[owned]]`, rather than stating it still does not. `crates/jlreq-conform/src/kumihan.rs`'s + own module doc is repaired in two places: `place` is credited with §3.3.6's own geometry + alongside §3.3.5's, and the "every multi-item `place` case this round publishes" and "no + case this round publishes" sentences are reworded to name the suite rather than a round — + durable now that `3.3.6.json`'s own second case is this suite's second multi-item `place` + fixture, and still true that none exercises the separations term of the derivation: + group-ruby's own base boundary here is independently blank in Table 1 *and* `lower_group` + itself still emits no `Separation` for a group-ruby run against any neighbor, either fact + alone already sufficient. `crates/jlreq-inline/src/lib.rs`'s own `# Status` carried the + identical stale claim as `place.rs`'s "What is not here" section above and was missed in + the first pass — this round's own review caught it before the gate battery could hide it — + so it is now repaired the same way, stating that task #85 has since run and named cases and + moved the rule to `[[owned]]` rather than that this round's own group-ruby geometry is + implemented but not yet cased. +- M4-a round 7: `RubyStyle::JukugoRuby` placement, both of §3.3.7's own paragraphs, wiring + `Question::JUKUGO_RUBY_LAYOUT` (task #88). No conformance case authored; §3.3.7 stays + `[[deferred]]`, on ADR-0006's own discipline that an implementation round does not move + its own rule to `[[owned]]`. + - Paragraph 1 ("two or fewer ruby characters per base") delegates each declared run, + unmodified, to the identical `place_mono_run` a `RubyStyle::MonoRuby` construct itself + calls — decline included, so a jukugo run whose ≤2-character reading still overflows its + base under katatsuki declines exactly as an ordinary mono-ruby run does. The ≤2 count is + read directly off each run's own declared annotation width, a genuine character count + rather than an extent comparison: unlike §3.3.5(a)-through-(c), paragraph 2's own + condition is "needs three or more ruby characters," not "is longer than its base," so a + wide-enough base character could carry three narrow ruby characters without ever + outrunning it. + - `crates/jlreq-inline/src/lower.rs`'s own alignment resolution is hoisted to cover + `RubyStyle::JukugoRuby` alongside `RubyStyle::MonoRuby`: without this, `Contribution:: + alignment_of` would answer `None` for a jukugo construct and `place_mono_run`'s own + `let Some(alignment) = ... else { return; }` would place nothing at all, silently, the + moment paragraph 1's own condition held. The `RuleId::POSITIONING_OF_MONO_RUBY_WITH_ + RESPECT_TO_BASE_CHARACTERS` citation stays mono-only — that citation is `crate::place`'s + to give once it has actually decided paragraph 1 governs a construct, a decision `lower` + never makes. Settled along the way: §3.3.5's own discouraged-katatsuki-in-horizontal- + writing flag transfers to a jukugo construct wholesale, on paragraph 1's own delegation to + "the method described in § 3.3.5" without qualification — §F's own stated assumption of a + katatsuki baseline governs a different method (the `phonetic` answer, declined below) and + has nothing to unsettle for a paragraph-1 construct. + - Paragraph 2 ("attach the ruby text to the kanji compound word as a whole") builds one + compound-wide synthetic `RubyRun` — the whole declared base range, against the first + declared run's own annotation start through the last's own end, `Ruby::new`'s own + `check_runs` contiguity invariant guaranteeing the span is the compound's whole reading — + and hands it to `place_group_run`, which gains an explicit `jis: bool` parameter in place + of its own former internal `Question::GROUP_RUBY_DISTRIBUTION` read (moved to its one + prior call site, `RubyStyle::GroupRuby`'s own arm in `place`, so that style's own + behavior is unchanged, byte for byte). `Question::JUKUGO_RUBY_LAYOUT`'s own `group` + answer passes `true` unconditionally — forcing `jis` regardless of the document's own + `Question::GROUP_RUBY_DISTRIBUTION` answer — the published reading of a genuinely + unstated question (`docs/decisions/jukugo-group-layout-distribution.md`): §3.3.6 itself + names exactly one of its own two methods "the method specified in JIS X 4051," twice, and + never its own "another way"; §3.3.7¶2's own "the layout as specified in JIS X 4051" cites + that identical, specific method, and its own "which is similar to the group-ruby method + described in § 3.3.6" is a comparison orienting the reader, not a second instruction + reopening the choice the first clause already closed by name. Reusing `place_group_run` + reuses its own ruby-longer-than-base decline too — the jukugo analogue of §3.3.6 + paragraph 3's own base-spreading blocker, structurally unclosable from `place` for the + identical reason group-ruby's own half is. `Question::JUKUGO_RUBY_LAYOUT`'s own + `phonetic` answer declines every compound it reaches, unconditionally: §F's own + phonetic-structure distribution is not implemented this round, not one part of it. + - A jukugo compound's own base range can straddle one `place` call's own `items` in a way + `RubyStyle::GroupRuby`'s own base range structurally cannot — §C.2#8's own second + sentence permits a break between two base characters of one jukugo complex, and + `lower_jukugo` gives the compound one shared `RunId` but a *fresh* `GroupId` per base + item precisely so that break survives (`docs/decisions/jukugo-ruby-unset-group.md`'s own + reading of `same_run_refusal` is what confirms `jlreq-line` actually permits it). Such a + straddle declines rather than silently skipping the way an ordinary out-of-range + group-ruby run does: paragraph 2's own "as a whole" instruction has no whole left to + attach once the line has split the compound, and JLReq states no method for that case. A + compound split across two lines is consequently declined twice, once by each partially- + covering `place` call — the correct per-line answer, not a double-report defect. This + decline is unit-test-only observable for this suite, permanently: `Compose::place`'s own + adapter always derives `items` as the case's whole declared base stream, so no + conformance case can ever construct the straddle at all. + - `Attachments::declined` widens from two stated reasons to four: §3.3.5(c)'s own + katatsuki-with-overflow choice and §3.3.6 paragraph 3's own base-spreading method each + now also catch a jukugo construct routed through the identical code, alongside the two + new jukugo-only reasons above. Its own doc, `crate::place`'s own module doc, `crates/ + jlreq-conform/cases.schema.json`'s `kind`/`lower`/`place` descriptions, and `crates/ + jlreq-conform/src/case.rs`'s `ExpectLower`/`ExpectPlace` docs are all repaired to state + the new count rather than the old one. + - `docs/decisions/jukugo-group-layout-distribution.md`, a new published reading + (`Standing::Unstated`) as argued above, with a matching `docs/decisions/README.md` row. + Confirmed direction-independent; no `docs/direction-sites.toml` entry follows, though the + existing `jlreq-inline`/`lower`/`3.3.5` entry gains one clause noting its read now also + resolves a jukugo construct's alignment, the identical code path rather than a second one. + - Four new `#[cfg(test)]` cases in `crates/jlreq-inline/src/place.rs`: a paragraph-1 + compound placing per base under both alignments; a paragraph-2 compound placing as one + `jis`-weighted group, measured under *both* `Policy::JLREQ` and a policy answering + `flush` for `Question::GROUP_RUBY_DISTRIBUTION` to make the forcing itself observable + (base 2000, reading 1600 over one-then-three ruby characters, surplus 400 dividing `jis`'s + own eight-unit weight sum exactly — offsets `[50, 550, 1050, 1550]` under either policy); + the same compound declined under a `phonetic`-answering policy; and the same compound + declined again with an `items` range covering only its first base item, exercising the + straddle no conformance case can reach. + - Every stale "unfilled slot" or "two reasons" claim this round falsifies is repaired: + `crates/jlreq-inline/src/lower.rs`'s own module doc, `Lowered::alignments` and `Lowered:: + declined`'s own field docs, `Contribution::alignment_of` and `Contribution:: + alignment_discouraged`'s own docs, `lower`'s own doc, `lower_jukugo`'s own doc and its + "Recording §3.3.7 here..." comment, `two`'s own doc, and a test assertion message that + described `lower` as computing no discrimination for a reason no longer accurate (it + computes none; `place` now does, and neither records a `RuleId`, for the reason `crate:: + lower`'s own module doc already argues for §3.3.6); `crates/jlreq-inline/src/ruby.rs`'s + own `RubyStyle::JukugoRuby` doc; `crates/jlreq-inline/src/lib.rs`'s own `# Status`; + `docs/design/api-spine.md`'s own `Attachments` sketch; `crates/jlreq-conform/cases. + schema.json`'s four spots named above; and `crates/jlreq-conform/src/case.rs`'s two. + `docs/conformance-deferrals.toml`'s own `3.3.7` entry is rewritten on §3.3.6's own + round-5-through-6 precedent — the blocker is now the absence of a case naming `3.3.7`, + not the absence of an implementation — stating exactly what landed and exactly what did + not; `F`, `F.1`, `F.2`, `F.3` and `F.4`'s own entries each gain a clause distinguishing + "`jlreq-inline` places jukugo ruby" from "applies §F's own distribution," so their own + unchanged wording cannot be misread as claiming §F landed; `3.3.2`'s own entry, which + cited `Question::JUKUGO_RUBY_LAYOUT` as an unfilled slot `lower`'s own module doc named, + is corrected to name §F alone, now that the question itself is real, read by `place`. +- M4-a round 8: the §3.3.7 jukugo-ruby placement conformance cases (task #90), ADR-0006's own + separately-authored phase for M4-a round 7's own implementation, closing coverage at 75/106 + inventoried rules (up from 74/106). No logic change to `crates/jlreq-inline/src/place.rs` — + every number below was derived by hand from §3.3.7's own two paragraphs and this round's own + fixture advances, never read out of the implementation, its `#[cfg(test)]` module or a debug + run. + - `crates/jlreq-conform/cases/3.3.7.json`, three cases naming rule `3.3.7`. The first two + share the identical base (`亜亜`, two 720-unit cl-19 items, the cl-19/cl-19 boundary + independently verified blank at `spec/captured/table1.en.tsv` line 485) and the identical + four-character reading (`かかかか`, 300 units each), differing in exactly one declared + field — how `runs` partitions the reading across the two base characters — which isolates + §3.3.7's own discriminator as a ruby-character *count* per base character rather than the + extent comparison §3.3.5(a)-through-(c)'s own three cases reduce to: + `jukugo-ruby-placement/paragraph-one-per-base-mono-delegation` (2 and 2, paragraph 1, + delegating per run to `place_mono_run` under both of `Question::RUBY_ALIGNMENT`'s + answers, sized so neither run outruns its base and re-cases task #81's still-open choice) + and `jukugo-ruby-placement/paragraph-two-whole-compound-attachment` (1 and 3, paragraph 2, + the whole compound attached as one `jis`-weighted unit — offsets `[30, 390, 750, 1110]`, + the identical arithmetic `3.3.6/group-ruby-placement/jis-versus-flush-distribution`'s own + rationale already derives, reused here as §3.3.7¶2's own forced reading — over three + `permitted` entries with totally-ordered key sets: the default `jis` geometry, a decline + under `ruby.jukugo_layout: phonetic`, and the *identical* `jis` geometry again under + `ruby.jukugo_layout: group` with `ruby.group_distribution: flush` — `decision:jukugo- + group-layout-distribution`'s own forcing, published as a named contradiction of the + expectation a reader would otherwise form at this non-zero surplus, where `jis` and + `flush` genuinely diverge for an ordinary group-ruby run; the file's own first entry, + matching every policy, would already assert the identical numbers under a flush-declaring + policy even without this third entry, so its own second-run test is not itself proof the + third entry was selected, unlike the `phonetic` run's). The third, + `jukugo-ruby-alignment/katatsuki-discouraged- + carries-through-the-delegation`, is a `lower` case for the one fact no `place` case can + observe — `Contribution::alignment_discouraged` for a jukugo construct in horizontal + writing — and asserts `rules: ["3.3.4"]`, not `["3.3.5"]` or `["3.3.7"]`: `lower`'s own + alignment-hoist records §3.3.5's citation only under an explicit mono-ruby style guard, so + a jukugo construct's own `lower` answer publishes only `RuleId::CHOICE_OF_SIDES_FOR_RUBY_ + WITH_RESPECT_TO_BASE_CHARACTERS` (§3.3.4), never §3.3.7, which belongs to `place` once it + has actually decided which paragraph governs. + - `crates/jlreq-conform/tests/suite.rs` gains a `section_3_3_7` `per_section!` row and three + second-run tests — `_is_also_measured_under_phonetic`, `_under_flush` and `_under_ + katatsuki` — on `section_3_3_5_is_also_measured_under_katatsuki`'s and `section_3_3_6_is_ + also_measured_under_flush`'s exact model: a second `Kumihan::new(Policy)` apiece, under + which the runner's own selection rule picks the entry naming that question rather than + `{}`. All four runs (including the default) carry an identical `[3 attempted, 0 not + attempted]` census — none of `place`'s own decline conditions or `lower`'s own alignment + resolution reads a policy this file's three cases do not already publish an entry for, so + only which permitted entry is selected moves. + - `docs/conformance-deferrals.toml`: `3.3.7` moves from `[[deferred]]` to `[[owned]]`, its + `why` naming the three cases, the three genuinely-exercised readings, and the honest scope + limit — §F entire (§F.1 through §F.4) stays uncased because it stays unimplemented, + paragraph 2's own fourth-sentence two-threshold overhang ceiling stays a declared slot + doubly moot behind the `phonetic` decline, `lower_jukugo`'s own absent `Separation` for a + jukugo compound's surplus is stated in both `place` cases' own rationale, and the + straddled-compound decline stays unit-test-only observable because `Compose::place`'s own + adapter always derives `items` as a case's whole declared base stream, so this round did + not spend effort hunting for a fixture that cannot exist. The `3.3.2` and `F` entries' + own "`3.3.7`'s own entry above" pointers are corrected to "below," now that `3.3.7` sits + in `[[owned]]`, past the `[[deferred]]` table both entries live in. + - Every stale "task #90 has not yet run" claim this round falsifies is repaired in place, + prose only, no field or type changed: `crates/jlreq-inline/src/lib.rs`'s own `# Status`, + `crates/jlreq-inline/src/place.rs`'s own "What is not here" section, and `docs/decisions/ + jukugo-group-layout-distribution.md`'s own closing section (which had promised task #90 + would publish exactly the `flush`-forcing case this round's second `place` case's own + third `permitted` entry now does) are all rewritten to state that the phase has run, name + the cases it published, and state the same honest residue the ledger's own new `why` + states. + +### Changed + +- The layout core is seven crates rather than five. `just purity` now checks the crate + graph as adjacency rather than as membership, so a permitted core crate reaching another + core crate it has no row for is a failure. +- Documents corrected against the frozen design: a character class is a property of an + occurrence rather than of a code point, a spacing amount is not a function of the two + adjacent classes alone, and ruby overhang is placed after line adjustment rather than + resolved before it. ADR 0001 and ADR 0005 carry superseded-in-part notes. +- Stage 1 of the generation pipeline lives in `xtask` rather than in `tools/jlreq-gen`, a + workspace excluded from the root. The scanner reads the snapshot with `std` alone, so + there is no dependency tree to keep out — and everything outside the workspace escapes + Clippy, `rustfmt`, `cargo-msrv` and, decisively, `cargo nextest`. + `docs/design/generation.md` records the change and the reasoning. +- The CI design job runs `just design` rather than the gates enumerated by hand. The two had + already drifted: `derive-check`, the only gate binding `spec/derived/` to the vendored + document, was in the aggregate and not in the list. +- `conform` treats an absent case directory as an operand that does not exist rather than as + an empty one, so declared coverage is reported as a check that did not run — naming how + many rules it would have closed over — instead of failing on a schedule. Creating the + directory turns it on, empty or not. +- The `typos` pre-commit hook passes `--force-exclude`. Without it `typos` ignores the + exclusions in `typos.toml` for paths named on the command line, and `{staged_files}` names + every path, so `--write-changes` would have "corrected" the vendored specification and + broken the digests that prove it is upstream's. +- Twelve recorded upstream defects rather than ten: the cl-24 Remarks role stated only in + Japanese, and §3.1.6's fourth Note, whose English leaves a cross-reference as the literal + placeholder the Japanese resolves to §B. Which Note it is was an unmeasured ordinal until + the detector counted them. +- **§D.2 note 5 is not a contradiction, and this project said it was.** The note gives the + middle-dot conditional space the third priority in Table 3 where notes 1 to 3 give it the + fourth, and §3.8.3 lists the line-end reduction and the mid-line one as separate steps: note + 5 is the first and notes 1 to 3 are the second. What is defective is one locale of one + sentence — note 5's English half drops the 行末に配置する its Japanese half states — so the + row is `d2-note-5-line-end-qualifier-omitted-in-english` and not + `d2-note-5-priority-contradiction`. `generation.md` had pre-committed the rule to + `Standing::Adjudicated` and `conformance.md` had it as a worked example of a case carrying + both readings; a case written to either would have published an alternative JLReq does not + permit. ADR 0009, `api-spine.md`, `generation.md` and `conformance.md` are corrected. +- Classification narrows on one more axis, and the axis is Appendix A's own. Where a key is + listed under several classes and the caller has declared the frame, a Remarks cell that + states that frame is describing this occurrence and a cell that states none is describing a + different one — which is what makes `proportionally-spaced` mean anything for the 469 keys + §A.27 shares with a lower-numbered class, and which Appendix A prints in its Character + column too, for the 92 keys where exactly one listing is qualified: ( against `(`, % + against `%`. Without it a declared frame was read only against §3.1.2's five classes, so a + proportional `U+0028` answered cl-01. The rule reproduces §3.1.3's and §3.2.6's three stated + answers for a European numeral — full-width cl-19, half-width cl-24, proportional cl-27 — + without being told them, and §3.2.6's Note is now read for the cl-24 arm it states in so + many words rather than for the cl-27 arm alone. +- A narrowing may no longer answer a question nobody asked. Removing §3.1.2's five classes on + a proportional advance had left `U+3014` alone in cl-28 and told the caller that JLReq had + decided their bracket surrounds a warichu (割注); a removal whose survivors are all + membership in a construct that no Remarks cell states the declared frame for is refused, and + `AxisSet::CONSTRUCT` reports the axis nobody supplied. +- `docs/decisions/ambiguous-context.md` publishes the tie-break the implementation had always + applied: the lowest-numbered surviving class **the supplied facts can reach**, passing over + membership in a construct the caller never declared. Nine of the thirty classes are such + memberships and four are numbered below cl-27, so the unqualified wording answered "inside a + unit symbol" for every proportional Latin letter in a Japanese document. Two conformance + cases were written against the wording before the correction, which is the measurement: a + published reading an implementer cannot reproduce from the document is the defect. +- `docs/design/conformance.md` is written in the tense the code is in. There is no `judge` + binary, no `answers.schema.json`, no `answers/` and no `src/bin`; every sentence describing + them now says so, at the top of the document rather than four hundred lines down, and ADR + 0006's ecosystem claim is stated as not yet met. The three ADR 0018 input refusals are + likewise not published as cases, because the format has no way to say that an input is + expected to be refused — a requirement on the format first, held meanwhile by + `jlreq-class`'s own tests over `Text::new`. +- ADR 0018's two `input` properties are checked by `jlreq-conform`'s own test rather than by + `conform --check`. `Text::new` *is* that reader, and a second reader inside a gate that does + not carry Appendix A would be a second answer to a question that already has one; the two + had already parted, which surfaced when the first case the gate accepted and the constructor + refused reached the runner. +- `spec/derived/defects.tsv` is derived rather than captured. `generation.md` had put it on + the captured side on the reasoning that most of its rows are defects of the matrices; + measured, not one of the twelve is — every one is a property of the HTML snapshot. +- `Report` carries `unselectable`, the count of permitted entries no declared policy of a run + could select. A published reading nothing can select is evaluated by nothing, and the number + is what stops that being a silence on a green run. +- **The M0 policy-space entry above is stale, and this is the correction rather than a silent + edit of it.** "The twenty-one places" is twenty-two, and "Stage 2 ... is still to come: + `Question::ALL` remains empty" is no longer true — see this milestone's Added entries for + what stage 2 generated and what `jlreq-class` and `jlreq-spacing` now read from it. +- `crates/jlreq-conform/tests/suite.rs`'s `UNSELECTABLE` fell from 170 to 0. Every permitted + reading a published case names was, at M0, a reading naming a question the policy space did + not have yet; now that `jlreq_spec::QUESTIONS` holds all twenty-two, every one of those + readings is a `Choice` this workspace can evaluate, and the count that used to state how + much of the suite nothing could measure now states that nothing is in that position. +- The five conformance-case declarations that named a Table 6 citation before anything read + it — `E.2.json`'s `E.2/em-dash-then-horizontal-ellipsis/two-kinds-open-a-third-stage- + quarter-em` (`"rule": "E.2#4"`, cl-08 x cl-08) and `E.2/western-character-then-postfixed- + abbreviation/the-general-rule-opens-a-third-stage-quarter-em` (`"rule": "E.2#10"`, cl-27 x + cl-13), and `E.json`'s `E/dividing-punctuation-then-western/the-boundary-carries-an- + independent-reduction-and-expansion`'s three `permitted` entries (`"rule": "E"`, cl-04 x + cl-27) — were audited against `crates/jlreq-spacing/src/generated/table6.rs`'s own rows at + those exact coordinates now that `check_expansion` reads them. All five agree with the + generated cell's own `rule` field: `(8, 8)` cites `RuleId::E_2_NOTE_4`, `(27, 13)` cites + `RuleId::E_2_NOTE_10`, and `(4, 27)` cites the bare `RuleId::OPPORTUNITIES_FOR_INTER_ + CHARACTER_SPACE_EXPANSION_DURING_LINE_ADJUSTMENT` (rendered `"E"`, the same generic + citation an unnoted Table 1 cell renders `"B"` and an unnoted Table 2 cell renders `"C"`). + No case needed correction and no generator defect was found. +- `docs/conformance-deferrals.toml`'s own `E.2#8`, `E.2#9` and `E.2#11` entries are rewritten. + Their stated blocker — a coordinate answering `Expansion::None` being "indistinguishable + from a bare absence" — no longer holds: `Boundary::expansion_rule()` now reads + `Some(RuleId::E_2_NOTE_8)` at cl-24 x cl-13, `Some(RuleId::E_2_NOTE_9)` at cl-24 x cl-27, + and `Some(RuleId::E_2_NOTE_11)` at cl-27 x cl-27, in every case regardless of what + `expansion()` itself answers there. All three stay `[[deferred]]` at M1, because publishing + a citation is not the same act as authoring the case that measures it (ADR 0006's own phase + split) — no case is added and none is moved to `[[owned]]` by this entry. `E.2#11`'s own + rewritten entry additionally records that whether its own alternative reading is worth a + case at all is still an open question for that later phase, not answered here: §3.8.4 step + (d)'s own Note calls the alternative 処理系定義 (implementation-defined) under JIS, so a case + asserting `kind: "none"` there might measure this workspace's own bookkeeping rather than + anything JLReq itself requires. +- `docs/conformance-deferrals.toml`'s own `E.2#8` and `E.2#9` entries move to `[[owned]]`, + naming the cases added above and the percent-sign scope limit as a stated fact rather than + an open question. `E.2#11`'s own entry stays `[[deferred]]`, and its "why" is rewritten + rather than left pointing at a future round: the decision is taken this round, and no case + is authored. The two rejected-alternative coordinates read alike at first — both `E.2#8` + and `E.2#9` state a captured `limit: None` default with a note offering an unselectable + alternative — but `E.2#11`'s own alternative is JLReq's fourth, residual expansion stage + with no stated ceiling, and §3.8.4 step (d)'s own Note (the only other sentence anywhere in + the document discussing a fourth-order opportunity at cl-27-against-cl-27) attributes that + residual stage to a JIS X 4051 provision JIS itself calls 処理系定義 — a genuinely different + kind of silence from E.2#8's and E.2#9's own concrete, merely-unselectable ceilings, and + the entry's own "why" now says so instead of naming the judgment as still open. +- `E.2/quantity-symbol-then-postfixed-abbreviation/a-declared-role-withdraws-the-opportunity`'s + expectation gains `rule: "E.2#10"` beside `kind: "none"`: `note_governed_expansion`'s own + doc and its literal `RuleId::E_2_NOTE_10` for this coordinate, corroborated independently + by Table 6's own `(27, 13)` cell carrying the identical citation, are what the note's own + denial cites even while withdrawing the opportunity it would otherwise state — a + strengthening the specification itself warrants, not a correction made to match code. No + other field of this case, and no other of `E.2.json`'s pre-existing four cases, changes. +- `conformance-cases-agree-with-the-cells` (ADR 0006) now runs: `xtask::attest` reports 16 of + 18 registered invariants running, up from 15. A boundary case may declare which captured + cells it exercises through `cells`, a new case-level, optional, list-valued field of + `{table, before, after}` objects — `crates/jlreq-conform/cases.schema.json`'s own + `matrix_cell`, validated by `conform`'s own `check_cells` and added to `CASE_OPTIONAL`. + Deliberately not the `address` grammar's `@` suffix: §D.1 is the legend of three matrices + at once ("Legend of Tables 3, 4 and 5"), so `D.1@cl-02,line-end` never named one captured + cell, and `spec/derived/rules.tsv` does not inventory the natural per-table alternative + either — it has `D.1` but never `B.1`, `C.1` or `E.1`. The checker (`Evidence`, threaded + through `Check::Whole` and `Check::Partial` uniformly rather than bolted onto `Capture` or + carried by a nineteenth `Check` variant) asserts existence — every declared coordinate is + one the agreed transcription has, at every table alike — and, for Table 1 alone, that a + case's default-policy (`policy: {}`) boundary answer agrees in units with the captured + cell. 21 of the suite's 72 boundary cases now declare a coordinate this way — every + `B.json` and `B.2.json` case, `D.1.json`'s one case, every `D.2.json` case, and `E.json`'s + and `E.2.json`'s cases, 43 coordinates in all, derived from each case's own quote and + rationale rather than read back off the transcription (ADR 0006) — and the run reports + zero disagreements. The remaining 51 boundary cases (`C.json`'s and `C.2.json`'s own Table + 2 coordinates, which carry no amount to compare, and every `A.*` and `3.x` boundary case, + whose own coordinate a checker here would have to derive by classifying `text` — a second + implementation of Appendix A, which ADR 0019 forbids) are the invariant's own named + remainder rather than a silence. `conform --check`'s own census is unchanged apart from a + new line reporting the count declared; declared coverage, the rule and address counts, and + every other number stay 56 files, 466 cases, 69 rule addresses, 373/72/10/2/9 by kind, + 69 owned / 37 deferred / 0 uncovered. +- Two stale claims this invariant's own absence had left standing are repaired. `xtask:: + attest`'s module doc no longer states that `B.1@cl-02,line-end` is a working matrix-cell + address — `B.1` is not an inventoried rule any more than `D.1` is, and the corrected + example, `B@cl-05,cl-05`, is the one `docs/design/address-corpus.tsv` actually validates. + `docs/design/conformance.md` no longer attributes the absence of table cells from + `spec/derived/rules.tsv` to `spec/captured/` being empty — the matrices have been + transcribed since the round that landed them; the real reason `covers` still has no user + is that `derive` has never been extended to walk a matrix into rule addresses at all, + independent of whether the transcription exists. diff --git a/Justfile b/Justfile index c186f44..7b97527 100644 --- a/Justfile +++ b/Justfile @@ -10,9 +10,9 @@ export RUSTDOCFLAGS := "-D warnings" # The layout core must stay free of std, I/O, and font access (docs/adr/0001). core_crates := "-p jlreq" -# Mutation testing targets the sole public library; xtask is repository tooling and the -# conformance product is an external black-box runner. -mutant_crates := "-p jlreq" +# Mutation testing covers both handwritten Rust products. Generated tables and repository +# tooling have independent generation/attestation gates. +mutant_crates := "-p jlreq -p jlreq-conformance" # A developer must be able to inspect an archive before committing; CI packages a clean # checkout and therefore deliberately omits Cargo's dirty-tree escape hatch. @@ -67,6 +67,9 @@ sample_engine := "target" / "debug/jlreq-sample-engine" # with the rest of `target/`, and nothing but the next run of the same census reads it. census_dir := "target" / "census" +# Generated, reviewable result of the exhaustive three-engine census. +census_summary := "docs/generated/conformance-summary.md" + # List the available development commands. default: @just --list @@ -88,27 +91,41 @@ lint: cargo clippy --workspace --all-targets --all-features -- -D warnings cargo clippy --workspace --all-targets --no-default-features -- -D warnings -# Run the workspace suite. Nextest runs normal tests process-per-test; Cargo -# separately runs doctests, which nextest does not currently support. +# Run the workspace suite. Nextest runs ordinary harnessed tests process-per-test. Cargo +# separately runs the harness-free synthetic transport executable and doctests, neither of +# which nextest currently executes. test: cargo nextest run --workspace --all-features + cargo test -p jlreq --lib pipeline::tests::ten_thousand_cluster_standard_paragraph_stays_below_the_search_budget -- --ignored --exact + cargo test --release -p jlreq --lib pipeline::tests::zero_width_pathological_paragraph_stops_at_the_default_search_budget -- --ignored --exact + cargo test -p jlreq-conformance --test transport --all-features cargo test --workspace --doc --all-features # Run the complete test suite with the non-fail-fast CI profile. test-ci: cargo nextest run --profile ci --workspace --all-features + cargo test -p jlreq --lib pipeline::tests::ten_thousand_cluster_standard_paragraph_stays_below_the_search_budget -- --ignored --exact + cargo test --release -p jlreq --lib pipeline::tests::zero_width_pathological_paragraph_stops_at_the_default_search_budget -- --ignored --exact + cargo test -p jlreq-conformance --test transport --all-features cargo test --workspace --doc --all-features # Build public documentation with warnings denied. doc: cargo doc --workspace --all-features --no-deps -# Build and verify the public library archive, then inspect every file Cargo would put in the -# CLI archive. Cargo cannot create that second archive until its exact-version jlreq -# dependency has been published; the CLI's workspace build, tests, and MSRV run separately. +# Build and verify both public crate archives. The temporary crates.io patch lets Cargo verify +# the exact-version inter-crate dependency before jlreq has actually been uploaded. package: cargo package -p jlreq --locked {{package_dirty}} - cargo package -p jlreq-conformance --locked {{package_dirty}} --list + cargo package -p jlreq-conformance --locked {{package_dirty}} --offline --config 'patch.crates-io.jlreq.path="crates/jlreq"' + sh scripts/verify-crates.sh + +# Ask Cargo to execute its complete crates.io publication preflight while retaining the +# upload locally. The patch validates jlreq-conformance before the first jlreq release has +# appeared in the index; published metadata still carries only version 0.1.0. +publish-dry-run: + cargo publish --dry-run --locked {{package_dirty}} -p jlreq + cargo publish --dry-run --locked {{package_dirty}} -p jlreq-conformance --config 'patch.crates-io.jlreq.path="crates/jlreq"' # Compile no-default, every individual feature, and representative feature pairs. feature-matrix: @@ -127,18 +144,48 @@ wasm: rustup target add wasm32-unknown-unknown cargo check {{core_crates}} --target wasm32-unknown-unknown --no-default-features -# Exercise malformed and extreme public inputs under libFuzzer and sanitizers. The target -# is a separate nightly workspace, so none of its dependencies enter the product graph. -# cargo-fuzz's MSVC runtime does not execute reliably; Windows still compiles the exact -# harness, while the required Linux CI job performs the bounded sanitizer run. +# Exercise input validation, composition/arithmetic, and protocol parsing separately under +# libFuzzer. Curated seeds are copied below target/ so a run never dirties the source tree. fuzz-check: - {{ if os() == "windows" { "cargo +nightly check --manifest-path fuzz/Cargo.toml --bin public_api" } else { "cargo +nightly fuzz run public_api --fuzz-dir fuzz -- -runs=10000" } }} + {{ if os() == "windows" { "cargo +nightly check --manifest-path fuzz/Cargo.toml --bins" } else { "just _fuzz-target input_validation 30" } }} + {{ if os() == "windows" { "cargo +nightly check --manifest-path fuzz/Cargo.toml --bins" } else { "just _fuzz-target composition 30" } }} + {{ if os() == "windows" { "cargo +nightly check --manifest-path fuzz/Cargo.toml --bins" } else { "just _fuzz-target protocol_parser 30" } }} # The install-action cargo-fuzz binary is itself built for musl. cargo-fuzz 0.13.2 # otherwise mistakes that build triple for the fuzz target, but ASan requires the # dynamically linked GNU target used by GitHub's Ubuntu runner. fuzz-check-linux-ci: - cargo +nightly fuzz run public_api --fuzz-dir fuzz --target x86_64-unknown-linux-gnu -- -runs=10000 + just _fuzz-target-linux input_validation 30 + just _fuzz-target-linux composition 30 + just _fuzz-target-linux protocol_parser 30 + +# A single bounded fuzz target. Runtime corpora are disposable target/ state; only +# fuzz/seeds is reviewed and committed. +[private] +_fuzz-target target seconds: + mkdir -p target/fuzz-corpus/{{target}} + cp fuzz/seeds/{{target}}/* target/fuzz-corpus/{{target}}/ + cargo +nightly fuzz run {{target}} target/fuzz-corpus/{{target}} --fuzz-dir fuzz -- -max_total_time={{seconds}} -timeout=10 + +[private] +_fuzz-target-linux target seconds: + mkdir -p target/fuzz-corpus/{{target}} + cp fuzz/seeds/{{target}}/* target/fuzz-corpus/{{target}}/ + cargo +nightly fuzz run {{target}} target/fuzz-corpus/{{target}} --fuzz-dir fuzz --target x86_64-unknown-linux-gnu -- -max_total_time={{seconds}} -timeout=10 + +# Scheduled sanitizer budget: fifteen minutes for each independent failure domain. +fuzz-scheduled: + just _fuzz-target-linux input_validation 900 + just _fuzz-target-linux composition 900 + just _fuzz-target-linux protocol_parser 900 + +# Each handwritten product must independently stay above both release thresholds. Generated +# tables, test fixtures, xtask, and independent engines are covered by their own gates. The +# transport regression deliberately kills its stalled synthetic engine, so LLVM may see that +# one incomplete profile; `all` still rejects a run in which no valid profile can be merged. +coverage: + cargo llvm-cov -p jlreq --all-features --ignore-filename-regex '(/src/generated/|/tests/)' --fail-under-lines 90 --fail-under-regions 85 --summary-only + cargo llvm-cov -p jlreq-conformance --all-features --exclude-from-report jlreq --ignore-filename-regex '(/tests/)' --failure-mode all --fail-under-lines 90 --fail-under-regions 85 --summary-only # Reject std, I/O, and font dependencies in the layout core (docs/adr/0001). purity: @@ -148,10 +195,16 @@ purity: placeholder: cargo run --quiet -p xtask -- placeholder -# Hold jlreq to the exact 1.0 surface and all 22 typed Style mappings. +# Hold jlreq to the exact 0.1.0 export surface and all 22 typed Style mappings. api: cargo run --quiet -p xtask -- api +# Before the initial release, hold the local 0.1.0 control in both directions. For every +# later 0.1.x candidate, additionally compare the complete rustdoc API with the latest +# published jlreq release and reject patch-incompatible changes. +semver: + sh scripts/check-semver.sh + # Require the private implementation modules to follow the one-way architecture in # ARCHITECTURE.md. direction: @@ -189,8 +242,8 @@ attest: conform: cargo run --quiet -p xtask -- conform --check -# Hold the unreleased 0.0.0 state, reject CRLF in tracked UTF-8 files, and reject broken -# local Markdown links (CONTRIBUTING.md). +# Hold the prepared 0.1.0 state without performing publication, reject CRLF in tracked UTF-8 +# files, and reject broken local Markdown links (CONTRIBUTING.md). repository: cargo run --quiet -p xtask -- repository @@ -208,12 +261,17 @@ shear: # Check REUSE/SPDX compliance. reuse: - uvx --with charset-normalizer==3.4.9 reuse==6.2.0 lint + uvx --with charset-normalizer==3.4.9 reuse==6.2.0 --no-multiprocessing lint # Validate GitHub Actions workflows. actionlint: actionlint -color +# Validate every repository-owned POSIX shell entry point, including release packaging and +# the three-engine census driver. +shellcheck: + shellcheck engines/census-all.sh scripts/*.sh + # Reject high-severity GitHub Actions and Dependabot security findings without # granting the auditor network or repository credentials. zizmor: @@ -225,11 +283,12 @@ msrv: cargo msrv verify --path crates/jlreq-conformance cargo msrv verify --path xtask -# Mutation-test the crates with real logic against their own `#[cfg(test)]` suites, or one -# crate if `crate` is given (e.g. `just mutants jlreq`). It remains a scheduled report -# outside `ci-required` because a full mutation run is intentionally slow. +# Mutation-test both handwritten products, or one package/shard when supplied. Generated +# table and exact equivalent-mutant exclusions are pinned in docs/mutation-ledger.toml. Any +# missed or timed-out mutant makes cargo-mutants, and therefore this gate, fail. # -# `--test-tool nextest` matches `just test`. No `-D warnings` here unlike the other gates: +# Cargo's test tool is intentional: unlike nextest it also runs the harness-free synthetic +# transport executable. No `-D warnings` here unlike the other gates: # `[workspace.lints]` sets these at `warn`, not `deny`, and CI only escalates them to errors # by exporting `RUSTFLAGS` per job — which this recipe deliberately does not do. Mutated # code that merely provokes a new lint (an unused binding, say) still builds and runs @@ -238,8 +297,20 @@ msrv: # a different, structural thing: generic replacement values (`Default::default()`, # `::std::iter::empty()`) that do not type-check against this crate's domain types or its # `no_std` boundary — see the milestone report for the per-crate rate. -mutants crate="": - cargo mutants {{ if crate == "" { mutant_crates } else { "-p " + crate } }} --test-tool nextest --no-times --colors=never -j 4 +mutants crate="" shard="": + sh scripts/verify-mutation-ledger.sh + cargo mutants {{ if crate == "" { mutant_crates } else { "-p " + crate } }} {{ if shard == "" { "" } else { "--shard " + shard } }} --test-tool cargo --minimum-test-timeout 120 --no-times --colors=never -j 4 + +# Pull requests exercise only mutations in the changed Rust surface; weekly and release +# workflows run the complete sharded gate above. +mutants-smoke base: + sh scripts/verify-mutation-ledger.sh + cargo mutants --workspace --in-diff {{base}} --test-tool cargo --minimum-test-timeout 120 --no-times --colors=never -j 4 + +# Hold generated and equivalent-mutant exclusions to their individual source hashes and +# require every cargo-mutants regex to have one proof in the reviewable ledger. +mutation-ledger: + sh scripts/verify-mutation-ledger.sh # Build the independent OCaml reference engine (engines/ocaml/README.md). The engines are # outside the Cargo workspace and no Rust gate reads them, so the recipes below are the @@ -431,15 +502,34 @@ census-racket kind: build-engine-racket ocaml-build diff {{census_dir}}/{{kind}}.rust.ndjson {{census_dir}}/{{kind}}.racket.ndjson > {{census_dir}}/{{kind}}.racket.diff || true echo "census-racket {{kind}}: $(wc -l < {{census_dir}}/{{kind}}.requests.ndjson | tr -d ' ') request(s), $(grep -c '^<' {{census_dir}}/{{kind}}.racket.diff || true) differing response(s) -- {{census_dir}}/{{kind}}.racket.diff" +# Run every census in the generator registry through Rust, OCaml, and Racket. The script +# verifies all three pairings, the response cardinality, the ten-kind registry, the +# 122,199-case floor, and the committed generated summary. Any difference is fatal. +census-all: ocaml-test build-engine-racket test-engine-racket + cargo build --quiet -p jlreq-conformance --bins + sh engines/census-all.sh {{census_probe}} {{sample_engine}} {{ocaml_engine}} {{racket_engine}} {{census_summary}} + # The gates that hold the design itself, all of them reading the tree and none of them # needing the network (docs/design/api-spine.md). -design: purity placeholder api direction derive-check generate-check attest conform repository +design: purity placeholder api direction derive-check generate-check attest conform mutation-ledger repository @echo "design gates passed" # Fast deterministic checks used during the edit/commit loop. -check: fmt-check toml-check typos lint design shear reuse actionlint zizmor +check: fmt-check toml-check typos lint design shear reuse shellcheck actionlint zizmor @echo "fast local checks passed" # Every practical CI gate available on a developer machine. -ci: fmt-check toml-check typos lint feature-matrix test-ci doc package no-std wasm fuzz-check design deny shear reuse actionlint zizmor msrv conform-engines +ci: fmt-check toml-check typos lint feature-matrix test-ci doc package no-std wasm fuzz-check coverage design semver deny shear reuse shellcheck actionlint zizmor msrv conform-engines @echo "local CI passed" + +# Release acceptance performs no publication, tag, GitHub Release, or external settings +# change. It intentionally requires a clean tracked tree so generated and packaged output +# is reproducible from the candidate commit. +release-check: + test -z "$(git status --porcelain --untracked-files=no)" || { echo "release-check requires a clean tracked tree" >&2; exit 1; } + just ci + just publish-dry-run + just census-all + just mutants jlreq + just mutants jlreq-conformance + sh scripts/verify-release-state.sh diff --git a/README.md b/README.md index 2b30bff..7c1f30d 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,11 @@ The public surface is deliberately limited to: Font loading, shaping, UAX #14 segmentation, bidi resolution, rasterization, and drawing remain the caller's responsibility. -This repository is an unreleased `0.0.0` development snapshot. It has not reached 0.1, and -neither package is publishable. The implementation exercises the candidate end-to-end -pipeline while its API and behavior remain free to change. The conformance inventory -currently reports zero mechanically implementable deferrals; three editorial and three -non-observable statements are classified with evidence rather than represented by empty -cases. +This tree is prepared as version 0.1.0: both crate archives, binaries, release metadata, +and verification workflows can be produced without publishing. No crate upload, tag, or +GitHub Release is performed by the preparation gates. Within 0.1.x, the public Rust surface +recorded in `docs/public-api.toml`, protocol v1, stable error codes, and MSRV 1.85 are +compatibility contracts. ## Quick start @@ -32,23 +31,27 @@ let text = ShapedText::new(source, Size::square(1_000)?, Frame::FullEm, clusters let paragraph = Paragraph::builder(text, 4_000) .breaks(source.char_indices().skip(1).map(|(at, _)| Break::allowed(at))) .build()?; -let layout = jlreq::compose(¶graph, &Style::book_2020()); - -for line in layout.lines() { - for placement in line.clusters() { - draw(placement); - } -} +let layout = jlreq::compose(¶graph, &Style::book_2020()) + .expect("this small paragraph is within the default resource limits"); +assert_eq!(layout.lines().len(), 2); # Ok::<(), jlreq::InputError>(()) ``` All input ranges are UTF-8 byte ranges. `ShapedText` owns the source and clusters; `ParagraphBuilder` validates ranges, breaks, tabs, writing mode, widow control, and inline -constructs once. Composition is then infallible: an overfull or otherwise degraded result -still contains placements and a stable diagnostic. +constructs once. Composition returns a complete exact `Layout` or a typed `ComposeError`. +It never returns a partial layout, approximates a placement, or silently falls back to +first-fit. Fit conditions that remain valid but cannot be improved, such as an overfull +line, still produce a complete layout with a stable diagnostic. Use `Composer` instead of the root `compose` function when composing repeatedly; it reuses -its search scratch space without lending it to the returned `Layout`. +its search scratch space without lending it to the returned `Layout`, supports explicit +`CompositionLimits`, and remains reusable after a resource error. See the executable +[`minimal`](crates/jlreq/examples/minimal.rs), +[`Composer`](crates/jlreq/examples/composer.rs), and +[`vertical`](crates/jlreq/examples/vertical.rs) examples. The +[`reference_integration`](crates/jlreq-conformance/tests/reference_integration.rs) test +connects ICU4X byte break offsets and HarfRust glyph clusters at the intended caller seam. ## Scope @@ -92,6 +95,12 @@ jlreq-conformance run ENGINE [SUITE.ndjson] 2 input, protocol, or engine error ``` +All commands accept `--help`, `--version`, `--verbose`, `--timeout-seconds`, +`--max-message-bytes`, `--max-suite-bytes`, and `--max-cases`. Defaults are 30 seconds +without communication, 1 MiB per message, 256 MiB per suite, and 200,000 cases. Requests +and responses stream concurrently; responses may arrive in any order and are matched by a +unique `id`. Duplicate, unknown, missing, or extra responses are protocol errors. + The package contains no library target. Its committed JSON Schema, built-in suite, and `jlreq-sample-engine` executable form an end-to-end protocol example. See [`docs/design/conformance.md`](docs/design/conformance.md). @@ -131,9 +140,12 @@ just ci # all practical CI checks, including no_std and WASM cargo run -p jlreq-conformance -- list ``` -The candidate 1.0 names are tracked in [`docs/api-1.0.toml`](docs/api-1.0.toml). This is a -development control, not a released compatibility promise. The gate checks both missing and -extra exports, as well as all 22 typed Style mappings. +The 0.1.0 names and release-line contract are tracked in +[`docs/public-api.toml`](docs/public-api.toml). The network-free API gate checks missing and +extra exports plus all 22 typed Style mappings. Starting with the next 0.1.x candidate, the +required semver job also compares the complete rustdoc API with the latest published jlreq +release. Stable error and diagnostic codes are listed in +[`docs/error-codes.md`](docs/error-codes.md). ## License diff --git a/REUSE.toml b/REUSE.toml index 99d21ab..9753ebe 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -21,7 +21,7 @@ SPDX-FileCopyrightText = "2026 jlreq contributors" SPDX-License-Identifier = "MIT OR Apache-2.0" [[annotations]] -path = ["**/tests/fixtures/**", "fuzz/corpus/**"] +path = ["**/tests/fixtures/**", "fuzz/corpus/**", "fuzz/seeds/**"] precedence = "override" SPDX-FileCopyrightText = "2026 jlreq contributors" SPDX-License-Identifier = "MIT OR Apache-2.0" diff --git a/ROADMAP.md b/ROADMAP.md index f4f3b4e..185381b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,13 +1,13 @@ # Roadmap -## Current status: unreleased 0.0.0 +## Current status: 0.1.0 prepared, not published -jlreq has not reached 0.1. Both product packages set `publish = false`; there is no -released compatibility contract, support line, release date, or implied path to 1.0. -Passing the repository's implementation and conformance gates is evidence about the current -tree, not a release decision. +Both product packages and their release artifacts are ready for 0.1.0. The public Rust API, +stable error codes, protocol v1, integer placement, MSRV 1.85, and `no_std + alloc` boundary +are the 0.1.x compatibility line. Preparation deliberately does not upload crates, create a +tag or GitHub Release, configure a Trusted Publisher, or change branch protection. -The current implementation is exploring a deliberately small eventual product boundary: +The release has a deliberately small product boundary: - one dependency-free `no_std + alloc` Rust library; - one validated paragraph composition pipeline for caller-shaped UTF-8 clusters; @@ -48,25 +48,22 @@ whole suite. Ten synthetic censuses agree with the Rust engine across 122,199 fu requests, and the twenty-six observable policies the exercise turned up — rules two engines must share to pass the same case, stated in no sentence of JLReq and no file under `docs/` — are listed in `engines/ocaml/README.md` and are candidates for `docs/decisions/`. -`engines/racket/` will follow the same shape. See +[`engines/racket/`](engines/racket/README.md) independently implements the same complete +protocol surface. The generated census summary, rather than prose copied by hand, records +all ten census counts and all three pairwise zero-difference results. See +[the summary](docs/generated/conformance-summary.md) and [ADR 0024](docs/adr/0024-independent-reference-engines.md). -## Before any release +## Publication-only work remaining -- Keep development test-first: reproduce an observable failure, verify Red, implement the - smallest coherent behavior, verify Green through the Rust API and protocol suite, then - refactor under the architecture gates. -- Expand mixed-script and vertical reference fixtures, malformed-input fuzz corpora, and - arithmetic-extreme coverage. -- Profile realistic paragraphs without exposing implementation tuning knobs. -- Treat `docs/api-1.0.toml` as a candidate-surface control only; compatibility remains open - to change before an explicit release decision. -- Do not remove `publish = false`, create a version tag, or move changelog entries out of - `Unreleased` as part of ordinary development work. +- Choose the release date and move the completed `Unreleased` notes to `0.1.0`. +- With explicit maintainer approval, upload `jlreq`, wait for the crates.io index, then + upload `jlreq-conformance`. +- Configure crates.io Trusted Publishing after that required first manual publication. +- Create the `v0.1.0` tag and GitHub Release from the already verified artifacts. +- Apply any desired external branch-protection settings separately. -## Candidate long-term invariants - -These are design goals to evaluate before a stable release, not current promises: +## Release-line invariants - `Style::default()` remains identical to `Style::jlreq_2020()`. - A future JLReq revision adds a dated profile and specification identifier rather than diff --git a/SECURITY.md b/SECURITY.md index 73462ae..a8f0842 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,6 +16,11 @@ Specifically in scope: - Unbounded memory growth or non-termination driven by input size or content - Integer overflow producing incorrect placement rather than a defined error +Composition has deterministic caller-configurable bounds for clusters, break candidates, +constructs, tab stops, and charged search transitions. The protocol runner separately +bounds message and suite bytes, case count, retained stderr, and inactivity time; a refusal +through one of these controls is expected behavior, not a partial result. + Out of scope: a layout result you disagree with. Where JLReq permits alternatives, use an issue or a conformance case. @@ -28,12 +33,12 @@ Expect an acknowledgement within seven days. ## Supported versions -jlreq has no released or supported version yet. Security reports against the development -branch are still welcome and fixes land on `main`; there is currently no backport policy. +The prepared 0.1.x line receives security fixes. Before the first publication, reports +against the prepared 0.1.0 tree and `main` follow the same policy. | Version | Supported | | --- | --- | -| `0.0.0` development snapshot | Best effort | -| Released versions | None exist | +| `0.1.x` | Supported | +| `< 0.1.0` development snapshots | Unsupported | [advisories]: https://github.com/P4suta/jlreq/security/advisories/new diff --git a/crates/jlreq-conformance/Cargo.toml b/crates/jlreq-conformance/Cargo.toml index 853a4ee..171b9b1 100644 --- a/crates/jlreq-conformance/Cargo.toml +++ b/crates/jlreq-conformance/Cargo.toml @@ -4,7 +4,6 @@ [package] name = "jlreq-conformance" -publish = false description = "Language-independent black-box conformance runner for jlreq engines" default-run = "jlreq-conformance" keywords = ["japanese", "typesetting", "jlreq", "conformance", "text-layout"] @@ -18,6 +17,16 @@ license.workspace = true authors.workspace = true repository.workspace = true homepage.workspace = true +include = [ + "src/**", + "tests/**", + "Cargo.toml", + "README.md", + "LICENSE-MIT", + "LICENSE-APACHE", + "protocol.schema.json", + "suite.ndjson", +] [[bin]] name = "jlreq-conformance" @@ -27,8 +36,13 @@ path = "src/main.rs" name = "jlreq-sample-engine" path = "src/sample_engine.rs" +[[test]] +name = "transport" +path = "tests/transport.rs" +harness = false + [dependencies] -jlreq = { version = "0.0.0", path = "../jlreq" } +jlreq = { version = "0.1.0", path = "../jlreq" } serde_json = "1.0" [dev-dependencies] diff --git a/crates/jlreq-conformance/LICENSE-APACHE b/crates/jlreq-conformance/LICENSE-APACHE new file mode 100644 index 0000000..137069b --- /dev/null +++ b/crates/jlreq-conformance/LICENSE-APACHE @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/crates/jlreq-conformance/LICENSE-MIT b/crates/jlreq-conformance/LICENSE-MIT new file mode 100644 index 0000000..d817195 --- /dev/null +++ b/crates/jlreq-conformance/LICENSE-MIT @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/jlreq-conformance/README.md b/crates/jlreq-conformance/README.md index e420e62..43bf26e 100644 --- a/crates/jlreq-conformance/README.md +++ b/crates/jlreq-conformance/README.md @@ -12,15 +12,19 @@ every message identifies `jlreq.conformance/1` and `jlreq-2020-08-11+unicode-17.0.0`. ```text -jlreq-conformance list [SUITE.ndjson] -jlreq-conformance validate [SUITE.ndjson|-] -jlreq-conformance run ENGINE [SUITE.ndjson] +jlreq-conformance [OPTIONS] list [SUITE.ndjson] +jlreq-conformance [OPTIONS] validate [SUITE.ndjson|-] +jlreq-conformance [OPTIONS] run ENGINE [SUITE.ndjson] ``` Exit status 0 means conformance or valid input, 1 means an observable mismatch, and 2 means -an input, protocol, or engine error. The package includes `protocol.schema.json`, the -built-in suite, and `jlreq-sample-engine`; it intentionally has no library target. +an input, protocol, or engine error. The runner streams requests and responses concurrently, +matches responses by unique `id` in any order, enforces bounded messages/suites/case counts, +and kills an engine after a configurable period without communication. Run `--help` for +`--verbose`, timeout, and size-limit controls. + +The package includes `protocol.schema.json`, the built-in suite, and +`jlreq-sample-engine`; it intentionally has no library target. See the [protocol design](https://github.com/P4suta/jlreq/blob/main/docs/design/conformance.md) -and [main repository guide](https://github.com/P4suta/jlreq) for the unreleased candidate -contract. +and [main repository guide](https://github.com/P4suta/jlreq) for the 0.1.0 contract. diff --git a/crates/jlreq-conformance/src/main.rs b/crates/jlreq-conformance/src/main.rs index b306fd5..97b2f5b 100644 --- a/crates/jlreq-conformance/src/main.rs +++ b/crates/jlreq-conformance/src/main.rs @@ -7,10 +7,17 @@ mod validation; use std::{ + collections::{BTreeMap, BTreeSet}, env, fs, - io::{self, Read, Write}, + io::{self, BufRead, BufReader, Cursor, Read, Write}, path::Path, - process::{Command, ExitCode, Stdio}, + process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitCode, Stdio}, + sync::{ + Arc, Mutex, + mpsc::{self, RecvTimeoutError, SyncSender}, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, }; use serde_json::{Map, Value}; @@ -22,146 +29,288 @@ const BUILTIN_SUITE: &str = include_str!("../suite.ndjson"); #[cfg(test)] const PROTOCOL_SCHEMA: &str = include_str!("../protocol.schema.json"); -fn main() -> ExitCode { - let mut arguments = env::args().skip(1); - let Some(command) = arguments.next() else { - usage(); - return ExitCode::from(2); - }; - let rest: Vec<_> = arguments.collect(); - match command.as_str() { - "validate" => validate_command(&rest), - "list" => list_command(&rest), - "run" => run_command(&rest), - _ => { - usage(); - ExitCode::from(2) - }, - } -} - -fn usage() { - eprintln!("usage: jlreq-conformance [arguments]"); -} +const DEFAULT_TIMEOUT_SECONDS: u64 = 30; +const DEFAULT_MAX_MESSAGE_BYTES: usize = 1024 * 1024; +const DEFAULT_MAX_SUITE_BYTES: usize = 256 * 1024 * 1024; +const DEFAULT_MAX_CASES: usize = 200_000; +const STDERR_RETAIN_BYTES: usize = 1024 * 1024; +const EVENT_CHANNEL_CAPACITY: usize = 64; +const WATCHDOG_TICK: Duration = Duration::from_millis(100); -fn validate_command(arguments: &[String]) -> ExitCode { - if arguments.len() > 1 { - usage(); - return ExitCode::from(2); - } - let input = match read_suite(arguments.first().map(String::as_str)) { - Ok(input) => input, - Err(error) => { - eprintln!("jlreq-conformance: {error}"); - return ExitCode::from(2); +fn main() -> ExitCode { + match parse_invocation(env::args().skip(1)) { + Ok(Invocation::Help) => { + print_help(); + ExitCode::SUCCESS }, - }; - match parse_messages(&input, false) { - Ok(messages) => { - eprintln!("validated {} message(s)", messages.len()); + Ok(Invocation::Version) => { + println!("jlreq-conformance {}", env!("CARGO_PKG_VERSION")); ExitCode::SUCCESS }, + Ok(Invocation::Command(options, command)) => run_command(&options, command), Err(error) => { eprintln!("jlreq-conformance: {error}"); + eprintln!("try 'jlreq-conformance --help' for usage"); ExitCode::from(2) }, } } -fn list_command(arguments: &[String]) -> ExitCode { - if arguments.len() > 1 { - usage(); - return ExitCode::from(2); - } - let input = match read_suite_or_builtin(arguments.first().map(String::as_str)) { - Ok(input) => input, - Err(error) => { - eprintln!("jlreq-conformance: {error}"); - return ExitCode::from(2); - }, - }; - match parse_cases(&input) { - Ok(cases) => { - for case in cases { - println!("{}", case.id); - } - ExitCode::SUCCESS - }, - Err(error) => { - eprintln!("jlreq-conformance: {error}"); - ExitCode::from(2) - }, +#[derive(Debug, Clone)] +struct Options { + verbose: bool, + timeout: Duration, + max_message_bytes: usize, + max_suite_bytes: usize, + max_cases: usize, +} + +impl Default for Options { + fn default() -> Self { + Self { + verbose: false, + timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECONDS), + max_message_bytes: DEFAULT_MAX_MESSAGE_BYTES, + max_suite_bytes: DEFAULT_MAX_SUITE_BYTES, + max_cases: DEFAULT_MAX_CASES, + } } } -fn run_command(arguments: &[String]) -> ExitCode { - if arguments.is_empty() || arguments.len() > 2 { - eprintln!("usage: jlreq-conformance run ENGINE [SUITE.ndjson]"); - return ExitCode::from(2); +#[derive(Debug, Clone)] +enum CliCommand { + Validate { + suite: Option, + }, + List { + suite: Option, + }, + Run { + engine: String, + suite: Option, + }, +} + +#[derive(Debug, Clone)] +enum Invocation { + Help, + Version, + Command(Options, CliCommand), +} + +fn parse_invocation(arguments: impl IntoIterator) -> Result { + let mut options = Options::default(); + let mut positional = Vec::new(); + let mut arguments = arguments.into_iter().peekable(); + let mut parse_options = true; + while let Some(argument) = arguments.next() { + if parse_options && argument == "--" { + parse_options = false; + } else if parse_options && matches!(argument.as_str(), "-h" | "--help") { + return Ok(Invocation::Help); + } else if parse_options && matches!(argument.as_str(), "-V" | "--version") { + return Ok(Invocation::Version); + } else if parse_options && argument == "--verbose" { + options.verbose = true; + } else if parse_options && argument.starts_with("--") { + let (name, inline) = argument + .split_once('=') + .map_or((argument.as_str(), None), |(name, value)| { + (name, Some(value)) + }); + let value = match name { + "--timeout-seconds" + | "--max-message-bytes" + | "--max-suite-bytes" + | "--max-cases" => inline.map(str::to_owned).or_else(|| arguments.next()), + _ => return Err(format!("unknown option {argument:?}")), + } + .ok_or_else(|| format!("{name} needs a positive integer"))?; + match name { + "--timeout-seconds" => { + let seconds = positive_u64(&value, name)?; + options.timeout = Duration::from_secs(seconds); + }, + "--max-message-bytes" => { + options.max_message_bytes = positive_usize(&value, name)?; + }, + "--max-suite-bytes" => { + options.max_suite_bytes = positive_usize(&value, name)?; + }, + "--max-cases" => options.max_cases = positive_usize(&value, name)?, + _ => return Err(format!("unknown option {argument:?}")), + } + } else { + positional.push(argument); + } } - let input = match read_suite_or_builtin(arguments.get(1).map(String::as_str)) { - Ok(input) => input, - Err(error) => { - eprintln!("jlreq-conformance: {error}"); - return ExitCode::from(2); - }, + + let Some(command) = positional.first().map(String::as_str) else { + return Err("a command is required".to_owned()); }; - let cases = match parse_cases(&input) { - Ok(cases) => cases, - Err(error) => { - eprintln!("jlreq-conformance: {error}"); - return ExitCode::from(2); + let command = match command { + "validate" if positional.len() <= 2 => CliCommand::Validate { + suite: positional.get(1).cloned(), + }, + "list" if positional.len() <= 2 => CliCommand::List { + suite: positional.get(1).cloned(), + }, + "run" if (2..=3).contains(&positional.len()) => CliCommand::Run { + engine: positional[1].clone(), + suite: positional.get(2).cloned(), }, + "validate" => return Err("usage: jlreq-conformance validate [SUITE.ndjson|-]".to_owned()), + "list" => return Err("usage: jlreq-conformance list [SUITE.ndjson]".to_owned()), + "run" => { + return Err("usage: jlreq-conformance run ENGINE [SUITE.ndjson]".to_owned()); + }, + other => return Err(format!("unknown command {other:?}")), }; - match run_engine(&arguments[0], &cases) { - Ok(0) => ExitCode::SUCCESS, - Ok(differences) => { - eprintln!("{differences} conformance case(s) differed"); - ExitCode::from(1) + Ok(Invocation::Command(options, command)) +} + +fn positive_u64(value: &str, option: &str) -> Result { + value + .parse::() + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| format!("{option} needs a positive integer")) +} + +fn positive_usize(value: &str, option: &str) -> Result { + value + .parse::() + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| format!("{option} needs a positive integer")) +} + +fn print_help() { + println!( + "jlreq-conformance {}\n\ + \n\ + Usage:\n\ + jlreq-conformance [OPTIONS] validate [SUITE.ndjson|-]\n\ + jlreq-conformance [OPTIONS] list [SUITE.ndjson]\n\ + jlreq-conformance [OPTIONS] run ENGINE [SUITE.ndjson]\n\ + \n\ + Options:\n\ + -h, --help Show this help\n\ + -V, --version Show the package version\n\ + --verbose Show bounded JSON differences\n\ + --timeout-seconds N No-I/O timeout (default: 30)\n\ + --max-message-bytes N Per-line limit (default: 1048576)\n\ + --max-suite-bytes N Stream limit (default: 268435456)\n\ + --max-cases N Message/case limit (default: 200000)", + env!("CARGO_PKG_VERSION") + ); +} + +fn run_command(options: &Options, command: CliCommand) -> ExitCode { + match command { + CliCommand::Validate { suite } => { + match read_messages(suite.as_deref(), false, false, options) { + Ok(messages) => { + eprintln!("validated {} message(s)", messages.len()); + ExitCode::SUCCESS + }, + Err(error) => protocol_exit(&error), + } }, - Err(error) => { - eprintln!("jlreq-conformance: {error}"); - ExitCode::from(2) + CliCommand::List { suite } => match read_cases(suite.as_deref(), true, options) { + Ok(cases) => { + for case in cases { + println!("{}", case.id); + } + ExitCode::SUCCESS + }, + Err(error) => protocol_exit(&error), + }, + CliCommand::Run { engine, suite } => { + let cases = match read_cases(suite.as_deref(), true, options) { + Ok(cases) => cases, + Err(error) => return protocol_exit(&error), + }; + match run_engine(&engine, cases, options) { + Ok(0) => ExitCode::SUCCESS, + Ok(differences) => { + eprintln!("{differences} conformance case(s) differed"); + ExitCode::from(1) + }, + Err(error) => protocol_exit(&error), + } }, } } -#[derive(Debug)] +fn protocol_exit(error: &str) -> ExitCode { + eprintln!("jlreq-conformance: {error}"); + ExitCode::from(2) +} + +#[derive(Debug, Clone)] struct Case { id: String, request: Value, expected: Value, } -fn read_suite(path: Option<&str>) -> Result { +fn suite_reader(path: Option<&str>, builtin_when_absent: bool) -> Result, String> { match path { - Some(path) if path != "-" => { - fs::read_to_string(Path::new(path)).map_err(|error| error.to_string()) - }, - _ => { - let mut input = String::new(); - io::stdin() - .read_to_string(&mut input) - .map_err(|error| error.to_string())?; - Ok(input) - }, + None if builtin_when_absent => Ok(Box::new(Cursor::new(BUILTIN_SUITE.as_bytes()))), + Some("-") | None => Ok(Box::new(BufReader::new(io::stdin()))), + Some(path) => fs::File::open(Path::new(path)) + .map(|file| Box::new(BufReader::new(file)) as Box) + .map_err(|error| format!("could not open suite {path:?}: {error}")), } } -fn read_suite_or_builtin(path: Option<&str>) -> Result { - match path { - None => Ok(BUILTIN_SUITE.to_owned()), - Some(path) => read_suite(Some(path)), - } +fn read_messages( + path: Option<&str>, + builtin_when_absent: bool, + cases: bool, + options: &Options, +) -> Result, String> { + let mut reader = suite_reader(path, builtin_when_absent)?; + parse_reader(&mut *reader, cases, options, "suite") } -fn parse_messages(input: &str, cases: bool) -> Result, String> { +fn parse_reader( + reader: &mut dyn BufRead, + cases: bool, + options: &Options, + stream_name: &str, +) -> Result, String> { let mut messages = Vec::new(); - for (line_index, line) in input.lines().enumerate() { + let mut total = 0_usize; + let mut line_number = 0_usize; + loop { + let previous_total = total; + let Some(line) = read_limited_line( + reader, + options.max_message_bytes, + options.max_suite_bytes, + &mut total, + stream_name, + )? + else { + break; + }; + if total == previous_total { + return Err(format!("{stream_name} reader made no progress")); + } + line_number = line_number.saturating_add(1); + let line = std::str::from_utf8(&line) + .map_err(|error| format!("line {line_number}: input is not UTF-8: {error}"))?; if line.trim().is_empty() { continue; } - let line_number = line_index.saturating_add(1); + if messages.len() >= options.max_cases { + return Err(format!( + "{stream_name} exceeds the {} message limit", + options.max_cases + )); + } let value: Value = serde_json::from_str(line) .map_err(|error| format!("line {line_number}: invalid JSON: {error}"))?; validate_envelope(&value, cases).map_err(|error| format!("line {line_number}: {error}"))?; @@ -173,6 +322,47 @@ fn parse_messages(input: &str, cases: bool) -> Result, String> { Ok(messages) } +fn read_limited_line( + reader: &mut dyn BufRead, + max_message_bytes: usize, + max_total_bytes: usize, + total: &mut usize, + stream_name: &str, +) -> Result>, String> { + let mut line = Vec::new(); + loop { + let available = reader + .fill_buf() + .map_err(|error| format!("could not read {stream_name}: {error}"))?; + if available.is_empty() { + return if line.is_empty() { + Ok(None) + } else { + Ok(Some(line)) + }; + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let content = newline.unwrap_or(available.len()); + let consumed = content.saturating_add(usize::from(newline.is_some())); + *total = total.saturating_add(consumed); + if *total > max_total_bytes { + return Err(format!( + "{stream_name} exceeds the {max_total_bytes} byte total limit" + )); + } + if line.len().saturating_add(content) > max_message_bytes { + return Err(format!( + "{stream_name} message exceeds the {max_message_bytes} byte line limit" + )); + } + line.extend_from_slice(&available[..content]); + reader.consume(consumed); + if newline.is_some() { + return Ok(Some(line)); + } + } +} + fn validate_envelope(value: &Value, case: bool) -> Result<(), String> { let object = value .as_object() @@ -243,7 +433,7 @@ fn validate_rules(value: &Value) -> Result<(), String> { if rules.is_empty() { return Err("suite rules must not be empty".to_owned()); } - let mut seen = std::collections::BTreeSet::new(); + let mut seen = BTreeSet::new(); for rule in rules { let rule = rule .as_str() @@ -264,8 +454,18 @@ fn required_string(object: &Map, name: &str, expected: &str) -> R } } -fn parse_cases(input: &str) -> Result, String> { - parse_messages(input, true)? +fn read_cases( + path: Option<&str>, + builtin_when_absent: bool, + options: &Options, +) -> Result, String> { + let values = read_messages(path, builtin_when_absent, true, options)?; + cases_from_values(values) +} + +fn cases_from_values(values: Vec) -> Result, String> { + let mut seen = BTreeSet::new(); + values .into_iter() .map(|value| { let object = value @@ -276,6 +476,9 @@ fn parse_cases(input: &str) -> Result, String> { .and_then(Value::as_str) .ok_or_else(|| "validated case lost its id".to_owned())? .to_owned(); + if !seen.insert(id.clone()) { + return Err(format!("suite case id {id:?} is repeated")); + } let request = object .get("request") .cloned() @@ -293,67 +496,510 @@ fn parse_cases(input: &str) -> Result, String> { .collect() } -fn run_engine(engine: &str, cases: &[Case]) -> Result { +#[cfg(test)] +fn parse_messages(input: &str, cases: bool) -> Result, String> { + let mut reader = Cursor::new(input.as_bytes()); + parse_reader(&mut reader, cases, &Options::default(), "NDJSON stream") +} + +#[cfg(test)] +fn parse_cases(input: &str) -> Result, String> { + cases_from_values(parse_messages(input, true)?) +} + +#[derive(Debug)] +enum EngineEvent { + Response(Value), + WriterDone(Result<(), String>), + ReaderDone(Result<(), String>), +} + +#[derive(Debug)] +struct StderrCapture { + retained: Vec, + discarded: usize, +} + +fn run_engine(engine: &str, cases: Vec, options: &Options) -> Result { let mut child = Command::new(engine) .stdin(Stdio::piped()) .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() .map_err(|error| format!("could not start engine {engine:?}: {error}"))?; - { - let stdin = child - .stdin - .as_mut() - .ok_or_else(|| "engine stdin was not piped".to_owned())?; - for case in cases { - let message = serde_json::json!({ - "protocol": PROTOCOL, - "spec": SPEC, - "id": case.id, - "request": case.request, - }); - serde_json::to_writer(&mut *stdin, &message).map_err(|error| error.to_string())?; - stdin.write_all(b"\n").map_err(|error| error.to_string())?; + let stdin = child + .stdin + .take() + .ok_or_else(|| stop_unstarted_child(&mut child, "engine stdin was not piped"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| stop_unstarted_child(&mut child, "engine stdout was not piped"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| stop_unstarted_child(&mut child, "engine stderr was not piped"))?; + + let cases = Arc::new(cases); + let activity = Arc::new(Mutex::new(Instant::now())); + let (sender, receiver) = mpsc::sync_channel(EVENT_CHANNEL_CAPACITY); + + let writer = spawn_writer( + stdin, + Arc::clone(&cases), + sender.clone(), + Arc::clone(&activity), + ) + .map_err(|error| { + stop_child(&mut child); + format!("could not start engine stdin writer: {error}") + })?; + let reader = match spawn_reader(stdout, options.clone(), sender, Arc::clone(&activity)) { + Ok(reader) => reader, + Err(error) => { + stop_child(&mut child); + let _ = writer.join(); + return Err(format!("could not start engine stdout reader: {error}")); + }, + }; + let stderr_reader = match spawn_stderr(stderr) { + Ok(stderr_reader) => stderr_reader, + Err(error) => { + stop_child(&mut child); + drop(receiver); + let _ = writer.join(); + let _ = reader.join(); + return Err(format!("could not start engine stderr drainer: {error}")); + }, + }; + + let expected: BTreeMap<_, _> = cases + .iter() + .enumerate() + .map(|(index, case)| (case.id.clone(), index)) + .collect(); + let mut seen = BTreeSet::new(); + let mut differences = 0_usize; + let mut writer_done = false; + let mut reader_done = false; + let mut status = None; + let mut failure = None; + + while engine_work_pending( + failure.is_some(), + (writer_done, reader_done, status.is_some()), + ) { + if status.is_none() { + match child.try_wait() { + Ok(found) => status = found, + Err(error) => failure = Some(format!("could not inspect engine status: {error}")), + } + } + let idle = activity_elapsed(&activity).unwrap_or(options.timeout); + if idle >= options.timeout { + failure = Some(format!( + "engine made no stdin/stdout progress for {} second(s)", + options.timeout.as_secs() + )); + break; + } + let remaining = options.timeout.saturating_sub(idle); + let wait = WATCHDOG_TICK.min(remaining); + match receiver.recv_timeout(wait) { + Ok(EngineEvent::Response(response)) => { + if let Err(error) = compare_response( + &response, + &expected, + &cases, + &mut seen, + &mut differences, + options.verbose, + ) { + failure = Some(error); + } + }, + Ok(EngineEvent::WriterDone(result)) => { + writer_done = true; + if let Err(error) = result { + failure = Some(error); + } + }, + Ok(EngineEvent::ReaderDone(result)) => { + reader_done = true; + if let Err(error) = result { + failure = Some(error); + } + }, + Err(RecvTimeoutError::Timeout) => {}, + Err(RecvTimeoutError::Disconnected) => { + if workers_disconnected_early(writer_done, reader_done) { + failure = Some("engine I/O workers stopped unexpectedly".to_owned()); + } + }, } } - drop(child.stdin.take()); - - let output = child - .wait_with_output() - .map_err(|error| format!("could not wait for engine: {error}"))?; - if !output.status.success() { - return Err(format!("engine exited with {}", output.status)); - } - let stdout = String::from_utf8(output.stdout) - .map_err(|error| format!("engine output was not UTF-8: {error}"))?; - let responses = parse_messages(&stdout, false)?; - if responses.len() != cases.len() { - return Err(format!( - "engine returned {} response(s) for {} request(s)", - responses.len(), - cases.len() + + if failure.is_some() { + let _ = child.kill(); + } + drop(receiver); + let waited = child.wait(); + let writer_join = join_worker(writer, "stdin writer"); + let reader_join = join_worker(reader, "stdout reader"); + let captured = join_stderr(stderr_reader); + + if let Some(error) = failure { + return Err(with_stderr(error, captured.as_ref().ok())); + } + writer_join?; + reader_join?; + let status = status + .or_else(|| waited.ok()) + .ok_or_else(|| "could not wait for engine".to_owned())?; + if !status.success() { + return Err(with_stderr( + format!("engine exited with {status}"), + captured.as_ref().ok(), )); } + let captured = captured?; + if seen.len() != cases.len() { + let missing = cases + .iter() + .filter(|case| !seen.contains(case.id.as_str())) + .take(3) + .map(|case| case.id.as_str()) + .collect::>() + .join(", "); + return Err(with_stderr( + format!( + "engine omitted {} response(s){}", + cases.len().saturating_sub(seen.len()), + if missing.is_empty() { + String::new() + } else { + format!(": {missing}") + } + ), + Some(&captured), + )); + } + Ok(differences) +} - let mut differences = 0_usize; - for (case, response) in cases.iter().zip(responses) { - let object = response - .as_object() - .ok_or_else(|| "validated response stopped being an object".to_owned())?; - if object.get("id").and_then(Value::as_str) != Some(case.id.as_str()) { - return Err(format!("response id does not match case {:?}", case.id)); +fn engine_work_pending(failed: bool, progress: (bool, bool, bool)) -> bool { + let (writer_done, reader_done, status_seen) = progress; + !(failed || (writer_done && reader_done && status_seen)) +} + +fn workers_disconnected_early(writer_done: bool, reader_done: bool) -> bool { + !(writer_done && reader_done) +} + +fn stop_unstarted_child(child: &mut Child, message: &str) -> String { + stop_child(child); + message.to_owned() +} + +fn stop_child(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +fn spawn_writer( + stdin: ChildStdin, + cases: Arc>, + sender: SyncSender, + activity: Arc>, +) -> io::Result> { + thread::Builder::new() + .name("jlreq-engine-stdin".to_owned()) + .spawn(move || { + let result = write_requests(stdin, &cases, &activity); + let _ = sender.send(EngineEvent::WriterDone(result)); + }) +} + +fn write_requests( + mut stdin: ChildStdin, + cases: &[Case], + activity: &Mutex, +) -> Result<(), String> { + for case in cases { + let message = serde_json::json!({ + "protocol": PROTOCOL, + "spec": SPEC, + "id": case.id, + "request": case.request, + }); + serde_json::to_writer(&mut stdin, &message).map_err(|error| { + format!( + "engine stdin could not encode request {:?}: {error}", + case.id + ) + })?; + stdin + .write_all(b"\n") + .and_then(|()| stdin.flush()) + .map_err(|error| { + format!( + "engine stdin could not write request {:?}: {error}", + case.id + ) + })?; + mark_activity(activity); + } + Ok(()) +} + +fn spawn_reader( + stdout: ChildStdout, + options: Options, + sender: SyncSender, + activity: Arc>, +) -> io::Result> { + thread::Builder::new() + .name("jlreq-engine-stdout".to_owned()) + .spawn(move || { + let result = read_responses(stdout, &options, &sender, &activity); + let _ = sender.send(EngineEvent::ReaderDone(result)); + }) +} + +fn read_responses( + stdout: ChildStdout, + options: &Options, + sender: &SyncSender, + activity: &Mutex, +) -> Result<(), String> { + let mut reader = BufReader::new(stdout); + let mut total = 0_usize; + let mut line_number = 0_usize; + let mut response_count = 0_usize; + loop { + let previous_total = total; + let Some(line) = read_limited_line( + &mut reader, + options.max_message_bytes, + options.max_suite_bytes, + &mut total, + "engine stdout", + )? + else { + break; + }; + if total == previous_total { + return Err("engine stdout reader made no progress".to_owned()); } - if object.get("response") != Some(&case.expected) { - differences = differences.saturating_add(1); - eprintln!("DIFF {}", case.id); + line_number = line_number.saturating_add(1); + let line = std::str::from_utf8(&line) + .map_err(|error| format!("engine line {line_number} is not UTF-8: {error}"))?; + if line.trim().is_empty() { + continue; } + response_count = response_count.saturating_add(1); + if response_count > options.max_cases { + return Err(format!( + "engine stdout exceeds the {} response limit", + options.max_cases + )); + } + let value: Value = serde_json::from_str(line) + .map_err(|error| format!("engine line {line_number}: invalid JSON: {error}"))?; + validate_envelope(&value, false) + .map_err(|error| format!("engine line {line_number}: {error}"))?; + mark_activity(activity); + sender + .send(EngineEvent::Response(value)) + .map_err(|_| "engine response consumer stopped".to_owned())?; } - Ok(differences) + Ok(()) +} + +fn spawn_stderr(stderr: ChildStderr) -> io::Result> { + thread::Builder::new() + .name("jlreq-engine-stderr".to_owned()) + .spawn(move || drain_stderr(stderr)) +} + +fn drain_stderr(mut stderr: ChildStderr) -> StderrCapture { + let mut retained = Vec::new(); + let mut discarded = 0_usize; + let mut buffer = [0_u8; 8192]; + loop { + match stderr.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(read) => { + let room = STDERR_RETAIN_BYTES.saturating_sub(retained.len()); + let keep = read.min(room); + retained.extend_from_slice(&buffer[..keep]); + discarded = discarded.saturating_add(read.saturating_sub(keep)); + }, + } + } + StderrCapture { + retained, + discarded, + } +} + +fn mark_activity(activity: &Mutex) { + if let Ok(mut last) = activity.lock() { + *last = Instant::now(); + } +} + +fn activity_elapsed(activity: &Mutex) -> Option { + activity.lock().ok().map(|last| last.elapsed()) +} + +fn join_worker(worker: JoinHandle<()>, name: &str) -> Result<(), String> { + worker.join().map_err(|_| format!("engine {name} panicked")) +} + +fn join_stderr(worker: JoinHandle) -> Result { + worker + .join() + .map_err(|_| "engine stderr drainer panicked".to_owned()) +} + +fn with_stderr(message: String, captured: Option<&StderrCapture>) -> String { + let Some(captured) = captured else { + return message; + }; + if captured.retained.is_empty() && captured.discarded == 0 { + return message; + } + let stderr = String::from_utf8_lossy(&captured.retained); + if captured.discarded == 0 { + format!("{message}; engine stderr: {}", stderr.trim_end()) + } else { + format!( + "{message}; engine stderr: {} [discarded {} byte(s)]", + stderr.trim_end(), + captured.discarded + ) + } +} + +fn compare_response( + response: &Value, + expected_ids: &BTreeMap, + cases: &[Case], + seen: &mut BTreeSet, + differences: &mut usize, + verbose: bool, +) -> Result<(), String> { + let object = response + .as_object() + .ok_or_else(|| "validated response stopped being an object".to_owned())?; + let id = object + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| "validated response lost its id".to_owned())?; + let Some(index) = expected_ids.get(id).copied() else { + return Err(format!("engine returned unknown response id {id:?}")); + }; + if !seen.insert(id.to_owned()) { + return Err(format!("engine returned duplicate response id {id:?}")); + } + let actual = object + .get("response") + .ok_or_else(|| "validated response lost its body".to_owned())?; + let expected = &cases[index].expected; + if actual != expected { + *differences = differences.saturating_add(1); + if verbose { + let difference = first_difference(expected, actual); + eprintln!( + "DIFF {id} path={} expected={} actual={}", + difference.path, difference.expected, difference.actual + ); + } else { + eprintln!("DIFF {id}"); + } + } + Ok(()) +} + +#[derive(Debug, PartialEq, Eq)] +struct JsonDifference { + path: String, + expected: String, + actual: String, +} + +fn first_difference(expected: &Value, actual: &Value) -> JsonDifference { + fn walk(expected: Option<&Value>, actual: Option<&Value>, path: &str) -> JsonDifference { + match (expected, actual) { + (Some(Value::Array(expected)), Some(Value::Array(actual))) => { + let length = expected.len().max(actual.len()); + for index in 0..length { + if expected.get(index) != actual.get(index) { + let next_path = bounded(&format!("{path}[{index}]"), 256); + return walk(expected.get(index), actual.get(index), &next_path); + } + } + }, + (Some(Value::Object(expected)), Some(Value::Object(actual))) => { + let keys: BTreeSet<_> = expected.keys().chain(actual.keys()).collect(); + for key in keys { + if expected.get(key) != actual.get(key) { + let rendered_key = + serde_json::to_string(key).unwrap_or_else(|_| "?".to_owned()); + let next_path = bounded(&format!("{path}[{rendered_key}]"), 256); + return walk(expected.get(key), actual.get(key), &next_path); + } + } + }, + _ => {}, + } + JsonDifference { + path: bounded(path, 256), + expected: render_json(expected), + actual: render_json(actual), + } + } + walk(Some(expected), Some(actual), "$") +} + +fn render_json(value: Option<&Value>) -> String { + value.map_or_else( + || "".to_owned(), + |value| { + bounded( + &serde_json::to_string(value).unwrap_or_else(|_| "".to_owned()), + 512, + ) + }, + ) +} + +fn bounded(value: &str, maximum: usize) -> String { + let mut output: String = value.chars().take(maximum).collect(); + if value.chars().count() > maximum { + output.push('…'); + } + output } #[cfg(test)] mod tests { - use super::{BUILTIN_SUITE, PROTOCOL_SCHEMA, parse_cases, parse_messages}; + use super::{ + BUILTIN_SUITE, CliCommand, Invocation, Options, PROTOCOL_SCHEMA, STDERR_RETAIN_BYTES, + StderrCapture, activity_elapsed, bounded, engine_work_pending, first_difference, + join_worker, mark_activity, parse_cases, parse_invocation, parse_messages, positive_u64, + positive_usize, read_limited_line, required_string, stop_unstarted_child, + validate_envelope, validate_rules, with_stderr, workers_disconnected_early, + }; use serde_json::json; + use std::{ + io::Cursor, + process::{Command, Stdio}, + sync::Mutex, + thread, + time::{Duration, Instant}, + }; #[test] fn builtin_suite_is_a_valid_versioned_case_stream() { @@ -489,4 +1135,272 @@ mod tests { }); assert!(parse_messages(&mixed_output.to_string(), false).is_err()); } + + #[test] + fn duplicate_suite_ids_are_rejected() { + let case = BUILTIN_SUITE.lines().next().expect("built-in case"); + assert!(parse_cases(&format!("{case}\n{case}\n")).is_err()); + } + + #[test] + fn cli_options_have_documented_defaults_and_accept_equals_syntax() { + let invocation = parse_invocation([ + "--verbose".to_owned(), + "--timeout-seconds=7".to_owned(), + "run".to_owned(), + "engine".to_owned(), + ]) + .expect("valid invocation"); + let Invocation::Command(options, _) = invocation else { + panic!("expected command"); + }; + assert!(options.verbose); + assert_eq!(options.timeout.as_secs(), 7); + let defaults = Options::default(); + assert!(!defaults.verbose); + assert_eq!(defaults.timeout, Duration::from_secs(30)); + assert_eq!(defaults.max_message_bytes, 1_048_576); + assert_eq!(defaults.max_suite_bytes, 268_435_456); + assert_eq!(defaults.max_cases, 200_000); + assert_eq!(STDERR_RETAIN_BYTES, 1_048_576); + } + + #[test] + fn cli_parses_every_limit_and_each_command_shape() { + let invocation = parse_invocation([ + "--max-message-bytes".to_owned(), + "11".to_owned(), + "--max-suite-bytes=22".to_owned(), + "--max-cases".to_owned(), + "33".to_owned(), + "list".to_owned(), + "suite.ndjson".to_owned(), + ]) + .expect("all limits are accepted"); + let Invocation::Command(options, CliCommand::List { suite }) = invocation else { + panic!("expected list command"); + }; + assert_eq!(options.max_message_bytes, 11); + assert_eq!(options.max_suite_bytes, 22); + assert_eq!(options.max_cases, 33); + assert_eq!(suite.as_deref(), Some("suite.ndjson")); + + let Invocation::Command(_, CliCommand::Validate { suite }) = + parse_invocation(["validate".to_owned(), "-".to_owned()]).expect("validate command") + else { + panic!("expected validate command"); + }; + assert_eq!(suite.as_deref(), Some("-")); + + let Invocation::Command(_, CliCommand::Run { engine, suite }) = parse_invocation([ + "run".to_owned(), + "engine".to_owned(), + "suite.ndjson".to_owned(), + ]) + .expect("run command") else { + panic!("expected run command"); + }; + assert_eq!(engine, "engine"); + assert_eq!(suite.as_deref(), Some("suite.ndjson")); + } + + #[test] + fn cli_rejects_zero_limits_and_wrong_positional_counts() { + for option in [ + "--timeout-seconds", + "--max-message-bytes", + "--max-suite-bytes", + "--max-cases", + ] { + assert!( + parse_invocation([option.to_owned(), "0".to_owned(), "list".to_owned()]).is_err(), + "{option}" + ); + } + assert_eq!(positive_u64("9", "option"), Ok(9)); + assert!(positive_u64("0", "option").is_err()); + assert_eq!(positive_usize("9", "option"), Ok(9)); + assert!(positive_usize("0", "option").is_err()); + + for arguments in [ + vec!["validate", "one", "two"], + vec!["list", "one", "two"], + vec!["run"], + vec!["run", "engine", "suite", "extra"], + ] { + assert!( + parse_invocation(arguments.into_iter().map(str::to_owned)).is_err(), + "invalid positional shape" + ); + } + } + + #[test] + fn line_and_total_limits_are_inclusive() { + let mut total = 0; + let mut reader = Cursor::new(b"abc\n".as_slice()); + assert_eq!( + read_limited_line(&mut reader, 3, 4, &mut total, "test"), + Ok(Some(b"abc".to_vec())) + ); + assert_eq!(total, 4); + + let mut total = 0; + let mut reader = Cursor::new(b"abc\n".as_slice()); + assert!(read_limited_line(&mut reader, 2, 4, &mut total, "test").is_err()); + + let mut total = 0; + let mut reader = Cursor::new(b"abc\n".as_slice()); + assert!(read_limited_line(&mut reader, 3, 3, &mut total, "test").is_err()); + } + + #[test] + fn suite_case_shape_and_rules_fail_at_the_envelope_boundary() { + let mut case: serde_json::Value = serde_json::from_str( + BUILTIN_SUITE + .lines() + .next() + .expect("built-in suite has a case"), + ) + .expect("built-in case JSON"); + case.as_object_mut().expect("case object").remove("request"); + assert_eq!( + validate_envelope(&case, true), + Err("expected is valid only beside a suite request".to_owned()) + ); + + let mut case: serde_json::Value = serde_json::from_str( + BUILTIN_SUITE + .lines() + .next() + .expect("built-in suite has a case"), + ) + .expect("built-in case JSON"); + case["expected"] = json!(false); + assert_eq!( + validate_envelope(&case, true), + Err("a suite case needs object-valued request and expected fields".to_owned()) + ); + + assert!(validate_rules(&json!([])).is_err()); + assert!(validate_rules(&json!(["3.1", "3.1"])).is_err()); + let object = json!({"protocol": "wrong"}); + assert!( + required_string( + object.as_object().expect("envelope object"), + "protocol", + "right" + ) + .is_err() + ); + } + + #[test] + fn watchdog_helpers_observe_activity_and_worker_failure() { + let old = Instant::now() + .checked_sub(Duration::from_secs(5)) + .expect("five seconds before now is representable"); + let activity = Mutex::new(old); + assert!( + activity_elapsed(&activity).expect("unpoisoned activity") >= Duration::from_secs(4) + ); + mark_activity(&activity); + assert!(activity_elapsed(&activity).expect("unpoisoned activity") < Duration::from_secs(1)); + + let worker = thread::spawn(|| panic!("intentional worker failure")); + assert_eq!( + join_worker(worker, "reader"), + Err("engine reader panicked".to_owned()) + ); + } + + #[test] + fn engine_completion_predicates_cover_every_partial_state() { + assert!(engine_work_pending(false, (false, false, false))); + assert!(engine_work_pending(false, (true, false, true))); + assert!(engine_work_pending(false, (false, true, true))); + assert!(engine_work_pending(false, (true, true, false))); + assert!(!engine_work_pending(false, (true, true, true))); + assert!(!engine_work_pending(true, (false, false, false))); + + assert!(workers_disconnected_early(false, false)); + assert!(workers_disconnected_early(true, false)); + assert!(workers_disconnected_early(false, true)); + assert!(!workers_disconnected_early(true, true)); + } + + #[test] + #[ignore = "helper subprocess for child-termination test"] + fn ignored_child_waits_until_killed() { + thread::sleep(Duration::from_secs(30)); + } + + #[test] + fn stopping_an_unstarted_pipeline_kills_and_waits_for_the_child() { + let mut child = Command::new(std::env::current_exe().expect("test executable path")) + .args([ + "--ignored", + "--exact", + "tests::ignored_child_waits_until_killed", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("helper child starts"); + assert!( + child.try_wait().expect("helper status").is_none(), + "helper child must still be running" + ); + + let message = stop_unstarted_child(&mut child, "pipeline did not start"); + let stopped = child.try_wait().expect("stopped child status").is_some(); + if !stopped { + let _ = child.kill(); + let _ = child.wait(); + } + assert_eq!(message, "pipeline did not start"); + assert!(stopped, "stop_child must reap the helper process"); + } + + #[test] + fn stderr_and_json_rendering_are_bounded_at_exact_edges() { + let empty = StderrCapture { + retained: Vec::new(), + discarded: 0, + }; + assert_eq!(with_stderr("failure".to_owned(), Some(&empty)), "failure"); + + let retained = StderrCapture { + retained: b"details\n".to_vec(), + discarded: 0, + }; + assert_eq!( + with_stderr("failure".to_owned(), Some(&retained)), + "failure; engine stderr: details" + ); + let discarded = StderrCapture { + retained: Vec::new(), + discarded: 7, + }; + assert_eq!( + with_stderr("failure".to_owned(), Some(&discarded)), + "failure; engine stderr: [discarded 7 byte(s)]" + ); + + assert_eq!(bounded("ab", 2), "ab"); + assert_eq!(bounded("abc", 2), "ab…"); + assert_eq!(bounded("abcd", 2), "ab…"); + } + + #[test] + fn verbose_difference_finds_a_bounded_leaf_path() { + let difference = first_difference( + &json!({"lines": [{"clusters": [1, 2, 3]}]}), + &json!({"lines": [{"clusters": [1, 9, 3]}]}), + ); + assert_eq!(difference.path, "$[\"lines\"][0][\"clusters\"][1]"); + assert_eq!(difference.expected, "2"); + assert_eq!(difference.actual, "9"); + } } diff --git a/crates/jlreq-conformance/src/sample_engine.rs b/crates/jlreq-conformance/src/sample_engine.rs index 7d6ccd0..972cb76 100644 --- a/crates/jlreq-conformance/src/sample_engine.rs +++ b/crates/jlreq-conformance/src/sample_engine.rs @@ -5,7 +5,7 @@ //! Reference implementation of the language-independent conformance protocol. use std::{ - io::{self, BufRead}, + io::{self, BufRead, Write}, process::ExitCode, }; @@ -25,6 +25,9 @@ use serde_json::{Map, Value, json}; const PROTOCOL: &str = "jlreq.conformance/1"; const SPEC: &str = jlreq::SPECIFICATION; +const MAX_MESSAGE_BYTES: usize = 1024 * 1024; +const MAX_STREAM_BYTES: usize = 256 * 1024 * 1024; +const MAX_MESSAGES: usize = 200_000; fn main() -> ExitCode { match run() { @@ -38,14 +41,42 @@ fn main() -> ExitCode { fn run() -> Result<(), String> { let stdin = io::stdin(); - for (line_index, line) in stdin.lock().lines().enumerate() { - let line = line.map_err(|error| error.to_string())?; + let stdout = io::stdout(); + let mut input = stdin.lock(); + let mut output = stdout.lock(); + run_stream(&mut input, &mut output, MAX_MESSAGES) +} + +fn run_stream( + input: &mut dyn BufRead, + output: &mut dyn Write, + max_messages: usize, +) -> Result<(), String> { + let mut total_bytes = 0_usize; + let mut line_number = 0_usize; + let mut message_count = 0_usize; + loop { + let previous_total = total_bytes; + let Some(line) = + read_limited_line(input, &mut total_bytes, MAX_MESSAGE_BYTES, MAX_STREAM_BYTES)? + else { + break; + }; + if total_bytes == previous_total { + return Err("input reader made no progress".to_owned()); + } + line_number = line_number.saturating_add(1); + let line = std::str::from_utf8(&line) + .map_err(|error| format!("line {line_number}: input is not UTF-8: {error}"))?; if line.trim().is_empty() { continue; } - let line_number = line_index.saturating_add(1); + message_count = message_count.saturating_add(1); + if message_count > max_messages { + return Err(format!("input exceeds the {max_messages} message limit")); + } let envelope: Value = - serde_json::from_str(&line).map_err(|error| format!("line {line_number}: {error}"))?; + serde_json::from_str(line).map_err(|error| format!("line {line_number}: {error}"))?; let object = object(&envelope, "message")?; exact_string(object, "protocol", PROTOCOL)?; exact_string(object, "spec", SPEC)?; @@ -54,20 +85,66 @@ fn run() -> Result<(), String> { .get("request") .ok_or_else(|| "request is required".to_owned())?; let (paragraph, style) = parse_request(request)?; - let layout = jlreq::compose(¶graph, &style); - println!( - "{}", - json!({ + let layout = jlreq::compose(¶graph, &style) + .map_err(|error| format!("{}: {}", error.code(), error))?; + serde_json::to_writer( + &mut *output, + &json!({ "protocol": PROTOCOL, "spec": SPEC, "id": id, "response": layout_json(&layout), - }) - ); + }), + ) + .map_err(|error| format!("could not encode response {id:?}: {error}"))?; + output + .write_all(b"\n") + .and_then(|()| output.flush()) + .map_err(|error| format!("could not write response {id:?}: {error}"))?; } Ok(()) } +fn read_limited_line( + reader: &mut dyn BufRead, + total_bytes: &mut usize, + max_message_bytes: usize, + max_stream_bytes: usize, +) -> Result>, String> { + let mut line = Vec::new(); + loop { + let available = reader + .fill_buf() + .map_err(|error| format!("could not read input: {error}"))?; + if available.is_empty() { + return if line.is_empty() { + Ok(None) + } else { + Ok(Some(line)) + }; + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let content = newline.unwrap_or(available.len()); + let consumed = content.saturating_add(usize::from(newline.is_some())); + *total_bytes = total_bytes.saturating_add(consumed); + if *total_bytes > max_stream_bytes { + return Err(format!( + "input exceeds the {max_stream_bytes} byte total limit" + )); + } + if line.len().saturating_add(content) > max_message_bytes { + return Err(format!( + "input message exceeds the {max_message_bytes} byte line limit" + )); + } + line.extend_from_slice(&available[..content]); + reader.consume(consumed); + if newline.is_some() { + return Ok(Some(line)); + } + } +} + fn parse_request(value: &Value) -> Result<(Paragraph, Style), String> { let request = object(value, "request")?; let text = parse_shaped_text(value)?; @@ -709,3 +786,130 @@ fn one_char(value: &str, name: &str) -> Result { } Ok(character) } + +#[cfg(test)] +mod tests { + use super::{ + KinsokuLevel, MAX_MESSAGE_BYTES, MAX_STREAM_BYTES, Style, exact_string, parse_style, + profile_style, read_limited_line, render_style_error, run_stream, + }; + use serde_json::json; + use std::io::Cursor; + + fn request(id: &str) -> String { + json!({ + "protocol": "jlreq.conformance/1", + "spec": "jlreq-2020-08-11+unicode-17.0.0", + "id": id, + "request": { + "source": "a", + "size": {"inline": 1000, "block": 1000}, + "frame": "full-em", + "clusters": [{"range": [0, 1], "advance": 500}], + "line_extent": 1000 + } + }) + .to_string() + } + + #[test] + fn stream_limits_are_inclusive_and_have_stable_defaults() { + assert_eq!(MAX_MESSAGE_BYTES, 1_048_576); + assert_eq!(MAX_STREAM_BYTES, 268_435_456); + + let mut total = 0; + let mut input = Cursor::new(b"abc\n".as_slice()); + assert_eq!( + read_limited_line(&mut input, &mut total, 3, 4), + Ok(Some(b"abc".to_vec())) + ); + assert_eq!(total, 4); + + let mut total = 0; + let mut input = Cursor::new(b"abc\n".as_slice()); + assert!(read_limited_line(&mut input, &mut total, 2, 4).is_err()); + + let mut total = 0; + let mut input = Cursor::new(b"abc\n".as_slice()); + assert!(read_limited_line(&mut input, &mut total, 3, 3).is_err()); + } + + #[test] + fn stream_message_count_is_checked_after_the_exact_limit() { + let input = format!("{}\n{}\n", request("one"), request("two")); + let mut input = Cursor::new(input.into_bytes()); + let mut output = Vec::new(); + assert_eq!( + run_stream(&mut input, &mut output, 1), + Err("input exceeds the 1 message limit".to_owned()) + ); + let output = String::from_utf8(output).expect("engine output is UTF-8"); + assert_eq!(output.lines().count(), 1); + } + + #[test] + fn profile_and_error_rendering_preserve_semantics() { + assert_eq!(profile_style("jlreq-2020"), Ok(Style::jlreq_2020())); + assert_eq!(profile_style("book-2020"), Ok(Style::book_2020())); + assert_eq!(profile_style("magazine-2020"), Ok(Style::magazine_2020())); + assert_eq!(profile_style("newspaper-2020"), Ok(Style::newspaper_2020())); + assert_eq!( + profile_style("jis-reading-2020"), + Ok(Style::jis_reading_2020()) + ); + assert!(profile_style("unknown").is_err()); + + let error = Style::builder() + .kinsoku_level(KinsokuLevel::VeryStrict) + .build() + .expect_err("the default breakable numeral conflicts with very-strict"); + assert_eq!( + render_style_error(error), + "style.very-strict-grouped-numeral: very-strict kinsoku excludes a breakable grouped-numeral boundary" + ); + } + + #[test] + fn every_style_setting_round_trips_through_the_protocol_parser() { + let style = json!({ + "profile": "jlreq-2020", + "kinsoku.level": "strict", + "adjustment.reduction_table": "table-3", + "spacing.line_end_punctuation": "half-em", + "spacing.line_end_full_stop_comma": "preferred", + "spacing.line_head_opening_bracket": "pattern-1", + "ruby.overhang_kana": "kana", + "ruby.overhang_indent": "permitted", + "ruby.alignment": "nakatsuki", + "ruby.group_distribution": "jis", + "ruby.jukugo_layout": "group", + "kinsoku.iteration_mark_at_line_head": "prohibited", + "adjustment.hanging_punctuation": "none", + "kinsoku.grouped_numeral_before_western": "breakable", + "spacing.sentence_medial_dividing_mark": "solid", + "adjustment.japanese_latin_expansion_ceiling": "half-em", + "adjustment.expansion_order": "jis", + "adjustment.preference": "least-adjustment", + "adjustment.remainder": "leading", + "classification.unlisted_code_point": "by-frame", + "classification.ambiguous_context": "lowest-class", + "classification.grouped_numeral_qualification": "by-width", + "kinsoku.relaxation_mechanism": "reclassify" + }); + + assert_eq!(parse_style(&style), Ok(Style::jlreq_2020())); + } + + #[test] + fn exact_envelope_strings_reject_near_misses() { + let envelope = json!({"protocol": "other"}); + assert!( + exact_string( + envelope.as_object().expect("envelope object"), + "protocol", + "jlreq.conformance/1" + ) + .is_err() + ); + } +} diff --git a/crates/jlreq-conformance/src/validation.rs b/crates/jlreq-conformance/src/validation.rs index d70f59f..5cc2756 100644 --- a/crates/jlreq-conformance/src/validation.rs +++ b/crates/jlreq-conformance/src/validation.rs @@ -619,3 +619,224 @@ fn one_char(value: Option<&Value>, name: &str) -> Result { Ok(character) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn valid_request(source: &str, clusters: &Value) -> Value { + json!({ + "source": source, + "size": {"inline": 1000, "block": 1000}, + "frame": "full-em", + "clusters": clusters, + "line_extent": 2000 + }) + } + + fn valid_placement() -> Value { + json!({ + "origin": {"cluster": 0}, + "range": [0, 1], + "inline": 0, + "block": 0, + "advance": 500, + "size": {"inline": 1000, "block": 1000}, + "frame": "full-em", + "writing_mode": "horizontal-tb", + "transform": "identity" + }) + } + + fn valid_line() -> Value { + json!({ + "range": [0, 1], + "inline_origin": 0, + "block_origin": 0, + "inline_extent": 500, + "block_extent": 1000, + "clusters": [valid_placement()], + "attachments": [] + }) + } + + #[test] + fn scalar_helpers_return_the_validated_value() { + assert_eq!(frame(Some(&json!("half-em"))), Ok("half-em")); + assert_eq!(writing_mode(Some(&json!("vertical-rl"))), Ok("vertical-rl")); + assert_eq!( + transform(Some(&json!("rotate-clockwise"))), + Ok("rotate-clockwise") + ); + assert_eq!(non_empty_string(Some(&json!("code")), "name"), Ok("code")); + assert_eq!(i32_value(Some(&json!(-17)), "number"), Ok(-17)); + assert_eq!(non_negative_i32(Some(&json!(0)), "number"), Ok(0)); + assert_eq!(non_negative_i32(Some(&json!(17)), "number"), Ok(17)); + assert_eq!(positive_i32(Some(&json!(17)), "number"), Ok(17)); + assert_eq!(positive_u16(Some(&json!(17)), "number"), Ok(17)); + assert_eq!(offset(Some(&json!(17)), "offset"), Ok(17)); + assert_eq!(one_char(Some(&json!("字")), "character"), Ok('字')); + } + + #[test] + fn scalar_helpers_reject_each_boundary_class() { + assert!(frame(Some(&json!("other"))).is_err()); + assert!(writing_mode(Some(&json!("sideways"))).is_err()); + assert!(transform(Some(&json!("mirror"))).is_err()); + assert!(non_empty_string(Some(&json!("")), "name").is_err()); + assert!(i32_value(Some(&json!(2_147_483_648_i64)), "number").is_err()); + assert!(non_negative_i32(Some(&json!(-1)), "number").is_err()); + assert!(positive_i32(Some(&json!(0)), "number").is_err()); + assert!(positive_i32(Some(&json!(-1)), "number").is_err()); + assert!(positive_u16(Some(&json!(0)), "number").is_err()); + assert!(positive_u16(Some(&json!(65_536)), "number").is_err()); + assert!(one_char(Some(&json!("")), "character").is_err()); + assert!(one_char(Some(&json!("ab")), "character").is_err()); + } + + #[test] + fn request_breaks_must_be_internal_utf8_boundaries() { + let ascii_clusters = json!([{"range": [0, 1], "advance": 500}]); + for offset in [0, 1] { + let mut request = valid_request("a", &ascii_clusters); + request["breaks"] = json!([{"offset": offset, "kind": "allowed"}]); + assert!(validate_request(&request).is_err(), "offset {offset}"); + } + + let mut request = valid_request("é", &json!([{"range": [0, 2], "advance": 500}])); + request["breaks"] = json!([{"offset": 1, "kind": "allowed"}]); + assert!(validate_request(&request).is_err()); + } + + #[test] + fn shaped_text_requires_exact_ordered_utf8_coverage() { + let mut request = valid_request( + "ab", + &json!([ + {"range": [0, 1], "advance": 500}, + {"range": [0, 2], "advance": 500} + ]), + ); + assert!(validate_request(&request).is_err(), "non-contiguous start"); + + request["clusters"] = json!([{"range": [0, 3], "advance": 500}]); + assert!(validate_request(&request).is_err(), "end beyond source"); + + request = valid_request("é", &json!([{"range": [0, 1], "advance": 500}])); + assert!(validate_request(&request).is_err(), "end inside a scalar"); + + request = valid_request( + "é", + &json!([ + {"range": [0, 1], "advance": 250}, + {"range": [1, 2], "advance": 250} + ]), + ); + assert!( + validate_request(&request).is_err(), + "two adjacent ranges cannot hide a shared non-boundary" + ); + + request = valid_request("ab", &json!([{"range": [0, 1], "advance": 500}])); + assert!(validate_request(&request).is_err(), "incomplete coverage"); + } + + #[test] + fn nested_request_validators_reject_invalid_values_directly() { + let annotation = json!({ + "source": "a", + "size": {"inline": 1000, "block": 1000}, + "frame": "full-em", + "clusters": [{"range": [0, 1], "advance": 500}], + "extra": true + }); + assert!(validate_annotation(&annotation, "annotation").is_err()); + + assert!(validate_construct(&json!({"kind": "unknown", "range": [0, 1]}), "a").is_err()); + + let source = "éa"; + let invalid_start = json!({"range": [1, 2]}); + assert!( + validate_source_range( + invalid_start.as_object().expect("range object"), + source, + "range" + ) + .is_err() + ); + let invalid_end = json!({"range": [0, 1]}); + assert!( + validate_source_range( + invalid_end.as_object().expect("range object"), + source, + "range" + ) + .is_err() + ); + + assert!(validate_tab_stop(&json!({"position": 0, "alignment": "character"})).is_err()); + assert!(validate_size(&json!({"inline": 0, "block": 1000})).is_err()); + } + + #[test] + fn response_validators_reject_invalid_values_directly() { + assert!(validate_line(&json!({"clusters": [], "attachments": []})).is_err()); + + let mut placement = valid_placement(); + placement["origin"] = json!({"cluster": 0, "construct": 0}); + assert!(validate_placement(&placement).is_err()); + placement["origin"] = json!({"other": 0}); + assert!(validate_placement(&placement).is_err()); + + let attachment = json!({ + "construct": 0, + "range": [0, 1], + "inline": 0, + "block": 0, + "advance": 0, + "size": {"inline": 1000, "block": 1000}, + "writing_mode": "horizontal-tb", + "transform": "identity", + "symbol": "・" + }); + assert!(validate_attachment(&attachment).is_err()); + + let diagnostic = json!({ + "code": "", + "severity": "warning", + "range": null, + "jlreq": "3.1" + }); + assert!(validate_diagnostic(&diagnostic).is_err()); + } + + #[test] + fn diagnostic_accepts_both_nullable_and_concrete_ranges() { + let mut diagnostic = json!({ + "code": "layout.test", + "severity": "info", + "range": null, + "jlreq": "3.1" + }); + assert!(validate_diagnostic(&diagnostic).is_ok()); + diagnostic["range"] = json!([0, 1]); + assert!(validate_diagnostic(&diagnostic).is_ok()); + diagnostic["range"] = json!([0, 0]); + assert!(validate_diagnostic(&diagnostic).is_err()); + } + + #[test] + fn field_sets_and_complete_response_are_closed() { + let value = json!({"known": true, "unknown": false}); + assert!(only_fields(value.as_object().expect("field object"), &["known"], "test").is_err()); + + assert!( + validate_response(&json!({ + "lines": [valid_line()], + "diagnostics": [] + })) + .is_ok() + ); + } +} diff --git a/crates/jlreq-conformance/tests/builtin_protocol.rs b/crates/jlreq-conformance/tests/builtin_protocol.rs index 4b7d231..bee0338 100644 --- a/crates/jlreq-conformance/tests/builtin_protocol.rs +++ b/crates/jlreq-conformance/tests/builtin_protocol.rs @@ -61,6 +61,26 @@ fn sample_engine_reports_builder_input_errors_with_exit_two() { ); } +#[test] +fn sample_engine_rejects_an_oversize_input_line() { + let mut child = Command::new(env!("CARGO_BIN_EXE_jlreq-sample-engine")) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the sample engine starts"); + let mut input = child.stdin.take().expect("engine stdin is piped"); + let oversized = vec![b' '; 1024 * 1024 + 1]; + let _ = input.write_all(&oversized); + drop(input); + let output = child.wait_with_output().expect("engine exits"); + + assert_eq!(output.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("input message exceeds the 1048576 byte line limit") + ); +} + #[test] fn runner_returns_one_when_an_engine_differs() { let mut case: serde_json::Value = serde_json::from_str( @@ -111,3 +131,65 @@ fn validator_returns_two_for_a_protocol_error() { "stderr reports the protocol error" ); } + +#[test] +fn cli_help_version_and_stdin_contract_are_executable() { + let help = Command::new(env!("CARGO_BIN_EXE_jlreq-conformance")) + .arg("--help") + .output() + .expect("help command starts"); + assert_eq!(help.status.code(), Some(0)); + let help = String::from_utf8_lossy(&help.stdout); + for option in [ + "--verbose", + "--timeout-seconds", + "--max-message-bytes", + "--max-suite-bytes", + "--max-cases", + ] { + assert!(help.contains(option), "help documents {option}"); + } + + let version = Command::new(env!("CARGO_BIN_EXE_jlreq-conformance")) + .arg("--version") + .output() + .expect("version command starts"); + assert_eq!(version.status.code(), Some(0)); + assert_eq!( + String::from_utf8_lossy(&version.stdout).trim(), + "jlreq-conformance 0.1.0" + ); + + let mut request: serde_json::Value = serde_json::from_str( + include_str!("../suite.ndjson") + .lines() + .next() + .expect("built-in suite has a case"), + ) + .expect("built-in case JSON"); + let object = request.as_object_mut().expect("case object"); + object.remove("rules"); + object.remove("expected"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_jlreq-conformance")) + .arg("validate") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("stdin validator starts"); + child + .stdin + .take() + .expect("validator stdin") + .write_all(format!("{request}\n").as_bytes()) + .expect("wire request is written"); + let output = child.wait_with_output().expect("stdin validator exits"); + assert_eq!( + output.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("validated 1 message(s)")); +} diff --git a/crates/jlreq-conformance/tests/reference_integration.rs b/crates/jlreq-conformance/tests/reference_integration.rs index b1a7b8b..5a4e652 100644 --- a/crates/jlreq-conformance/tests/reference_integration.rs +++ b/crates/jlreq-conformance/tests/reference_integration.rs @@ -84,7 +84,10 @@ fn icu4x_byte_offsets_feed_breaks_without_conversion() { .build() .expect("ICU4X offsets are accepted verbatim"); assert_eq!( - jlreq::compose(¶graph, &Style::default()).lines().len(), + jlreq::compose(¶graph, &Style::default()) + .expect("composition succeeds") + .lines() + .len(), 3 ); } diff --git a/crates/jlreq-conformance/tests/transport.rs b/crates/jlreq-conformance/tests/transport.rs new file mode 100644 index 0000000..f6f3fe9 --- /dev/null +++ b/crates/jlreq-conformance/tests/transport.rs @@ -0,0 +1,274 @@ +// SPDX-FileCopyrightText: 2026 jlreq contributors +// +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Cross-platform subprocess transport regressions. + +#![allow( + clippy::arithmetic_side_effects, + clippy::expect_used, + clippy::panic, + clippy::unwrap_used +)] + +use std::{ + env, fs, + io::{self, BufRead, BufReader, BufWriter, Write}, + path::PathBuf, + process::{Command, Output}, + time::{Duration, Instant}, +}; + +use serde_json::{Value, json}; + +const MODE_ENV: &str = "JLREQ_SYNTHETIC_ENGINE_MODE"; +const COUNT_ENV: &str = "JLREQ_SYNTHETIC_ENGINE_COUNT"; +const PROTOCOL: &str = "jlreq.conformance/1"; +const SPEC: &str = "jlreq-2020-08-11+unicode-17.0.0"; + +fn main() { + if let Ok(mode) = env::var(MODE_ENV) { + synthetic_engine(&mode); + return; + } + + transport_regressions(); +} + +fn transport_regressions() { + let pair = suite(2, None); + assert_success("unordered", &pair, &[]); + assert_success("normal-limit", &suite(1, None), &[]); + + let large_id = "i".repeat(70 * 1024); + assert_success("normal", &suite(1, Some(&large_id)), &[]); + + let many = suite(2_000, None); + assert_success("write-before-read", &many, &[(COUNT_ENV, "2000")]); + assert_success("stderr-flood", &pair, &[]); + + assert_protocol_error("duplicate", &pair, "duplicate response id", &[]); + assert_protocol_error("unknown", &pair, "unknown response id", &[]); + assert_protocol_error("missing", &pair, "omitted 1 response(s): case-000001", &[]); + assert_protocol_error( + "extra-response", + &suite(1, None), + "engine stdout exceeds the 1 response limit", + &[], + ); + assert_protocol_error( + "huge-line", + &pair, + "engine stdout message exceeds the 1048576 byte line limit", + &[], + ); + assert_protocol_error("midway-stop", &many, "engine", &[]); + + let started = Instant::now(); + assert_protocol_error( + "stall", + &pair, + "made no stdin/stdout progress for 1 second", + &[], + ); + assert!( + started.elapsed() < Duration::from_secs(4), + "the watchdog must kill and join the stalled child promptly" + ); + + let flood = run("stderr-flood-fail", &pair, &[]); + assert_eq!(flood.status.code(), Some(2), "{}", stderr(&flood)); + assert!(stderr(&flood).contains("[discarded ")); + assert!( + flood.stderr.len() <= 1024 * 1024 + 4_096, + "stderr retention must remain bounded" + ); +} + +fn assert_success(mode: &str, contents: &str, extra_env: &[(&str, &str)]) { + let output = run(mode, contents, extra_env); + assert_eq!(output.status.code(), Some(0), "{}", stderr(&output)); +} + +fn assert_protocol_error(mode: &str, contents: &str, expected: &str, extra_env: &[(&str, &str)]) { + let output = run(mode, contents, extra_env); + assert_eq!(output.status.code(), Some(2), "{}", stderr(&output)); + assert!( + stderr(&output).contains(expected), + "expected {expected:?} in {:?}", + stderr(&output) + ); +} + +fn run(mode: &str, contents: &str, extra_env: &[(&str, &str)]) -> Output { + let path = temporary_suite(mode); + fs::write(&path, contents).expect("synthetic suite is written"); + let executable = env::current_exe().expect("transport test executable has a path"); + let mut command = Command::new(env!("CARGO_BIN_EXE_jlreq-conformance")); + command.args(["--timeout-seconds", "1"]).env(MODE_ENV, mode); + if matches!(mode, "extra-response" | "normal-limit") { + command.args(["--max-cases", "1"]); + } + command.args([ + "run", + executable.to_str().expect("test executable path is UTF-8"), + path.to_str().expect("suite path is UTF-8"), + ]); + for (name, value) in extra_env { + command.env(name, value); + } + let output = command.output().expect("conformance runner starts"); + fs::remove_file(path).expect("temporary suite is removed"); + output +} + +fn temporary_suite(label: &str) -> PathBuf { + env::temp_dir().join(format!( + "jlreq-transport-{label}-{}.ndjson", + std::process::id() + )) +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn suite(count: usize, first_id: Option<&str>) -> String { + let mut suite = String::new(); + for index in 0..count { + let id = first_id + .filter(|_| index == 0) + .map_or_else(|| format!("case-{index:06}"), str::to_owned); + let case = json!({ + "protocol": PROTOCOL, + "spec": SPEC, + "id": id, + "rules": ["synthetic-transport"], + "request": { + "source": "A", + "size": {"inline": 1000, "block": 1000}, + "frame": "proportional", + "clusters": [{"range": [0, 1], "advance": 500}], + "line_extent": 1000 + }, + "expected": empty_layout() + }); + suite.push_str(&case.to_string()); + suite.push('\n'); + } + suite +} + +fn synthetic_engine(mode: &str) { + match mode { + "write-before-read" => { + let count = env::var(COUNT_ENV) + .expect("write-first count is provided") + .parse::() + .expect("write-first count is numeric"); + let stdout = io::stdout(); + let mut output = BufWriter::new(stdout.lock()); + for index in 0..count { + write_response(&mut output, &format!("case-{index:06}")); + } + output.flush().expect("responses are flushed"); + io::copy(&mut io::stdin().lock(), &mut io::sink()).expect("requests are drained"); + }, + "stall" => std::thread::sleep(Duration::from_secs(30)), + "huge-line" => { + let mut input = BufReader::new(io::stdin().lock()); + let mut ignored = String::new(); + input.read_line(&mut ignored).expect("one request is read"); + io::stdout() + .lock() + .write_all(&vec![b'x'; 1024 * 1024 + 1]) + .expect("oversize output is written"); + }, + "stderr-flood" | "stderr-flood-fail" => { + io::stderr() + .lock() + .write_all(&vec![b'x'; 2 * 1024 * 1024]) + .expect("stderr flood is written"); + if mode == "stderr-flood-fail" { + std::process::exit(9); + } + respond_to_input(false, false, false); + }, + "midway-stop" => { + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .expect("one request is read"); + std::process::exit(9); + }, + "unordered" => respond_to_input(true, false, false), + "duplicate" => respond_to_input(false, true, false), + "missing" => respond_to_input(false, false, true), + "unknown" => { + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .expect("one request is read"); + write_response(&mut io::stdout().lock(), "unknown-case"); + }, + "extra-response" => { + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .expect("one request is read"); + let mut output = io::stdout().lock(); + write_response(&mut output, "case-000000"); + write_response(&mut output, "unknown-case"); + }, + "normal" | "normal-limit" => respond_to_input(false, false, false), + other => panic!("unknown synthetic engine mode {other:?}"), + } +} + +fn respond_to_input(reverse: bool, duplicate_first: bool, omit_last: bool) { + let input = BufReader::new(io::stdin().lock()); + let mut ids = input + .lines() + .map(|line| { + let value: Value = serde_json::from_str(&line.expect("request is read")) + .expect("request is valid JSON"); + value["id"].as_str().expect("request has id").to_owned() + }) + .collect::>(); + if reverse { + ids.reverse(); + } + if omit_last { + ids.pop(); + } + let stdout = io::stdout(); + let mut output = stdout.lock(); + for id in &ids { + write_response(&mut output, id); + } + if duplicate_first { + write_response(&mut output, ids.first().expect("suite is non-empty")); + } +} + +fn write_response(output: &mut dyn Write, id: &str) { + serde_json::to_writer( + &mut *output, + &json!({ + "protocol": PROTOCOL, + "spec": SPEC, + "id": id, + "response": empty_layout() + }), + ) + .expect("response is encoded"); + output.write_all(b"\n").expect("response is written"); + output.flush().expect("response is flushed"); +} + +fn empty_layout() -> Value { + json!({"lines": [], "diagnostics": []}) +} diff --git a/crates/jlreq/Cargo.toml b/crates/jlreq/Cargo.toml index 3b184db..474db16 100644 --- a/crates/jlreq/Cargo.toml +++ b/crates/jlreq/Cargo.toml @@ -4,7 +4,6 @@ [package] name = "jlreq" -publish = false description = "A no_std Japanese line-composition engine for pre-shaped text" keywords = ["japanese", "typesetting", "jlreq", "text-layout", "no-std"] categories = ["text-processing", "internationalization", "no-std"] @@ -17,6 +16,15 @@ license.workspace = true authors.workspace = true repository.workspace = true homepage.workspace = true +include = [ + "src/**", + "tests/**", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-MIT", + "LICENSE-APACHE", +] [lints] workspace = true diff --git a/crates/jlreq/LICENSE-APACHE b/crates/jlreq/LICENSE-APACHE new file mode 100644 index 0000000..137069b --- /dev/null +++ b/crates/jlreq/LICENSE-APACHE @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/crates/jlreq/LICENSE-MIT b/crates/jlreq/LICENSE-MIT new file mode 100644 index 0000000..d817195 --- /dev/null +++ b/crates/jlreq/LICENSE-MIT @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/jlreq/README.md b/crates/jlreq/README.md index 35b579c..c536724 100644 --- a/crates/jlreq/README.md +++ b/crates/jlreq/README.md @@ -6,10 +6,10 @@ SPDX-License-Identifier: MIT OR Apache-2.0 # jlreq -`jlreq` is a dependency-free `no_std + alloc` Japanese line-composition engine for +`jlreq` 0.1.0 is a dependency-free `no_std + alloc` Japanese line-composition engine for already-shaped text. Callers provide UTF-8 byte ranges, cluster advances, and line-break -opportunities; jlreq returns logical placements and diagnostics without loading fonts, -shaping, running bidi, or drawing. +opportunities; jlreq returns integer logical placements without loading fonts, shaping, +running bidi, rendering, or discovering UAX #14 breaks. ```rust use jlreq::{Break, Cluster, Frame, Paragraph, ShapedText, Size, Style}; @@ -22,17 +22,22 @@ let text = ShapedText::new(source, Size::square(1_000)?, Frame::FullEm, clusters let paragraph = Paragraph::builder(text, 4_000) .breaks(source.char_indices().skip(1).map(|(at, _)| Break::allowed(at))) .build()?; -let layout = jlreq::compose(¶graph, &Style::book_2020()); +let layout = jlreq::compose(¶graph, &Style::book_2020()) + .expect("this small paragraph is within the default resource limits"); -for line in layout.lines() { - for placement in line.clusters() { - draw(placement); - } -} +assert_eq!(layout.lines().len(), 2); # Ok::<(), jlreq::InputError>(()) ``` -See the [repository guide](https://github.com/P4suta/jlreq) for the unreleased development -status, scope, shaping and segmentation integrations, the language-independent conformance -protocol, and development policy. Generate API documentation locally with -`cargo doc -p jlreq --open`. +Composition returns either a complete exact `Layout` or a typed `ComposeError`; it never +returns a partial layout or silently changes search strategy. `CompositionLimits` bounds +clusters, break candidates, constructs, tab stops, and exact-search transitions. A +`Composer` retains scratch allocation across calls and remains reusable after an error. + +The packaged, executable examples cover [minimal composition](examples/minimal.rs), +[Composer reuse and a resource refusal](examples/composer.rs), and +[vertical placement](examples/vertical.rs). The repository's +[ICU4X + HarfRust integration test](https://github.com/P4suta/jlreq/blob/main/crates/jlreq-conformance/tests/reference_integration.rs) +shows the intended segmentation/shaping boundary. See the +[repository guide](https://github.com/P4suta/jlreq) for scope and protocol details, or run +`cargo doc -p jlreq --open` for the API reference. diff --git a/crates/jlreq/examples/composer.rs b/crates/jlreq/examples/composer.rs new file mode 100644 index 0000000..7fcc476 --- /dev/null +++ b/crates/jlreq/examples/composer.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 jlreq contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Reuse one composer, handle a typed resource error, and continue composing afterward. + +use jlreq::{ + Cluster, Composer, CompositionLimits, CompositionResource, Frame, Paragraph, ShapedText, Size, + Style, +}; + +fn paragraph(source: &str) -> Result { + let clusters = source.char_indices().map(|(start, character)| { + Cluster::new(start..start.saturating_add(character.len_utf8()), 1_000) + }); + let text = ShapedText::new(source, Size::square(1_000)?, Frame::FullEm, clusters)?; + Paragraph::builder(text, 4_000).build() +} + +fn main() -> Result<(), Box> { + let first = paragraph("日本")?; + let second = paragraph("組版")?; + let mut composer = Composer::new(); + let first_layout = composer.compose(&first, &Style::jlreq_2020())?; + let second_layout = composer.compose(&second, &Style::book_2020())?; + assert_eq!(first_layout.lines().len(), 1); + assert_eq!(second_layout.lines().len(), 1); + + composer.set_limits(CompositionLimits::default().with_max_clusters(1)); + let Err(error) = composer.compose(&first, &Style::jlreq_2020()) else { + return Err("the two-cluster paragraph unexpectedly fit its configured limit".into()); + }; + assert_eq!(error.code(), "compose.cluster-limit"); + assert_eq!(error.resource(), CompositionResource::Clusters); + assert_eq!((error.limit(), error.observed()), (1, 2)); + + composer.set_limits(CompositionLimits::default()); + assert!(composer.compose(&first, &Style::jlreq_2020()).is_ok()); + Ok(()) +} diff --git a/crates/jlreq/examples/minimal.rs b/crates/jlreq/examples/minimal.rs new file mode 100644 index 0000000..be22875 --- /dev/null +++ b/crates/jlreq/examples/minimal.rs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 jlreq contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Minimal horizontal composition with explicit legal break positions. + +use jlreq::{Break, Cluster, Frame, Paragraph, ShapedText, Size, Style}; + +fn main() -> Result<(), Box> { + let source = "日本語組版"; + let clusters = source.char_indices().map(|(start, character)| { + Cluster::new(start..start.saturating_add(character.len_utf8()), 1_000) + }); + let text = ShapedText::new(source, Size::square(1_000)?, Frame::FullEm, clusters)?; + let paragraph = Paragraph::builder(text, 4_000) + .breaks( + source + .char_indices() + .skip(1) + .map(|(offset, _)| Break::allowed(offset)), + ) + .build()?; + let layout = jlreq::compose(¶graph, &Style::book_2020())?; + + assert_eq!(layout.lines().len(), 2); + Ok(()) +} diff --git a/crates/jlreq/examples/vertical.rs b/crates/jlreq/examples/vertical.rs new file mode 100644 index 0000000..74ea594 --- /dev/null +++ b/crates/jlreq/examples/vertical.rs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 jlreq contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Compose a vertical line and inspect the Latin cluster's coordinate transform. + +use jlreq::{Cluster, CoordinateTransform, Frame, Paragraph, ShapedText, Size, Style, WritingMode}; + +fn main() -> Result<(), Box> { + let source = "縦A"; + let clusters = source.char_indices().map(|(start, character)| { + Cluster::new(start..start.saturating_add(character.len_utf8()), 1_000) + }); + let text = ShapedText::new(source, Size::square(1_000)?, Frame::FullEm, clusters)?; + let paragraph = Paragraph::builder(text, 4_000) + .writing_mode(WritingMode::VerticalRl) + .build()?; + let layout = jlreq::compose(¶graph, &Style::jlreq_2020())?; + + assert_eq!( + layout.lines()[0].clusters()[1].transform(), + CoordinateTransform::RotateClockwise + ); + Ok(()) +} diff --git a/crates/jlreq/src/generated.rs b/crates/jlreq/src/generated.rs index dcf3f91..18735f8 100644 --- a/crates/jlreq/src/generated.rs +++ b/crates/jlreq/src/generated.rs @@ -59,16 +59,11 @@ const fn ascends(before: &appendix_a::Listing, after: &appendix_a::Listing) -> b before.class < after.class } -const fn distinct_keys() -> usize { +const fn distinct_keys(listings: &[appendix_a::Listing]) -> usize { let mut distinct = 0_usize; let mut index = 0_usize; - while index < appendix_a::LISTINGS.len() { - if index == 0 - || !same_key( - &appendix_a::LISTINGS[index.saturating_sub(1)], - &appendix_a::LISTINGS[index], - ) - { + while index < listings.len() { + if index == 0 || !same_key(&listings[index.saturating_sub(1)], &listings[index]) { distinct = distinct.saturating_add(1); } index = index.saturating_add(1); @@ -76,18 +71,13 @@ const fn distinct_keys() -> usize { distinct } -const fn multi_class_keys() -> usize { +const fn multi_class_keys(listings: &[appendix_a::Listing]) -> usize { let mut shared = 0_usize; let mut index = 0_usize; - while index < appendix_a::LISTINGS.len() { - let begins = index == 0 - || !same_key( - &appendix_a::LISTINGS[index.saturating_sub(1)], - &appendix_a::LISTINGS[index], - ); + while index < listings.len() { + let begins = index == 0 || !same_key(&listings[index.saturating_sub(1)], &listings[index]); let next = index.saturating_add(1); - let continues = next < appendix_a::LISTINGS.len() - && same_key(&appendix_a::LISTINGS[index], &appendix_a::LISTINGS[next]); + let continues = next < listings.len() && same_key(&listings[index], &listings[next]); if begins && continues { shared = shared.saturating_add(1); } @@ -96,68 +86,65 @@ const fn multi_class_keys() -> usize { shared } -const fn covered_ideographs() -> u32 { +const fn covered_ideographs(ranges: &[ideograph::Range]) -> u32 { let mut total = 0_u32; let mut index = 0_usize; - while index < ideograph::RANGES.len() { - let range = &ideograph::RANGES[index]; + while index < ranges.len() { + let range = &ranges[index]; total = total.saturating_add(range.last.saturating_sub(range.first).saturating_add(1)); index = index.saturating_add(1); } total } -const _: () = assert!(appendix_a::MAX_KEY_LEN == 2); -const _: () = assert!(appendix_a::LISTINGS.len() == LISTING_COUNT); -const _: () = assert!(distinct_keys() == DISTINCT_KEY_COUNT); -const _: () = assert!(multi_class_keys() == MULTI_CLASS_KEY_COUNT); -const _: () = assert!(appendix_a::REMARKS.len() == REMARK_COUNT); -const _: () = assert!(appendix_a::FRAMES_UNSTATED == 0); -const _: () = assert!(appendix_a::USAGE_UNQUALIFIED == 0); -const _: () = assert!(appendix_a::USAGE_HORIZONTAL_ONLY == 1); -const _: () = assert!(appendix_a::USAGE_VERTICAL_ONLY == 2); -const _: () = assert!(appendix_a::ROLE_UNSTATED == 0); -const _: () = assert!(appendix_a::ROLE_DECIMAL_POINT == 1); -const _: () = assert!(appendix_a::ROLE_DIGIT_GROUP_SEPARATOR == 2); - -const _: () = { +const fn listings_valid(listings: &[appendix_a::Listing]) -> bool { let mut index = 0_usize; - while index < appendix_a::LISTINGS.len() { - let listing = &appendix_a::LISTINGS[index]; - assert!(listing.class >= 1 && listing.class <= CLASS_COUNT); - assert!(listing.remark < 14); - assert!(listing.key_len >= 1 && listing.key_len <= 2); - assert!(listing.key[0] != 0); - assert!((listing.key_len == 1) == (listing.key[1] == 0)); - if index > 0 { - assert!(ascends( - &appendix_a::LISTINGS[index.saturating_sub(1)], - listing - )); + while index < listings.len() { + let listing = &listings[index]; + if listing.class < 1 || listing.class > CLASS_COUNT { + return false; + } + if listing.remark as usize >= REMARK_COUNT { + return false; + } + if listing.key_len < 1 || listing.key_len as usize > appendix_a::MAX_KEY_LEN { + return false; + } + if listing.key[0] == 0 { + return false; + } + if (listing.key_len == 1) != (listing.key[1] == 0) { + return false; + } + if index > 0 && !ascends(&listings[index.saturating_sub(1)], listing) { + return false; } index = index.saturating_add(1); } -}; + true +} -const _: () = assert!(table3::CELLS.len() == TABLE_WITH_LINE_EDGE_COUNT); -const _: () = assert!(table4::CELLS.len() == TABLE_WITH_LINE_EDGE_COUNT); -const _: () = assert!(table5::CELLS.len() == TABLE_WITH_LINE_EDGE_COUNT); -const _: () = assert!(table6::CELLS.len() == TABLE_WITHOUT_LINE_EDGE_COUNT); -const _: () = { - let tables = [table3::CELLS, table4::CELLS, table5::CELLS, table6::CELLS]; +const fn ranged_cells_valid(tables: &[&[crate::spec::RawRangedCell]]) -> bool { let mut table = 0_usize; while table < tables.len() { let cells = tables[table]; let mut index = 0_usize; while index < cells.len() { let cell = &cells[index]; - assert!(cell.before <= CLASS_COUNT && cell.after <= CLASS_COUNT); - assert!(cell.before != 17 && cell.before != 18); - assert!(cell.after != 17 && cell.after != 18); + if cell.before > CLASS_COUNT || cell.after > CLASS_COUNT { + return false; + } + if cell.before == 17 || cell.before == 18 || cell.after == 17 || cell.after == 18 { + return false; + } if let Some(limit) = cell.limit { - assert!(limit >= 0 && limit <= 720); + if limit < 0 || limit > 720 { + return false; + } + } + if cell.stage > 6 { + return false; } - assert!(cell.stage <= 6); let _ = cell.two_valued; let _ = cell.residual; let _ = cell.rule; @@ -165,33 +152,45 @@ const _: () = { } table = table.saturating_add(1); } -}; + true +} -const _: () = assert!(table2::CELLS.len() == TABLE_WITHOUT_LINE_EDGE_COUNT); -const _: () = { +const fn break_cells_valid(cells: &[crate::spec::RawBreakCell]) -> bool { let mut index = 0_usize; - while index < table2::CELLS.len() { - let cell = &table2::CELLS[index]; - assert!(cell.before >= 1 && cell.before <= CLASS_COUNT); - assert!(cell.after >= 1 && cell.after <= CLASS_COUNT); - assert!(cell.before != 17 && cell.before != 18); - assert!(cell.after != 17 && cell.after != 18); - assert!(cell.levels <= 0b1111); + while index < cells.len() { + let cell = &cells[index]; + if cell.before < 1 || cell.before > CLASS_COUNT { + return false; + } + if cell.after < 1 || cell.after > CLASS_COUNT { + return false; + } + if cell.before == 17 || cell.before == 18 || cell.after == 17 || cell.after == 18 { + return false; + } + if cell.levels > 0b1111 { + return false; + } let _ = cell.prohibited; let _ = cell.rule; index = index.saturating_add(1); } -}; + true +} -const _: () = assert!(table1::CELLS.len() == TABLE_WITH_LINE_EDGE_COUNT); -const _: () = { +const fn spacing_cells_valid(cells: &[crate::spec::RawSpacingCell]) -> bool { let mut index = 0_usize; - while index < table1::CELLS.len() { - let cell = &table1::CELLS[index]; - assert!(cell.before <= CLASS_COUNT && cell.after <= CLASS_COUNT); - assert!(cell.before != 17 && cell.before != 18); - assert!(cell.after != 17 && cell.after != 18); - assert!(cell.terms.len() <= 2); + while index < cells.len() { + let cell = &cells[index]; + if cell.before > CLASS_COUNT || cell.after > CLASS_COUNT { + return false; + } + if cell.before == 17 || cell.before == 18 || cell.after == 17 || cell.after == 18 { + return false; + } + if cell.terms.len() > 2 { + return false; + } let _ = cell.prohibited; let _ = cell.rule; match cell.hang { @@ -202,64 +201,520 @@ const _: () = { let mut term = 0_usize; while term < cell.terms.len() { let _ = cell.terms[term].trailing; - assert!(cell.terms[term].amount >= 0 && cell.terms[term].amount <= 720); + if cell.terms[term].amount < 0 || cell.terms[term].amount > 720 { + return false; + } term = term.saturating_add(1); } index = index.saturating_add(1); } -}; + true +} -const _: () = { +const fn remarks_valid(remarks: &[appendix_a::Remark]) -> bool { let mut index = 0_usize; - while index < appendix_a::REMARKS.len() { - let remark = &appendix_a::REMARKS[index]; - assert!((remark.frames & !ALL_FRAMES) == 0); - assert!(remark.usage <= appendix_a::USAGE_VERTICAL_ONLY); - assert!(remark.role <= appendix_a::ROLE_DIGIT_GROUP_SEPARATOR); - assert!(index != 0 || (remark.en.is_empty() && remark.ja.is_empty())); - assert!(index == 0 || !remark.ja.is_empty()); + while index < remarks.len() { + let remark = &remarks[index]; + if (remark.frames & !ALL_FRAMES) != 0 { + return false; + } + if remark.usage > appendix_a::USAGE_VERTICAL_ONLY { + return false; + } + if remark.role > appendix_a::ROLE_DIGIT_GROUP_SEPARATOR { + return false; + } + if index == 0 && (!remark.en.is_empty() || !remark.ja.is_empty()) { + return false; + } + if index > 0 && remark.ja.is_empty() { + return false; + } index = index.saturating_add(1); } -}; + true +} -const _: () = assert!(ideograph::RANGES.len() == IDEOGRAPH_RANGE_COUNT); -const _: () = assert!(covered_ideographs() == IDEOGRAPH_COUNT); -const _: () = { +const fn ideograph_ranges_valid(ranges: &[ideograph::Range]) -> bool { let mut index = 0_usize; - while index < ideograph::RANGES.len() { - let range = &ideograph::RANGES[index]; - assert!(range.first <= range.last); - if index > 0 { - assert!(ideograph::RANGES[index.saturating_sub(1)].last < range.first); + while index < ranges.len() { + let range = &ranges[index]; + if range.first > range.last { + return false; + } + if index > 0 && ranges[index.saturating_sub(1)].last >= range.first { + return false; } index = index.saturating_add(1); } -}; + true +} -const _: () = assert!(folding::FOLDS.len() == FOLD_COUNT); -const _: () = { +const fn folds_valid(folds: &[folding::Fold]) -> bool { let mut index = 0_usize; - while index < folding::FOLDS.len() { - let fold = &folding::FOLDS[index]; - assert!(fold.source != fold.target); - assert!(fold.frame == appendix_a::FRAME_FULL_EM || fold.frame == appendix_a::FRAME_HALF_EM); - if index > 0 { - assert!(folding::FOLDS[index.saturating_sub(1)].source < fold.source); + while index < folds.len() { + let fold = &folds[index]; + if fold.source == fold.target { + return false; + } + if fold.frame != appendix_a::FRAME_FULL_EM && fold.frame != appendix_a::FRAME_HALF_EM { + return false; + } + if index > 0 && folds[index.saturating_sub(1)].source >= fold.source { + return false; } index = index.saturating_add(1); } -}; + true +} -const _: () = assert!(script::RANGES.len() == SCRIPT_RANGE_COUNT); -const _: () = { +const fn script_ranges_valid(ranges: &[script::Range]) -> bool { let mut index = 0_usize; - while index < script::RANGES.len() { - let range = &script::RANGES[index]; - assert!(range.first <= range.last); - assert!(range.script == script::HIRAGANA || range.script == script::KATAKANA); - if index > 0 { - assert!(script::RANGES[index.saturating_sub(1)].last < range.first); + while index < ranges.len() { + let range = &ranges[index]; + if range.first > range.last { + return false; + } + if range.script != script::HIRAGANA && range.script != script::KATAKANA { + return false; + } + if index > 0 && ranges[index.saturating_sub(1)].last >= range.first { + return false; } index = index.saturating_add(1); } -}; + true +} + +const _: () = assert!(appendix_a::MAX_KEY_LEN == 2); +const _: () = assert!(appendix_a::LISTINGS.len() == LISTING_COUNT); +const _: () = assert!(distinct_keys(appendix_a::LISTINGS) == DISTINCT_KEY_COUNT); +const _: () = assert!(multi_class_keys(appendix_a::LISTINGS) == MULTI_CLASS_KEY_COUNT); +const _: () = assert!(appendix_a::REMARKS.len() == REMARK_COUNT); +const _: () = assert!(appendix_a::FRAMES_UNSTATED == 0); +const _: () = assert!(appendix_a::USAGE_UNQUALIFIED == 0); +const _: () = assert!(appendix_a::USAGE_HORIZONTAL_ONLY == 1); +const _: () = assert!(appendix_a::USAGE_VERTICAL_ONLY == 2); +const _: () = assert!(appendix_a::ROLE_UNSTATED == 0); +const _: () = assert!(appendix_a::ROLE_DECIMAL_POINT == 1); +const _: () = assert!(appendix_a::ROLE_DIGIT_GROUP_SEPARATOR == 2); + +const _: () = assert!(listings_valid(appendix_a::LISTINGS)); + +const _: () = assert!(table3::CELLS.len() == TABLE_WITH_LINE_EDGE_COUNT); +const _: () = assert!(table4::CELLS.len() == TABLE_WITH_LINE_EDGE_COUNT); +const _: () = assert!(table5::CELLS.len() == TABLE_WITH_LINE_EDGE_COUNT); +const _: () = assert!(table6::CELLS.len() == TABLE_WITHOUT_LINE_EDGE_COUNT); +const _: () = assert!(ranged_cells_valid(&[ + table3::CELLS, + table4::CELLS, + table5::CELLS, + table6::CELLS, +])); + +const _: () = assert!(table2::CELLS.len() == TABLE_WITHOUT_LINE_EDGE_COUNT); +const _: () = assert!(break_cells_valid(table2::CELLS)); + +const _: () = assert!(table1::CELLS.len() == TABLE_WITH_LINE_EDGE_COUNT); +const _: () = assert!(spacing_cells_valid(table1::CELLS)); + +const _: () = assert!(remarks_valid(appendix_a::REMARKS)); + +const _: () = assert!(ideograph::RANGES.len() == IDEOGRAPH_RANGE_COUNT); +const _: () = assert!(covered_ideographs(ideograph::RANGES) == IDEOGRAPH_COUNT); +const _: () = assert!(ideograph_ranges_valid(ideograph::RANGES)); + +const _: () = assert!(folding::FOLDS.len() == FOLD_COUNT); +const _: () = assert!(folds_valid(folding::FOLDS)); + +const _: () = assert!(script::RANGES.len() == SCRIPT_RANGE_COUNT); +const _: () = assert!(script_ranges_valid(script::RANGES)); + +#[cfg(test)] +mod tests { + use super::*; + use crate::spec::{RawBreakCell, RawHang, RawRangedCell, RawSpacingCell, RawTerm}; + + static VALID_TERMS: [RawTerm; 2] = [ + RawTerm { + trailing: false, + amount: 0, + }, + RawTerm { + trailing: true, + amount: 720, + }, + ]; + static TOO_MANY_TERMS: [RawTerm; 3] = [ + RawTerm { + trailing: false, + amount: 0, + }, + RawTerm { + trailing: false, + amount: 1, + }, + RawTerm { + trailing: true, + amount: 2, + }, + ]; + static NEGATIVE_TERM: [RawTerm; 1] = [RawTerm { + trailing: false, + amount: -1, + }]; + static OVERSIZED_TERM: [RawTerm; 1] = [RawTerm { + trailing: true, + amount: 721, + }]; + + const fn listing( + first: u32, + second: u32, + key_len: u8, + class: u8, + remark: u8, + ) -> appendix_a::Listing { + appendix_a::Listing { + key: [first, second], + key_len, + class, + remark, + } + } + + fn valid_ranged_cell() -> RawRangedCell { + RawRangedCell { + before: CLASS_COUNT, + after: CLASS_COUNT, + limit: Some(720), + two_valued: true, + residual: true, + stage: 6, + rule: "test", + } + } + + fn assert_ranged_cell_rejected(cell: RawRangedCell) { + assert!(!ranged_cells_valid(&[&[], &[cell]])); + } + + fn valid_break_cell() -> RawBreakCell { + RawBreakCell { + before: 1, + after: CLASS_COUNT, + prohibited: false, + levels: 0b1111, + rule: "test", + } + } + + fn assert_break_cell_rejected(cell: RawBreakCell) { + assert!(!break_cells_valid(&[cell])); + } + + fn valid_spacing_cell(terms: &'static [RawTerm]) -> RawSpacingCell { + RawSpacingCell { + before: 0, + after: CLASS_COUNT, + prohibited: false, + hang: RawHang::None, + rule: "test", + terms, + } + } + + fn assert_spacing_cell_rejected(cell: RawSpacingCell) { + assert!(!spacing_cells_valid(&[cell])); + } + + fn empty_remark() -> appendix_a::Remark { + appendix_a::Remark { + en: "", + ja: "", + frames: ALL_FRAMES, + usage: appendix_a::USAGE_VERTICAL_ONLY, + role: appendix_a::ROLE_DIGIT_GROUP_SEPARATOR, + } + } + + fn described_remark() -> appendix_a::Remark { + appendix_a::Remark { + en: "English", + ja: "日本語", + frames: ALL_FRAMES, + usage: appendix_a::USAGE_VERTICAL_ONLY, + role: appendix_a::ROLE_DIGIT_GROUP_SEPARATOR, + } + } + + #[test] + fn listing_helpers_cover_equality_ordering_and_counts() { + let first = listing(1, 0, 1, 1, 0); + let same_key_later_class = listing(1, 0, 1, 2, 0); + let second_code_point = listing(1, 2, 2, 1, 0); + let later_key = listing(2, 0, 1, 1, 0); + + assert!(same_key(&first, &same_key_later_class)); + assert!(!same_key(&first, &second_code_point)); + assert!(ascends(&first, &same_key_later_class)); + assert!(!ascends(&first, &first)); + assert!(ascends(&first, &second_code_point)); + assert!(!ascends(&second_code_point, &first)); + assert!(ascends(&second_code_point, &later_key)); + assert!(!ascends(&later_key, &second_code_point)); + + let listings = [first, same_key_later_class, later_key]; + assert_eq!(distinct_keys(&listings), 2); + assert_eq!(multi_class_keys(&listings), 1); + assert!(listings_valid(&listings)); + } + + #[test] + fn listing_validation_rejects_every_invalid_field_and_order() { + assert!(listings_valid(&[listing(1, 2, 2, CLASS_COUNT, 13,)])); + for invalid in [ + listing(1, 0, 1, 0, 0), + listing(1, 0, 1, CLASS_COUNT.saturating_add(1), 0), + listing(1, 0, 1, 1, 14), + listing(1, 0, 0, 1, 0), + listing(1, 2, 3, 1, 0), + listing(0, 0, 1, 1, 0), + listing(1, 2, 1, 1, 0), + listing(1, 0, 2, 1, 0), + ] { + assert!(!listings_valid(&[invalid])); + } + assert!(!listings_valid(&[ + listing(1, 0, 1, 1, 0), + listing(1, 0, 1, 1, 0), + ])); + assert!(!listings_valid(&[ + listing(2, 0, 1, 1, 0), + listing(1, 0, 1, 1, 0), + ])); + } + + #[test] + fn ranged_cell_validation_checks_all_boundaries() { + let valid = valid_ranged_cell(); + assert!(ranged_cells_valid(&[&[], &[valid]])); + for invalid in [ + RawRangedCell { + before: CLASS_COUNT.saturating_add(1), + ..valid + }, + RawRangedCell { + after: CLASS_COUNT.saturating_add(1), + ..valid + }, + RawRangedCell { + before: 17, + ..valid + }, + RawRangedCell { + before: 18, + ..valid + }, + RawRangedCell { after: 17, ..valid }, + RawRangedCell { after: 18, ..valid }, + RawRangedCell { + limit: Some(-1), + ..valid + }, + RawRangedCell { + limit: Some(721), + ..valid + }, + RawRangedCell { stage: 7, ..valid }, + ] { + assert_ranged_cell_rejected(invalid); + } + } + + #[test] + fn break_cell_validation_checks_all_boundaries() { + let valid = valid_break_cell(); + assert!(break_cells_valid(&[valid])); + for invalid in [ + RawBreakCell { before: 0, ..valid }, + RawBreakCell { + before: CLASS_COUNT.saturating_add(1), + ..valid + }, + RawBreakCell { + before: 17, + ..valid + }, + RawBreakCell { + before: 18, + ..valid + }, + RawBreakCell { after: 0, ..valid }, + RawBreakCell { + after: CLASS_COUNT.saturating_add(1), + ..valid + }, + RawBreakCell { after: 17, ..valid }, + RawBreakCell { after: 18, ..valid }, + RawBreakCell { + levels: 0b1_0000, + ..valid + }, + ] { + assert_break_cell_rejected(invalid); + } + } + + #[test] + fn spacing_cell_validation_checks_all_boundaries() { + let valid = valid_spacing_cell(&VALID_TERMS); + assert!(spacing_cells_valid(&[valid])); + for invalid in [ + RawSpacingCell { + before: CLASS_COUNT.saturating_add(1), + ..valid + }, + RawSpacingCell { + after: CLASS_COUNT.saturating_add(1), + ..valid + }, + RawSpacingCell { + before: 17, + ..valid + }, + RawSpacingCell { + before: 18, + ..valid + }, + RawSpacingCell { after: 17, ..valid }, + RawSpacingCell { after: 18, ..valid }, + valid_spacing_cell(&TOO_MANY_TERMS), + valid_spacing_cell(&NEGATIVE_TERM), + valid_spacing_cell(&OVERSIZED_TERM), + ] { + assert_spacing_cell_rejected(invalid); + } + } + + #[test] + fn remarks_validation_checks_masks_qualifiers_and_empty_cells() { + assert!(remarks_valid(&[empty_remark(), described_remark()])); + assert!(!remarks_valid(&[appendix_a::Remark { + frames: 0b0010_0000, + ..empty_remark() + }])); + assert!(!remarks_valid(&[appendix_a::Remark { + usage: appendix_a::USAGE_VERTICAL_ONLY.saturating_add(1), + ..empty_remark() + }])); + assert!(!remarks_valid(&[appendix_a::Remark { + role: appendix_a::ROLE_DIGIT_GROUP_SEPARATOR.saturating_add(1), + ..empty_remark() + }])); + assert!(!remarks_valid(&[appendix_a::Remark { + en: "not empty", + ..empty_remark() + }])); + assert!(!remarks_valid(&[ + empty_remark(), + appendix_a::Remark { + en: "English", + ja: "", + frames: 0, + usage: 0, + role: 0, + }, + ])); + } + + #[test] + fn range_and_fold_validation_rejects_reversal_overlap_and_bad_tags() { + let ideographs = [ + ideograph::Range { first: 1, last: 2 }, + ideograph::Range { first: 4, last: 4 }, + ]; + assert_eq!(covered_ideographs(&ideographs), 3); + assert!(ideograph_ranges_valid(&ideographs)); + assert!(!ideograph_ranges_valid(&[ideograph::Range { + first: 2, + last: 1, + }])); + assert!(!ideograph_ranges_valid(&[ + ideograph::Range { first: 1, last: 2 }, + ideograph::Range { first: 2, last: 3 }, + ])); + + let folds = [ + folding::Fold { + source: 2, + target: 1, + frame: appendix_a::FRAME_FULL_EM, + }, + folding::Fold { + source: 4, + target: 3, + frame: appendix_a::FRAME_HALF_EM, + }, + ]; + assert!(folds_valid(&folds)); + assert!(!folds_valid(&[folding::Fold { + source: 1, + target: 1, + frame: appendix_a::FRAME_FULL_EM, + }])); + assert!(!folds_valid(&[folding::Fold { + source: 2, + target: 1, + frame: 0, + }])); + assert!(!folds_valid(&[ + folding::Fold { + source: 2, + target: 1, + frame: appendix_a::FRAME_FULL_EM, + }, + folding::Fold { + source: 2, + target: 0, + frame: appendix_a::FRAME_FULL_EM, + }, + ])); + + let scripts = [ + script::Range { + first: 1, + last: 2, + script: script::HIRAGANA, + }, + script::Range { + first: 4, + last: 5, + script: script::KATAKANA, + }, + ]; + assert!(script_ranges_valid(&scripts)); + assert!(!script_ranges_valid(&[script::Range { + first: 2, + last: 1, + script: script::HIRAGANA, + }])); + assert!(!script_ranges_valid(&[script::Range { + first: 1, + last: 2, + script: 0, + }])); + assert!(!script_ranges_valid(&[ + script::Range { + first: 1, + last: 2, + script: script::HIRAGANA, + }, + script::Range { + first: 2, + last: 3, + script: script::KATAKANA, + }, + ])); + } +} diff --git a/crates/jlreq/src/generated/appendix_a.rs b/crates/jlreq/src/generated/appendix_a.rs index af02712..a82ec06 100644 --- a/crates/jlreq/src/generated/appendix_a.rs +++ b/crates/jlreq/src/generated/appendix_a.rs @@ -13,7 +13,7 @@ //! - Source SHA-256: `d47f6bf7a6d51d20c4e86419c35ef5ccaaf0c4baa9fb4ace55a0990e8360734c` //! - Specification: JLReq, 2020-08-11 //! - Generator: `xtask/src/classes.rs`, `xtask/src/generate.rs` -//! - Generator SHA-256: `a388af8ad963070b6c86477a42ee30023c165c0a82b92d30d4327df1dcfe3137` +//! - Generator SHA-256: `64c173c676c3d099077b59c02d707c9a3d470534f1ac5425d9eab6d1c1e78cdb` //! - Entries: 1686 /// The longest key Appendix A enumerates, in code points. diff --git a/crates/jlreq/src/generated/folding.rs b/crates/jlreq/src/generated/folding.rs index 900ae9b..2725599 100644 --- a/crates/jlreq/src/generated/folding.rs +++ b/crates/jlreq/src/generated/folding.rs @@ -13,7 +13,7 @@ //! - Source SHA-256: `4e8abc525815527522e36bad9410d0f7fa8733d29010f8a518e92fbc233cefaf` //! - Specification: JLReq, 2020-08-11 //! - Generator: `xtask/src/classes.rs`, `xtask/src/generate.rs` -//! - Generator SHA-256: `a388af8ad963070b6c86477a42ee30023c165c0a82b92d30d4327df1dcfe3137` +//! - Generator SHA-256: `64c173c676c3d099077b59c02d707c9a3d470534f1ac5425d9eab6d1c1e78cdb` //! - Entries: 226 use super::appendix_a::{FRAME_FULL_EM, FRAME_HALF_EM}; diff --git a/crates/jlreq/src/generated/ideograph.rs b/crates/jlreq/src/generated/ideograph.rs index 688b3e1..42cddfc 100644 --- a/crates/jlreq/src/generated/ideograph.rs +++ b/crates/jlreq/src/generated/ideograph.rs @@ -13,7 +13,7 @@ //! - Source SHA-256: `cf61e33039cc37974f3e017e6381e94873df3e55701199a71877b18bbd01886d` //! - Specification: JLReq, 2020-08-11 //! - Generator: `xtask/src/classes.rs`, `xtask/src/generate.rs` -//! - Generator SHA-256: `a388af8ad963070b6c86477a42ee30023c165c0a82b92d30d4327df1dcfe3137` +//! - Generator SHA-256: `64c173c676c3d099077b59c02d707c9a3d470534f1ac5425d9eab6d1c1e78cdb` //! - Entries: 16 /// One range of code points the Unicode Character Database gives diff --git a/crates/jlreq/src/generated/script.rs b/crates/jlreq/src/generated/script.rs index a7df28e..7e96310 100644 --- a/crates/jlreq/src/generated/script.rs +++ b/crates/jlreq/src/generated/script.rs @@ -13,7 +13,7 @@ //! - Source SHA-256: `3b7c3d2e4e2f35912137c2fef54928c4cbf42f850bcd2483175ec11f5c78bfd1` //! - Specification: JLReq, 2020-08-11 //! - Generator: `xtask/src/classes.rs`, `xtask/src/generate.rs` -//! - Generator SHA-256: `a388af8ad963070b6c86477a42ee30023c165c0a82b92d30d4327df1dcfe3137` +//! - Generator SHA-256: `64c173c676c3d099077b59c02d707c9a3d470534f1ac5425d9eab6d1c1e78cdb` //! - Entries: 22 /// The `Script=Hiragana` tag. diff --git a/crates/jlreq/src/generated/table1.rs b/crates/jlreq/src/generated/table1.rs index e81818d..638fc0d 100644 --- a/crates/jlreq/src/generated/table1.rs +++ b/crates/jlreq/src/generated/table1.rs @@ -13,7 +13,7 @@ //! - Source SHA-256: `02736559abf8f4be082781304b9291dbf8765ab95c8d07c630128153cce2332f` //! - Specification: JLReq, 2020-08-11 //! - Generator: `xtask/src/generate.rs`, `xtask/src/spacing.rs` -//! - Generator SHA-256: `23efc502ed358c12303978c55aa110c667e2145dc2ebd0ab775e889eed711109` +//! - Generator SHA-256: `c8974d1fe3f413864bf20ad59a6418cea98202fb0ab5d5fe26364566b408f9fa` //! - Entries: 841 use crate::spec::{RawHang, RawSpacingCell, RawTerm, em}; diff --git a/crates/jlreq/src/generated/table2.rs b/crates/jlreq/src/generated/table2.rs index 90e0716..2fc4962 100644 --- a/crates/jlreq/src/generated/table2.rs +++ b/crates/jlreq/src/generated/table2.rs @@ -13,7 +13,7 @@ //! - Source SHA-256: `3e93d1104a5c730bc9eca01880ef989520b7c3ebb1ef98833be0424a44edbd66` //! - Specification: JLReq, 2020-08-11 //! - Generator: `xtask/src/generate.rs`, `xtask/src/spacing.rs` -//! - Generator SHA-256: `23efc502ed358c12303978c55aa110c667e2145dc2ebd0ab775e889eed711109` +//! - Generator SHA-256: `c8974d1fe3f413864bf20ad59a6418cea98202fb0ab5d5fe26364566b408f9fa` //! - Entries: 784 use crate::spec::RawBreakCell; diff --git a/crates/jlreq/src/generated/table3.rs b/crates/jlreq/src/generated/table3.rs index 6e7577c..b3fec47 100644 --- a/crates/jlreq/src/generated/table3.rs +++ b/crates/jlreq/src/generated/table3.rs @@ -13,7 +13,7 @@ //! - Source SHA-256: `ca550d5323625d71d583ecd27006e5ec0d7c569814e4dfdd392593bc9aa43222` //! - Specification: JLReq, 2020-08-11 //! - Generator: `xtask/src/generate.rs`, `xtask/src/spacing.rs` -//! - Generator SHA-256: `23efc502ed358c12303978c55aa110c667e2145dc2ebd0ab775e889eed711109` +//! - Generator SHA-256: `c8974d1fe3f413864bf20ad59a6418cea98202fb0ab5d5fe26364566b408f9fa` //! - Entries: 841 use crate::spec::{RawRangedCell, em}; diff --git a/crates/jlreq/src/generated/table4.rs b/crates/jlreq/src/generated/table4.rs index ed052f9..a67f15b 100644 --- a/crates/jlreq/src/generated/table4.rs +++ b/crates/jlreq/src/generated/table4.rs @@ -13,7 +13,7 @@ //! - Source SHA-256: `f8274c63edd9a561f2d951ac2831f731da7db89f22d0a58c5ae533fe5d6d2a65` //! - Specification: JLReq, 2020-08-11 //! - Generator: `xtask/src/generate.rs`, `xtask/src/spacing.rs` -//! - Generator SHA-256: `23efc502ed358c12303978c55aa110c667e2145dc2ebd0ab775e889eed711109` +//! - Generator SHA-256: `c8974d1fe3f413864bf20ad59a6418cea98202fb0ab5d5fe26364566b408f9fa` //! - Entries: 841 use crate::spec::{RawRangedCell, em}; diff --git a/crates/jlreq/src/generated/table5.rs b/crates/jlreq/src/generated/table5.rs index 092c7e9..bac40c1 100644 --- a/crates/jlreq/src/generated/table5.rs +++ b/crates/jlreq/src/generated/table5.rs @@ -13,7 +13,7 @@ //! - Source SHA-256: `785b6275731e54b8b235efabfb9418b9e766145c5f59cdf99cda8a9e018c3519` //! - Specification: JLReq, 2020-08-11 //! - Generator: `xtask/src/generate.rs`, `xtask/src/spacing.rs` -//! - Generator SHA-256: `23efc502ed358c12303978c55aa110c667e2145dc2ebd0ab775e889eed711109` +//! - Generator SHA-256: `c8974d1fe3f413864bf20ad59a6418cea98202fb0ab5d5fe26364566b408f9fa` //! - Entries: 841 use crate::spec::{RawRangedCell, em}; diff --git a/crates/jlreq/src/generated/table6.rs b/crates/jlreq/src/generated/table6.rs index 0215212..ad8652e 100644 --- a/crates/jlreq/src/generated/table6.rs +++ b/crates/jlreq/src/generated/table6.rs @@ -13,7 +13,7 @@ //! - Source SHA-256: `e63166631b11e80c00adff7e1bf3126be347e95133e74112310307fff1f27ac9` //! - Specification: JLReq, 2020-08-11 //! - Generator: `xtask/src/generate.rs`, `xtask/src/spacing.rs` -//! - Generator SHA-256: `23efc502ed358c12303978c55aa110c667e2145dc2ebd0ab775e889eed711109` +//! - Generator SHA-256: `c8974d1fe3f413864bf20ad59a6418cea98202fb0ab5d5fe26364566b408f9fa` //! - Entries: 784 use crate::spec::{RawRangedCell, em}; diff --git a/crates/jlreq/src/layout.rs b/crates/jlreq/src/layout.rs index cc63daf..9115f68 100644 --- a/crates/jlreq/src/layout.rs +++ b/crates/jlreq/src/layout.rs @@ -298,3 +298,43 @@ impl Layout { &self.diagnostics } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attachment_and_diagnostic_accessors_preserve_all_fields() { + let attachment = Attachment { + construct: 7, + range: 3..9, + inline: 11, + block: -13, + advance: 17, + size: Size::new(19, 23).expect("positive size"), + writing_mode: WritingMode::VerticalRl, + transform: CoordinateTransform::TateChuYoko, + symbol: Some('・'), + }; + assert_eq!(attachment.construct(), 7); + assert_eq!(attachment.range(), 3..9); + assert_eq!(attachment.inline(), 11); + assert_eq!(attachment.block(), -13); + assert_eq!(attachment.advance(), 17); + assert_eq!(attachment.size(), Size::new(19, 23).expect("positive size")); + assert_eq!(attachment.writing_mode(), WritingMode::VerticalRl); + assert_eq!(attachment.transform(), CoordinateTransform::TateChuYoko); + assert_eq!(attachment.symbol(), Some('・')); + + let diagnostic = Diagnostic { + code: "layout.test", + severity: Severity::Warning, + range: Some(5..8), + jlreq: "3.1.1", + }; + assert_eq!(diagnostic.code(), "layout.test"); + assert_eq!(diagnostic.severity(), Severity::Warning); + assert_eq!(diagnostic.range(), Some(5..8)); + assert_eq!(diagnostic.jlreq(), "3.1.1"); + } +} diff --git a/crates/jlreq/src/lib.rs b/crates/jlreq/src/lib.rs index 2a28eaa..0751e1d 100644 --- a/crates/jlreq/src/lib.rs +++ b/crates/jlreq/src/lib.rs @@ -20,7 +20,8 @@ //! let paragraph = Paragraph::builder(text, 4_000) //! .breaks(source.char_indices().skip(1).map(|(at, _)| Break::allowed(at))) //! .build()?; -//! let layout = jlreq::compose(¶graph, &Style::book_2020()); +//! let layout = jlreq::compose(¶graph, &Style::book_2020()) +//! .expect("small paragraph is within the default resource limits"); //! //! assert_eq!(layout.lines().len(), 2); //! # Ok::<(), jlreq::InputError>(()) @@ -33,6 +34,7 @@ extern crate alloc; mod construct; mod generated; mod layout; +mod limits; mod model; mod normalize; mod paragraph; @@ -45,6 +47,7 @@ pub use layout::{ Attachment, ClusterPlacement, CoordinateTransform, Diagnostic, Layout, Line, PlacementOrigin, Severity, }; +pub use limits::{ComposeError, CompositionLimits, CompositionResource}; pub use model::{Cluster, ClusterRole, Frame, InputError, ShapedText, Size, WritingMode}; pub use paragraph::{Alignment, Break, Paragraph, ParagraphBuilder, TabAlignment, TabStop, Widow}; pub use pipeline::Composer; @@ -56,7 +59,6 @@ pub const SPECIFICATION: &str = "jlreq-2020-08-11+unicode-17.0.0"; /// Compose one validated paragraph with a fresh scratch allocator. /// /// Use Composer when composing repeatedly so its temporary buffers can be reused. -#[must_use] -pub fn compose(paragraph: &Paragraph, style: &Style) -> Layout { +pub fn compose(paragraph: &Paragraph, style: &Style) -> Result { Composer::new().compose(paragraph, style) } diff --git a/crates/jlreq/src/limits.rs b/crates/jlreq/src/limits.rs new file mode 100644 index 0000000..c7b70df --- /dev/null +++ b/crates/jlreq/src/limits.rs @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: 2026 jlreq contributors +// +// SPDX-License-Identifier: MIT OR Apache-2.0 + +/// A finite resource consumed while composing a paragraph. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum CompositionResource { + /// Shaped clusters in the paragraph. + Clusters, + /// Caller-visible break candidates, including the implicit paragraph end. + BreakCandidates, + /// Inline constructs in the paragraph. + Constructs, + /// Tab stops available to each line. + TabStops, + /// Dynamic-programming transitions and special-element inspections. + SearchTransitions, +} + +impl CompositionResource { + const fn error_code(self) -> &'static str { + match self { + Self::Clusters => "compose.cluster-limit", + Self::BreakCandidates => "compose.break-candidate-limit", + Self::Constructs => "compose.construct-limit", + Self::TabStops => "compose.tab-stop-limit", + Self::SearchTransitions => "compose.transition-limit", + } + } + + const fn description(self) -> &'static str { + match self { + Self::Clusters => "cluster limit exceeded", + Self::BreakCandidates => "break-candidate limit exceeded", + Self::Constructs => "construct limit exceeded", + Self::TabStops => "tab-stop limit exceeded", + Self::SearchTransitions => "composition search transition limit exceeded", + } + } +} + +/// Deterministic resource limits for one composition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub struct CompositionLimits { + clusters: usize, + break_candidates: usize, + constructs: usize, + tab_stops: usize, + search_transitions: usize, +} + +impl CompositionLimits { + /// The default maximum number of shaped clusters. + pub const DEFAULT_MAX_CLUSTERS: usize = 65_536; + /// The default maximum number of break candidates. + pub const DEFAULT_MAX_BREAK_CANDIDATES: usize = 65_536; + /// The default maximum number of inline constructs. + pub const DEFAULT_MAX_CONSTRUCTS: usize = 4_096; + /// The default maximum number of tab stops. + pub const DEFAULT_MAX_TAB_STOPS: usize = 4_096; + /// The default maximum number of search transitions and special inspections. + pub const DEFAULT_MAX_SEARCH_TRANSITIONS: usize = 8_000_000; + + /// The release defaults as a value usable in constants. + pub const DEFAULT: Self = Self { + clusters: Self::DEFAULT_MAX_CLUSTERS, + break_candidates: Self::DEFAULT_MAX_BREAK_CANDIDATES, + constructs: Self::DEFAULT_MAX_CONSTRUCTS, + tab_stops: Self::DEFAULT_MAX_TAB_STOPS, + search_transitions: Self::DEFAULT_MAX_SEARCH_TRANSITIONS, + }; + + /// The shaped-cluster limit. + #[must_use] + pub const fn max_clusters(self) -> usize { + self.clusters + } + + /// The break-candidate limit. + #[must_use] + pub const fn max_break_candidates(self) -> usize { + self.break_candidates + } + + /// The inline-construct limit. + #[must_use] + pub const fn max_constructs(self) -> usize { + self.constructs + } + + /// The tab-stop limit. + #[must_use] + pub const fn max_tab_stops(self) -> usize { + self.tab_stops + } + + /// The composition-search work limit. + #[must_use] + pub const fn max_search_transitions(self) -> usize { + self.search_transitions + } + + /// Return limits with a different shaped-cluster maximum. + #[must_use] + pub const fn with_max_clusters(mut self, maximum: usize) -> Self { + self.clusters = maximum; + self + } + + /// Return limits with a different break-candidate maximum. + #[must_use] + pub const fn with_max_break_candidates(mut self, maximum: usize) -> Self { + self.break_candidates = maximum; + self + } + + /// Return limits with a different inline-construct maximum. + #[must_use] + pub const fn with_max_constructs(mut self, maximum: usize) -> Self { + self.constructs = maximum; + self + } + + /// Return limits with a different tab-stop maximum. + #[must_use] + pub const fn with_max_tab_stops(mut self, maximum: usize) -> Self { + self.tab_stops = maximum; + self + } + + /// Return limits with a different composition-search work maximum. + #[must_use] + pub const fn with_max_search_transitions(mut self, maximum: usize) -> Self { + self.search_transitions = maximum; + self + } +} + +impl Default for CompositionLimits { + fn default() -> Self { + Self::DEFAULT + } +} + +/// Composition stopped before producing a layout because a declared resource limit was hit. +/// +/// No partial or approximate layout is returned. The same [`crate::Composer`] can be reused +/// immediately after this error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct ComposeError { + resource: CompositionResource, + limit: usize, + observed: usize, +} + +impl ComposeError { + pub(crate) const fn new(resource: CompositionResource, limit: usize, observed: usize) -> Self { + Self { + resource, + limit, + observed, + } + } + + /// A stable, language-independent error code. + #[must_use] + pub const fn code(self) -> &'static str { + self.resource.error_code() + } + + /// The resource whose limit was exceeded. + #[must_use] + pub const fn resource(self) -> CompositionResource { + self.resource + } + + /// The configured inclusive maximum. + #[must_use] + pub const fn limit(self) -> usize { + self.limit + } + + /// The amount required or observed when composition stopped. + #[must_use] + pub const fn observed(self) -> usize { + self.observed + } +} + +impl core::fmt::Display for ComposeError { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter.write_str(self.resource.description()) + } +} + +impl core::error::Error for ComposeError {} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + + #[test] + fn every_resource_has_a_stable_code_description_and_display() { + let cases = [ + ( + CompositionResource::Clusters, + "compose.cluster-limit", + "cluster limit exceeded", + ), + ( + CompositionResource::BreakCandidates, + "compose.break-candidate-limit", + "break-candidate limit exceeded", + ), + ( + CompositionResource::Constructs, + "compose.construct-limit", + "construct limit exceeded", + ), + ( + CompositionResource::TabStops, + "compose.tab-stop-limit", + "tab-stop limit exceeded", + ), + ( + CompositionResource::SearchTransitions, + "compose.transition-limit", + "composition search transition limit exceeded", + ), + ]; + for (resource, code, message) in cases { + let error = ComposeError::new(resource, 13, 17); + assert_eq!(error.code(), code); + assert_eq!(resource.description(), message); + assert_eq!(format!("{error}"), message); + assert_eq!(error.resource(), resource); + assert_eq!(error.limit(), 13); + assert_eq!(error.observed(), 17); + } + } +} diff --git a/crates/jlreq/src/model.rs b/crates/jlreq/src/model.rs index eee8991..f9a839c 100644 --- a/crates/jlreq/src/model.rs +++ b/crates/jlreq/src/model.rs @@ -55,6 +55,27 @@ impl core::fmt::Display for InputError { } } +impl core::error::Error for InputError {} + +#[cfg(test)] +mod error_tests { + use super::*; + use alloc::format; + + #[test] + fn input_error_and_cluster_accessors_preserve_values() { + let error = InputError::new("input.uncovered-text", Some(3..7), "test message"); + assert_eq!(error.code(), "input.uncovered-text"); + assert_eq!(error.range(), Some(3..7)); + assert_eq!(error.message(), "test message"); + assert_eq!(format!("{error}"), "test message"); + + let cluster = Cluster::new(3..7, 19); + assert_eq!(cluster.range(), 3..7); + assert_eq!(cluster.advance(), 19); + } +} + /// A caller-unit font size along the inline and block axes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] diff --git a/crates/jlreq/src/normalize.rs b/crates/jlreq/src/normalize.rs index 384095e..7275be8 100644 --- a/crates/jlreq/src/normalize.rs +++ b/crates/jlreq/src/normalize.rs @@ -30,10 +30,19 @@ impl ShapedText { || at == self.source.len() || self .clusters - .iter() - .any(|cluster| cluster.range().start == at || cluster.range().end == at); + .binary_search_by_key(&at, |cluster| cluster.range().start) + .is_ok(); shaped_boundary && !splits_appendix_pair(&self.source, at) } + + pub(crate) fn cluster_ordinal(&self, at: usize) -> Option { + if at == self.source.len() { + return Some(self.clusters.len()); + } + self.clusters + .binary_search_by_key(&at, |cluster| cluster.range().start) + .ok() + } } fn validate_clusters( @@ -76,17 +85,22 @@ fn validate_clusters( "a cluster endpoint is not a UTF-8 code-point boundary", )); } - if range.start != cursor { - let code = if range.start < cursor { - "input.overlapping-clusters" - } else { - "input.uncovered-text" - }; - return Err(InputError::new( - code, - Some(range), - "clusters must cover the source exactly once in source order", - )); + match range.start.cmp(&cursor) { + core::cmp::Ordering::Less => { + return Err(InputError::new( + "input.overlapping-clusters", + Some(range), + "clusters must cover the source exactly once in source order", + )); + }, + core::cmp::Ordering::Greater => { + return Err(InputError::new( + "input.uncovered-text", + Some(range), + "clusters must cover the source exactly once in source order", + )); + }, + core::cmp::Ordering::Equal => {}, } if cluster.advance() < 0 { return Err(InputError::new( @@ -119,7 +133,7 @@ fn validate_clusters( } fn splits_appendix_pair(source: &str, at: usize) -> bool { - if at == 0 || at >= source.len() || !source.is_char_boundary(at) { + if !source.is_char_boundary(at) { return false; } let Some(before) = source[..at].chars().next_back() else { @@ -141,3 +155,91 @@ fn is_appendix_pair(piece: &str) -> bool { }; characters.next().is_none() && spec::is_pair(first, second) } + +#[cfg(test)] +mod tests { + use super::*; + + fn size() -> Size { + Size::square(1_000).expect("positive size") + } + + #[test] + fn shaped_boundaries_include_endpoints_but_not_appendix_pair_splits() { + let text = ShapedText::new( + "ab", + size(), + Frame::Proportional, + [Cluster::new(0..1, 500), Cluster::new(1..2, 500)], + ) + .expect("valid text"); + assert!(text.cluster_boundary(0)); + assert!(text.cluster_boundary(1)); + assert!(text.cluster_boundary(2)); + assert!(!text.cluster_boundary(3)); + + let pair = "\u{02e5}\u{02e9}"; + let split = '\u{02e5}'.len_utf8(); + let paired = ShapedText::new( + pair, + size(), + Frame::Proportional, + [ + Cluster::new(0..split, 500), + Cluster::new(split..pair.len(), 500), + ], + ) + .expect("proportional pair may be separately shaped"); + assert!(!paired.cluster_boundary(split)); + } + + #[test] + fn cluster_validation_distinguishes_range_and_advance_boundaries() { + let empty = ShapedText::new("a", size(), Frame::FullEm, [Cluster::new(0..0, 0)]) + .expect_err("empty cluster range"); + assert_eq!(empty.code(), "input.cluster-out-of-range"); + + let outside = ShapedText::new("a", size(), Frame::FullEm, [Cluster::new(0..2, 0)]) + .expect_err("outside cluster range"); + assert_eq!(outside.code(), "input.cluster-out-of-range"); + + let negative = ShapedText::new("a", size(), Frame::FullEm, [Cluster::new(0..1, -1)]) + .expect_err("negative advance"); + assert_eq!(negative.code(), "input.negative-advance"); + assert!( + ShapedText::new("a", size(), Frame::FullEm, [Cluster::new(0..1, 0)]).is_ok(), + "zero advance is valid" + ); + + let overlap = ShapedText::new( + "abc", + size(), + Frame::Proportional, + [Cluster::new(0..2, 1), Cluster::new(1..3, 1)], + ) + .expect_err("overlapping clusters"); + assert_eq!(overlap.code(), "input.overlapping-clusters"); + let gap = ShapedText::new( + "abc", + size(), + Frame::Proportional, + [Cluster::new(0..1, 1), Cluster::new(2..3, 1)], + ) + .expect_err("uncovered text between clusters"); + assert_eq!(gap.code(), "input.uncovered-text"); + } + + #[test] + fn appendix_pair_helpers_cover_all_guard_edges() { + let pair = "\u{02e5}\u{02e9}"; + let split = '\u{02e5}'.len_utf8(); + assert!(is_appendix_pair(pair)); + assert!(!is_appendix_pair("")); + assert!(!is_appendix_pair("a")); + assert!(!is_appendix_pair("abc")); + assert!(splits_appendix_pair(pair, split)); + assert!(!splits_appendix_pair(pair, 0)); + assert!(!splits_appendix_pair(pair, pair.len())); + assert!(!splits_appendix_pair("é", 1)); + } +} diff --git a/crates/jlreq/src/paragraph.rs b/crates/jlreq/src/paragraph.rs index 542045c..49027ac 100644 --- a/crates/jlreq/src/paragraph.rs +++ b/crates/jlreq/src/paragraph.rs @@ -2,8 +2,7 @@ // // SPDX-License-Identifier: MIT OR Apache-2.0 -use alloc::vec::Vec; -use core::cmp::Ordering; +use alloc::{vec, vec::Vec}; use crate::construct::{Construct, ConstructKind, is_math_token}; use crate::model::{InputError, ShapedText, WritingMode}; @@ -148,7 +147,7 @@ impl TabStop { } } -/// A completely validated paragraph ready for infallible composition. +/// A completely validated paragraph ready for exact, resource-bounded composition. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct Paragraph { @@ -157,6 +156,7 @@ pub struct Paragraph { pub(crate) breaks: Vec, pub(crate) constructs: Vec, pub(crate) tab_stops: Vec, + pub(crate) line_tabs: Vec, pub(crate) first_line_indent: i32, pub(crate) alignment: Alignment, pub(crate) widow: Widow, @@ -327,16 +327,13 @@ impl ParagraphBuilder { } validate_constructs(&self.text, &self.constructs)?; - validate_breaks( - &self.text, - &self.constructs, - self.writing_mode, - &mut self.breaks, - )?; + let line_tabs = line_tab_mask(&self.text, &self.constructs, self.writing_mode); + validate_breaks(&self.text, &self.constructs, &line_tabs, &mut self.breaks)?; validate_construct_breaks(&self.text, &self.constructs, &self.breaks)?; validate_tabs( &self.text, &self.breaks, + &line_tabs, self.line_extent, &mut self.tab_stops, )?; @@ -347,6 +344,7 @@ impl ParagraphBuilder { breaks: self.breaks, constructs: self.constructs, tab_stops: self.tab_stops, + line_tabs, first_line_indent: self.first_line_indent, alignment: self.alignment, widow: self.widow, @@ -385,13 +383,11 @@ fn validate_constructs(text: &ShapedText, constructs: &[Construct]) -> Result<() } if ruby.kind() == crate::RubyKind::Mono && text - .clusters() - .iter() - .filter(|cluster| { - let cluster = cluster.range(); - run.base().start <= cluster.start && cluster.end <= run.base().end - }) - .count() + .cluster_ordinal(run.base().end) + .unwrap_or(usize::MAX) + .saturating_sub( + text.cluster_ordinal(run.base().start).unwrap_or(usize::MAX), + ) != 1 { return Err(InputError::new( @@ -428,25 +424,30 @@ fn validate_constructs(text: &ShapedText, constructs: &[Construct]) -> Result<() } } - for (index, left) in constructs.iter().enumerate() { - let left = left.range(); - let following = index.saturating_add(1); - let Some(rest) = constructs.get(following..) else { - continue; - }; - for right in rest { - let right = right.range(); - let crosses = - (left.start < right.start && right.start < left.end && left.end < right.end) - || (right.start < left.start && left.start < right.end && right.end < left.end); - if crosses { + let mut ordered: Vec<_> = constructs.iter().map(Construct::range).collect(); + ordered.sort_by(|left, right| { + left.start + .cmp(&right.start) + .then_with(|| right.end.cmp(&left.end)) + }); + let mut stack = Vec::new(); + for current in ordered { + while stack + .last() + .is_some_and(|open: &core::ops::Range| open.end <= current.start) + { + stack.pop(); + } + if let Some(open) = stack.last() { + if current.end > open.end { return Err(InputError::new( "input.crossing-constructs", - Some(left.start.min(right.start)..left.end.max(right.end)), + Some(open.start.min(current.start)..open.end.max(current.end)), "inline construct ranges may nest or be disjoint, but may not cross", )); } } + stack.push(current); } Ok(()) } @@ -469,10 +470,11 @@ fn stacks_text_off_the_line(construct: &Construct, writing_mode: WritingMode) -> fn validate_breaks( text: &ShapedText, constructs: &[Construct], - writing_mode: WritingMode, + line_tabs: &[bool], breaks: &mut Vec, ) -> Result<(), InputError> { let end = text.source().len(); + let blocked = blocked_break_boundaries(text, constructs); for opportunity in &*breaks { if opportunity.offset > end || !text.cluster_boundary(opportunity.offset) { return Err(InputError::new( @@ -481,44 +483,15 @@ fn validate_breaks( "break offsets must be shaped-cluster boundaries", )); } - for construct in constructs { - let range = construct.range(); - if range.start < opportunity.offset - && opportunity.offset < range.end - && !construct_allows_break(text, construct, opportunity.offset) - { - return Err(InputError::new( - "input.break-inside-construct", - Some(opportunity.offset..opportunity.offset), - "this inline structure is indivisible at the requested break", - )); - } - } - } - for cluster in text.clusters() { - if &text.source()[cluster.range()] != "\t" { - continue; - } - let offset = cluster.range().start; - let inside_construct = constructs.iter().any(|construct| { - let range = construct.range(); - if range.start < offset && offset < range.end { - return true; - } - // A structure that stacks its text off the line sets its first character in - // the structure like every other one, so a sign there stands in it rather - // than beside it and §3.6.3's cut is not available. A structure that *ends* - // at the sign leaves the sign beside it and the cut available - // (`docs/decisions/tab-line-correspondence.md`). - range.start == offset && stacks_text_off_the_line(construct, writing_mode) - }); - if offset != 0 - && !inside_construct - && !breaks - .iter() - .any(|opportunity| opportunity.offset == offset) - { - breaks.push(Break::allowed(offset)); + let ordinal = text + .cluster_ordinal(opportunity.offset) + .unwrap_or(usize::MAX); + if blocked.get(ordinal).copied().unwrap_or(false) { + return Err(InputError::new( + "input.break-inside-construct", + Some(opportunity.offset..opportunity.offset), + "this inline structure is indivisible at the requested break", + )); } } breaks.retain(|opportunity| opportunity.offset != 0); @@ -533,6 +506,23 @@ fn validate_breaks( "each byte offset may carry only one break kind", )); } + let mut generated_tab_breaks = Vec::new(); + for (ordinal, cluster) in text.clusters().iter().enumerate() { + if !line_tabs.get(ordinal).copied().unwrap_or(false) { + continue; + } + let offset = cluster.range().start; + if offset != 0 + && !blocked.get(ordinal).copied().unwrap_or(false) + && breaks + .binary_search_by_key(&offset, |opportunity| opportunity.offset) + .is_err() + { + generated_tab_breaks.push(Break::allowed(offset)); + } + } + breaks.extend(generated_tab_breaks); + breaks.sort_by_key(|opportunity| opportunity.offset); if breaks.last().is_none_or(|last| last.offset != end) { breaks.push(Break::mandatory(end)); } else if let Some(last) = breaks.last_mut() { @@ -541,34 +531,79 @@ fn validate_breaks( Ok(()) } -fn construct_allows_break(text: &ShapedText, construct: &Construct, at: usize) -> bool { - match construct.kind() { - ConstructKind::Ruby(ruby) => { - ruby.kind() != crate::RubyKind::Group - && ruby.runs().iter().any(|run| run.base().end == at) - }, - ConstructKind::Emphasis { .. } - | ConstructKind::Warichu(_) - | ConstructKind::Furawake { .. } => true, - ConstructKind::Formula(range) => { - let before = text - .clusters() - .iter() - .find(|cluster| cluster.range().end == at) - .and_then(|cluster| single_cluster_character(text, cluster)); - let after = text - .clusters() - .iter() - .find(|cluster| cluster.range().start == at) - .and_then(|cluster| single_cluster_character(text, cluster)); - range.start < at - && at < range.end - && (before.is_some_and(is_math_token) || after.is_some_and(is_math_token)) - }, - _ => false, +fn blocked_break_boundaries(text: &ShapedText, constructs: &[Construct]) -> Vec { + let boundary_count = text.clusters().len().saturating_add(1); + let mut ordinary = vec![0_i32; boundary_count]; + let mut formula = vec![0_i32; boundary_count]; + let mut allowed = vec![0_i32; boundary_count]; + + for construct in constructs { + let range = construct.range(); + let (Some(start), Some(end)) = ( + text.cluster_ordinal(range.start), + text.cluster_ordinal(range.end), + ) else { + continue; + }; + match construct.kind() { + ConstructKind::Emphasis { .. } + | ConstructKind::Warichu(_) + | ConstructKind::Furawake { .. } => {}, + ConstructKind::Formula(_) => add_interior_range(&mut formula, start, end), + ConstructKind::Ruby(ruby) => { + add_interior_range(&mut ordinary, start, end); + if ruby.kind() != crate::RubyKind::Group { + for run in ruby.runs() { + if let Some(boundary) = text.cluster_ordinal(run.base().end) { + if let Some(value) = allowed.get_mut(boundary) { + *value = value.saturating_add(1); + } + } + } + } + }, + _ => add_interior_range(&mut ordinary, start, end), + } + } + + let mut ordinary_depth = 0_i32; + let mut formula_depth = 0_i32; + (0..boundary_count) + .map(|boundary| { + ordinary_depth = ordinary_depth.saturating_add(ordinary[boundary]); + formula_depth = formula_depth.saturating_add(formula[boundary]); + let ordinary_blocked = ordinary_depth.saturating_sub(allowed[boundary]) > 0; + ordinary_blocked || (formula_depth > 0 && !boundary_touches_math_token(text, boundary)) + }) + .collect() +} + +fn add_interior_range(difference: &mut [i32], start: usize, end: usize) { + let interior_start = start.saturating_add(1); + if interior_start >= end { + return; + } + if let Some(value) = difference.get_mut(interior_start) { + *value = value.saturating_add(1); + } + if let Some(value) = difference.get_mut(end) { + *value = value.saturating_sub(1); } } +fn boundary_touches_math_token(text: &ShapedText, boundary: usize) -> bool { + boundary + .checked_sub(1) + .and_then(|ordinal| text.clusters().get(ordinal)) + .and_then(|cluster| single_cluster_character(text, cluster)) + .is_some_and(is_math_token) + || text + .clusters() + .get(boundary) + .and_then(|cluster| single_cluster_character(text, cluster)) + .is_some_and(is_math_token) +} + fn validate_construct_breaks( text: &ShapedText, constructs: &[Construct], @@ -578,12 +613,9 @@ fn validate_construct_breaks( let ConstructKind::Furawake { range, columns, .. } = construct.kind() else { continue; }; - let split_count = breaks - .iter() - .filter(|opportunity| { - range.start < opportunity.offset && opportunity.offset < range.end - }) - .count(); + let first_split = breaks.partition_point(|opportunity| opportunity.offset <= range.start); + let after_last_split = breaks.partition_point(|opportunity| opportunity.offset < range.end); + let split_count = after_last_split.saturating_sub(first_split); if split_count != usize::from(columns.saturating_sub(1)) { return Err(InputError::new( "input.furawake-split-count", @@ -591,15 +623,11 @@ fn validate_construct_breaks( "furawake needs exactly one declared split between adjacent sublines", )); } - if usize::from(*columns) - > text - .clusters() - .iter() - .filter(|cluster| { - range.start <= cluster.range().start && cluster.range().end <= range.end - }) - .count() - { + let cluster_count = text + .cluster_ordinal(range.end) + .unwrap_or(0) + .saturating_sub(text.cluster_ordinal(range.start).unwrap_or(0)); + if usize::from(*columns) > cluster_count { return Err(InputError::new( "input.furawake-empty-subline", Some(range.clone()), @@ -619,14 +647,11 @@ fn single_cluster_character(text: &ShapedText, cluster: &crate::Cluster) -> Opti fn validate_tabs( text: &ShapedText, breaks: &[Break], + line_tabs: &[bool], line_extent: i32, stops: &mut Vec, ) -> Result<(), InputError> { - stops.sort_by(|left, right| { - left.position - .partial_cmp(&right.position) - .unwrap_or(Ordering::Equal) - }); + stops.sort_by_key(|stop| stop.position); for stop in &*stops { if stop.position >= line_extent { return Err(InputError::new( @@ -647,8 +672,12 @@ fn validate_tabs( )); } let mut tab_count = 0_usize; - for cluster in text.clusters() { - if &text.source()[cluster.range()] == "\t" { + let mut mandatory = breaks + .iter() + .filter(|opportunity| opportunity.is_mandatory()) + .peekable(); + for (ordinal, cluster) in text.clusters().iter().enumerate() { + if line_tabs.get(ordinal).copied().unwrap_or(false) { tab_count = tab_count.saturating_add(1); if tab_count > stops.len() { return Err(InputError::new( @@ -658,11 +687,267 @@ fn validate_tabs( )); } } - if breaks.iter().any(|opportunity| { - opportunity.offset() == cluster.range().end && opportunity.is_mandatory() - }) { + if mandatory + .next_if(|opportunity| opportunity.offset() == cluster.range().end) + .is_some() + { tab_count = 0; } } Ok(()) } + +fn line_tab_mask( + text: &ShapedText, + constructs: &[Construct], + writing_mode: WritingMode, +) -> Vec { + let cluster_count = text.clusters().len(); + let mut difference = vec![0_i32; cluster_count.saturating_add(1)]; + for construct in constructs { + if !stacks_text_off_the_line(construct, writing_mode) { + continue; + } + let range = construct.range(); + let (Some(start), Some(end)) = ( + text.cluster_ordinal(range.start), + text.cluster_ordinal(range.end), + ) else { + continue; + }; + if let Some(value) = difference.get_mut(start) { + *value = value.saturating_add(1); + } + if let Some(value) = difference.get_mut(end) { + *value = value.saturating_sub(1); + } + } + + let mut depth = 0_i32; + text.clusters() + .iter() + .enumerate() + .map(|(ordinal, cluster)| { + depth = depth.saturating_add(difference.get(ordinal).copied().unwrap_or(0)); + depth == 0 && &text.source()[cluster.range()] == "\t" + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::construct::{Ruby, RubyKind, RubyRun}; + use crate::model::{Cluster, Frame, Size}; + + fn text(source: &str) -> ShapedText { + let clusters = source.char_indices().map(|(start, character)| { + Cluster::new(start..start.saturating_add(character.len_utf8()), 500) + }); + ShapedText::new( + source, + Size::square(1_000).expect("positive size"), + Frame::FullEm, + clusters, + ) + .expect("valid shaped text") + } + + fn proportional_one_cluster(source: &str) -> ShapedText { + ShapedText::new( + source, + Size::square(1_000).expect("positive size"), + Frame::Proportional, + [Cluster::new(0..source.len(), 500)], + ) + .expect("valid proportional cluster") + } + + #[test] + fn break_and_paragraph_accessors_preserve_declared_values() { + let allowed = Break::allowed(1); + let mandatory = Break::mandatory(2); + let discretionary = Break::discretionary(3); + assert_eq!(allowed.offset(), 1); + assert!(!allowed.is_mandatory()); + assert!(!allowed.is_discretionary()); + assert_eq!(mandatory.offset(), 2); + assert!(mandatory.is_mandatory()); + assert!(!mandatory.is_discretionary()); + assert_eq!(discretionary.offset(), 3); + assert!(!discretionary.is_mandatory()); + assert!(discretionary.is_discretionary()); + + let paragraph = Paragraph::builder(text("ab"), 2_000) + .breaks([Break::allowed(1)]) + .constructs([Construct::emphasis_dots(0..1, '・')]) + .tab_stops([TabStop::new(700, TabAlignment::End).expect("valid stop")]) + .first_line_indent(123) + .alignment(Alignment::End) + .widow(Widow::MinimumClusters(2)) + .writing_mode(WritingMode::VerticalRl) + .build() + .expect("valid paragraph"); + assert_eq!(paragraph.line_extent(), 2_000); + assert_eq!(paragraph.breaks().len(), 2); + assert_eq!(paragraph.constructs().len(), 1); + assert_eq!(paragraph.tab_stops().len(), 1); + assert_eq!(paragraph.first_line_indent(), 123); + assert_eq!(paragraph.alignment(), Alignment::End); + assert_eq!(paragraph.widow(), Widow::MinimumClusters(2)); + assert_eq!(paragraph.writing_mode(), WritingMode::VerticalRl); + } + + #[test] + fn indent_and_construct_range_guards_are_independent() { + for indent in [-1, 1_000] { + let error = Paragraph::builder(text("a"), 1_000) + .first_line_indent(indent) + .build() + .expect_err("invalid indent"); + assert_eq!(error.code(), "input.invalid-indent"); + } + + for construct in [ + Construct::emphasis_dots(0..0, '・'), + Construct::emphasis_dots(0..2, '・'), + ] { + let error = Paragraph::builder(text("a"), 1_000) + .constructs([construct]) + .build() + .expect_err("invalid construct range"); + assert_eq!(error.code(), "input.construct-out-of-range"); + } + + let error = Paragraph::builder(proportional_one_cluster("ab"), 1_000) + .constructs([Construct::emphasis_dots(0..1, '・')]) + .build() + .expect_err("construct splits shaped cluster"); + assert_eq!(error.code(), "input.construct-splits-cluster"); + } + + #[test] + fn ruby_and_special_construct_guards_are_independent() { + let annotation = text("xy"); + let split_run_ruby = Ruby::new( + RubyKind::Jukugo, + 0..2, + annotation.clone(), + [RubyRun::new(0..1, 0..1), RubyRun::new(1..2, 1..2)], + ) + .expect("document-level ruby runs"); + let error = Paragraph::builder(proportional_one_cluster("ab"), 1_000) + .constructs([Construct::ruby(split_run_ruby)]) + .build() + .expect_err("ruby run splits the shaped base"); + assert_eq!(error.code(), "input.ruby-run-splits-cluster"); + + let mono = Ruby::new(RubyKind::Mono, 0..2, text("x"), [RubyRun::new(0..2, 0..1)]) + .expect("document-level mono ruby"); + let error = Paragraph::builder(text("ab"), 1_000) + .constructs([Construct::ruby(mono)]) + .build() + .expect_err("mono run covers two clusters"); + assert_eq!(error.code(), "input.mono-ruby-run-shape"); + + let cases = [ + ( + Construct::furawake(0..1, 0, 0), + "input.invalid-furawake-columns", + ), + ( + Construct::furawake(0..1, 1, -1), + "input.invalid-furawake-line-gap", + ), + (Construct::jidori(0..1, 0), "input.invalid-jidori-cells"), + ]; + for (construct, code) in cases { + let error = Paragraph::builder(text("a"), 1_000) + .constructs([construct]) + .build() + .expect_err("invalid special construct"); + assert_eq!(error.code(), code); + } + } + + #[test] + fn construct_stack_accepts_adjacency_and_rejects_crossing() { + let adjacent = Paragraph::builder(text("abc"), 1_000) + .constructs([ + Construct::emphasis_dots(0..1, '・'), + Construct::emphasis_dots(1..2, '・'), + ]) + .build(); + assert!(adjacent.is_ok()); + + let shared_end = Paragraph::builder(text("abc"), 1_000) + .constructs([ + Construct::emphasis_dots(0..2, '・'), + Construct::reference_mark(1..2, text("※")), + ]) + .build(); + assert!(shared_end.is_ok()); + + let crossing = Paragraph::builder(text("abc"), 1_000) + .constructs([ + Construct::emphasis_dots(0..2, '・'), + Construct::emphasis_dots(1..3, '・'), + ]) + .build() + .expect_err("crossing constructs"); + assert_eq!(crossing.code(), "input.crossing-constructs"); + } + + #[test] + fn generated_tab_breaks_respect_start_blocking_and_existing_offsets() { + let shaped = text("\tab"); + let mut breaks = vec![Break::allowed(1)]; + let line_tabs = vec![true, true, false]; + validate_breaks(&shaped, &[], &line_tabs, &mut breaks).expect("valid tab breaks"); + assert_eq!( + breaks + .iter() + .map(|opportunity| opportunity.offset()) + .collect::>(), + vec![1, 3] + ); + + let shaped = text("a\tb"); + let construct = Construct::tate_chu_yoko(0..3); + let mut breaks = Vec::new(); + validate_breaks(&shaped, &[construct], &[false, true, false], &mut breaks) + .expect("blocked tab boundary is not generated"); + assert_eq!( + breaks + .iter() + .map(|opportunity| opportunity.offset()) + .collect::>(), + vec![3] + ); + } + + #[test] + fn mandatory_break_resets_tab_stop_consumption() { + let shaped = text("\tA\t"); + let mut stops = vec![TabStop::new(500, TabAlignment::Start).expect("valid stop")]; + validate_tabs( + &shaped, + &[Break::mandatory(2), Break::mandatory(3)], + &[true, false, true], + 1_000, + &mut stops, + ) + .expect("one stop is reusable after a mandatory break"); + + let before_break = text("\t\tA"); + let error = validate_tabs( + &before_break, + &[Break::mandatory(2)], + &[true, true, false], + 1_000, + &mut vec![TabStop::new(500, TabAlignment::Start).expect("valid stop")], + ) + .expect_err("two tabs before the break need two stops"); + assert_eq!(error.code(), "input.insufficient-tab-stops"); + } +} diff --git a/crates/jlreq/src/pipeline.rs b/crates/jlreq/src/pipeline.rs index bfb7e9c..9a5213e 100644 --- a/crates/jlreq/src/pipeline.rs +++ b/crates/jlreq/src/pipeline.rs @@ -12,6 +12,7 @@ use crate::layout::{ Attachment, ClusterPlacement, CoordinateTransform, Diagnostic, Layout, Line, PlacementOrigin, Severity, }; +use crate::limits::{ComposeError, CompositionLimits, CompositionResource}; use crate::model::{ClusterRole, Frame, Size, WritingMode}; use crate::paragraph::{Alignment, Paragraph, TabAlignment, TabStop, Widow}; use crate::style::{ @@ -23,7 +24,7 @@ use crate::style::{ UnlistedCodePoint, }; -const INFINITE_COST: i64 = i64::MAX / 4; +const INFINITE_COST: u128 = u128::MAX; #[derive(Debug, Clone, Copy)] struct Candidate { @@ -34,11 +35,46 @@ struct Candidate { #[derive(Debug, Clone, Copy)] struct Node { - cost: i64, + cost: u128, previous: usize, line_count: usize, } +#[derive(Debug, Default)] +struct PreparedParagraph { + candidate_ordinals: Vec, + legal_candidates: Vec, + natural_prefix: Vec, + minimum_prefix: Vec, + reduction_prefix: Vec, + line_end_reduction: Vec, + regular: bool, +} + +impl PreparedParagraph { + const fn new() -> Self { + Self { + candidate_ordinals: Vec::new(), + legal_candidates: Vec::new(), + natural_prefix: Vec::new(), + minimum_prefix: Vec::new(), + reduction_prefix: Vec::new(), + line_end_reduction: Vec::new(), + regular: false, + } + } + + fn clear(&mut self) { + self.candidate_ordinals.clear(); + self.legal_candidates.clear(); + self.natural_prefix.clear(); + self.minimum_prefix.clear(); + self.reduction_prefix.clear(); + self.line_end_reduction.clear(); + self.regular = false; + } +} + #[derive(Debug, Clone)] struct WarichuSegment { range: Range, @@ -125,11 +161,14 @@ struct LineContext { #[derive(Debug, Default)] #[non_exhaustive] pub struct Composer { + limits: CompositionLimits, + transitions: usize, candidates: Vec, nodes: Vec, chosen: Vec, line_advances: Vec, line_adjustments: Vec, + prepared: PreparedParagraph, } impl Composer { @@ -137,24 +176,105 @@ impl Composer { #[must_use] pub const fn new() -> Self { Self { + limits: CompositionLimits::DEFAULT, + transitions: 0, candidates: Vec::new(), nodes: Vec::new(), chosen: Vec::new(), line_advances: Vec::new(), line_adjustments: Vec::new(), + prepared: PreparedParagraph::new(), } } - /// Normalize, choose breaks globally, and place one validated paragraph. + /// Build a reusable composer with explicit deterministic resource limits. + #[must_use] + pub const fn with_limits(limits: CompositionLimits) -> Self { + Self { + limits, + transitions: 0, + candidates: Vec::new(), + nodes: Vec::new(), + chosen: Vec::new(), + line_advances: Vec::new(), + line_adjustments: Vec::new(), + prepared: PreparedParagraph::new(), + } + } + + /// The limits used by subsequent composition calls. #[must_use] - pub fn compose(&mut self, paragraph: &Paragraph, style: &Style) -> Layout { + pub const fn limits(&self) -> CompositionLimits { + self.limits + } + + /// Replace the limits used by subsequent composition calls. + pub const fn set_limits(&mut self, limits: CompositionLimits) { + self.limits = limits; + } + + /// Normalize, choose breaks globally, and place one validated paragraph. + pub fn compose( + &mut self, + paragraph: &Paragraph, + style: &Style, + ) -> Result { + self.reset_for_call(); if paragraph.text.clusters().is_empty() { - return Layout::default(); + return Ok(Layout::default()); } + self.check_static_limits(paragraph)?; self.prepare_candidates(paragraph); - self.search(paragraph, style); + self.prepare_indexes(paragraph, style); + self.search(paragraph, style)?; self.backtrack(); - self.place(paragraph, style) + Ok(self.place(paragraph, style)) + } + + fn reset_for_call(&mut self) { + self.transitions = 0; + self.candidates.clear(); + self.nodes.clear(); + self.chosen.clear(); + self.line_advances.clear(); + self.line_adjustments.clear(); + self.prepared.clear(); + } + + fn check_static_limits(&self, paragraph: &Paragraph) -> Result<(), ComposeError> { + check_limit( + CompositionResource::Clusters, + self.limits.max_clusters(), + paragraph.text.clusters().len(), + )?; + check_limit( + CompositionResource::BreakCandidates, + self.limits.max_break_candidates(), + paragraph.breaks.len(), + )?; + check_limit( + CompositionResource::Constructs, + self.limits.max_constructs(), + paragraph.constructs.len(), + )?; + check_limit( + CompositionResource::TabStops, + self.limits.max_tab_stops(), + paragraph.tab_stops.len(), + ) + } + + fn charge_transitions(&mut self, amount: usize) -> Result<(), ComposeError> { + let observed = self.transitions.saturating_add(amount); + if observed > self.limits.max_search_transitions() { + return Err(ComposeError::new( + CompositionResource::SearchTransitions, + self.limits.max_search_transitions(), + observed, + )); + } + self.transitions = observed; + Ok(()) } fn prepare_candidates(&mut self, paragraph: &Paragraph) { @@ -177,7 +297,105 @@ impl Composer { ); } - fn search(&mut self, paragraph: &Paragraph, style: &Style) { + fn prepare_indexes(&mut self, paragraph: &Paragraph, style: &Style) { + self.prepared.candidate_ordinals.extend( + self.candidates + .iter() + .map(|candidate| cluster_index_at_or_after(paragraph, candidate.offset)), + ); + self.prepared + .legal_candidates + .extend(self.candidates.iter().map(|candidate| { + candidate.mandatory || break_is_legal(paragraph, style, candidate.offset) + })); + + self.prepared.regular = paragraph.constructs.is_empty() + && !paragraph + .line_tabs + .iter() + .copied() + .any(core::convert::identity); + if !self.prepared.regular { + return; + } + + let cluster_count = paragraph.text.clusters().len(); + self.prepared + .natural_prefix + .reserve(cluster_count.saturating_add(1)); + self.prepared + .minimum_prefix + .reserve(cluster_count.saturating_add(1)); + self.prepared + .reduction_prefix + .reserve(cluster_count.saturating_add(1)); + self.prepared.line_end_reduction.reserve(cluster_count); + self.prepared.natural_prefix.push(0); + self.prepared.minimum_prefix.push(0); + self.prepared.reduction_prefix.push(0); + + for ordinal in 0..cluster_count { + let natural = i64::from(effective_cluster_advance(paragraph, style, ordinal)); + let previous_natural = self.prepared.natural_prefix.last().copied().unwrap_or(0); + self.prepared + .natural_prefix + .push(previous_natural.saturating_add(natural)); + + let minimum = if is_western_word_space(paragraph, ordinal) { + 0 + } else { + i64::from(paragraph.text.clusters()[ordinal].advance()) + }; + let previous_minimum = self.prepared.minimum_prefix.last().copied().unwrap_or(0); + self.prepared + .minimum_prefix + .push(previous_minimum.saturating_add(minimum)); + + let mut sites = Vec::new(); + if paragraph + .text + .clusters() + .get(ordinal.saturating_add(1)) + .is_some() + { + if is_western_word_space(paragraph, ordinal) { + let cluster = ¶graph.text.clusters()[ordinal]; + let capacity = effective_cluster_body_advance(paragraph, ordinal) + .saturating_sub(quarter_inline_size(paragraph, cluster)) + .max(0); + push_reduction_site( + &mut sites, + 0, + cluster + .size_override() + .unwrap_or(paragraph.text.size()) + .inline(), + capacity, + 1, + false, + ); + } + append_table_reduction_sites(paragraph, style, ordinal, 0, &mut sites); + } + let capacity = sites.iter().fold(0_i64, |sum, site| { + sum.saturating_add(i64::from(site.capacity)) + }); + let previous_capacity = self.prepared.reduction_prefix.last().copied().unwrap_or(0); + self.prepared + .reduction_prefix + .push(previous_capacity.saturating_add(capacity)); + + sites.clear(); + append_line_end_reduction_site(paragraph, style, ordinal, 0, &mut sites); + self.prepared + .line_end_reduction + .push(sites.iter().fold(0_i64, |sum, site| { + sum.saturating_add(i64::from(site.capacity)) + })); + } + } + + fn search(&mut self, paragraph: &Paragraph, style: &Style) -> Result<(), ComposeError> { self.nodes.clear(); self.nodes.resize( self.candidates.len(), @@ -193,70 +411,116 @@ impl Composer { line_count: 0, }; + let mut mandatory_partition_start = 0_usize; for end in 1..self.candidates.len() { let candidate = self.candidates[end]; - if !candidate.mandatory && !break_is_legal(paragraph, style, candidate.offset) { + if !self.prepared.legal_candidates[end] { continue; } - for start in 0..end { - if self.nodes[start].cost == INFINITE_COST - || self.candidates[start.saturating_add(1)..end] - .iter() - .any(|inner| inner.mandatory) - { + for start in (mandatory_partition_start..end).rev() { + self.charge_transitions(1)?; + if self.nodes[start].cost == INFINITE_COST { continue; } let line_number = self.nodes[start].line_count; - let measured_width = measure_line( - paragraph, - style, - self.candidates[start].offset, - candidate.offset, - line_number, - ); + let start_ordinal = self.prepared.candidate_ordinals[start]; + let end_ordinal = self.prepared.candidate_ordinals[end]; + if !self.prepared.regular { + self.charge_transitions( + end_ordinal + .saturating_sub(start_ordinal) + .saturating_add(paragraph.constructs.len()), + )?; + } + let measured_width = if self.prepared.regular { + fast_measure_line( + &self.prepared, + paragraph, + style, + start_ordinal, + end_ordinal, + line_number, + ) + } else { + measure_line( + paragraph, + style, + self.candidates[start].offset, + candidate.offset, + line_number, + ) + }; let available = i64::from(paragraph.line_extent); - let width = width_after_available_reduction( - paragraph, - style, - self.candidates[start].offset, - candidate.offset, - measured_width, - available, - ); + let width = if self.prepared.regular { + fast_width_after_available_reduction( + &self.prepared, + paragraph, + style, + start_ordinal, + end_ordinal, + measured_width, + available, + ) + } else { + width_after_available_reduction( + paragraph, + style, + self.candidates[start].offset, + candidate.offset, + measured_width, + available, + ) + }; let delta = available.saturating_sub(width); let is_last = end.saturating_add(1) == self.candidates.len(); - let mut cost = line_badness(delta, is_last, style.adjustment_preference()); + let mut edge_cost = + non_negative_cost(line_badness(delta, is_last, style.adjustment_preference())); if candidate.discretionary { - cost = cost.saturating_add(100_000); + edge_cost = edge_cost.saturating_add(100_000); } - cost = cost.saturating_add(warichu_break_penalty(paragraph, candidate.offset)); - cost = cost.saturating_add(formula_break_penalty(paragraph, candidate.offset)); + edge_cost = edge_cost.saturating_add(non_negative_cost(warichu_break_penalty( + paragraph, + candidate.offset, + ))); + edge_cost = edge_cost.saturating_add(non_negative_cost(formula_break_penalty( + paragraph, + candidate.offset, + ))); if is_last { - cost = cost.saturating_add(widow_penalty( + edge_cost = edge_cost.saturating_add(non_negative_cost(widow_penalty( paragraph, self.candidates[start].offset, candidate.offset, - )); + ))); } - cost = cost.saturating_add(self.nodes[start].cost); - if cost < self.nodes[end].cost { + let cost = edge_cost.saturating_add(self.nodes[start].cost); + if search_candidate_precedes(cost, start, self.nodes[end]) { self.nodes[end] = Node { cost, previous: start, line_count: line_number.saturating_add(1), }; } - } - } - let last = self.nodes.len().saturating_sub(1); - if self.nodes[last].cost == INFINITE_COST { - self.nodes[last] = Node { - cost: 0, - previous: 0, - line_count: 1, - }; + if self.prepared.regular { + let minimum_width = + fast_minimum_width(&self.prepared, start_ordinal, end_ordinal); + if search_lower_bound_exceeds( + minimum_width, + available, + is_last, + style.adjustment_preference(), + self.nodes[end].cost, + ) { + break; + } + } + } + if candidate.mandatory { + mandatory_partition_start = end; + } } + Ok(()) } fn backtrack(&mut self) { @@ -264,11 +528,7 @@ impl Composer { let mut cursor = self.nodes.len().saturating_sub(1); self.chosen.push(cursor); while cursor != 0 { - let previous = self.nodes[cursor].previous; - if previous == cursor { - break; - } - cursor = previous; + cursor = self.nodes[cursor].previous; self.chosen.push(cursor); } self.chosen.reverse(); @@ -357,20 +617,13 @@ impl Composer { Alignment::Center => remaining.max(0) / 2, Alignment::End => remaining.max(0), }; - let justify = paragraph.alignment == Alignment::Justify - && !is_last - && remaining > 0 - && clusters.len() > 1; + let justify = line_should_justify(paragraph.alignment, is_last, remaining, clusters.len()); prepare_line_adjustments( paragraph, style, start_cluster, end_cluster, - if remaining < 0 || justify { - remaining - } else { - 0 - }, + line_adjustment_need(remaining, justify), &mut self.line_adjustments, ); @@ -494,6 +747,135 @@ impl Composer { } } +fn check_limit( + resource: CompositionResource, + limit: usize, + observed: usize, +) -> Result<(), ComposeError> { + if observed > limit { + Err(ComposeError::new(resource, limit, observed)) + } else { + Ok(()) + } +} + +fn non_negative_cost(cost: i64) -> u128 { + u128::try_from(cost).unwrap_or(0) +} + +fn search_candidate_precedes(cost: u128, start: usize, current: Node) -> bool { + (cost, start) < (current.cost, current.previous) +} + +fn search_lower_bound_exceeds( + minimum_width: i64, + available: i64, + is_last: bool, + preference: AdjustmentPreference, + best_cost: u128, +) -> bool { + match minimum_width.cmp(&available) { + core::cmp::Ordering::Greater => { + non_negative_cost(line_badness( + available.saturating_sub(minimum_width), + is_last, + preference, + )) > best_cost + }, + core::cmp::Ordering::Less | core::cmp::Ordering::Equal => false, + } +} + +fn line_should_justify( + alignment: Alignment, + is_last: bool, + remaining: i64, + cluster_count: usize, +) -> bool { + alignment == Alignment::Justify && !is_last && remaining.is_positive() && cluster_count > 1 +} + +fn line_adjustment_need(remaining: i64, justify: bool) -> i64 { + if remaining.is_negative() || justify { + remaining + } else { + 0 + } +} + +fn fast_measure_line( + prepared: &PreparedParagraph, + paragraph: &Paragraph, + style: &Style, + start: usize, + end: usize, + line_number: usize, +) -> i64 { + if start >= end { + return i64::from(line_head_indent(paragraph, style, start, line_number)); + } + let mut width = range_sum(&prepared.natural_prefix, start, end); + let last = end.saturating_sub(1); + let last_natural = range_sum(&prepared.natural_prefix, last, end); + if is_western_word_space(paragraph, last) { + width = width.saturating_sub(last_natural); + } else { + width = width + .saturating_sub(last_natural) + .saturating_add(i64::from(effective_cluster_body_advance(paragraph, last))) + .saturating_add(i64::from(line_end_space_after(paragraph, style, last))); + } + if start != last && is_western_word_space(paragraph, start) { + width = width.saturating_sub(range_sum( + &prepared.natural_prefix, + start, + start.saturating_add(1), + )); + } + width.saturating_add(i64::from(line_head_indent( + paragraph, + style, + start, + line_number, + ))) +} + +fn fast_width_after_available_reduction( + prepared: &PreparedParagraph, + paragraph: &Paragraph, + style: &Style, + start: usize, + end: usize, + width: i64, + available: i64, +) -> i64 { + let need = width.saturating_sub(available); + if need <= 0 || start >= end { + return width; + } + let last = end.saturating_sub(1); + let internal_capacity = range_sum(&prepared.reduction_prefix, start, last); + let line_end_capacity = prepared.line_end_reduction.get(last).copied().unwrap_or(0); + let capacity = internal_capacity.saturating_add(line_end_capacity); + let reduced = width.saturating_sub(need.min(capacity)); + reduced.saturating_sub(hanging_amount(paragraph, style, end, reduced, available)) +} + +fn fast_minimum_width(prepared: &PreparedParagraph, start: usize, end: usize) -> i64 { + let Some(last) = end.checked_sub(1) else { + return 0; + }; + range_sum(&prepared.minimum_prefix, start, last) +} + +fn range_sum(prefix: &[i64], start: usize, end: usize) -> i64 { + prefix + .get(end) + .copied() + .unwrap_or(i64::MAX) + .saturating_sub(prefix.get(start).copied().unwrap_or(0)) +} + fn cluster_index_at_or_after(paragraph: &Paragraph, offset: usize) -> usize { paragraph .text @@ -674,12 +1056,9 @@ fn boundary_expansion_site(paragraph: &Paragraph, style: &Style, before: usize) | ConstructKind::Furawake { range, .. } | ConstructKind::Jidori { range, .. } | ConstructKind::Script { range, .. } => range.start < boundary && boundary < range.end, - ConstructKind::Ruby(ruby) => { - ruby.kind() != RubyKind::Mono - && ruby.base().start < boundary - && boundary < ruby.base().end - }, - _ => false, + ConstructKind::Ruby(_) + | ConstructKind::Emphasis { .. } + | ConstructKind::ReferenceMark { .. } => false, }) { return ExpansionSite::None; @@ -725,7 +1104,6 @@ fn boundary_expansion_site(paragraph: &Paragraph, style: &Style, before: usize) after_solid, ); let weight = match components { - [amount, 0] if amount > 0 => before_size.inline(), [0, amount] if amount > 0 => after_size.inline(), _ => before_size.inline(), }; @@ -748,7 +1126,7 @@ fn boundary_expansion_site(paragraph: &Paragraph, style: &Style, before: usize) crate::spec::scale_spec_units(weight, limit), ); let cap = ceiling.saturating_sub(current).max(0); - if cap == 0 || cell.stage == 0 { + if cap == 0 { ExpansionSite::None } else { ExpansionSite::Site { @@ -766,7 +1144,7 @@ fn expansion_ceiling(style: &Style, before: u8, after: u8, weight: i32, table: i match style.japanese_latin_expansion_ceiling() { JapaneseLatinExpansionCeiling::HalfEm => half_rounded_up(weight), JapaneseLatinExpansionCeiling::ThirdEm => { - (weight / 3).saturating_add(i32::from(weight % 3 != 0)) + weight.saturating_add(2).checked_div(3).unwrap_or(0) }, JapaneseLatinExpansionCeiling::Rigid => quarter_rounded_up(weight), } @@ -853,19 +1231,20 @@ fn prepare_line_adjustments( ) { adjustments.clear(); adjustments.resize(line_end.saturating_sub(line_start), 0); - if adjustments.is_empty() || need == 0 { - return; - } - if need < 0 { - prepare_line_reductions( - paragraph, - style, - line_start, - line_end, - need.saturating_abs(), - adjustments, - ); - return; + match need.cmp(&0) { + core::cmp::Ordering::Less => { + prepare_line_reductions( + paragraph, + style, + line_start, + line_end, + need.saturating_abs(), + adjustments, + ); + return; + }, + core::cmp::Ordering::Equal => return, + core::cmp::Ordering::Greater => {}, } let sites: Vec<_> = (line_start..line_end.saturating_sub(1)) @@ -931,14 +1310,11 @@ fn prepare_line_reductions( if need <= 0 { break; } - let mut discrete: Vec<_> = sites + let discrete: Vec<_> = sites .iter() .copied() .filter(|site| site.stage == stage && site.discrete) .collect(); - if style.remainder() == Remainder::Trailing { - discrete.reverse(); - } for site in discrete { if need <= 0 { break; @@ -1165,13 +1541,12 @@ fn append_table_reduction_sites( return; }; let active = match components { - [amount, 0] if amount > 0 => Some((amount, before_size.inline())), - [0, amount] if amount > 0 => Some((amount, after_size.inline())), + [amount, 0] => Some((amount, before_size.inline())), + [0, amount] => Some((amount, after_size.inline())), _ => None, }; - let (amount, weight) = match (active, cell.limit) { - (Some(active), Some(_)) if cell.stage != 0 => active, - _ => return, + let (Some((amount, weight)), Some(_)) = (active, cell.limit) else { + return; }; let floor = crate::spec::scale_spec_units(weight, cell.limit.unwrap_or(0)); push_reduction_site( @@ -1256,7 +1631,7 @@ fn distribute_reduction( remainder: Remainder, adjustments: &mut [i32], ) { - if amount <= 0 || sites.is_empty() { + if amount <= 0 { return; } let weight_sum = sites.iter().fold(0_i64, |sum, site| { @@ -1272,46 +1647,95 @@ fn distribute_reduction( .min(i64::from(site.capacity)) }) .collect(); - let mut left = amount.saturating_sub( + let left = amount.saturating_sub( assigned .iter() .fold(0_i64, |sum, take| sum.saturating_add(*take)), ); - while left > 0 { - let mut progressed = false; - match remainder { - Remainder::Leading => { - for (site, take) in sites.iter().zip(&mut assigned) { - if left == 0 { - break; - } - if *take < i64::from(site.capacity) { - *take = take.saturating_add(1); - left = left.saturating_sub(1); - progressed = true; - } - } - }, - Remainder::Trailing => { - for (site, take) in sites.iter().zip(&mut assigned).rev() { - if left == 0 { - break; - } - if *take < i64::from(site.capacity) { - *take = take.saturating_add(1); - left = left.saturating_sub(1); - progressed = true; - } - } - }, + let capacities: Vec<_> = sites + .iter() + .zip(&assigned) + .map(|(site, take)| i64::from(site.capacity).saturating_sub(*take).max(0)) + .collect(); + let extra = capped_round_robin(left, &capacities, remainder); + for (take, addition) in assigned.iter_mut().zip(extra) { + *take = take.saturating_add(addition); + } + for (site, take) in sites.iter().zip(assigned) { + apply_reduction(site.boundary, take, adjustments); + } +} + +fn capped_round_robin(amount: i64, capacities: &[i64], remainder: Remainder) -> Vec { + let total_capacity = capacities.iter().fold(0_i64, |sum, capacity| { + sum.saturating_add((*capacity).max(0)) + }); + let target = amount.max(0).min(total_capacity); + let maximum_rounds = capacities + .iter() + .copied() + .map(|capacity| capacity.max(0)) + .max() + .unwrap_or(0) + .min(target); + + let mut lower = 0_i64; + let mut upper = maximum_rounds; + for _ in 0..64 { + let distance = upper.saturating_sub(lower); + if distance == 0 { + break; + } + let previous = (lower, upper); + let rounds = lower + .saturating_add(distance / 2) + .saturating_add(distance % 2); + let consumed = capacities.iter().fold(0_i64, |sum, capacity| { + sum.saturating_add((*capacity).max(0).min(rounds)) + }); + if consumed <= target { + lower = rounds; + } else { + upper = rounds.saturating_sub(1); } - if !progressed { + if (lower, upper) == previous { break; } } - for (site, take) in sites.iter().zip(assigned) { - apply_reduction(site.boundary, take, adjustments); + + let mut shares: Vec<_> = capacities + .iter() + .map(|capacity| (*capacity).max(0).min(lower)) + .collect(); + let placed = shares + .iter() + .fold(0_i64, |sum, share| sum.saturating_add(*share)); + let mut left = target.saturating_sub(placed); + match remainder { + Remainder::Leading => { + for (capacity, share) in capacities.iter().zip(&mut shares) { + if left == 0 { + break; + } + if (*capacity).max(0) > lower { + *share = share.saturating_add(1); + left = left.saturating_sub(1); + } + } + }, + Remainder::Trailing => { + for (capacity, share) in capacities.iter().zip(&mut shares).rev() { + if left == 0 { + break; + } + if (*capacity).max(0) > lower { + *share = share.saturating_add(1); + left = left.saturating_sub(1); + } + } + }, } + shares } fn apply_reduction(boundary: usize, amount: i64, adjustments: &mut [i32]) { @@ -1326,62 +1750,45 @@ fn distribute_adjustment( remainder: Remainder, adjustments: &mut [i32], ) { - if amount <= 0 || sites.is_empty() { + if amount <= 0 { return; } let weight_sum = sites.iter().fold(0_i64, |sum, (_, weight, _)| { sum.saturating_add(i64::from((*weight).max(1))) }); + let assigned: Vec<_> = sites + .iter() + .map(|&(_, weight, cap)| { + let proportional = amount + .saturating_mul(i64::from(weight.max(1))) + .checked_div(weight_sum.max(1)) + .unwrap_or(0); + cap.map_or(proportional, |cap| proportional.min(i64::from(cap))) + }) + .collect(); let mut placed = 0_i64; - for &(index, weight, cap) in sites { - let proportional = amount - .saturating_mul(i64::from(weight.max(1))) - .checked_div(weight_sum.max(1)) - .unwrap_or(0); - let share = cap.map_or(proportional, |cap| proportional.min(i64::from(cap))); + for (&(index, _, _), share) in sites.iter().zip(&assigned) { if let Some(adjustment) = adjustments.get_mut(index) { - *adjustment = adjustment.saturating_add(clamp_i32(share)); - placed = placed.saturating_add(share); + *adjustment = adjustment.saturating_add(clamp_i32(*share)); + placed = placed.saturating_add(*share); } } - let mut left = amount.saturating_sub(placed); - while left > 0 { - let mut progressed = false; - match remainder { - Remainder::Leading => { - for &(index, _, cap) in sites { - if left == 0 { - break; - } - let Some(adjustment) = adjustments.get_mut(index) else { - continue; - }; - if cap.is_none_or(|cap| *adjustment < cap) { - *adjustment = adjustment.saturating_add(1); - left = left.saturating_sub(1); - progressed = true; - } - } - }, - Remainder::Trailing => { - for &(index, _, cap) in sites.iter().rev() { - if left == 0 { - break; - } - let Some(adjustment) = adjustments.get_mut(index) else { - continue; - }; - if cap.is_none_or(|cap| *adjustment < cap) { - *adjustment = adjustment.saturating_add(1); - left = left.saturating_sub(1); - progressed = true; - } - } - }, - } - if !progressed { - break; + let left = amount.saturating_sub(placed); + let capacities: Vec<_> = sites + .iter() + .map(|&(index, _, cap)| { + adjustments.get(index).map_or(0, |adjustment| { + cap.map_or(left, |cap| { + i64::from(cap).saturating_sub(i64::from(*adjustment)).max(0) + }) + }) + }) + .collect(); + let extra = capped_round_robin(left, &capacities, remainder); + for (&(index, _, _), addition) in sites.iter().zip(extra) { + if let Some(adjustment) = adjustments.get_mut(index) { + *adjustment = adjustment.saturating_add(clamp_i32(addition)); } } } @@ -1710,7 +2117,7 @@ fn phonetic_jukugo_plan( for run in ruby.runs() { let base = cluster_index_at_or_after(paragraph, run.base().start) ..cluster_index_at_or_after(paragraph, run.base().end); - if base.start < line_start || base.end > line_end || base.start >= base.end { + if base.start < line_start || base.end > line_end { continue; } let (annotation_count, annotation_width, ruby_em) = @@ -1763,25 +2170,26 @@ fn phonetic_jukugo_plan( leading_allowance, trailing_allowance, }; - let maximum_expansion = - runs.iter() - .filter(|run| run.annotation_count > 2) - .fold(0_i64, |sum, run| { - let base_width = run.base_end.saturating_sub(run.base_start); - sum.saturating_add(run.annotation_width.saturating_sub(base_width).max(0)) - }); + let maximum_expansion = runs.iter().fold(0_i64, |sum, run| { + let base_width = run.base_end.saturating_sub(run.base_start); + sum.saturating_add(run.annotation_width.saturating_sub(base_width).max(0)) + }); build_phonetic_jukugo_plan(paragraph, style, &runs, 0, edges).or_else(|| { let mut lower = 1_i64; let mut upper = maximum_expansion; let upper_plan = build_phonetic_jukugo_plan(paragraph, style, &runs, upper, edges)?; while lower < upper { + let previous = (lower, upper); let middle = lower.saturating_add(upper.saturating_sub(lower) / 2); if build_phonetic_jukugo_plan(paragraph, style, &runs, middle, edges).is_some() { upper = middle; } else { lower = middle.saturating_add(1); } + if (lower, upper) == previous { + return Some(upper_plan); + } } if lower == maximum_expansion { Some(upper_plan) @@ -1802,7 +2210,7 @@ fn build_phonetic_jukugo_plan( let mut before = alloc::vec![0_i64; raw_runs.len()]; let mut after = alloc::vec![0_i64; raw_runs.len()]; for (index, (run, amount)) in raw_runs.iter().zip(assigned).enumerate() { - if run.annotation_count <= 2 || amount == 0 { + if amount == 0 { continue; } if run.base.start == edges.line.start && run.base.end != edges.line.end { @@ -1855,7 +2263,13 @@ fn build_phonetic_jukugo_plan( phonetic_gap_after(&gaps_after, first.base.start.saturating_sub(1)) }; let mut runs = Vec::with_capacity(raw_runs.len()); - for (index, raw) in raw_runs.iter().enumerate() { + let mut inter_run_boundary = None; + for raw in raw_runs { + if let Some(boundary) = inter_run_boundary { + cursor = cursor + .saturating_add(i64::from(boundary_space_after(paragraph, boundary))) + .saturating_add(phonetic_gap_after(&gaps_after, boundary)); + } let base_start = cursor; let mut base_end = base_start; for ordinal in raw.base.clone() { @@ -1869,12 +2283,7 @@ fn build_phonetic_jukugo_plan( .saturating_add(phonetic_gap_after(&gaps_after, ordinal)); } } - if index.saturating_add(1) < raw_runs.len() { - let boundary = raw.base.end.saturating_sub(1); - cursor = cursor - .saturating_add(i64::from(boundary_space_after(paragraph, boundary))) - .saturating_add(phonetic_gap_after(&gaps_after, boundary)); - } + inter_run_boundary = Some(raw.base.end.saturating_sub(1)); runs.push(PhoneticJukugoRun { base: raw.base.clone(), annotation: raw.annotation.clone(), @@ -2142,18 +2551,10 @@ fn ruby_span_overhang( } let base = cluster_index_at_or_after(paragraph, base.start) ..cluster_index_at_or_after(paragraph, base.end); - if base.start < line_start || base.end > line_end || base.start >= base.end { + if base.start < line_start || base.end > line_end { return None; } - let base_width = base.clone().fold(0_i64, |sum, ordinal| { - let body = i64::from(effective_cluster_body_advance(paragraph, ordinal)); - let boundary = if ordinal.saturating_add(1) < base.end { - i64::from(boundary_space_after_with_style(paragraph, style, ordinal)) - } else { - 0 - }; - sum.saturating_add(body).saturating_add(boundary) - }); + let base_width = ruby_base_width(paragraph, style, &base); let mut annotation_width = 0_i64; let mut ruby_em = 0_i32; for cluster in ruby.annotation().clusters().iter().filter(|cluster| { @@ -2485,10 +2886,7 @@ fn ordinary_boundary_space_after_with_style( current_solid, following_solid, ); - let table_is_blank = crate::generated::table1::CELLS - .iter() - .find(|cell| cell.before == before && cell.after == after) - .is_none_or(|cell| cell.terms.is_empty()); + let table_is_blank = table_one_cell_is_blank(before, after); if !table_is_blank || style.sentence_medial_dividing_mark() != SentenceMedialDividingMark::QuarterEm { @@ -2509,6 +2907,13 @@ fn ordinary_boundary_space_after_with_style( .saturating_add(after_quarter) } +fn table_one_cell_is_blank(before: u8, after: u8) -> bool { + crate::generated::table1::CELLS + .iter() + .find(|cell| cell.before == before && cell.after == after) + .is_none_or(|cell| cell.terms.is_empty()) +} + fn class_of_cluster(paragraph: &Paragraph, ordinal: usize) -> u8 { class_of_cluster_impl(paragraph, None, ordinal) } @@ -2588,7 +2993,7 @@ fn formula_boundary_space_after(paragraph: &Paragraph, ordinal: usize) -> Option let following = clusters.get(following_ordinal)?; match (current_formula, following_formula) { - (Some(current_range), Some(following_range)) if current_range == following_range => { + (Some(current_range), Some(_)) => { if current_range.start == 0 && current_range.end == clusters.len() { let current_character = single_cluster_character(paragraph, current); let following_character = single_cluster_character(paragraph, following); @@ -2602,20 +3007,11 @@ fn formula_boundary_space_after(paragraph: &Paragraph, ordinal: usize) -> Option }; return Some(quarter_inline_size(paragraph, symbol)); } - if current_character.is_some_and(is_math_operator) - || following_character.is_some_and(is_math_operator) - { - return Some(0); - } } Some(0) }, - (None, Some(range)) if range.start == following_ordinal => { - Some(formula_outer_boundary_space(paragraph, current, following)) - }, - (Some(range), None) if range.end == following_ordinal => { - Some(formula_outer_boundary_space(paragraph, following, current)) - }, + (None, Some(_)) => Some(formula_outer_boundary_space(paragraph, current, following)), + (Some(_), None) => Some(formula_outer_boundary_space(paragraph, following, current)), _ => Some(0), } } @@ -2910,10 +3306,11 @@ fn warichu_member_advance(paragraph: &Paragraph, ordinal: usize, lane: &Range bool { - let Some(cluster) = paragraph.text.clusters().get(ordinal) else { - return false; - }; - ¶graph.text.source()[cluster.range()] == "\t" - && tate_chu_yoko_cluster_range(paragraph, ordinal).is_none() - && warichu_cluster_range(paragraph, ordinal).is_none() - && furawake_cluster_range(paragraph, ordinal).is_none() + paragraph.line_tabs.get(ordinal).copied().unwrap_or(false) } fn measure_line( @@ -3833,27 +4224,9 @@ fn place_ruby_span( else { return 0; }; - let annotation_width = span - .annotation - .clusters() - .iter() - .filter(|cluster| { - let cluster = cluster.range(); - span.annotation_range.start <= cluster.start && cluster.end <= span.annotation_range.end - }) - .fold(0_i64, |sum, cluster| { - sum.saturating_add(i64::from(cluster.advance())) - }); + let (annotation_count, annotation_width, _) = + ruby_annotation_metrics(span.annotation, &span.annotation_range); let base_width = i64::from(base_end).saturating_sub(i64::from(base_start)); - let annotation_count = span - .annotation - .clusters() - .iter() - .filter(|cluster| { - let cluster = cluster.range(); - span.annotation_range.start <= cluster.start && cluster.end <= span.annotation_range.end - }) - .count(); let surplus = base_width.saturating_sub(annotation_width).max(0); let mut gaps = Vec::new(); let base_plan = span.distribution.and_then(|distribution| { @@ -3868,7 +4241,10 @@ fn place_ruby_span( }); let mut inline = if let Some(plan) = base_plan { i64::from(base_start).saturating_sub(i64::from(plan.leading)) - } else if annotation_width > base_width { + } else if matches!( + annotation_width.cmp(&base_width), + core::cmp::Ordering::Greater + ) { i64::from(base_start).saturating_add((base_width.saturating_sub(annotation_width)) / 2) } else if let Some(distribution) = span.distribution { let weights = match distribution { @@ -3935,7 +4311,7 @@ fn place_ruby_span( } fn proportional_shares(total: i64, weights: &[i32], remainder: Remainder) -> Vec { - if total <= 0 || weights.is_empty() { + if total <= 0 { return vec![0; weights.len()]; } let weight_sum = weights.iter().fold(0_i64, |sum, weight| { @@ -3944,37 +4320,35 @@ fn proportional_shares(total: i64, weights: &[i32], remainder: Remainder) -> Vec if weight_sum == 0 { return vec![0; weights.len()]; } + let total = u128::try_from(total).unwrap_or(0); + let weight_sum = u128::try_from(weight_sum).unwrap_or(0); let mut shares: Vec<_> = weights .iter() .map(|weight| { - total - .saturating_mul(i64::from((*weight).max(0))) + let weight = u128::try_from((*weight).max(0)).unwrap_or(0); + let share = total + .saturating_mul(weight) .checked_div(weight_sum) - .unwrap_or(0) + .unwrap_or(0); + i64::try_from(share).unwrap_or(i64::MAX) }) .collect(); - let mut left = total.saturating_sub(shares.iter().copied().sum::()); - while left > 0 { - match remainder { - Remainder::Leading => { - for share in &mut shares { - if left == 0 { - break; - } - *share = share.saturating_add(1); - left = left.saturating_sub(1); - } - }, - Remainder::Trailing => { - for share in shares.iter_mut().rev() { - if left == 0 { - break; - } - *share = share.saturating_add(1); - left = left.saturating_sub(1); - } - }, - } + let assigned = shares.iter().fold(0_u128, |sum, share| { + sum.saturating_add(u128::try_from(*share).unwrap_or(0)) + }); + let left = usize::try_from(total.saturating_sub(assigned)).unwrap_or(usize::MAX); + debug_assert!(left <= weights.len()); + match remainder { + Remainder::Leading => { + for share in shares.iter_mut().take(left) { + *share = share.saturating_add(1); + } + }, + Remainder::Trailing => { + for share in shares.iter_mut().rev().take(left) { + *share = share.saturating_add(1); + } + }, } shares.into_iter().map(clamp_i32).collect() } @@ -4049,12 +4423,13 @@ fn clamp_i32(value: i64) -> i32 { #[cfg(test)] mod tests { - use alloc::vec; + use alloc::{string::String, vec, vec::Vec}; + use core::ops::Range; - use crate::construct::Construct; - use crate::model::{Cluster, Frame, ShapedText, Size, WritingMode}; + use crate::construct::{Construct, Ruby, RubyKind, RubyRun}; + use crate::model::{Cluster, ClusterRole, Frame, ShapedText, Size, WritingMode}; use crate::paragraph::{Break, Paragraph, Widow}; - use crate::style::Style; + use crate::style::{Remainder, Style}; fn text(source: &str) -> ShapedText { let clusters = source.char_indices().map(|(start, character)| { @@ -4069,52 +4444,2751 @@ mod tests { .expect("valid fixture text") } - #[test] - fn optimal_search_uses_the_whole_paragraph() { - let source = "日本語組版"; - let paragraph = Paragraph::builder(text(source), 4_000) + fn mapped_text( + source: &str, + frame: Frame, + mut map: impl FnMut(usize, Cluster) -> Cluster, + ) -> ShapedText { + let clusters = source + .char_indices() + .enumerate() + .map(|(ordinal, (start, character))| { + map( + ordinal, + Cluster::new(start..start.saturating_add(character.len_utf8()), 1_000), + ) + }); + ShapedText::new( + source, + Size::square(1_000).expect("positive fixture size"), + frame, + clusters, + ) + .expect("valid mapped text") + } + + fn ruby(kind: RubyKind, base: Range, annotation: &str, runs: Vec) -> Ruby { + Ruby::new(kind, base, text(annotation), runs).expect("valid ruby fixture") + } + + fn break_everywhere(source: &str, extent: i32, mode: WritingMode) -> Paragraph { + Paragraph::builder(text(source), extent) .breaks( source .char_indices() .skip(1) .map(|(offset, _)| Break::allowed(offset)), ) - .widow(Widow::MinimumClusters(2)) + .writing_mode(mode) .build() - .expect("valid paragraph"); - let layout = crate::compose(¶graph, &Style::default()); - assert_eq!(layout.lines().len(), 2); - assert_eq!(layout.lines()[0].clusters().len(), 3); + .expect("valid generated paragraph") + } + + fn placement( + ordinal: usize, + range: Range, + inline: i32, + advance: i32, + transform: crate::CoordinateTransform, + ) -> crate::ClusterPlacement { + crate::ClusterPlacement { + origin: crate::PlacementOrigin::Cluster(ordinal), + range, + inline, + block: 0, + advance, + size: Size::square(9).expect("positive placement size"), + frame: Frame::FullEm, + writing_mode: WritingMode::HorizontalTb, + transform, + } + } + + fn line(range: Range, clusters: Vec) -> crate::Line { + crate::Line { + range, + inline_origin: 0, + block_origin: 1_000, + inline_extent: 10_000, + block_extent: 1_000, + clusters, + attachments: Vec::new(), + } + } + + fn oracle_chosen( + paragraph: &Paragraph, + style: &Style, + candidates: &[super::Candidate], + ) -> Vec { + let mut nodes = vec![ + super::Node { + cost: super::INFINITE_COST, + previous: 0, + line_count: 0, + }; + candidates.len() + ]; + nodes[0] = super::Node { + cost: 0, + previous: 0, + line_count: 0, + }; + for end in 1..candidates.len() { + let candidate = candidates[end]; + if !candidate.mandatory && !super::break_is_legal(paragraph, style, candidate.offset) { + continue; + } + for start in 0..end { + if nodes[start].cost == super::INFINITE_COST + || candidates[start.saturating_add(1)..end] + .iter() + .any(|inner| inner.mandatory) + { + continue; + } + let line_number = nodes[start].line_count; + let measured = super::measure_line( + paragraph, + style, + candidates[start].offset, + candidate.offset, + line_number, + ); + let available = i64::from(paragraph.line_extent); + let width = super::width_after_available_reduction( + paragraph, + style, + candidates[start].offset, + candidate.offset, + measured, + available, + ); + let is_last = end.saturating_add(1) == candidates.len(); + let mut edge = super::non_negative_cost(super::line_badness( + available.saturating_sub(width), + is_last, + style.adjustment_preference(), + )); + if candidate.discretionary { + edge = edge.saturating_add(100_000); + } + edge = edge.saturating_add(super::non_negative_cost(super::warichu_break_penalty( + paragraph, + candidate.offset, + ))); + edge = edge.saturating_add(super::non_negative_cost(super::formula_break_penalty( + paragraph, + candidate.offset, + ))); + if is_last { + edge = edge.saturating_add(super::non_negative_cost(super::widow_penalty( + paragraph, + candidates[start].offset, + candidate.offset, + ))); + } + let cost = nodes[start].cost.saturating_add(edge); + if cost < nodes[end].cost { + nodes[end] = super::Node { + cost, + previous: start, + line_count: line_number.saturating_add(1), + }; + } + } + } + let mut chosen = Vec::new(); + let mut cursor = nodes.len().saturating_sub(1); + chosen.push(cursor); + while cursor != 0 { + cursor = nodes[cursor].previous; + chosen.push(cursor); + } + chosen.reverse(); + chosen } #[test] - fn vertical_lines_progress_toward_negative_block_coordinates() { - let paragraph = Paragraph::builder(text("日本"), 1_000) - .breaks(vec![Break::allowed(3)]) - .writing_mode(WritingMode::VerticalRl) + fn prepared_and_composer_reset_clear_every_cached_field() { + let mut prepared = super::PreparedParagraph::new(); + prepared.candidate_ordinals.push(1); + prepared.legal_candidates.push(true); + prepared.natural_prefix.push(2); + prepared.minimum_prefix.push(3); + prepared.reduction_prefix.push(4); + prepared.line_end_reduction.push(5); + prepared.regular = true; + prepared.clear(); + assert!(prepared.candidate_ordinals.is_empty()); + assert!(prepared.legal_candidates.is_empty()); + assert!(prepared.natural_prefix.is_empty()); + assert!(prepared.minimum_prefix.is_empty()); + assert!(prepared.reduction_prefix.is_empty()); + assert!(prepared.line_end_reduction.is_empty()); + assert!(!prepared.regular); + + let mut composer = super::Composer::new(); + composer.transitions = 9; + composer.candidates.push(super::Candidate { + offset: 1, + mandatory: false, + discretionary: true, + }); + composer.nodes.push(super::Node { + cost: 1, + previous: 2, + line_count: 3, + }); + composer.chosen.push(1); + composer.line_advances.push(2); + composer.line_adjustments.push(3); + composer.prepared.regular = true; + composer.reset_for_call(); + assert_eq!(composer.transitions, 0); + assert!(composer.candidates.is_empty()); + assert!(composer.nodes.is_empty()); + assert!(composer.chosen.is_empty()); + assert!(composer.line_advances.is_empty()); + assert!(composer.line_adjustments.is_empty()); + assert!(!composer.prepared.regular); + } + + #[test] + fn numeric_prefix_and_limit_helpers_have_inclusive_edges() { + assert_eq!(super::non_negative_cost(-1), 0); + assert_eq!(super::non_negative_cost(0), 0); + assert_eq!(super::non_negative_cost(17), 17); + assert!( + super::check_limit(crate::CompositionResource::Clusters, 3, 3).is_ok(), + "the declared limit is inclusive" + ); + let error = super::check_limit(crate::CompositionResource::Clusters, 3, 4) + .expect_err("one past the limit"); + assert_eq!(error.limit(), 3); + assert_eq!(error.observed(), 4); + + let prefix = [0, 2, 5, 9]; + assert_eq!(super::range_sum(&prefix, 1, 3), 7); + assert_eq!(super::range_sum(&prefix, 2, 2), 0); + assert_eq!(super::range_sum(&prefix, 0, 9), i64::MAX); + let prepared = super::PreparedParagraph { + minimum_prefix: prefix.to_vec(), + ..super::PreparedParagraph::new() + }; + assert_eq!(super::fast_minimum_width(&prepared, 1, 3), 3); + assert_eq!(super::fast_minimum_width(&prepared, 1, 0), 0); + + let paragraph = break_everywhere("abc", 3_000, WritingMode::HorizontalTb); + assert_eq!(super::cluster_index_at_or_after(¶graph, 0), 0); + assert_eq!(super::cluster_index_at_or_after(¶graph, 1), 1); + assert_eq!(super::cluster_index_at_or_after(¶graph, 2), 2); + assert_eq!(super::cluster_index_at_or_after(¶graph, 3), 3); + assert_eq!(super::cluster_index_at_or_after(¶graph, 4), 3); + + let current = super::Node { + cost: 10, + previous: 3, + line_count: 9, + }; + assert!(super::search_candidate_precedes(9, 99, current)); + assert!(super::search_candidate_precedes(10, 2, current)); + assert!(!super::search_candidate_precedes(10, 3, current)); + assert!(!super::search_candidate_precedes(10, 4, current)); + assert!(!super::search_candidate_precedes(11, 0, current)); + + let preference = crate::style::AdjustmentPreference::LeastAdjustment; + let strict_bound = super::non_negative_cost(super::line_badness(-1, false, preference)); + assert!(!super::search_lower_bound_exceeds( + 10, 10, false, preference, 0 + )); + assert!(!super::search_lower_bound_exceeds( + 9, 10, false, preference, 0 + )); + assert!(super::search_lower_bound_exceeds( + 11, + 10, + false, + preference, + strict_bound.saturating_sub(1) + )); + assert!(!super::search_lower_bound_exceeds( + 11, + 10, + false, + preference, + strict_bound + )); + + assert!(super::line_should_justify( + crate::Alignment::Justify, + false, + 1, + 2 + )); + assert!(!super::line_should_justify( + crate::Alignment::Justify, + true, + 1, + 2 + )); + assert!(!super::line_should_justify( + crate::Alignment::Justify, + false, + 0, + 2 + )); + assert!(!super::line_should_justify( + crate::Alignment::Justify, + false, + 1, + 1 + )); + assert!(!super::line_should_justify( + crate::Alignment::Start, + false, + 1, + 2 + )); + assert_eq!(super::line_adjustment_need(-1, false), -1); + assert_eq!(super::line_adjustment_need(0, true), 0); + assert_eq!(super::line_adjustment_need(1, true), 1); + assert_eq!(super::line_adjustment_need(1, false), 0); + } + + #[test] + fn indexed_and_special_search_charge_exact_transition_work() { + let regular = Paragraph::builder(text("AB"), 4_000) .build() - .expect("valid paragraph"); - let layout = crate::compose(¶graph, &Style::default()); - assert_eq!(layout.lines().len(), 2); - assert!(layout.lines()[1].block_origin() < layout.lines()[0].block_origin()); + .expect("valid regular paragraph"); + let mut composer = super::Composer::new(); + composer + .compose(®ular, &Style::default()) + .expect("regular search succeeds"); + assert_eq!(composer.transitions, 1); + + let formula = Paragraph::builder(text("AB"), 4_000) + .constructs([Construct::formula(0..2)]) + .build() + .expect("valid non-regular paragraph"); + composer + .compose(&formula, &Style::default()) + .expect("special search succeeds"); + assert_eq!(composer.transitions, 4); } #[test] - fn distinct_ornamented_complexes_lower_to_table_six_stage_three() { - let paragraph = Paragraph::builder(text("日本"), 2_000) - .constructs([ - Construct::script(0..3, text("注")), - Construct::script(3..6, text("記")), - ]) + fn fast_measure_trims_leading_space_only_when_it_is_not_also_trailing() { + fn assert_fast_matches_full(source: &str, expected: i64) { + let paragraph = Paragraph::builder( + mapped_text(source, Frame::Proportional, |_, cluster| cluster), + 4_000, + ) .build() - .expect("valid ornamented paragraph"); + .expect("valid proportional paragraph"); + let style = Style::default(); + let mut composer = super::Composer::new(); + composer.prepare_candidates(¶graph); + composer.prepare_indexes(¶graph, &style); + assert!(composer.prepared.regular); + let cluster_end = paragraph.text.clusters().len(); + assert_eq!( + super::measure_line(¶graph, &style, 0, source.len(), 0), + expected + ); + assert_eq!( + super::fast_measure_line(&composer.prepared, ¶graph, &style, 0, cluster_end, 0), + expected + ); + } + + assert_fast_matches_full(" ", 0); + assert_fast_matches_full(" A", 1_000); + } + + #[test] + fn construct_cluster_ranges_and_internal_boundaries_are_exact() { + let furawake = Paragraph::builder(text("abc"), 3_000) + .breaks([Break::allowed(1)]) + .constructs([Construct::furawake(0..2, 2, 17)]) + .build() + .expect("valid furawake"); assert_eq!( - super::boundary_expansion_site(¶graph, &Style::default(), 0), + super::furawake_cluster_range(&furawake, 0), + Some((0..2, 2, 17)) + ); + assert_eq!( + super::furawake_cluster_range(&furawake, 1), + Some((0..2, 2, 17)) + ); + assert_eq!(super::furawake_cluster_range(&furawake, 2), None); + assert!(!super::is_internal_furawake_offset(&furawake, 0)); + assert!(super::is_internal_furawake_offset(&furawake, 1)); + assert!(!super::is_internal_furawake_offset(&furawake, 2)); + + let jidori = Paragraph::builder(text("abc"), 3_000) + .constructs([Construct::jidori(0..2, 3)]) + .build() + .expect("valid jidori"); + assert_eq!(super::jidori_cluster_range(&jidori, 0), Some((0..2, 3))); + assert_eq!(super::jidori_cluster_range(&jidori, 1), Some((0..2, 3))); + assert_eq!(super::jidori_cluster_range(&jidori, 2), None); + assert!(super::is_internal_jidori_boundary(&jidori, 0)); + assert!(!super::is_internal_jidori_boundary(&jidori, 1)); + assert!(!super::is_internal_jidori_boundary(&jidori, 3)); + } + + #[test] + fn reduction_site_special_cases_are_a_closed_table() { + use crate::style::ReductionTable; + + let cases = [ + (5, 5, ReductionTable::Table3, vec![(0, 4), (1, 4)]), + (5, 5, ReductionTable::Table4, vec![(0, 2), (1, 2)]), + (5, 5, ReductionTable::Table5, vec![]), + (6, 5, ReductionTable::Table3, vec![(1, 4)]), + (6, 5, ReductionTable::Table4, vec![(1, 2)]), + (6, 5, ReductionTable::Table5, vec![]), + (7, 5, ReductionTable::Table3, vec![(0, 5), (1, 4)]), + (7, 5, ReductionTable::Table4, vec![(1, 2)]), + (7, 5, ReductionTable::Table5, vec![(0, 3)]), + ]; + for (before, after, table, expected) in cases { + let mut sites = Vec::new(); + assert!(super::append_special_reduction_sites( + table, + before, + after, + [300, 200], + [400, 800], + 9, + &mut sites + )); + assert_eq!( + sites + .iter() + .map(|site| { + let component = usize::from(site.weight == 800); + (component, site.stage) + }) + .collect::>(), + expected, + "{before}/{after} {table:?}" + ); + assert!(sites.iter().all(|site| site.boundary == 9)); + } + let mut sites = Vec::new(); + assert!(!super::append_special_reduction_sites( + ReductionTable::Table3, + 19, + 19, + [300, 200], + [400, 800], + 9, + &mut sites + )); + assert!(sites.is_empty()); + } + + #[test] + fn reduction_rounding_and_site_guards_preserve_exact_units() { + assert_eq!(super::quarter_rounded_up(0), 0); + assert_eq!(super::quarter_rounded_up(1), 1); + assert_eq!(super::quarter_rounded_up(4), 1); + assert_eq!(super::quarter_rounded_up(5), 2); + + let mut sites = Vec::new(); + super::push_reduction_site(&mut sites, 2, 10, 0, 1, false); + super::push_reduction_site(&mut sites, 2, 10, 1, 0, false); + assert!(sites.is_empty()); + super::push_reduction_site(&mut sites, 2, 10, 1, 1, true); + assert_eq!( + sites, + [super::ReductionSite { + boundary: 2, + weight: 10, + capacity: 1, + stage: 1, + discrete: true, + }] + ); + + let mut adjustments = [7, 11]; + super::distribute_reduction(0, &sites, Remainder::Leading, &mut adjustments); + assert_eq!(adjustments, [7, 11]); + super::distribute_adjustment(0, &[(0, 1, None)], Remainder::Leading, &mut adjustments); + assert_eq!(adjustments, [7, 11]); + } + + #[test] + fn line_adjustment_stages_do_not_promote_bounded_stage_one_sites() { + let paragraph = Paragraph::builder( + mapped_text("A 〉", Frame::Proportional, |ordinal, cluster| { + if ordinal == 1 { + Cluster::new(cluster.range(), 200) + } else { + cluster + } + }), + 4_000, + ) + .constructs([Construct::formula(0..2)]) + .build() + .expect("valid isolated western-space expansion site"); + assert_eq!( + super::boundary_expansion_site(¶graph, &Style::default(), 1), super::ExpansionSite::Site { weight: 1_000, - bounded: Some((250, 3)), + bounded: Some((300, 1)), residual: false, } ); + + let mut adjustments = vec![99]; + super::prepare_line_adjustments( + ¶graph, + &Style::default(), + 0, + 3, + 1_000, + &mut adjustments, + ); + assert_eq!(adjustments, [0, 300, 0]); + + super::prepare_line_adjustments(¶graph, &Style::default(), 0, 3, 0, &mut adjustments); + assert_eq!(adjustments, [0, 0, 0]); + + let reducible = Paragraph::builder( + mapped_text(" A", Frame::Proportional, |_, cluster| cluster), + 3_000, + ) + .build() + .expect("valid western-space reduction site"); + super::prepare_line_adjustments( + &reducible, + &Style::default(), + 0, + 2, + -100, + &mut adjustments, + ); + assert_eq!(adjustments, [-100, 0]); + } + + #[test] + fn reductions_reject_empty_lines_and_distribute_exactly() { + let closing = Paragraph::builder(text("〉"), 2_000) + .build() + .expect("valid closing bracket"); + assert!(super::reduction_sites(&closing, &Style::default(), 1, 1).is_empty()); + + let sites = [ + super::ReductionSite { + boundary: 0, + weight: 1, + capacity: 1, + stage: 1, + discrete: false, + }, + super::ReductionSite { + boundary: 1, + weight: 3, + capacity: 3, + stage: 1, + discrete: false, + }, + ]; + let mut leading = [0, 0]; + super::distribute_reduction(3, &sites, Remainder::Leading, &mut leading); + assert_eq!(leading, [-1, -2]); + let mut trailing = [0, 0]; + super::distribute_reduction(3, &sites, Remainder::Trailing, &mut trailing); + assert_eq!(trailing, [0, -3]); + + let expansion_sites = [(0, 1, Some(1)), (1, 3, Some(3))]; + let mut leading = [0, 0]; + super::distribute_adjustment(3, &expansion_sites, Remainder::Leading, &mut leading); + assert_eq!(leading, [1, 2]); + let mut trailing = [0, 0]; + super::distribute_adjustment(3, &expansion_sites, Remainder::Trailing, &mut trailing); + assert_eq!(trailing, [0, 3]); + } + + #[test] + fn expansion_ceiling_rounds_each_policy_and_ignores_other_pairs() { + use crate::style::JapaneseLatinExpansionCeiling; + + for (ceiling, expected) in [ + (JapaneseLatinExpansionCeiling::HalfEm, 4), + (JapaneseLatinExpansionCeiling::ThirdEm, 3), + (JapaneseLatinExpansionCeiling::Rigid, 2), + ] { + let style = Style::builder() + .japanese_latin_expansion_ceiling(ceiling) + .build() + .expect("valid style"); + assert_eq!(super::expansion_ceiling(&style, 19, 27, 7, 99), expected); + assert_eq!(super::expansion_ceiling(&style, 27, 19, 7, 99), expected); + assert_eq!(super::expansion_ceiling(&style, 19, 19, 7, 99), 99); + } + let thirds = Style::builder() + .japanese_latin_expansion_ceiling(JapaneseLatinExpansionCeiling::ThirdEm) + .build() + .expect("valid thirds style"); + assert_eq!(super::expansion_ceiling(&thirds, 19, 27, 2, 99), 1); + assert_eq!(super::expansion_ceiling(&thirds, 19, 27, 6, 99), 2); + } + + #[test] + fn expansion_complex_identity_covers_every_kind_member_and_edge() { + let script = Paragraph::builder(text("日本外"), 5_000) + .constructs([Construct::script(0..6, text("注"))]) + .build() + .expect("valid script complex"); + for ordinal in 0..2 { + assert_eq!( + super::expansion_complex_at(&script, ordinal), + Some(super::ComplexIdentity { + kind: super::ComplexKind::Ornamented, + construct: 0, + member: 0, + }) + ); + } + assert_eq!(super::expansion_complex_at(&script, 2), None); + assert_eq!(super::expansion_complex_at(&script, 3), None); + + let mono = ruby( + RubyKind::Mono, + 0..6, + "にほ", + vec![RubyRun::new(0..3, 0..3), RubyRun::new(3..6, 3..6)], + ); + let mono_paragraph = Paragraph::builder(text("日本外"), 5_000) + .constructs([Construct::ruby(mono)]) + .build() + .expect("valid mono ruby complex"); + for (ordinal, member) in [(0, 0), (1, 1)] { + assert_eq!( + super::expansion_complex_at(&mono_paragraph, ordinal), + Some(super::ComplexIdentity { + kind: super::ComplexKind::SimpleRuby, + construct: 0, + member, + }) + ); + } + assert_eq!(super::expansion_complex_at(&mono_paragraph, 2), None); + + for (kind, expected, runs) in [ + ( + RubyKind::Group, + super::ComplexKind::SimpleRuby, + vec![RubyRun::new(0..6, 0..6)], + ), + ( + RubyKind::Jukugo, + super::ComplexKind::JukugoRuby, + vec![RubyRun::new(0..3, 0..3), RubyRun::new(3..6, 3..6)], + ), + ] { + let ruby = ruby(kind, 0..6, "にほ", runs); + let paragraph = Paragraph::builder(text("日本"), 4_000) + .constructs([Construct::ruby(ruby)]) + .build() + .expect("valid group-like ruby complex"); + for ordinal in 0..2 { + assert_eq!( + super::expansion_complex_at(¶graph, ordinal), + Some(super::ComplexIdentity { + kind: expected, + construct: 0, + member: 0, + }) + ); + } + } + + let horizontal = Paragraph::builder(text("12"), 3_000) + .constructs([Construct::tate_chu_yoko(0..2)]) + .build() + .expect("valid horizontal tate-chu-yoko"); + assert_eq!(super::expansion_complex_at(&horizontal, 0), None); + let vertical = Paragraph::builder(text("12日"), 4_000) + .constructs([Construct::tate_chu_yoko(0..2)]) + .writing_mode(WritingMode::VerticalRl) + .build() + .expect("valid vertical tate-chu-yoko"); + for ordinal in 0..2 { + assert_eq!( + super::expansion_complex_at(&vertical, ordinal), + Some(super::ComplexIdentity { + kind: super::ComplexKind::TateChuYoko, + construct: 0, + member: 0, + }) + ); + } + assert_eq!(super::expansion_complex_at(&vertical, 2), None); + } + + #[test] + fn boundary_expansion_sites_cover_spaces_constructs_classes_and_weights() { + use crate::style::JapaneseLatinExpansionCeiling; + + let western = |advance: i32, following: &str| { + let source = alloc::format!(" {following}"); + Paragraph::builder( + mapped_text(&source, Frame::Proportional, |ordinal, cluster| { + if ordinal == 0 { + Cluster::new(cluster.range(), advance) + } else { + cluster + } + }), + 5_000, + ) + .build() + .expect("valid western-space fixture") + }; + assert_eq!( + super::boundary_expansion_site(&western(200, "A"), &Style::default(), 0), + super::ExpansionSite::Site { + weight: 1_000, + bounded: Some((300, 1)), + residual: true, + } + ); + assert_eq!( + super::boundary_expansion_site(&western(200, "〜"), &Style::default(), 0), + super::ExpansionSite::Site { + weight: 1_000, + bounded: Some((300, 1)), + residual: true, + } + ); + assert_eq!( + super::boundary_expansion_site(&western(1_000, "A"), &Style::default(), 0), + super::ExpansionSite::Site { + weight: 1_000, + bounded: None, + residual: true, + } + ); + assert_eq!( + super::boundary_expansion_site(&western(200, "〉"), &Style::default(), 0), + super::ExpansionSite::Site { + weight: 1_000, + bounded: Some((300, 1)), + residual: false, + } + ); + + let plain_formula_pair = Paragraph::builder( + mapped_text("日A", Frame::FullEm, |ordinal, cluster| { + if ordinal == 1 { + cluster.with_frame(Frame::Proportional) + } else { + cluster + } + }), + 4_000, + ) + .build() + .expect("valid Japanese/Latin pair"); + assert!(matches!( + super::boundary_expansion_site(&plain_formula_pair, &Style::default(), 0), + super::ExpansionSite::Site { .. } + )); + let internal_formula = Paragraph::builder(plain_formula_pair.text.clone(), 4_000) + .constructs([Construct::formula(0..4)]) + .build() + .expect("valid internal formula pair"); + assert_eq!( + super::boundary_expansion_site(&internal_formula, &Style::default(), 0), + super::ExpansionSite::None + ); + + for source in ["——", "〳〵"] { + let paragraph = Paragraph::builder(text(source), 4_000) + .build() + .expect("valid inseparable pair"); + assert_eq!( + super::boundary_expansion_site(¶graph, &Style::default(), 0), + super::ExpansionSite::None + ); + } + let different = Paragraph::builder(text("—…"), 4_000) + .build() + .expect("valid different inseparable pair"); + assert!(matches!( + super::boundary_expansion_site(&different, &Style::default(), 0), + super::ExpansionSite::Site { .. } + )); + + let plain_quantity = Paragraph::builder( + mapped_text("A%", Frame::Proportional, |_, cluster| cluster), + 4_000, + ) + .build() + .expect("valid ordinary 27/13 pair"); + assert!(matches!( + super::boundary_expansion_site(&plain_quantity, &Style::default(), 0), + super::ExpansionSite::Site { .. } + )); + let role_quantity = Paragraph::builder( + mapped_text("A%", Frame::Proportional, |ordinal, cluster| { + if ordinal == 0 { + cluster.with_role(ClusterRole::QuantitySymbol) + } else { + cluster + } + }), + 4_000, + ) + .build() + .expect("valid role-qualified quantity pair"); + assert_eq!( + super::boundary_expansion_site(&role_quantity, &Style::default(), 0), + super::ExpansionSite::None + ); + let digit_quantity = Paragraph::builder( + mapped_text("1%", Frame::Proportional, |_, cluster| cluster), + 4_000, + ) + .build() + .expect("valid digit quantity pair"); + assert_eq!( + super::boundary_expansion_site(&digit_quantity, &Style::default(), 0), + super::ExpansionSite::None + ); + let role_before_ideograph = Paragraph::builder( + mapped_text("A日", Frame::Proportional, |ordinal, cluster| { + if ordinal == 0 { + cluster.with_role(ClusterRole::QuantitySymbol) + } else { + cluster + } + }), + 4_000, + ) + .build() + .expect("valid quantity role before an ideograph"); + assert!(matches!( + super::boundary_expansion_site(&role_before_ideograph, &Style::default(), 0), + super::ExpansionSite::Site { .. } + )); + + let unequal_ideographs = Paragraph::builder( + mapped_text("日本", Frame::FullEm, |ordinal, cluster| { + if ordinal == 1 { + cluster.with_size(Size::square(2_000).expect("positive size")) + } else { + cluster + } + }), + 5_000, + ) + .build() + .expect("valid unequal ideographs"); + assert_eq!( + super::boundary_expansion_site(&unequal_ideographs, &Style::default(), 0), + super::ExpansionSite::Site { + weight: 1_000, + bounded: Some((250, 3)), + residual: false, + } + ); + let annotated = Paragraph::builder(unequal_ideographs.text.clone(), 5_000) + .constructs([Construct::script(0..3, text("注"))]) + .build() + .expect("valid unequal script boundary"); + assert_eq!( + super::boundary_expansion_site(&annotated, &Style::default(), 0), + super::ExpansionSite::Site { + weight: 2_000, + bounded: Some((500, 2)), + residual: false, + } + ); + + let rigid = Style::builder() + .japanese_latin_expansion_ceiling(JapaneseLatinExpansionCeiling::Rigid) + .build() + .expect("valid rigid expansion style"); + assert_eq!( + super::boundary_expansion_site(&plain_formula_pair, &rigid, 0), + super::ExpansionSite::None + ); + } + + #[test] + fn construct_classification_covers_every_special_owner() { + let annotation = text("注"); + let cases = [ + ( + Construct::ruby(ruby( + RubyKind::Group, + 0..3, + "注", + vec![RubyRun::new(0..3, 0..3)], + )), + 22, + ), + ( + Construct::ruby(ruby( + RubyKind::Jukugo, + 0..3, + "注", + vec![RubyRun::new(0..3, 0..3)], + )), + 23, + ), + (Construct::emphasis_dots(0..3, '・'), 21), + (Construct::script(0..3, annotation.clone()), 21), + (Construct::reference_mark(0..3, annotation), 20), + ]; + for (construct, expected) in cases { + let paragraph = Paragraph::builder(text("日"), 1_000) + .constructs([construct]) + .build() + .expect("valid owned cluster"); + assert_eq!(super::class_of_cluster(¶graph, 0), expected); + } + + let vertical = Paragraph::builder(text("12"), 1_000) + .constructs([Construct::tate_chu_yoko(0..2)]) + .writing_mode(WritingMode::VerticalRl) + .build() + .expect("valid tate-chu-yoko"); + assert_eq!(super::class_of_cluster(&vertical, 0), 30); + assert_eq!(super::class_of_cluster(&vertical, 1), 30); + } + + #[test] + fn contextual_punctuation_roles_are_solid_only_in_named_contexts() { + let vertical = Paragraph::builder( + mapped_text( + "()・、・", + Frame::FullEm, + |ordinal, cluster| match ordinal { + 0 | 1 => cluster.with_role(ClusterRole::WarichuBracket), + 2 => cluster.with_role(ClusterRole::DecimalPoint), + 3 => cluster.with_role(ClusterRole::DigitGroupSeparator), + _ => cluster.with_role(ClusterRole::GroupedNumeral), + }, + ), + 10_000, + ) + .writing_mode(WritingMode::VerticalRl) + .build() + .expect("valid contextual punctuation"); + let characters = ['(', ')', '・', '、', '・']; + for (ordinal, character) in characters.into_iter().enumerate() { + assert!(super::contextual_punctuation_is_solid( + &vertical, + &vertical.text.clusters()[ordinal], + character + )); + } + + let horizontal = Paragraph::builder( + mapped_text("・、A", Frame::FullEm, |ordinal, cluster| match ordinal { + 0 => cluster.with_role(ClusterRole::DecimalPoint), + 1 => cluster.with_role(ClusterRole::DigitGroupSeparator), + _ => cluster.with_role(ClusterRole::WarichuBracket), + }), + 10_000, + ) + .build() + .expect("valid horizontal punctuation"); + assert!(!super::contextual_punctuation_is_solid( + &horizontal, + &horizontal.text.clusters()[0], + '・' + )); + assert!(!super::contextual_punctuation_is_solid( + &horizontal, + &horizontal.text.clusters()[1], + '、' + )); + assert!(!super::contextual_punctuation_is_solid( + &horizontal, + &horizontal.text.clusters()[2], + 'A' + )); + } + + #[test] + fn western_space_and_rounding_predicates_have_exact_edges() { + let proportional = Paragraph::builder( + mapped_text(" A", Frame::Proportional, |_, cluster| cluster), + 2_000, + ) + .build() + .expect("valid proportional text"); + assert!(super::is_western_word_space(&proportional, 0)); + assert!(!super::is_western_word_space(&proportional, 1)); + assert!(!super::is_western_word_space(&proportional, 2)); + + let full_em = Paragraph::builder(text(" "), 1_000) + .build() + .expect("valid full-em space"); + assert!(!super::is_western_word_space(&full_em, 0)); + let formula_role = Paragraph::builder( + mapped_text(" ", Frame::Proportional, |_, cluster| { + cluster.with_role(ClusterRole::Formula) + }), + 1_000, + ) + .build() + .expect("valid role-tagged space"); + assert!(!super::is_western_word_space(&formula_role, 0)); + + for (value, expected) in [(0, 0), (1, 1), (2, 1), (3, 2), (4, 2)] { + assert_eq!(super::half_rounded_up(value), expected); + } + + let inseparable = crate::generated::appendix_a::LISTINGS + .iter() + .find(|listing| listing.class == crate::spec::INSEPARABLE && listing.key[1] == 0) + .and_then(|listing| char::from_u32(listing.key[0])) + .expect("Appendix A has a one-character cl-08 member"); + assert!(super::is_inseparable_character(inseparable)); + assert!(!super::is_inseparable_character('A')); + } + + #[test] + fn line_end_and_sentence_medial_spacing_obey_style_edges() { + use crate::style::{LineEndFullStopComma, LineEndPunctuation, SentenceMedialDividingMark}; + + let closing = Paragraph::builder(text(")"), 2_000) + .build() + .expect("valid closing bracket"); + let solid = Style::builder() + .line_end_punctuation(LineEndPunctuation::Solid) + .build() + .expect("valid style"); + assert_eq!(super::line_end_space_after(&closing, &solid, 0), 0); + + let comma = Paragraph::builder(text("、"), 2_000) + .build() + .expect("valid comma"); + let jis = Style::builder() + .line_end_full_stop_comma(LineEndFullStopComma::Jis) + .build() + .expect("valid style"); + assert_eq!(super::line_end_space_after(&comma, &jis, 0), 0); + + let terminator = Paragraph::builder( + mapped_text("!)", Frame::FullEm, |ordinal, cluster| { + if ordinal == 0 { + cluster.with_role(ClusterRole::SentenceTerminator) + } else { + cluster + } + }), + 3_000, + ) + .build() + .expect("valid terminator"); + assert_eq!( + super::ordinary_boundary_space_after_with_style(&terminator, &Style::default(), 0), + 0 + ); + + let medial = Paragraph::builder( + mapped_text("!日", Frame::FullEm, |ordinal, cluster| { + if ordinal == 0 { + cluster.with_role(ClusterRole::SentenceMedial) + } else { + cluster + } + }), + 3_000, + ) + .build() + .expect("valid medial mark"); + let quarter = Style::builder() + .sentence_medial_dividing_mark(SentenceMedialDividingMark::QuarterEm) + .build() + .expect("valid style"); + assert_eq!( + super::ordinary_boundary_space_after_with_style(&medial, &quarter, 0), + 250 + ); + + let unqualified = Paragraph::builder(text("!日"), 3_000) + .build() + .expect("valid unqualified dividing mark"); + assert_eq!( + super::ordinary_boundary_space_after_with_style(&unqualified, &quarter, 0), + 0 + ); + let wrong_class = Paragraph::builder( + mapped_text("日日", Frame::FullEm, |ordinal, cluster| { + if ordinal == 0 { + cluster.with_role(ClusterRole::SentenceMedial) + } else { + cluster + } + }), + 3_000, + ) + .build() + .expect("valid role on a non-dividing class"); + assert_eq!( + super::ordinary_boundary_space_after_with_style(&wrong_class, &quarter, 0), + 0 + ); + } + + #[test] + fn table_one_blank_lookup_matches_every_generated_cell() { + for cell in crate::generated::table1::CELLS { + assert_eq!( + super::table_one_cell_is_blank(cell.before, cell.after), + cell.terms.is_empty(), + "cl-{:02}/cl-{:02}", + cell.before, + cell.after + ); + } + assert!(super::table_one_cell_is_blank(0, 0)); + } + + #[test] + fn formula_boundaries_distinguish_internal_tokens_and_outer_neighbors() { + let internal = Paragraph::builder( + mapped_text("A=B", Frame::Proportional, |_, cluster| cluster), + 5_000, + ) + .constructs([Construct::formula(0..3)]) + .build() + .expect("valid whole formula"); + assert_eq!(super::formula_boundary_space_after(&internal, 0), Some(250)); + assert_eq!(super::formula_boundary_space_after(&internal, 1), Some(250)); + + let adjacent_symbols = Paragraph::builder( + mapped_text("==", Frame::Proportional, |_, cluster| cluster), + 3_000, + ) + .constructs([Construct::formula(0..2)]) + .build() + .expect("valid adjacent formula symbols"); + assert_eq!( + super::formula_boundary_space_after(&adjacent_symbols, 0), + Some(0) + ); + + let left_partial = Paragraph::builder( + mapped_text("A=日", Frame::Proportional, |_, cluster| cluster), + 5_000, + ) + .constructs([Construct::formula(0..2)]) + .build() + .expect("valid formula touching only the paragraph start"); + assert_eq!( + super::formula_boundary_space_after(&left_partial, 0), + Some(0) + ); + let right_partial = Paragraph::builder( + mapped_text("日=A", Frame::Proportional, |_, cluster| cluster), + 5_000, + ) + .constructs([Construct::formula(3..5)]) + .build() + .expect("valid formula touching only the paragraph end"); + assert_eq!( + super::formula_boundary_space_after(&right_partial, 1), + Some(0) + ); + + let adjacent_formulas = Paragraph::builder( + mapped_text("AB", Frame::Proportional, |_, cluster| cluster), + 3_000, + ) + .constructs([Construct::formula(0..1), Construct::formula(1..2)]) + .build() + .expect("valid adjacent formula constructs"); + assert_eq!( + super::formula_boundary_space_after(&adjacent_formulas, 0), + Some(0) + ); + + let outer = Paragraph::builder( + mapped_text("日A", Frame::FullEm, |ordinal, cluster| { + if ordinal == 1 { + cluster + .with_frame(Frame::Proportional) + .with_role(ClusterRole::Formula) + } else { + cluster + } + }), + 4_000, + ) + .constructs([Construct::formula(3..4)]) + .build() + .expect("valid outer formula"); + assert_eq!(super::formula_boundary_space_after(&outer, 0), Some(250)); + + let trailing_outer = Paragraph::builder( + mapped_text("A日", Frame::FullEm, |ordinal, cluster| { + if ordinal == 0 { + cluster + .with_frame(Frame::Proportional) + .with_role(ClusterRole::Formula) + } else { + cluster + } + }), + 4_000, + ) + .constructs([Construct::formula(0..1)]) + .build() + .expect("valid formula before a Japanese neighbor"); + assert_eq!( + super::formula_boundary_space_after(&trailing_outer, 0), + Some(250) + ); + + let plain = Paragraph::builder(text("日本"), 3_000) + .build() + .expect("valid plain text"); + assert_eq!(super::formula_boundary_space_after(&plain, 0), None); + } + + #[test] + fn japanese_formula_neighbor_exclusions_are_independent() { + let source = "日 ()、。・+"; + let paragraph = Paragraph::builder(text(source), 20_000) + .build() + .expect("valid neighbor set"); + assert!(super::is_japanese_formula_neighbor( + ¶graph, + ¶graph.text.clusters()[0] + )); + for cluster in ¶graph.text.clusters()[1..] { + assert!(!super::is_japanese_formula_neighbor(¶graph, cluster)); + } + + let proportional = + Paragraph::builder(mapped_text("日", Frame::Proportional, |_, c| c), 2_000) + .build() + .expect("valid proportional neighbor"); + assert!(!super::is_japanese_formula_neighbor( + &proportional, + &proportional.text.clusters()[0] + )); + } + + #[test] + fn formula_outer_spacing_requires_a_japanese_neighbor_and_eligible_endpoint() { + let eligible = Paragraph::builder( + mapped_text("日A", Frame::FullEm, |ordinal, cluster| { + if ordinal == 1 { + cluster.with_frame(Frame::Proportional) + } else { + cluster + } + }), + 4_000, + ) + .build() + .expect("valid eligible formula endpoint"); + assert!(super::formula_endpoint_needs_quarter( + &eligible, + &eligible.text.clusters()[1] + )); + assert_eq!( + super::formula_outer_boundary_space( + &eligible, + &eligible.text.clusters()[0], + &eligible.text.clusters()[1] + ), + 250 + ); + + let rigid = Paragraph::builder(text("日A"), 4_000) + .build() + .expect("valid rigid formula endpoint"); + assert!(!super::formula_endpoint_needs_quarter( + &rigid, + &rigid.text.clusters()[1] + )); + assert_eq!( + super::formula_outer_boundary_space( + &rigid, + &rigid.text.clusters()[0], + &rigid.text.clusters()[1] + ), + 0 + ); + + let non_japanese = Paragraph::builder( + mapped_text(" A", Frame::FullEm, |ordinal, cluster| { + if ordinal == 1 { + cluster.with_frame(Frame::Proportional) + } else { + cluster + } + }), + 4_000, + ) + .build() + .expect("valid non-Japanese formula neighbor"); + assert_eq!( + super::formula_outer_boundary_space( + &non_japanese, + &non_japanese.text.clusters()[0], + &non_japanese.text.clusters()[1] + ), + 0 + ); + + let math = Paragraph::builder( + mapped_text("+", Frame::Proportional, |_, cluster| cluster), + 2_000, + ) + .build() + .expect("valid mathematical endpoint"); + assert!(!super::formula_endpoint_needs_quarter( + &math, + &math.text.clusters()[0] + )); + + let grouped = Paragraph::builder( + mapped_text("A", Frame::FullEm, |_, cluster| { + cluster.with_role(ClusterRole::GroupedNumeral) + }), + 2_000, + ) + .build() + .expect("valid grouped-numeral endpoint"); + assert!(super::formula_endpoint_needs_quarter( + &grouped, + &grouped.text.clusters()[0] + )); + } + + #[test] + fn jidori_plan_distributes_surplus_only_inside_a_complete_range() { + let paragraph = Paragraph::builder(text("日本語"), 5_000) + .constructs([Construct::jidori(0..9, 4)]) + .build() + .expect("valid jidori paragraph"); + let plan = super::jidori_plan(¶graph, &Style::default(), 0..3, 4, 0, 3, 0); + assert_eq!(plan.range, 0..3); + assert_eq!(plan.extra_after, [500, 500, 0]); + assert_eq!(plan.extra_after(0), 500); + assert_eq!(plan.extra_after(1), 500); + assert_eq!(plan.extra_after(2), 0); + assert_eq!(plan.extra_after(3), 0); + assert_eq!( + super::jidori_extra_after(¶graph, &Style::default(), 0, 1, 3, 0), + 0 + ); + assert_eq!( + super::jidori_extra_after(¶graph, &Style::default(), 0, 0, 2, 0), + 0 + ); + + let uneven = Paragraph::builder( + mapped_text("日本語", Frame::FullEm, |ordinal, cluster| { + if ordinal == 0 { + Cluster::new(cluster.range(), 999) + } else { + cluster + } + }), + 5_000, + ) + .build() + .expect("valid uneven jidori members"); + let trailing = Style::builder() + .remainder(Remainder::Trailing) + .build() + .expect("valid trailing-remainder style"); + assert_eq!( + super::jidori_plan(&uneven, &trailing, 0..3, 4, 0, 3, 0).extra_after, + [500, 501, 0] + ); + + let closing = Paragraph::builder(text("〉日"), 4_000) + .build() + .expect("valid jidori boundary spacing fixture"); + assert_eq!(super::boundary_space_after(&closing, 0), 500); + assert_eq!( + super::jidori_plan(&closing, &Style::default(), 0..2, 3, 0, 2, 0).extra_after, + [500, 0] + ); + } + + #[test] + fn ruby_span_visitation_switches_between_runs_groups_and_phonetic_layout() { + use crate::style::JukugoRubyLayout; + + let per_run = ruby( + RubyKind::Jukugo, + 0..6, + "にほんご", + vec![RubyRun::new(0..3, 0..6), RubyRun::new(3..6, 6..12)], + ); + let paragraph = Paragraph::builder(text("日本"), 4_000) + .constructs([Construct::ruby(per_run)]) + .build() + .expect("valid jukugo"); + let mut spans = Vec::new(); + super::visit_ruby_spans(¶graph, &Style::default(), |_, base, annotation| { + spans.push((base, annotation)); + }); + assert_eq!(spans, [(0..3, 0..6), (3..6, 6..12)]); + + let grouped = ruby( + RubyKind::Jukugo, + 0..6, + "にほんごく", + vec![RubyRun::new(0..3, 0..9), RubyRun::new(3..6, 9..15)], + ); + let paragraph = Paragraph::builder(text("日本"), 4_000) + .constructs([Construct::ruby(grouped)]) + .build() + .expect("valid grouped jukugo"); + spans.clear(); + super::visit_ruby_spans(¶graph, &Style::default(), |_, base, annotation| { + spans.push((base, annotation)); + }); + assert_eq!(spans, [(0..6, 0..15)]); + + let phonetic = Style::builder() + .jukugo_ruby_layout(JukugoRubyLayout::Phonetic) + .build() + .expect("valid style"); + spans.clear(); + super::visit_ruby_spans(¶graph, &phonetic, |_, base, annotation| { + spans.push((base, annotation)); + }); + assert!(spans.is_empty()); + } + + #[test] + fn group_ruby_distribution_and_overhang_have_exact_integer_geometry() { + use crate::style::{GroupRubyDistribution, JukugoRubyLayout}; + + let group = ruby( + RubyKind::Group, + 0..6, + "にほんご", + vec![RubyRun::new(0..6, 0..12)], + ); + let paragraph = Paragraph::builder(text("日本"), 8_000) + .constructs([Construct::ruby(group.clone())]) + .build() + .expect("valid group ruby"); + let jis = + super::group_ruby_base_plan(¶graph, &Style::default(), &group, &(0..6), &(0..12)) + .expect("annotation is wider than the base"); + assert_eq!(jis.base, 0..2); + assert_eq!(jis.leading, 500); + assert_eq!(jis.trailing, 500); + assert_eq!(jis.gap_after(0), 1_000); + assert_eq!(jis.gap_after(1), 0); + + let flush_style = Style::builder() + .group_ruby_distribution(GroupRubyDistribution::Flush) + .build() + .expect("valid style"); + let flush = + super::group_ruby_base_plan(¶graph, &flush_style, &group, &(0..6), &(0..12)) + .expect("flush distribution"); + assert_eq!(flush.leading, 0); + assert_eq!(flush.trailing, 0); + assert_eq!(flush.gap_after(0), 2_000); + + let mono = ruby(RubyKind::Mono, 0..3, "にほ", vec![RubyRun::new(0..3, 0..6)]); + let mono_paragraph = Paragraph::builder(text("日"), 4_000) + .constructs([Construct::ruby(mono.clone())]) + .build() + .expect("valid mono ruby"); + assert!( + super::group_ruby_base_plan( + &mono_paragraph, + &Style::default(), + &mono, + &(0..3), + &(0..6) + ) + .is_none() + ); + let overhang = + super::ruby_span_overhang(&mono_paragraph, &Style::default(), &mono, 0..3, 0..6, 0, 1) + .expect("mono annotation overhangs"); + assert_eq!(overhang.base, 0..1); + assert_eq!(overhang.leading, 500); + assert_eq!(overhang.trailing, 500); + assert_eq!(overhang.ruby_em, 1_000); + assert!( + super::ruby_span_overhang(&mono_paragraph, &Style::default(), &mono, 0..3, 0..6, 1, 2) + .is_none() + ); + + let jukugo = ruby( + RubyKind::Jukugo, + 0..6, + "にほんご", + vec![RubyRun::new(0..3, 0..6), RubyRun::new(3..6, 6..12)], + ); + let jukugo_paragraph = Paragraph::builder(text("日本"), 8_000) + .constructs([Construct::ruby(jukugo.clone())]) + .build() + .expect("valid jukugo"); + assert!( + super::group_ruby_base_plan( + &jukugo_paragraph, + &Style::default(), + &jukugo, + &(0..6), + &(0..12) + ) + .is_some() + ); + assert!( + super::group_ruby_base_plan( + &jukugo_paragraph, + &Style::default(), + &jukugo, + &(0..3), + &(0..6) + ) + .is_none() + ); + let phonetic = Style::builder() + .jukugo_ruby_layout(JukugoRubyLayout::Phonetic) + .build() + .expect("valid style"); + assert!( + super::group_ruby_base_plan(&jukugo_paragraph, &phonetic, &jukugo, &(0..6), &(0..12)) + .is_none() + ); + + let spaced_base = Paragraph::builder(text("〉日"), 4_000) + .build() + .expect("valid ruby base spacing fixture"); + assert_eq!( + super::ruby_base_width(&spaced_base, &Style::default(), &(0..2)), + 2_500 + ); + assert_eq!( + super::ruby_base_width(&spaced_base, &Style::default(), &(0..1)), + 1_000 + ); + } + + #[test] + fn ruby_boundary_separation_rejects_partial_groups_and_honors_line_end() { + let group = ruby( + RubyKind::Group, + 0..6, + "にほんご", + vec![RubyRun::new(0..6, 0..12)], + ); + let group_paragraph = Paragraph::builder(text("日本"), 8_000) + .constructs([Construct::ruby(group)]) + .build() + .expect("valid group ruby"); + assert_eq!( + super::ruby_boundary_separation_after(&group_paragraph, &Style::default(), 0, 1, 2, 0), + 0 + ); + assert_eq!( + super::ruby_boundary_separation_after(&group_paragraph, &Style::default(), 0, 0, 1, 0), + 0 + ); + + let mono = ruby(RubyKind::Mono, 3..4, "にほ", vec![RubyRun::new(3..4, 0..6)]); + let mono_paragraph = Paragraph::builder(text("外A〉"), 8_000) + .constructs([Construct::ruby(mono)]) + .build() + .expect("valid middle mono ruby"); + assert_eq!( + super::ruby_boundary_separation_after(&mono_paragraph, &Style::default(), 1, 0, 3, 0), + 0 + ); + assert_eq!( + super::ruby_boundary_separation_after(&mono_paragraph, &Style::default(), 1, 0, 2, 0), + 500 + ); + } + + #[test] + fn ruby_neighbor_allowance_distinguishes_each_punctuation_side() { + let leading = Paragraph::builder(text("(日A日・日あ"), 20_000) + .build() + .expect("valid neighbor text"); + assert_eq!( + super::ruby_neighbor_overhang_allowance( + &leading, + &Style::default(), + 0, + super::RubySide::Leading, + 333 + ), + 333 + ); + assert_eq!( + super::ruby_neighbor_overhang_allowance( + &leading, + &Style::default(), + 2, + super::RubySide::Leading, + 333 + ), + 0 + ); + assert_eq!( + super::ruby_neighbor_overhang_allowance( + &leading, + &Style::default(), + 4, + super::RubySide::Leading, + 333 + ), + 333 + ); + assert_eq!( + super::ruby_neighbor_overhang_allowance( + &leading, + &Style::default(), + 6, + super::RubySide::Leading, + 333 + ), + 333 + ); + + let trailing = Paragraph::builder(text("A)A。A、"), 20_000) + .build() + .expect("valid trailing neighbors"); + for ordinal in [1, 3, 5] { + assert_eq!( + super::ruby_neighbor_overhang_allowance( + &trailing, + &Style::default(), + ordinal, + super::RubySide::Trailing, + 333 + ), + 333 + ); + } + assert_eq!( + super::ruby_neighbor_overhang_allowance( + &trailing, + &Style::default(), + 0, + super::RubySide::Trailing, + 333 + ), + 0 + ); + + let leading_trailing_marks = Paragraph::builder(text("〉日。日、日"), 20_000) + .build() + .expect("valid leading closing marks"); + for ordinal in [0, 2, 4] { + assert_eq!( + super::ruby_neighbor_overhang_allowance( + &leading_trailing_marks, + &Style::default(), + ordinal, + super::RubySide::Leading, + 333 + ), + 333 + ); + } + + let trailing_opening = Paragraph::builder(text("日〈"), 4_000) + .build() + .expect("valid trailing opening bracket"); + assert_eq!( + super::ruby_neighbor_overhang_allowance( + &trailing_opening, + &Style::default(), + 1, + super::RubySide::Trailing, + 333 + ), + 333 + ); + } + + #[test] + fn phonetic_expansion_apportionment_excludes_short_runs_and_respects_ties() { + let runs = [ + super::PhoneticJukugoRun { + base: 0..1, + annotation: 0..1, + base_start: 0, + base_end: 1, + annotation_start: 0, + annotation_width: 2, + ruby_em: 1, + annotation_count: 3, + }, + super::PhoneticJukugoRun { + base: 1..2, + annotation: 1..2, + base_start: 1, + base_end: 2, + annotation_start: 0, + annotation_width: 100, + ruby_em: 1, + annotation_count: 2, + }, + super::PhoneticJukugoRun { + base: 2..3, + annotation: 2..3, + base_start: 2, + base_end: 3, + annotation_start: 0, + annotation_width: 1, + ruby_em: 1, + annotation_count: 4, + }, + ]; + assert_eq!( + super::apportion_phonetic_expansion(&runs, 5, Remainder::Leading), + [4, 0, 1] + ); + assert_eq!( + super::apportion_phonetic_expansion(&runs, 5, Remainder::Trailing), + [3, 0, 2] + ); + + let reordered = [runs[1].clone(), runs[0].clone(), runs[2].clone()]; + assert_eq!( + super::apportion_phonetic_expansion(&reordered, 5, Remainder::Leading), + [0, 4, 1] + ); + } + + #[test] + fn phonetic_plan_builder_keeps_external_and_internal_boundaries_distinct() { + let spaced = Paragraph::builder(text("〉日"), 4_000) + .build() + .expect("valid phonetic base spacing fixture"); + let whole = [super::PhoneticJukugoRun { + base: 0..2, + annotation: 0..1, + base_start: 0, + base_end: 0, + annotation_start: 0, + annotation_width: 2_000, + ruby_em: 1_000, + annotation_count: 3, + }]; + let plan = super::build_phonetic_jukugo_plan( + &spaced, + &Style::default(), + &whole, + 0, + super::PhoneticEdges { + line: super::LineContext { + start: 0, + end: 2, + index: 0, + }, + leading_allowance: 0, + trailing_allowance: 0, + }, + ) + .expect("the annotation fits the spaced base"); + assert_eq!((plan.runs[0].base_start, plan.runs[0].base_end), (0, 2_500)); + + let inset = Paragraph::builder(text("AAA"), 5_000) + .build() + .expect("valid inset phonetic run"); + let raw = [super::PhoneticJukugoRun { + base: 1..2, + annotation: 0..1, + base_start: 0, + base_end: 0, + annotation_start: 0, + annotation_width: 1_000, + ruby_em: 100, + annotation_count: 3, + }]; + let plan = super::build_phonetic_jukugo_plan( + &inset, + &Style::default(), + &raw, + 2, + super::PhoneticEdges { + line: super::LineContext { + start: 0, + end: 3, + index: 0, + }, + leading_allowance: 0, + trailing_allowance: 0, + }, + ) + .expect("the inset annotation fits after symmetric expansion"); + assert_eq!((plan.runs[0].base_start, plan.runs[0].base_end), (1, 1_001)); + assert_eq!(plan.gap_after(0), 1); + assert_eq!(plan.gap_after(1), 1); + } + + #[test] + fn phonetic_jukugo_plan_is_present_only_for_the_requested_complete_runs() { + use crate::style::JukugoRubyLayout; + + let wide_ruby = ruby( + RubyKind::Jukugo, + 0..6, + "にほんごくご", + vec![RubyRun::new(0..3, 0..9), RubyRun::new(3..6, 9..18)], + ); + let paragraph = Paragraph::builder(text("日本"), 10_000) + .constructs([Construct::ruby(wide_ruby.clone())]) + .build() + .expect("valid phonetic jukugo"); + assert!( + super::phonetic_jukugo_plan(¶graph, &Style::default(), &wide_ruby, 0, 2, 0) + .is_none() + ); + let phonetic = Style::builder() + .jukugo_ruby_layout(JukugoRubyLayout::Phonetic) + .build() + .expect("valid style"); + let plan = super::phonetic_jukugo_plan(¶graph, &phonetic, &wide_ruby, 0, 2, 0) + .expect("phonetic runs can be placed"); + assert_eq!(plan.runs.len(), 2); + assert!(plan.runs[0].annotation_start < plan.runs[1].annotation_start); + assert!( + plan.runs[0] + .annotation_start + .saturating_add(plan.runs[0].annotation_width) + <= plan.runs[1].annotation_start + ); + assert_eq!(plan.gap_after(9), 0); + + let compact_ruby = ruby( + RubyKind::Jukugo, + 0..6, + "にほ", + vec![RubyRun::new(0..3, 0..3), RubyRun::new(3..6, 3..6)], + ); + let compact = Paragraph::builder(text("日本"), 6_000) + .constructs([Construct::ruby(compact_ruby.clone())]) + .build() + .expect("valid compact phonetic jukugo"); + let right = super::phonetic_jukugo_plan(&compact, &phonetic, &compact_ruby, 1, 2, 1) + .expect("right run is complete"); + assert_eq!(right.runs.len(), 1); + assert_eq!(right.runs[0].base, 1..2); + let left = super::phonetic_jukugo_plan(&compact, &phonetic, &compact_ruby, 0, 1, 0) + .expect("left run is complete"); + assert_eq!(left.runs.len(), 1); + assert_eq!(left.runs[0].base, 0..1); + + let indented_ruby = ruby( + RubyKind::Jukugo, + 0..3, + "にほん", + vec![RubyRun::new(0..3, 0..9)], + ); + let indented = Paragraph::builder(text("日"), 6_000) + .constructs([Construct::ruby(indented_ruby.clone())]) + .first_line_indent(500) + .build() + .expect("valid indented phonetic ruby"); + let permitted = Style::builder() + .jukugo_ruby_layout(JukugoRubyLayout::Phonetic) + .ruby_overhang_indent(crate::style::RubyOverhangIndent::Permitted) + .build() + .expect("valid indent-overhang style"); + let prohibited = Style::builder() + .jukugo_ruby_layout(JukugoRubyLayout::Phonetic) + .ruby_overhang_indent(crate::style::RubyOverhangIndent::Prohibited) + .build() + .expect("valid no-indent-overhang style"); + assert_eq!( + super::phonetic_jukugo_plan(&indented, &permitted, &indented_ruby, 0, 1, 0) + .expect("first-line indent permits overhang") + .leading_gap, + 750 + ); + assert_eq!( + super::phonetic_jukugo_plan(&indented, &prohibited, &indented_ruby, 0, 1, 0) + .expect("prohibited overhang is absorbed as expansion") + .leading_gap, + 1_000 + ); + } + + #[test] + fn proportional_shares_are_exact_bounded_and_directional() { + assert_eq!( + super::proportional_shares(5, &[1, 1, 1], Remainder::Leading), + [2, 2, 1] + ); + assert_eq!( + super::proportional_shares(5, &[1, 1, 1], Remainder::Trailing), + [1, 2, 2] + ); + assert_eq!( + super::proportional_shares(4, &[1, -1, 2], Remainder::Leading), + [2, 0, 2] + ); + assert_eq!( + super::proportional_shares(4, &[1, -1, 2], Remainder::Trailing), + [1, 0, 3] + ); + assert_eq!( + super::proportional_shares(0, &[1, 2], Remainder::Leading), + [0, 0] + ); + assert_eq!( + super::proportional_shares(5, &[0, -1], Remainder::Leading), + [0, 0] + ); + assert!(super::proportional_shares(5, &[], Remainder::Leading).is_empty()); + assert_eq!( + super::proportional_shares(i64::MAX, &[i32::MAX, i32::MAX], Remainder::Leading), + [i32::MAX, i32::MAX] + ); + } + + #[test] + fn placement_bounds_annotation_counts_and_line_fit_use_closed_ranges() { + let fixture_line = line( + 0..3, + vec![ + placement(0, 0..1, 10, 5, crate::CoordinateTransform::Identity), + placement(1, 1..2, 20, 7, crate::CoordinateTransform::Identity), + placement(2, 2..3, 30, 100, crate::CoordinateTransform::TateChuYoko), + ], + ); + assert_eq!( + super::bounds_for_range(&fixture_line, &(0..2)), + Some((10, 27)) + ); + assert_eq!( + super::bounds_for_range(&fixture_line, &(1..3)), + Some((20, 39)) + ); + assert_eq!(super::bounds_for_range(&fixture_line, &(4..5)), None); + assert_eq!(super::placement_inline_end(&fixture_line.clusters[0]), 15); + assert_eq!(super::placement_inline_end(&fixture_line.clusters[2]), 39); + + let annotation = text("日本語"); + assert_eq!(super::annotation_cluster_count(&annotation, &(0..9)), 3); + assert_eq!(super::annotation_cluster_count(&annotation, &(0..6)), 2); + assert_eq!(super::annotation_cluster_count(&annotation, &(1..8)), 1); + assert_eq!(super::annotation_cluster_count(&annotation, &(9..9)), 0); + assert!(super::range_fits_line(&(0..3), &fixture_line)); + assert!(super::range_fits_line(&(1..2), &fixture_line)); + assert!(!super::range_fits_line(&(0..4), &fixture_line)); + assert!(!super::range_fits_line(&(0..3), &line(1..3, Vec::new()))); + } + + #[test] + fn badness_widow_penalty_and_diagnostic_have_exact_thresholds() { + use crate::style::AdjustmentPreference; + + assert_eq!( + super::line_badness(-3, false, AdjustmentPreference::LeastAdjustment), + 10_009_000 + ); + assert_eq!( + super::line_badness(10, true, AdjustmentPreference::LeastAdjustment), + 1 + ); + assert_eq!( + super::line_badness(10, false, AdjustmentPreference::LeastAdjustment), + 100 + ); + assert_eq!( + super::line_badness(10, false, AdjustmentPreference::EvenTexture), + 200 + ); + + let paragraph = Paragraph::builder(text("日本"), 3_000) + .widow(Widow::MinimumClusters(2)) + .build() + .expect("valid widow paragraph"); + assert_eq!(super::widow_penalty(¶graph, 0, 3), 1_000_000_000); + assert_eq!(super::widow_penalty(¶graph, 0, 6), 0); + let mut layout = crate::Layout::default(); + layout.lines.push(line( + 0..3, + vec![placement( + 0, + 0..3, + 0, + 1_000, + crate::CoordinateTransform::Identity, + )], + )); + super::add_widow_diagnostic(¶graph, &mut layout); + assert_eq!(layout.diagnostics.len(), 1); + assert_eq!(layout.diagnostics[0].code(), "layout.widow"); + assert_eq!(layout.diagnostics[0].range(), Some(0..3)); + + layout.lines[0].clusters.push(placement( + 1, + 3..6, + 1_000, + 1_000, + crate::CoordinateTransform::Identity, + )); + layout.diagnostics.clear(); + super::add_widow_diagnostic(¶graph, &mut layout); + assert!(layout.diagnostics.is_empty()); + } + + #[test] + fn furawake_lanes_and_placements_keep_outer_boundaries_outside() { + let plain = Paragraph::builder( + mapped_text("日A", Frame::FullEm, |ordinal, cluster| { + if ordinal == 1 { + cluster.with_frame(Frame::Proportional) + } else { + cluster + } + }), + 4_000, + ) + .build() + .expect("valid punctuation paragraph"); + assert_eq!(super::construct_lane_width(&plain, &(0..1)), 1_000); + assert_eq!(super::construct_lane_width(&plain, &(0..2)), 2_250); + + let source = "日A日A"; + let paragraph = Paragraph::builder( + mapped_text(source, Frame::FullEm, |ordinal, cluster| { + if ordinal % 2 == 1 { + cluster.with_frame(Frame::Proportional) + } else { + cluster + } + }), + 8_000, + ) + .breaks([Break::allowed(3)]) + .constructs([Construct::furawake(0..source.len(), 2, 100)]) + .build() + .expect("valid furawake paragraph"); + let segment = super::furawake_segment(¶graph, 0..4, 2, 100, 4); + assert_eq!(segment.range, 0..4); + assert_eq!(segment.lanes, [0..1, 1..4]); + assert_eq!(segment.block_extents, [1_000, 1_000]); + assert_eq!(segment.block_extent, 2_100); + assert_eq!(segment.advance, 3_500); + let mut placed = Vec::new(); + super::place_furawake_segment(¶graph, &segment, 100, 0, &mut placed); + assert_eq!( + placed + .iter() + .map(|item| (item.inline, item.block, item.advance)) + .collect::>(), + [ + (100, -550, 1_000), + (100, 550, 1_250), + (1_350, 550, 1_250), + (2_600, 550, 1_000) + ] + ); + + let outer = Paragraph::builder( + mapped_text("日A日", Frame::FullEm, |ordinal, cluster| { + if ordinal == 1 { + cluster.with_frame(Frame::Proportional) + } else { + cluster + } + }), + 6_000, + ) + .build() + .expect("valid outer-boundary fixture"); + assert_eq!( + super::furawake_segment(&outer, 0..2, 1, 0, 2).advance, + 2_250 + ); + assert_eq!( + super::furawake_segment(&outer, 0..2, 1, 0, 3).advance, + 2_500 + ); + + let offset_source = "X日A日"; + let offset = Paragraph::builder( + mapped_text(offset_source, Frame::FullEm, |ordinal, cluster| { + if ordinal == 2 { + cluster.with_frame(Frame::Proportional) + } else { + cluster + } + }), + 8_000, + ) + .breaks([Break::allowed(1), Break::allowed(4)]) + .constructs([Construct::furawake(1..offset_source.len(), 2, 0)]) + .build() + .expect("valid nonzero-start furawake"); + let offset_segment = super::furawake_segment(&offset, 1..4, 2, 0, 4); + assert_eq!(offset_segment.lanes, [1..2, 2..4]); + assert_eq!( + super::effective_cluster_body_advance(&offset, 1), + offset_segment.advance + ); + assert_eq!(super::effective_cluster_body_advance(&offset, 2), 0); + assert_eq!(super::effective_cluster_body_advance(&offset, 3), 0); + } + + #[test] + fn warichu_split_trim_geometry_and_break_penalty_are_exact() { + let proportional = mapped_text("( abc)", Frame::Proportional, |ordinal, cluster| { + if ordinal == 0 || ordinal == 5 { + cluster.with_role(ClusterRole::WarichuBracket) + } else { + cluster + } + }); + let declared = Paragraph::builder(proportional.clone(), 10_000) + .breaks([Break::allowed(3)]) + .constructs([Construct::warichu(0..6)]) + .build() + .expect("valid declared warichu split"); + let segment = super::warichu_segment(&declared, 0..6, 0, 6); + assert_eq!(segment.range, 0..6); + assert_eq!(segment.leading_bracket, Some(0)); + assert_eq!(segment.first_lane, 1..3); + assert_eq!(segment.second_lane, 3..5); + assert_eq!(segment.trailing_bracket, Some(5)); + assert_eq!((segment.first_width, segment.second_width), (1_000, 2_000)); + assert_eq!(segment.advance, 4_000); + assert_eq!(super::warichu_member_advance(&declared, 1, &(1..3)), 0); + + let automatic = Paragraph::builder(proportional, 10_000) + .constructs([Construct::warichu(0..6)]) + .build() + .expect("valid automatic warichu split"); + assert_eq!(super::choose_warichu_split(&automatic, 1..5), 4); + + let tied_text = mapped_text("abc", Frame::Proportional, |ordinal, cluster| { + if ordinal == 1 { + Cluster::new(cluster.range(), 0) + } else { + cluster + } + }); + let tied = Paragraph::builder(tied_text, 4_000) + .build() + .expect("valid tied split paragraph"); + assert_eq!(super::choose_warichu_split(&tied, 0..3), 1); + + let two = Paragraph::builder(text("ab"), 4_000) + .build() + .expect("valid two-member warichu fixture"); + let two_segment = super::warichu_segment(&two, 0..2, 0, 2); + let mut placed = Vec::new(); + super::place_warichu_segment(&two, &two_segment, 50, 0, &mut placed); + assert_eq!( + placed + .iter() + .map(|item| (item.inline, item.block, item.advance)) + .collect::>(), + [(50, -500, 1_000), (50, 500, 1_000)] + ); + + let vertical_two = Paragraph::builder(text("ab"), 4_000) + .writing_mode(WritingMode::VerticalRl) + .build() + .expect("valid vertical two-member fixture"); + let vertical_segment = super::warichu_segment(&vertical_two, 0..2, 0, 2); + let mut vertical_placed = Vec::new(); + super::place_warichu_segment( + &vertical_two, + &vertical_segment, + 50, + 0, + &mut vertical_placed, + ); + assert_eq!( + vertical_placed + .iter() + .map(|item| (item.inline, item.block, item.advance)) + .collect::>(), + [(50, 500, 1_000), (50, -500, 1_000)] + ); + + let outer = Paragraph::builder( + mapped_text("日A日", Frame::FullEm, |ordinal, cluster| { + if ordinal == 1 { + cluster.with_frame(Frame::Proportional) + } else { + cluster + } + }), + 6_000, + ) + .build() + .expect("valid outer-boundary warichu fixture"); + assert_eq!(super::warichu_segment(&outer, 0..2, 0, 2).advance, 1_000); + assert_eq!(super::warichu_segment(&outer, 0..2, 0, 3).advance, 1_250); + + let penalty = Paragraph::builder(text("abcd"), 8_000) + .breaks([Break::allowed(1), Break::allowed(2), Break::allowed(3)]) + .constructs([Construct::warichu(0..4)]) + .build() + .expect("valid penalty paragraph"); + assert_eq!(super::warichu_break_penalty(&penalty, 0), 0); + assert_eq!(super::warichu_break_penalty(&penalty, 1), 2_000_000); + assert_eq!(super::warichu_break_penalty(&penalty, 2), 0); + assert_eq!(super::warichu_break_penalty(&penalty, 3), 2_000_000); + assert_eq!(super::warichu_break_penalty(&penalty, 4), 0); + } + + #[test] + fn tab_measurement_skips_a_stop_at_the_current_cursor() { + use crate::paragraph::{TabAlignment, TabStop}; + + let paragraph = Paragraph::builder( + mapped_text("A\tBC", Frame::Proportional, |_, cluster| cluster), + 10_000, + ) + .tab_stops([ + TabStop::new(1_000, TabAlignment::Start).expect("valid stop"), + TabStop::new(3_000, TabAlignment::Start).expect("valid stop"), + ]) + .build() + .expect("valid tab paragraph"); + assert_eq!( + super::segment_width( + ¶graph, + &Style::default(), + 2, + 4, + super::LineContext { + start: 0, + end: 4, + index: 0, + } + ), + 2_000 + ); + assert_eq!( + super::measure_line(¶graph, &Style::default(), 0, 4, 0), + 5_000 + ); + let mut advances = [1_000; 4]; + super::apply_tabs(¶graph, &Style::default(), 0, 4, 0, &mut advances); + assert_eq!(advances, [1_000, 2_000, 1_000, 1_000]); + } + + #[test] + fn kinsoku_helpers_cover_every_level_and_relaxation_mechanism() { + use crate::style::{ + GroupedNumeralBeforeWestern, IterationMarkAtLineHead, KinsokuLevel, RelaxationMechanism, + }; + + assert_eq!(super::kinsoku_level_bit(KinsokuLevel::VeryLoose), 0b0001); + assert_eq!(super::kinsoku_level_bit(KinsokuLevel::Loose), 0b0010); + assert_eq!(super::kinsoku_level_bit(KinsokuLevel::Strict), 0b0100); + assert_eq!(super::kinsoku_level_bit(KinsokuLevel::VeryStrict), 0b1000); + + let strict = Style::default(); + assert_eq!(super::reclassified_break_class(&strict, 10, Some('ぁ')), 16); + assert_eq!(super::reclassified_break_class(&strict, 11, Some('ぁ')), 15); + assert_eq!(super::reclassified_break_class(&strict, 11, Some('ァ')), 16); + assert_eq!(super::reclassified_break_class(&strict, 19, Some('日')), 19); + let iteration = Style::builder() + .iteration_mark_at_line_head(IterationMarkAtLineHead::Permitted) + .build() + .expect("valid iteration style"); + assert_eq!( + super::reclassified_break_class(&iteration, 12, Some('々')), + 19 + ); + let very_strict = Style::builder() + .kinsoku_level(KinsokuLevel::VeryStrict) + .iteration_mark_at_line_head(IterationMarkAtLineHead::Permitted) + .grouped_numeral_before_western(GroupedNumeralBeforeWestern::Unbreakable) + .relaxation_mechanism(RelaxationMechanism::Matrix) + .build() + .expect("valid very-strict style"); + assert_eq!( + super::reclassified_break_class(&very_strict, 12, Some('々')), + 12 + ); + assert_eq!( + super::reclassified_break_class(&very_strict, 10, Some('ぁ')), + 10 + ); + + let very_loose = Style::builder() + .kinsoku_level(KinsokuLevel::VeryLoose) + .build() + .expect("valid very-loose style"); + assert!(super::c_3_relaxes_boundary(&very_loose, 3, 19, None, None)); + assert!(super::c_3_relaxes_boundary( + &very_loose, + 8, + 8, + Some('〳'), + Some('〵') + )); + assert!(!super::c_3_relaxes_boundary( + &very_loose, + 19, + 19, + Some('日'), + Some('本') + )); + + let loose = Style::builder() + .kinsoku_level(KinsokuLevel::Loose) + .build() + .expect("valid loose style"); + for pair in [ + (19, 19, Some('・'), Some('日')), + (8, 8, Some('…'), Some('…')), + (19, 19, Some('%'), Some('日')), + ] { + assert!(super::c_3_relaxes_boundary( + &loose, pair.0, pair.1, pair.2, pair.3 + )); + } + let matrix = Style::builder() + .relaxation_mechanism(RelaxationMechanism::Matrix) + .build() + .expect("valid matrix style"); + assert_eq!(super::reclassified_break_class(&matrix, 10, Some('ぁ')), 10); + assert!(super::c_3_relaxes_boundary(&matrix, 10, 19, None, None)); + assert!(!super::c_3_relaxes_boundary(&strict, 10, 19, None, None)); + assert!(!super::c_3_relaxes_boundary( + &very_strict, + 3, + 10, + Some('々'), + Some('%') + )); + + assert!(super::c_3_relaxes_boundary( + &iteration, + 19, + 19, + Some('々'), + None + )); + assert!(super::c_3_relaxes_boundary( + &iteration, + 19, + 19, + None, + Some('々') + )); + assert!(!super::c_3_relaxes_boundary( + &strict, + 19, + 19, + Some('々'), + None + )); + assert!(!super::c_3_relaxes_boundary( + &iteration, + 19, + 19, + Some('日'), + Some('本') + )); + let loose_matrix = Style::builder() + .kinsoku_level(KinsokuLevel::Loose) + .relaxation_mechanism(RelaxationMechanism::Matrix) + .build() + .expect("valid loose matrix style"); + assert!(super::c_3_relaxes_boundary( + &loose_matrix, + 10, + 19, + None, + None + )); + + assert!(super::cl_08_same_kind(Some('—'), Some('—'))); + assert!(super::cl_08_same_kind(Some('〳'), Some('〵'))); + assert!(!super::cl_08_same_kind(Some('〳'), Some('A'))); + assert!(!super::cl_08_same_kind(Some('A'), Some('〵'))); + assert!(!super::cl_08_same_kind(Some('—'), Some('…'))); + assert!(!super::cl_08_same_kind(None, Some('〵'))); + } + + #[test] + fn break_legality_keeps_common_prohibitions_and_tab_cut() { + use crate::paragraph::{TabAlignment, TabStop}; + + let opening = Paragraph::builder(text("(A"), 4_000) + .breaks([Break::allowed(3)]) + .build() + .expect("valid opening paragraph"); + assert!(super::break_is_legal(&opening, &Style::default(), 0)); + assert!(!super::break_is_legal(&opening, &Style::default(), 3)); + assert!(super::break_is_legal( + &opening, + &Style::default(), + opening.text.source().len() + )); + + let closing = Paragraph::builder(text("A)"), 4_000) + .breaks([Break::allowed(1)]) + .build() + .expect("valid closing paragraph"); + assert!(!super::break_is_legal(&closing, &Style::default(), 1)); + + let opening_before_iteration = Paragraph::builder(text("(々"), 4_000) + .breaks([Break::allowed(3)]) + .build() + .expect("valid opening/iteration paragraph"); + let iteration = Style::builder() + .iteration_mark_at_line_head(crate::style::IterationMarkAtLineHead::Permitted) + .build() + .expect("valid iteration style"); + assert!(!super::break_is_legal( + &opening_before_iteration, + &iteration, + 3 + )); + + let percent_before_closing = Paragraph::builder(text("%)"), 4_000) + .breaks([Break::allowed(1)]) + .build() + .expect("valid percent/closing paragraph"); + assert!(!super::break_is_legal( + &percent_before_closing, + &Style::magazine_2020(), + 1 + )); + + let tab = Paragraph::builder( + mapped_text("A\t", Frame::Proportional, |_, cluster| cluster), + 4_000, + ) + .tab_stops([TabStop::new(2_000, TabAlignment::Start).expect("valid stop")]) + .build() + .expect("valid tab paragraph"); + assert!(super::break_is_legal(&tab, &Style::default(), 1)); + } + + #[test] + fn formula_and_attachment_geometry_are_centered_with_integer_division() { + let formula = Paragraph::builder( + mapped_text("A=+B", Frame::Proportional, |_, cluster| cluster), + 8_000, + ) + .constructs([Construct::formula(0..4)]) + .build() + .expect("valid independent formula"); + assert_eq!(super::formula_break_penalty(&formula, 0), 0); + assert_eq!(super::formula_break_penalty(&formula, 1), 0); + assert_eq!(super::formula_break_penalty(&formula, 2), 100_000_000); + assert_eq!(super::formula_break_penalty(&formula, 3), 200_000_000); + assert_eq!(super::formula_break_penalty(&formula, 4), 0); + + let script = Paragraph::builder(text("ab"), 4_000) + .constructs([Construct::script(0..2, text("x"))]) + .build() + .expect("valid script paragraph"); + let mut script_line = line( + 0..2, + vec![ + placement(0, 0..1, 0, 1_000, crate::CoordinateTransform::Identity), + placement(1, 1..2, 1_000, 1_000, crate::CoordinateTransform::Identity), + ], + ); + super::place_attachments(&script, &Style::default(), 0, &mut script_line); + assert_eq!(script_line.attachments.len(), 1); + assert_eq!(script_line.attachments[0].inline(), 500); + assert_eq!(script_line.attachments[0].block(), 0); + assert_eq!(script_line.block_extent, 2_000); + + let emphasis = Paragraph::builder(text("ab"), 4_000) + .constructs([Construct::emphasis_dots(0..2, '・')]) + .build() + .expect("valid emphasis paragraph"); + let mut emphasis_line = line(0..2, script_line.clusters.clone()); + super::place_attachments(&emphasis, &Style::default(), 0, &mut emphasis_line); + assert_eq!( + emphasis_line + .attachments + .iter() + .map(|attachment| (attachment.inline(), attachment.block(), attachment.symbol())) + .collect::>(), + [(497, 995, Some('・')), (1_497, 995, Some('・'))] + ); + } + + #[test] + fn local_orientation_distinguishes_horizontal_vertical_and_tate_chu_yoko() { + let horizontal = Paragraph::builder( + mapped_text("A", Frame::Proportional, |_, cluster| cluster), + 2_000, + ) + .build() + .expect("valid horizontal paragraph"); + assert_eq!( + super::local_orientation(&horizontal, 0, Frame::Proportional), + ( + WritingMode::HorizontalTb, + crate::CoordinateTransform::Identity + ) + ); + + let vertical = Paragraph::builder( + mapped_text("A日", Frame::FullEm, |ordinal, cluster| { + if ordinal == 0 { + cluster.with_frame(Frame::Proportional) + } else { + cluster + } + }), + 3_000, + ) + .writing_mode(WritingMode::VerticalRl) + .build() + .expect("valid vertical paragraph"); + assert_eq!( + super::local_orientation(&vertical, 0, Frame::Proportional), + ( + WritingMode::VerticalRl, + crate::CoordinateTransform::RotateClockwise + ) + ); + assert_eq!( + super::local_orientation(&vertical, 1, Frame::FullEm), + ( + WritingMode::VerticalRl, + crate::CoordinateTransform::Identity + ) + ); + + let tcy = Paragraph::builder(text("12"), 3_000) + .constructs([Construct::tate_chu_yoko(0..2)]) + .writing_mode(WritingMode::VerticalRl) + .build() + .expect("valid tate-chu-yoko paragraph"); + assert_eq!( + super::local_orientation(&tcy, 0, Frame::FullEm), + ( + WritingMode::HorizontalTb, + crate::CoordinateTransform::TateChuYoko + ) + ); + + let partial_tcy = Paragraph::builder(text("12日"), 4_000) + .constructs([Construct::tate_chu_yoko(0..2)]) + .writing_mode(WritingMode::VerticalRl) + .build() + .expect("valid partial tate-chu-yoko paragraph"); + assert_eq!( + super::local_orientation(&partial_tcy, 2, Frame::FullEm), + ( + WritingMode::VerticalRl, + crate::CoordinateTransform::Identity + ) + ); + } + + #[test] + fn optimal_search_uses_the_whole_paragraph() { + let source = "日本語組版"; + let paragraph = Paragraph::builder(text(source), 4_000) + .breaks( + source + .char_indices() + .skip(1) + .map(|(offset, _)| Break::allowed(offset)), + ) + .widow(Widow::MinimumClusters(2)) + .build() + .expect("valid paragraph"); + let layout = crate::compose(¶graph, &Style::default()).expect("composition succeeds"); + assert_eq!(layout.lines().len(), 2); + assert_eq!(layout.lines()[0].clusters().len(), 3); + } + + #[test] + fn vertical_lines_progress_toward_negative_block_coordinates() { + let paragraph = Paragraph::builder(text("日本"), 1_000) + .breaks(vec![Break::allowed(3)]) + .writing_mode(WritingMode::VerticalRl) + .build() + .expect("valid paragraph"); + let layout = crate::compose(¶graph, &Style::default()).expect("composition succeeds"); + assert_eq!(layout.lines().len(), 2); + assert!(layout.lines()[1].block_origin() < layout.lines()[0].block_origin()); + } + + #[test] + fn distinct_ornamented_complexes_lower_to_table_six_stage_three() { + let paragraph = Paragraph::builder(text("日本"), 2_000) + .constructs([ + Construct::script(0..3, text("注")), + Construct::script(3..6, text("記")), + ]) + .build() + .expect("valid ornamented paragraph"); + assert_eq!( + super::boundary_expansion_site(¶graph, &Style::default(), 0), + super::ExpansionSite::Site { + weight: 1_000, + bounded: Some((250, 3)), + residual: false, + } + ); + } + + #[test] + fn extreme_capped_remainders_do_not_take_per_unit_work() { + let sites = [ + super::ReductionSite { + boundary: 0, + weight: i32::MAX, + capacity: i32::MAX, + stage: 1, + discrete: false, + }, + super::ReductionSite { + boundary: 1, + weight: 1, + capacity: i32::MAX, + stage: 1, + discrete: false, + }, + ]; + let amount = i64::from(i32::MAX).saturating_mul(2); + let mut reductions = [0, 0]; + super::distribute_reduction(amount, &sites, Remainder::Leading, &mut reductions); + assert_eq!(reductions, [i32::MIN.saturating_add(1); 2]); + + let mut expansions = [0, 0]; + super::distribute_adjustment( + amount, + &[(0, i32::MAX, Some(i32::MAX)), (1, 1, Some(i32::MAX))], + Remainder::Trailing, + &mut expansions, + ); + assert_eq!(expansions, [i32::MAX; 2]); + } + + #[test] + fn capped_round_robin_preserves_leading_and_trailing_ties() { + let capacities = [2, 3, 3]; + assert_eq!( + super::capped_round_robin(5, &capacities, Remainder::Leading), + [2, 2, 1] + ); + assert_eq!( + super::capped_round_robin(5, &capacities, Remainder::Trailing), + [1, 2, 2] + ); + assert_eq!( + super::capped_round_robin(4, &[1, 3, 3], Remainder::Leading), + [1, 2, 1] + ); + assert_eq!( + super::capped_round_robin(4, &[3, 3, 1], Remainder::Trailing), + [1, 2, 1] + ); + } + + #[test] + fn indexed_search_matches_the_quadratic_oracle_across_profiles_and_directions() { + let styles = [ + Style::jlreq_2020(), + Style::book_2020(), + Style::magazine_2020(), + Style::newspaper_2020(), + Style::jis_reading_2020(), + ]; + for mode in [WritingMode::HorizontalTb, WritingMode::VerticalRl] { + for style in styles { + for extent in [1_000, 2_000, 3_000, 4_000] { + let paragraph = break_everywhere("日(A)本、語", extent, mode); + let mut composer = super::Composer::new(); + composer + .compose(¶graph, &style) + .expect("small oracle fixture is within limits"); + let oracle = oracle_chosen(¶graph, &style, &composer.candidates); + assert_eq!(composer.chosen, oracle, "mode={mode:?}, extent={extent}"); + } + } + } + } + + #[test] + #[ignore = "the release performance gate runs this explicitly"] + fn ten_thousand_cluster_standard_paragraph_stays_below_the_search_budget() { + fn transitions(cluster_count: usize) -> usize { + let source: String = "日".repeat(cluster_count); + let paragraph = break_everywhere(&source, 20_000, WritingMode::HorizontalTb); + let mut composer = super::Composer::new(); + composer + .compose(¶graph, &Style::default()) + .expect("standard paragraph stays within default limits"); + composer.transitions + } + + let thousand = transitions(1_000); + let ten_thousand = transitions(10_000); + assert!( + ten_thousand <= 500_000, + "observed {ten_thousand} transitions" + ); + assert!( + ten_thousand <= thousand.saturating_mul(12), + "1k={thousand}, 10k={ten_thousand}" + ); + } + + #[test] + #[ignore = "the release pathological-input gate runs this explicitly"] + fn zero_width_pathological_paragraph_stops_at_the_default_search_budget() { + let source: String = "日".repeat(4_100); + let clusters = source.char_indices().map(|(start, character)| { + Cluster::new(start..start.saturating_add(character.len_utf8()), 0) + }); + let shaped = ShapedText::new( + &source, + Size::square(1_000).expect("positive fixture size"), + Frame::FullEm, + clusters, + ) + .expect("valid zero-width fixture"); + let paragraph = Paragraph::builder(shaped, 20_000) + .breaks( + source + .char_indices() + .skip(1) + .map(|(offset, _)| Break::allowed(offset)), + ) + .build() + .expect("pathological fixture remains inside static resource limits"); + let mut composer = super::Composer::new(); + let error = composer + .compose(¶graph, &Style::default()) + .expect_err("exact search must stop at the default transition budget"); + + let limit = crate::CompositionLimits::DEFAULT_MAX_SEARCH_TRANSITIONS; + assert_eq!( + error.resource(), + crate::CompositionResource::SearchTransitions + ); + assert_eq!(error.limit(), limit); + assert_eq!(error.observed(), limit.saturating_add(1)); + assert_eq!(composer.transitions, limit); } } diff --git a/crates/jlreq/src/spec.rs b/crates/jlreq/src/spec.rs index 9fee753..0162a88 100644 --- a/crates/jlreq/src/spec.rs +++ b/crates/jlreq/src/spec.rs @@ -73,14 +73,14 @@ pub(crate) const MATH_SYMBOL: u8 = 17; pub(crate) const MATH_OPERATOR: u8 = 18; const IDEOGRAPH: u8 = 19; const CONSTRUCT_CLASSES: u32 = class_bit(20) - | class_bit(21) - | class_bit(22) - | class_bit(23) - | class_bit(24) - | class_bit(25) - | class_bit(28) - | class_bit(29) - | class_bit(30); + .saturating_add(class_bit(21)) + .saturating_add(class_bit(22)) + .saturating_add(class_bit(23)) + .saturating_add(class_bit(24)) + .saturating_add(class_bit(25)) + .saturating_add(class_bit(28)) + .saturating_add(class_bit(29)) + .saturating_add(class_bit(30)); pub(crate) fn class_of( piece: &str, @@ -184,6 +184,15 @@ const fn class_bit(class: u8) -> u32 { 1_u32 << class.saturating_sub(1) } +fn insert_class(set: u32, class: u8) -> u32 { + let bit = class_bit(class); + if set & bit == 0 { + set.saturating_add(bit) + } else { + set + } +} + fn candidates(key: [u32; MAX_KEY_LEN]) -> u32 { let literal = listings(key); let selected = if literal.is_empty() && key[1] == 0 { @@ -195,12 +204,12 @@ fn candidates(key: [u32; MAX_KEY_LEN]) -> u32 { }; let mut classes = selected .iter() - .fold(0_u32, |set, listing| set | class_bit(listing.class)); + .fold(0_u32, |set, listing| insert_class(set, listing.class)); if key[1] == 0 && char::from_u32(key[0]).is_some_and(is_ideograph) && classes & class_bit(IDEOGRAPH) == 0 { - classes |= class_bit(IDEOGRAPH); + classes = insert_class(classes, IDEOGRAPH); } classes } @@ -216,7 +225,7 @@ fn narrow_by_usage(classes: u32, key: [u32; MAX_KEY_LEN], mode: WritingMode) -> || (usage == crate::generated::appendix_a::USAGE_VERTICAL_ONLY && mode == WritingMode::VerticalRl); if permitted { - set | class_bit(listing.class) + insert_class(set, listing.class) } else { set } @@ -235,7 +244,7 @@ fn narrow_by_frame(classes: u32, key: [u32; MAX_KEY_LEN], frame: Frame) -> u32 { .fold(0_u32, |set, listing| { let frames = crate::generated::appendix_a::REMARKS[usize::from(listing.remark)].frames; if frames == crate::generated::appendix_a::FRAMES_UNSTATED || frames & frame_bit != 0 { - set | class_bit(listing.class) + insert_class(set, listing.class) } else { set } @@ -247,18 +256,25 @@ fn narrow_by_frame(classes: u32, key: [u32; MAX_KEY_LEN], frame: Frame) -> u32 { let frames = crate::generated::appendix_a::REMARKS[usize::from(listing.remark)].frames; let stated_by_advance = matches!(listing.class, 1 | 2 | 5 | 6 | 7) && matches!(frame, Frame::FullEm | Frame::HalfEm); - if listing.class < 20 && (frames & frame_bit != 0 || stated_by_advance) { - set | class_bit(listing.class) + if (1..20).contains(&listing.class) && (frames & frame_bit != 0 || stated_by_advance) { + insert_class(set, listing.class) } else { set } }); if explicitly_stated != 0 { - narrowed = keep(narrowed, narrowed & (explicitly_stated | CONSTRUCT_CLASSES)); + narrowed = keep( + narrowed, + narrowed & explicitly_stated.saturating_add(CONSTRUCT_CLASSES), + ); } if frame == Frame::Proportional { - let without_half_advance = - narrowed & !(class_bit(1) | class_bit(2) | class_bit(5) | class_bit(6) | class_bit(7)); + let half_advance_classes = class_bit(1) + .saturating_add(class_bit(2)) + .saturating_add(class_bit(5)) + .saturating_add(class_bit(6)) + .saturating_add(class_bit(7)); + let without_half_advance = narrowed & !half_advance_classes; narrowed = keep(narrowed, without_half_advance); } if narrowed & class_bit(IDEOGRAPH) != 0 && narrowed & class_bit(27) != 0 { @@ -423,6 +439,214 @@ fn script(character: char) -> Option { #[cfg(test)] mod tests { use super::*; + use alloc::{string::String, vec::Vec}; + + const ALL_CLASSES: u32 = (1_u32 << 30) - 1; + const REFERENCE_CONSTRUCT_CLASSES: u32 = ref_bit(20) + | ref_bit(21) + | ref_bit(22) + | ref_bit(23) + | ref_bit(24) + | ref_bit(25) + | ref_bit(28) + | ref_bit(29) + | ref_bit(30); + + const fn ref_bit(class: u8) -> u32 { + 1_u32 << class.saturating_sub(1) + } + + fn ref_literal(key: [u32; MAX_KEY_LEN]) -> Vec<&'static crate::generated::appendix_a::Listing> { + LISTINGS + .iter() + .filter(|listing| listing.key == key) + .collect() + } + + fn ref_fold(character: char) -> Option { + FOLDS + .iter() + .find(|fold| fold.source == character as u32) + .and_then(|fold| char::from_u32(fold.target)) + } + + fn ref_candidate_listings( + key: [u32; MAX_KEY_LEN], + ) -> Vec<&'static crate::generated::appendix_a::Listing> { + let literal = ref_literal(key); + if !literal.is_empty() || key[1] != 0 { + return literal; + } + char::from_u32(key[0]) + .and_then(ref_fold) + .map_or(literal, |folded| ref_literal([folded as u32, 0])) + } + + fn ref_is_ideograph(character: char) -> bool { + let code_point = character as u32; + IDEOGRAPH_RANGES + .iter() + .any(|range| range.first <= code_point && code_point <= range.last) + } + + fn ref_candidates(key: [u32; MAX_KEY_LEN]) -> u32 { + let mut classes = ref_candidate_listings(key) + .iter() + .fold(0, |set, listing| set | ref_bit(listing.class)); + if key[1] == 0 + && char::from_u32(key[0]).is_some_and(ref_is_ideograph) + && classes & ref_bit(IDEOGRAPH) == 0 + { + classes |= ref_bit(IDEOGRAPH); + } + classes + } + + const fn ref_keep(original: u32, narrowed: u32) -> u32 { + if narrowed == 0 { original } else { narrowed } + } + + fn ref_narrow_by_usage(classes: u32, key: [u32; MAX_KEY_LEN], mode: WritingMode) -> u32 { + let narrowed = ref_candidate_listings(key).iter().fold(0, |set, listing| { + let usage = crate::generated::appendix_a::REMARKS[usize::from(listing.remark)].usage; + let permitted = usage == crate::generated::appendix_a::USAGE_UNQUALIFIED + || (usage == crate::generated::appendix_a::USAGE_HORIZONTAL_ONLY + && mode == WritingMode::HorizontalTb) + || (usage == crate::generated::appendix_a::USAGE_VERTICAL_ONLY + && mode == WritingMode::VerticalRl); + if permitted { + set | ref_bit(listing.class) + } else { + set + } + }); + ref_keep(classes, classes & narrowed) + } + + fn ref_narrow_by_frame(classes: u32, key: [u32; MAX_KEY_LEN], frame: Frame) -> u32 { + let frame_bit = match frame { + Frame::FullEm => crate::generated::appendix_a::FRAME_FULL_EM, + Frame::HalfEm => crate::generated::appendix_a::FRAME_HALF_EM, + Frame::Proportional => crate::generated::appendix_a::FRAME_PROPORTIONAL, + }; + let listings = ref_candidate_listings(key); + let permitted = listings.iter().fold(0, |set, listing| { + let frames = crate::generated::appendix_a::REMARKS[usize::from(listing.remark)].frames; + if frames == crate::generated::appendix_a::FRAMES_UNSTATED || frames & frame_bit != 0 { + set | ref_bit(listing.class) + } else { + set + } + }); + let mut narrowed = ref_keep(classes, classes & permitted); + let explicitly_stated = listings.iter().fold(0, |set, listing| { + let frames = crate::generated::appendix_a::REMARKS[usize::from(listing.remark)].frames; + let stated_by_advance = matches!(listing.class, 1 | 2 | 5 | 6 | 7) + && matches!(frame, Frame::FullEm | Frame::HalfEm); + if listing.class < 20 && (frames & frame_bit != 0 || stated_by_advance) { + set | ref_bit(listing.class) + } else { + set + } + }); + if explicitly_stated != 0 { + narrowed = ref_keep( + narrowed, + narrowed & (explicitly_stated | REFERENCE_CONSTRUCT_CLASSES), + ); + } + if frame == Frame::Proportional { + let without_half_advance = + narrowed & !(ref_bit(1) | ref_bit(2) | ref_bit(5) | ref_bit(6) | ref_bit(7)); + narrowed = ref_keep(narrowed, without_half_advance); + } + if narrowed & ref_bit(IDEOGRAPH) != 0 && narrowed & ref_bit(27) != 0 { + narrowed = match frame { + Frame::Proportional => narrowed & !ref_bit(IDEOGRAPH), + Frame::FullEm => narrowed & !ref_bit(27), + Frame::HalfEm if narrowed & ref_bit(24) != 0 => narrowed & !ref_bit(IDEOGRAPH), + _ => narrowed, + }; + } + narrowed + } + + fn ref_narrow_by_role( + classes: u32, + role: Option, + character: char, + grouped_numeral_requires_role: bool, + ) -> u32 { + let selected = match role { + Some( + ClusterRole::DecimalPoint + | ClusterRole::DigitGroupSeparator + | ClusterRole::GroupedNumeral, + ) => ref_bit(24), + Some(ClusterRole::SentenceMedial | ClusterRole::SentenceTerminator) => ref_bit(4), + Some(ClusterRole::UnitSymbol) => ref_bit(25), + Some(ClusterRole::WarichuBracket) if single_has_class(character, OPENING_BRACKET) => { + ref_bit(28) + }, + Some(ClusterRole::WarichuBracket) if single_has_class(character, CLOSING_BRACKET) => { + ref_bit(29) + }, + _ if grouped_numeral_requires_role && classes & ref_bit(24) != 0 => { + return ref_bit(27); + }, + _ => return classes, + }; + ref_keep(classes, classes & selected) + } + + fn ref_first(classes: u32) -> Option { + (1..=30).find(|class| classes & ref_bit(*class) != 0) + } + + fn ref_last(classes: u32) -> Option { + (1..=30).rev().find(|class| classes & ref_bit(*class) != 0) + } + + fn ref_class_of( + piece: &str, + frame: Frame, + role: Option, + mode: WritingMode, + unlisted_is_ideographic: bool, + highest: bool, + grouped_requires_role: bool, + ) -> u8 { + let mut characters = piece.chars(); + let Some(first) = characters.next() else { + return IDEOGRAPH; + }; + let second = characters.next(); + if characters.next().is_some() { + return if frame == Frame::Proportional { 27 } else { 19 }; + } + let key = [first as u32, second.map_or(0, |character| character as u32)]; + let mut classes = ref_candidates(key); + if classes == 0 { + return if unlisted_is_ideographic || frame != Frame::Proportional { + 19 + } else { + 27 + }; + } + classes = ref_narrow_by_usage(classes, key, mode); + classes = ref_narrow_by_frame(classes, key, frame); + classes = ref_narrow_by_role(classes, role, first, grouped_requires_role); + let select = |set| { + if highest { + ref_last(set) + } else { + ref_first(set) + } + }; + select(classes & !REFERENCE_CONSTRUCT_CLASSES) + .or_else(|| select(classes)) + .unwrap_or(IDEOGRAPH) + } #[test] fn literal_membership_precedes_wide_folding() { @@ -477,4 +701,187 @@ mod tests { 19 ); } + + #[test] + fn handwritten_candidate_narrowing_matches_an_independent_table_oracle() { + let frames = [Frame::FullEm, Frame::HalfEm, Frame::Proportional]; + let modes = [WritingMode::HorizontalTb, WritingMode::VerticalRl]; + let mut previous = None; + for listing in LISTINGS { + let key = listing.key; + if previous == Some(key) { + continue; + } + previous = Some(key); + assert_eq!( + candidates(key), + ref_candidates(key), + "candidates for {key:?}" + ); + let actual = listings_for_candidate(key) + .iter() + .map(|listing| (listing.key, listing.class, listing.remark)) + .collect::>(); + let expected = ref_candidate_listings(key) + .iter() + .map(|listing| (listing.key, listing.class, listing.remark)) + .collect::>(); + assert_eq!(actual, expected, "listings for {key:?}"); + for mode in modes { + assert_eq!( + narrow_by_usage(ALL_CLASSES, key, mode), + ref_narrow_by_usage(ALL_CLASSES, key, mode), + "usage for {key:?} {mode:?}" + ); + } + for frame in frames { + assert_eq!( + narrow_by_frame(ALL_CLASSES, key, frame), + ref_narrow_by_frame(ALL_CLASSES, key, frame), + "frame for {key:?} {frame:?}" + ); + } + + let mut piece = String::new(); + piece.push(char::from_u32(key[0]).expect("generated scalar")); + if let Some(second) = char::from_u32(key[1]).filter(|_| key[1] != 0) { + piece.push(second); + } + for frame in frames { + for mode in modes { + for highest in [false, true] { + for grouped in [false, true] { + assert_eq!( + class_of(&piece, frame, None, mode, false, highest, grouped), + ref_class_of(&piece, frame, None, mode, false, highest, grouped), + "class for {key:?} {frame:?} {mode:?}" + ); + } + } + } + } + } + } + + #[test] + fn role_narrowing_and_unlisted_shapes_match_the_oracle() { + assert_eq!(CONSTRUCT_CLASSES, REFERENCE_CONSTRUCT_CLASSES); + let ideograph_key = ['𠀀' as u32, 0]; + assert_eq!(candidates(ideograph_key), ref_bit(IDEOGRAPH)); + + let synthetic = ['🦀' as u32, 0]; + let ideograph_or_western = ref_bit(IDEOGRAPH) | ref_bit(27); + assert_eq!( + narrow_by_frame(ideograph_or_western, synthetic, Frame::HalfEm), + ideograph_or_western, + "half-em without a cl-24 candidate keeps an otherwise ambiguous mask" + ); + + let unknown_pair = ['(' as u32, 'x' as u32]; + assert!(listings_for_candidate(unknown_pair).is_empty()); + + let roles = [ + None, + Some(ClusterRole::DecimalPoint), + Some(ClusterRole::DigitGroupSeparator), + Some(ClusterRole::SentenceMedial), + Some(ClusterRole::SentenceTerminator), + Some(ClusterRole::GroupedNumeral), + Some(ClusterRole::UnitSymbol), + Some(ClusterRole::QuantitySymbol), + Some(ClusterRole::Formula), + Some(ClusterRole::WarichuBracket), + ]; + for character in ['(', ')', '.', '!', '1', 'A'] { + for role in roles { + for grouped in [false, true] { + assert_eq!( + narrow_by_role(ALL_CLASSES, role, character, grouped), + ref_narrow_by_role(ALL_CLASSES, role, character, grouped), + "role {role:?} for {character:?}" + ); + } + } + } + for (piece, frame) in [ + ("", Frame::FullEm), + ("🦀", Frame::FullEm), + ("🦀", Frame::Proportional), + ("abc", Frame::FullEm), + ("abc", Frame::Proportional), + ] { + for unlisted in [false, true] { + assert_eq!( + class_of( + piece, + frame, + None, + WritingMode::HorizontalTb, + unlisted, + false, + false + ), + ref_class_of( + piece, + frame, + None, + WritingMode::HorizontalTb, + unlisted, + false, + false + ) + ); + } + } + } + + #[test] + fn unicode_range_searches_include_both_endpoints_only() { + for range in IDEOGRAPH_RANGES { + for code_point in [range.first, range.last] { + let character = char::from_u32(code_point).expect("ideograph scalar"); + assert!(is_ideograph(character)); + } + if let Some(character) = range + .first + .checked_sub(1) + .and_then(char::from_u32) + .filter(|character| !ref_is_ideograph(*character)) + { + assert!(!is_ideograph(character)); + } + if let Some(character) = range + .last + .checked_add(1) + .and_then(char::from_u32) + .filter(|character| !ref_is_ideograph(*character)) + { + assert!(!is_ideograph(character)); + } + } + for range in SCRIPT_RANGES { + for code_point in [range.first, range.last] { + let character = char::from_u32(code_point).expect("script scalar"); + assert_eq!(script(character), Some(range.script)); + } + if let Some(character) = range.first.checked_sub(1).and_then(char::from_u32) { + let expected = SCRIPT_RANGES + .iter() + .find(|candidate| { + candidate.first <= character as u32 && character as u32 <= candidate.last + }) + .map(|candidate| candidate.script); + assert_eq!(script(character), expected); + } + if let Some(character) = range.last.checked_add(1).and_then(char::from_u32) { + let expected = SCRIPT_RANGES + .iter() + .find(|candidate| { + candidate.first <= character as u32 && character as u32 <= candidate.last + }) + .map(|candidate| candidate.script); + assert_eq!(script(character), expected); + } + } + } } diff --git a/crates/jlreq/src/style.rs b/crates/jlreq/src/style.rs index 93f83ed..4796519 100644 --- a/crates/jlreq/src/style.rs +++ b/crates/jlreq/src/style.rs @@ -722,3 +722,45 @@ impl core::fmt::Display for StyleError { formatter.write_str(self.message) } } + +impl core::error::Error for StyleError {} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + + #[test] + fn dated_profiles_override_every_documented_field() { + let book = Style::book_2020(); + assert_eq!(book.reduction_table(), ReductionTable::Table5); + assert_eq!( + book.line_head_opening_bracket(), + LineHeadOpeningBracket::Pattern3 + ); + assert_eq!(book.hanging_punctuation(), HangingPunctuation::Hanging); + + let jis = Style::jis_reading_2020(); + assert_eq!(jis.reduction_table(), ReductionTable::Table4); + assert_eq!(jis.line_end_punctuation(), LineEndPunctuation::Solid); + assert_eq!(jis.line_end_full_stop_comma(), LineEndFullStopComma::Jis); + assert_eq!(jis.ruby_overhang_kana(), RubyOverhangKana::Jis); + } + + #[test] + fn style_error_exposes_and_displays_its_message() { + let error = Style::builder() + .kinsoku_level(KinsokuLevel::VeryStrict) + .build() + .expect_err("very strict conflicts with the default breakable numeral policy"); + assert_eq!(error.code(), "style.very-strict-grouped-numeral"); + assert_eq!( + error.message(), + "very-strict kinsoku excludes a breakable grouped-numeral boundary" + ); + assert_eq!( + format!("{error}"), + "very-strict kinsoku excludes a breakable grouped-numeral boundary" + ); + } +} diff --git a/crates/jlreq/tests/public_api.rs b/crates/jlreq/tests/public_api.rs index dc0ffd6..5e3c806 100644 --- a/crates/jlreq/tests/public_api.rs +++ b/crates/jlreq/tests/public_api.rs @@ -5,8 +5,9 @@ //! Black-box acceptance tests for the intentionally small public API. use jlreq::{ - Alignment, Break, Cluster, ClusterRole, Construct, CoordinateTransform, Frame, Paragraph, Ruby, - RubyKind, RubyRun, ShapedText, Size, Style, TabAlignment, TabStop, Widow, WritingMode, + Alignment, Break, Cluster, ClusterRole, Composer, CompositionLimits, CompositionResource, + Construct, CoordinateTransform, Frame, Paragraph, Ruby, RubyKind, RubyRun, ShapedText, Size, + Style, TabAlignment, TabStop, Widow, WritingMode, style::{ AdjustmentPreference, AmbiguousContext, ExpansionOrder, GroupRubyDistribution, GroupedNumeralBeforeWestern, GroupedNumeralQualification, HangingPunctuation, @@ -17,6 +18,12 @@ use jlreq::{ }, }; +macro_rules! compose_ok { + ($paragraph:expr, $style:expr) => { + jlreq::compose($paragraph, $style).expect("fixture stays within composition limits") + }; +} + fn shaped(source: &str, frame: Frame, advance: i32) -> Result { let clusters = source.char_indices().map(|(start, character)| { Cluster::new(start..start.saturating_add(character.len_utf8()), advance) @@ -120,7 +127,7 @@ fn all_nine_constructs_compose_in_both_writing_modes() { .writing_mode(mode) .build() .expect("valid construct paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines().len(), 1); assert_eq!(layout.lines()[0].clusters().len(), 1); } @@ -137,7 +144,7 @@ fn jidori_fills_declared_cells_and_keeps_its_outer_boundary_separate() { .alignment(Alignment::Start) .build() .expect("valid jidori paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); let line = &layout.lines()[0]; assert_eq!( line.clusters() @@ -167,7 +174,7 @@ fn jidori_leaves_trailing_space_when_no_internal_boundary_can_expand() { .alignment(Alignment::Start) .build() .expect("valid closed jidori paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); let line = &layout.lines()[0]; assert_eq!(line.inline_extent(), 4_000); assert_eq!(line.clusters()[0].inline(), 0); @@ -187,7 +194,7 @@ fn vertical_western_text_exposes_upright_rotated_and_tate_chu_yoko_methods() { .writing_mode(WritingMode::VerticalRl) .build() .expect("valid upright paragraph"); - let upright_layout = jlreq::compose(&upright, &Style::default()); + let upright_layout = compose_ok!(&upright, &Style::default()); let upright_cluster = &upright_layout.lines()[0].clusters()[0]; assert_eq!(upright_cluster.writing_mode(), WritingMode::VerticalRl); assert_eq!(upright_cluster.transform(), CoordinateTransform::Identity); @@ -199,7 +206,7 @@ fn vertical_western_text_exposes_upright_rotated_and_tate_chu_yoko_methods() { .writing_mode(WritingMode::VerticalRl) .build() .expect("valid rotated paragraph"); - let rotated_layout = jlreq::compose(&rotated, &Style::default()); + let rotated_layout = compose_ok!(&rotated, &Style::default()); assert!(rotated_layout.lines()[0].clusters().iter().all(|cluster| { cluster.writing_mode() == WritingMode::VerticalRl && cluster.transform() == CoordinateTransform::RotateClockwise @@ -213,7 +220,7 @@ fn vertical_western_text_exposes_upright_rotated_and_tate_chu_yoko_methods() { .writing_mode(WritingMode::VerticalRl) .build() .expect("valid tate-chu-yoko paragraph"); - let tate_chu_yoko_layout = jlreq::compose(&tate_chu_yoko, &Style::default()); + let tate_chu_yoko_layout = compose_ok!(&tate_chu_yoko, &Style::default()); assert!( tate_chu_yoko_layout.lines()[0] .clusters() @@ -249,7 +256,7 @@ fn tate_chu_yoko_is_one_centered_solid_item_in_a_vertical_line() { .build() .expect("valid tate-chu-yoko paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); let first = &layout.lines()[0]; assert_eq!(first.inline_extent(), 4_000); assert_eq!(first.block_extent(), 1_200); @@ -315,7 +322,7 @@ fn tate_chu_yoko_punctuation_boundaries_follow_the_directional_half_em_rules() { .writing_mode(WritingMode::VerticalRl) .build() .expect("valid punctuation paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); let line = &layout.lines()[0]; let first_digit = line .clusters() @@ -353,7 +360,7 @@ fn tate_chu_yoko_punctuation_boundaries_follow_the_directional_half_em_rules() { .writing_mode(WritingMode::VerticalRl) .build() .expect("valid line-end paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines()[0].inline_extent(), 1_500); assert_eq!(layout.lines()[0].clusters()[0].advance(), 1_500); @@ -373,7 +380,7 @@ fn tate_chu_yoko_punctuation_boundaries_follow_the_directional_half_em_rules() { .writing_mode(WritingMode::VerticalRl) .build() .expect("valid multi-key paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!( layout.lines()[0].clusters()[1].inline(), 1_500, @@ -388,7 +395,7 @@ fn appendix_a_opening_brackets_are_not_limited_to_a_handwritten_subset() { .build() .expect("valid Appendix A paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); let line = &layout.lines()[0]; assert_eq!( line.clusters() @@ -421,7 +428,7 @@ fn sentence_medial_dividing_mark_choice_requires_the_declared_role() { .sentence_medial_dividing_mark(choice) .build() .expect("consistent dividing-mark style"); - jlreq::compose(¶graph, &style).lines()[0] + compose_ok!(¶graph, &style).lines()[0] .clusters() .iter() .map(jlreq::ClusterPlacement::inline) @@ -469,7 +476,7 @@ fn sentence_terminator_space_is_inserted_and_withdrawn_at_a_wrap() { let one_line = Paragraph::builder(text.clone(), 4_000) .build() .expect("valid one-line paragraph"); - let layout = jlreq::compose(&one_line, &Style::default()); + let layout = compose_ok!(&one_line, &Style::default()); assert_eq!(layout.lines().len(), 1); assert_eq!(layout.lines()[0].inline_extent(), 4_000); assert_eq!( @@ -486,7 +493,7 @@ fn sentence_terminator_space_is_inserted_and_withdrawn_at_a_wrap() { .breaks([Break::allowed(6)]) .build() .expect("valid wrapping paragraph"); - let layout = jlreq::compose(&wrapped, &Style::default()); + let layout = compose_ok!(&wrapped, &Style::default()); assert_eq!(layout.lines().len(), 2); assert_eq!(layout.lines()[0].inline_extent(), 2_000); assert_eq!(layout.lines()[1].clusters()[0].inline(), 0); @@ -514,7 +521,7 @@ fn classification_choices_change_black_box_spacing_and_breaks() { .unlisted_code_point(choice) .build() .expect("consistent unlisted style"); - jlreq::compose(¶graph, &style).lines()[0].clusters()[1].inline() + compose_ok!(¶graph, &style).lines()[0].clusters()[1].inline() }; assert_eq!(unlisted(UnlistedCodePoint::ByFrame), 950); assert_eq!(unlisted(UnlistedCodePoint::Ideographic), 700); @@ -539,7 +546,7 @@ fn classification_choices_change_black_box_spacing_and_breaks() { .ambiguous_context(choice) .build() .expect("consistent ambiguity style"); - jlreq::compose(¶graph, &style).lines()[0].clusters()[1].inline() + compose_ok!(¶graph, &style).lines()[0].clusters()[1].inline() }; assert_eq!(ambiguous(AmbiguousContext::LowestClass), 1_000); assert_eq!(ambiguous(AmbiguousContext::HighestClass), 1_250); @@ -566,7 +573,7 @@ fn classification_choices_change_black_box_spacing_and_breaks() { .grouped_numeral_qualification(choice) .build() .expect("consistent grouped-numeral style"); - jlreq::compose(¶graph, &style).lines().len() + compose_ok!(¶graph, &style).lines().len() }; assert_eq!(grouped(GroupedNumeralQualification::ByWidth), 2); assert_eq!(grouped(GroupedNumeralQualification::ByRole), 1); @@ -598,7 +605,7 @@ fn table_one_spaces_japanese_and_western_text_by_the_referents_em() { let paragraph = Paragraph::builder(text, 3_000) .build() .expect("valid mixed-size paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); let line = &layout.lines()[0]; ( line.clusters() @@ -643,7 +650,7 @@ fn contextual_decimal_punctuation_withdraws_its_ordinary_space() { .writing_mode(mode) .build() .expect("valid punctuation paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); let line = &layout.lines()[0]; ( line.clusters() @@ -730,7 +737,7 @@ fn western_word_space_collapses_only_at_true_line_edges() { .alignment(alignment) .build() .expect("valid word-space paragraph"); - jlreq::compose(¶graph, &Style::default()) + compose_ok!(¶graph, &Style::default()) } let edged = compose_ascii(" AB ", [], Alignment::Start, 2_000); @@ -812,7 +819,7 @@ fn warichu_builds_two_balanced_sublines_and_can_straddle_main_lines() { .writing_mode(mode) .build() .expect("valid balanced warichu paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines().len(), 1); let line = &layout.lines()[0]; assert_eq!(line.inline_extent(), 3_000); @@ -857,7 +864,7 @@ fn warichu_builds_two_balanced_sublines_and_can_straddle_main_lines() { .constructs([Construct::warichu(0..5)]) .build() .expect("word-space breaks are valid inside warichu"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines().len(), 1); assert_eq!(layout.lines()[0].inline_extent(), 250); assert_eq!( @@ -880,7 +887,7 @@ fn warichu_builds_two_balanced_sublines_and_can_straddle_main_lines() { .constructs([Construct::warichu(0..10)]) .build() .expect("valid straddling warichu paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines().len(), 2); assert_eq!(layout.lines()[0].range(), 0..5); assert_eq!(layout.lines()[1].range(), 5..10); @@ -942,7 +949,7 @@ fn the_reduction_ladder_does_not_reach_the_seam_between_a_warichu_s_sublines() { .constructs([Construct::warichu(3..12)]) .build() .expect("valid seam paragraph"); - jlreq::compose(¶graph, &Style::default()) + compose_ok!(¶graph, &Style::default()) } let natural = compose_at(16_000); @@ -999,7 +1006,7 @@ fn furawake_aligns_declared_sublines_and_never_becomes_an_outer_break() { .writing_mode(mode) .build() .expect("one declared split builds two furiwake lines"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines().len(), 1); let line = &layout.lines()[0]; assert_eq!(line.inline_extent(), 2_000); @@ -1057,7 +1064,7 @@ fn formula_spacing_width_and_breaks_follow_math_token_context() { .constructs([Construct::formula(3..6)]) .build() .expect("valid inline formula paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!( layout.lines()[0] .clusters() @@ -1079,7 +1086,7 @@ fn formula_spacing_width_and_breaks_follow_math_token_context() { .constructs([Construct::formula(0..5)]) .build() .expect("valid display formula paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!( layout.lines()[0] .clusters() @@ -1096,7 +1103,7 @@ fn formula_spacing_width_and_breaks_follow_math_token_context() { .constructs([Construct::formula(0..3)]) .build() .expect("either side of an equality token is a valid caller-declared break"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines().len(), 2); assert_eq!(layout.lines()[0].inline_extent(), 500); assert_eq!(layout.lines()[1].inline_extent(), 1_750); @@ -1108,7 +1115,7 @@ fn formula_spacing_width_and_breaks_follow_math_token_context() { .constructs([Construct::formula(0..8)]) .build() .expect("valid independent formula alternatives"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!( layout .lines() @@ -1151,7 +1158,7 @@ fn emphasis_dots_are_half_sized_centered_and_reserve_their_side() { .writing_mode(mode) .build() .expect("valid emphasis paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); let line = &layout.lines()[0]; assert_eq!(line.block_extent(), 1_500); assert_eq!(line.attachments().len(), 2); @@ -1180,7 +1187,7 @@ fn emphasis_dots_are_half_sized_centered_and_reserve_their_side() { ]) .build() .expect("two disjoint emphasis runs are valid"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines()[0].attachments().len(), 2); assert_eq!( layout.lines()[0].block_extent(), @@ -1221,7 +1228,7 @@ fn ruby_is_on_block_start_and_reserves_the_largest_annotation_size() { .writing_mode(mode) .build() .expect("valid ruby paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); let line = &layout.lines()[0]; assert_eq!(line.block_extent(), 1_700); assert_eq!(line.attachments().len(), 2); @@ -1254,7 +1261,7 @@ fn group_ruby_distribution_changes_leading_and_interior_shares() { .group_ruby_distribution(distribution) .build() .expect("consistent group-ruby style"); - jlreq::compose(¶graph, &style).lines()[0] + compose_ok!(¶graph, &style).lines()[0] .attachments() .iter() .map(jlreq::Attachment::inline) @@ -1296,7 +1303,7 @@ fn single_character_group_ruby_flush_stays_at_the_base_start() { .group_ruby_distribution(distribution) .build() .expect("consistent group-ruby style"); - jlreq::compose(¶graph, &style).lines()[0].attachments()[0].inline() + compose_ok!(¶graph, &style).lines()[0].attachments()[0].inline() }; assert_eq!(compose_with(GroupRubyDistribution::Jis), 850); @@ -1327,7 +1334,7 @@ fn group_ruby_longer_than_base_distributes_the_base_by_the_selected_method() { .group_ruby_distribution(distribution) .build() .expect("valid group-ruby style"); - jlreq::compose(¶graph, &style) + compose_ok!(¶graph, &style) }; let jis = compose_with(GroupRubyDistribution::Jis); @@ -1393,7 +1400,7 @@ fn ruby_kinds_preserve_base_associations_and_break_semantics() { .constructs([Construct::ruby(ruby)]) .build() .expect("valid ruby paragraph"); - jlreq::compose(¶graph, &Style::default()) + compose_ok!(¶graph, &Style::default()) }; let mono_layout = compose_kind(mono.clone()); @@ -1434,7 +1441,7 @@ fn ruby_kinds_preserve_base_associations_and_break_semantics() { .alignment(Alignment::Justify) .build() .expect("valid adjusted ruby paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!( layout.lines()[0].clusters()[1].inline(), expected_second_base, @@ -1451,7 +1458,7 @@ fn ruby_kinds_preserve_base_associations_and_break_semantics() { .constructs([Construct::ruby(ruby)]) .build() .expect("mono and jukugo may split at a declared run boundary"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines().len(), 2); assert_eq!(layout.lines()[0].attachments().len(), 1); assert_eq!(layout.lines()[0].attachments()[0].range(), 0..3); @@ -1501,7 +1508,7 @@ fn phonetic_jukugo_follows_runs_before_expanding_eligible_base_gaps() { .writing_mode(mode) .build() .expect("valid phonetic jukugo paragraph"); - jlreq::compose(¶graph, &phonetic) + compose_ok!(¶graph, &phonetic) }; let forward = compose( @@ -1740,7 +1747,7 @@ fn long_ruby_respects_neighbor_and_indent_overhang_budgets() { .alignment(Alignment::Start) .build() .expect("valid long-ruby paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!( layout.lines()[0] .clusters() @@ -1771,7 +1778,7 @@ fn long_ruby_respects_neighbor_and_indent_overhang_budgets() { .alignment(Alignment::Start) .build() .expect("valid overhang-neighbor paragraph"); - jlreq::compose(¶graph, &style) + compose_ok!(¶graph, &style) }; let ideograph = compose_preceding('前', Style::default()); let opening = compose_preceding('「', Style::default()); @@ -1841,7 +1848,7 @@ fn long_ruby_respects_neighbor_and_indent_overhang_budgets() { .alignment(Alignment::Start) .build() .expect("valid ruby-indent paragraph"); - jlreq::compose(¶graph, &style) + compose_ok!(¶graph, &style) }; let permitted = compose_indent(Style::default()); let prohibited = compose_indent( @@ -1866,7 +1873,7 @@ fn long_ruby_respects_neighbor_and_indent_overhang_budgets() { .alignment(Alignment::Start) .build() .expect("valid overhang-fixpoint paragraph"); - let fixpoint = jlreq::compose(&fixpoint, &Style::default()); + let fixpoint = compose_ok!(&fixpoint, &Style::default()); assert_eq!( fixpoint .lines() @@ -1901,7 +1908,7 @@ fn construct_run_boundaries_expand_at_third_order_but_not_inside_one_run() { .writing_mode(mode) .build() .expect("valid construct-run paragraph"); - jlreq::compose(¶graph, &Style::default()).lines()[0] + compose_ok!(¶graph, &Style::default()).lines()[0] .clusters() .iter() .map(jlreq::ClusterPlacement::inline) @@ -1971,7 +1978,7 @@ fn construct_run_boundaries_expand_at_third_order_but_not_inside_one_run() { .writing_mode(WritingMode::VerticalRl) .build() .expect("valid single tate-chu-yoko run"); - let same_tcy = jlreq::compose(&same_tcy, &Style::default()); + let same_tcy = compose_ok!(&same_tcy, &Style::default()); assert_eq!( same_tcy.lines()[0] .clusters() @@ -2008,7 +2015,7 @@ fn construct_run_boundaries_expand_at_third_order_but_not_inside_one_run() { .alignment(Alignment::Justify) .build() .expect("valid mixed-size complex paragraph"); - let mixed = jlreq::compose(&mixed, &Style::default()); + let mixed = compose_ok!(&mixed, &Style::default()); assert_eq!( mixed.lines()[0] .clusters() @@ -2112,10 +2119,7 @@ fn complex_break_rules_distinguish_internal_and_run_boundaries() { ]) .build() .expect("the boundary between distinct ornamented complexes is breakable"); - assert_eq!( - jlreq::compose(&separate, &Style::default()).lines().len(), - 2 - ); + assert_eq!(compose_ok!(&separate, &Style::default()).lines().len(), 2); } #[test] @@ -2141,7 +2145,7 @@ fn mandatory_discretionary_widow_and_tabs_share_the_paragraph_pipeline() { .alignment(Alignment::Start) .build() .expect("valid tab paragraph"); - let layout = jlreq::compose(&tabbed, &Style::default()); + let layout = compose_ok!(&tabbed, &Style::default()); assert_eq!(layout.lines()[0].clusters()[2].inline(), 3_000); let source = "日本語"; @@ -2151,7 +2155,7 @@ fn mandatory_discretionary_widow_and_tabs_share_the_paragraph_pipeline() { .widow(Widow::MinimumClusters(2)) .build() .expect("valid mixed-break paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines().len(), 2); assert_eq!(layout.lines()[0].range(), 0..6); } @@ -2172,6 +2176,49 @@ fn tab_characters_require_declared_stops() { .expect("the same tab-stop declaration is reusable on each mandatory-delimited line"); } +#[test] +fn tabs_set_off_the_line_do_not_consume_or_require_stops() { + let cases = [ + ( + Construct::tate_chu_yoko(1..2), + WritingMode::VerticalRl, + "vertical tate-chu-yoko", + ), + ( + Construct::warichu(1..2), + WritingMode::HorizontalTb, + "warichu", + ), + ( + Construct::furawake(1..2, 1, 0), + WritingMode::HorizontalTb, + "furawake", + ), + ]; + for (construct, mode, label) in cases { + let paragraph = Paragraph::builder( + shaped("A\tB", Frame::Proportional, 500).expect("valid tab fixture"), + 4_000, + ) + .constructs([construct]) + .writing_mode(mode) + .build() + .unwrap_or_else(|error| panic!("{label} rejected its internal tab: {error}")); + assert!(paragraph.tab_stops().is_empty()); + compose_ok!(¶graph, &Style::default()); + } + + let horizontal = Paragraph::builder( + shaped("A\tB", Frame::Proportional, 500).expect("valid horizontal fixture"), + 4_000, + ) + .constructs([Construct::tate_chu_yoko(1..2)]) + .writing_mode(WritingMode::HorizontalTb) + .build() + .expect_err("horizontal tate-chu-yoko is ordinary inline text"); + assert_eq!(horizontal.code(), "input.insufficient-tab-stops"); +} + #[test] fn tab_alignments_are_direction_independent() { let cases = [ @@ -2190,7 +2237,7 @@ fn tab_alignments_are_direction_independent() { .writing_mode(writing_mode) .build() .expect("valid tab paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!( [ layout.lines()[0].clusters()[2].inline(), @@ -2210,7 +2257,7 @@ fn exhausted_tab_positions_continue_on_the_next_line() { .alignment(Alignment::Start) .build() .expect("valid overflowing tab paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines().len(), 2); assert_eq!(layout.lines()[0].range(), 0..4); @@ -2237,7 +2284,7 @@ fn a_tab_sign_that_opens_a_tate_chu_yoko_run_is_set_in_the_run() { .writing_mode(WritingMode::VerticalRl) .build() .expect("valid tate-chu-yoko tab paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines().len(), 1); let line = &layout.lines()[0]; @@ -2283,7 +2330,7 @@ fn a_tab_sign_inside_a_warichu_takes_no_stop_and_steps_no_cursor() { .alignment(Alignment::Start) .build() .expect("valid warichu tab paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines().len(), 1); let line = &layout.lines()[0]; @@ -2319,10 +2366,7 @@ fn segmenter_offsets_can_include_paragraph_boundaries_verbatim() { assert_eq!(paragraph.breaks().len(), 2); assert_eq!(paragraph.breaks()[0].offset(), 3); assert!(paragraph.breaks()[1].is_mandatory()); - assert_eq!( - jlreq::compose(¶graph, &Style::default()).lines().len(), - 2 - ); + assert_eq!(compose_ok!(¶graph, &Style::default()).lines().len(), 2); } #[test] @@ -2334,7 +2378,7 @@ fn table_two_prohibits_a_closing_bracket_at_every_kinsoku_level() { .build() .expect("valid break opportunities"); - let layout = jlreq::compose(¶graph, &Style::newspaper_2020()); + let layout = compose_ok!(¶graph, &Style::newspaper_2020()); assert_eq!(layout.lines()[0].range(), 0..6); } @@ -2344,7 +2388,7 @@ fn lines_at_only_boundary(text: ShapedText, offset: usize, style: &Style) -> Opt .alignment(Alignment::Start) .build() .ok()?; - Some(jlreq::compose(¶graph, style).lines().len()) + Some(compose_ok!(¶graph, style).lines().len()) } #[test] @@ -2492,7 +2536,7 @@ fn reduction_tables_apply_their_distinct_floor_to_an_overfull_boundary() { .reduction_table(table) .build() .expect("consistent reduction style"); - jlreq::compose(¶graph, &style) + compose_ok!(¶graph, &style) }; for table in [ReductionTable::Table3, ReductionTable::Table4] { @@ -2513,7 +2557,7 @@ fn reduction_uses_lower_priority_stages_only_after_earlier_ones() { .alignment(Alignment::Start) .build() .expect("valid staged paragraph"); - let layout = jlreq::compose(¶graph, &Style::jlreq_2020()); + let layout = compose_ok!(¶graph, &Style::jlreq_2020()); let positions: Vec<_> = layout.lines()[0] .clusters() .iter() @@ -2541,7 +2585,7 @@ fn western_word_space_reduces_first_and_keeps_a_quarter_em() { .alignment(Alignment::Start) .build() .expect("valid Western-space paragraph"); - let layout = jlreq::compose(¶graph, &Style::jlreq_2020()); + let layout = compose_ok!(¶graph, &Style::jlreq_2020()); assert_eq!(layout.lines()[0].clusters()[2].inline(), 1_250); assert_eq!(layout.lines()[0].inline_extent(), 2_250); @@ -2567,7 +2611,7 @@ fn generated_reduction_tables_include_the_line_end_axis() { .reduction_table(table) .build() .expect("consistent reduction style"); - jlreq::compose(¶graph, &style) + compose_ok!(¶graph, &style) }; assert_eq!( @@ -2599,7 +2643,7 @@ fn opening_bracket_line_head_patterns_cover_first_and_wrapped_lines() { .line_head_opening_bracket(pattern) .build() .expect("consistent line-head style"); - jlreq::compose(¶graph, &style).lines()[0].clusters()[0].inline() + compose_ok!(¶graph, &style).lines()[0].clusters()[0].inline() }; assert_eq!(compose_first(LineHeadOpeningBracket::Pattern1), 1_000); assert_eq!(compose_first(LineHeadOpeningBracket::Pattern2), 1_500); @@ -2615,7 +2659,7 @@ fn opening_bracket_line_head_patterns_cover_first_and_wrapped_lines() { .line_head_opening_bracket(pattern) .build() .expect("consistent line-head style"); - jlreq::compose(¶graph, &style).lines()[1].clusters()[0].inline() + compose_ok!(¶graph, &style).lines()[1].clusters()[0].inline() }; assert_eq!(compose_wrapped(LineHeadOpeningBracket::Pattern1), 0); assert_eq!(compose_wrapped(LineHeadOpeningBracket::Pattern2), 500); @@ -2642,7 +2686,7 @@ fn hanging_punctuation_closes_only_the_shortfall_reduction_leaves() { .hanging_punctuation(hanging) .build() .expect("consistent hanging style"); - jlreq::compose(¶graph, &style) + compose_ok!(¶graph, &style) }; let none = compose_with(HangingPunctuation::None); @@ -2671,7 +2715,7 @@ fn optimal_search_counts_reducible_space_before_choosing_a_break() { .alignment(Alignment::Start) .build() .expect("valid reducible search paragraph"); - let layout = jlreq::compose(¶graph, &Style::jlreq_2020()); + let layout = compose_ok!(¶graph, &Style::jlreq_2020()); assert_eq!(layout.lines().len(), 1); assert_eq!(layout.lines()[0].inline_extent(), 3_000); @@ -2685,7 +2729,7 @@ fn table_six_does_not_expand_a_boundary_it_marks_closed() { .alignment(Alignment::Justify) .build() .expect("valid closed-expansion paragraph"); - let layout = jlreq::compose(¶graph, &Style::jlreq_2020()); + let layout = compose_ok!(¶graph, &Style::jlreq_2020()); assert_eq!(layout.lines()[0].inline_extent(), 2_000); } @@ -2717,7 +2761,7 @@ fn japanese_latin_expansion_ceiling_changes_the_stage_distribution() { .japanese_latin_expansion_ceiling(ceiling) .build() .expect("consistent expansion style"); - jlreq::compose(¶graph, &style) + compose_ok!(¶graph, &style) }; let half = compose_with(JapaneseLatinExpansionCeiling::HalfEm); @@ -2749,7 +2793,7 @@ fn western_word_space_expands_to_half_an_em_at_the_first_stage() { .alignment(Alignment::Justify) .build() .expect("valid Western expansion paragraph"); - let layout = jlreq::compose(¶graph, &Style::jlreq_2020()); + let layout = compose_ok!(¶graph, &Style::jlreq_2020()); assert_eq!(layout.lines()[0].clusters()[1].inline(), 1_000); assert_eq!(layout.lines()[0].clusters()[2].inline(), 1_500); @@ -2763,12 +2807,86 @@ fn composer_reuses_scratch_without_borrowing_the_returned_layout() { .build() .expect("valid paragraph"); let mut composer = jlreq::Composer::new(); - let first = composer.compose(¶graph, &Style::default()); - let second = composer.compose(¶graph, &Style::book_2020()); + let first = composer + .compose(¶graph, &Style::default()) + .expect("composition succeeds"); + let second = composer + .compose(¶graph, &Style::book_2020()) + .expect("composition succeeds"); assert_eq!(first.lines().len(), 2); assert_eq!(second.lines().len(), 2); } +#[test] +fn composition_limits_are_typed_atomic_and_reusable() { + fn assert_error() {} + assert_error::(); + assert_error::(); + assert_error::(); + + let defaults = CompositionLimits::default(); + assert_eq!(defaults.max_clusters(), 65_536); + assert_eq!(defaults.max_break_candidates(), 65_536); + assert_eq!(defaults.max_constructs(), 4_096); + assert_eq!(defaults.max_tab_stops(), 4_096); + assert_eq!(defaults.max_search_transitions(), 8_000_000); + + let paragraph = Paragraph::builder( + shaped("日本語", Frame::FullEm, 0).expect("valid zero-width fixture"), + 1_000, + ) + .breaks([Break::allowed(3), Break::allowed(6)]) + .build() + .expect("valid pathological paragraph"); + let limits = defaults.with_max_search_transitions(1); + let mut composer = Composer::with_limits(limits); + assert_eq!(composer.limits(), limits); + let error = composer + .compose(¶graph, &Style::default()) + .expect_err("the exact search must stop at its declared budget"); + assert_eq!(error.code(), "compose.transition-limit"); + assert_eq!(error.resource(), CompositionResource::SearchTransitions); + assert_eq!(error.limit(), 1); + assert_eq!(error.observed(), 2); + + composer.set_limits(defaults); + let recovered = composer + .compose(¶graph, &Style::default()) + .expect("the composer is reusable after an error"); + assert!(!recovered.lines().is_empty()); + + let static_error = Composer::with_limits(defaults.with_max_clusters(2)) + .compose(¶graph, &Style::default()) + .expect_err("three clusters exceed the configured limit"); + assert_eq!(static_error.code(), "compose.cluster-limit"); + assert_eq!(static_error.resource(), CompositionResource::Clusters); + assert_eq!(static_error.limit(), 2); + assert_eq!(static_error.observed(), 3); + + let empty = Paragraph::builder( + ShapedText::new( + "", + Size::square(1_000).expect("positive size"), + Frame::FullEm, + [], + ) + .expect("valid empty text"), + 1_000, + ) + .build() + .expect("valid empty paragraph"); + let zero = CompositionLimits::default() + .with_max_clusters(0) + .with_max_break_candidates(0) + .with_max_constructs(0) + .with_max_tab_stops(0) + .with_max_search_transitions(0); + let layout = Composer::with_limits(zero) + .compose(&empty, &Style::default()) + .expect("an empty paragraph always succeeds"); + assert!(layout.lines().is_empty()); +} + #[test] fn line_extent_is_occupied_width_not_the_shifted_end_coordinate() { for (alignment, origin) in [ @@ -2781,7 +2899,7 @@ fn line_extent_is_occupied_width_not_the_shifted_end_coordinate() { .alignment(alignment) .build() .expect("valid aligned paragraph"); - let layout = jlreq::compose(¶graph, &Style::default()); + let layout = compose_ok!(¶graph, &Style::default()); assert_eq!(layout.lines()[0].inline_origin(), origin); assert_eq!(layout.lines()[0].inline_extent(), 1_000); } diff --git a/data/manifest.toml b/data/manifest.toml index 6200361..f4bf28d 100644 --- a/data/manifest.toml +++ b/data/manifest.toml @@ -11,7 +11,7 @@ [[file]] path = "ROADMAP.md" -sha256 = "a6b10efed08e330003e172ee7fd9fcf1a8b3b81e5df62c731833df3e685020b6" +sha256 = "8d2b11a5e4ae31c948c625a7f4c4828dbac3c68294f727fa480cc3edeff499e4" [[file]] path = "crates/jlreq-conformance/protocol.schema.json" @@ -23,52 +23,52 @@ sha256 = "8f8f70b5f193a140994c8ca42bde5e46a445f55465b6b098012f7ffd0ccbadcf" [[file]] path = "crates/jlreq/src/generated/appendix_a.rs" -sha256 = "a15fbbc79376857b1fa9ee4dab2b952917d19da3b68e49cb185a962e8180cef7" +sha256 = "00761c3eb613d272f3713e9c6b71383540a273021971a2c22b615eed23f8959a" [[file]] path = "crates/jlreq/src/generated/folding.rs" -sha256 = "e9252275a61aa1173010453f98ba50b2fa73a4e207e6a7cb75c602fe4ee07b7f" +sha256 = "11ed0a1852127cadc94ca98efcc6c51a8daedfb33e10033b07abae6353cc5fe0" [[file]] path = "crates/jlreq/src/generated/ideograph.rs" -sha256 = "d373eb12d21a49e0e8f1486deb6393f6142299652cdb17bd5e47ffb153b57536" +sha256 = "13e3bb408d7148add03d2348c1360f33cdd71a462f0f4291ad8e5f38af41aef8" [[file]] path = "crates/jlreq/src/generated/script.rs" -sha256 = "4f52398632c2e28ac508726c2282f24109ddd19d72d048d4961e46cc5d995a72" +sha256 = "ad15e49f34640d3f949ca7e2a3a3f64315ce68266102759051eb4fe12cace6af" [[file]] path = "crates/jlreq/src/generated/table1.rs" -sha256 = "5812f8df8d1fb21f644bc6c5455be33df48e79188a452ec98c867d91289aa8b9" +sha256 = "6de73a04480c3ee32ee59d7d23128aa82783dc0ccbde8559f3be17b77482105c" [[file]] path = "crates/jlreq/src/generated/table2.rs" -sha256 = "96019ce3b0e56573d3469ab9add361c5e10c74efe4ccf3546fc72ec38d641e23" +sha256 = "4bb486fd05ae95896b728d26f1b14774a531fd83f7fec05e571530914d59a751" [[file]] path = "crates/jlreq/src/generated/table3.rs" -sha256 = "192b43ccf7eb5a4f14018927c308c4b564531754933218887804915dc3ae4445" +sha256 = "9427c738b22ee41090a52b9d2d3f0f1c82c1b072bc26660940ede1d394648176" [[file]] path = "crates/jlreq/src/generated/table4.rs" -sha256 = "800b3eed5d30e8791c43b9380f40d924e14c214cc194fb1cb75ef563451050a3" +sha256 = "2cc7292a8b5bbeeaa04dc693c02e68012230f7c0aee10fdd3824f0f68f8bbd47" [[file]] path = "crates/jlreq/src/generated/table5.rs" -sha256 = "04807f9ab3c637200a27b0fecd52e1c9917ce6f26ad429665f7b8f4aa2f2d9ff" +sha256 = "ee099abbea8b724d9ffbb2fc5dfed43fa09fac21851d1cf67ff7ca5ce91ba3bb" [[file]] path = "crates/jlreq/src/generated/table6.rs" -sha256 = "387afbffa61adb57be7d92bdc58d1e99a5b6596d606f498ae40dc5fa4c8c2092" - -[[file]] -path = "docs/api-1.0.toml" -sha256 = "3b4cd6e0d110ff44523f16d9aaa0074330f1b8f5234b9423264487c580d66c71" +sha256 = "0eeebc30219709e4cdc8d78f36981e0df9e90fe9097550f1ef636aec5fd4d6ea" [[file]] path = "docs/conformance-deferrals.toml" sha256 = "e75e3651a359f3886308b0a01867d18538d4e62423498f405c9b5e1e8c204221" +[[file]] +path = "docs/public-api.toml" +sha256 = "419582158ccc214d12a6f0258a75ed5eb78b905abfc0f0202026fc6f294a0517" + [[file]] path = "spec/PROVENANCE.toml" sha256 = "8cbb75be7fb4ad0fd4b18faa3973f2f6b5a28439a78f42ef219489f9876c7904" @@ -183,7 +183,7 @@ sha256 = "2e1efc1dcb59c575eedf5ccae60f95229f706ee6d031835247d843c11d96470c" [[file]] path = "xtask/src/api.rs" -sha256 = "04ea79a347b720cfd3c0aca0dcf98cfd5c450bb335266a707cdbe7c821a11eae" +sha256 = "d2d3c985110fd526be7500fbfa75517b7ff0e986896faf0921f326c0cc5b442d" [[file]] path = "xtask/src/attest.rs" @@ -211,7 +211,7 @@ sha256 = "b4811f9b3fd18724d82f43c8a1ec82252e865c263c75872dd8a6b6ec9615747e" [[file]] path = "xtask/src/generate.rs" -sha256 = "0a179849cca8b659685aa55b80144fdb0c75faf64adfddce76fd1b9daab7595a" +sha256 = "81cc82c92a695f636510c901ace253cdd39a32bbc515312c945be8d7985064b3" [[file]] path = "xtask/src/inventory.rs" diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..2ed51d7 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,89 @@ + + +# Release procedure + +This guide separates reversible preparation from irreversible publication. Cargo documents +that a published version is permanent: it cannot be overwritten or deleted, and yanking +only prevents new resolutions. Always inspect `cargo package`/`cargo publish --dry-run` +output first ([Cargo publishing reference](https://doc.rust-lang.org/cargo/reference/publishing.html)). + +## Prepared state + +`just release-check` is the single non-publishing acceptance command. It requires a clean +tracked tree and verifies generation/attestation/design gates, tests and doctests, coverage, +full mutation runs, all three reference engines and the 122,199-case census, MSRV 1.85, +`no_std`, WASM, the public API contract, package contents, extracted crate builds, and CLI +installation. At 0.1.0 the semver gate fixes the local baseline without contacting a +registry; later 0.1.x candidates are checked in patch mode against the latest published +jlreq release. All repository-owned shell entry points are checked by ShellCheck 0.11.0. +The manual +`Release check` workflow runs that command and builds the six target archives: + +- Linux x86_64 GNU and musl, and Linux AArch64 GNU; +- Windows x86_64 MSVC; and +- macOS x86_64 and AArch64. + +`just publish-dry-run` is part of that command and contacts crates.io for Cargo's complete +publication preflight, but Cargo retains both uploads locally. The conformance dry-run uses +the packaged local `jlreq` only because 0.1.0 is not in the index before the first release. + +The mutation step first verifies [the exclusion ledger](mutation-ledger.toml): all ten +generated table files have individual hashes, and each of the five proven-equivalent +mutants has one exact regex, source hash, and proof. Handwritten integrity checks are not +excluded. + +Each archive contains `jlreq-conformance`, `jlreq-sample-engine`, the repository README, +and both license texts. The workflow emits SHA-256 files and target-scoped CycloneDX 1.5 +JSON, validates its component, version, license, and target triple, and records GitHub +build-provenance and SBOM attestations. Verify downloaded artifacts with `sha256sum -c`; +after publication, consumers can additionally use `gh attestation verify`. + +## First publication checklist + +The first crates.io release cannot use Trusted Publishing. crates.io requires one manual +release before a Trusted Publisher can be configured +([Rust announcement](https://blog.rust-lang.org/2025/07/11/crates-io-development-update-2025-07/)). +The `Release` workflow therefore has an `initial-token` mode protected by the `release` +GitHub environment and an exact confirmation phrase. It is intentionally never triggered +by a push or tag. + +Before approving it: + +- [ ] Replace the `Unreleased` heading with `0.1.0` and the approved date; update the + comparison link. +- [ ] Confirm the candidate commit passed `Release check` and record that workflow run ID. +- [ ] Download its artifacts, verify every checksum and attestation, and inspect both + `.crate` archives. +- [ ] Confirm the `jlreq` and `jlreq-conformance` names and the 0.1.0 version have not + already been published. +- [ ] Store a narrowly scoped crates.io token in the `release` environment as + `CRATES_IO_TOKEN`, require environment approval, and enter the exact workflow + confirmation phrase. + +The workflow performs the irreversible sequence in this order: + +1. publish `jlreq` 0.1.0; +2. wait until that exact version is visible in the crates.io index; +3. publish `jlreq-conformance` 0.1.0; +4. create and push `v0.1.0`; and +5. create the GitHub Release from the already attested archives, checksums, SBOMs, and + `.crate` files. + +If Cargo times out while polling the index, check crates.io before retrying: the upload may +already be permanent. Never rerun a publish blindly. + +## Trusted Publishing after 0.1.0 + +After both first uploads succeed, configure each crate's Trusted Publisher for this +repository, `.github/workflows/release.yml`, and the `release` environment. Future releases +select `trusted-publishing`; the official +[`rust-lang/crates-io-auth-action`](https://github.com/rust-lang/crates-io-auth-action) +exchanges GitHub OIDC for a short-lived crates.io token and revokes it after the job. +Remove the long-lived `CRATES_IO_TOKEN` secret after verifying the OIDC path. + +Tag creation, GitHub Release creation, Trusted Publisher configuration, branch-protection +changes, and the two uploads are external mutations. None is part of `just release-check`. diff --git a/docs/design/api-spine.md b/docs/design/api-spine.md index bd9aaab..56ea8b3 100644 --- a/docs/design/api-spine.md +++ b/docs/design/api-spine.md @@ -1,12 +1,18 @@ -# The candidate 1.0 API spine +# The 0.1.0 API spine This is the human-readable contract for the only public Rust library, `jlreq`. The exact directly exported names and the 22 Style mappings are machine-readable in -[`docs/api-1.0.toml`](../api-1.0.toml) and checked in both directions by `xtask api`. +[`docs/public-api.toml`](../public-api.toml) and checked in both directions by `xtask api`. -This document describes a candidate 1.0 contract for the unreleased `0.0.0` workspace. It -is a mechanical design control, not a compatibility promise. The old multi-crate API is not -carried forward and has no compatibility facade. +This document describes the 0.1.0 contract. It is both a mechanical design control and the +compatibility floor for 0.1.x. The old multi-crate API is not carried forward and has no +compatibility facade. + +Before the first publication, `just semver` enforces the local export and Style mapping in +`docs/public-api.toml`; there is no registry baseline to compare yet. For every later 0.1.x +candidate the same required gate uses `cargo-semver-checks` in patch mode against the latest +normal, non-yanked jlreq release. This rolling baseline protects APIs added by intermediate +0.1.x releases as well as the original 0.1.0 surface. ## Principles @@ -14,8 +20,8 @@ carried forward and has no compatibility facade. - All public geometry is a bounded `i32` in the caller's unit. - Inputs are already shaped. Font I/O, shaping, UAX #14, bidi, and rendering are out of scope. -- `ParagraphBuilder::build` is the validation boundary. Composition of a validated - paragraph does not fail. +- `ParagraphBuilder::build` is the representation-validation boundary. Composition of a + validated paragraph returns either a complete exact layout or a typed resource error. - Classification, spacing records, lowering seams, feasibility, ladders, badness, and rule IDs are private. - Public result types are read-only views with private fields. @@ -29,11 +35,12 @@ let text = ShapedText::new(source, Size::square(1_000)?, Frame::FullEm, clusters let paragraph = Paragraph::builder(text, 20_000) .breaks(break_offsets.map(Break::allowed)) .build()?; -let layout = jlreq::compose(¶graph, &Style::book_2020()); +let layout = jlreq::compose(¶graph, &Style::book_2020())?; for line in layout.lines() { for placement in line.clusters() { - draw(placement); + // Pass `placement` to the caller's renderer. + let _ = placement; } } ``` @@ -105,7 +112,7 @@ rejects contradictions at `build()`. Profiles are: - `jis_reading_2020` (the alternatives JLReq records, not complete JIS X 4051 conformance). The `jlreq::style` namespace contains the 22 dedicated choice enums. Their complete names -and specification paths live in `docs/api-1.0.toml`; generic `Question`, `Choice`, and +and specification paths live in `docs/public-api.toml`; generic `Question`, `Choice`, and string-setting types are intentionally absent. ## Results @@ -133,10 +140,12 @@ reference string are contractual. It exposes no internal rule sequence. `InputError` means no valid paragraph could be built; its stable code and optional range are for programs, while its message may improve. `StyleError` similarly exposes a stable -conflict code. +conflict code. `ComposeError` exposes a stable code, resource, limit, and observed count; +it never carries a partial layout. `Style::default()` never changes meaning. A new specification revision adds a dated profile and a new specification identifier. The process protocol is versioned separately. -The candidate 1.0 design has no planned compatibility layer for the former experimental -API. Until an explicit release decision, all of this surface may still change. +The 0.1.0 design has no compatibility layer for the former experimental API. After 0.1.0, +compatible 0.1.x releases preserve this surface and its stable codes; incompatible changes +require a semver-minor release while the major version is zero. diff --git a/docs/design/conformance.md b/docs/design/conformance.md index 6a4a072..c56e5de 100644 --- a/docs/design/conformance.md +++ b/docs/design/conformance.md @@ -36,14 +36,17 @@ bounds, UTF-8 cluster coverage, and unknown fields in addition to the envelope v ## Commands ```text -jlreq-conformance list [SUITE.ndjson] -jlreq-conformance validate [SUITE.ndjson|-] -jlreq-conformance run ENGINE [SUITE.ndjson] +jlreq-conformance [OPTIONS] list [SUITE.ndjson] +jlreq-conformance [OPTIONS] validate [SUITE.ndjson|-] +jlreq-conformance [OPTIONS] run ENGINE [SUITE.ndjson] ``` With no suite path, `list` and `run` use the built-in suite. `validate` reads stdin unless a -path is provided. `run` starts `ENGINE` once, sends every request to its stdin, closes -stdin, then reads one response per request from stdout. +path is provided. `run` starts `ENGINE` once and concurrently writes requests, reads +responses, drains stderr, and watches the child. The defaults are a 30-second no-progress +timeout, 1 MiB per message, 256 MiB per stream, and 200,000 cases; the corresponding +`--timeout-seconds`, `--max-message-bytes`, `--max-suite-bytes`, and `--max-cases` options +may lower or raise them. `--verbose` prints a bounded first JSON difference. Exit codes are fixed: @@ -53,9 +56,9 @@ Exit codes are fixed: | 1 | one or more observable results differed | | 2 | invalid JSON/input, protocol mismatch, process error, or malformed response | -Response IDs must occur in request order and match exactly. A missing, extra, or reordered -response is a protocol error. A valid response whose result differs from `expected` is a -conformance difference. +Responses may arrive in any order and are associated by their unique `id`. A duplicate, +unknown, missing, or extra response is a protocol error. A valid response whose result +differs from `expected` is a conformance difference. ## Request model @@ -112,8 +115,10 @@ falsifiable. [`engines/`](../../engines/) holds independent implementations of t that make the claim testable. They are not products: each is outside the Cargo workspace, [ADR 0022](../adr/0022-unified-public-crate-and-process-conformance.md)'s public-surface gates (`purity`, `api`, `direction`, `derive`, `generate`) never see them, and none is a -`jlreq` dependency in either direction. The first is [`engines/ocaml/`](../../engines/ocaml/README.md); -`engines/racket/` follows the same shape. +`jlreq` dependency in either direction. The two are +[`engines/ocaml/`](../../engines/ocaml/README.md) and +[`engines/racket/`](../../engines/racket/README.md); both implement the complete built-in +suite and participate in every release census. They exist for two reasons: @@ -216,3 +221,7 @@ Editorial guidance and statements no layout result can observe carry explicit `editorial` or `non-observable` classifications with evidence; empty cases never count as coverage. The bundled sample engine runs the complete protocol-v1 suite as an external process using only this contract. + +The release census enumerates all ten generator kinds and compares all three engine +pairings. Its case counts and zero-difference result are generated, never copied into this +document by hand: see the [current census summary](../generated/conformance-summary.md). diff --git a/docs/design/generation.md b/docs/design/generation.md index df38fc8..b6d0175 100644 --- a/docs/design/generation.md +++ b/docs/design/generation.md @@ -200,7 +200,7 @@ laundered into a requirement on the way to the generated table. Three further controls hold that file. `docs/design/api-spine.md` publishes one `Question` constant per row and the `api` gate subtracts the two lists in both directions, so a constant nobody read the specification for and a row no caller can name each fail the build. -`docs/api-1.0.toml` maps every one of the twenty-two questions to a dedicated public enum +`docs/public-api.toml` maps every one of the twenty-two questions to a dedicated public enum and records its answer count; the API gate holds those mappings against the derived rows in both directions. A `divergent` row is also checked for still diverging: the derivation compares the character classes the two renderings of its address cite and refuses @@ -448,7 +448,7 @@ which heading closes a class name — so a change to one rewrites the meaning of rows with the source digest, the specification date and the entry count all unchanged. Every derived file therefore states `Reader:` and `Reader SHA-256:`, every generated file states `Generator:` and `Generator SHA-256:`, and `data/manifest.toml` records a digest for each of -those modules. This replaces a `Generator: xtask 0.0.0` line taken from the shared workspace +those modules. This replaces the former line that recorded only xtask's shared workspace version, which moved on a release and never on a change to a generator: churn where information was wanted, and the one recorded identifier that could not distinguish two generators. diff --git a/docs/error-codes.md b/docs/error-codes.md new file mode 100644 index 0000000..f04a567 --- /dev/null +++ b/docs/error-codes.md @@ -0,0 +1,74 @@ + + +# Stable error and diagnostic codes + +The code is the compatibility key; prose messages may become clearer in patch releases. +`InputError` reports invalid caller data, `StyleError` reports contradictory settings, +`ComposeError` reports an atomic resource refusal, and `Diagnostic` describes a complete +layout that was necessarily degraded. `just repository` compares this table with every +literal in the handwritten product source in both directions. + +## Input errors + +| Code | Meaning | +| --- | --- | +| `input.invalid-size` | A size is not positive on both axes. | +| `input.cluster-out-of-range` | A cluster is empty or outside its source. | +| `input.invalid-utf8-boundary` | A range endpoint splits a UTF-8 code point. | +| `input.negative-advance` | A shaped cluster advance is negative. | +| `input.overlapping-clusters` | Cluster coverage overlaps. | +| `input.uncovered-text` | Cluster coverage leaves source bytes uncovered. | +| `input.cluster-covers-multiple-keys` | One non-proportional cluster hides multiple JLReq keys. | +| `input.empty-construct` | A ruby or other structure has an empty base. | +| `input.ruby-without-runs` | Ruby has no base-to-annotation run. | +| `input.group-ruby-run-count` | Group ruby does not contain exactly one run. | +| `input.invalid-ruby-base-run` | Ruby base runs do not partition the declared base. | +| `input.invalid-ruby-annotation-run` | Ruby annotation runs do not partition shaped annotation. | +| `input.incomplete-ruby-runs` | Ruby runs do not cover both streams completely. | +| `input.invalid-line-extent` | The line extent is not positive. | +| `input.invalid-indent` | The first-line indent leaves no positive measure. | +| `input.break-splits-cluster` | A declared break is not a shaped-cluster boundary. | +| `input.duplicate-break` | More than one break is declared at an offset. | +| `input.construct-out-of-range` | A structure is empty or outside the source. | +| `input.construct-splits-cluster` | A structure endpoint splits a cluster. | +| `input.crossing-constructs` | Structure ranges cross instead of nesting or remaining disjoint. | +| `input.break-inside-construct` | A break violates the selected structure's break model. | +| `input.mono-ruby-run-shape` | Mono ruby does not map one base cluster per run. | +| `input.ruby-run-splits-cluster` | A ruby run endpoint splits a base cluster. | +| `input.invalid-furawake-columns` | Furawake has no columns. | +| `input.invalid-furawake-line-gap` | Furawake has a negative line gap. | +| `input.furawake-split-count` | Furawake break declarations do not match its columns. | +| `input.furawake-empty-subline` | A furawake declaration creates an empty subline. | +| `input.invalid-jidori-cells` | Jidori has no cells. | +| `input.invalid-tab-stop` | A tab stop position is not positive. | +| `input.duplicate-tab-stop` | Tab stop positions are not strictly increasing. | +| `input.tab-stop-outside-line` | A tab stop is beyond the usable line extent. | +| `input.insufficient-tab-stops` | A mandatory-line partition has more line tabs than stops. | + +## Style errors + +| Code | Meaning | +| --- | --- | +| `style.very-strict-relaxation` | Very-strict kinsoku is combined with relaxation. | +| `style.very-strict-grouped-numeral` | Very-strict kinsoku permits a grouped-numeral break. | + +## Composition resource errors + +| Code | Meaning | +| --- | --- | +| `compose.cluster-limit` | The paragraph exceeds the configured cluster limit. | +| `compose.break-candidate-limit` | The paragraph exceeds the break-candidate limit. | +| `compose.construct-limit` | The paragraph exceeds the structure limit. | +| `compose.tab-stop-limit` | The paragraph exceeds the tab-stop limit. | +| `compose.transition-limit` | Exact search exceeds the configured transition budget. | + +## Layout diagnostics + +| Code | Meaning | +| --- | --- | +| `layout.overfull` | A complete line remains wider than its measure after permitted adjustment. | +| `layout.widow` | The complete layout cannot meet the requested final-line cluster minimum. | diff --git a/docs/generated/conformance-summary.md b/docs/generated/conformance-summary.md new file mode 100644 index 0000000..d9adcee --- /dev/null +++ b/docs/generated/conformance-summary.md @@ -0,0 +1,20 @@ + + + +# Conformance census summary + +Protocol `jlreq.conformance/1`, specification `jlreq-2020-08-11+unicode-17.0.0`. + +| Census | Cases | Rust ↔ OCaml | Rust ↔ Racket | OCaml ↔ Racket | +| --- | ---: | ---: | ---: | ---: | +| `spacing` | 2116 | 0 | 0 | 0 | +| `break` | 2116 | 0 | 0 | 0 | +| `reduction` | 3174 | 0 | 0 | 0 | +| `expansion` | 3174 | 0 | 0 | 0 | +| `vertical` | 5290 | 0 | 0 | 0 | +| `tate-chu-yoko` | 4761 | 0 | 0 | 0 | +| `ruby` | 37030 | 0 | 0 | 0 | +| `constructs` | 20102 | 0 | 0 | 0 | +| `tabs` | 31211 | 0 | 0 | 0 | +| `widow` | 13225 | 0 | 0 | 0 | +| **Total** | **122199** | **0** | **0** | **0** | diff --git a/docs/mutation-ledger.toml b/docs/mutation-ledger.toml new file mode 100644 index 0000000..8ac7e68 --- /dev/null +++ b/docs/mutation-ledger.toml @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: 2026 jlreq contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +version = 1 + +[baseline] +commit = "31a92cfaa75cae261e2eece4c95c35e483eb47a8" +missed = 483 +timeout = 5 +note = "Pre-0.1.0 audit baseline; release gates require both counts to be zero." + +[[release_result]] +audited = "2026-08-25" +package = "jlreq" +tested = 1949 +caught = 1490 +unviable = 459 +missed = 0 +timeout = 0 +equivalent_excluded = 5 +note = "Full 0.1.0 release-candidate run after restoring handwritten generated.rs integrity checks to mutation scope." + +[[release_result]] +audited = "2026-08-25" +package = "jlreq-conformance" +tested = 326 +caught = 292 +unviable = 34 +missed = 0 +timeout = 0 +equivalent_excluded = 0 +note = "Full 0.1.0 release-candidate run; no conformance-source or equivalent-mutant exclusions." + +[[exclusion]] +path = "crates/jlreq/src/generated/appendix_a.rs" +sha256 = "00761c3eb613d272f3713e9c6b71383540a273021971a2c22b615eed23f8959a" +kind = "generated" +provenance = "data/manifest.toml" +reason = "Generated Appendix A table; just generate-check and just attest verify it byte-for-byte." + +[[exclusion]] +path = "crates/jlreq/src/generated/folding.rs" +sha256 = "11ed0a1852127cadc94ca98efcc6c51a8daedfb33e10033b07abae6353cc5fe0" +kind = "generated" +provenance = "data/manifest.toml" +reason = "Generated width-folding table; just generate-check and just attest verify it byte-for-byte." + +[[exclusion]] +path = "crates/jlreq/src/generated/ideograph.rs" +sha256 = "13e3bb408d7148add03d2348c1360f33cdd71a462f0f4291ad8e5f38af41aef8" +kind = "generated" +provenance = "data/manifest.toml" +reason = "Generated Unified_Ideograph ranges; just generate-check and just attest verify them byte-for-byte." + +[[exclusion]] +path = "crates/jlreq/src/generated/script.rs" +sha256 = "ad15e49f34640d3f949ca7e2a3a3f64315ce68266102759051eb4fe12cace6af" +kind = "generated" +provenance = "data/manifest.toml" +reason = "Generated kana-script ranges; just generate-check and just attest verify them byte-for-byte." + +[[exclusion]] +path = "crates/jlreq/src/generated/table1.rs" +sha256 = "6de73a04480c3ee32ee59d7d23128aa82783dc0ccbde8559f3be17b77482105c" +kind = "generated" +provenance = "data/manifest.toml" +reason = "Generated JLReq Table 1 cells; just generate-check and just attest verify them byte-for-byte." + +[[exclusion]] +path = "crates/jlreq/src/generated/table2.rs" +sha256 = "4bb486fd05ae95896b728d26f1b14774a531fd83f7fec05e571530914d59a751" +kind = "generated" +provenance = "data/manifest.toml" +reason = "Generated JLReq Table 2 cells; just generate-check and just attest verify them byte-for-byte." + +[[exclusion]] +path = "crates/jlreq/src/generated/table3.rs" +sha256 = "9427c738b22ee41090a52b9d2d3f0f1c82c1b072bc26660940ede1d394648176" +kind = "generated" +provenance = "data/manifest.toml" +reason = "Generated JLReq Table 3 cells; just generate-check and just attest verify them byte-for-byte." + +[[exclusion]] +path = "crates/jlreq/src/generated/table4.rs" +sha256 = "2cc7292a8b5bbeeaa04dc693c02e68012230f7c0aee10fdd3824f0f68f8bbd47" +kind = "generated" +provenance = "data/manifest.toml" +reason = "Generated JLReq Table 4 cells; just generate-check and just attest verify them byte-for-byte." + +[[exclusion]] +path = "crates/jlreq/src/generated/table5.rs" +sha256 = "ee099abbea8b724d9ffbb2fc5dfed43fa09fac21851d1cf67ff7ca5ce91ba3bb" +kind = "generated" +provenance = "data/manifest.toml" +reason = "Generated JLReq Table 5 cells; just generate-check and just attest verify them byte-for-byte." + +[[exclusion]] +path = "crates/jlreq/src/generated/table6.rs" +sha256 = "0eeebc30219709e4cdc8d78f36981e0df9e90fe9097550f1ef636aec5fd4d6ea" +kind = "generated" +provenance = "data/manifest.toml" +reason = "Generated JLReq Table 6 cells; just generate-check and just attest verify them byte-for-byte." + +# Every proven equivalent mutant is listed with its exact cargo-mutants name, the +# source-file SHA-256, and a proof. The regex exclusions in .cargo/mutants.toml match +# only these five names. + +[[equivalent]] +mutant = "crates/jlreq/src/generated.rs:35:5: replace | with ^" +exclude_re = '^crates/jlreq/src/generated[.]rs:35:5: replace [|] with \^$' +source_sha256 = "d4621a5ba23aa7a94090267395d431a5e6f5817e65539aca3fe2d026a84aedb8" +proof = "FRAME_FULL_EM is bit 0 and FRAME_HALF_EM is bit 1, so the accumulated operand and the added bit are disjoint; OR and XOR are identical." + +[[equivalent]] +mutant = "crates/jlreq/src/generated.rs:36:5: replace | with ^" +exclude_re = '^crates/jlreq/src/generated[.]rs:36:5: replace [|] with \^$' +source_sha256 = "d4621a5ba23aa7a94090267395d431a5e6f5817e65539aca3fe2d026a84aedb8" +proof = "FRAME_THIRD_EM is bit 2 and does not overlap the accumulated bits 0-1; OR and XOR are identical." + +[[equivalent]] +mutant = "crates/jlreq/src/generated.rs:37:5: replace | with ^" +exclude_re = '^crates/jlreq/src/generated[.]rs:37:5: replace [|] with \^$' +source_sha256 = "d4621a5ba23aa7a94090267395d431a5e6f5817e65539aca3fe2d026a84aedb8" +proof = "FRAME_QUARTER_EM is bit 3 and does not overlap the accumulated bits 0-2; OR and XOR are identical." + +[[equivalent]] +mutant = "crates/jlreq/src/generated.rs:38:5: replace | with ^" +exclude_re = '^crates/jlreq/src/generated[.]rs:38:5: replace [|] with \^$' +source_sha256 = "d4621a5ba23aa7a94090267395d431a5e6f5817e65539aca3fe2d026a84aedb8" +proof = "FRAME_PROPORTIONAL is bit 4 and does not overlap the accumulated bits 0-3; OR and XOR are identical." + +[[equivalent]] +mutant = "crates/jlreq/src/generated.rs:55:41: replace < with <= in ascends" +exclude_re = '^crates/jlreq/src/generated[.]rs:55:41: replace < with <= in ascends$' +source_sha256 = "d4621a5ba23aa7a94090267395d431a5e6f5817e65539aca3fe2d026a84aedb8" +proof = "This return is dominated by before.key[position] != after.key[position]; for unequal integers, < and <= have the same truth value." diff --git a/docs/api-1.0.toml b/docs/public-api.toml similarity index 89% rename from docs/api-1.0.toml rename to docs/public-api.toml index 5124308..b59191b 100644 --- a/docs/api-1.0.toml +++ b/docs/public-api.toml @@ -2,10 +2,13 @@ # # SPDX-License-Identifier: MIT OR Apache-2.0 -# Candidate public names for a possible jlreq 1.0. The workspace remains an unpublished -# 0.0.0 snapshot, so this is a development control rather than a compatibility promise. -# Signatures and openness are checked by xtask's structural API pass; this file makes -# additions and removals deliberate. Implementation modules remain private. +# Public names released by jlreq 0.1.0. The `api` gate checks this list in both directions; +# every compatible 0.1.x release must preserve it. The `semver` gate reads the release +# contract below and compares later 0.1.x candidates with the latest published jlreq. +# Implementation modules remain private. + +baseline_version = "0.1.0" +compatible_series = "0.1" [[module]] path = "jlreq" @@ -17,6 +20,9 @@ items = [ "ClusterPlacement", "ClusterRole", "Composer", + "ComposeError", + "CompositionLimits", + "CompositionResource", "Construct", "CoordinateTransform", "Diagnostic", diff --git a/engines/census-all.sh b/engines/census-all.sh new file mode 100755 index 0000000..358c795 --- /dev/null +++ b/engines/census-all.sh @@ -0,0 +1,106 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 jlreq contributors +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +set -eu + +if [ "$#" -ne 5 ]; then + echo "usage: census-all.sh CENSUS RUST OCAML RACKET COMMITTED-SUMMARY" >&2 + exit 2 +fi + +probe=$1 +rust_engine=$2 +ocaml_engine=$3 +racket_engine=$4 +committed_summary=$5 +output_dir=target/census +generated_summary=$output_dir/conformance-summary.md +rows=$output_dir/conformance-summary.rows + +mkdir -p "$output_dir" +: > "$rows" + +kinds=$("$probe" kinds) +kind_count=$(printf '%s\n' "$kinds" | sed '/^$/d' | wc -l | tr -d ' ') +if [ "$kind_count" -ne 10 ]; then + echo "census registry has $kind_count kinds; release protocol v1 requires exactly 10" >&2 + exit 1 +fi + +total=0 +for kind in $kinds; do + requests=$output_dir/$kind.requests.ndjson + "$probe" generate "$kind" > "$requests" + count=$(wc -l < "$requests" | tr -d ' ') + total=$((total + count)) + + for engine_name in rust ocaml racket; do + case $engine_name in + rust) engine=$rust_engine ;; + ocaml) engine=$ocaml_engine ;; + racket) engine=$racket_engine ;; + esac + raw=$output_dir/$kind.$engine_name.raw + canonical=$output_dir/$kind.$engine_name.canonical + normalized=$output_dir/$kind.$engine_name.ndjson + "$engine" < "$requests" > "$raw" + response_count=$(wc -l < "$raw" | tr -d ' ') + if [ "$response_count" -ne "$count" ]; then + echo "$engine_name returned $response_count of $count responses for $kind" >&2 + exit 1 + fi + "$probe" normalize < "$raw" > "$canonical" + LC_ALL=C sort "$canonical" > "$normalized" + done + + if ! cmp -s "$output_dir/$kind.rust.ndjson" "$output_dir/$kind.ocaml.ndjson"; then + echo "census $kind differs between Rust and OCaml" >&2 + exit 1 + fi + if ! cmp -s "$output_dir/$kind.rust.ndjson" "$output_dir/$kind.racket.ndjson"; then + echo "census $kind differs between Rust and Racket" >&2 + exit 1 + fi + if ! cmp -s "$output_dir/$kind.ocaml.ndjson" "$output_dir/$kind.racket.ndjson"; then + echo "census $kind differs between OCaml and Racket" >&2 + exit 1 + fi + + # Backticks are literal Markdown, not shell substitutions. + # shellcheck disable=SC2016 + printf '| `%s` | %s | 0 | 0 | 0 |\n' "$kind" "$count" >> "$rows" + echo "census $kind: $count cases, zero differences" +done + +if [ "$total" -lt 122199 ]; then + echo "census generated $total cases; expected at least 122199" >&2 + exit 1 +fi + +{ + # REUSE-IgnoreStart + # These are HTML comments emitted into the generated Markdown, not this script's license. + echo '' + echo '' + # REUSE-IgnoreEnd + echo '' + echo '# Conformance census summary' + echo + # Backticks are literal Markdown, not shell substitutions. + # shellcheck disable=SC2016 + echo 'Protocol `jlreq.conformance/1`, specification `jlreq-2020-08-11+unicode-17.0.0`.' + echo + echo '| Census | Cases | Rust ↔ OCaml | Rust ↔ Racket | OCaml ↔ Racket |' + echo '| --- | ---: | ---: | ---: | ---: |' + cat "$rows" + printf '| **Total** | **%s** | **0** | **0** | **0** |\n' "$total" +} > "$generated_summary" + +if ! cmp -s "$generated_summary" "$committed_summary"; then + echo "$committed_summary is stale; replace it with $generated_summary" >&2 + exit 1 +fi + +echo "census-all: $kind_count kinds, $total cases, zero three-engine differences" diff --git a/engines/ocaml/lib/paragraph.ml b/engines/ocaml/lib/paragraph.ml index 47fc366..96f99f1 100644 --- a/engines/ocaml/lib/paragraph.ml +++ b/engines/ocaml/lib/paragraph.ml @@ -66,6 +66,34 @@ type t = { let is_mandatory (opportunity : break_opportunity) : bool = opportunity.kind = Mandatory let is_discretionary (opportunity : break_opportunity) : bool = opportunity.kind = Discretionary +(** Whether a shaped cluster is one of §3.6.3's signs of the outer line. + + Warichu and furawake text is set on sublines beside the outer line. Tate-chu-yoko + is likewise off the outer inline axis only in vertical composition; in horizontal + composition its text remains ordinary inline text. The containment test includes a + construct's first cluster. *) +let line_tab_in (text : Model.shaped_text) (constructs : Construct.t array) + (writing_mode : Model.writing_mode) (ordinal : int) : bool = + if ordinal < 0 || ordinal >= Array.length text.Model.clusters then false + else + let cluster = text.Model.clusters.(ordinal) in + String.equal (Model.cluster_piece text cluster) "\t" + && not + (Array.exists + (fun (construct : Construct.t) -> + let stacked = + match construct.Construct.kind with + | Construct.Warichu | Construct.Furawake _ -> true + | Construct.Tate_chu_yoko -> writing_mode = Model.Vertical_rl + | _ -> false + in + let first, last = construct.Construct.range in + stacked && first <= cluster.Model.first && cluster.Model.last <= last) + constructs) + +let is_line_tab (paragraph : t) (ordinal : int) : bool = + line_tab_in paragraph.text paragraph.constructs paragraph.writing_mode ordinal + (** The ordinal of the first cluster whose range starts at or after [offset]. Lines are cut at byte offsets and placed as cluster runs, so this is the one @@ -245,37 +273,44 @@ let scalars_across (source : string) (offset : int) : int option * int option = both. A warichu splits into two sublines (§3.4.2) and a furawake into its declared columns (§3.7.2), so a break inside one of those is exactly what the caller means by it. *) -let check_indivisible_constructs (text : Model.shaped_text) (breaks : break_opportunity list) - (constructs : Construct.t array) : unit = - let refuse ?(unless = fun _ -> false) ordinal (first, last) = - List.iter - (fun opportunity -> - if - first < opportunity.offset - && opportunity.offset < last - && not (unless opportunity.offset) - then - fail - "a break is at byte %d, inside construct %d, which covers bytes %d..%d and is \ - indivisible" - opportunity.offset ordinal first last) - breaks - in +let construct_blocks_break (text : Model.shaped_text) (offset : int) + (construct : Construct.t) : bool = let at_a_math_token offset = let before, after = scalars_across text.Model.source offset in let token = function Some scalar -> Construct.is_math_token scalar | None -> false in token before || token after in + let inside (first, last) = first < offset && offset < last in + match construct.Construct.kind with + | Construct.Tate_chu_yoko | Construct.Jidori _ | Construct.Reference_mark _ + | Construct.Script _ -> + inside construct.Construct.range + | Construct.Formula -> inside construct.Construct.range && not (at_a_math_token offset) + | Construct.Ruby { runs; _ } -> + List.exists (fun (run : Construct.ruby_run) -> inside run.Construct.run_base) runs + | Construct.Warichu | Construct.Emphasis_dots _ | Construct.Furawake _ -> false + +(** Whether the same invariant that rejects a caller-supplied break also blocks an + automatically supplied tab cut. Keeping the answer here prevents transport into the + search from inventing a boundary the validated paragraph itself would reject. *) +let break_blocked (paragraph : t) (offset : int) : bool = + Array.exists + (construct_blocks_break paragraph.text offset) + paragraph.constructs + +let check_indivisible_constructs (text : Model.shaped_text) (breaks : break_opportunity list) + (constructs : Construct.t array) : unit = Array.iteri (fun ordinal (construct : Construct.t) -> - match construct.Construct.kind with - | Construct.Tate_chu_yoko | Construct.Jidori _ | Construct.Reference_mark _ - | Construct.Script _ -> - refuse ordinal construct.Construct.range - | Construct.Formula -> refuse ~unless:at_a_math_token ordinal construct.Construct.range - | Construct.Ruby { runs; _ } -> - List.iter (fun (run : Construct.ruby_run) -> refuse ordinal run.Construct.run_base) runs - | Construct.Warichu | Construct.Emphasis_dots _ | Construct.Furawake _ -> ()) + List.iter + (fun opportunity -> + if construct_blocks_break text opportunity.offset construct then + let first, last = construct.Construct.range in + fail + "a break is at byte %d, inside construct %d, which covers bytes %d..%d and is \ + indivisible" + opportunity.offset ordinal first last) + breaks) constructs (** §3.7.2's columns, as a shape the request has to have. @@ -319,6 +354,7 @@ let check_furawake_splits (breaks : break_opportunity list) (constructs : Constr mandatory breaks rather than over the whole paragraph, and a surplus of stops is not an error -- a stop the line never reaches is simply never used. *) let check_tab_stop_supply (text : Model.shaped_text) (breaks : break_opportunity list) + (constructs : Construct.t array) (writing_mode : Model.writing_mode) (tab_stops : tab_stop list) : unit = let supply = List.length tab_stops in let boundaries = @@ -328,8 +364,8 @@ let check_tab_stop_supply (text : Model.shaped_text) (breaks : break_opportunity breaks) in let signs = ref 0 in - Array.iter - (fun (cluster : Model.cluster) -> + Array.iteri + (fun ordinal (cluster : Model.cluster) -> let rec reach () = match !boundaries with | offset :: rest when offset <= cluster.Model.first -> @@ -339,7 +375,7 @@ let check_tab_stop_supply (text : Model.shaped_text) (breaks : break_opportunity | _ -> () in reach (); - if String.equal (Model.cluster_piece text cluster) "\t" then begin + if line_tab_in text constructs writing_mode ordinal then begin incr signs; if !signs > supply then fail "a line holds %d tab sign(s) and the request states %d tab stop(s)" !signs supply @@ -408,13 +444,13 @@ let build ~(text : Model.shaped_text) ~(line_extent : int) fail "two tab stops are stated at %d" stop.position) tab_stops) tab_stops; - check_tab_stop_supply text breaks tab_stops; + let constructs = Array.of_list constructs in + check_tab_stop_supply text breaks constructs writing_mode tab_stops; (* §3.6.3 walks the stops "in order", and the order of positions along the line is the only order a line knows: the caller's listing order is how the stops were written down, not where they are. Sorting here means the search and the placement never have to ask. *) let tab_stops = List.sort (fun left right -> compare left.position right.position) tab_stops in - let constructs = Array.of_list constructs in check_constructs text constructs; check_indivisible_constructs text breaks constructs; check_furawake_splits breaks constructs; diff --git a/engines/ocaml/lib/pipeline.ml b/engines/ocaml/lib/pipeline.ml index 88be9f3..6677093 100644 --- a/engines/ocaml/lib/pipeline.ml +++ b/engines/ocaml/lib/pipeline.ml @@ -393,23 +393,6 @@ let is_math_token_cluster (paragraph : Paragraph.t) (cluster : Model.cluster) : let is_tab (paragraph : Paragraph.t) (cluster : Model.cluster) : bool = String.equal (piece_of paragraph cluster) "\t" -(** Whether [offset] falls strictly inside some construct. - - Every construct in the vocabulary is at least one object on the line -- a - tate-chu-yoko run, a base character group, an ornamented complex, a note set on - lines of its own, a run given a length of its own, a formula -- and §3.6.3's cut - is not a break opportunity that a rule about characters could permit or forbid. - It is the tab's own, so the only thing that can stop it is there being no line - boundary at that point at all, which is what standing inside a construct means. - A construct that begins or ends exactly at the sign leaves the cut available: - the sign is then beside the construct rather than in it. *) -let is_inside_construct (paragraph : Paragraph.t) (offset : int) : bool = - Array.exists - (fun (construct : Construct.t) -> - let first, last = construct.Construct.range in - first < offset && offset < last) - paragraph.Paragraph.constructs - (** Whether the cluster at [ordinal] is a tab sign §3.6 has anything to say about. §3.6.3 corresponds the signs of a {i line} with the stops of that line, and three @@ -424,19 +407,7 @@ let is_inside_construct (paragraph : Paragraph.t) (offset : int) : bool = README.md, where the reference engine's answer at the first character is recorded as a disagreement rather than followed. *) let is_line_tab (paragraph : Paragraph.t) (ordinal : int) : bool = - match cluster_at paragraph ordinal with - | None -> false - | Some cluster -> - is_tab paragraph cluster - && not - (Array.exists - (fun (construct : Construct.t) -> - match construct.Construct.kind with - | Construct.Warichu | Construct.Furawake _ | Construct.Tate_chu_yoko -> - let first, last = construct.Construct.range in - first <= cluster.first && cluster.last <= last - | _ -> false) - paragraph.Paragraph.constructs) + Paragraph.is_line_tab paragraph ordinal (** §C.2 note 5's {i kinds} of inseparable character, as §C.3's very loose level enumerates them: each mark is its own kind, and the three code points of the @@ -2270,12 +2241,12 @@ let ruby_gaps ~(line_start : int) ~(line_end : int) (spans : ruby_span list) : i "if there is only one character, it should be aligned to the left of the jidori block", which is the same sentence about the same situation. - One bullet of §3.7.3 is left unimplemented deliberately. Its two locales disagree: - the English says to "add the same spacing to those space characters as is being - added to the other characters" and the Japanese says the opposite, that a space - takes the extra on one of its two sides and not on both. The reference engine opens - both sides, which is the English reading; see README.md, "Observable policies with - no written source". *) + One bullet of §3.7.3 cannot be read as one locale-independent rule because its two + locales disagree: the English says to "add the same spacing to those space + characters as is being added to the other characters" and the Japanese says the + opposite, that a space takes the extra on one of its two sides and not on both. The + reference engine opens both sides, the recorded English reading; see README.md, + "Observable policies with no written source". *) let jidori_extras (paragraph : Paragraph.t) (style : Style.t) ~(stacks : stack list) ~(line_start : int) ~(line_end : int) : int array = let count = Num.usub line_end line_start in @@ -2797,10 +2768,11 @@ type node = { before a tab -- §3.6.3's cut is the tab's own and answers to no character class, which is why it can leave an opening bracket at the line end. - A sign standing inside a construct is the exception, and for the same reason: - the cut is not a break opportunity but a line boundary, and there is no line - boundary inside one object. A sign inside a warichu or a furawake is not even a - sign of this line ({!is_line_tab}), so it never runs any stops out to begin with. + A sign standing at a boundary {!Paragraph.break_blocked} rejects is the exception, + and for the same reason: the cut is not a break opportunity but a line boundary, + and there is no boundary there. Emphasis dots deliberately do not block their + per-character boundaries. A sign inside a warichu or a furawake is not even a sign + of this line ({!is_line_tab}), so it never runs any stops out to begin with. A tab at offset zero is the head candidate already, and a caller who stated a break there has said the same thing twice; both are dropped so that no two @@ -2808,13 +2780,14 @@ type node = { let tab_candidates (paragraph : Paragraph.t) (stated : int list) : candidate list = let clusters = paragraph.Paragraph.text.Model.clusters in Array.to_list clusters - |> List.filter_map (fun (cluster : Model.cluster) -> + |> List.mapi (fun ordinal (cluster : Model.cluster) -> (ordinal, cluster)) + |> List.filter_map (fun (ordinal, (cluster : Model.cluster)) -> let offset = cluster.Model.first in if - (not (is_tab paragraph cluster)) + (not (is_line_tab paragraph ordinal)) || offset = 0 || List.mem offset stated - || is_inside_construct paragraph offset + || Paragraph.break_blocked paragraph offset then None else Some diff --git a/engines/ocaml/probe/census.ml b/engines/ocaml/probe/census.ml index 25c2053..e996183 100644 --- a/engines/ocaml/probe/census.ml +++ b/engines/ocaml/probe/census.ml @@ -2642,12 +2642,18 @@ let print_classes () : unit = (Jlreq.Tables.row_label value) done +(** The registry names, one per line. Automation consumes this command instead of + copying the list, so adding an eleventh census cannot silently shrink a full run. *) +let print_kinds () : unit = List.iter (fun kind -> print_endline kind.kind_name) kinds + let usage () = let buffer = Buffer.create 512 in Buffer.add_string buffer "usage: census generate one NDJSON request envelope per line, on stdout\n"; Buffer.add_string buffer " census classes the representative code point chosen for each class, as TSV\n"; + Buffer.add_string buffer + " census kinds every registered census name, one per line\n"; Buffer.add_string buffer " census normalize a response stream on stdin, with every object's keys sorted,\n\ \ so that `diff` means what it looks like it means\n"; @@ -2661,6 +2667,7 @@ let usage () = let run (arguments : string list) : unit = match arguments with | [ "classes" ] -> print_classes () + | [ "kinds" ] -> print_kinds () | [ "normalize" ] -> normalize () | [ "generate"; name ] -> ( match List.find_opt (fun kind -> String.equal kind.kind_name name) kinds with diff --git a/engines/ocaml/proto/protocol.ml b/engines/ocaml/proto/protocol.ml index 8f86e9a..68985f6 100644 --- a/engines/ocaml/proto/protocol.ml +++ b/engines/ocaml/proto/protocol.ml @@ -16,7 +16,7 @@ v} The response repeats [protocol], [spec] and [id] and replaces [request] with - [response]. The runner checks the ids in order, so a dropped or reordered + [response]. The runner associates unique ids in any order, so a dropped, duplicate, or unknown answer is a protocol error rather than a wrong answer, and every request gets exactly one line back. diff --git a/engines/ocaml/test/test_pipeline.ml b/engines/ocaml/test/test_pipeline.ml index 044f9b6..43cc983 100644 --- a/engines/ocaml/test/test_pipeline.ml +++ b/engines/ocaml/test/test_pipeline.ml @@ -1124,6 +1124,12 @@ let run () = ~actual: (built ~extent:2500 ~constructs:[ Jidori (0, 2, 2) ] ~tab_stops:[ at 1000 ] [ letter "A"; letter "A"; tab; letter "B" ]); + let emphasis_tab = + compose_built ~extent:3000 ~constructs:[ Emphasis (0, 3, dot) ] + ~tab_stops:[ at 500 ] [ p opening_bracket; p "\t"; p opening_bracket ] + in + Check.equal_int "an exhausted tab may cut between two emphasis complexes" ~expected:2 + ~actual:(List.length emphasis_tab.Layout.lines); (* A warichu's sublines are not the line, so a sign on one takes no stop at all -- and the cursor a stop is measured against steps once past the whole block, not once per character inside it: the outer sign below stands at 1000, the block's @@ -1135,12 +1141,12 @@ let run () = ~expected:"(1000/1000) [0:-500+500 500:-500+500 0:500+500] []" ~actual: (built ~extent:4000 ~constructs:[ Warichu (0, 3) ] ~breaks:[ (2, Paragraph.Allowed) ] - ~tab_stops:[ at 2000 ] [ letter "A"; tab; letter "B" ]); + ~tab_stops:[] [ letter "A"; tab; letter "B" ]); Check.equal_string "and the stop past the block is measured from the block's own width" ~expected:"(1700/1000) [0:-500+500 500:-500+500 0:500+500 1000:0+200 1200:0+500] []" ~actual: (built ~extent:4000 ~constructs:[ Warichu (0, 3) ] ~breaks:[ (2, Paragraph.Allowed) ] - ~tab_stops:[ at 1200; at 3000 ] [ letter "A"; tab; letter "B"; tab; letter "C" ]); + ~tab_stops:[ at 1200 ] [ letter "A"; tab; letter "B"; tab; letter "C" ]); (* §3.6.1: "if there is more than one tab sign, it is necessary to set the same numbers of tab positions and tab types as the number of tab signs". A stretch diff --git a/engines/racket/compose.rkt b/engines/racket/compose.rkt index fafc473..8b3955b 100644 --- a/engines/racket/compose.rkt +++ b/engines/racket/compose.rkt @@ -757,11 +757,10 @@ ;; §3.6.3's fourth case sends the sign and what follows it to the next ;; line, and there are two places it has nothing to say to. A sign at ;; the line head has no earlier boundary to be sent back from, and a - ;; sign inside a construct the caller declared cannot end the line - ;; there -- the construct is indivisible and the boundary is not one - ;; (docs/decisions/construct-break-refusal.md). Both keep their line - ;; and take one em of the paragraph's own size. - [(or (zero? offset) (inside-construct? para (vector-ref items (+ first offset)))) + ;; sign at a boundary the paragraph's construct invariants block cannot + ;; end the line there (docs/decisions/construct-break-refusal.md). Both + ;; keep their line and take one em of the paragraph's own size. + [(or (zero? offset) (tab-cut-blocked? para items (+ first offset))) (vector-set! out offset (extent-inline (paragraph-size para)))] [else (set! cut #t)])) (walk (add1 offset) @@ -769,11 +768,27 @@ (vector-ref fixed (add1 offset)))))) (values out cut)])) -;; Whether the item stands strictly inside a construct the caller declared. -(define (inside-construct? para one) +;; Whether the paragraph's ordinary construct-break invariant blocks an automatic +;; §3.6.3 cut before item `index`. Emphasis dots leave each character boundary open; +;; ruby leaves only run boundaries open; formulae leave their named math-token +;; boundaries open. Warichu and furawake are listed for completeness, although a tab +;; inside either is not a sign of the outer line and never reaches this predicate. +(define (tab-cut-blocked? para items index) + (define offset (item-start (vector-ref items index))) + (define (inside? start end) (< start offset end)) + (define math-boundary? + (or (and (positive? index) (math-class? (item-class (vector-ref items (sub1 index))))) + (math-class? (item-class (vector-ref items index))))) (for/or ([each (in-list (paragraph-constructs para))]) - (and (> (item-start one) (construct-start each)) - (< (item-end one) (construct-end each))))) + (case (construct-kind each) + [(emphasis-dots warichu furawake) #f] + [(formula) + (and (inside? (construct-start each) (construct-end each)) (not math-boundary?))] + [(ruby) + (for/or ([found (in-list (cdr (assq 'runs (construct-payload each))))]) + (define base (hash-ref found 'base)) + (inside? (car base) (cadr base)))] + [else (inside? (construct-start each) (construct-end each))]))) ;; Whether the item is set inside a structure that stacks its text off the line: a ;; tate-chu-yoko run, which runs across the line, or a warichu's and a furawake's @@ -784,12 +799,6 @@ ;; A structure's FIRST character is set in the structure exactly as the rest are, so ;; it is in it and not beside it; a construct that ends where the item begins is ;; behind the item and does not contain it at all. -(define (in-stacked-structure? para one) - (for/or ([each (in-list (paragraph-constructs para))]) - (and (memq (construct-kind each) '(tate-chu-yoko warichu furawake)) - (>= (item-start one) (construct-start each)) - (<= (item-end one) (construct-end each))))) - ;; Whether the item is a tab sign §3.6.3 has anything to say about. ;; ;; §3.6.3 corresponds the signs of a LINE with the stops of that line, and both @@ -802,7 +811,7 @@ ;; position each, so a stop reaches a sign inside one of those like any other ;; character (docs/decisions/tab-line-correspondence.md). (define (line-sign? para one) - (and (tab-sign? para one) (not (in-stacked-structure? para one)))) + (line-tab-sign? para one)) ;; The string one sign puts at its stop: what stands between it and the next sign of ;; the line, or the line end, as `(width . text)` pairs. A sign the line does not @@ -1453,7 +1462,8 @@ ;; that is not a sign of this line offers no such boundary: ;; it never runs any stops out, and there is no line boundary ;; inside the one position its structure holds. - (line-sign? para (vector-ref items index)) + (and (line-sign? para (vector-ref items index)) + (not (tab-cut-blocked? para items index))) (and kind (or (eq? kind 'mandatory) (breakable? para style diff --git a/engines/racket/info.rkt b/engines/racket/info.rkt index f4deeb5..b24edc2 100644 --- a/engines/racket/info.rkt +++ b/engines/racket/info.rkt @@ -24,7 +24,7 @@ (define pkg-desc "An independent Racket implementation of the jlreq conformance protocol") (define pkg-authors '("jlreq contributors")) (define license '(MIT OR Apache-2.0)) -(define version "0.0.0") +(define version "0.1.0") (define deps '("base")) (define build-deps '("compiler-lib" "rackunit-lib")) diff --git a/engines/racket/protocol.rkt b/engines/racket/protocol.rkt index 61833e4..31fd542 100644 --- a/engines/racket/protocol.rkt +++ b/engines/racket/protocol.rkt @@ -15,7 +15,7 @@ ;; "request":{...}} ;; ;; The response repeats `protocol`, `spec` and `id` and replaces `request` with -;; `response`. The runner checks the ids in order, so a dropped or reordered answer +;; `response`. The runner associates unique ids in any order, so a dropped, duplicate, or unknown answer ;; is a protocol error rather than a wrong answer, and every request gets exactly ;; one line back. ;; diff --git a/engines/racket/tabs.rkt b/engines/racket/tabs.rkt index f5bc55a..8c295af 100644 --- a/engines/racket/tabs.rkt +++ b/engines/racket/tabs.rkt @@ -56,6 +56,7 @@ "spacing.rkt") (provide tab-sign? + line-tab-sign? validate-tabs stops-in-order tab-target) @@ -67,6 +68,21 @@ (define text (source-slice (paragraph-source para) (item-start one) (item-end one))) (and (= (string-length text) 1) (char=? (string-ref text 0) tab-character))) +;; Whether the sign has a coordinate on the outer line's inline axis. Warichu and +;; furawake always stack their text on sublines; tate-chu-yoko does so only in vertical +;; composition. A construct's first item is contained just like every later item. +(define (line-tab-sign? para one) + (and (tab-sign? para one) + (not + (for/or ([each (in-list (paragraph-constructs para))]) + (define stacked? + (or (memq (construct-kind each) '(warichu furawake)) + (and (eq? (construct-kind each) 'tate-chu-yoko) + (eq? (paragraph-writing-mode para) 'vertical-rl)))) + (and stacked? + (>= (item-start one) (construct-start each)) + (<= (item-end one) (construct-end each))))))) + ;; The caller's stops, in the order they stand along the line. (define (stops-in-order para) (sort (paragraph-tab-stops para) < #:key tab-stop-position)) @@ -97,7 +113,7 @@ (fail-input "input.tab-count: a stretch of the paragraph holds ~a tab sign(s) and the request states ~a tab stop(s)" signs stops))) (count (add1 index) - (+ (if cut? 0 signs) (if (tab-sign? para one) 1 0)))]))) + (+ (if cut? 0 signs) (if (line-tab-sign? para one) 1 0)))]))) ;; Where the string after a sign has to start, for the sign to put it at `stop`. ;; diff --git a/engines/racket/tests/test-compose.rkt b/engines/racket/tests/test-compose.rkt index 9a8932c..81b7dd5 100644 --- a/engines/racket/tests/test-compose.rkt +++ b/engines/racket/tests/test-compose.rkt @@ -338,8 +338,7 @@ 8000 #:alignment "start" #:breaks (list (break* 5 "allowed")) - #:constructs (list (hasheq 'kind "warichu" 'range '(1 8))) - #:tab-stops (list (stop* 2500)))) + #:constructs (list (hasheq 'kind "warichu" 'range '(1 8))))) '(((0 9) 0 3000 ((0 0 1000) (1 1000 500) (2 1500 500) (3 1000 500) (4 2000 1000))))) @@ -359,8 +358,7 @@ 8000 #:alignment "start" #:breaks (list (break* 5 "allowed")) - #:constructs (list (hasheq 'kind "warichu" 'range '(1 8))) - #:tab-stops (list (stop* 250)))) + #:constructs (list (hasheq 'kind "warichu" 'range '(1 8))))) '(((0 9) 0 3000 ((0 0 1000) (1 1000 500) (2 1500 500) (3 1000 500) (4 2000 1000))))) @@ -382,11 +380,27 @@ #:alignment "start" #:breaks (list (break* 5 "allowed")) #:constructs (list (hasheq 'kind "warichu" 'range '(1 8))) - #:tab-stops (list (stop* 2500) (stop* 3000)))) + #:tab-stops (list (stop* 2500)))) '(((0 10) 0 3500 ((0 0 1000) (1 1000 500) (2 1500 500) (3 1000 500) (4 2000 500) (5 2500 1000))))) + ;; Emphasis dots are one complex per base character, so unlike a jidori or a + ;; superscript they leave a boundary for §3.6.3's exhausted-stop cut. + (check-equal? (layout-of (request "〈\t〈" + (list (cluster* 0 3 1000) + (cluster* 3 4 1000) + (cluster* 4 7 1000)) + 3000 + #:alignment "start" + #:constructs + (list (hasheq 'kind "emphasis-dots" + 'range '(0 7) + 'mark "•")) + #:tab-stops (list (stop* 500)))) + '(((0 3) 0 1000 ((0 0 1000))) + ((3 7) 0 1500 ((1 0 500) (2 500 1000))))) + ;; ------------------------------------------------------------------ ;; §3.4.2: the block a note makes, and what stands inside it ;; ------------------------------------------------------------------ diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index b3975c8..99bef48 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -43,16 +43,23 @@ dependencies = [ "r-efi", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jlreq" -version = "0.0.0" +version = "0.1.0" [[package]] name = "jlreq-fuzz" -version = "0.0.0" +version = "0.1.0" dependencies = [ "jlreq", "libfuzzer-sys", + "serde_json", ] [[package]] @@ -81,14 +88,103 @@ dependencies = [ "cc", ] +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 703bf53..fd97933 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "jlreq-fuzz" -version = "0.0.0" +version = "0.1.0" publish = false edition = "2024" @@ -14,10 +14,25 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "=0.4.13" jlreq = { path = "../crates/jlreq" } +serde_json = "1.0" [[bin]] -name = "public_api" -path = "fuzz_targets/public_api.rs" +name = "input_validation" +path = "fuzz_targets/input_validation.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "composition" +path = "fuzz_targets/composition.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "protocol_parser" +path = "fuzz_targets/protocol_parser.rs" test = false doc = false bench = false diff --git a/fuzz/README.md b/fuzz/README.md index f671bbb..7339527 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -4,20 +4,26 @@ SPDX-FileCopyrightText: 2026 jlreq contributors SPDX-License-Identifier: MIT OR Apache-2.0 --> -# Public API fuzzing +# Fuzzing -The `public_api` target feeds malformed and valid caller-owned byte ranges, advances, -breaks, tabs, and all nine inline structures through the same public API an integration -uses. Rejection by `ShapedText`, `Ruby`, or `ParagraphBuilder` is expected; every accepted -paragraph must compose and expose all result views without panicking. +The fuzz suite separates the three failure domains that matter at the public boundary: -Run the bounded CI workload with `just fuzz-check`, or continue exploring locally with: +- `input_validation` feeds malformed and valid caller-owned byte ranges, advances, + breaks, tabs, and all inline structures into the validated paragraph model. +- `composition` exercises accepted paragraphs, every style and writing mode, arithmetic + boundaries, resource limits, and all result views. +- `protocol_parser` feeds arbitrary and malformed NDJSON through the conformance protocol + parser and then validates any request that was successfully decoded. + +Run all three bounded CI workloads with `just fuzz-check`, or continue one target locally +with: ```console -cargo +nightly fuzz run public_api +cargo +nightly fuzz run composition --fuzz-dir fuzz ``` -The committed corpus keeps invalid UTF-8 boundaries, overlapping ranges, Appendix A pair -splits, extreme arithmetic, crossing constructs, and construct-internal breaks in every -regression run. Crashes minimized by cargo-fuzz belong in `corpus/public_api/`; generated -artifacts remain ignored. +Reviewed, coverage-minimized inputs live in `fuzz/seeds//`. The recipes copy them +to `target/fuzz-corpus/` before execution, so libFuzzer's evolving corpus never changes the +working tree. Existing files under the legacy `fuzz/corpus/public_api/` are retained as +source material only; minimize a useful input and move the result into the matching seed +directory before committing it. diff --git a/fuzz/fuzz_targets/composition.rs b/fuzz/fuzz_targets/composition.rs new file mode 100644 index 0000000..18ff219 --- /dev/null +++ b/fuzz/fuzz_targets/composition.rs @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: 2026 jlreq contributors +// +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#![no_main] + +use std::hint::black_box; + +use jlreq::{ + Alignment, Break, Cluster, Composer, CompositionLimits, Construct, Frame, Paragraph, Ruby, + RubyKind, RubyRun, ShapedText, Size, Style, TabAlignment, TabStop, Widow, WritingMode, +}; +use libfuzzer_sys::fuzz_target; + +fn byte(data: &[u8], index: usize) -> u8 { + data[index % data.len()] +} + +fn annotation(source: &str) -> ShapedText { + let clusters = source.char_indices().map(|(start, character)| { + Cluster::new(start..start.saturating_add(character.len_utf8()), 500) + }); + ShapedText::new( + source, + Size::square(500).unwrap_or_else(|_| unreachable!()), + Frame::FullEm, + clusters, + ) + .unwrap_or_else(|_| unreachable!()) +} + +fn structure(selector: u8, base_end: usize) -> Construct { + let range = 0..base_end; + match selector % 9 { + 0 => { + let reading = annotation("かな"); + let run = RubyRun::new(range.clone(), 0..reading.source().len()); + let ruby = Ruby::new(RubyKind::Group, range, reading, [run]) + .unwrap_or_else(|_| unreachable!()); + Construct::ruby(ruby) + }, + 1 => Construct::tate_chu_yoko(range), + 2 => Construct::emphasis_dots(range, '・'), + 3 => Construct::warichu(range), + 4 => Construct::furawake(range, 1, 0), + 5 => Construct::jidori(range, 2), + 6 => Construct::reference_mark(range, annotation("※")), + 7 => Construct::script(range, annotation("注")), + _ => Construct::formula(range), + } +} + +fn style(selector: u8) -> Style { + match selector % 5 { + 0 => Style::jlreq_2020(), + 1 => Style::book_2020(), + 2 => Style::magazine_2020(), + 3 => Style::newspaper_2020(), + _ => Style::jis_reading_2020(), + } +} + +fuzz_target!(|data: &[u8]| { + if data.is_empty() { + return; + } + + let repeated = usize::from(byte(data, 1) % 64).saturating_add(1); + let source = match byte(data, 0) % 4 { + 0 => "日本Latin、組版。".repeat(repeated), + 1 => "12\t34".to_owned(), + 2 => String::from_utf8_lossy(data).into_owned(), + _ => "零幅病的入力".repeat(repeated), + }; + if source.is_empty() { + return; + } + + let advance = match byte(data, 2) % 5 { + 0 => 0, + 1 => 1, + 2 => 500, + 3 => 1_000, + _ => i32::MAX, + }; + let clusters = source + .char_indices() + .map(|(start, character)| { + Cluster::new(start..start.saturating_add(character.len_utf8()), advance) + }) + .collect::>(); + let base_end = clusters[0].range().end; + let text = ShapedText::new( + source.clone(), + Size::square(match byte(data, 3) % 3 { + 0 => 1, + 1 => 1_000, + _ => i32::MAX, + }) + .unwrap_or_else(|_| unreachable!()), + if byte(data, 4) & 1 == 0 { + Frame::FullEm + } else { + Frame::Proportional + }, + clusters, + ) + .unwrap_or_else(|_| unreachable!()); + + let use_structure = byte(data, 5) & 1 != 0; + let breaks = if use_structure { + Vec::new() + } else { + source + .char_indices() + .skip(1) + .map(|(offset, _)| { + if byte(data, offset) % 17 == 0 { + Break::mandatory(offset) + } else if byte(data, offset) & 1 == 0 { + Break::allowed(offset) + } else { + Break::discretionary(offset) + } + }) + .collect() + }; + let constructs = use_structure + .then(|| structure(byte(data, 6), base_end)) + .into_iter(); + let tabs = source.contains('\t').then(|| { + TabStop::new(2_000, TabAlignment::Character('.')).unwrap_or_else(|_| unreachable!()) + }); + let Ok(paragraph) = Paragraph::builder( + text, + match byte(data, 7) % 4 { + 0 => 1, + 1 => 1_000, + 2 => 20_000, + _ => i32::MAX, + }, + ) + .breaks(breaks) + .constructs(constructs) + .tab_stops(tabs) + .first_line_indent(if byte(data, 8) & 1 == 0 { 0 } else { i32::MAX }) + .alignment(match byte(data, 9) % 4 { + 0 => Alignment::Start, + 1 => Alignment::Center, + 2 => Alignment::End, + _ => Alignment::Justify, + }) + .widow(Widow::MinimumClusters(u16::from(byte(data, 10)))) + .writing_mode(if byte(data, 11) & 1 == 0 { + WritingMode::HorizontalTb + } else { + WritingMode::VerticalRl + }) + .build() else { + return; + }; + + let limits = if byte(data, 12) & 1 == 0 { + CompositionLimits::default() + } else { + CompositionLimits::default().with_max_search_transitions(usize::from(byte(data, 13))) + }; + let mut composer = Composer::with_limits(limits); + match composer.compose(¶graph, &style(byte(data, 14))) { + Ok(layout) => { + for line in layout.lines() { + black_box((line.range(), line.clusters(), line.attachments())); + } + black_box(layout.diagnostics()); + }, + Err(error) => { + black_box(( + error.code(), + error.resource(), + error.limit(), + error.observed(), + )); + }, + } +}); diff --git a/fuzz/fuzz_targets/public_api.rs b/fuzz/fuzz_targets/input_validation.rs similarity index 83% rename from fuzz/fuzz_targets/public_api.rs rename to fuzz/fuzz_targets/input_validation.rs index b318320..314ab84 100644 --- a/fuzz/fuzz_targets/public_api.rs +++ b/fuzz/fuzz_targets/input_validation.rs @@ -230,48 +230,16 @@ fuzz_target!(|data: &[u8]| { return; }; - let layout = jlreq::compose(¶graph, &style(take(data, &mut cursor))); - for line in layout.lines() { - black_box(( - line.range(), - line.inline_origin(), - line.block_origin(), - line.inline_extent(), - line.block_extent(), - )); - for placement in line.clusters() { - black_box(( - placement.origin(), - placement.range(), - placement.inline(), - placement.block(), - placement.advance(), - placement.size(), - placement.frame(), - placement.writing_mode(), - placement.transform(), - )); - } - for attachment in line.attachments() { - black_box(( - attachment.construct(), - attachment.range(), - attachment.inline(), - attachment.block(), - attachment.advance(), - attachment.size(), - attachment.writing_mode(), - attachment.transform(), - attachment.symbol(), - )); - } - } - for diagnostic in layout.diagnostics() { - black_box(( - diagnostic.code(), - diagnostic.severity(), - diagnostic.range(), - diagnostic.jlreq(), - )); - } + black_box(( + paragraph.text(), + paragraph.line_extent(), + paragraph.breaks(), + paragraph.constructs(), + paragraph.tab_stops(), + paragraph.first_line_indent(), + paragraph.alignment(), + paragraph.widow(), + paragraph.writing_mode(), + style(take(data, &mut cursor)), + )); }); diff --git a/fuzz/fuzz_targets/protocol_parser.rs b/fuzz/fuzz_targets/protocol_parser.rs new file mode 100644 index 0000000..daceee9 --- /dev/null +++ b/fuzz/fuzz_targets/protocol_parser.rs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 jlreq contributors +// +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#![no_main] + +use std::hint::black_box; + +use libfuzzer_sys::fuzz_target; +use serde_json::Value; + +#[path = "../../crates/jlreq-conformance/src/validation.rs"] +mod validation; + +const MAX_MESSAGE_BYTES: usize = 1024 * 1024; +const MAX_CASES: usize = 200_000; + +fuzz_target!(|data: &[u8]| { + for line in data.split(|byte| *byte == b'\n').take(MAX_CASES + 1) { + if line.is_empty() || line.len() > MAX_MESSAGE_BYTES { + continue; + } + let Ok(value) = serde_json::from_slice::(line) else { + continue; + }; + if value.get("request").is_some() { + let _ = black_box(validation::validate_request(&value)); + } + if value.get("response").is_some() { + let _ = black_box(validation::validate_response(&value)); + } + } +}); diff --git a/fuzz/seeds/composition/extreme-capped-remainder b/fuzz/seeds/composition/extreme-capped-remainder new file mode 100644 index 0000000000000000000000000000000000000000..c1f373f5c7fe3a7148a1404fce38d3d7c5b1cfa3 GIT binary patch literal 28 bcmZROT-w?3xIweY(1`QWa|Bb(xQYt^s}Ku4 literal 0 HcmV?d00001 diff --git a/fuzz/seeds/composition/extreme-zero-width b/fuzz/seeds/composition/extreme-zero-width new file mode 100644 index 0000000..d3cc863 --- /dev/null +++ b/fuzz/seeds/composition/extreme-zero-width @@ -0,0 +1 @@ +零幅病的入力 diff --git a/fuzz/seeds/composition/mixed-vertical-tab b/fuzz/seeds/composition/mixed-vertical-tab new file mode 100644 index 0000000..2378d23 --- /dev/null +++ b/fuzz/seeds/composition/mixed-vertical-tab @@ -0,0 +1 @@ +日本Latin、組版。12 34 diff --git a/fuzz/corpus/public_api/appendix-pair-split b/fuzz/seeds/input_validation/appendix-pair-split similarity index 100% rename from fuzz/corpus/public_api/appendix-pair-split rename to fuzz/seeds/input_validation/appendix-pair-split diff --git a/fuzz/corpus/public_api/construct-internal-break b/fuzz/seeds/input_validation/construct-internal-break similarity index 100% rename from fuzz/corpus/public_api/construct-internal-break rename to fuzz/seeds/input_validation/construct-internal-break diff --git a/fuzz/corpus/public_api/crossing-constructs b/fuzz/seeds/input_validation/crossing-constructs similarity index 100% rename from fuzz/corpus/public_api/crossing-constructs rename to fuzz/seeds/input_validation/crossing-constructs diff --git a/fuzz/corpus/public_api/extreme-arithmetic b/fuzz/seeds/input_validation/extreme-arithmetic similarity index 100% rename from fuzz/corpus/public_api/extreme-arithmetic rename to fuzz/seeds/input_validation/extreme-arithmetic diff --git a/fuzz/corpus/public_api/invalid-utf8-boundary b/fuzz/seeds/input_validation/invalid-utf8-boundary similarity index 100% rename from fuzz/corpus/public_api/invalid-utf8-boundary rename to fuzz/seeds/input_validation/invalid-utf8-boundary diff --git a/fuzz/corpus/public_api/overlapping-clusters b/fuzz/seeds/input_validation/overlapping-clusters similarity index 100% rename from fuzz/corpus/public_api/overlapping-clusters rename to fuzz/seeds/input_validation/overlapping-clusters diff --git a/fuzz/seeds/protocol_parser/malformed.ndjson b/fuzz/seeds/protocol_parser/malformed.ndjson new file mode 100644 index 0000000..946eb1f --- /dev/null +++ b/fuzz/seeds/protocol_parser/malformed.ndjson @@ -0,0 +1 @@ +{"protocol":"jlreq.conformance/1","id":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[ diff --git a/fuzz/seeds/protocol_parser/valid-request.ndjson b/fuzz/seeds/protocol_parser/valid-request.ndjson new file mode 100644 index 0000000..3395b42 --- /dev/null +++ b/fuzz/seeds/protocol_parser/valid-request.ndjson @@ -0,0 +1 @@ +{"protocol":"jlreq.conformance/1","spec":"jlreq-2020-08-11+unicode-17.0.0","id":"seed","request":{"source":"日","size":{"inline":1000,"block":1000},"frame":"full-em","clusters":[{"range":[0,3],"advance":1000}],"line_extent":1000}} diff --git a/mise.toml b/mise.toml index b92de90..7c4e11b 100644 --- a/mise.toml +++ b/mise.toml @@ -22,6 +22,7 @@ typos = "1.48.0" taplo = "0.10.0" actionlint = "1.7.12" zizmor = "1.28.0" +shellcheck = "0.11.0" # REUSE runs through uvx. uv = "0.11.32" @@ -35,6 +36,9 @@ cargo-binstall = "1.21.1" "cargo:cargo-shear" = "1.13.3" "cargo:cargo-mutants" = "27.1.0" "cargo:cargo-fuzz" = "0.13.2" +"cargo:cargo-llvm-cov" = "0.9.0" +"cargo:cargo-cyclonedx" = "0.5.9" +"cargo:cargo-semver-checks" = "0.50.0" [settings.cargo] binstall = true diff --git a/release-plz.toml b/release-plz.toml index 0487197..a8716d9 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -2,9 +2,8 @@ # # SPDX-License-Identifier: MIT OR Apache-2.0 -# Inert release configuration for the unreleased 0.0.0 development workspace. -# Publishing, tags, releases, and changelog mutation stay disabled until a maintainer makes -# a separate, explicit release decision. +# Inert release configuration for the prepared 0.1.0 workspace. Publishing, tags, releases, +# and changelog mutation stay disabled until a maintainer makes a separate, explicit decision. [workspace] dependencies_update = false diff --git a/scripts/check-semver.sh b/scripts/check-semver.sh new file mode 100644 index 0000000..18d5bc7 --- /dev/null +++ b/scripts/check-semver.sh @@ -0,0 +1,79 @@ +#!/bin/sh + +# SPDX-FileCopyrightText: 2026 jlreq contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +set -eu + +control=docs/public-api.toml + +contract_value() { + key=$1 + values=$(sed -n "s/^${key} = \"\([^\"]*\)\"$/\\1/p" "$control") + lines=$(printf '%s\n' "$values" | awk 'NF { count += 1 } END { print count + 0 }') + if [ "$lines" -ne 1 ]; then + echo "semver: $control must contain exactly one $key string" >&2 + exit 2 + fi + printf '%s\n' "$values" +} + +baseline_version=$(contract_value baseline_version) +compatible_series=$(contract_value compatible_series) +package_id=$(cargo pkgid -p jlreq) +current_version=${package_id##*#} +current_version=${current_version##*@} + +case "$baseline_version" in + "$compatible_series".*) ;; + *) + echo "semver: baseline $baseline_version is outside compatible series $compatible_series" >&2 + exit 2 + ;; +esac + +case "$current_version" in + "$baseline_version" | "$compatible_series".*) ;; + *) + echo "semver: jlreq $current_version is outside the $compatible_series.x contract in $control" >&2 + echo "semver: review and update the release-line policy before continuing" >&2 + exit 2 + ;; +esac + +# This local, network-free control is always enforced, including before the first version +# exists in a registry. It detects missing and extra exports and policy-choice drift. +cargo run --quiet -p xtask -- api + +# Before 0.1.0 is published there is no external semantic-versioning baseline. A caller can +# supply a source baseline to exercise the complete check in tests or release engineering. +if [ -n "${JLREQ_SEMVER_BASELINE_ROOT:-}" ]; then + if [ ! -d "$JLREQ_SEMVER_BASELINE_ROOT" ] && [ ! -f "$JLREQ_SEMVER_BASELINE_ROOT" ]; then + echo "semver: baseline root does not exist: $JLREQ_SEMVER_BASELINE_ROOT" >&2 + exit 2 + fi + # The current tree's documentation warnings are already denied by `just doc`. Do not + # make a later compiler's new warning in an immutable registry baseline masquerade as + # a semantic-versioning failure. + RUSTDOCFLAGS='' cargo semver-checks check-release \ + --manifest-path crates/jlreq/Cargo.toml \ + --package jlreq \ + --baseline-root "$JLREQ_SEMVER_BASELINE_ROOT" \ + --release-type patch \ + --all-features + exit 0 +fi + +if [ "$current_version" = "$baseline_version" ]; then + echo "semver: $current_version is the initial baseline; registry comparison begins with the next $compatible_series.x candidate" + exit 0 +fi + +# For later 0.1.x candidates cargo-semver-checks resolves the latest normal, non-yanked +# crates.io version. Comparing with the latest release also protects API added in an +# intermediate patch release, not only the original 0.1.0 surface. +RUSTDOCFLAGS='' cargo semver-checks check-release \ + --manifest-path crates/jlreq/Cargo.toml \ + --package jlreq \ + --release-type patch \ + --all-features diff --git a/scripts/package-binaries.sh b/scripts/package-binaries.sh new file mode 100755 index 0000000..646543b --- /dev/null +++ b/scripts/package-binaries.sh @@ -0,0 +1,91 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 jlreq contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +set -eu + +if [ "$#" -ne 1 ]; then + echo "usage: package-binaries.sh RUST-TARGET" >&2 + exit 2 +fi + +target=$1 +case $target in + '' | *[!A-Za-z0-9._-]*) + echo "RUST-TARGET contains an unsafe path character: $target" >&2 + exit 2 + ;; +esac + +root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +version=0.1.0 +name=jlreq-$version-$target +dist=$root/target/dist + +mkdir -p "$dist" +scratch=$(mktemp -d "$dist/.package-$target.XXXXXX") +stage=$scratch/$name +mkdir -p "$stage" + +cleanup() { + rm -r "$scratch" +} +trap cleanup EXIT HUP INT TERM + +cargo build --locked --release --target "$target" -p jlreq-conformance --bins + +suffix= +case $target in + *windows*) suffix=.exe ;; +esac + +for binary in jlreq-conformance jlreq-sample-engine; do + cp "$root/target/$target/release/$binary$suffix" "$stage/" +done +cp "$root/README.md" "$stage/" +cp "$root/LICENSES/MIT.txt" "$stage/LICENSE-MIT" +cp "$root/LICENSES/Apache-2.0.txt" "$stage/LICENSE-APACHE" + +case $target in + *windows*) + archive=$dist/$name.zip + candidate=$scratch/$name.zip + (cd "$scratch" && 7z a -bd -tzip "$candidate" "$name" >/dev/null) + ;; + *) + archive=$dist/$name.tar.gz + candidate=$scratch/$name.tar.gz + tar -czf "$candidate" -C "$scratch" "$name" + ;; +esac + +case $candidate in + *.zip) + listing=$(7z l -ba "$candidate") + printf '%s\n' "$listing" | grep -F "$name/jlreq-conformance$suffix" >/dev/null + printf '%s\n' "$listing" | grep -F "$name/jlreq-sample-engine$suffix" >/dev/null + printf '%s\n' "$listing" | grep -F "$name/README.md" >/dev/null + printf '%s\n' "$listing" | grep -F "$name/LICENSE-MIT" >/dev/null + printf '%s\n' "$listing" | grep -F "$name/LICENSE-APACHE" >/dev/null + ;; + *) + for required in jlreq-conformance jlreq-sample-engine README.md LICENSE-MIT LICENSE-APACHE; do + tar -tzf "$candidate" | grep -Fx "$name/$required" >/dev/null + done + ;; +esac + +size=$(wc -c < "$candidate" | tr -d ' ') +test "$size" -le 52428800 || { + echo "$candidate exceeds the 50 MiB binary-archive limit" >&2 + exit 1 +} + +mv -f "$candidate" "$archive" +if command -v sha256sum >/dev/null 2>&1; then + (cd "$dist" && sha256sum "$(basename "$archive")" > "$(basename "$archive").sha256") +else + (cd "$dist" && shasum -a 256 "$(basename "$archive")" > "$(basename "$archive").sha256") +fi + +echo "$archive" diff --git a/scripts/verify-crates.sh b/scripts/verify-crates.sh new file mode 100755 index 0000000..293bb84 --- /dev/null +++ b/scripts/verify-crates.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 jlreq contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +set -eu + +root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +version=0.1.0 +package_dir=$root/target/package +audit=$(mktemp -d "${TMPDIR:-/tmp}/jlreq-crates.XXXXXX") +trap 'rm -r "$audit"' EXIT HUP INT TERM + +for package in jlreq jlreq-conformance; do + archive=$package_dir/$package-$version.crate + test -f "$archive" || { + echo "missing crate archive: $archive" >&2 + exit 1 + } + size=$(wc -c < "$archive" | tr -d ' ') + test "$size" -le 5242880 || { + echo "$archive exceeds the 5 MiB release limit ($size bytes)" >&2 + exit 1 + } + if tar -tzf "$archive" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then + echo "$archive contains an unsafe path" >&2 + exit 1 + fi + for required in Cargo.toml README.md LICENSE-MIT LICENSE-APACHE; do + tar -tzf "$archive" | grep -Fx "$package-$version/$required" >/dev/null || { + echo "$archive omits $required" >&2 + exit 1 + } + done + tar -xzf "$archive" -C "$audit" +done + +for required in protocol.schema.json suite.ndjson; do + test -f "$audit/jlreq-conformance-$version/$required" || { + echo "jlreq-conformance archive omits $required" >&2 + exit 1 + } +done + +cargo test --manifest-path "$audit/jlreq-$version/Cargo.toml" --all-targets --offline +cargo test --manifest-path "$audit/jlreq-$version/Cargo.toml" --doc --offline + +patch="patch.crates-io.jlreq.path=\"$audit/jlreq-$version\"" +cargo test --manifest-path "$audit/jlreq-conformance-$version/Cargo.toml" --all-targets \ + --offline --config "$patch" +# A binary-only package has no Cargo doctest target. Building its rustdoc proves that the +# package documentation target itself is complete and valid in the extracted archive. +cargo doc --manifest-path "$audit/jlreq-conformance-$version/Cargo.toml" --no-deps \ + --offline --config "$patch" +cargo install --path "$audit/jlreq-conformance-$version" --root "$audit/install" \ + --locked --offline --config "$patch" + +suffix= +case $(uname -s) in + MINGW*|MSYS*|CYGWIN*) suffix=.exe ;; +esac +"$audit/install/bin/jlreq-conformance$suffix" --version | grep -Fx \ + "jlreq-conformance $version" >/dev/null +"$audit/install/bin/jlreq-conformance$suffix" --help >/dev/null +"$audit/install/bin/jlreq-sample-engine$suffix" --help >/dev/null 2>&1 || { + status=$? + test "$status" -eq 2 +} + +echo "crate archives verified: jlreq $version and jlreq-conformance $version" diff --git a/scripts/verify-mutation-ledger.sh b/scripts/verify-mutation-ledger.sh new file mode 100644 index 0000000..c224ada --- /dev/null +++ b/scripts/verify-mutation-ledger.sh @@ -0,0 +1,175 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 jlreq contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +set -eu + +root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" + +ledger=docs/mutation-ledger.toml +config=.cargo/mutants.toml +scratch=$(mktemp -d "${TMPDIR:-/tmp}/jlreq-mutation-ledger.XXXXXX") + +cleanup() { + rm -r "$scratch" +} +trap cleanup EXIT HUP INT TERM + +fail() { + echo "mutation-ledger: $*" >&2 + exit 1 +} + +digest() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -- "$1" | awk '{ print $1 }' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 -- "$1" | awk '{ print $1 }' + else + fail "neither sha256sum nor shasum is available" + fi +} + +# The ledger deliberately uses only flat, quoted fields inside these array tables. Parse +# that closed shape instead of adding a package dependency merely to validate repository +# metadata. +awk ' +function clear() { + path = sha = kind = provenance = reason = "" +} +function value(line) { + sub(/^[^=]+=[[:space:]]*/, "", line) + return substr(line, 2, length(line) - 2) +} +function emit() { + if (section != "exclusion") return + if (path == "" || sha == "" || kind == "" || provenance == "" || reason == "") { + print "docs/mutation-ledger.toml: incomplete [[exclusion]]" > "/dev/stderr" + failed = 1 + return + } + print path "\t" sha "\t" kind "\t" provenance "\t" reason +} +$0 == "[[exclusion]]" { + emit() + section = "exclusion" + clear() + next +} +/^\[\[/ { + emit() + section = "" + clear() + next +} +section == "exclusion" && /^path = / { path = value($0); next } +section == "exclusion" && /^sha256 = / { sha = value($0); next } +section == "exclusion" && /^kind = / { kind = value($0); next } +section == "exclusion" && /^provenance = / { provenance = value($0); next } +section == "exclusion" && /^reason = / { reason = value($0); next } +END { + emit() + if (failed) exit 1 +} +' "$ledger" >"$scratch/exclusions.tsv" + +awk ' +function clear() { + mutant = pattern = sha = proof = "" +} +function value(line) { + sub(/^[^=]+=[[:space:]]*/, "", line) + return substr(line, 2, length(line) - 2) +} +function emit() { + if (section != "equivalent") return + if (mutant == "" || pattern == "" || sha == "" || proof == "") { + print "docs/mutation-ledger.toml: incomplete [[equivalent]]" > "/dev/stderr" + failed = 1 + return + } + print mutant "\t" pattern "\t" sha "\t" proof +} +$0 == "[[equivalent]]" { + emit() + section = "equivalent" + clear() + next +} +/^\[\[/ { + emit() + section = "" + clear() + next +} +section == "equivalent" && /^mutant = / { mutant = value($0); next } +section == "equivalent" && /^exclude_re = / { pattern = value($0); next } +section == "equivalent" && /^source_sha256 = / { sha = value($0); next } +section == "equivalent" && /^proof = / { proof = value($0); next } +END { + emit() + if (failed) exit 1 +} +' "$ledger" >"$scratch/equivalent.tsv" + +if grep -Eq '^glob = ' "$ledger"; then + fail "generated exclusions must name individual paths, not a broad glob" +fi + +find crates/jlreq/src/generated -maxdepth 1 -type f -name '*.rs' -print | + LC_ALL=C sort >"$scratch/generated-files.txt" +cut -f 1 "$scratch/exclusions.tsv" | LC_ALL=C sort >"$scratch/excluded-files.txt" +if ! cmp -s "$scratch/generated-files.txt" "$scratch/excluded-files.txt"; then + diff -u "$scratch/generated-files.txt" "$scratch/excluded-files.txt" >&2 || true + fail "the ledger must exclude every generated table, and only those tables" +fi + +duplicates=$(cut -f 1 "$scratch/exclusions.tsv" | LC_ALL=C sort | uniq -d) +test -z "$duplicates" || fail "duplicate generated exclusion: $duplicates" + +tab=$(printf '\t') +while IFS="$tab" read -r path expected kind provenance reason; do + test "$kind" = generated || fail "$path has non-generated exclusion kind $kind" + test "$provenance" = data/manifest.toml || fail "$path is not anchored to data/manifest.toml" + test -n "$reason" || fail "$path has no exclusion reason" + test -f "$path" || fail "$path does not exist" + test "${#expected}" -eq 64 || fail "$path has a malformed SHA-256" + case "$expected" in + *[!0-9a-f]*) fail "$path has a non-hexadecimal SHA-256" ;; + esac + actual=$(digest "$path") + test "$actual" = "$expected" || fail "$path changed: expected $expected, observed $actual" +done <"$scratch/exclusions.tsv" + +if ! grep -Fqx 'exclude_globs = ["crates/jlreq/src/generated/**"]' "$config"; then + fail "$config must exclude the generated table directory exactly" +fi +if grep -Fq '"crates/jlreq/src/generated.rs"' "$config"; then + fail "$config excludes the handwritten generated.rs integrity checks" +fi + +duplicates=$(cut -f 1 "$scratch/equivalent.tsv" | LC_ALL=C sort | uniq -d) +test -z "$duplicates" || fail "duplicate equivalent mutant: $duplicates" +duplicates=$(cut -f 2 "$scratch/equivalent.tsv" | LC_ALL=C sort | uniq -d) +test -z "$duplicates" || fail "duplicate equivalent-mutant regex: $duplicates" + +while IFS="$tab" read -r mutant pattern expected proof; do + case "$mutant" in + *.rs:*) source="${mutant%%.rs:*}.rs" ;; + *) fail "equivalent mutant does not start with a Rust source path: $mutant" ;; + esac + test -n "$proof" || fail "$mutant has no equivalence proof" + test -f "$source" || fail "$source does not exist" + actual=$(digest "$source") + test "$actual" = "$expected" || fail "$mutant is stale: expected $expected, observed $actual" + grep -Fqx " '$pattern'," "$config" || fail "$mutant has no exact regex in $config" +done <"$scratch/equivalent.tsv" + +documented=$(wc -l <"$scratch/equivalent.tsv" | tr -d ' ') +configured=$(grep -c "^ '" "$config" || true) +test "$configured" -eq "$documented" || + fail "$config has $configured equivalent regex(es), but the ledger documents $documented" + +generated=$(wc -l <"$scratch/exclusions.tsv" | tr -d ' ') +echo "mutation-ledger: verified $generated generated file(s) and $documented equivalent mutant(s) against source SHA-256" diff --git a/scripts/verify-release-state.sh b/scripts/verify-release-state.sh new file mode 100755 index 0000000..d72723c --- /dev/null +++ b/scripts/verify-release-state.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 jlreq contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +set -eu + +root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" + +test -f docs/public-api.toml +test ! -e docs/api-1.0.toml +test -f docs/generated/conformance-summary.md +test -f docs/error-codes.md +test -f docs/mutation-ledger.toml +test -f .github/workflows/release.yml +test -f .github/workflows/release-check.yml + +grep -F 'version = "0.1.0"' Cargo.toml >/dev/null +grep -F 'jlreq = { version = "0.1.0", path = "../jlreq" }' \ + crates/jlreq-conformance/Cargo.toml >/dev/null +if grep -F 'publish = false' crates/jlreq/Cargo.toml crates/jlreq-conformance/Cargo.toml; then + echo "release crates must be publishable" >&2 + exit 1 +fi +if grep -Eq '^git_(tag|release)_enable = true$' release-plz.toml; then + echo "release-plz must remain externally inert during preparation" >&2 + exit 1 +fi + +git diff --exit-code -- crates/jlreq/src/generated data/manifest.toml \ + docs/generated/conformance-summary.md + +echo "0.1.0 release state is internally consistent; no publication was performed" diff --git a/typos.toml b/typos.toml index 1ad94f8..724b185 100644 --- a/typos.toml +++ b/typos.toml @@ -26,6 +26,10 @@ clreq = "clreq" # the W3C Requirements for Chinese Text Layout # Unicode property values a conformance case names, which are the standard's spelling. Nd = "Nd" # General_Category=Nd, the decimal digits +[default.extend-identifiers] +# The 7-Zip bare-output switch used when auditing Windows release archives. +ba = "ba" + [files] extend-exclude = [ "target/**", diff --git a/xtask/src/api.rs b/xtask/src/api.rs index 9b26fa5..59c0d03 100644 --- a/xtask/src/api.rs +++ b/xtask/src/api.rs @@ -4,9 +4,9 @@ //! The `api` gate. //! -//! Holds the unified candidate surface exactly to `docs/api-1.0.toml`, including the +//! Holds the released 0.1.0 surface exactly to `docs/public-api.toml`, including the //! bidirectional mapping from all 22 specification questions to dedicated Style enums. -//! The retired pre-1.0 crate surfaces are deliberately outside this gate. Their structural +//! The retired pre-0.1 crate surfaces are deliberately outside this gate. Their structural //! parser remains unit-tested for repository archaeology, but is inactive unless somebody //! restores the deleted `docs/api-frozen.toml` control. //! @@ -80,9 +80,9 @@ //! Visibility is read literally: a type is public when it is declared `pub`, whether or not //! a `pub use` re-exports it. That is the outer bound and it fails closed — a `pub` type is //! one export line away from an adopter's hands, so holding it to the frozen shape now is -//! what keeps the shape from being decided by that line. The 1.0 surface governed by the +//! what keeps the shape from being decided by that line. The 0.1.0 surface governed by the //! active path is the sole `jlreq` library and the `jlreq::style` namespace, compared -//! exactly with `docs/api-1.0.toml`. +//! exactly with `docs/public-api.toml`. use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -94,7 +94,7 @@ use crate::shared::{self, Gate}; /// The `api` gate, as the dispatcher sees it. pub(crate) const GATE: Gate = Gate { name: "api", - purpose: "jlreq matches docs/api-1.0.toml and its 22 typed Style mappings exactly", + purpose: "jlreq matches docs/public-api.toml and its 22 typed Style mappings exactly", reference: concat!( "docs/adr/0012-outcome-and-detail-compatibility.md ", "and docs/design/api-spine.md" @@ -113,7 +113,7 @@ fn run(arguments: &[String]) -> io::Result> { } let root = shared::workspace_root()?; let mut violations = Vec::new(); - // Keep the historical parser executable only for repository archaeology: the 1.0 + // Keep the historical parser executable only for repository archaeology: the 0.1.0 // workspace does not ship this file, so it is never part of the release contract. if root.join("docs").join("api-frozen.toml").is_file() { let control = Control::read(&root)?; @@ -131,16 +131,16 @@ fn run(arguments: &[String]) -> io::Result> { } } check_policy_space(&root, &mut violations)?; - check_one_point_zero_allowlist(&root, &mut violations)?; - println!("api: checked the sole public Rust crate against docs/api-1.0.toml"); + check_zero_one_zero_allowlist(&root, &mut violations)?; + println!("api: checked the sole public Rust crate against docs/public-api.toml"); Ok(violations) } // ------------------------------------------------------------------------------------- -// The jlreq 1.0 allowlist +// The jlreq 0.1.0 allowlist // ------------------------------------------------------------------------------------- -/// One public module whose directly exported item names are frozen for 1.0. +/// One public module whose directly exported item names are frozen for 0.1.0. #[derive(Debug)] struct AllowedModule { /// `jlreq` for the crate root, or a public module path such as `jlreq::style`. @@ -160,26 +160,26 @@ struct StyleChoiceMapping { count: usize, } -/// Read the explicit 1.0 surface and reject missing, extra, or duplicated module rows. +/// Read the explicit 0.1.0 surface and reject missing, extra, or duplicated module rows. fn allowed_modules(root: &Path) -> io::Result> { - let path = root.join("docs").join("api-1.0.toml"); + let path = root.join("docs").join("public-api.toml"); let text = fs::read_to_string(path)?; let entries = entries_of(&text); let mut modules = Vec::new(); let mut paths = BTreeSet::new(); for entry in entries.iter().filter(|entry| entry.table == "module") { let module_path = entry.single("path").ok_or_else(|| { - malformed("a `[[module]]` entry in docs/api-1.0.toml has no path".to_owned()) + malformed("a `[[module]]` entry in docs/public-api.toml has no path".to_owned()) })?; let items: BTreeSet = entry.list("items").iter().cloned().collect(); if items.is_empty() { return Err(malformed(format!( - "the `[[module]]` entry for `{module_path}` in docs/api-1.0.toml has no items" + "the `[[module]]` entry for `{module_path}` in docs/public-api.toml has no items" ))); } if !paths.insert(module_path.to_owned()) { return Err(malformed(format!( - "docs/api-1.0.toml lists the module `{module_path}` more than once" + "docs/public-api.toml lists the module `{module_path}` more than once" ))); } modules.push(AllowedModule { @@ -189,7 +189,7 @@ fn allowed_modules(root: &Path) -> io::Result> { } if modules.is_empty() { return Err(malformed( - "docs/api-1.0.toml has no `[[module]]` entries".to_owned(), + "docs/public-api.toml has no `[[module]]` entries".to_owned(), )); } Ok(modules) @@ -197,7 +197,7 @@ fn allowed_modules(root: &Path) -> io::Result> { /// Read the complete mapping from specification questions to typed public enums. fn style_choice_mappings(root: &Path) -> io::Result> { - let path = root.join("docs").join("api-1.0.toml"); + let path = root.join("docs").join("public-api.toml"); let text = fs::read_to_string(path)?; let entries = entries_of(&text); let mut mappings = Vec::new(); @@ -225,7 +225,7 @@ fn style_choice_mappings(root: &Path) -> io::Result> { } if mappings.is_empty() { return Err(malformed( - "docs/api-1.0.toml has no `[[style_choice]]` entries".to_owned(), + "docs/public-api.toml has no `[[style_choice]]` entries".to_owned(), )); } Ok(mappings) @@ -243,19 +243,19 @@ fn check_style_choice_mappings( for mapping in mappings { if !seen_questions.insert(&mapping.question) { violations.push(format!( - "docs/api-1.0.toml maps `{}` more than once", + "docs/public-api.toml maps `{}` more than once", mapping.question )); } if !seen_types.insert(&mapping.rust_type) { violations.push(format!( - "docs/api-1.0.toml maps more than one question to `{}`", + "docs/public-api.toml maps more than one question to `{}`", mapping.rust_type )); } if !style_items.contains(&mapping.rust_type) { violations.push(format!( - "docs/api-1.0.toml maps `{}` to `{}`, which jlreq::style does not export", + "docs/public-api.toml maps `{}` to `{}`, which jlreq::style does not export", mapping.question, mapping.rust_type )); } @@ -264,7 +264,7 @@ fn check_style_choice_mappings( .find(|question| question.path == mapping.question) else { violations.push(format!( - "docs/api-1.0.toml maps `{}`, which {POLICY_SPACE} does not record", + "docs/public-api.toml maps `{}`, which {POLICY_SPACE} does not record", mapping.question )); continue; @@ -283,7 +283,7 @@ fn check_style_choice_mappings( for question in derived { if !seen_questions.contains(&question.path) { violations.push(format!( - "{POLICY_SPACE} records `{}`, but docs/api-1.0.toml maps it to no typed enum", + "{POLICY_SPACE} records `{}`, but docs/public-api.toml maps it to no typed enum", question.path )); } @@ -303,21 +303,21 @@ fn check_allowed_items(allowed: &AllowedModule, source: &str) -> Vec { let mut violations = Vec::new(); for missing in allowed.items.difference(&actual) { violations.push(format!( - "docs/api-1.0.toml allows `{path}::{missing}`, but that item is not exported", + "docs/public-api.toml allows `{path}::{missing}`, but that item is not exported", path = allowed.path )); } for extra in actual.difference(&allowed.items) { violations.push(format!( - "`{path}::{extra}` is exported but absent from docs/api-1.0.toml", + "`{path}::{extra}` is exported but absent from docs/public-api.toml", path = allowed.path )); } violations } -/// Hold the only public Rust crate to the root and style-module names frozen for 1.0. -fn check_one_point_zero_allowlist(root: &Path, violations: &mut Vec) -> io::Result<()> { +/// Hold the only public Rust crate to the root and style-module names frozen for 0.1.0. +fn check_zero_one_zero_allowlist(root: &Path, violations: &mut Vec) -> io::Result<()> { let modules = allowed_modules(root)?; for module in &modules { let relative = match module.path.as_str() { @@ -330,7 +330,7 @@ fn check_one_point_zero_allowlist(root: &Path, violations: &mut Vec) -> }, path => { return Err(malformed(format!( - "docs/api-1.0.toml names `{path}`; the only public Rust crate is `jlreq`" + "docs/public-api.toml names `{path}`; the only public Rust crate is `jlreq`" ))); }, }; @@ -339,7 +339,7 @@ fn check_one_point_zero_allowlist(root: &Path, violations: &mut Vec) -> violations.extend(check_allowed_items(module, &source)); } println!( - "api: docs/api-1.0.toml freezes {items} item name(s) across {modules} public module(s).", + "api: docs/public-api.toml freezes {items} item name(s) across {modules} public module(s).", items = modules .iter() .map(|module| module.items.len()) @@ -832,7 +832,7 @@ impl Surface { Ok(Self { members }) } - /// Published members plus the blocked jlreq release candidate. + /// Published members in the jlreq release line. fn published(&self) -> impl Iterator { self.members .iter() @@ -2603,9 +2603,9 @@ fn report(control: &Control, surface: &Surface) { /// The derived policy space those constants are generated from. const POLICY_SPACE: &str = "spec/derived/questions.tsv"; -/// Hold the derived policy space equal to the dedicated typed enums 1.0 publishes. +/// Hold the derived policy space equal to the dedicated typed enums 0.1.0 publishes. /// -/// `docs/api-1.0.toml` maps every derived question path to one public enum and its closed +/// `docs/public-api.toml` maps every derived question path to one public enum and its closed /// choice count. The subtraction runs in both directions, so an unmapped specification row, /// an invented public setting, or a changed answer count is a failure. /// @@ -2624,7 +2624,7 @@ fn check_policy_space(root: &Path, violations: &mut Vec) -> io::Result<( .iter() .find(|module| module.path == "jlreq::style") .map(|module| &module.items) - .ok_or_else(|| malformed("docs/api-1.0.toml has no `jlreq::style` module".to_owned()))?; + .ok_or_else(|| malformed("docs/public-api.toml has no `jlreq::style` module".to_owned()))?; let derived = derived_questions(root)?.ok_or_else(|| { malformed(format!( "{POLICY_SPACE} does not exist, so the typed Style mapping cannot be checked" @@ -2636,7 +2636,7 @@ fn check_policy_space(root: &Path, violations: &mut Vec) -> io::Result<( style_items, )); println!( - "api: docs/api-1.0.toml maps {mappings} typed Style choice(s) onto {rows} generated JLReq question(s).", + "api: docs/public-api.toml maps {mappings} typed Style choice(s) onto {rows} generated JLReq question(s).", mappings = mappings.len(), rows = derived.len() ); @@ -2776,7 +2776,7 @@ mod tests { } #[test] - fn one_point_zero_allowlist_is_exact_in_both_directions() { + fn zero_one_zero_allowlist_is_exact_in_both_directions() { let allowed = AllowedModule { path: "jlreq".to_owned(), items: ["Style".to_owned(), "compose".to_owned()] @@ -2882,7 +2882,7 @@ mod tests { } #[test] - fn the_jlreq_release_candidate_is_checked_before_publication() { + fn the_jlreq_release_surface_is_checked_for_compatibility() { let surface = Surface { members: vec![ internal_member("jlreq", "pub struct PublicApi;\n"), @@ -3422,7 +3422,7 @@ mod tests { #[test] fn the_repository_itself_holds_every_check() { - let violations = super::run(&[]).expect("the 1.0 API gate runs"); + let violations = super::run(&[]).expect("the 0.1.0 API gate runs"); assert!(violations.is_empty(), "{violations:#?}"); } } diff --git a/xtask/src/direction.rs b/xtask/src/direction.rs index 68d2dcb..b635285 100644 --- a/xtask/src/direction.rs +++ b/xtask/src/direction.rs @@ -33,6 +33,10 @@ const LAYERS: &[Layer] = &[ name: "style", may_depend_on: &[], }, + Layer { + name: "limits", + may_depend_on: &[], + }, Layer { name: "generated", may_depend_on: &["spec"], @@ -63,6 +67,7 @@ const LAYERS: &[Layer] = &[ "construct", "generated", "layout", + "limits", "model", "normalize", "paragraph", @@ -76,6 +81,7 @@ const LAYERS: &[Layer] = &[ "construct", "generated", "layout", + "limits", "model", "normalize", "paragraph", diff --git a/xtask/src/generate.rs b/xtask/src/generate.rs index 8a64625..ef8463d 100644 --- a/xtask/src/generate.rs +++ b/xtask/src/generate.rs @@ -703,7 +703,7 @@ fn ledger() -> BTreeSet { "spec/captured/table6.ja.tsv", "spec/captured/invariants.tsv", "xtask/src/attest.rs", - "docs/api-1.0.toml", + "docs/public-api.toml", "xtask/src/api.rs", "crates/jlreq-conformance/suite.ndjson", "crates/jlreq-conformance/protocol.schema.json", @@ -1613,7 +1613,7 @@ mod tests { "spec/snapshot/index.html", "spec/captured/table1.ja.tsv", "spec/captured/invariants.tsv", - "docs/api-1.0.toml", + "docs/public-api.toml", "crates/jlreq-conformance/suite.ndjson", "crates/jlreq-conformance/protocol.schema.json", "docs/conformance-deferrals.toml", diff --git a/xtask/src/repository.rs b/xtask/src/repository.rs index fdd019c..13f088f 100644 --- a/xtask/src/repository.rs +++ b/xtask/src/repository.rs @@ -4,6 +4,7 @@ //! Repository-wide checks that do not belong to a Cargo package. +use std::collections::BTreeSet; use std::fs; use std::io; use std::path::{Path, PathBuf}; @@ -14,7 +15,7 @@ use crate::shared::{self, Gate}; /// The repository-hygiene gate exposed by the dispatcher. pub(crate) const GATE: Gate = Gate { name: "repository", - purpose: "the workspace is unreleased, tracked UTF-8 files use LF, and local Markdown links resolve", + purpose: "the 0.1.0 workspace is release-ready, tracked UTF-8 files use LF, and local Markdown links resolve", reference: "CONTRIBUTING.md", run, }; @@ -32,7 +33,8 @@ fn run(arguments: &[String]) -> io::Result> { let mut utf8_files = 0_usize; let mut documents = 0_usize; let mut links = 0_usize; - let mut violations = unreleased_state_violations(&root)?; + let mut violations = release_ready_state_violations(&root)?; + violations.extend(error_code_violations(&root)?); for file in &files { let bytes = fs::read(file)?; let Ok(source) = std::str::from_utf8(&bytes) else { @@ -63,12 +65,60 @@ fn run(arguments: &[String]) -> io::Result> { Ok(violations) } -/// Refuse to let development snapshots become publishable or acquire a release version. -fn unreleased_state_violations(root: &Path) -> io::Result> { +/// Require the user-facing error-code reference and product literals to name the same set. +fn error_code_violations(root: &Path) -> io::Result> { + let mut code = BTreeSet::new(); + for name in [ + "construct.rs", + "limits.rs", + "model.rs", + "normalize.rs", + "paragraph.rs", + "pipeline.rs", + "style.rs", + ] { + let source = fs::read_to_string(root.join("crates/jlreq/src").join(name))?; + code.extend(quoted_error_codes(&source, '"')); + } + let reference = fs::read_to_string(root.join("docs/error-codes.md"))?; + let documented = quoted_error_codes(&reference, '`'); + let mut violations = Vec::new(); + for missing in code.difference(&documented) { + violations.push(format!( + "docs/error-codes.md: missing product code `{missing}`" + )); + } + for stale in documented.difference(&code) { + violations.push(format!( + "docs/error-codes.md: unknown product code `{stale}`" + )); + } + Ok(violations) +} + +fn quoted_error_codes(source: &str, delimiter: char) -> BTreeSet { + source + .split(delimiter) + .enumerate() + .filter(|(index, value)| { + index % 2 == 1 + && ["input.", "style.", "compose.", "layout."] + .iter() + .any(|prefix| value.starts_with(prefix)) + && value + .chars() + .all(|character| character.is_ascii_lowercase() || ".-".contains(character)) + }) + .map(|(_, value)| value.to_owned()) + .collect() +} + +/// Keep the manifests publishable while all externally mutating release actions stay disabled. +fn release_ready_state_violations(root: &Path) -> io::Result> { let mut violations = Vec::new(); let workspace = fs::read_to_string(root.join("Cargo.toml"))?; - if !workspace.contains("version = \"0.0.0\"") { - violations.push("Cargo.toml: development snapshots use version 0.0.0".to_owned()); + if !workspace.contains("version = \"0.1.0\"") { + violations.push("Cargo.toml: the release-ready workspace uses version 0.1.0".to_owned()); } for manifest in [ @@ -76,17 +126,20 @@ fn unreleased_state_violations(root: &Path) -> io::Result> { "crates/jlreq-conformance/Cargo.toml", ] { let source = fs::read_to_string(root.join(manifest))?; - if !source.lines().any(|line| line.trim() == "publish = false") { - violations.push(format!( - "{manifest}: development packages set publish = false" - )); + if source.lines().any(|line| line.trim() == "publish = false") { + violations.push(format!("{manifest}: release packages must be publishable")); + } + for required in ["LICENSE-MIT", "LICENSE-APACHE", "README.md"] { + if !source.contains(&format!("\"{required}\"")) { + violations.push(format!("{manifest}: package include list omits {required}")); + } } } let conformance = fs::read_to_string(root.join("crates/jlreq-conformance/Cargo.toml"))?; - if !conformance.contains("jlreq = { version = \"0.0.0\", path = \"../jlreq\" }") { + if !conformance.contains("jlreq = { version = \"0.1.0\", path = \"../jlreq\" }") { violations.push( - "crates/jlreq-conformance/Cargo.toml: the local jlreq dependency uses version 0.0.0" + "crates/jlreq-conformance/Cargo.toml: jlreq needs version 0.1.0 plus its local path" .to_owned(), ); } @@ -98,9 +151,8 @@ fn unreleased_state_violations(root: &Path) -> io::Result> { "git_tag_enable = true" | "git_release_enable = true" ) }) { - violations.push( - "release-plz.toml: development snapshots do not create tags or releases".to_owned(), - ); + violations + .push("release-plz.toml: preparation must not create tags or releases".to_owned()); } let changelog = fs::read_to_string(root.join("CHANGELOG.md"))?; @@ -131,6 +183,10 @@ fn tracked_files(root: &Path) -> io::Result> { .split(|byte| *byte == 0) .filter(|path| !path.is_empty()) .map(|path| root.join(String::from_utf8_lossy(path).as_ref())) + // During a rename, the index still names the old path until the change is staged. + // A clean candidate commit has no such entries; skipping them keeps the gate useful + // while the replacement file is being authored. + .filter(|path| path.exists()) .collect::>(); documents.sort(); Ok(documents) @@ -226,7 +282,7 @@ fn unresolved_link(root: &Path, document: &Path, target: &str) -> Option #[cfg(test)] mod tests { - use super::{Link, local_links, unreleased_state_violations, unresolved_link}; + use super::{Link, local_links, release_ready_state_violations, unresolved_link}; use std::path::Path; #[test] @@ -260,11 +316,12 @@ mod tests { } #[test] - fn the_workspace_is_explicitly_an_unreleased_development_snapshot() -> std::io::Result<()> { + fn the_workspace_is_publishable_but_external_release_actions_are_inert() -> std::io::Result<()> + { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .expect("workspace root"); - assert_eq!(unreleased_state_violations(root)?, Vec::::new()); + assert_eq!(release_ready_state_violations(root)?, Vec::::new()); Ok(()) } }