aboutsummaryrefslogtreecommitdiff
path: root/docs/requirements
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:21:47 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:21:47 -0400
commitea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /docs/requirements
parent8c0b4c53b130555f040884c1f52b90f16b23e241 (diff)
feat: implement Helix-style modal buffer under DO-178C DAL-C
The repository was an unmodified ratatui component template: no editor code, JSON5 config, and placeholder widgets. This establishes the first working baseline — `moji <file>` opens a file into a ropey rope and edits it with Helix selection-first semantics. Requirements, implementation and tests land together because they must: the traceability check rejects requirements with no implementation and tests naming requirements that do not exist, so neither half is a valid commit on its own. Package renamed to mojibake-editor (mojibake was taken on crates.io); binary is moji, library target stays mojibake. Class: New behaviour Requirements: MJB-HLR-001..019, MJB-LLR-001..205 Derived: MJB-DR-001..007 (DR-001 resolved, six open for review) Verified: cargo build; clippy --all-targets -D warnings clean; cargo test 294 passing; ./scripts/check-trace.sh 98/98/98; cargo package clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'docs/requirements')
-rw-r--r--docs/requirements/derived.md161
-rw-r--r--docs/requirements/hlr.md151
-rw-r--r--docs/requirements/llr.md160
3 files changed, 472 insertions, 0 deletions
diff --git a/docs/requirements/derived.md b/docs/requirements/derived.md
new file mode 100644
index 0000000..8b31070
--- /dev/null
+++ b/docs/requirements/derived.md
@@ -0,0 +1,161 @@
+# mojibake — Derived Requirements
+
+Software Level: **DAL-C** (DO-178C)
+
+DO-178C §5.1.1.b: requirements arising from design decisions that are not
+traceable to a higher-level requirement must be recorded and **flagged for
+review** by the safety assessment process. Each entry below states what forced
+it and what the reviewer must judge.
+
+---
+
+## MJB-DR-001 — UTF-8 is strict; declared encodings are transcoded
+
+**Status:** **resolved** — no longer an open conflict
+**Relates to:** the originating task statement, which listed a "non-UTF-8
+rejection path" as a robustness case.
+
+### How it stood
+
+Full Helix save fidelity transcodes rather than rejects, so the first
+implementation decoded every input leniently and replaced bad bytes with U+FFFD.
+That appeared to contradict the requested rejection path, and the contradiction
+was recorded here for the safety assessment. The flagged risk was concrete:
+substitution is invisible to the user, and saving a buffer that contains
+substituted characters writes U+FFFD over their data.
+
+### How it was resolved
+
+The requester directed that the UTF-8 rule be made an **exception**. The two
+behaviours are not in fact in conflict once split by whether the encoding is
+*known*:
+
+| Input | Behaviour | Rationale |
+|---|---|---|
+| BOM declares a non-UTF-8 encoding | Transcode; invalid sequences become U+FFFD | The encoding is known, so the substitution is reproducible on write and Helix fidelity is preserved |
+| UTF-8, declared or assumed | **Reject** with the failing byte offset | Nothing is known that would make a repair reproducible, so refusing is the only non-destructive answer |
+
+**Derived requirements.** MJB-LLR-113 governs the transcoding branch;
+**MJB-LLR-118** governs the UTF-8 exception. MJB-LLR-115 continues to require
+that a detected encoding and BOM round-trip through load and save.
+
+**Residual item for the reviewer.** A user who genuinely wants to inspect a
+binary file now cannot open it at all. No override (`moji --binary`, or a
+`:e!`-style force) is provided. This is a deliberate omission rather than an
+oversight: an override would reintroduce the lossy-save hazard through a
+different door, and should be added only with a corresponding requirement that
+makes such a buffer read-only.
+
+---
+
+## MJB-DR-002 — Byte offsets require explicit char-boundary defence
+
+**Status:** open — needs review
+**Forced by:** the choice of ropey 2.0 with `metric_chars` disabled.
+
+Under char indexing, an index cannot fall inside a character. Under byte
+indexing it can, and `Rope::insert`/`remove` panic when it does. This failure
+mode does not exist in the Helix design being ported and so is not covered by
+any HLR.
+
+**Derived requirement.** Every externally supplied byte offset shall be clamped
+into range and moved to a char boundary at construction (MJB-LLR-011), and
+change-set application shall validate operation boundaries and return an error
+rather than allow a rope panic (MJB-LLR-044).
+
+**Reviewer must judge:** whether returning an error is the correct response, or
+whether a violated boundary indicates a defect that should abort. Current choice
+is to return an error, consistent with MJB-HLR-018.
+
+---
+
+## MJB-DR-003 — Soft wrap is not implemented
+
+**Status:** open — needs review
+**Forced by:** scope.
+
+Helix's viewport carries a `vertical_offset` to address rows within a
+soft-wrapped line. With no soft wrap, one buffer line occupies exactly one
+screen row, so `vertical_offset` is always zero and is omitted from
+`ViewPosition` (MJB-LLR-090).
+
+**Derived requirement.** Lines wider than the viewport shall scroll horizontally
+rather than wrap (MJB-LLR-099).
+
+**Reviewer must judge:** that horizontal scrolling is acceptable for the intended
+use, and that reintroducing soft wrap later is understood to require reworking
+the viewport anchor.
+
+---
+
+## MJB-DR-004 — Keymap ownership is split between App and Buffer
+
+**Status:** open — needs review
+**Forced by:** the template's event routing, which delivers every key both to the
+application keymap and to every component.
+
+Leaving that routing intact would let a global binding such as `q` fire while the
+user types in insert mode. Ownership is therefore split: `App` owns only the
+`Global` mode and consumes matching keys; the buffer owns all other modes.
+
+**Derived requirement.** `App` shall not forward a key to components once the
+`Global` keymap has matched it (MJB-LLR-203).
+
+**Reviewer must judge:** that `Global` bindings are intentionally unreachable
+from every mode, and that placing `Ctrl-c` there is intended even though Helix
+binds `Ctrl-c` to comment-toggle in normal mode.
+
+---
+
+## MJB-DR-005 — Count is accumulated but consumed by few commands
+
+**Status:** open — needs review
+**Forced by:** MJB-LLR-155 being cheap to implement but not required by any
+command in the mandated binding set.
+
+No command in MJB-HLR-006 through MJB-HLR-013 requires a count. The count is
+accumulated and passed to command execution, where motions honour it and other
+commands ignore it.
+
+**Reviewer must judge:** whether silently ignoring a count on a command that does
+not use it is acceptable, or whether it should be reported as an error.
+
+---
+
+## MJB-DR-007 — UTF-16 must be encoded by hand
+
+**Status:** open — needs review
+**Found during:** implementation, by a failing round-trip test.
+
+`encoding_rs::Encoding::encode` is deliberately asymmetric. It decodes UTF-16
+but refuses to encode to it, silently substituting UTF-8 and reporting the
+substitution only through a return value that is easy to discard. Delegating
+the save path to it wrote UTF-8 bytes beneath a UTF-16 byte order mark — a
+file that no longer matched its own BOM.
+
+**Derived requirement.** UTF-16LE and UTF-16BE shall be encoded directly from
+`str::encode_utf16`, with explicit little- and big-endian byte order, and shall
+not be routed through `encoding_rs::Encoding::encode` (MJB-LLR-115).
+
+**Reviewer must judge:** whether the remaining encodings, which *are* delegated
+to `encoding_rs`, share any comparable asymmetry. The known set is UTF-16LE and
+UTF-16BE; single-byte and UTF-8 encodings round-trip correctly. Note that this
+defect was invisible to inspection and was caught only by a round-trip test —
+the same test shape should guard any encoding added later.
+
+---
+
+## MJB-DR-006 — Pre-release dependency under DAL-C
+
+**Status:** accepted by the requester — recorded for review
+**See:** [../reviews/library-selection.md](../reviews/library-selection.md)
+
+ropey 2.0.0-beta.1 is a pre-release, self-described as not battle-tested. It was
+selected over the stable 1.6.1 deliberately, with the trade-off stated.
+
+**Derived requirement.** The dependency shall be pinned to an exact version and
+reached only through `src/buffer/document.rs`, so that a replacement is confined
+to one module.
+
+**Reviewer must judge:** whether a pre-release dependency is acceptable for the
+intended deployment, and whether the confinement is in fact maintained.
diff --git a/docs/requirements/hlr.md b/docs/requirements/hlr.md
new file mode 100644
index 0000000..0db3d34
--- /dev/null
+++ b/docs/requirements/hlr.md
@@ -0,0 +1,151 @@
+# mojibake — High-Level Requirements
+
+Software Level: **DAL-C** (DO-178C)
+Scope: the file buffer component, its configuration, and the removal of the
+template widgets.
+
+ID scheme: `MJB-HLR-nnn`. Low-level requirements deriving from these are in
+[llr.md](llr.md); requirements the implementation needed that these did not
+anticipate are in [derived.md](derived.md). Traceability is in
+[../traceability/trace.md](../traceability/trace.md).
+
+---
+
+## File loading and representation
+
+**MJB-HLR-001 — File load from command line**
+The editor shall accept an optional file path as a positional command-line
+argument and load its contents into the buffer. When no path is supplied the
+editor shall present an empty buffer. When the path does not exist the editor
+shall present an empty buffer associated with that path, so that a subsequent
+write creates the file.
+
+**MJB-HLR-002 — Character encoding and byte order mark**
+The editor shall detect a byte order mark on load, decode the file contents
+using the detected encoding, and record both the encoding and the presence of
+the BOM so that a subsequent write reproduces them. In the absence of a BOM the
+editor shall decode as UTF-8.
+
+Where the encoding is declared by a byte order mark and is not UTF-8, byte
+sequences invalid in that encoding shall be decoded to replacement characters
+and shall not terminate the editor; the declared encoding makes such a
+substitution reproducible on write.
+
+**UTF-8 is an exception to the preceding paragraph.** Content decoded as UTF-8,
+whether declared by a byte order mark or assumed in its absence, shall be
+validated strictly. A file containing an invalid UTF-8 sequence shall be
+**refused**, with a diagnostic identifying the offset at which validation
+failed, and shall leave the file unmodified. Repairing such content would write
+the repair back over the user's data on the next save.
+
+**MJB-HLR-003 — Line ending detection and preservation**
+The editor shall detect the predominant line ending of a loaded file (LF, CRLF
+or CR), record it, and reproduce that line ending on write. A file whose line
+ending cannot be determined shall use the platform default.
+
+**MJB-HLR-004 — Rope text storage**
+The editor shall hold buffer text in a rope structure indexed by byte offset,
+such that insertion and deletion cost does not scale with total file size.
+
+## Selection and cursor
+
+**MJB-HLR-005 — Selection model**
+The editor shall represent the cursor as a selection range with an anchor and a
+head, both byte offsets into the rope. Ranges shall be half-open — inclusive of
+the lower bound and exclusive of the upper bound — regardless of whether the
+head precedes or follows the anchor. The visible block cursor shall occupy one
+grapheme cluster inward from the head.
+
+## Motion
+
+**MJB-HLR-006 — Character and line motions**
+The editor shall provide, in normal mode, motions by one grapheme cluster left
+and right and by one line up and down, bound by default to `h`, `l`, `k` and
+`j`. Motions shall not move outside the buffer bounds.
+
+**MJB-HLR-007 — Word motions select**
+The editor shall provide next-word-start, previous-word-start and
+next-word-end motions bound by default to `w`, `b` and `e`. Consistent with
+Helix and unlike Vim, each shall leave a selection spanning the traversed text
+rather than a collapsed cursor, so that a subsequent operator acts on that
+selection without operator-pending state.
+
+**MJB-HLR-008 — Goto commands**
+The editor shall provide goto commands for start of file, end of file, start of
+line and end of line, bound by default to the two-key sequences `gg`, `ge`,
+`gh` and `gl`. Multi-key sequences shall resolve deterministically and shall not
+depend on the interval between keystrokes.
+
+## Modification
+
+**MJB-HLR-009 — Insert-mode entry**
+The editor shall enter insert mode via commands that place the cursor before the
+selection, after the selection, at the first character of the line, at the end
+of the line, and on a newly opened line below or above the current line — bound
+by default to `i`, `a`, `I`, `A`, `o` and `O`.
+
+**MJB-HLR-010 — Text modification**
+The editor shall delete the current selection on `d` and shall delete the
+current selection and enter insert mode on `c`. In insert mode the editor shall
+insert typed printable characters at the cursor and shall delete the preceding
+character on backspace.
+
+**MJB-HLR-011 — Undo and redo**
+The editor shall express every buffer modification as a transaction and shall
+retain the inverse of each applied transaction, such that `u` reverts the most
+recent modification and `U` reapplies it. Undo when no modification remains and
+redo when no reverted modification remains shall be no-ops and shall not be
+errors.
+
+## Presentation
+
+**MJB-HLR-012 — Viewport pagination**
+The editor shall render only those lines intersecting the visible viewport. The
+work performed per frame shall be proportional to the viewport height and shall
+not scale with the number of lines in the buffer.
+
+**MJB-HLR-013 — Scrolling and paging**
+The editor shall keep the cursor within the viewport, maintaining a configurable
+scroll-off margin from the top and bottom edges, clamped so that the margin
+never exceeds half the viewport. The editor shall provide half-viewport paging
+bound by default to `Ctrl-u` and `Ctrl-d` and full-viewport paging bound by
+default to `Ctrl-b` and `Ctrl-f`; each shall move the cursor together with the
+viewport.
+
+**MJB-HLR-019 — Single-widget presentation**
+The buffer shall be the only widget the editor presents. The frame-rate counter
+and the placeholder widget inherited from the application template shall be
+removed, together with their registrations and configuration, leaving no
+unreachable code.
+
+## Configuration and input
+
+**MJB-HLR-014 — TOML configuration**
+The editor shall read its configuration from TOML. No other configuration format
+shall be accepted, and no parser for another format shall remain in the
+dependency graph.
+
+**MJB-HLR-015 — Config-driven modal keymap**
+Key bindings shall be defined in configuration and scoped by editor mode, with
+the bindings required by MJB-HLR-006 through MJB-HLR-013 supplied as built-in
+defaults that user configuration overrides per binding. The keymap shall support
+multi-key sequences, and shall support modes in which an unbound printable key
+carries a default meaning rather than being discarded.
+
+**MJB-HLR-016 — Command mode**
+The editor shall provide a command line entered with `:` supporting at minimum
+write, quit, write-and-quit, force-quit and force-write, using the command names
+and aliases of Helix.
+
+## Robustness
+
+**MJB-HLR-017 — File write**
+The editor shall write buffer contents to the associated path on command. The
+write shall preserve a symbolic link target rather than replacing the link,
+shall preserve file permissions, shall refuse to write a read-only path, and
+shall restore the previous contents if the write fails partway.
+
+**MJB-HLR-018 — Error handling**
+The editor shall not terminate abnormally in response to malformed input,
+malformed configuration, or a failed file operation. Such conditions shall be
+reported to the user and the editor shall remain usable.
diff --git a/docs/requirements/llr.md b/docs/requirements/llr.md
new file mode 100644
index 0000000..67dfe6e
--- /dev/null
+++ b/docs/requirements/llr.md
@@ -0,0 +1,160 @@
+# mojibake — Low-Level Requirements
+
+Software Level: **DAL-C** (DO-178C)
+
+Each LLR cites its parent HLR. Source items implementing an LLR carry a
+`// MJB-LLR-nnn` comment immediately above them. Tests exercising an LLR are
+named `mjb_llr_nnn_<description>`.
+
+All positions are **byte offsets** into the rope. `LT` denotes
+`ropey::LineType::LF_CR`, the line-break convention enabled by default.
+
+---
+
+## selection.rs — Range and Selection (MJB-HLR-005)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-001 | `Range` shall store `anchor` and `head` as byte offsets. |
+| MJB-LLR-002 | `Range::from()` shall return `min(anchor, head)`; `Range::to()` shall return `max(anchor, head)`. |
+| MJB-LLR-003 | `Range::is_empty()` shall return true exactly when `anchor == head`. |
+| MJB-LLR-004 | `Range::direction()` shall return `Backward` when `head < anchor` and `Forward` otherwise. |
+| MJB-LLR-005 | `Range::cursor(text)` shall return `prev_grapheme_boundary(text, head)` when `head > anchor`, and `head` otherwise. |
+| MJB-LLR-006 | `Range::put_cursor(text, byte_idx, extend)` with `extend == false` shall return a point range at `byte_idx`. |
+| MJB-LLR-007 | `Range::put_cursor(text, byte_idx, extend)` with `extend == true` shall retain the anchor, adjusting it by one grapheme when the range flips direction across it, and shall place the head one grapheme past `byte_idx` when the anchor precedes it. |
+| MJB-LLR-008 | `Range::line_range(text)` shall return the inclusive line-index span covered by the range. |
+| MJB-LLR-009 | `Selection` shall maintain the invariant that it contains exactly one range and that `primary_index` is zero. |
+| MJB-LLR-010 | `Selection::primary()` shall return the range at `primary_index`. |
+| MJB-LLR-011 | Constructing a `Range` shall clamp both offsets into `0..=text.len()` and shall move each to the nearest char boundary. |
+
+## grapheme.rs — Grapheme boundaries and width (MJB-HLR-005, MJB-HLR-013)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-020 | `prev_grapheme_boundary(text, byte_idx)` shall return the byte offset of the grapheme boundary preceding `byte_idx`, or `0` when none exists. |
+| MJB-LLR-021 | `next_grapheme_boundary(text, byte_idx)` shall return the byte offset of the grapheme boundary following `byte_idx`, or `text.len()` when none exists. |
+| MJB-LLR-022 | Grapheme boundary computation shall operate across rope chunk edges, producing the same result as if the text were contiguous. |
+| MJB-LLR-023 | `is_grapheme_boundary(text, byte_idx)` shall return whether `byte_idx` lies on a grapheme cluster boundary. |
+| MJB-LLR-024 | `grapheme_width(g)` shall return the terminal display width of a grapheme cluster, treating a tab as advancing to the next tab stop and treating zero-width and control characters as width zero. |
+| MJB-LLR-025 | `display_column(line, byte_idx)` shall return the display column of `byte_idx` within a line, accumulating grapheme widths rather than counting bytes. |
+
+## transaction.rs / history.rs — Edits and undo (MJB-HLR-010, MJB-HLR-011)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-040 | `Operation` shall have variants `Retain(usize)`, `Delete(usize)` and `Insert(String)`, whose counts are byte lengths. |
+| MJB-LLR-041 | `ChangeSet` shall record `len` (required document length before application) and `len_after` (document length after application). |
+| MJB-LLR-042 | `ChangeSet::apply(rope)` shall return an error, leaving the rope unmodified, when `rope.len() != self.len`. |
+| MJB-LLR-043 | `ChangeSet::apply(rope)` shall realise `Retain` by advancing the position, `Delete(n)` by `rope.remove(pos..pos + n)`, and `Insert(s)` by `rope.insert(pos, s)` followed by advancing. |
+| MJB-LLR-044 | `ChangeSet::apply` shall return an error when any operation boundary does not fall on a char boundary of the rope, rather than panicking inside the rope. |
+| MJB-LLR-045 | `ChangeSet::invert(original)` shall map `Retain(n)` to `Retain(n)`, `Delete(n)` to `Insert` of the corresponding slice of `original`, and `Insert(s)` to `Delete(s.len())`. |
+| MJB-LLR-046 | Applying a change set and then applying its inverse shall reproduce the original rope contents exactly. |
+| MJB-LLR-047 | `Transaction` shall pair a `ChangeSet` with an optional resulting `Selection`. |
+| MJB-LLR-048 | `Transaction::change(rope, changes)` shall build a change set from an iterator of `(from, to, Option<String>)` triples ordered by ascending `from`. |
+| MJB-LLR-049 | `Transaction::insert(rope, selection, text)` shall insert `text` at the cursor of the selection. |
+| MJB-LLR-050 | `Transaction::delete(rope, selection)` shall delete the span `from()..to()` of the selection's primary range. |
+| MJB-LLR-051 | `History::commit` shall push the pair (forward transaction, inverse transaction) and discard any reverted entries ahead of the cursor. |
+| MJB-LLR-052 | `History::undo` shall return the inverse of the entry preceding the cursor and decrement the cursor; when the cursor is at zero it shall return `None`. |
+| MJB-LLR-053 | `History::redo` shall return the forward transaction at the cursor and increment the cursor; when the cursor is at the end it shall return `None`. |
+
+## movement.rs — Motions (MJB-HLR-006, MJB-HLR-007, MJB-HLR-008)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-060 | `CharCategory` shall classify a char as `Eol`, `Whitespace`, `Word` or `Punctuation`; `Word` shall comprise alphanumerics and underscore. |
+| MJB-LLR-061 | `is_word_boundary(a, b)` shall return `categorize(a) != categorize(b)`. |
+| MJB-LLR-062 | `move_char_left` shall move the head one grapheme toward zero, producing a point range, and shall be a no-op at offset zero. |
+| MJB-LLR-063 | `move_char_right` shall move the head one grapheme toward the end, producing a point range, and shall be a no-op at the end of the buffer. |
+| MJB-LLR-064 | `move_line_up` and `move_line_down` shall move the cursor to the same display column on the adjacent line, clamping to that line's length, and shall be no-ops on the first and last line respectively. |
+| MJB-LLR-065 | `move_next_word_start` shall return a range whose anchor is the pre-motion cursor position and whose head is the start of the following word, so the result is a selection spanning the traversed text. |
+| MJB-LLR-066 | `move_prev_word_start` shall return a range spanning backward from the pre-motion cursor to the start of the preceding word. |
+| MJB-LLR-067 | `move_next_word_end` shall return a range spanning from the pre-motion cursor to the end of the following word. |
+| MJB-LLR-068 | Word motions shall skip line-ending characters and shall stop at a category transition as defined by MJB-LLR-061. |
+| MJB-LLR-069 | Word motions shall be no-ops when already at the corresponding buffer boundary. |
+| MJB-LLR-070 | `goto_file_start` shall place a point range at offset zero. |
+| MJB-LLR-071 | `goto_last_line` shall place a point range at the first offset of the last line. |
+| MJB-LLR-072 | `goto_line_start` shall place a point range at the first offset of the cursor's line. |
+| MJB-LLR-073 | `goto_line_end` shall place a point range at the offset of the line's last character, excluding its line terminator. |
+
+## view.rs — Viewport (MJB-HLR-012, MJB-HLR-013)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-090 | `ViewPosition` shall store `anchor`, the byte offset of the first visible line's start, and `horizontal_offset`, a display-column count. |
+| MJB-LLR-091 | `View::top_line(rope)` shall return `rope.byte_to_line_idx(anchor, LT)`. |
+| MJB-LLR-092 | `View::visible_lines(rope, height)` shall yield at most `height` lines beginning at the top line, obtained via `rope.lines_at(top, LT)`, touching no other line. |
+| MJB-LLR-093 | `ensure_cursor_in_view` shall clamp the top scroll-off margin to `min(scrolloff, (height - 1) / 2)` and the bottom margin to `min(scrolloff, height / 2)`. |
+| MJB-LLR-094 | `ensure_cursor_in_view` shall set the top line to `cursor_line - scrolloff_top` when the cursor line is above the top margin. |
+| MJB-LLR-095 | `ensure_cursor_in_view` shall set the top line to `cursor_line + scrolloff_bottom + 1 - height` when the cursor line is at or below the bottom margin. |
+| MJB-LLR-096 | `ensure_cursor_in_view` shall leave the anchor unchanged when the cursor lies within both margins. |
+| MJB-LLR-097 | The computed top line shall be clamped to `0..len_lines(LT)` and converted back to a byte anchor with `rope.line_to_byte_idx(top, LT)`. |
+| MJB-LLR-098 | `ensure_cursor_in_view` shall be a no-op when the viewport height is zero, rather than underflowing. |
+| MJB-LLR-099 | `ensure_horizontal_in_view` shall adjust `horizontal_offset` so the cursor's display column lies within `[offset, offset + width)`. |
+| MJB-LLR-100 | `page_cursor_half_up` and `page_cursor_half_down` shall move the cursor and the viewport by `height / 2` lines. |
+| MJB-LLR-101 | `page_up` and `page_down` shall move the cursor and the viewport by `height` lines. |
+| MJB-LLR-102 | Paging shall saturate at the first and last line rather than wrapping or underflowing. |
+
+## document.rs / encoding.rs / line_ending.rs (MJB-HLR-001..004)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-110 | `detect_bom(bytes)` shall recognise the UTF-8, UTF-16LE and UTF-16BE byte order marks and return the encoding and BOM length. |
+| MJB-LLR-111 | `Document::open(path)` shall decode file contents through the detected encoding into a rope, recording encoding and BOM presence. |
+| MJB-LLR-112 | `Document::open` shall produce an empty rope, with the path retained, when the path does not exist. |
+| MJB-LLR-113 | Decoding content whose encoding is declared by a byte order mark and is not UTF-8 shall substitute replacement characters for sequences invalid in that encoding, and shall not return an error. |
+| MJB-LLR-118 | Content decoded as UTF-8, whether BOM-declared or assumed, shall be validated strictly; an invalid sequence shall produce `DecodeError::InvalidUtf8` carrying the byte offset at which validation failed, and `Document::open` shall propagate it without modifying the file. |
+| MJB-LLR-114 | `LineEnding::detect(rope)` shall return the line ending of the first terminator present, and the platform default when the buffer contains none. |
+| MJB-LLR-115 | `Document::encode()` shall reproduce the recorded BOM, translate line terminators to the recorded line ending, and encode via the recorded encoding. |
+| MJB-LLR-116 | `Document` shall expose a `modified` flag, set on the first applied transaction and cleared on a successful write. |
+| MJB-LLR-117 | `Document::apply(transaction)` shall apply the change set to the rope, commit the inverse to history, update the selection, and set `modified`. |
+
+## save.rs — Write path (MJB-HLR-017)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-130 | The write target shall be the resolved symbolic link target when the path is a symbolic link, with a relative target joined onto the link's parent directory. |
+| MJB-LLR-131 | The write shall fail with `PermissionDenied`, before modifying anything, when the target exists and is not writable. |
+| MJB-LLR-132 | The write shall fail with a message directing the user to `:w!` when the target's parent directory does not exist; with force, the parent shall be created recursively. |
+| MJB-LLR-133 | `must_copy` shall be true when the target is a symbolic link or has a hard link count greater than one. |
+| MJB-LLR-134 | A backup shall be created in the target's own directory, by copy when `must_copy` and by rename otherwise, so that no rename crosses a filesystem boundary. |
+| MJB-LLR-135 | When the write fails and a backup exists, the backup shall be restored — copied back when `must_copy`, renamed back otherwise. |
+| MJB-LLR-136 | When the write succeeds, permissions shall be copied from the backup onto the target and the backup shall be removed. |
+| MJB-LLR-137 | A successful write shall clear the document's `modified` flag. |
+
+## keymap.rs / command.rs — Input (MJB-HLR-015, MJB-HLR-016)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-150 | `Keymap::new` shall precompute the set of all proper prefixes of every bound key sequence, per mode. |
+| MJB-LLR-151 | On a key that completes a bound sequence, resolution shall yield `Matched` and clear the pending sequence. |
+| MJB-LLR-152 | On a key extending a known prefix without completing a binding, resolution shall yield `Pending` and retain the sequence. |
+| MJB-LLR-153 | On a key that neither completes a binding nor extends a known prefix, resolution shall yield `Cancelled` carrying the accumulated keys, and clear the pending sequence. |
+| MJB-LLR-154 | Resolution shall not depend on elapsed time; no pending sequence shall be discarded on a timer. |
+| MJB-LLR-155 | In normal and select modes with no pending sequence, `1`–`9`, and `0` once a count is in progress, shall accumulate a decimal count consumed by the next matched command. |
+| MJB-LLR-156 | In insert mode a `Cancelled` result carrying a single printable character with neither Control nor Alt held shall be interpreted as inserting that character. |
+| MJB-LLR-157 | The `Command` enum shall have one unit variant per bound editor command and shall deserialize from its variant name. |
+| MJB-LLR-158 | Command mode shall route keys to a line editor rather than the keymap, accepting printable characters, backspace, `Enter` to submit and `Escape` to cancel. |
+| MJB-LLR-159 | The command line shall parse `w`/`write`, `q`/`quit`, `wq`/`x`/`write-quit`, `q!`/`quit!` and `w!`/`write!`, and shall report an unrecognised command without terminating. |
+| MJB-LLR-160 | `q` with unsaved modifications shall be refused with a message; `q!` shall discard them. |
+
+## config.rs (MJB-HLR-014, MJB-HLR-018)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-180 | The built-in default configuration shall be the compiled-in contents of `.config/config.toml`, parsed with `toml::from_str`. |
+| MJB-LLR-181 | The only configuration file consulted shall be `config.toml` in the configuration directory. |
+| MJB-LLR-182 | User bindings shall override built-in defaults per individual binding, leaving unlisted defaults in force. |
+| MJB-LLR-183 | An unparsable key-sequence string in user configuration shall produce a recoverable error identifying the offending string, and shall not panic. |
+| MJB-LLR-184 | `Mode` shall have variants `Normal`, `Insert`, `Select`, `Command` and `Global`, deserialized from their lower-case names. |
+| MJB-LLR-185 | Configuration shall supply `scrolloff` and `insert_final_newline`, with defaults of 5 and true. |
+
+## components/buffer.rs / app.rs (MJB-HLR-012, MJB-HLR-019)
+
+| ID | Requirement |
+|---|---|
+| MJB-LLR-200 | The buffer component shall render only the lines yielded by `View::visible_lines`. |
+| MJB-LLR-201 | The buffer component shall render the block cursor at the primary range's cursor position and shall style the selection span distinctly. |
+| MJB-LLR-202 | The buffer component shall reserve the final viewport row for a status line showing mode, path, modified indicator and cursor position. |
+| MJB-LLR-203 | `App` shall consult the `Global` keymap first and shall not forward a key to components when that lookup matches. |
+| MJB-LLR-204 | `App` shall register no component other than the buffer. |
+| MJB-LLR-205 | `App` shall not retain per-tick key state, the chord-timeout mechanism having been removed. |