1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
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. |
|