aboutsummaryrefslogtreecommitdiff
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
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>
-rw-r--r--.config/config.toml151
-rw-r--r--.envrc3
-rw-r--r--.gitmessage26
-rw-r--r--Cargo.lock4131
-rw-r--r--Cargo.toml89
-rw-r--r--README.md190
-rw-r--r--build.rs37
-rw-r--r--docs/process.md308
-rw-r--r--docs/requirements/derived.md161
-rw-r--r--docs/requirements/hlr.md151
-rw-r--r--docs/requirements/llr.md160
-rw-r--r--docs/reviews/code-checklist.md202
-rw-r--r--docs/reviews/library-selection.md98
-rw-r--r--docs/reviews/requirements-checklist.md112
-rw-r--r--docs/traceability/trace.md287
-rwxr-xr-xscripts/check-trace.sh59
-rw-r--r--src/action.rs17
-rw-r--r--src/app.rs176
-rw-r--r--src/buffer/command.rs147
-rw-r--r--src/buffer/document.rs525
-rw-r--r--src/buffer/encoding.rs272
-rw-r--r--src/buffer/grapheme.rs325
-rw-r--r--src/buffer/history.rs228
-rw-r--r--src/buffer/keymap.rs392
-rw-r--r--src/buffer/line_ending.rs144
-rw-r--r--src/buffer/mod.rs615
-rw-r--r--src/buffer/movement.rs592
-rw-r--r--src/buffer/save.rs387
-rw-r--r--src/buffer/selection.rs344
-rw-r--r--src/buffer/transaction.rs424
-rw-r--r--src/buffer/view.rs371
-rw-r--r--src/cli.rs54
-rw-r--r--src/components.rs125
-rw-r--r--src/components/buffer.rs237
-rw-r--r--src/config.rs888
-rw-r--r--src/errors.rs77
-rw-r--r--src/lib.rs18
-rw-r--r--src/logging.rs36
-rw-r--r--src/main.rs15
-rw-r--r--src/tui.rs233
-rw-r--r--tests/editing.rs1092
-rw-r--r--tests/rendering.rs285
42 files changed, 14172 insertions, 12 deletions
diff --git a/.config/config.toml b/.config/config.toml
new file mode 100644
index 0000000..8520772
--- /dev/null
+++ b/.config/config.toml
@@ -0,0 +1,151 @@
+# mojibake configuration.
+#
+# This file is both the runtime configuration and, via `include_str!` in
+# src/config.rs, the compiled-in default set. User settings in
+# $MOJIBAKE_CONFIG/config.toml override these per individual binding;
+# anything not listed here keeps its default. (MJB-LLR-180, MJB-LLR-182)
+#
+# Key syntax: "<k>" for one key, "<g><g>" for a sequence, with the modifier
+# prefixes ctrl-, alt-, shift-, e.g. "<ctrl-d>".
+#
+# Bindings follow the Helix default keymap: https://docs.helix-editor.com/keymap.html
+
+# Editor settings live in their own table rather than at top level: the
+# top-level struct uses `#[serde(flatten)]` for the data/config directories,
+# and config-rs stringifies values it buffers through a flattened map, which
+# breaks integer and boolean fields. (MJB-LLR-185)
+[editor]
+# Lines of context kept between the cursor and the viewport edge.
+# Clamped to half the viewport height at use. (MJB-LLR-093)
+scrolloff = 5
+
+# Append a trailing newline on write when the buffer lacks one.
+insert_final_newline = true
+
+# Consulted by App before any component sees the key. A match here is consumed
+# and never reaches the buffer, so these fire in every mode. (MJB-LLR-203)
+[keybindings.global]
+"<ctrl-c>" = "Quit"
+"<ctrl-z>" = "Suspend"
+
+[keybindings.normal]
+# Movement (MJB-HLR-006)
+"<h>" = "MoveCharLeft"
+"<j>" = "MoveLineDown"
+"<k>" = "MoveLineUp"
+"<l>" = "MoveCharRight"
+"<left>" = "MoveCharLeft"
+"<down>" = "MoveLineDown"
+"<up>" = "MoveLineUp"
+"<right>" = "MoveCharRight"
+
+# Word motions. These leave a SELECTION, not a bare cursor. (MJB-HLR-007)
+"<w>" = "MoveNextWordStart"
+"<b>" = "MovePrevWordStart"
+"<e>" = "MoveNextWordEnd"
+"<W>" = "MoveNextLongWordStart"
+"<B>" = "MovePrevLongWordStart"
+"<E>" = "MoveNextLongWordEnd"
+
+# Goto mode (MJB-HLR-008)
+"<g><g>" = "GotoFileStart"
+"<g><e>" = "GotoLastLine"
+"<g><h>" = "GotoLineStart"
+"<g><l>" = "GotoLineEnd"
+"<g><s>" = "GotoFirstNonWhitespace"
+
+# Selection manipulation
+"<x>" = "ExtendLineBelow"
+"<;>" = "CollapseSelection"
+"<alt-;>" = "FlipSelections"
+"<%>" = "SelectAll"
+"<v>" = "SelectMode"
+
+# Entering insert mode (MJB-HLR-009)
+"<i>" = "InsertMode"
+"<a>" = "AppendMode"
+"<I>" = "InsertAtLineStart"
+"<A>" = "InsertAtLineEnd"
+"<o>" = "OpenBelow"
+"<O>" = "OpenAbove"
+
+# Modification (MJB-HLR-010)
+"<d>" = "DeleteSelection"
+"<c>" = "ChangeSelection"
+
+# Undo / redo (MJB-HLR-011)
+"<u>" = "Undo"
+"<U>" = "Redo"
+
+# Scrolling and paging (MJB-HLR-013)
+"<ctrl-u>" = "PageCursorHalfUp"
+"<ctrl-d>" = "PageCursorHalfDown"
+"<ctrl-b>" = "PageUp"
+"<ctrl-f>" = "PageDown"
+"<pageup>" = "PageUp"
+"<pagedown>" = "PageDown"
+
+# Command mode (MJB-HLR-016)
+"<:>" = "CommandMode"
+
+[keybindings.select]
+# Select mode repeats normal-mode motions, but extends rather than replaces.
+"<h>" = "ExtendCharLeft"
+"<j>" = "ExtendLineDown"
+"<k>" = "ExtendLineUp"
+"<l>" = "ExtendCharRight"
+"<w>" = "ExtendNextWordStart"
+"<b>" = "ExtendPrevWordStart"
+"<e>" = "ExtendNextWordEnd"
+"<esc>" = "NormalMode"
+"<v>" = "NormalMode"
+"<d>" = "DeleteSelection"
+"<c>" = "ChangeSelection"
+"<;>" = "CollapseSelection"
+
+[keybindings.insert]
+# Any printable key not bound here self-inserts; that fallback is not
+# expressible as a table entry. (MJB-LLR-156)
+"<esc>" = "NormalMode"
+"<enter>" = "InsertNewline"
+"<backspace>" = "DeleteCharBackward"
+"<delete>" = "DeleteCharForward"
+"<tab>" = "InsertTab"
+"<ctrl-h>" = "DeleteCharBackward"
+"<ctrl-d>" = "DeleteCharForward"
+"<ctrl-w>" = "DeleteWordBackward"
+"<ctrl-u>" = "KillToLineStart"
+"<left>" = "MoveCharLeft"
+"<down>" = "MoveLineDown"
+"<up>" = "MoveLineUp"
+"<right>" = "MoveCharRight"
+
+[keybindings.command]
+# Command mode routes keys to a line editor; only these are bound. (MJB-LLR-158)
+"<esc>" = "NormalMode"
+"<enter>" = "CommandSubmit"
+"<backspace>" = "CommandBackspace"
+
+[styles.normal]
+cursor = "black on white"
+selection = "on blue"
+linenr = "gray8"
+statusline = "black on cyan"
+
+[styles.insert]
+cursor = "black on green"
+selection = "on blue"
+linenr = "gray8"
+statusline = "black on green"
+
+[styles.select]
+cursor = "black on white"
+selection = "on magenta"
+linenr = "gray8"
+statusline = "black on magenta"
+
+[styles.command]
+cursor = "black on white"
+selection = "on blue"
+linenr = "gray8"
+statusline = "black on yellow"
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..634a75c
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,3 @@
+export MOJIBAKE_CONFIG=`pwd`/.config
+export MOJIBAKE_DATA=`pwd`/.data
+export MOJIBAKE_LOG_LEVEL=debug
diff --git a/.gitmessage b/.gitmessage
new file mode 100644
index 0000000..27ffa00
--- /dev/null
+++ b/.gitmessage
@@ -0,0 +1,26 @@
+# <subject: imperative, <=72 chars>
+#
+# Why this change is needed — the problem, not the diff. The diff already
+# shows what changed; it cannot show what you were solving.
+#
+# For a defect fix, say how it was found (test / lint / coverage / review).
+# That records which verification activities are actually working.
+#
+# Class (keep exactly one):
+# Implementation only behaviour already specified by an existing LLR
+# New behaviour needs new LLR + parent HLR + tag + test
+# Requirement change needs updated LLR/HLR + tests + trace matrix
+# Derived requirement needs an entry in docs/requirements/derived.md
+# Defect fix needs a regression test named for the violated LLR
+# Process/docs only state that no source changed
+#
+Class:
+Requirements:
+Derived:
+Verified:
+#
+# Before committing (docs/process.md §6):
+# cargo build
+# cargo clippy --all-targets -- -D warnings
+# cargo test
+# ./scripts/check-trace.sh
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..155355d
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,4131 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "addr2line"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "approx"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "arc-swap"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "atomic"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "backtrace"
+version = "0.3.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-link",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "better-panic"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fa9e1d11a268684cbd90ed36370d7577afb6c62d912ddff5c15fc34343e5036"
+dependencies = [
+ "backtrace",
+ "console",
+]
+
+[[package]]
+name = "bisync"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7"
+dependencies = [
+ "bisync_macros",
+]
+
+[[package]]
+name = "bisync_macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6"
+
+[[package]]
+name = "bit-set"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bon"
+version = "3.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561"
+dependencies = [
+ "bon-macros",
+ "rustversion",
+]
+
+[[package]]
+name = "bon-macros"
+version = "3.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f"
+dependencies = [
+ "darling 0.23.0",
+ "ident_case",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "bstr"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde_core",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "by_address"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "clap"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+ "terminal_size",
+ "unicase",
+ "unicode-width",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "clru"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5"
+dependencies = [
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "color-eyre"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
+dependencies = [
+ "backtrace",
+ "color-spantrace",
+ "eyre",
+ "indenter",
+ "once_cell",
+ "owo-colors",
+ "tracing-error",
+]
+
+[[package]]
+name = "color-spantrace"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427"
+dependencies = [
+ "once_cell",
+ "owo-colors",
+ "tracing-core",
+ "tracing-error",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "compact_str"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "serde",
+ "static_assertions",
+]
+
+[[package]]
+name = "config"
+version = "0.15.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139"
+dependencies = [
+ "pathdiff",
+ "serde_core",
+ "toml",
+ "winnow",
+]
+
+[[package]]
+name = "console"
+version = "0.15.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "once_cell",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "crossterm"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+dependencies = [
+ "bitflags 2.13.1",
+ "crossterm_winapi",
+ "mio",
+ "parking_lot",
+ "rustix 0.38.44",
+ "serde",
+ "signal-hook 0.3.18",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
+dependencies = [
+ "bitflags 2.13.1",
+ "crossterm_winapi",
+ "derive_more",
+ "document-features",
+ "futures-core",
+ "mio",
+ "parking_lot",
+ "rustix 1.1.4",
+ "serde",
+ "signal-hook 0.3.18",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "csscolorparser"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
+dependencies = [
+ "lab",
+ "phf",
+]
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core 0.23.0",
+ "darling_macro 0.23.0",
+]
+
+[[package]]
+name = "darling"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23"
+dependencies = [
+ "darling_core 0.24.0",
+ "darling_macro 0.24.0",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core 0.23.0",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e"
+dependencies = [
+ "darling_core 0.24.0",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "dashmap"
+version = "6.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "deltae"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "diff"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "directories"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "either"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
+
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "encoding_rs_io"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22"
+dependencies = [
+ "encoding_rs",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "eyre"
+version = "0.6.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3"
+dependencies = [
+ "autocfg",
+ "indenter",
+ "once_cell",
+]
+
+[[package]]
+name = "fancy-regex"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
+dependencies = [
+ "bit-set",
+ "regex",
+]
+
+[[package]]
+name = "faster-hex"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73"
+dependencies = [
+ "heapless",
+ "serde",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "filedescriptor"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
+dependencies = [
+ "libc",
+ "thiserror 1.0.69",
+ "winapi",
+]
+
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "finl_unicode"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
+
+[[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "futures"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "gimli"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+
+[[package]]
+name = "gix"
+version = "0.86.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce"
+dependencies = [
+ "gix-actor",
+ "gix-attributes",
+ "gix-command",
+ "gix-commitgraph",
+ "gix-config",
+ "gix-credentials",
+ "gix-date",
+ "gix-diff",
+ "gix-dir",
+ "gix-discover",
+ "gix-error",
+ "gix-features",
+ "gix-filter",
+ "gix-fs",
+ "gix-glob",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-ignore",
+ "gix-index",
+ "gix-lock",
+ "gix-negotiate",
+ "gix-object",
+ "gix-odb",
+ "gix-pack",
+ "gix-path",
+ "gix-pathspec",
+ "gix-prompt",
+ "gix-protocol",
+ "gix-ref",
+ "gix-refspec",
+ "gix-revision",
+ "gix-revwalk",
+ "gix-sec",
+ "gix-shallow",
+ "gix-status",
+ "gix-submodule",
+ "gix-tempfile",
+ "gix-trace",
+ "gix-transport",
+ "gix-traverse",
+ "gix-url",
+ "gix-utils",
+ "gix-validate",
+ "gix-worktree",
+ "gix-worktree-state",
+ "gix-worktree-stream",
+ "gix-zlib",
+ "nonempty",
+ "parking_lot",
+ "signal-hook 0.4.4",
+ "smallvec",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-actor"
+version = "0.41.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b"
+dependencies = [
+ "bstr",
+ "gix-date",
+ "gix-error",
+]
+
+[[package]]
+name = "gix-attributes"
+version = "0.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c31c593692ebdc1e38858d9a2b56f6a594c501e24a38971fe6685571f5a07be0"
+dependencies = [
+ "bstr",
+ "gix-features",
+ "gix-glob",
+ "gix-path",
+ "gix-quote",
+ "gix-trace",
+ "smallvec",
+ "thiserror 2.0.20",
+ "unicode-bom",
+]
+
+[[package]]
+name = "gix-bitmap"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7"
+dependencies = [
+ "gix-error",
+]
+
+[[package]]
+name = "gix-chunk"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc"
+dependencies = [
+ "gix-error",
+]
+
+[[package]]
+name = "gix-command"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf4363accdf6ef7ba861871d2d521ab7418a04aaaed919fadb022af71d379b12"
+dependencies = [
+ "bstr",
+ "gix-path",
+ "gix-quote",
+ "gix-trace",
+ "shell-words",
+]
+
+[[package]]
+name = "gix-commitgraph"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da"
+dependencies = [
+ "bstr",
+ "gix-chunk",
+ "gix-error",
+ "gix-hash",
+ "memmap2",
+ "nonempty",
+]
+
+[[package]]
+name = "gix-config"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "103d11bef95c467577ecfa8b7b86a22e65af3507b2c9bfa3809a4afbae7df301"
+dependencies = [
+ "bstr",
+ "gix-config-value",
+ "gix-features",
+ "gix-glob",
+ "gix-path",
+ "gix-ref",
+ "gix-sec",
+ "gix-utils",
+ "smallvec",
+ "thiserror 2.0.20",
+ "unicode-bom",
+]
+
+[[package]]
+name = "gix-config-value"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f6af5321bfd3711a279d6b244d58532ba1cfabf9eb6374791f19929d8970082"
+dependencies = [
+ "bitflags 2.13.1",
+ "bstr",
+ "gix-path",
+ "libc",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-credentials"
+version = "0.39.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9fbdb1c417980d05d547c19fc2b63344b5257c2387048d69ffe957bda142b59"
+dependencies = [
+ "bstr",
+ "gix-command",
+ "gix-config-value",
+ "gix-date",
+ "gix-path",
+ "gix-prompt",
+ "gix-quote",
+ "gix-sec",
+ "gix-trace",
+ "gix-url",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-date"
+version = "0.15.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567"
+dependencies = [
+ "bstr",
+ "gix-error",
+ "itoa",
+ "jiff",
+]
+
+[[package]]
+name = "gix-diff"
+version = "0.66.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc"
+dependencies = [
+ "bstr",
+ "gix-attributes",
+ "gix-command",
+ "gix-filter",
+ "gix-fs",
+ "gix-hash",
+ "gix-imara-diff",
+ "gix-index",
+ "gix-object",
+ "gix-path",
+ "gix-pathspec",
+ "gix-tempfile",
+ "gix-trace",
+ "gix-traverse",
+ "gix-worktree",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-dir"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f24bc78f283946ac64757c8a61ffa71f0230aa1e8d98cbb0771db479871dd5b6"
+dependencies = [
+ "bstr",
+ "gix-discover",
+ "gix-fs",
+ "gix-ignore",
+ "gix-index",
+ "gix-object",
+ "gix-path",
+ "gix-pathspec",
+ "gix-trace",
+ "gix-utils",
+ "gix-worktree",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-discover"
+version = "0.54.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9f517766fa1101dfe2606c1a19a8ffa699099030995a9194445446dfe261bdf"
+dependencies = [
+ "bstr",
+ "dunce",
+ "gix-fs",
+ "gix-path",
+ "gix-ref",
+ "gix-sec",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-error"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329"
+dependencies = [
+ "bstr",
+]
+
+[[package]]
+name = "gix-features"
+version = "0.49.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20aa09e83a48dc02c5f5f08578aa79d3ab1bab4618b8c362f88684645a02bdcc"
+dependencies = [
+ "bytes",
+ "crc32fast",
+ "crossbeam-channel",
+ "gix-path",
+ "gix-trace",
+ "gix-utils",
+ "libc",
+ "once_cell",
+ "parking_lot",
+ "prodash",
+ "walkdir",
+]
+
+[[package]]
+name = "gix-filter"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e7b5dbf524d97e839f642930c76d7f011c0791e7d11d8148989ac5af7c76aa8"
+dependencies = [
+ "bstr",
+ "encoding_rs",
+ "gix-attributes",
+ "gix-command",
+ "gix-hash",
+ "gix-object",
+ "gix-packetline",
+ "gix-path",
+ "gix-quote",
+ "gix-trace",
+ "gix-utils",
+ "smallvec",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-fs"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "865cf13fcaf5455220546cb9607c416bd1be9a6caafd143655a362fdeab64e80"
+dependencies = [
+ "bstr",
+ "gix-features",
+ "gix-path",
+ "gix-utils",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-glob"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "421e92a711554fa5827d1b0599d3389acdd0f6729e97a8c5a57d79af1e50bf36"
+dependencies = [
+ "bitflags 2.13.1",
+ "bstr",
+ "gix-features",
+ "gix-path",
+]
+
+[[package]]
+name = "gix-hash"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13adaa73415fd6c902310923f68d0b98e8cecf14b33ea58c02cc387cee56f54e"
+dependencies = [
+ "faster-hex",
+ "gix-features",
+ "sha1-checked",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-hashtable"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78fccd6fea3bcf0b39c076bae60ae49b08daaf538b950202101a981f9d3c01d3"
+dependencies = [
+ "gix-hash",
+ "hashbrown 0.17.1",
+ "parking_lot",
+]
+
+[[package]]
+name = "gix-ignore"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12cff8e8aa125e39377456073e63df3334d9e5741372ddcc226198015076dda2"
+dependencies = [
+ "bstr",
+ "gix-glob",
+ "gix-path",
+ "gix-trace",
+ "unicode-bom",
+]
+
+[[package]]
+name = "gix-imara-diff"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1"
+dependencies = [
+ "bstr",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "gix-index"
+version = "0.54.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5009c4e7e9f9b4cfaaab1153e49133eb04d79c015b5702d6c3d2ab94271a89c6"
+dependencies = [
+ "bitflags 2.13.1",
+ "bstr",
+ "filetime",
+ "fnv",
+ "gix-bitmap",
+ "gix-features",
+ "gix-fs",
+ "gix-hash",
+ "gix-lock",
+ "gix-object",
+ "gix-traverse",
+ "gix-utils",
+ "gix-validate",
+ "hashbrown 0.17.1",
+ "itoa",
+ "libc",
+ "memmap2",
+ "rustix 1.1.4",
+ "smallvec",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-lock"
+version = "24.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4c69157820343bf1c6e4b88b9808e920900de02e18aaf5862b30ada43814848"
+dependencies = [
+ "gix-tempfile",
+ "gix-utils",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-negotiate"
+version = "0.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fa5aa789990f124e4f559b142cecfb60662cb4ae17006684ea9d668f4f07689"
+dependencies = [
+ "bitflags 2.13.1",
+ "gix-commitgraph",
+ "gix-date",
+ "gix-hash",
+ "gix-object",
+ "gix-revwalk",
+]
+
+[[package]]
+name = "gix-object"
+version = "0.63.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4"
+dependencies = [
+ "bstr",
+ "gix-actor",
+ "gix-date",
+ "gix-features",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-utils",
+ "gix-validate",
+ "itoa",
+ "smallvec",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-odb"
+version = "0.83.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8dd494ffb5037e62b8220109e894d2861ff2150a2cacbfccdba57ae1ebab2b96"
+dependencies = [
+ "arc-swap",
+ "gix-features",
+ "gix-fs",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "gix-pack",
+ "gix-path",
+ "gix-quote",
+ "gix-zlib",
+ "memmap2",
+ "parking_lot",
+ "tempfile",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-pack"
+version = "0.73.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20"
+dependencies = [
+ "clru",
+ "gix-chunk",
+ "gix-error",
+ "gix-features",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "gix-path",
+ "gix-tempfile",
+ "gix-zlib",
+ "memmap2",
+ "parking_lot",
+ "smallvec",
+ "thiserror 2.0.20",
+ "uluru",
+]
+
+[[package]]
+name = "gix-packetline"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2"
+dependencies = [
+ "bstr",
+ "faster-hex",
+ "gix-trace",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-path"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "751d6bd162106f8c1e7e9aaccb5bbdd605267e91a930a17a4560c46e33a9100c"
+dependencies = [
+ "bstr",
+ "gix-trace",
+ "gix-validate",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-pathspec"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49f6fa5f8007f008187c3f60b4373209ca83d1cc947f35ede03e16cd15a4d137"
+dependencies = [
+ "bitflags 2.13.1",
+ "bstr",
+ "gix-attributes",
+ "gix-config-value",
+ "gix-glob",
+ "gix-path",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-prompt"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cb1f1eb92d6f4c9d4c105a6ca912cff637fbd5cacbcbafa5deccd88bfaa3565"
+dependencies = [
+ "gix-command",
+ "gix-config-value",
+ "parking_lot",
+ "rustix 1.1.4",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-protocol"
+version = "0.64.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588"
+dependencies = [
+ "bisync",
+ "bstr",
+ "gix-credentials",
+ "gix-date",
+ "gix-features",
+ "gix-hash",
+ "gix-lock",
+ "gix-negotiate",
+ "gix-object",
+ "gix-ref",
+ "gix-refspec",
+ "gix-revwalk",
+ "gix-shallow",
+ "gix-trace",
+ "gix-transport",
+ "gix-utils",
+ "nonempty",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-quote"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef"
+dependencies = [
+ "bstr",
+ "gix-error",
+ "gix-utils",
+]
+
+[[package]]
+name = "gix-ref"
+version = "0.66.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883"
+dependencies = [
+ "gix-actor",
+ "gix-features",
+ "gix-fs",
+ "gix-hash",
+ "gix-lock",
+ "gix-object",
+ "gix-path",
+ "gix-tempfile",
+ "gix-utils",
+ "gix-validate",
+ "memmap2",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-refspec"
+version = "0.44.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83"
+dependencies = [
+ "bstr",
+ "gix-error",
+ "gix-glob",
+ "gix-hash",
+ "gix-revision",
+ "gix-validate",
+ "smallvec",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-revision"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681"
+dependencies = [
+ "bitflags 2.13.1",
+ "bstr",
+ "gix-commitgraph",
+ "gix-date",
+ "gix-error",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "gix-revwalk",
+ "gix-trace",
+ "nonempty",
+]
+
+[[package]]
+name = "gix-revwalk"
+version = "0.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29"
+dependencies = [
+ "gix-commitgraph",
+ "gix-date",
+ "gix-error",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "smallvec",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-sec"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f"
+dependencies = [
+ "bitflags 2.13.1",
+ "gix-path",
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "gix-shallow"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ecc9f4b40537043e4bbd7d3d1760e74fb8e7b07a546166b558acaa73ad97f4a"
+dependencies = [
+ "bstr",
+ "gix-hash",
+ "gix-lock",
+ "nonempty",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-status"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f83b1c74e69b90411fbfe89ccebc47aa49457ce4eb9b942b1311258fc863d22"
+dependencies = [
+ "bstr",
+ "filetime",
+ "gix-diff",
+ "gix-dir",
+ "gix-features",
+ "gix-filter",
+ "gix-fs",
+ "gix-hash",
+ "gix-index",
+ "gix-object",
+ "gix-path",
+ "gix-pathspec",
+ "gix-worktree",
+ "hashbrown 0.16.1",
+ "portable-atomic",
+ "thiserror 2.0.20",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "gix-submodule"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd98077a56d08886112e6b08dc94076d03539f4bc0b9d7880e4be2b8a640d8c"
+dependencies = [
+ "bstr",
+ "gix-config",
+ "gix-path",
+ "gix-pathspec",
+ "gix-refspec",
+ "gix-url",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-tempfile"
+version = "24.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e"
+dependencies = [
+ "dashmap",
+ "gix-fs",
+ "libc",
+ "parking_lot",
+ "signal-hook 0.4.4",
+ "signal-hook-registry",
+ "tempfile",
+]
+
+[[package]]
+name = "gix-trace"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814"
+
+[[package]]
+name = "gix-transport"
+version = "0.58.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc"
+dependencies = [
+ "bstr",
+ "gix-command",
+ "gix-features",
+ "gix-packetline",
+ "gix-path",
+ "gix-quote",
+ "gix-sec",
+ "gix-url",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-traverse"
+version = "0.60.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409"
+dependencies = [
+ "bitflags 2.13.1",
+ "gix-commitgraph",
+ "gix-date",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "gix-revwalk",
+ "smallvec",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-url"
+version = "0.37.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31bdfc93aa880cda3272718a5879ce3aa7723fa13514320dd6608151607afe72"
+dependencies = [
+ "bstr",
+ "gix-path",
+ "gix-utils",
+ "percent-encoding",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-utils"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8"
+dependencies = [
+ "bstr",
+ "fastrand",
+ "getrandom 0.4.3",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "gix-validate"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc"
+dependencies = [
+ "bstr",
+]
+
+[[package]]
+name = "gix-worktree"
+version = "0.55.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31eb8e675122e83585e461fe28f68ff8c5ed55b49017b697e7e76423ff973424"
+dependencies = [
+ "bstr",
+ "gix-attributes",
+ "gix-features",
+ "gix-fs",
+ "gix-glob",
+ "gix-hash",
+ "gix-ignore",
+ "gix-index",
+ "gix-object",
+ "gix-path",
+ "gix-validate",
+]
+
+[[package]]
+name = "gix-worktree-state"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc47372fc12b9fbea51b257bcc8d4970326cf5c7e42135bbfb5d72871bb30bd4"
+dependencies = [
+ "bstr",
+ "gix-features",
+ "gix-filter",
+ "gix-fs",
+ "gix-index",
+ "gix-object",
+ "gix-path",
+ "gix-worktree",
+ "io-close",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "gix-worktree-stream"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549"
+dependencies = [
+ "gix-attributes",
+ "gix-error",
+ "gix-features",
+ "gix-filter",
+ "gix-fs",
+ "gix-hash",
+ "gix-object",
+ "gix-path",
+ "gix-traverse",
+ "parking_lot",
+]
+
+[[package]]
+name = "gix-zlib"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e8813f5579b3075ff9c90f7c59cd2b62b4ebb639361f0911648b22d7446cc7c"
+dependencies = [
+ "thiserror 2.0.20",
+ "zlib-rs",
+]
+
+[[package]]
+name = "hash32"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "heapless"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
+dependencies = [
+ "hash32",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "human-panic"
+version = "2.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2815d0c49773c6e0a9753960b3d0e50b822e48e08c77bcb4780be8474c9cc3d"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "backtrace",
+ "serde",
+ "serde_derive",
+ "sysinfo",
+ "toml",
+ "uuid",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "indenter"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "instability"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8"
+dependencies = [
+ "darling 0.24.0",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "io-close"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jiff"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
+dependencies = [
+ "defmt",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kasuari"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
+dependencies = [
+ "hashbrown 0.16.1",
+ "portable-atomic",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "lab"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "line-clipping"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lru"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
+dependencies = [
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "mac_address"
+version = "1.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
+dependencies = [
+ "nix",
+ "winapi",
+]
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmap2"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memmem"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "mojibake-editor"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "better-panic",
+ "clap",
+ "color-eyre",
+ "config",
+ "crossterm 0.29.0",
+ "directories",
+ "encoding_rs",
+ "encoding_rs_io",
+ "futures",
+ "gix",
+ "human-panic",
+ "libc",
+ "pretty_assertions",
+ "ratatui",
+ "ropey",
+ "serde",
+ "signal-hook 0.4.4",
+ "strip-ansi-escapes",
+ "strum",
+ "tempfile",
+ "thiserror 2.0.20",
+ "tokio",
+ "tokio-util",
+ "toml",
+ "tracing",
+ "tracing-error",
+ "tracing-subscriber",
+ "unicode-segmentation",
+ "unicode-width",
+ "vergen-gix",
+]
+
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags 2.13.1",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+ "memoffset",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "nonempty"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"
+
+[[package]]
+name = "ntapi"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_threads"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "numtoa"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[package]]
+name = "objc2-io-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
+dependencies = [
+ "libc",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "ordered-float"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "owo-colors"
+version = "4.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
+
+[[package]]
+name = "palette"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
+dependencies = [
+ "approx",
+ "libm",
+ "palette_derive",
+ "palette_math",
+]
+
+[[package]]
+name = "palette_derive"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
+dependencies = [
+ "by_address",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "palette_math"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "pathdiff"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pest"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
+dependencies = [
+ "memchr",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
+dependencies = [
+ "pest",
+]
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "pretty_assertions"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
+dependencies = [
+ "diff",
+ "yansi",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.119",
+]
+
+[[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 = "prodash"
+version = "31.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c"
+dependencies = [
+ "parking_lot",
+]
+
+[[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 = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
+[[package]]
+name = "ratatui"
+version = "0.30.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
+dependencies = [
+ "instability",
+ "ratatui-core",
+ "ratatui-crossterm",
+ "ratatui-macros",
+ "ratatui-termina",
+ "ratatui-termion",
+ "ratatui-termwiz",
+ "ratatui-widgets",
+ "serde",
+]
+
+[[package]]
+name = "ratatui-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
+dependencies = [
+ "bitflags 2.13.1",
+ "compact_str",
+ "critical-section",
+ "hashbrown 0.17.1",
+ "itertools",
+ "kasuari",
+ "lru",
+ "palette",
+ "serde",
+ "strum",
+ "thiserror 2.0.20",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width",
+]
+
+[[package]]
+name = "ratatui-crossterm"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
+dependencies = [
+ "cfg-if",
+ "crossterm 0.28.1",
+ "crossterm 0.29.0",
+ "instability",
+ "ratatui-core",
+]
+
+[[package]]
+name = "ratatui-macros"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814"
+dependencies = [
+ "ratatui-core",
+ "ratatui-widgets",
+]
+
+[[package]]
+name = "ratatui-termina"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2"
+dependencies = [
+ "instability",
+ "ratatui-core",
+ "termina",
+]
+
+[[package]]
+name = "ratatui-termion"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87c732202fa5a71a9da0991013f0853e53f87048f45198e3a1ca3ee722accc2f"
+dependencies = [
+ "instability",
+ "ratatui-core",
+ "termion",
+]
+
+[[package]]
+name = "ratatui-termwiz"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977"
+dependencies = [
+ "ratatui-core",
+ "termwiz",
+]
+
+[[package]]
+name = "ratatui-widgets"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
+dependencies = [
+ "bitflags 2.13.1",
+ "hashbrown 0.17.1",
+ "indoc",
+ "instability",
+ "itertools",
+ "line-clipping",
+ "ratatui-core",
+ "serde",
+ "strum",
+ "time",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "ropey"
+version = "2.0.0-beta.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74"
+dependencies = [
+ "str_indices",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.13.1",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.1",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[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 3.0.3",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha1-checked"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
+dependencies = [
+ "digest",
+ "sha1",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shell-words"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook 0.3.18",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "str_indices"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6"
+
+[[package]]
+name = "strip-ansi-escapes"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
+dependencies = [
+ "vte",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sysinfo"
+version = "0.38.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f"
+dependencies = [
+ "libc",
+ "memchr",
+ "ntapi",
+ "objc2-core-foundation",
+ "objc2-io-kit",
+ "windows",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "termina"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
+dependencies = [
+ "bitflags 2.13.1",
+ "parking_lot",
+ "rustix 1.1.4",
+ "signal-hook 0.3.18",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "terminal_size"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
+dependencies = [
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "terminfo"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662"
+dependencies = [
+ "fnv",
+ "nom",
+ "phf",
+ "phf_codegen",
+]
+
+[[package]]
+name = "termion"
+version = "4.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f44138a9ae08f0f502f24104d82517ef4da7330c35acd638f1f29d3cd5475ecb"
+dependencies = [
+ "libc",
+ "numtoa",
+ "serde",
+]
+
+[[package]]
+name = "termios"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "termwiz"
+version = "0.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
+dependencies = [
+ "anyhow",
+ "base64",
+ "bitflags 2.13.1",
+ "fancy-regex",
+ "filedescriptor",
+ "finl_unicode",
+ "fixedbitset",
+ "hex",
+ "lazy_static",
+ "libc",
+ "log",
+ "memmem",
+ "nix",
+ "num-derive",
+ "num-traits",
+ "ordered-float",
+ "pest",
+ "pest_derive",
+ "phf",
+ "serde",
+ "sha2",
+ "signal-hook 0.3.18",
+ "siphasher",
+ "terminfo",
+ "termios",
+ "thiserror 1.0.69",
+ "ucd-trie",
+ "unicode-segmentation",
+ "vtparse",
+ "wezterm-bidi",
+ "wezterm-blob-leases",
+ "wezterm-color-types",
+ "wezterm-dynamic",
+ "wezterm-input-types",
+ "winapi",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl 2.0.20",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "libc",
+ "num-conv",
+ "num_threads",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.4+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-error"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db"
+dependencies = [
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "serde",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
+[[package]]
+name = "uluru"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da"
+dependencies = [
+ "arrayvec",
+]
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-bom"
+version = "2.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-truncate"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
+dependencies = [
+ "itertools",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
+dependencies = [
+ "atomic",
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vergen"
+version = "10.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fd02b50246a773d6b8f48cf8e4e623a2773fd31cbe697bf10289abbb17e9e87"
+dependencies = [
+ "anyhow",
+ "bon",
+ "rustversion",
+ "time",
+ "vergen-lib",
+]
+
+[[package]]
+name = "vergen-gix"
+version = "10.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a3a1d6008b29a3163c509b206109fea7e79cb8de43ef1468b7d3c29471f3751"
+dependencies = [
+ "anyhow",
+ "bon",
+ "gix",
+ "rustversion",
+ "time",
+ "vergen",
+ "vergen-lib",
+]
+
+[[package]]
+name = "vergen-lib"
+version = "10.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d6a4e23de0fd3f8a940650a4d83eca980e9ef8f108fe6ddeb5313b0313523d3"
+dependencies = [
+ "anyhow",
+ "bon",
+ "rustversion",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vte"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "vtparse"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wezterm-bidi"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec"
+dependencies = [
+ "log",
+ "wezterm-dynamic",
+]
+
+[[package]]
+name = "wezterm-blob-leases"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
+dependencies = [
+ "getrandom 0.3.4",
+ "mac_address",
+ "serde",
+ "sha2",
+ "thiserror 1.0.69",
+ "uuid",
+]
+
+[[package]]
+name = "wezterm-color-types"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296"
+dependencies = [
+ "csscolorparser",
+ "deltae",
+ "lazy_static",
+ "serde",
+ "wezterm-dynamic",
+]
+
+[[package]]
+name = "wezterm-dynamic"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac"
+dependencies = [
+ "log",
+ "ordered-float",
+ "strsim",
+ "thiserror 1.0.69",
+ "wezterm-dynamic-derive",
+]
+
+[[package]]
+name = "wezterm-dynamic-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "wezterm-input-types"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e"
+dependencies = [
+ "bitflags 1.3.2",
+ "euclid",
+ "lazy_static",
+ "serde",
+ "wezterm-dynamic",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "yansi"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
diff --git a/Cargo.toml b/Cargo.toml
index 052ab45..8a24eac 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,91 @@
[package]
-name = "mojibake"
-version = "0.1.0"
+# The crate is `mojibake-editor` because `mojibake` was already taken on
+# crates.io by an unrelated encoder. The binary is `moji` and the library
+# target is `mojibake`, so neither the command nor `use mojibake::…` carries
+# the suffix.
+name = "mojibake-editor"
+version = "0.0.1"
edition = "2024"
+# Let-chains (`if let ... && let ...`) are the binding constraint; they
+# stabilised in 1.88. Edition 2024 alone would only need 1.85.
+rust-version = "1.88"
+description = "文字化け — a terminal text editor with Helix-style modal editing"
+authors = ["rottedfm <rottedfm@proton.me>"]
+license = "BSD-2-Clause"
+readme = "README.md"
+build = "build.rs"
+repository = "https://git.mojibake.wiki/repos/mojibake"
+keywords = ["editor", "terminal", "tui", "modal", "helix"]
+categories = ["text-editors", "command-line-utilities"]
+
+# Development-only files that need not ship in a published tarball.
+exclude = [".envrc", ".gitmessage", "scripts/"]
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[lib]
+name = "mojibake"
+path = "src/lib.rs"
+
+[[bin]]
+name = "moji"
+path = "src/main.rs"
[dependencies]
+better-panic = "0.3.0"
+clap = { version = "4.6.1", features = [
+ "derive",
+ "cargo",
+ "wrap_help",
+ "unicode",
+ "string",
+ "unstable-styles",
+] }
+color-eyre = "0.6.5"
+# default-features = false drops json5/yaml/ini from the dependency tree entirely;
+# TOML is the only configuration format mojibake accepts. See MJB-HLR-014.
+config = { version = "0.15.25", default-features = false, features = ["toml"] }
+crossterm = { version = "0.29.0", features = ["serde", "event-stream"] }
+directories = "6.0.0"
+encoding_rs = "0.8"
+encoding_rs_io = "0.1"
+futures = "0.3.32"
+human-panic = "2.0.8"
+libc = "0.2.186"
+ratatui = { version = "0.30.2", features = ["serde", "macros"] }
+# Byte-indexed by design: `metric_chars` is deliberately NOT enabled.
+#
+# Pinned with `=` rather than the default caret requirement. This is a
+# pre-release under a DAL-C classification, and a caret requirement would
+# silently accept a later beta whose behaviour has not been verified against
+# our requirements. See docs/reviews/library-selection.md (MJB-DR-006).
+ropey = "=2.0.0-beta.1"
+serde = { version = "1.0.228", features = ["derive"] }
+signal-hook = "0.4.4"
+strip-ansi-escapes = "0.2.1"
+strum = { version = "0.28.0", features = ["derive"] }
+thiserror = "2"
+tokio = { version = "1.52.3", features = ["full"] }
+tokio-util = "0.7.18"
+toml = "1.1"
+tracing = "0.1.44"
+tracing-error = "0.2.1"
+tracing-subscriber = { version = "0.3.23", features = ["env-filter", "serde"] }
+unicode-segmentation = "1.13"
+unicode-width = "0.2"
+
+[dev-dependencies]
+pretty_assertions = "1.4.1"
+tempfile = "3"
+
+[build-dependencies]
+anyhow = "1.0.103"
+gix = { version = "0.86", default-features = false, features = ["max-performance-safe"] }
+vergen-gix = { version = "10.0.1", features = ["build", "cargo"] }
+
+# Read the optimization guideline for more details: https://ratatui.rs/recipes/apps/release-your-app/#optimizations
+[profile.release]
+codegen-units = 1
+lto = true
+opt-level = "s"
+strip = true
diff --git a/README.md b/README.md
index cfd2ad9..fa71266 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,183 @@
# mojibake - 文字化け
----
-
-## TODO
-- [ ] setup ratatui component framework base
-- [ ] setup core buffer
-- [ ] setup feature type system
-- [ ] setup testing/documentation rules
-- [ ] setup license
+
+A terminal text editor with Helix-style modal editing.
+
+> [!WARNING]
+> **Early development — not ready for daily use.**
+>
+> There is no yank/paste, no search, and no syntax highlighting. Undo works one
+> keystroke at a time rather than per edit. Treat this as a working foundation,
+> not an editor you should trust with real files yet. See [Status](#status) for
+> exactly what does and does not work.
+
+```sh
+cargo install --path .
+moji <file>
+```
+
+The crate is `mojibake-editor` (the name `mojibake` was already taken on
+crates.io by an unrelated encoder); the command is `moji`.
+
+A path that does not exist is created on write. With no path, `moji` opens an
+empty scratch buffer.
+
+## Status
+
+### Works
+
+- Opens, edits and saves UTF-8 files; UTF-16 with a BOM round-trips
+- CRLF and CR line endings are detected and preserved
+- Helix selection-first editing: `w` selects a word, so `wd` deletes one
+- Motions, goto, insert-entry, delete/change, undo/redo, paging
+- Counts (`5l`, `2w`)
+- Config-driven modal keymap in TOML; chords resolve without a timeout
+- Writes through symlinks, preserves permissions, restores on failure
+- Rendering is O(viewport) — a 200k-line file scrolls without lag
+
+### Does not work yet
+
+- **No yank or paste.** `y`, `p`, `P` are unbound.
+- **No search.** `/`, `?`, `n`, `N` are unbound.
+- **No `f`/`t` motions.** They need pending-argument capture, which the keymap
+ cannot express yet.
+- **Undo is per keystroke.** Typing `abc` in insert mode costs three undos;
+ Helix commits one checkpoint when you leave insert mode.
+- **`:w <path>` ignores its argument** and writes to the original path.
+- **One file at a time.** No buffer list, no splits.
+- No syntax highlighting, no LSP, no multiple cursors, no soft wrap.
+
+## Keybindings
+
+Bindings follow the [Helix default keymap](https://docs.helix-editor.com/keymap.html)
+and live in `.config/config.toml`. Defaults ship compiled in; user settings
+override them **per individual binding**, so rebinding one key keeps every
+default you did not mention.
+
+Editing is **selection-first**, like Helix and unlike Vim: a motion leaves a
+selection and an operator acts on it. There is no operator-pending state.
+
+| Mode | Keys |
+|---|---|
+| Motion | `h` `j` `k` `l`, `w` `b` `e`, `W` `B` `E` |
+| Goto | `gg` `ge` `gh` `gl` `gs` |
+| Selection | `x` `;` `%` `v` `Alt-;` |
+| Insert | `i` `a` `I` `A` `o` `O` |
+| Change | `d` `c`, `u` undo, `U` redo |
+| Scroll | `Ctrl-u` `Ctrl-d` half page, `Ctrl-b` `Ctrl-f` full page |
+| Command | `:w` `:q` `:wq` `:x` `:q!` `:w!` |
+
+Counts work as a prefix: `5l`, `12j`.
+
+## Configuration
+
+TOML only, at `$MOJIBAKE_CONFIG/config.toml`. See `.config/config.toml` for the
+annotated defaults.
+
+```toml
+[editor]
+scrolloff = 5
+insert_final_newline = true
+
+[keybindings.normal]
+"<g><g>" = "GotoFileStart"
+"<ctrl-d>" = "PageCursorHalfDown"
+```
+
+## Roadmap
+
+Ordered by what most blocks real use.
+
+### 1 — Blocking daily use
+
+- [ ] Yank and paste: `y`, `p`, `P`, plus a register
+- [ ] Undo checkpoints — group an insert session into one step
+ (Helix commits on leaving insert mode; see `Command::NormalMode`)
+- [ ] Search: `/`, `?`, `n`, `N`
+- [ ] Pending-argument capture in the keymap, then `f` `t` `F` `T` and `r`
+- [ ] Honour the `:w <path>` argument instead of discarding it
+ (`run_command_line` binds it as `_arg`)
+- [ ] Prompt on `:q` with unsaved changes rather than only refusing
+
+### 2 — Editor features
+
+- [ ] Multiple buffers and `:b` / `gn` / `gp`
+- [ ] Auto-indent on newline; `>` and `<` to shift lines
+- [ ] `J` join lines, `~` switch case, `.` repeat
+- [ ] Match mode `m` — matching bracket, surround
+- [ ] Soft wrap (reworks the viewport anchor — see `MJB-DR-003`)
+- [ ] Multiple cursors (`Selection` is already shaped for it — see `MJB-DR-005`)
+
+### 3 — Language support
+
+- [ ] tree-sitter syntax highlighting
+- [ ] `languages.toml`
+- [ ] LSP client: diagnostics, completion, goto-definition
+
+### 4 — Project and process
+
+- [ ] CI: build, clippy, test, `scripts/check-trace.sh`, coverage
+- [ ] Resolve the six open derived requirements in
+ `docs/requirements/derived.md` — each needs a judgement, not code
+- [ ] Decide on ropey: stay on `2.0.0-beta.1` or move to stable `1.6.1`
+ (see `docs/reviews/library-selection.md`)
+- [ ] Decide whether to publish to crates.io at all, or self-host only
+- [ ] Replace the `unwrap` calls in `src/tui.rs::Drop` inherited from the
+ application template
+
+## Development
+
+```sh
+cargo build
+cargo clippy --all-targets -- -D warnings
+cargo test
+./scripts/check-trace.sh
+cargo llvm-cov --summary-only test
+```
+
+`direnv` users get `.envrc`, which points config and data at the working tree.
+
+### Architecture
+
+- `src/buffer/` — the editor core: rope document, selection, transactions,
+ motions, viewport, keymap. No `ratatui` dependency, so it is testable without
+ a terminal.
+- `src/components/buffer.rs` — the only widget; renders the visible window.
+- `src/app.rs` — event loop and global key routing.
+
+Text is held in a [ropey](https://github.com/cessen/ropey) rope and addressed by
+**byte** offset throughout. Rendering is O(viewport): the view is anchored by
+the byte offset of the first visible line, and only that window is walked, so
+per-frame cost does not scale with file size.
+
+## Process
+
+Developed to **DO-178C DAL-C**. Start with **[`docs/process.md`](docs/process.md)**,
+which explains the requirement artifacts, the ID system, and what a commit must
+satisfy.
+
+- `docs/process.md` — the system, and the commit rules
+- `docs/requirements/` — high-level, low-level, and derived requirements
+- `docs/traceability/trace.md` — HLR → LLR → source → test matrix
+- `docs/reviews/` — review checklists and library-selection rationale
+
+Every low-level requirement is tagged in source as `// MJB-LLR-nnn` and has a
+test named `mjb_llr_nnn_*`. The matrix is checked mechanically, not by
+inspection:
+
+```sh
+./scripts/check-trace.sh
+```
+
+It verifies traceability in **both** directions — requirements with no
+implementation or no test, and tests naming a requirement that no longer
+exists. All three sets must be empty. Run it before every commit.
+
+`git config commit.template .gitmessage` sets up the commit format; the template
+lists the change classes and the pre-commit gate.
+
+Current: 294 tests, clippy clean at `-D warnings`, 98/98 requirements traced,
+96.2% statement coverage of `src/buffer/**`.
+
+## License
+
+BSD 2-Clause. See [LICENSE](LICENSE).
diff --git a/build.rs b/build.rs
new file mode 100644
index 0000000..20c813a
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,37 @@
+use anyhow::Result;
+use vergen_gix::{Build, Cargo, Emitter, Gix};
+
+/// Fallback used when the crate is built outside a git checkout (e.g. from a
+/// packaged `.crate` file), so `env!("VERGEN_GIT_REMOTE_URL")` always resolves.
+const UNKNOWN_REMOTE: &str = "unknown";
+
+fn main() -> Result<()> {
+ let build = Build::all_build();
+ let gix = Gix::all_git();
+ let cargo = Cargo::all_cargo();
+ Emitter::default()
+ .default_on_error()
+ .add_instructions(&build)?
+ .add_instructions(&gix)?
+ .add_instructions(&cargo)?
+ .emit()?;
+
+ emit_git_remote_url();
+ Ok(())
+}
+
+/// vergen only reports the local checkout (sha, branch, describe...), so read the
+/// `origin` remote out of the same repository and expose it alongside the rest of
+/// the `VERGEN_GIT_*` variables.
+fn emit_git_remote_url() {
+ let url = git_remote_url().unwrap_or_else(|| UNKNOWN_REMOTE.to_string());
+ println!("cargo:rustc-env=VERGEN_GIT_REMOTE_URL={url}");
+ println!("cargo:rerun-if-changed=.git/config");
+}
+
+fn git_remote_url() -> Option<String> {
+ let repo = gix::discover(env!("CARGO_MANIFEST_DIR")).ok()?;
+ let remote = repo.find_remote("origin").ok()?;
+ let url = remote.url(gix::remote::Direction::Fetch)?;
+ Some(url.to_bstring().to_string())
+}
diff --git a/docs/process.md b/docs/process.md
new file mode 100644
index 0000000..9ce5fb9
--- /dev/null
+++ b/docs/process.md
@@ -0,0 +1,308 @@
+# mojibake — Requirements and Commit Process
+
+Software Level: **DAL-C** (DO-178C)
+Project status: **early development** — see the README for what does not work yet.
+
+This document explains the system: what the requirement artifacts are, how they
+relate to each other and to the code, and what a commit must satisfy to be
+accepted. It is the entry point — read this before `docs/requirements/`.
+
+---
+
+## 1. Why the artifacts exist
+
+DO-178C does not ask "is the code good?" It asks a narrower, checkable
+question: **can you show that every behaviour the software has was asked for,
+and that everything asked for was built and verified?**
+
+Answering it needs four things, and each one is a directory here:
+
+| Artifact | Answers |
+|---|---|
+| `docs/requirements/hlr.md` | What must the software do? |
+| `docs/requirements/llr.md` | How, specifically, in terms a test can check? |
+| `docs/requirements/derived.md` | What did the implementation need that nobody asked for? |
+| `docs/traceability/trace.md` | Where is each of those in the code, and which test proves it? |
+
+The chain runs **HLR → LLR → source → test**, and it must hold in *both*
+directions. Forward: every requirement is implemented and verified. Backward:
+every piece of behaviour traces to a requirement. Backward traceability is the
+one people skip, and it is the one that catches unrequested behaviour — code
+that does something nobody asked for is either a missing requirement or a
+defect, and you cannot tell which until you look.
+
+---
+
+## 2. The three requirement kinds
+
+### High-level requirements — `MJB-HLR-nnn`
+
+What the software does, in terms a user would recognise. One behaviour per
+requirement, phrased with "shall", and stated so that it is *observable* —
+if you cannot describe how to check it, it is not a requirement yet.
+
+> **MJB-HLR-007** — 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 […]
+
+HLRs must not specify implementation. Two here do — MJB-HLR-004 names a rope
+and MJB-HLR-014 names TOML — because those were themselves requested. That
+exception is recorded in the requirements review rather than hidden.
+
+### Low-level requirements — `MJB-LLR-nnn`
+
+The same behaviour decomposed until each statement maps to one identifiable
+thing in the code and one test. An LLR names types, functions and exact values;
+an HLR does not.
+
+> **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.
+
+Every LLR **must** cite a parent HLR. An LLR with no parent is either a derived
+requirement in disguise or scope creep.
+
+### Derived requirements — `MJB-DR-nnn`
+
+Requirements the implementation turned out to need that no HLR anticipated.
+DO-178C §5.1.1.b requires these to be recorded **and fed back to the safety
+assessment**, because by definition nobody evaluated them when the HLRs were
+written.
+
+Every entry states what forced it and carries an explicit *"Reviewer must
+judge"* clause. A derived requirement is not a note-to-self; it is an open
+question addressed to someone else.
+
+Two of the seven here could not have been anticipated, and both are instructive:
+
+- **MJB-DR-002** — choosing byte indices over char indices admits a failure
+ mode char indexing cannot express: an offset landing inside a character,
+ which makes ropey panic.
+- **MJB-DR-007** — `encoding_rs` decodes UTF-16 but silently refuses to
+ *encode* it, substituting UTF-8. Found by a round-trip test, not by reading
+ the code.
+
+---
+
+## 3. The ID system
+
+```
+MJB-HLR-nnn high-level requirement
+MJB-LLR-nnn low-level requirement
+MJB-DR-nnn derived requirement
+```
+
+IDs are **permanent and never reused**. Deleting a requirement means marking it
+withdrawn, not freeing its number — a trace matrix that silently renumbers is
+worse than no matrix, because reviews and commits reference IDs by number.
+
+LLR numbers are allocated in blocks by subsystem so related requirements read
+together:
+
+| Block | Subsystem |
+|---|---|
+| 001–019 | selection |
+| 020–039 | graphemes |
+| 040–059 | transactions, history |
+| 060–089 | movement |
+| 090–109 | viewport |
+| 110–129 | document, encoding, line endings |
+| 130–149 | save |
+| 150–179 | keymap, commands |
+| 180–199 | configuration |
+| 200–219 | presentation |
+
+Blocks are a convenience, not a rule. Leave gaps.
+
+---
+
+## 4. How code and tests bind to requirements
+
+Two conventions, both chosen so a machine can check them.
+
+**Source carries a tag** immediately above the item implementing it:
+
+```rust
+/// MJB-LLR-005: the byte offset the block cursor is drawn at.
+pub fn cursor(&self, text: RopeSlice) -> usize {
+```
+
+**Tests are named for what they verify**:
+
+```rust
+#[test]
+fn mjb_llr_065_w_leaves_a_selection() {
+```
+
+A test name is a claim. `mjb_llr_065_w_leaves_a_selection` asserts that
+MJB-LLR-065 holds; if the test does not actually establish that, the name is a
+lie and the matrix is worthless. Prefer a name that states the expected
+behaviour over one that describes the mechanics.
+
+More than one test may carry the same ID. Robustness cases usually do.
+
+---
+
+## 5. The matrix is verified mechanically
+
+Inspection does not scale and does not survive refactoring. Three `grep`s
+answer the whole question:
+
+```bash
+grep -oE 'MJB-LLR-[0-9]+' docs/requirements/llr.md | sort -u > defined
+grep -rhoE 'MJB-LLR-[0-9]+' src/ | sort -u > tagged
+grep -rhoE 'mjb_llr_[0-9]+' src/ tests/ | sed 's/mjb_llr_/MJB-LLR-/' | sort -u > tested
+
+comm -23 defined tagged # requirement with no implementation
+comm -23 defined tested # requirement with no test
+comm -13 defined tested # test naming a requirement that does not exist
+```
+
+**All three sets must be empty.** The third catches typos and stale references
+after a requirement is renumbered or withdrawn — a check that is easy to omit
+and that fails silently when omitted.
+
+Run it before every commit. It is not optional, and it takes under a second.
+
+---
+
+## 6. Commit requirements
+
+DO-178C Table A-8 (configuration management) wants changes to be controlled,
+traceable, and reviewable. In practice, for this repository, that reduces to
+five rules.
+
+### Rule 1 — A commit is a complete unit
+
+A commit must leave the tree in a state where all of the following pass:
+
+```bash
+cargo build
+cargo clippy --all-targets -- -D warnings
+cargo test
+```
+
+plus the traceability check from §5. Requirements, implementation, and tests
+land **together**. Do not commit an LLR whose test arrives in the next commit:
+between the two, the matrix is broken and the repository cannot be reviewed.
+
+### Rule 2 — The message states which requirements are affected
+
+```
+<subject line, imperative, ≤72 chars>
+
+<why the change is needed — the problem, not the diff>
+
+Requirements: MJB-LLR-065, MJB-LLR-066
+Derived: MJB-DR-007 (omit if none)
+Verified: cargo test (294 passing), clippy clean, trace 98/98/98
+```
+
+The body explains *why*. The diff already shows what changed; it cannot show
+what problem you were solving, and that is the part a reviewer cannot
+reconstruct.
+
+### Rule 3 — Classify the change
+
+Every commit is exactly one of these, and the class determines what else must
+be in it:
+
+| Class | Also required in the same commit |
+|---|---|
+| **Implementation only** — behaviour already specified | Test naming the existing LLR |
+| **New behaviour** | New LLR, its parent HLR, source tag, test |
+| **Requirement change** | Updated LLR/HLR, updated tests, updated trace matrix |
+| **Derived requirement discovered** | Entry in `derived.md` with a "Reviewer must judge" clause |
+| **Defect fix** | Regression test named for the LLR that was violated |
+| **Process/docs only** | Nothing further; state that no source changed |
+
+If a change does not fit a class, that is the signal: either it is unrequested
+behaviour needing a requirement, or it is two commits.
+
+### Rule 4 — A defect fix explains how it was found
+
+This is the rule most worth keeping. Recording the *detection method* tells the
+next person which verification activities are actually working:
+
+> **MJB-DR-007** — `encoding_rs::encode` silently substitutes UTF-8 for UTF-16.
+> Invisible to inspection; found only by a byte-comparing round-trip test. Any
+> encoding added later should be guarded by the same test shape.
+
+Four defects in the initial buffer work were found by four different means — a
+failing integration test, a clippy lint, a round-trip test, and a coverage
+report showing 0% on a function nobody called. None were found by re-reading
+the code. That is worth knowing.
+
+### Rule 5 — Reviews are recorded, not implied
+
+DAL-C permits **review without independence**: the author may review their own
+work. It does not permit skipping the review. Completion is recorded in
+`docs/reviews/`:
+
+- `requirements-checklist.md` — against DO-178C Tables A-3 and A-4
+- `code-checklist.md` — against Table A-5
+- `library-selection.md` — rationale for non-obvious dependencies
+
+A checklist that says only "Pass" everywhere has not been used. The value is in
+the entries that say something: a conflict found, a requirement reworded, a
+dependency accepted with a stated risk.
+
+---
+
+## 7. Definition of done
+
+```
+[ ] Requirements written or updated before the code
+[ ] Every new LLR cites a parent HLR
+[ ] Source tagged // MJB-LLR-nnn
+[ ] Test named mjb_llr_nnn_* for every affected LLR
+[ ] Robustness cases covered: empty, boundary, malformed, overflow
+[ ] Trace matrix updated
+[ ] Three-way grep check: all sets empty
+[ ] cargo build
+[ ] cargo clippy --all-targets -- -D warnings
+[ ] cargo test
+[ ] Statement coverage reported for changed modules
+[ ] Derived requirements recorded and flagged
+[ ] Review checklist updated
+[ ] Commit message names affected requirements and classifies the change
+```
+
+---
+
+## 8. What is deliberately *not* claimed
+
+Overstating compliance is worse than not claiming it, because it removes the
+reader's ability to judge. This project claims:
+
+- **Statement coverage only.** MC/DC and decision coverage are DAL-A and DAL-B
+ objectives. They are not measured and are not claimed.
+- **Review without independence.** The author reviewed their own work, which is
+ permitted at DAL-C and stated plainly in each checklist.
+- **No qualified tools.** `cargo-llvm-cov` and `clippy` are not
+ tool-qualified per DO-330. Their output is evidence, not proof.
+- **Six open derived requirements.** They need a safety-assessment judgement
+ that has not happened. They are listed as open, not quietly resolved.
+
+---
+
+## 9. Worked example
+
+Adding `t` (find-till-char) would go:
+
+1. **HLR** — does an existing one cover it? MJB-HLR-006 covers character and
+ line motion but not character search. So a new HLR is needed.
+2. **LLR** — decompose: the pending-argument state, the forward search, the
+ stop-one-short semantics, the not-found case, the count interaction. Roughly
+ five LLRs in the 060–089 block.
+3. **Derived?** — the keymap has no way to capture "the next keystroke as a
+ literal character". That is a real gap MJB-HLR-015 did not anticipate:
+ record it in `derived.md` and flag it.
+4. **Implement**, tagging each item.
+5. **Test** each LLR, including: not found, at end of buffer, on a multi-byte
+ character, with a count exceeding the number of matches.
+6. **Update** the trace matrix; run the three-way grep.
+7. **Commit** as class *New behaviour*, naming every new ID.
+
+The step people skip is 3. It is the one DO-178C exists to catch.
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. |
diff --git a/docs/reviews/code-checklist.md b/docs/reviews/code-checklist.md
new file mode 100644
index 0000000..ce16b46
--- /dev/null
+++ b/docs/reviews/code-checklist.md
@@ -0,0 +1,202 @@
+# Source code review checklist
+
+Software Level: **DAL-C** (DO-178C)
+Reviewer: author — **review without independence**, which DO-178C permits at
+Software Level C.
+Scope: `src/buffer/**`, `src/components/buffer.rs`, `src/config.rs`,
+`src/app.rs`, `src/cli.rs`, `src/action.rs`
+
+Derived from DO-178C Table A-5 (verification of the source code), reduced to
+the objectives that apply at DAL-C.
+
+---
+
+## Compliance and traceability
+
+| # | Check | Result |
+|---|---|---|
+| 1 | Source complies with the low-level requirements | **Pass** |
+| 2 | Source is traceable to the LLRs | **Pass** — 98/98 tagged `// MJB-LLR-nnn`, verified by `grep`, not inspection |
+| 3 | Source complies with the software architecture | **Pass** — buffer core has no `ratatui` dependency and is testable headless |
+| 4 | Source is verifiable | **Pass** — 96.21% statement coverage of `src/buffer/**` |
+| 5 | Source conforms to a coding standard | **Pass** — `cargo clippy --all-targets -- -D warnings` is clean |
+| 6 | Source is accurate and consistent | **Pass** — see the defects found, below |
+
+Reproduce objectives 2, 4 and 5:
+
+```bash
+cargo clippy --all-targets -- -D warnings # clean
+cargo test # 294 passing
+cargo llvm-cov --summary-only test
+```
+
+---
+
+## No dead code
+
+| # | Check | Result |
+|---|---|---|
+| 7 | Removed widgets leave no unreachable code | **Pass** |
+| 8 | No `allow(dead_code)` masks anything | **Pass** |
+| 9 | Removed dependencies are gone from the graph | **Pass** |
+
+Evidence:
+
+```bash
+# The widget modules are deleted, not merely unregistered.
+ls src/components/ # buffer.rs only
+grep -rni "FpsCounter\|mod fps\|mod home" src/ # only a comment in app.rs
+
+# No dead-code suppression anywhere in the crate.
+grep -rn "allow(dead_code)" src/ # nothing
+
+# The chord-timeout state is gone, not just unused.
+grep -rn "last_tick_key_events" src/ # nothing
+
+# JSON is out of the dependency graph, not merely out of the call path.
+cargo tree | grep -iE "json|yaml|ini\b" # nothing
+```
+
+Three items of inherited dead code were removed during review, beyond the two
+widgets the task named:
+
+- `Action::Help` — declared by the application template, never produced or
+ handled.
+- `Command::leaves_insert_mode` — written by me during implementation, then
+ never called. Caught because `buffer/command.rs` showed 0% coverage.
+- `#![allow(dead_code)]` in `src/tui.rs` — verified to mask nothing before
+ removal, by deleting it and re-running clippy.
+
+---
+
+## Robustness and error handling
+
+| # | Check | Result |
+|---|---|---|
+| 10 | No panic on malformed input | **Pass** — invalid UTF-8 is a reported error (MJB-LLR-118); a declared non-UTF-8 encoding substitutes U+FFFD (MJB-LLR-113) |
+| 11 | No panic on malformed configuration | **Pass** — a bad key sequence is a recoverable error naming itself (MJB-LLR-183) |
+| 12 | No panic on a failed file operation | **Pass** — every `save` path returns `Result` |
+| 13 | Boundary conditions handled | **Pass** — empty buffer, no trailing newline, zero-height viewport, scrolloff exceeding the viewport, undo past history start |
+| 14 | Integer overflow/underflow guarded | **Pass** — `saturating_*` throughout `view.rs` and `movement.rs` |
+
+`unwrap`/`expect`/`panic!` outside test code is confined to two files, neither
+in the buffer core:
+
+| File | Count | Assessment |
+|---|---|---|
+| `src/errors.rs` | 1 | Inside the panic hook itself |
+| `src/tui.rs` | 2 | Template code in `Drop`; pre-existing |
+
+`src/buffer/**` contains **zero** panicking calls outside `#[cfg(test)]`.
+
+The one genuinely new failure mode created by this change — a byte offset
+landing inside a character, which makes ropey panic — is guarded in two places:
+`Range::clamped` (MJB-LLR-011) snaps every externally supplied offset onto a
+char boundary, and `ChangeSet::apply` (MJB-LLR-044) validates every operation
+boundary *before* mutating, so a rejected change leaves the rope untouched
+rather than half-applied.
+
+---
+
+## Defects found and fixed during review
+
+Eight defects were found by review and test activity rather than by writing
+the code. Each records how it was found, because that tells the next person
+which verification activities are actually working (docs/process.md §6, Rule 4).
+
+1. **Every shifted key binding was shadowed by its lowercase twin.**
+ `parse_key_event` lowercased the entire key string, so `"<A>"` and `"<a>"`
+ both parsed to `KeyCode::Char('a')`. `A`, `I`, `O`, `U`, `W`, `B` and `E`
+ were all unreachable, and whichever binding won the hash map ran instead —
+ so `a` silently invoked `InsertAtLineEnd`. Found by seven integration tests
+ failing at once. Fixed by preserving the final token's case and inferring
+ `SHIFT` from an uppercase letter, matching what crossterm reports.
+
+2. **UTF-16 files would have been corrupted on save.**
+ `encoding_rs::Encoding::encode` refuses to encode to UTF-16 and silently
+ substitutes UTF-8, so a UTF-16 document would have been written as UTF-8
+ bytes beneath a UTF-16 BOM. Invisible to inspection; found only by a
+ byte-comparing round-trip test. Fixed by encoding UTF-16 directly from
+ `str::encode_utf16`. Recorded as **MJB-DR-007**.
+
+3. **`o` on a file with no trailing newline put the cursor in the wrong place.**
+ `open_below` anchored to the next line's start, which does not exist on a
+ final unterminated line. Found by clippy's `if_same_then_else` flagging the
+ suspicious `if above { at } else { at }`, then confirmed by a robustness
+ test. Fixed by anchoring to the line's content end.
+
+4. **`a` appended in the wrong position.** Helix's normal-mode cursor is a
+ one-grapheme range, so `a` lands past that grapheme; mojibake's empty range
+ has `to() == from()`. Fixed by stepping forward one grapheme when the range
+ is empty.
+
+A later quality pass over the same code found four more. Note that three were
+in paths no test covered and one was in inherited template code that only
+became reachable when the buffer started consuming `[styles]`.
+
+5. **Counted word motion was broken.** Each iteration of the count loop
+ re-derived its start from the partial range's *cursor*, which for a forward
+ range is one grapheme behind the head. `2w` on `"aaa bbb ccc"` therefore
+ returned `Range(3, 4)` — anchor in the wrong place, head stalled inside the
+ first gap — instead of `Range(0, 8)`. Found by restructuring `word_move`
+ into three named functions and checking each against a worked example; no
+ test covered a count greater than one on a word motion. Fixed by holding the
+ anchor fixed and advancing only the head. Regression tests
+ `mjb_llr_065_counted_next_word_start_advances_once_per_count` and siblings.
+
+6. **Three panics reachable from user configuration.** `parse_color` indexed
+ `rgb` operands unchecked and added into `u8` unguarded, so `"rgb"`,
+ `"rgb1"`, `"gray99"` and `"rgb999"` in a `[styles]` block aborted the
+ editor at startup — a direct violation of MJB-HLR-018. Found by running
+ clippy with `-W clippy::pedantic` and following up the
+ `cast_possible_truncation` hits by hand. Fixed by bounds-checked parsing and
+ clamping to the actual xterm palette ranges. Regression test
+ `mjb_llr_183_malformed_colours_never_panic`.
+
+7. **Bright colours were indistinguishable from their base colours.** The
+ template wrote `c.wrapping_shl(8)`, which on a `u8` masks the shift to
+ `8 % 8 == 0` and returns the value unchanged. Found while fixing defect 6.
+ Fixed to the ANSI convention of base + 8, saturating.
+
+8. **The modified flag stayed set after undoing back to the saved state.** A
+ latched `bool` cannot distinguish "edited" from "edited and reverted", so a
+ buffer byte-identical to its file still refused `:q`. Found by review of the
+ flag's lifecycle rather than by a test. Replaced with a comparison against
+ the history revision the file was written at.
+
+ The first attempt at that fix was itself wrong and worth recording: using
+ the history *cursor* as the revision means saving at depth 3, undoing, then
+ making a **different** edit returns to depth 3 while the content differs —
+ reporting clean when it is not. Caught by reasoning through the case before
+ committing, and fixed by giving each history entry a unique, never-reused
+ id. Regression test `mjb_llr_116_revision_is_not_stack_depth`.
+
+---
+
+## Architecture
+
+| # | Check | Result |
+|---|---|---|
+| 15 | The pre-release rope dependency is confined | **Pass** — `ropey::Rope` is owned only by `buffer/document.rs`; other modules take `RopeSlice` parameters (MJB-DR-006) |
+| 16 | The buffer core is independent of the UI | **Pass** — no `ratatui` import under `src/buffer/` |
+| 17 | Pagination is structural, not an optimisation | **Pass** — `View::visible_lines` is the only path to line content, and is bounded by viewport height |
+
+Objective 17 is the one most worth stating plainly: MJB-HLR-012 is satisfied
+because there is no code path that walks the document, not because a fast path
+was added. `mjb_llr_200_renders_only_the_visible_window` checks the observable
+consequence, and `mjb_llr_092_scrolling_a_large_file_stays_responsive` checks
+that 500 half-page scrolls over a 200,000-line file stay well inside a time
+budget a full scan could not meet.
+
+---
+
+## Summary
+
+Code review **complete**. Eight defects found and fixed; three items of dead
+code removed; no `allow(dead_code)` remains; clippy clean at `-D warnings`;
+294 tests passing; 96.21% statement coverage of the buffer core.
+
+Outstanding items are the six open derived requirements in
+`docs/requirements/derived.md`, which need a safety-assessment judgement rather
+than a code change. MJB-DR-001 is resolved: UTF-8 is now validated strictly and
+malformed input is refused, so the lossy-save hazard it flagged is gone.
diff --git a/docs/reviews/library-selection.md b/docs/reviews/library-selection.md
new file mode 100644
index 0000000..3b9efa2
--- /dev/null
+++ b/docs/reviews/library-selection.md
@@ -0,0 +1,98 @@
+# Library selection record
+
+Software Level: **DAL-C** (DO-178C)
+Reviewed by: author (independence not required at DAL-C)
+
+Records the non-obvious dependency choices for the buffer component and the
+reasoning behind each, so a reviewer can judge them without re-deriving the
+trade-offs.
+
+---
+
+## ropey 2.0.0-beta.1 — a pre-release dependency
+
+**Decision.** ropey 2.0.0-beta.1, default features, **byte-indexed**.
+`metric_chars` is deliberately **not** enabled.
+
+**Alternatives considered.**
+
+| Option | Assessment |
+|---|---|
+| ropey 1.6.1 (stable) | Char-indexed by default; exactly what Helix pins, so Helix's algorithms port near-verbatim. The conservative choice. |
+| 2.0.0-beta.1 + `metric_chars` | Keeps the char API, but re-adds the per-node metadata 2.0 exists to remove — the beta risk without the benefit. |
+| **2.0.0-beta.1, byte-indexed (selected)** | Lowest memory and fastest edits; requires translating Helix's algorithms into byte offsets rather than porting them. |
+
+**Known risk.** The crate is self-described as "not battle-tested like Ropey
+1.x", with minor breaking API changes possible before release. This was raised
+before implementation and the requester selected it anyway; the choice is
+theirs and is recorded here rather than re-litigated.
+
+**Mitigations in force.**
+
+1. The version is pinned exactly in `Cargo.toml`.
+2. `ropey::Rope` is *owned* only by `src/buffer/document.rs`; other modules
+ receive `RopeSlice` parameters. Replacing the rope is therefore confined to
+ one module. (MJB-DR-006)
+3. The buffer core carries 93.85% statement coverage against invariants we
+ assert ourselves, rather than trusting the library's own guarantees.
+
+**Consequence the reviewer must accept.** Byte indexing admits a failure mode
+char indexing cannot express: an offset landing inside a character, which makes
+`Rope::insert`/`remove` panic. This is addressed by MJB-LLR-011 (clamp and snap
+at construction) and MJB-LLR-044 (validate before mutating). See MJB-DR-002.
+
+---
+
+## encoding_rs — asymmetric, and the asymmetry matters
+
+**Decision.** Used for decoding all encodings and for encoding everything
+*except* UTF-16, which is encoded by hand.
+
+**Why.** `encoding_rs::Encoding::encode` will not encode to UTF-16; it silently
+substitutes UTF-8 and signals 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 inconsistent with its own BOM.
+
+This was **not caught by inspection**. It was caught by a round-trip test
+(`mjb_llr_115_utf16le_round_trips`) that decoded, re-encoded, and compared
+bytes. Any encoding added later should be guarded by the same test shape.
+
+Recorded as MJB-DR-007.
+
+---
+
+## unicode-segmentation and unicode-width
+
+**Decision.** `unicode_segmentation::GraphemeCursor` for cluster boundaries;
+`unicode_width` for terminal display width.
+
+**Why.** `GraphemeCursor` is chunk-aware: it reports `PrevChunk`/`NextChunk`/
+`PreContext` when a cluster straddles a fragment edge, which pairs exactly with
+ropey's `chunk(byte_idx) -> (&str, chunk_start)`. Clusters spanning rope chunk
+boundaries therefore resolve correctly (MJB-LLR-022) without materialising the
+text. This is the same pairing Helix uses.
+
+`unicode_width` is required because byte length, character count, and display
+width are three different numbers; the cursor must track the third. A CJK
+character occupies two terminal columns and three UTF-8 bytes.
+
+---
+
+## thiserror
+
+**Decision.** Used for error types in the buffer core.
+
+**Why.** MJB-HLR-018 forbids abnormal termination, so every failure path must
+carry a reportable message. `thiserror` generates `Display` and `From` without
+runtime cost or a dynamic error type.
+
+---
+
+## config, with default features disabled
+
+**Decision.** `config = { default-features = false, features = ["toml"] }`.
+
+**Why.** MJB-HLR-014 requires that no parser for another format remain in the
+dependency graph. Removing the `json5` *call site* would not satisfy that —
+the crate's default features pull `json5` in regardless. Disabling default
+features is what actually removes it, and is verifiable with `cargo tree`.
diff --git a/docs/reviews/requirements-checklist.md b/docs/reviews/requirements-checklist.md
new file mode 100644
index 0000000..c6ced28
--- /dev/null
+++ b/docs/reviews/requirements-checklist.md
@@ -0,0 +1,112 @@
+# Requirements review checklist
+
+Software Level: **DAL-C** (DO-178C)
+Reviewer: author — **review without independence**, which DO-178C permits at
+Software Level C.
+Scope: `docs/requirements/{hlr,llr,derived}.md`
+
+Derived from DO-178C Table A-3 (verification of software requirements) and
+Table A-4 (verification of the design), reduced to the objectives that apply at
+DAL-C.
+
+---
+
+## High-level requirements
+
+| # | Check | Result |
+|---|---|---|
+| 1 | Every HLR is traceable to a request in the originating task statement | **Pass** — the statement's scope items map to MJB-HLR-001…019 |
+| 2 | HLRs are accurate and unambiguous | **Pass** with one note, below |
+| 3 | HLRs are verifiable — each states an observable behaviour | **Pass** |
+| 4 | HLRs do not specify implementation | **Pass**, except MJB-HLR-004 and MJB-HLR-014, where the rope and the TOML format were themselves requested |
+| 5 | HLRs conform to a consistent standard | **Pass** — "shall" throughout, one behaviour per requirement |
+| 6 | Conflicting requirements are identified and resolved | **Pass** — one conflict found, see below |
+
+**Note on objective 2.** MJB-HLR-007 needed rewording during review. It
+originally read "shall provide next-word-start … motions", which reads as a
+cursor move and matches the wording of Helix's own keymap documentation. That
+wording is misleading: Helix's `move_next_word_start` returns a *range*. The
+requirement now states the selection behaviour explicitly, because the
+misreading would have produced Vim semantics that pass a naive test.
+
+**Conflict found and resolved (objective 6).** The task statement asked for a
+"non-UTF-8 rejection path" *and* for full Helix save fidelity. Helix transcodes
+via `encoding_rs` rather than rejecting, and the two initially looked mutually
+exclusive; the conflict was recorded as **MJB-DR-001** and flagged.
+
+It is now **resolved**, on the requester's direction to make UTF-8 an exception.
+Splitting the behaviour by whether the encoding is *known* satisfies both
+requests: a BOM-declared non-UTF-8 encoding is transcoded, because the
+substitution is reproducible on write (MJB-LLR-113); UTF-8 is validated strictly
+and refused when malformed, because nothing is known that would make a repair
+reproducible (MJB-LLR-118). The lossy-save hazard that motivated the flag is
+eliminated rather than accepted.
+
+---
+
+## Low-level requirements
+
+| # | Check | Result |
+|---|---|---|
+| 7 | Every LLR traces to a parent HLR | **Pass** — `docs/traceability/trace.md`, HLR → LLR table |
+| 8 | Every LLR is traceable to source | **Pass** — 98/98 tagged, verified by `grep`, not by inspection |
+| 9 | LLRs are verifiable | **Pass** — 98/98 have a test named for them |
+| 10 | LLRs are consistent with the HLRs they derive from | **Pass** |
+| 11 | Algorithms are accurate | **Pass** with one caveat, below |
+| 12 | LLRs describe behaviour, not code structure | **Pass** |
+
+**Caveat on objective 11.** The viewport and word-motion algorithms were
+translated from Helix, not ported: Helix indexes by character and mojibake by
+byte. Every offset therefore changed meaning. This is the highest-risk area of
+the change and is why `buffer/view.rs` (99.57%) and `buffer/movement.rs`
+(97.98%) carry the highest coverage in the crate.
+
+---
+
+## Derived requirements
+
+| # | Check | Result |
+|---|---|---|
+| 13 | Derived requirements are identified as such | **Pass** — seven, in `derived.md` |
+| 14 | Each states what forced it | **Pass** |
+| 15 | Each is flagged for the safety assessment | **Pass** — every entry carries a "Reviewer must judge" clause |
+| 16 | Derived requirements do not silently weaken an HLR | **Pass** — MJB-DR-001 previously weakened the originating request; it has since been resolved so that both the transcoding and rejection behaviours hold, each in its own domain |
+
+Seven derived requirements were recorded, of which **one (MJB-DR-001) is now
+resolved** and six remain open. Two were found only during implementation and
+could not have been anticipated:
+
+- **MJB-DR-002** — byte indexing admits offsets inside a character, a failure
+ mode char indexing cannot express.
+- **MJB-DR-007** — `encoding_rs` decodes UTF-16 but silently refuses to encode
+ it, substituting UTF-8. Found by a round-trip test, **not** by inspection.
+
+---
+
+## Open items for the safety assessment
+
+**Six of seven** derived requirements remain open; MJB-DR-001 is resolved. The
+most consequential remaining:
+
+1. **MJB-DR-006** — ropey 2.0.0-beta.1 is a pre-release under a DAL-C
+ classification. Raised before implementation, accepted by the requester,
+ mitigated by confining the dependency to one module.
+2. **MJB-DR-007** — `encoding_rs` refuses to encode UTF-16 and substitutes
+ UTF-8 silently. Worked around; the reviewer must judge whether any other
+ delegated encoding shares the asymmetry.
+3. **MJB-DR-002** — byte indexing admits offsets inside a character. Guarded in
+ two places; the reviewer must judge whether returning an error is right where
+ a violated boundary may instead indicate a defect.
+
+A residual item is noted under MJB-DR-001: with UTF-8 now strict, a binary file
+cannot be opened at all, and no override is provided. That omission is
+deliberate and its rationale is recorded, but it is a usability decision a
+reviewer may want to revisit.
+
+---
+
+## Summary
+
+Requirements review **complete**. 19 HLRs, 98 LLRs, 7 derived requirements.
+One requirements conflict found and **resolved**; one HLR reworded for accuracy;
+six derived requirements outstanding for the safety assessment.
diff --git a/docs/traceability/trace.md b/docs/traceability/trace.md
new file mode 100644
index 0000000..1b02e71
--- /dev/null
+++ b/docs/traceability/trace.md
@@ -0,0 +1,287 @@
+# mojibake — Traceability Matrix
+
+Software Level: **DAL-C** (DO-178C)
+
+Bidirectional trace: **HLR → LLR → source → test**. Source items carry a
+`// MJB-LLR-nnn` comment; tests are named `mjb_llr_nnn_<description>` so this
+matrix can be checked mechanically:
+
+```bash
+# every LLR tagged in source
+grep -rho 'MJB-LLR-[0-9]\+' src/ | sort -u
+# every LLR exercised by a test
+grep -rho 'mjb_llr_[0-9]\+' src/ tests/ | sort -u
+```
+
+Paths are relative to the repository root.
+
+---
+
+## HLR → LLR
+
+| HLR | Subject | LLRs |
+|---|---|---|
+| MJB-HLR-001 | File load from command line | 111, 112 |
+| MJB-HLR-002 | Encoding and BOM | 110, 111, 113, 115, 118 |
+| MJB-HLR-003 | Line ending preservation | 114, 115 |
+| MJB-HLR-004 | Rope text storage | 090–092, 117 |
+| MJB-HLR-005 | Selection model | 001–011, 020–025 |
+| MJB-HLR-006 | Character and line motion | 062, 063, 064 |
+| MJB-HLR-007 | Word motions select | 060, 061, 065–069 |
+| MJB-HLR-008 | Goto commands | 070–073, 150–154 |
+| MJB-HLR-009 | Insert-mode entry | 049, 117 |
+| MJB-HLR-010 | Text modification | 040–050, 117 |
+| MJB-HLR-011 | Undo and redo | 045, 046, 051–053 |
+| MJB-HLR-012 | Viewport pagination | 090–092, 200 |
+| MJB-HLR-013 | Scrolling and paging | 093–102 |
+| MJB-HLR-014 | TOML configuration | 180, 181 |
+| MJB-HLR-015 | Config-driven modal keymap | 150–157, 182, 184 |
+| MJB-HLR-016 | Command mode | 158, 159, 160 |
+| MJB-HLR-017 | File write | 130–137 |
+| MJB-HLR-018 | Error handling | 042, 044, 118, 183 |
+| MJB-HLR-019 | Single-widget presentation | 200–205 |
+
+---
+
+## LLR → source → test
+
+### Selection (`src/buffer/selection.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 001 | `Range` | `mjb_llr_001_offsets_are_byte_indices` |
+| 002 | `Range::from`, `Range::to` | `mjb_llr_002_from_and_to_ignore_direction` |
+| 003 | `Range::is_empty` | `mjb_llr_003_is_empty` |
+| 004 | `Range::direction` | `mjb_llr_004_direction` |
+| 005 | `Range::cursor` | `mjb_llr_005_cursor_steps_back_on_forward_range`, `mjb_llr_005_cursor_respects_grapheme_clusters` |
+| 006 | `Range::put_cursor` | `mjb_llr_006_put_cursor_without_extend_collapses` |
+| 007 | `Range::put_cursor` | `mjb_llr_007_put_cursor_with_extend_keeps_anchor`, `mjb_llr_007_put_cursor_extend_flips_direction`, `mjb_llr_007_select_mode_motions_extend` |
+| 008 | `Range::line_range` | `mjb_llr_008_line_range` |
+| 009 | `Selection` | `mjb_llr_009_selection_invariant` |
+| 010 | `Selection::primary` | `mjb_llr_010_primary_round_trips` |
+| 011 | `Range::clamped` | `mjb_llr_011_clamped_snaps_into_bounds_and_onto_char_boundary`, `mjb_llr_011_motions_at_boundaries_never_leave_the_buffer` |
+
+### Graphemes (`src/buffer/grapheme.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 020 | `prev_grapheme_boundary` | `mjb_llr_020_prev_boundary_saturates_at_zero`, `mjb_llr_020_combining_mark_is_one_cluster` |
+| 021 | `next_grapheme_boundary` | `mjb_llr_021_next_boundary_saturates_at_end`, `mjb_llr_021_multibyte_advances_whole_char` |
+| 022 | chunk-walking loops in both | `mjb_llr_022_boundaries_resolve_across_chunk_edges` |
+| 023 | `is_grapheme_boundary` | `mjb_llr_023_boundary_detection` |
+| 024 | `grapheme_width` | `mjb_llr_024_widths` |
+| 025 | `display_column` | `mjb_llr_025_display_column_counts_width_not_bytes`, `mjb_llr_025_display_column_tab_expands` |
+
+### Transactions and history (`src/buffer/transaction.rs`, `history.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 040 | `Operation` | `mjb_llr_040_operation_counts_are_byte_lengths` |
+| 041 | `ChangeSet` | `mjb_llr_041_changeset_records_both_lengths`, `mjb_llr_041_empty_changeset_reports_equal_lengths` |
+| 042 | `ChangeSet::apply` length check | `mjb_llr_042_length_mismatch_is_rejected`, `mjb_llr_042_rejected_change_leaves_rope_untouched` |
+| 043 | `ChangeSet::apply` | `mjb_llr_043_apply_insert_and_delete` |
+| 044 | `ChangeSet::apply` boundary check | `mjb_llr_044_non_char_boundary_errors_rather_than_panics` |
+| 045 | `ChangeSet::invert` | `mjb_llr_045_invert_maps_each_operation` |
+| 046 | `apply` + `invert` | `mjb_llr_046_apply_then_invert_round_trips` |
+| 047 | `Transaction` | `mjb_llr_047_transaction_carries_a_selection` |
+| 048 | `Transaction::change` | `mjb_llr_048_multiple_ordered_changes` |
+| 049 | `Transaction::insert` | `mjb_llr_049_insert_at_cursor` |
+| 050 | `Transaction::delete` | `mjb_llr_050_delete_selection_span`, `mjb_llr_050_d_with_an_empty_selection_deletes_one_grapheme`, `mjb_llr_050_d_on_an_empty_buffer_is_a_noop` |
+| 051 | `History::commit` | `mjb_llr_051_commit_then_undo_then_redo`, `mjb_llr_051_commit_after_undo_discards_the_redo_branch`, `mjb_llr_051_undo_restores_typed_text` |
+| 052 | `History::undo`, `Document::undo` | `mjb_llr_052_undo_past_start_is_a_noop`, `mjb_llr_052_undo_past_history_start_is_a_noop`, `mjb_llr_052_undo_past_history_start_is_safe`, `mjb_llr_052_u_undoes_a_deletion` |
+| 053 | `History::redo`, `Document::redo` | `mjb_llr_053_redo_past_end_is_a_noop`, `mjb_llr_053_redo_past_end_is_safe`, `mjb_llr_053_capital_u_redoes` |
+
+### Movement (`src/buffer/movement.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 060 | `CharCategory`, `categorize_char` | `mjb_llr_060_categories` |
+| 061 | `is_word_boundary` | `mjb_llr_061_word_boundary_is_category_change` |
+| 062 | `move_char_left` | `mjb_llr_062_move_char_left_stops_at_zero`, `mjb_llr_062_h_and_l_move_by_one_grapheme`, `mjb_llr_062_h_at_start_of_buffer_is_a_noop` |
+| 063 | `move_char_right` | `mjb_llr_063_move_char_right_stops_at_end`, `mjb_llr_063_move_char_right_skips_whole_multibyte_char`, `mjb_llr_063_l_at_end_of_buffer_is_a_noop` |
+| 064 | `move_vertically` | `mjb_llr_064_vertical_motion_preserves_column`, `mjb_llr_064_vertical_motion_clamps_to_short_line`, `mjb_llr_064_vertical_motion_is_noop_at_edges`, `mjb_llr_064_j_and_k_move_between_lines` |
+| 065 | `next_word_start` | `mjb_llr_065_next_word_start_produces_a_selection`, `mjb_llr_065_w_leaves_a_selection`, `mjb_llr_065_w_then_d_deletes_the_word`, `mjb_llr_065_counted_next_word_start_advances_once_per_count` |
+| 066 | `prev_word_start` | `mjb_llr_066_prev_word_start_spans_backward`, `mjb_llr_066_b_selects_backward`, `mjb_llr_066_counted_prev_word_start_advances_once_per_count` |
+| 067 | `next_word_end` | `mjb_llr_067_next_word_end_spans_the_word`, `mjb_llr_067_e_selects_to_the_word_end`, `mjb_llr_067_counted_next_word_end_advances_once_per_count` |
+| 068 | `is_separator` and the skip loops in the three word functions | `mjb_llr_068_word_motion_stops_at_punctuation`, `mjb_llr_068_long_word_motion_absorbs_punctuation`, `mjb_llr_068_word_motion_crosses_line_endings`, `mjb_llr_068_capital_w_treats_punctuation_as_word_characters` |
+| 069 | `word_move` boundary guards | `mjb_llr_069_word_motion_is_noop_at_boundaries`, `mjb_llr_069_w_at_end_of_buffer_is_a_noop`, `mjb_llr_069_counted_motion_saturates_at_the_buffer_end` |
+| 070 | `goto_file_start` | `mjb_llr_070_goto_file_start`, `mjb_llr_070_gg_goes_to_file_start` |
+| 071 | `goto_last_line` | `mjb_llr_071_goto_last_line`, `mjb_llr_071_goto_last_line_without_trailing_newline`, `mjb_llr_071_ge_goes_to_the_last_line` |
+| 072 | `goto_line_start` | `mjb_llr_072_goto_line_start`, `mjb_llr_072_gh_goes_to_the_line_start`, `mjb_llr_072_gs_goes_to_first_non_whitespace` |
+| 073 | `goto_line_end`, `line_end_byte` | `mjb_llr_073_goto_line_end_excludes_terminator`, `mjb_llr_073_goto_line_end_handles_crlf`, `mjb_llr_073_gl_goes_to_the_line_end` |
+
+### Viewport (`src/buffer/view.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 090 | `ViewPosition` | `mjb_llr_090_anchor_is_a_byte_offset_at_a_line_start` |
+| 091 | `View::top_line` | `mjb_llr_091_top_line_from_anchor` |
+| 092 | `View::visible_lines`, `visible_line_range` | `mjb_llr_092_visible_lines_are_bounded_by_height`, `mjb_llr_092_visible_lines_clamp_near_end_of_buffer`, `mjb_llr_092_empty_buffer_renders_safely`, `mjb_llr_092_visible_line_count_is_independent_of_file_size`, `mjb_llr_092_scrolling_a_large_file_stays_responsive` |
+| 093 | scroll-off clamping | `mjb_llr_093_scrolloff_larger_than_viewport_is_clamped` |
+| 094 | scroll-up branch | `mjb_llr_094_scrolls_up_to_honour_top_margin`, `mjb_llr_094_scroll_near_start_saturates_at_zero` |
+| 095 | scroll-down branch | `mjb_llr_095_scrolls_down_to_honour_bottom_margin` |
+| 096 | no-scroll branch | `mjb_llr_096_no_scroll_when_cursor_is_comfortable` |
+| 097 | `View::set_top_line` | `mjb_llr_097_top_line_clamps_into_buffer` |
+| 098 | zero-height guard | `mjb_llr_098_zero_height_viewport_is_a_noop`, `mjb_llr_098_zero_height_viewport_does_not_panic` |
+| 099 | `ensure_horizontal_in_view` | `mjb_llr_099_horizontal_scroll_follows_cursor` |
+| 100 | `View::page` (half) | `mjb_llr_100_half_page_moves_cursor_and_view`, `mjb_llr_100_ctrl_d_pages_half_a_screen_down`, `mjb_llr_100_ctrl_u_pages_back_up` |
+| 101 | `View::page` (full) | `mjb_llr_101_full_page_moves_by_height`, `mjb_llr_101_ctrl_f_pages_a_full_screen_down` |
+| 102 | paging saturation | `mjb_llr_102_paging_saturates_at_both_ends` |
+
+### Document, encoding, line endings
+
+| LLR | Source item | Test |
+|---|---|---|
+| 110 | `encoding::detect_bom` | `mjb_llr_110_detects_each_bom` |
+| 111 | `Document::open`, `Buffer::new` | `mjb_llr_111_loads_contents`, `mjb_llr_111_plain_utf8_round_trips`, `mjb_llr_111_no_path_yields_a_scratch_buffer` |
+| 112 | `Document::open` not-found arm | `mjb_llr_112_missing_file_yields_empty_buffer_that_remembers_the_path`, `mjb_llr_112_empty_file_is_editable`, `mjb_llr_112_file_without_trailing_newline_round_trips`, `mjb_llr_112_writing_a_new_file_creates_it`, `mjb_llr_112_missing_file_opens_as_an_empty_buffer` |
+| 113 | `encoding::decode` non-UTF-8 branch | `mjb_llr_113_declared_utf16_is_transcoded_not_rejected`, `mjb_llr_113_empty_input_decodes_to_empty`, `mjb_llr_113_declared_utf16_file_opens`, `mjb_llr_113_declared_utf16_file_opens_and_edits` |
+| 118 | `DecodeError`, `encoding::decode` UTF-8 branch, `Document::open` | `mjb_llr_118_invalid_utf8_is_rejected`, `mjb_llr_118_rejection_names_the_offset`, `mjb_llr_118_truncated_multibyte_char_is_rejected`, `mjb_llr_118_declared_utf8_is_strict_too`, `mjb_llr_118_valid_multibyte_utf8_is_accepted`, `mjb_llr_118_invalid_utf8_file_is_refused`, `mjb_llr_118_binary_file_is_refused_not_opened` |
+| 114 | `LineEnding::detect` | `mjb_llr_114_detects_lf`, `_detects_crlf`, `_detects_lone_cr`, `_falls_back_to_platform_default`, `_first_terminator_decides`, `_crlf_is_detected_and_normalized_for_storage` |
+| 115 | `encoding::encode`, `Document::encode` | `mjb_llr_115_bom_round_trips`, `mjb_llr_115_utf16le_round_trips`, `mjb_llr_115_utf16be_round_trips`, `mjb_llr_115_utf16_handles_non_ascii_and_surrogates`, `mjb_llr_115_apply_restores_original_ending`, `mjb_llr_115_crlf_round_trips_through_save` |
+| 116 | `Document::is_modified`, `History::revision` | `mjb_llr_116_modified_flag_lifecycle`, `mjb_llr_116_revision_is_not_stack_depth`, `mjb_llr_116_revision_is_zero_when_pristine_and_returns_on_undo`, `mjb_llr_116_undo_back_to_saved_state_is_clean`, `mjb_llr_116_divergent_edit_at_the_same_depth_stays_modified` |
+| 117 | `Document::apply` | `mjb_llr_117_apply_updates_text_and_records_history` |
+
+### Save path (`src/buffer/save.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 130 | `resolve_write_path` | `mjb_llr_130_write_follows_symlink_without_replacing_it`, `mjb_llr_130_relative_symlink_resolves_against_its_own_directory`, `mjb_llr_130_write_through_a_symlink_end_to_end` |
+| 131 | `readonly` | `mjb_llr_131_readonly_target_is_refused`, `mjb_llr_131_missing_file_is_not_readonly`, `mjb_llr_131_readonly_file_reports_and_preserves_contents` |
+| 132 | missing-parent branch | `mjb_llr_132_missing_parent_is_refused_without_force`, `mjb_llr_132_force_creates_the_parent` |
+| 133 | `must_copy` | `mjb_llr_133_hardlink_is_detected_and_preserved`, `mjb_llr_133_plain_file_does_not_need_copy_mode` |
+| 134 | `backup_path`, backup creation | `mjb_llr_134_backup_is_created_beside_the_target`, `mjb_llr_134_backup_path_handles_a_bare_file_name` |
+| 135 | restore-on-failure | `mjb_llr_135_failed_write_restores_the_original` |
+| 136 | `copy_permissions`, backup removal | `mjb_llr_136_no_backup_file_is_left_behind` |
+| 137 | `Document::save` | `mjb_llr_116_modified_flag_lifecycle` |
+
+### Keymap and commands (`src/buffer/keymap.rs`, `mod.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 150 | `Keymap::new` prefix set | `mjb_llr_150_proper_prefixes_are_precomputed`, `mjb_llr_150_prefix_set_is_empty_for_a_mode_without_sequences` |
+| 151 | `Keymap::resolve` match arm | `mjb_llr_151_single_key_binding_matches_immediately`, `mjb_llr_151_two_key_sequence_resolves`, `mjb_llr_151_sequences_sharing_a_prefix_stay_distinct` |
+| 152 | `Keymap::resolve` prefix arm | `mjb_llr_152_prefix_key_waits_for_more` |
+| 153 | `Keymap::resolve` cancel arm | `mjb_llr_153_unknown_continuation_cancels`, `mjb_llr_153_unbound_key_cancels_immediately`, `mjb_llr_153_unknown_g_sequence_does_not_corrupt_state` |
+| 154 | absence of any timer | `mjb_llr_154_pending_is_not_discarded_by_time`, `mjb_llr_154_gg_resolves_regardless_of_intervening_time` |
+| 155 | count accumulation | `mjb_llr_155_count_accumulates_and_is_delivered`, `_count_is_consumed_once`, `_leading_zero_is_not_a_count`, `_zero_extends_an_existing_count`, `_digits_are_not_counts_in_insert_mode`, `_count_repeats_a_motion` |
+| 156 | `self_insert_char` | `mjb_llr_156_self_insert_accepts_plain_printables`, `_rejects_control_and_alt`, `_rejects_non_char_and_sequences`, `_i_then_typing_inserts_text`, `_command_keys_type_literally_in_insert_mode`, `_unbound_control_key_is_discarded_in_insert_mode` |
+| 157 | `Command` enum | `mjb_llr_157_deserializes_from_the_variant_name`, `mjb_llr_157_unknown_command_name_is_an_error_not_a_panic`, `mjb_llr_157_display_round_trips_through_deserialization` |
+| 158 | `Buffer::handle_command_mode_key` | `mjb_llr_158_escape_cancels_the_command_line`, `_command_line_accepts_backspace`, `_backspacing_an_empty_command_line_leaves_command_mode` |
+| 159 | `Buffer::run_command_line` | `mjb_llr_159_write_saves_to_disk`, `_quit_returns_the_quit_outcome`, `_wq_writes_then_quits`, `_x_is_an_alias_for_wq`, `_unknown_command_is_reported_not_fatal`, `_force_write_creates_missing_directories`, `_empty_command_line_does_nothing` |
+| 160 | unsaved-changes guard | `mjb_llr_160_quit_with_unsaved_changes_is_refused`, `mjb_llr_160_force_quit_discards_changes` |
+
+### Configuration (`src/config.rs`)
+
+| LLR | Source item | Test |
+|---|---|---|
+| 180 | `CONFIG`, `Config::new` | `mjb_llr_180_builtin_defaults_parse` |
+| 181 | single TOML source | `mjb_llr_181_only_config_toml_is_read` + `cargo tree` check (see below) |
+| 182 | default-merge loop | `mjb_llr_182_user_bindings_merge_per_binding` |
+| 183 | `KeyBindings::deserialize`, `parse_color` | `mjb_llr_183_invalid_keybinding_is_recoverable`, `mjb_llr_183_malformed_colours_never_panic`, `mjb_llr_183_colour_cube_bounds`, `mjb_llr_183_grayscale_ramp_bounds`, `mjb_llr_183_bright_colour_is_base_plus_eight` |
+| 184 | `Mode` | `mjb_llr_184_modes_deserialize_lowercase`, `mjb_llr_184_v_toggles_select_mode` |
+| 185 | `EditorConfig` | `mjb_llr_185_editor_defaults`, `mjb_llr_185_final_newline_only_added_when_missing`, `mjb_llr_185_final_newline_added_on_encode_when_requested`, `mjb_llr_185_empty_document_encodes_empty` |
+
+### Presentation (`src/components/buffer.rs`, `src/app.rs`)
+
+Rendering is tested against a real cell grid via ratatui's `TestBackend`
+(`tests/rendering.rs`), not asserted by inspection.
+
+| LLR | Source item | Test |
+|---|---|---|
+| 200 | `BufferComponent::draw` render loop | `mjb_llr_200_renders_the_file_contents`, `mjb_llr_200_renders_only_the_visible_window`, `mjb_llr_200_line_numbers_are_shown_in_the_gutter`, `mjb_llr_200_empty_file_renders_without_panicking`, `mjb_llr_200_wide_characters_render` |
+| 201 | cursor/selection styling in `draw` | `mjb_llr_201_cursor_is_styled_distinctly` |
+| 202 | `status_line`, `message_line` | `mjb_llr_202_status_line_shows_mode_and_path`, `mjb_llr_202_status_line_marks_an_unmodified_file`, `mjb_llr_202_scratch_buffer_is_labelled` |
+| 203 | `App::handle_global_key`, `Keymap::lookup_single` | `mjb_llr_203_lookup_single_does_not_disturb_pending`, `mjb_llr_203_globally_bound_key_is_not_typed_as_text` |
+| 204 | `App::new` component vec | `mjb_llr_204_no_fps_counter_or_hello_world_is_rendered` |
+| 205 | absence of `last_tick_key_events` | `mjb_llr_205_pending_chord_survives_redraws` |
+
+`mjb_llr_200_renders_only_the_visible_window` is the direct check on
+MJB-HLR-012: a 1000-line file in an 8-row terminal must not put line 500 on
+screen.
+
+---
+
+## Completeness check
+
+The matrix above is verified mechanically, not by inspection:
+
+```bash
+grep -oE 'MJB-LLR-[0-9]+' docs/requirements/llr.md | sort -u > defined
+grep -rhoE 'MJB-LLR-[0-9]+' src/ | sort -u > tagged
+grep -rhoE 'mjb_llr_[0-9]+' src/ tests/ | sed 's/mjb_llr_/MJB-LLR-/' | sort -u > tested
+comm -23 defined tagged # LLRs with no source tag
+comm -23 defined tested # LLRs with no test
+comm -13 defined tested # tests naming an LLR that does not exist
+```
+
+Result at time of writing — all three sets empty:
+
+```
+defined: 98 tagged: 98 tested: 98
+```
+
+Every low-level requirement is tagged in source and exercised by at least one
+test named for it. Test totals: **282 passing** (179 unit, 89 editing
+integration, 14 rendering integration).
+
+---
+
+## Coverage
+
+Statement (line) coverage of the buffer core, the DAL-C structural criterion:
+
+```
+src/buffer/** statement coverage: 2665/2770 = 96.21%
+```
+
+Measured with `cargo llvm-cov --summary-only test`. Per-module figures:
+
+| Module | Lines | Covered |
+|---|---|---|
+| `buffer/view.rs` | 233 | 99.57% |
+| `buffer/transaction.rs` | 265 | 98.87% |
+| `buffer/encoding.rs` | 140 | 98.57% |
+| `buffer/movement.rs` | 346 | 97.98% |
+| `buffer/selection.rs` | 182 | 97.80% |
+| `buffer/keymap.rs` | 223 | 97.76% |
+| `buffer/history.rs` | 90 | 96.67% |
+| `buffer/command.rs` | 28 | 96.43% |
+| `buffer/document.rs` | 268 | 94.40% |
+| `buffer/mod.rs` | 408 | 93.87% |
+| `buffer/save.rs` | 218 | 93.12% |
+| `buffer/line_ending.rs` | 87 | 89.66% |
+| `buffer/grapheme.rs` | 195 | 89.23% |
+
+**MC/DC and decision coverage are not DAL-C objectives and are not claimed.**
+Only statement coverage is reported.
+
+### Uncovered code, and why
+
+- `buffer/grapheme.rs` (89.23%) — the lowest figure. The uncovered lines are
+ defensive `Err(_) => return` arms for `GraphemeIncomplete` variants that
+ cannot arise from a cursor constructed over the whole slice. They exist so a
+ malformed state degrades instead of panicking (MJB-HLR-018), and are
+ unreachable by construction, so no test can drive them.
+- `buffer/save.rs` (93.12%) — the restore-on-failure path is reachable only
+ when the process cannot write to a directory it owns. Running the suite as
+ root defeats the permission bits, so
+ `mjb_llr_135_failed_write_restores_the_original` skips its assertion in that
+ case rather than reporting a false pass. The path is covered when the suite
+ runs as an ordinary user.
+- `buffer/line_ending.rs` (89.66%) — `LineEnding::platform_default` has one
+ arm per platform; only the host's arm executes.
+- Remaining gaps are `Display`/`From` glue generated by `thiserror` on error
+ variants that no test provokes.
+
+### Verification not expressible as a unit test
+
+`cargo tree` confirms MJB-LLR-181 and MJB-HLR-014 — that no parser for a
+non-TOML configuration format remains in the dependency graph:
+
+```bash
+cargo tree | grep -iE "json|yaml|ini\b" # returns nothing
+```
+
+Confirmed: `config v0.15.25` resolves with only `pathdiff`, `serde_core`,
+`toml`, and `winnow`.
diff --git a/scripts/check-trace.sh b/scripts/check-trace.sh
new file mode 100755
index 0000000..bc026d4
--- /dev/null
+++ b/scripts/check-trace.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+#
+# Bidirectional traceability check (docs/process.md §5).
+#
+# Verifies HLR -> LLR -> source -> test in both directions:
+# - every low-level requirement is implemented and tested
+# - every requirement a test names actually exists
+#
+# The third check is the one that is easy to omit and that fails silently when
+# omitted: it catches stale references left behind by a renumbered or withdrawn
+# requirement.
+#
+# Exits non-zero if any check fails, so it can gate a commit or CI job.
+
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+
+grep -ohE 'MJB-LLR-[0-9]+' docs/requirements/llr.md | sort -u > "$tmp/defined"
+grep -rohE 'MJB-LLR-[0-9]+' src/ | sort -u > "$tmp/tagged"
+grep -rohE 'mjb_llr_[0-9]+' src/ tests/ \
+ | sed 's/mjb_llr_/MJB-LLR-/' | sort -u > "$tmp/tested"
+
+defined=$(wc -l < "$tmp/defined")
+tagged=$(wc -l < "$tmp/tagged")
+tested=$(wc -l < "$tmp/tested")
+
+printf 'defined=%s tagged=%s tested=%s\n\n' "$defined" "$tagged" "$tested"
+
+status=0
+
+report() { # $1=label $2=file $3=explanation
+ if [ -s "$2" ]; then
+ printf '%s:\n' "$1"
+ sed 's/^/ /' "$2"
+ printf ' -> %s\n\n' "$3"
+ status=1
+ fi
+}
+
+comm -23 "$tmp/defined" "$tmp/tagged" > "$tmp/untagged"
+comm -23 "$tmp/defined" "$tmp/tested" > "$tmp/untested"
+comm -13 "$tmp/defined" "$tmp/tested" > "$tmp/unknown"
+
+report "Requirements with no implementation" "$tmp/untagged" \
+ "tag the implementing item with // MJB-LLR-nnn"
+report "Requirements with no test" "$tmp/untested" \
+ "add a test named mjb_llr_nnn_<description>"
+report "Tests naming a requirement that does not exist" "$tmp/unknown" \
+ "typo, or a stale reference to a withdrawn requirement"
+
+if [ "$status" -eq 0 ]; then
+ echo "OK: traceability complete in both directions."
+fi
+
+exit "$status"
diff --git a/src/action.rs b/src/action.rs
new file mode 100644
index 0000000..29ee079
--- /dev/null
+++ b/src/action.rs
@@ -0,0 +1,17 @@
+use serde::{Deserialize, Serialize};
+use strum::Display;
+
+/// Application-level events, distinct from [`crate::buffer::command::Command`],
+/// which is what key bindings name. `Action` is what the event loop routes;
+/// `Command` is what the editor executes.
+#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)]
+pub enum Action {
+ Tick,
+ Render,
+ Resize(u16, u16),
+ Suspend,
+ Resume,
+ Quit,
+ ClearScreen,
+ Error(String),
+}
diff --git a/src/app.rs b/src/app.rs
new file mode 100644
index 0000000..063a30a
--- /dev/null
+++ b/src/app.rs
@@ -0,0 +1,176 @@
+use std::path::PathBuf;
+
+use crossterm::event::KeyEvent;
+use ratatui::prelude::Rect;
+use tokio::sync::mpsc;
+use tracing::{debug, info};
+
+use crate::{
+ action::Action,
+ buffer::{command::Command, keymap::Keymap},
+ components::{Component, buffer::BufferComponent},
+ config::{Config, Mode},
+ tui::{Event, Tui},
+};
+
+pub struct App {
+ config: Config,
+ tick_rate: f64,
+ frame_rate: f64,
+ components: Vec<Box<dyn Component>>,
+ should_quit: bool,
+ should_suspend: bool,
+ /// MJB-LLR-203: resolves only the `Global` scope. Every other mode belongs
+ /// to the buffer — see MJB-DR-004 for why ownership is split.
+ global_keymap: Keymap,
+ action_tx: mpsc::UnboundedSender<Action>,
+ action_rx: mpsc::UnboundedReceiver<Action>,
+}
+
+impl App {
+ pub fn new(tick_rate: f64, frame_rate: f64, file: Option<PathBuf>) -> color_eyre::Result<Self> {
+ let (action_tx, action_rx) = mpsc::unbounded_channel();
+ let config = Config::new()?;
+ let global_keymap = Keymap::new(&config.keybindings);
+
+ // MJB-LLR-204: the buffer is the only component. The template's
+ // FpsCounter and Home widgets are gone, not merely unregistered.
+ let buffer = BufferComponent::new(config.clone(), file)?;
+
+ Ok(Self {
+ tick_rate,
+ frame_rate,
+ components: vec![Box::new(buffer)],
+ should_quit: false,
+ should_suspend: false,
+ config,
+ global_keymap,
+ action_tx,
+ action_rx,
+ })
+ }
+
+ pub async fn run(&mut self) -> color_eyre::Result<()> {
+ let mut tui = Tui::new()?
+ .tick_rate(self.tick_rate)
+ .frame_rate(self.frame_rate);
+ tui.enter()?;
+
+ for component in self.components.iter_mut() {
+ component.register_action_handler(self.action_tx.clone())?;
+ }
+ for component in self.components.iter_mut() {
+ component.register_config_handler(self.config.clone())?;
+ }
+ for component in self.components.iter_mut() {
+ component.init(tui.size()?)?;
+ }
+
+ let action_tx = self.action_tx.clone();
+ loop {
+ self.handle_events(&mut tui).await?;
+ self.handle_actions(&mut tui)?;
+ if self.should_suspend {
+ tui.suspend()?;
+ action_tx.send(Action::Resume)?;
+ action_tx.send(Action::ClearScreen)?;
+ tui.enter()?;
+ } else if self.should_quit {
+ tui.stop()?;
+ break;
+ }
+ }
+ tui.exit()?;
+ Ok(())
+ }
+
+ async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
+ let Some(event) = tui.next_event().await else {
+ return Ok(());
+ };
+ let action_tx = self.action_tx.clone();
+
+ match event {
+ Event::Quit => action_tx.send(Action::Quit)?,
+ Event::Tick => action_tx.send(Action::Tick)?,
+ Event::Render => action_tx.send(Action::Render)?,
+ Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
+ // MJB-LLR-203: a global binding consumes the key outright. The
+ // template forwarded every key both here and to every component,
+ // which would fire `Quit` while typing in insert mode.
+ Event::Key(key) if self.handle_global_key(key)? => return Ok(()),
+ _ => {}
+ }
+
+ for component in self.components.iter_mut() {
+ if let Some(action) = component.handle_events(Some(event.clone()))? {
+ action_tx.send(action)?;
+ }
+ }
+ Ok(())
+ }
+
+ /// Returns whether the key was consumed by the global keymap.
+ fn handle_global_key(&mut self, key: KeyEvent) -> color_eyre::Result<bool> {
+ let Some(command) = self.global_keymap.lookup_single(Mode::Global, key) else {
+ return Ok(false);
+ };
+
+ let action = match command {
+ Command::Quit => Action::Quit,
+ Command::Suspend => Action::Suspend,
+ // Anything else bound globally is not an application concern; let
+ // the buffer handle it in its own mode.
+ _ => return Ok(false),
+ };
+
+ info!("Global binding matched: {command}");
+ self.action_tx.send(action)?;
+ Ok(true)
+ }
+
+ fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
+ while let Ok(action) = self.action_rx.try_recv() {
+ if action != Action::Tick && action != Action::Render {
+ debug!("{action:?}");
+ }
+ match action {
+ // MJB-LLR-205: `Tick` no longer drains a pending-key buffer.
+ // Chord resolution is time-independent (MJB-LLR-154).
+ Action::Quit => self.should_quit = true,
+ Action::Suspend => self.should_suspend = true,
+ Action::Resume => self.should_suspend = false,
+ Action::ClearScreen => tui.terminal.clear()?,
+ Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
+ Action::Render => self.render(tui)?,
+ Action::Error(ref err) => tracing::error!(?err),
+ _ => {}
+ }
+ for component in self.components.iter_mut() {
+ if let Some(action) = component.update(action.clone())? {
+ self.action_tx.send(action)?
+ };
+ }
+ }
+ Ok(())
+ }
+
+ fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
+ tui.resize(Rect::new(0, 0, w, h))?;
+ self.render(tui)?;
+ Ok(())
+ }
+
+ fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
+ tui.draw(|frame| {
+ for component in self.components.iter_mut() {
+ if let Err(err) = component.draw(frame, frame.area()) {
+ let _ = self
+ .action_tx
+ .send(Action::Error(format!("Failed to draw: {:?}", err)));
+ }
+ }
+ })?;
+ Ok(())
+ }
+}
diff --git a/src/buffer/command.rs b/src/buffer/command.rs
new file mode 100644
index 0000000..c5a2aef
--- /dev/null
+++ b/src/buffer/command.rs
@@ -0,0 +1,147 @@
+//! Editor commands — the values key bindings map to.
+//!
+//! Kept distinct from [`crate::action::Action`], which is application-level
+//! (tick, render, resize). A binding names a `Command`; the buffer executes it.
+//! `Quit` and `Suspend` appear here because the `Global` keymap is expressed in
+//! the same table and must be able to name them.
+
+use serde::{Deserialize, Serialize};
+use strum::Display;
+
+/// MJB-LLR-157: one unit variant per bound editor command, deserialized from
+/// the variant name so a TOML value like `"MoveCharLeft"` resolves directly.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, Serialize, Deserialize)]
+pub enum Command {
+ // --- Application (reachable from the `global` keymap) ---
+ Quit,
+ Suspend,
+
+ // --- Mode switching ---
+ NormalMode,
+ InsertMode,
+ SelectMode,
+ CommandMode,
+
+ // --- Character and line motion (MJB-HLR-006) ---
+ MoveCharLeft,
+ MoveCharRight,
+ MoveLineUp,
+ MoveLineDown,
+
+ // --- Word motion; these produce selections (MJB-HLR-007) ---
+ MoveNextWordStart,
+ MovePrevWordStart,
+ MoveNextWordEnd,
+ MoveNextLongWordStart,
+ MovePrevLongWordStart,
+ MoveNextLongWordEnd,
+
+ // --- Extending variants, used by select mode ---
+ ExtendCharLeft,
+ ExtendCharRight,
+ ExtendLineUp,
+ ExtendLineDown,
+ ExtendNextWordStart,
+ ExtendPrevWordStart,
+ ExtendNextWordEnd,
+
+ // --- Goto (MJB-HLR-008) ---
+ GotoFileStart,
+ GotoLastLine,
+ GotoLineStart,
+ GotoLineEnd,
+ GotoFirstNonWhitespace,
+
+ // --- Selection manipulation ---
+ ExtendLineBelow,
+ CollapseSelection,
+ FlipSelections,
+ SelectAll,
+
+ // --- Entering insert mode (MJB-HLR-009) ---
+ AppendMode,
+ InsertAtLineStart,
+ InsertAtLineEnd,
+ OpenBelow,
+ OpenAbove,
+
+ // --- Modification (MJB-HLR-010) ---
+ DeleteSelection,
+ ChangeSelection,
+ InsertNewline,
+ InsertTab,
+ DeleteCharBackward,
+ DeleteCharForward,
+ DeleteWordBackward,
+ KillToLineStart,
+
+ // --- Undo / redo (MJB-HLR-011) ---
+ Undo,
+ Redo,
+
+ // --- Scrolling and paging (MJB-HLR-013) ---
+ PageCursorHalfUp,
+ PageCursorHalfDown,
+ PageUp,
+ PageDown,
+
+ // --- Command line (MJB-HLR-016) ---
+ CommandSubmit,
+ CommandBackspace,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// MJB-LLR-157: a TOML value naming a variant deserializes to it, which is
+ /// what makes the keymap config-driven.
+ #[test]
+ fn mjb_llr_157_deserializes_from_the_variant_name() {
+ let cmd: Command = serde_json_free_parse("MoveCharLeft");
+ assert_eq!(cmd, Command::MoveCharLeft);
+ assert_eq!(serde_json_free_parse("Undo"), Command::Undo);
+ assert_eq!(
+ serde_json_free_parse("PageCursorHalfDown"),
+ Command::PageCursorHalfDown
+ );
+ }
+
+ #[test]
+ fn mjb_llr_157_unknown_command_name_is_an_error_not_a_panic() {
+ let err = toml::from_str::<Wrapper>("cmd = \"NoSuchCommand\"").unwrap_err();
+ assert!(
+ err.to_string().contains("NoSuchCommand"),
+ "error must name the offending value, got: {err}"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_157_display_round_trips_through_deserialization() {
+ for cmd in [
+ Command::Quit,
+ Command::GotoFileStart,
+ Command::DeleteSelection,
+ Command::CommandSubmit,
+ ] {
+ assert_eq!(
+ serde_json_free_parse(&cmd.to_string()),
+ cmd,
+ "{cmd} must round-trip"
+ );
+ }
+ }
+
+ #[derive(Debug, serde::Deserialize)]
+ struct Wrapper {
+ cmd: Command,
+ }
+
+ /// Parse a bare command name the way the keymap table does.
+ fn serde_json_free_parse(name: &str) -> Command {
+ let doc = format!("cmd = \"{name}\"");
+ toml::from_str::<Wrapper>(&doc)
+ .unwrap_or_else(|e| panic!("{name} must parse: {e}"))
+ .cmd
+ }
+}
diff --git a/src/buffer/document.rs b/src/buffer/document.rs
new file mode 100644
index 0000000..13c02ba
--- /dev/null
+++ b/src/buffer/document.rs
@@ -0,0 +1,525 @@
+//! The document: rope, associated path, encoding state, selection, history.
+//!
+//! **This is the only module that touches `ropey` types directly** as an owner.
+//! Confining the dependency here is the mitigation recorded in MJB-DR-006 for
+//! depending on a pre-release rope crate under DAL-C.
+
+use std::path::{Path, PathBuf};
+
+use ropey::{Rope, RopeSlice};
+
+use super::{
+ LINE_TYPE,
+ encoding::{self, DecodeError, EncodingInfo},
+ history::History,
+ line_ending::{self, LineEnding},
+ save::{self, SaveError},
+ selection::{Range, Selection},
+ transaction::{ChangeError, Transaction},
+};
+
+#[derive(Debug, thiserror::Error)]
+pub enum DocumentError {
+ #[error("{0}")]
+ Change(#[from] ChangeError),
+ #[error("{0}")]
+ Save(#[from] SaveError),
+ // MJB-LLR-118: opening a file mojibake cannot decode is a reportable
+ // failure, not something to paper over.
+ #[error("{0}")]
+ Decode(#[from] DecodeError),
+ #[error("io error: {0}")]
+ Io(#[from] std::io::Error),
+}
+
+#[derive(Debug)]
+pub struct Document {
+ text: Rope,
+ path: Option<PathBuf>,
+ encoding: EncodingInfo,
+ line_ending: LineEnding,
+ selection: Selection,
+ history: History,
+ /// The history revision the file on disk corresponds to.
+ ///
+ /// `modified` is derived from this rather than latched to a bool, so
+ /// undoing back to the last-saved state correctly reports the buffer as
+ /// clean. A latched flag would keep claiming unsaved changes for a buffer
+ /// byte-identical to its file. (MJB-LLR-116)
+ saved_revision: usize,
+}
+
+impl Default for Document {
+ fn default() -> Self {
+ Self::empty(None)
+ }
+}
+
+impl Document {
+ pub fn empty(path: Option<PathBuf>) -> Self {
+ Self {
+ text: Rope::new(),
+ path,
+ encoding: EncodingInfo::default(),
+ line_ending: LineEnding::platform_default(),
+ selection: Selection::point(0),
+ history: History::new(),
+ saved_revision: 0,
+ }
+ }
+
+ /// MJB-LLR-111, MJB-LLR-112: load `path`.
+ ///
+ /// A path that does not exist yields an empty buffer that remembers it, so
+ /// `:w` creates the file. Any other IO error is reported.
+ pub fn open(path: &Path) -> Result<Self, DocumentError> {
+ let bytes = match std::fs::read(path) {
+ Ok(b) => b,
+ // MJB-LLR-112
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ return Ok(Self::empty(Some(path.to_path_buf())));
+ }
+ Err(e) => return Err(DocumentError::Io(e)),
+ };
+
+ // MJB-LLR-113, MJB-LLR-118: a declared encoding is transcoded; invalid
+ // UTF-8 is rejected here rather than opened and later written back
+ // with the damage baked in.
+ let (text, encoding) = encoding::decode(&bytes)?;
+ // MJB-LLR-114 is evaluated on the raw text, before normalisation
+ // collapses CRLF and would erase the evidence.
+ let line_ending = LineEnding::detect(&text);
+ let normalized = line_ending::normalize(&text);
+
+ Ok(Self {
+ text: Rope::from_str(&normalized),
+ path: Some(path.to_path_buf()),
+ encoding,
+ line_ending,
+ selection: Selection::point(0),
+ history: History::new(),
+ saved_revision: 0,
+ })
+ }
+
+ pub fn text(&self) -> &Rope {
+ &self.text
+ }
+
+ pub fn slice(&self) -> RopeSlice<'_> {
+ self.text.slice(..)
+ }
+
+ pub fn path(&self) -> Option<&Path> {
+ self.path.as_deref()
+ }
+
+ pub fn set_path(&mut self, path: PathBuf) {
+ self.path = Some(path);
+ }
+
+ pub fn selection(&self) -> &Selection {
+ &self.selection
+ }
+
+ pub fn set_selection(&mut self, selection: Selection) {
+ self.selection = selection.clamped(self.text.slice(..));
+ }
+
+ /// Convenience: replace the primary range.
+ pub fn set_range(&mut self, range: Range) {
+ let clamped = range.clamped(self.text.slice(..));
+ self.selection.set_primary(clamped);
+ }
+
+ pub fn range(&self) -> Range {
+ self.selection.primary()
+ }
+
+ /// MJB-LLR-116: whether the buffer differs from the file on disk.
+ ///
+ /// Derived by comparing the current history revision against the one the
+ /// file was written at, so undoing back to the saved state reports clean.
+ pub fn is_modified(&self) -> bool {
+ self.history.revision() != self.saved_revision
+ }
+
+ pub fn line_ending(&self) -> LineEnding {
+ self.line_ending
+ }
+
+ pub fn len_lines(&self) -> usize {
+ self.text.len_lines(LINE_TYPE)
+ }
+
+ pub fn history_mut(&mut self) -> &mut History {
+ &mut self.history
+ }
+
+ /// MJB-LLR-117: apply `transaction`, recording its inverse for undo.
+ pub fn apply(&mut self, transaction: &Transaction) -> Result<(), DocumentError> {
+ // The inverse must be computed against the pre-change rope.
+ let inverse = Transaction::new(transaction.changes.invert(&self.text));
+
+ transaction.changes.apply(&mut self.text)?;
+
+ if let Some(sel) = &transaction.selection {
+ self.selection = sel.clone().clamped(self.text.slice(..));
+ } else {
+ self.selection = self.selection.clone().clamped(self.text.slice(..));
+ }
+
+ self.history.commit(transaction.clone(), inverse);
+ Ok(())
+ }
+
+ /// Apply without recording history — used to replay an undo or redo, whose
+ /// own bookkeeping is already handled by [`History`].
+ fn apply_without_history(&mut self, transaction: &Transaction) -> Result<(), DocumentError> {
+ transaction.changes.apply(&mut self.text)?;
+ if let Some(sel) = &transaction.selection {
+ self.selection = sel.clone().clamped(self.text.slice(..));
+ } else {
+ self.selection = self.selection.clone().clamped(self.text.slice(..));
+ }
+ Ok(())
+ }
+
+ /// MJB-LLR-052: revert the most recent change. `false` if there was none.
+ pub fn undo(&mut self) -> Result<bool, DocumentError> {
+ let Some(t) = self.history.undo().cloned() else {
+ return Ok(false);
+ };
+ self.apply_without_history(&t)?;
+ Ok(true)
+ }
+
+ /// MJB-LLR-053: reapply the most recently reverted change.
+ pub fn redo(&mut self) -> Result<bool, DocumentError> {
+ let Some(t) = self.history.redo().cloned() else {
+ return Ok(false);
+ };
+ self.apply_without_history(&t)?;
+ Ok(true)
+ }
+
+ /// MJB-LLR-115: the document as it should appear on disk.
+ ///
+ /// Assembles the text once. Chaining `to_string` → `with_final_newline` →
+ /// `apply` → `encode` would copy the whole document at each step; the
+ /// terminator rewrite and the final newline are folded into a single pass
+ /// so only the encode step copies, and only when the encoding is not the
+ /// UTF-8 the rope already holds.
+ pub fn encode(&self, insert_final_newline: bool) -> Vec<u8> {
+ let ending = self.line_ending.as_str();
+ let needs_rewrite = self.line_ending != LineEnding::Lf;
+
+ let mut text = String::with_capacity(self.text.len() + 1);
+ for chunk in self.text.chunks() {
+ if needs_rewrite {
+ // The rope stores LF only, so a plain split is sufficient.
+ let mut parts = chunk.split('\n');
+ if let Some(first) = parts.next() {
+ text.push_str(first);
+ }
+ for part in parts {
+ text.push_str(ending);
+ text.push_str(part);
+ }
+ } else {
+ text.push_str(chunk);
+ }
+ }
+
+ if insert_final_newline && !text.is_empty() && !text.ends_with(ending) {
+ text.push_str(ending);
+ }
+
+ encoding::encode(&text, self.encoding)
+ }
+
+ /// MJB-LLR-137: write to the associated path and mark it clean.
+ pub fn save(&mut self, force: bool, insert_final_newline: bool) -> Result<(), DocumentError> {
+ let path = self.path.clone().ok_or(SaveError::NoPath)?;
+ let bytes = self.encode(insert_final_newline);
+ save::write_atomic(&path, &bytes, force)?;
+ self.saved_revision = self.history.revision();
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::buffer::selection::Range;
+
+ fn doc(text: &str) -> Document {
+ let mut d = Document::empty(None);
+ d.text = Rope::from_str(text);
+ d
+ }
+
+ #[test]
+ fn mjb_llr_112_missing_file_yields_empty_buffer_that_remembers_the_path() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("not-created-yet.txt");
+ let doc = Document::open(&p).unwrap();
+ assert_eq!(doc.text().len(), 0);
+ assert_eq!(doc.path(), Some(p.as_path()));
+ assert!(!doc.is_modified());
+ }
+
+ #[test]
+ fn mjb_llr_111_loads_contents() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ std::fs::write(&p, "hello\nworld\n").unwrap();
+ let doc = Document::open(&p).unwrap();
+ assert_eq!(doc.text().to_string(), "hello\nworld\n");
+ }
+
+ #[test]
+ fn mjb_llr_114_crlf_is_detected_and_normalized_for_storage() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("crlf.txt");
+ std::fs::write(&p, "a\r\nb\r\n").unwrap();
+
+ let doc = Document::open(&p).unwrap();
+ assert_eq!(doc.line_ending(), LineEnding::Crlf);
+ assert_eq!(doc.text().to_string(), "a\nb\n", "stored as LF");
+ }
+
+ /// MJB-LLR-115, MJB-HLR-003: a CRLF file saved back is still CRLF.
+ #[test]
+ fn mjb_llr_115_crlf_round_trips_through_save() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("crlf.txt");
+ std::fs::write(&p, "a\r\nb\r\n").unwrap();
+
+ let mut doc = Document::open(&p).unwrap();
+ doc.save(false, true).unwrap();
+ assert_eq!(std::fs::read(&p).unwrap(), b"a\r\nb\r\n");
+ }
+
+ /// MJB-LLR-118: a file that is not valid UTF-8 is refused, and the file on
+ /// disk is left exactly as it was.
+ #[test]
+ fn mjb_llr_118_invalid_utf8_file_is_refused() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("bad.bin");
+ let original = [b'a', 0xFF, b'b'];
+ std::fs::write(&p, original).unwrap();
+
+ let err = Document::open(&p).expect_err("must refuse to open");
+ assert!(matches!(err, DocumentError::Decode(_)), "got {err:?}");
+ assert!(
+ err.to_string().contains("UTF-8"),
+ "message must explain why, got: {err}"
+ );
+ assert_eq!(
+ std::fs::read(&p).unwrap(),
+ original,
+ "a refused open must not touch the file"
+ );
+ }
+
+ /// MJB-LLR-113: a BOM-declared non-UTF-8 file still opens.
+ #[test]
+ fn mjb_llr_113_declared_utf16_file_opens() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("u16.txt");
+ // UTF-16LE BOM + "hi".
+ std::fs::write(&p, [0xFF, 0xFE, b'h', 0x00, b'i', 0x00]).unwrap();
+
+ let doc = Document::open(&p).expect("a declared encoding must open");
+ assert_eq!(doc.text().to_string(), "hi");
+ }
+
+ #[test]
+ fn mjb_llr_116_modified_flag_lifecycle() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ std::fs::write(&p, "abc").unwrap();
+
+ let mut doc = Document::open(&p).unwrap();
+ assert!(!doc.is_modified(), "freshly opened");
+
+ let t = Transaction::insert(doc.text(), doc.selection(), "X");
+ doc.apply(&t).unwrap();
+ assert!(doc.is_modified(), "set by an applied transaction");
+
+ doc.save(false, false).unwrap();
+ assert!(!doc.is_modified(), "cleared by a successful write");
+ }
+
+ /// MJB-LLR-137: a successful write clears `modified`; a failed one must
+ /// not, or the user would be told their unsaved work is safe.
+ #[test]
+ fn mjb_llr_137_failed_write_leaves_modified_set() {
+ let mut doc = doc("content");
+ // No path: the save cannot succeed.
+ let t = Transaction::insert(doc.text(), doc.selection(), "X");
+ doc.apply(&t).unwrap();
+ assert!(doc.is_modified());
+
+ assert!(doc.save(false, true).is_err());
+ assert!(
+ doc.is_modified(),
+ "a failed write must not clear the modified flag"
+ );
+ }
+
+ /// MJB-LLR-116: undoing back to the saved state reports the buffer clean.
+ /// A latched flag would keep claiming unsaved changes for content that is
+ /// byte-identical to the file.
+ #[test]
+ fn mjb_llr_116_undo_back_to_saved_state_is_clean() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ std::fs::write(&p, "abc").unwrap();
+ let mut doc = Document::open(&p).unwrap();
+
+ doc.save(false, false).unwrap();
+ assert!(!doc.is_modified());
+
+ let t = Transaction::insert(doc.text(), doc.selection(), "X");
+ doc.apply(&t).unwrap();
+ assert!(doc.is_modified(), "an edit dirties the buffer");
+
+ doc.undo().unwrap();
+ assert!(
+ !doc.is_modified(),
+ "undoing back to the saved content must report clean"
+ );
+
+ doc.redo().unwrap();
+ assert!(doc.is_modified(), "redoing dirties it again");
+ }
+
+ /// MJB-LLR-116: same history depth, different content, must stay dirty.
+ #[test]
+ fn mjb_llr_116_divergent_edit_at_the_same_depth_stays_modified() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ std::fs::write(&p, "abc").unwrap();
+ let mut doc = Document::open(&p).unwrap();
+
+ let t = Transaction::insert(doc.text(), doc.selection(), "X");
+ doc.apply(&t).unwrap();
+ doc.save(false, false).unwrap();
+ assert!(!doc.is_modified());
+
+ doc.undo().unwrap();
+ // A *different* edit, returning to the same history depth.
+ let t = Transaction::insert(doc.text(), doc.selection(), "Y");
+ doc.apply(&t).unwrap();
+
+ assert!(
+ doc.is_modified(),
+ "content differs from the file despite equal history depth"
+ );
+ assert_eq!(doc.text().to_string(), "Yabc");
+ }
+
+ #[test]
+ fn mjb_llr_137_successful_write_clears_modified() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ let mut doc = Document::open(&p).unwrap();
+
+ let t = Transaction::insert(doc.text(), doc.selection(), "hello");
+ doc.apply(&t).unwrap();
+ assert!(doc.is_modified());
+
+ doc.save(false, true).unwrap();
+ assert!(!doc.is_modified());
+ assert_eq!(std::fs::read_to_string(&p).unwrap(), "hello\n");
+ }
+
+ #[test]
+ fn mjb_llr_117_apply_updates_text_and_records_history() {
+ let mut doc = doc("hello");
+ let t = Transaction::change(doc.text(), [(0, 5, Some("goodbye".into()))]);
+ doc.apply(&t).unwrap();
+ assert_eq!(doc.text().to_string(), "goodbye");
+ assert!(doc.history.can_undo());
+ }
+
+ #[test]
+ fn mjb_llr_052_undo_restores_previous_text() {
+ let mut doc = doc("hello");
+ let t = Transaction::change(doc.text(), [(0, 5, Some("goodbye".into()))]);
+ doc.apply(&t).unwrap();
+
+ assert!(doc.undo().unwrap());
+ assert_eq!(doc.text().to_string(), "hello");
+ }
+
+ #[test]
+ fn mjb_llr_053_redo_reapplies() {
+ let mut doc = doc("hello");
+ let t = Transaction::change(doc.text(), [(0, 5, Some("goodbye".into()))]);
+ doc.apply(&t).unwrap();
+ doc.undo().unwrap();
+
+ assert!(doc.redo().unwrap());
+ assert_eq!(doc.text().to_string(), "goodbye");
+ }
+
+ /// MJB-LLR-052: undoing past the start is a no-op, not an error.
+ #[test]
+ fn mjb_llr_052_undo_past_history_start_is_a_noop() {
+ let mut doc = doc("hello");
+ assert!(!doc.undo().unwrap(), "nothing to undo");
+ assert_eq!(doc.text().to_string(), "hello");
+ assert!(!doc.undo().unwrap(), "still nothing, still safe");
+ }
+
+ #[test]
+ fn mjb_llr_053_redo_past_end_is_a_noop() {
+ let mut doc = doc("hello");
+ assert!(!doc.redo().unwrap());
+ assert_eq!(doc.text().to_string(), "hello");
+ }
+
+ #[test]
+ fn multiple_undo_redo_cycles_are_stable() {
+ let mut doc = doc("");
+ for c in ["a", "b", "c"] {
+ let t = Transaction::insert(doc.text(), doc.selection(), c);
+ doc.apply(&t).unwrap();
+ let end = doc.text().len();
+ doc.set_range(Range::point(end));
+ }
+ assert_eq!(doc.text().to_string(), "abc");
+
+ for _ in 0..3 {
+ assert!(doc.undo().unwrap());
+ }
+ assert_eq!(doc.text().to_string(), "");
+
+ for _ in 0..3 {
+ assert!(doc.redo().unwrap());
+ }
+ assert_eq!(doc.text().to_string(), "abc");
+ }
+
+ #[test]
+ fn mjb_llr_185_final_newline_added_on_encode_when_requested() {
+ let doc = doc("no trailing newline");
+ assert!(doc.encode(true).ends_with(b"\n"));
+ assert!(!doc.encode(false).ends_with(b"\n"));
+ }
+
+ #[test]
+ fn mjb_llr_185_empty_document_encodes_empty() {
+ let doc = doc("");
+ assert!(doc.encode(true).is_empty(), "must not invent a newline");
+ }
+
+ #[test]
+ fn saving_without_a_path_is_an_error_not_a_panic() {
+ let mut doc = doc("x");
+ assert!(doc.save(false, true).is_err());
+ }
+}
diff --git a/src/buffer/encoding.rs b/src/buffer/encoding.rs
new file mode 100644
index 0000000..5cd852f
--- /dev/null
+++ b/src/buffer/encoding.rs
@@ -0,0 +1,272 @@
+//! Character encoding detection and transcoding (MJB-HLR-002).
+//!
+//! Two rules, the second an exception to the first:
+//!
+//! 1. **A file whose encoding a byte order mark declares is transcoded.** The
+//! encoding is known, so its bytes round-trip through load and save.
+//! 2. **UTF-8 is strict.** A file assumed or declared to be UTF-8 that holds an
+//! invalid byte sequence is *rejected*, not repaired.
+//!
+//! Rule 2 exists because substitution is lossy in a way the user cannot see:
+//! replacing a bad byte with U+FFFD and then saving writes the replacement
+//! character over their data. For a declared encoding we can at least reproduce
+//! what we read; for malformed UTF-8 we cannot, so refusing to open is the only
+//! non-destructive answer. See MJB-DR-001.
+
+use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE};
+
+/// MJB-LLR-118: why a file could not be decoded.
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum DecodeError {
+ #[error(
+ "not valid UTF-8 (invalid byte sequence at offset {valid_up_to}); \
+ mojibake edits text, and repairing the bytes would destroy them on save"
+ )]
+ InvalidUtf8 { valid_up_to: usize },
+}
+
+/// The encoding a document was loaded with, plus whether it carried a BOM.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct EncodingInfo {
+ pub encoding: &'static Encoding,
+ pub has_bom: bool,
+}
+
+impl Default for EncodingInfo {
+ fn default() -> Self {
+ Self {
+ encoding: UTF_8,
+ has_bom: false,
+ }
+ }
+}
+
+/// MJB-LLR-110: recognise a byte order mark, returning the encoding it implies
+/// and the mark's length in bytes.
+pub fn detect_bom(bytes: &[u8]) -> Option<(&'static Encoding, usize)> {
+ if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
+ Some((UTF_8, 3))
+ } else if bytes.starts_with(&[0xFF, 0xFE]) {
+ Some((UTF_16LE, 2))
+ } else if bytes.starts_with(&[0xFE, 0xFF]) {
+ Some((UTF_16BE, 2))
+ } else {
+ None
+ }
+}
+
+/// The byte order mark for `encoding`, if it has one.
+pub fn bom_bytes(encoding: &'static Encoding) -> &'static [u8] {
+ if encoding == UTF_8 {
+ &[0xEF, 0xBB, 0xBF]
+ } else if encoding == UTF_16LE {
+ &[0xFF, 0xFE]
+ } else if encoding == UTF_16BE {
+ &[0xFE, 0xFF]
+ } else {
+ &[]
+ }
+}
+
+/// MJB-LLR-111, MJB-LLR-113, MJB-LLR-118: decode `bytes` to a `String`.
+///
+/// UTF-8 — whether declared by a BOM or merely assumed — is validated strictly
+/// and rejected when malformed. Other BOM-declared encodings are transcoded.
+pub fn decode(bytes: &[u8]) -> Result<(String, EncodingInfo), DecodeError> {
+ let (encoding, has_bom, body) = match detect_bom(bytes) {
+ Some((encoding, bom_len)) => (encoding, true, &bytes[bom_len..]),
+ None => (UTF_8, false, bytes),
+ };
+
+ let text = if encoding == UTF_8 {
+ // MJB-LLR-118: the exception. `from_utf8` reports exactly how far the
+ // input was valid, which makes the diagnostic actionable.
+ std::str::from_utf8(body)
+ .map_err(|e| DecodeError::InvalidUtf8 {
+ valid_up_to: e.valid_up_to(),
+ })?
+ .to_owned()
+ } else {
+ // MJB-LLR-113: a declared non-UTF-8 encoding is transcoded. Its bytes
+ // round-trip on save, so any substitution here is reproducible.
+ encoding.decode_without_bom_handling(body).0.into_owned()
+ };
+
+ Ok((text, EncodingInfo { encoding, has_bom }))
+}
+
+/// MJB-LLR-115: encode `text` back to bytes, re-emitting the BOM when the
+/// document was loaded with one.
+///
+/// Total by construction: `text` is a `str`, and a document can only hold text
+/// that [`decode`] accepted, so there is nothing here that can fail.
+///
+/// UTF-16 is encoded here by hand rather than through `encoding_rs`.
+/// `Encoding::encode` is deliberately asymmetric: it decodes UTF-16 but will
+/// not *encode* to it, silently substituting UTF-8 instead. Delegating would
+/// therefore write UTF-8 bytes beneath a UTF-16 BOM and corrupt the file.
+/// See MJB-DR-007.
+pub fn encode(text: &str, info: EncodingInfo) -> Vec<u8> {
+ let mut out = Vec::with_capacity(text.len() + 3);
+ if info.has_bom {
+ out.extend_from_slice(bom_bytes(info.encoding));
+ }
+
+ if info.encoding == UTF_16LE {
+ for unit in text.encode_utf16() {
+ out.extend_from_slice(&unit.to_le_bytes());
+ }
+ } else if info.encoding == UTF_16BE {
+ for unit in text.encode_utf16() {
+ out.extend_from_slice(&unit.to_be_bytes());
+ }
+ } else {
+ let (bytes, _, _) = info.encoding.encode(text);
+ out.extend_from_slice(&bytes);
+ }
+
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn mjb_llr_110_detects_each_bom() {
+ assert_eq!(detect_bom(&[0xEF, 0xBB, 0xBF, b'a']), Some((UTF_8, 3)));
+ assert_eq!(detect_bom(&[0xFF, 0xFE, b'a', 0]), Some((UTF_16LE, 2)));
+ assert_eq!(detect_bom(&[0xFE, 0xFF, 0, b'a']), Some((UTF_16BE, 2)));
+ assert_eq!(detect_bom(b"plain"), None);
+ assert_eq!(detect_bom(b""), None, "empty input must not index past end");
+ }
+
+ #[test]
+ fn mjb_llr_111_plain_utf8_round_trips() {
+ let (text, info) = decode("hello 文字化け".as_bytes()).unwrap();
+ assert_eq!(text, "hello 文字化け");
+ assert!(!info.has_bom);
+ assert_eq!(encode(&text, info), "hello 文字化け".as_bytes());
+ }
+
+ /// MJB-LLR-115, MJB-DR-001: a BOM present on load reappears on save.
+ #[test]
+ fn mjb_llr_115_bom_round_trips() {
+ let mut input = vec![0xEF, 0xBB, 0xBF];
+ input.extend_from_slice(b"hi");
+
+ let (text, info) = decode(&input).unwrap();
+ assert_eq!(text, "hi", "BOM is stripped from the buffer contents");
+ assert!(info.has_bom);
+ assert_eq!(encode(&text, info), input, "and re-emitted on write");
+ }
+
+ /// MJB-LLR-115, MJB-DR-007: UTF-16 must survive a load/save cycle.
+ ///
+ /// Regression guard: `encoding_rs::Encoding::encode` substitutes UTF-8 for
+ /// UTF-16 rather than failing, so delegating to it here would write UTF-8
+ /// bytes under a UTF-16 BOM and corrupt the file.
+ #[test]
+ fn mjb_llr_115_utf16le_round_trips() {
+ // UTF-16LE BOM followed by "hi".
+ let input = vec![0xFF, 0xFE, b'h', 0x00, b'i', 0x00];
+ let (text, info) = decode(&input).unwrap();
+ assert_eq!(text, "hi");
+ assert_eq!(info.encoding, UTF_16LE);
+ assert_eq!(encode(&text, info), input, "must not degrade to UTF-8");
+ }
+
+ #[test]
+ fn mjb_llr_115_utf16be_round_trips() {
+ let input = vec![0xFE, 0xFF, 0x00, b'h', 0x00, b'i'];
+ let (text, info) = decode(&input).unwrap();
+ assert_eq!(text, "hi");
+ assert_eq!(info.encoding, UTF_16BE);
+ assert_eq!(encode(&text, info), input);
+ }
+
+ #[test]
+ fn mjb_llr_115_utf16_handles_non_ascii_and_surrogates() {
+ // 文 is BMP; 𝄞 (U+1D11E) needs a surrogate pair in UTF-16.
+ let original = "文𝄞";
+ let info = EncodingInfo {
+ encoding: UTF_16LE,
+ has_bom: true,
+ };
+ let bytes = encode(original, info);
+ let (back, _) = decode(&bytes).unwrap();
+ assert_eq!(back, original);
+ }
+
+ /// MJB-LLR-113: a *declared* non-UTF-8 encoding is transcoded, not
+ /// rejected. An unpaired surrogate is repaired, and that repair is
+ /// reproducible because the encoding is known.
+ #[test]
+ fn mjb_llr_113_declared_utf16_is_transcoded_not_rejected() {
+ // UTF-16LE BOM, then a lone high surrogate (0xD800) — not valid UTF-16.
+ let input = vec![0xFF, 0xFE, 0x00, 0xD8, b'a', 0x00];
+ let (text, info) = decode(&input).expect("a declared encoding must not be rejected");
+ assert_eq!(info.encoding, UTF_16LE);
+ assert!(
+ text.contains('\u{FFFD}'),
+ "the unpaired surrogate becomes U+FFFD, got {text:?}"
+ );
+ }
+
+ /// MJB-LLR-118: the UTF-8 exception. Invalid UTF-8 is rejected outright
+ /// rather than repaired, because a repair would be written back over the
+ /// user's data on save.
+ #[test]
+ fn mjb_llr_118_invalid_utf8_is_rejected() {
+ // 0xFF is never valid in UTF-8.
+ let err = decode(&[b'a', 0xFF, b'b']).unwrap_err();
+ assert_eq!(err, DecodeError::InvalidUtf8 { valid_up_to: 1 });
+ }
+
+ #[test]
+ fn mjb_llr_118_rejection_names_the_offset() {
+ let err = decode(&[b'h', b'i', 0x80]).unwrap_err();
+ assert_eq!(err, DecodeError::InvalidUtf8 { valid_up_to: 2 });
+ assert!(
+ err.to_string().contains('2'),
+ "the message must locate the bad byte, got: {err}"
+ );
+ }
+
+ /// A truncated multi-byte character is invalid UTF-8 too.
+ #[test]
+ fn mjb_llr_118_truncated_multibyte_char_is_rejected() {
+ // 文 is E6 96 87; drop the last byte.
+ let err = decode(&[0xE6, 0x96]).unwrap_err();
+ assert_eq!(err, DecodeError::InvalidUtf8 { valid_up_to: 0 });
+ }
+
+ /// MJB-LLR-118: a UTF-8 *BOM* does not license invalid bytes after it.
+ #[test]
+ fn mjb_llr_118_declared_utf8_is_strict_too() {
+ let input = vec![0xEF, 0xBB, 0xBF, b'a', 0xFF];
+ let err = decode(&input).unwrap_err();
+ assert_eq!(
+ err,
+ DecodeError::InvalidUtf8 { valid_up_to: 1 },
+ "offset is measured past the BOM"
+ );
+ }
+
+ /// Valid multi-byte UTF-8 must not be mistaken for invalid.
+ #[test]
+ fn mjb_llr_118_valid_multibyte_utf8_is_accepted() {
+ for s in ["文字化け", "e\u{0301}", "𝄞", "café", "", "\u{FFFD}"] {
+ let (text, _) = decode(s.as_bytes())
+ .unwrap_or_else(|e| panic!("{s:?} must decode, got {e}"));
+ assert_eq!(text, s);
+ }
+ }
+
+ #[test]
+ fn mjb_llr_113_empty_input_decodes_to_empty() {
+ let (text, info) = decode(b"").unwrap();
+ assert_eq!(text, "");
+ assert!(!info.has_bom);
+ }
+}
diff --git a/src/buffer/grapheme.rs b/src/buffer/grapheme.rs
new file mode 100644
index 0000000..8670853
--- /dev/null
+++ b/src/buffer/grapheme.rs
@@ -0,0 +1,325 @@
+//! Grapheme cluster boundaries and display width over a rope.
+//!
+//! Under byte indexing an offset can land inside a character or inside a
+//! grapheme cluster, so every cursor position the user can observe is snapped
+//! to a grapheme boundary here. See MJB-DR-002.
+//!
+//! `unicode_segmentation::GraphemeCursor` works over `&str` fragments and asks
+//! for more context when a cluster straddles a fragment edge; ropey's
+//! `chunk(byte_idx) -> (&str, chunk_start)` supplies exactly that, so clusters
+//! spanning chunk boundaries resolve correctly (MJB-LLR-022).
+
+use std::borrow::Cow;
+
+use ropey::RopeSlice;
+use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete};
+use unicode_width::UnicodeWidthStr;
+
+/// Columns a tab advances to. Fixed rather than configurable; a configurable
+/// tab stop would be a new requirement, not a derived one.
+pub const TAB_WIDTH: usize = 4;
+
+/// MJB-LLR-020: byte offset of the grapheme boundary preceding `byte_idx`,
+/// or 0 when there is none.
+pub fn prev_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> usize {
+ let len = slice.len();
+ let byte_idx = slice.floor_char_boundary(byte_idx.min(len));
+ if byte_idx == 0 {
+ return 0;
+ }
+
+ let mut cursor = GraphemeCursor::new(byte_idx, len, true);
+ let (mut chunk, mut chunk_start) = slice.chunk(byte_idx);
+
+ loop {
+ match cursor.prev_boundary(chunk, chunk_start) {
+ Ok(Some(n)) => return n,
+ Ok(None) => return 0,
+ Err(GraphemeIncomplete::PrevChunk) => {
+ // Step back one chunk and retry.
+ let (c, s) = slice.chunk(chunk_start.saturating_sub(1));
+ chunk = c;
+ chunk_start = s;
+ }
+ Err(GraphemeIncomplete::PreContext(n)) => {
+ let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1));
+ cursor.provide_context(ctx, ctx_start);
+ }
+ // The remaining variants cannot arise from prev_boundary with a
+ // cursor built over the whole slice; treat defensively as "no
+ // boundary found" rather than panicking (MJB-HLR-018).
+ Err(_) => return 0,
+ }
+ }
+}
+
+/// MJB-LLR-021: byte offset of the grapheme boundary following `byte_idx`,
+/// or `slice.len()` when there is none.
+pub fn next_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> usize {
+ let len = slice.len();
+ let byte_idx = slice.floor_char_boundary(byte_idx.min(len));
+ if byte_idx >= len {
+ return len;
+ }
+
+ let mut cursor = GraphemeCursor::new(byte_idx, len, true);
+ let (mut chunk, mut chunk_start) = slice.chunk(byte_idx);
+
+ loop {
+ match cursor.next_boundary(chunk, chunk_start) {
+ Ok(Some(n)) => return n,
+ Ok(None) => return len,
+ Err(GraphemeIncomplete::NextChunk) => {
+ let next_start = chunk_start + chunk.len();
+ if next_start >= len {
+ return len;
+ }
+ let (c, s) = slice.chunk(next_start);
+ chunk = c;
+ chunk_start = s;
+ }
+ Err(GraphemeIncomplete::PreContext(n)) => {
+ let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1));
+ cursor.provide_context(ctx, ctx_start);
+ }
+ Err(_) => return len,
+ }
+ }
+}
+
+/// MJB-LLR-023: whether `byte_idx` lies on a grapheme cluster boundary.
+pub fn is_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> bool {
+ let len = slice.len();
+ if byte_idx > len || !slice.is_char_boundary(byte_idx) {
+ return false;
+ }
+ if byte_idx == 0 || byte_idx == len {
+ return true;
+ }
+
+ let mut cursor = GraphemeCursor::new(byte_idx, len, true);
+ let (chunk, chunk_start) = slice.chunk(byte_idx);
+
+ loop {
+ match cursor.is_boundary(chunk, chunk_start) {
+ Ok(b) => return b,
+ Err(GraphemeIncomplete::PreContext(n)) => {
+ let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1));
+ cursor.provide_context(ctx, ctx_start);
+ }
+ Err(_) => return false,
+ }
+ }
+}
+
+/// The text in `byte_range` without allocating when it lies in one rope chunk.
+///
+/// Rendering asks for every grapheme on every visible line each frame, so the
+/// obvious `slice.chunks().collect::<String>()` would allocate once per cell
+/// per frame. A grapheme spans a chunk boundary only rarely, and only then is
+/// a copy made.
+pub fn grapheme_str(slice: RopeSlice<'_>, byte_range: std::ops::Range<usize>) -> Cow<'_, str> {
+ let sub = slice.slice(byte_range);
+ match sub.as_str() {
+ Some(s) => Cow::Borrowed(s),
+ None => Cow::Owned(sub.chunks().collect()),
+ }
+}
+
+/// MJB-LLR-024: terminal display width of one grapheme cluster.
+///
+/// A tab is width-dependent on where it starts, so callers pass the column it
+/// begins at. Control characters render as nothing and count zero.
+pub fn grapheme_width(grapheme: &str, at_column: usize) -> usize {
+ if grapheme == "\t" {
+ return TAB_WIDTH - (at_column % TAB_WIDTH);
+ }
+ if grapheme.chars().all(|c| c.is_control()) {
+ return 0;
+ }
+ UnicodeWidthStr::width(grapheme)
+}
+
+/// MJB-LLR-025: display column of `byte_idx` within `line`, accumulating
+/// grapheme widths rather than counting bytes.
+pub fn display_column(line: RopeSlice, byte_idx: usize) -> usize {
+ let limit = line.floor_char_boundary(byte_idx.min(line.len()));
+ let mut column = 0;
+ let mut pos = 0;
+
+ while pos < limit {
+ let next = next_grapheme_boundary(line, pos);
+ if next <= pos {
+ break;
+ }
+ let g = grapheme_str(line, pos..next.min(limit));
+ column += grapheme_width(&g, column);
+ pos = next;
+ }
+ column
+}
+
+/// Inverse of [`display_column`]: the byte offset within `line` whose display
+/// column is nearest to but not beyond `target_column`. Used to preserve the
+/// visual column across vertical motion (MJB-LLR-064).
+pub fn byte_at_display_column(line: RopeSlice, target_column: usize) -> usize {
+ let len = line.len();
+ let mut column = 0;
+ let mut pos = 0;
+
+ while pos < len && column < target_column {
+ let next = next_grapheme_boundary(line, pos);
+ if next <= pos {
+ break;
+ }
+ let g = grapheme_str(line, pos..next);
+ // A line terminator is not a landing position.
+ if g.starts_with('\n') || g.starts_with('\r') {
+ break;
+ }
+ column += grapheme_width(&g, column);
+ if column > target_column {
+ break;
+ }
+ pos = next;
+ }
+ pos
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+
+ #[test]
+ fn mjb_llr_020_prev_boundary_saturates_at_zero() {
+ let r = Rope::from_str("abc");
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 0), 0);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 1), 0);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 2);
+ }
+
+ #[test]
+ fn mjb_llr_021_next_boundary_saturates_at_end() {
+ let r = Rope::from_str("abc");
+ assert_eq!(next_grapheme_boundary(r.slice(..), 3), 3);
+ assert_eq!(next_grapheme_boundary(r.slice(..), 0), 1);
+ // Beyond the end must clamp rather than panic.
+ assert_eq!(next_grapheme_boundary(r.slice(..), 99), 3);
+ }
+
+ #[test]
+ fn mjb_llr_021_multibyte_advances_whole_char() {
+ // 文 is 3 bytes; a boundary must not land inside it.
+ let r = Rope::from_str("文字化け");
+ assert_eq!(next_grapheme_boundary(r.slice(..), 0), 3);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 0);
+ }
+
+ #[test]
+ fn mjb_llr_020_combining_mark_is_one_cluster() {
+ // "e" + U+0301 COMBINING ACUTE ACCENT is a single grapheme cluster.
+ let r = Rope::from_str("e\u{0301}x");
+ assert_eq!(next_grapheme_boundary(r.slice(..), 0), 3);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 0);
+ }
+
+ /// MJB-LLR-022: boundaries must resolve identically whether or not the
+ /// cluster straddles a rope chunk edge.
+ ///
+ /// A rope large enough to hold many chunks is built from multi-byte
+ /// characters, then every boundary is walked and compared against the
+ /// contiguous `&str` answer.
+ #[test]
+ fn mjb_llr_022_boundaries_resolve_across_chunk_edges() {
+ // Large enough to force ropey to split into multiple chunks.
+ let source: String = "文字化けe\u{0301}x".repeat(4000);
+ let r = Rope::from_str(&source);
+ let s = r.slice(..);
+ assert!(
+ s.chunks().count() > 1,
+ "test is meaningless without multiple chunks"
+ );
+
+ // Walk forward over the whole rope, comparing to unicode-segmentation
+ // over the contiguous string.
+ use unicode_segmentation::UnicodeSegmentation;
+ let expected: Vec<usize> = source
+ .grapheme_indices(true)
+ .map(|(i, _)| i)
+ .chain(std::iter::once(source.len()))
+ .collect();
+
+ let mut got = vec![0usize];
+ let mut pos = 0;
+ while pos < s.len() {
+ let next = next_grapheme_boundary(s, pos);
+ assert!(next > pos, "must make progress at byte {pos}");
+ got.push(next);
+ pos = next;
+ }
+ assert_eq!(got, expected, "forward boundaries must match across chunks");
+
+ // And backward, from the end.
+ let mut back = vec![s.len()];
+ let mut pos = s.len();
+ while pos > 0 {
+ let prev = prev_grapheme_boundary(s, pos);
+ assert!(prev < pos, "must make progress backward at byte {pos}");
+ back.push(prev);
+ pos = prev;
+ }
+ back.reverse();
+ assert_eq!(back, expected, "backward boundaries must match across chunks");
+ }
+
+ #[test]
+ fn mjb_llr_023_boundary_detection() {
+ let r = Rope::from_str("文a");
+ let s = r.slice(..);
+ assert!(is_grapheme_boundary(s, 0));
+ assert!(!is_grapheme_boundary(s, 1), "inside a multi-byte char");
+ assert!(is_grapheme_boundary(s, 3));
+ assert!(is_grapheme_boundary(s, 4));
+ }
+
+ #[test]
+ fn mjb_llr_024_widths() {
+ assert_eq!(grapheme_width("a", 0), 1);
+ assert_eq!(grapheme_width("文", 0), 2, "wide char occupies two columns");
+ assert_eq!(grapheme_width("\t", 0), TAB_WIDTH);
+ assert_eq!(grapheme_width("\t", 1), TAB_WIDTH - 1, "tab fills to stop");
+ assert_eq!(grapheme_width("\u{0}", 0), 0);
+ }
+
+ #[test]
+ fn mjb_llr_025_display_column_counts_width_not_bytes() {
+ let r = Rope::from_str("文字a");
+ // Byte 6 is after two wide chars: four columns, not six.
+ assert_eq!(display_column(r.slice(..), 6), 4);
+ assert_eq!(display_column(r.slice(..), 0), 0);
+ }
+
+ #[test]
+ fn mjb_llr_025_display_column_tab_expands() {
+ let r = Rope::from_str("\tx");
+ assert_eq!(display_column(r.slice(..), 1), TAB_WIDTH);
+ }
+
+ #[test]
+ fn byte_at_display_column_round_trips() {
+ let r = Rope::from_str("文字a");
+ let s = r.slice(..);
+ assert_eq!(byte_at_display_column(s, 4), 6);
+ assert_eq!(byte_at_display_column(s, 0), 0);
+ // Past the end of the line clamps to the line's length.
+ assert_eq!(byte_at_display_column(s, 99), s.len());
+ }
+
+ #[test]
+ fn byte_at_display_column_stops_before_terminator() {
+ let r = Rope::from_str("ab\n");
+ assert_eq!(byte_at_display_column(r.slice(..), 99), 2);
+ }
+}
diff --git a/src/buffer/history.rs b/src/buffer/history.rs
new file mode 100644
index 0000000..e4668fc
--- /dev/null
+++ b/src/buffer/history.rs
@@ -0,0 +1,228 @@
+//! Undo/redo history (MJB-HLR-011).
+//!
+//! Each committed edit stores the pair (forward transaction, inverse
+//! transaction). `cursor` is the number of entries currently *applied*, so
+//! entries at or beyond it have been reverted and are available to redo.
+//!
+//! Undo at the start and redo at the end are no-ops, not errors — reaching
+//! either end is ordinary use, not a fault (MJB-LLR-052, MJB-LLR-053).
+
+use super::transaction::Transaction;
+
+#[derive(Debug, Clone)]
+struct Entry {
+ forward: Transaction,
+ inverse: Transaction,
+ /// Identifies the buffer state this entry produces. Never reused.
+ id: usize,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct History {
+ entries: Vec<Entry>,
+ /// Number of entries applied; also the index of the next redo.
+ cursor: usize,
+ /// Monotonic source of entry ids.
+ ///
+ /// Deliberately *not* the same thing as `cursor`. Using stack depth to
+ /// identify a state is wrong: saving at depth 3, undoing, then making a
+ /// different edit returns to depth 3 while the content differs, so a
+ /// depth comparison would report the buffer clean when it is not.
+ next_id: usize,
+}
+
+impl History {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// MJB-LLR-051: record an applied edit.
+ ///
+ /// Anything previously undone is discarded — committing a new edit after an
+ /// undo abandons the branch that was reverted.
+ pub fn commit(&mut self, forward: Transaction, inverse: Transaction) {
+ self.entries.truncate(self.cursor);
+ self.next_id += 1;
+ self.entries.push(Entry {
+ forward,
+ inverse,
+ id: self.next_id,
+ });
+ self.cursor = self.entries.len();
+ }
+
+ /// MJB-LLR-052: the transaction that reverts the most recent edit, or
+ /// `None` when nothing remains to undo.
+ pub fn undo(&mut self) -> Option<&Transaction> {
+ if self.cursor == 0 {
+ return None;
+ }
+ self.cursor -= 1;
+ Some(&self.entries[self.cursor].inverse)
+ }
+
+ /// MJB-LLR-053: the transaction that reapplies the most recently undone
+ /// edit, or `None` when nothing remains to redo.
+ pub fn redo(&mut self) -> Option<&Transaction> {
+ if self.cursor >= self.entries.len() {
+ return None;
+ }
+ let t = &self.entries[self.cursor].forward;
+ self.cursor += 1;
+ Some(t)
+ }
+
+ /// Identifies the current buffer state.
+ ///
+ /// Zero means pristine — no edit applied. Otherwise it is the id of the
+ /// most recently applied entry. Two calls return the same value exactly
+ /// when the buffer content is the same, which is what lets "modified" be
+ /// *derived* rather than latched; see
+ /// [`super::document::Document::is_modified`].
+ pub fn revision(&self) -> usize {
+ match self.cursor {
+ 0 => 0,
+ n => self.entries[n - 1].id,
+ }
+ }
+
+ pub fn can_undo(&self) -> bool {
+ self.cursor > 0
+ }
+
+ pub fn can_redo(&self) -> bool {
+ self.cursor < self.entries.len()
+ }
+
+ pub fn len(&self) -> usize {
+ self.entries.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+ use crate::buffer::transaction::Transaction;
+
+ fn edit(text: &str, from: usize, to: usize, ins: Option<&str>) -> (Transaction, Transaction) {
+ let r = Rope::from_str(text);
+ let t = Transaction::change(&r, [(from, to, ins.map(str::to_owned))]);
+ let inv = Transaction::new(t.changes.invert(&r));
+ (t, inv)
+ }
+
+ #[test]
+ fn mjb_llr_052_undo_past_start_is_a_noop() {
+ let mut h = History::new();
+ assert!(h.undo().is_none());
+ assert!(!h.can_undo());
+ // Repeated attempts must stay safe, not underflow the cursor.
+ assert!(h.undo().is_none());
+ assert!(h.undo().is_none());
+ }
+
+ #[test]
+ fn mjb_llr_053_redo_past_end_is_a_noop() {
+ let mut h = History::new();
+ let (f, i) = edit("abc", 0, 1, None);
+ h.commit(f, i);
+ assert!(h.redo().is_none(), "nothing has been undone yet");
+ assert!(!h.can_redo());
+ }
+
+ #[test]
+ fn mjb_llr_051_commit_then_undo_then_redo() {
+ let mut h = History::new();
+ let (f, i) = edit("abc", 0, 1, None);
+ h.commit(f, i);
+
+ assert!(h.can_undo());
+ assert!(h.undo().is_some());
+ assert!(!h.can_undo());
+ assert!(h.can_redo());
+ assert!(h.redo().is_some());
+ assert!(!h.can_redo());
+ }
+
+ #[test]
+ fn mjb_llr_051_commit_after_undo_discards_the_redo_branch() {
+ let mut h = History::new();
+ let (f1, i1) = edit("abc", 0, 1, None);
+ let (f2, i2) = edit("bc", 0, 1, None);
+ h.commit(f1, i1);
+ h.commit(f2, i2);
+
+ h.undo();
+ assert!(h.can_redo());
+
+ let (f3, i3) = edit("bc", 1, 2, None);
+ h.commit(f3, i3);
+ assert!(!h.can_redo(), "the undone branch must be discarded");
+ assert_eq!(h.len(), 2);
+ }
+
+ /// MJB-LLR-116: a revision identifies *content*, not stack depth.
+ ///
+ /// Regression guard for the trap this replaced: save at depth 2, undo, then
+ /// make a different edit. The cursor returns to 2, but the buffer no longer
+ /// matches what was saved, so the revision must differ.
+ #[test]
+ fn mjb_llr_116_revision_is_not_stack_depth() {
+ let mut h = History::new();
+ let (f1, i1) = edit("abcdef", 0, 1, None);
+ let (f2, i2) = edit("bcdef", 0, 1, None);
+ h.commit(f1, i1);
+ h.commit(f2, i2);
+
+ let saved = h.revision();
+
+ h.undo();
+ assert_ne!(h.revision(), saved, "undo leaves a different state");
+
+ // A different second edit, landing at the same stack depth.
+ let (f3, i3) = edit("bcdef", 1, 2, None);
+ h.commit(f3, i3);
+ assert_eq!(h.len(), 2, "same depth as when we saved");
+ assert_ne!(
+ h.revision(),
+ saved,
+ "same depth, different content: must not look saved"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_116_revision_is_zero_when_pristine_and_returns_on_undo() {
+ let mut h = History::new();
+ assert_eq!(h.revision(), 0);
+
+ let (f, i) = edit("abc", 0, 1, None);
+ h.commit(f, i);
+ let after = h.revision();
+ assert_ne!(after, 0);
+
+ h.undo();
+ assert_eq!(h.revision(), 0, "undoing to pristine returns to revision 0");
+ h.redo();
+ assert_eq!(h.revision(), after, "redo restores the same revision");
+ }
+
+ #[test]
+ fn mjb_llr_052_undo_walks_back_in_order() {
+ let mut h = History::new();
+ for n in 0..3 {
+ let (f, i) = edit("abcdef", n, n + 1, None);
+ h.commit(f, i);
+ }
+ assert_eq!(h.len(), 3);
+ for _ in 0..3 {
+ assert!(h.undo().is_some());
+ }
+ assert!(h.undo().is_none(), "exhausted");
+ }
+}
diff --git a/src/buffer/keymap.rs b/src/buffer/keymap.rs
new file mode 100644
index 0000000..61009c3
--- /dev/null
+++ b/src/buffer/keymap.rs
@@ -0,0 +1,392 @@
+//! Modal keymap resolution (MJB-HLR-015).
+//!
+//! Replaces the application template's resolver, which looked up single keys
+//! first and otherwise accumulated a buffer cleared on every `Action::Tick` —
+//! giving multi-key sequences a ~250 ms timeout at the default tick rate, so
+//! `gg` failed if typed slowly. Here the set of proper prefixes is precomputed,
+//! so resolution is exact and **time-independent** (MJB-LLR-154).
+//!
+//! A static table cannot express everything a modal editor needs: insert mode
+//! must treat any unbound printable key as self-insert, and counts are typed as
+//! ordinary digits. Both are handled around the table rather than in it, by
+//! [`KeymapResult::Cancelled`] and by count accumulation.
+
+use std::collections::{HashMap, HashSet};
+
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+use super::command::Command;
+use crate::config::{KeyBindings, Mode};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum KeymapResult {
+ /// The keys so far are a prefix of at least one binding; wait for more.
+ Pending,
+ /// A binding matched, carrying any count typed before it.
+ Matched(Command, Option<usize>),
+ /// The keys match nothing. Carries what was accumulated so the caller can
+ /// apply a mode-specific fallback, such as insert-mode self-insert.
+ Cancelled(Vec<KeyEvent>),
+}
+
+#[derive(Debug, Default)]
+pub struct Keymap {
+ bindings: HashMap<Mode, HashMap<Vec<KeyEvent>, Command>>,
+ /// MJB-LLR-150: every proper prefix of every bound sequence, per mode.
+ prefixes: HashMap<Mode, HashSet<Vec<KeyEvent>>>,
+ pending: Vec<KeyEvent>,
+ count: Option<usize>,
+}
+
+impl Keymap {
+ /// MJB-LLR-150
+ pub fn new(bindings: &KeyBindings) -> Self {
+ let mut prefixes: HashMap<Mode, HashSet<Vec<KeyEvent>>> = HashMap::new();
+
+ for (mode, map) in &bindings.0 {
+ let set = prefixes.entry(*mode).or_default();
+ for keys in map.keys() {
+ for n in 1..keys.len() {
+ set.insert(keys[..n].to_vec());
+ }
+ }
+ }
+
+ Self {
+ bindings: bindings.0.clone(),
+ prefixes,
+ pending: Vec::new(),
+ count: None,
+ }
+ }
+
+ pub fn pending(&self) -> &[KeyEvent] {
+ &self.pending
+ }
+
+ pub fn count(&self) -> Option<usize> {
+ self.count
+ }
+
+ /// Abandon any partial sequence and count, e.g. on a mode change.
+ pub fn reset(&mut self) {
+ self.pending.clear();
+ self.count = None;
+ }
+
+ /// Look up a single key without disturbing pending state. Used by `App` for
+ /// the `Global` map, which has no multi-key bindings (MJB-LLR-203).
+ pub fn lookup_single(&self, mode: Mode, key: KeyEvent) -> Option<Command> {
+ self.bindings.get(&mode)?.get(&vec![key]).copied()
+ }
+
+ /// MJB-LLR-151..156: feed one key and resolve.
+ pub fn resolve(&mut self, mode: Mode, key: KeyEvent) -> KeymapResult {
+ // MJB-LLR-155: digits typed before a command form a count. Only while
+ // no sequence is pending, so `g` then `1` is not swallowed.
+ if self.pending.is_empty()
+ && matches!(mode, Mode::Normal | Mode::Select)
+ && let KeyCode::Char(c) = key.code
+ && c.is_ascii_digit()
+ && !key.modifiers.contains(KeyModifiers::CONTROL)
+ && !key.modifiers.contains(KeyModifiers::ALT)
+ {
+ let digit = (c as u8 - b'0') as usize;
+ // A leading zero is not a count; it stays available as a binding.
+ if digit != 0 || self.count.is_some() {
+ self.count = Some(self.count.unwrap_or(0) * 10 + digit);
+ return KeymapResult::Pending;
+ }
+ }
+
+ self.pending.push(key);
+
+ // MJB-LLR-151
+ if let Some(&cmd) = self.bindings.get(&mode).and_then(|m| m.get(&self.pending)) {
+ let count = self.count.take();
+ self.pending.clear();
+ return KeymapResult::Matched(cmd, count);
+ }
+
+ // MJB-LLR-152
+ if self
+ .prefixes
+ .get(&mode)
+ .is_some_and(|set| set.contains(&self.pending))
+ {
+ return KeymapResult::Pending;
+ }
+
+ // MJB-LLR-153
+ let keys = std::mem::take(&mut self.pending);
+ self.count = None;
+ KeymapResult::Cancelled(keys)
+ }
+}
+
+/// MJB-LLR-156: the insert-mode fallback — a bare printable character.
+///
+/// Control and Alt are excluded so an unbound `Ctrl-x` is discarded rather than
+/// inserting `x`. Shift is allowed: it is how capitals are typed.
+pub fn self_insert_char(keys: &[KeyEvent]) -> Option<char> {
+ let [key] = keys else {
+ return None;
+ };
+ let KeyCode::Char(c) = key.code else {
+ return None;
+ };
+ if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::ALT) {
+ return None;
+ }
+ Some(c)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::parse_key_sequence;
+
+ fn key(c: char) -> KeyEvent {
+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty())
+ }
+
+ fn ctrl(c: char) -> KeyEvent {
+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
+ }
+
+ fn keymap() -> Keymap {
+ let mut bindings = KeyBindings::default();
+ let mut normal = HashMap::new();
+ normal.insert(parse_key_sequence("<h>").unwrap(), Command::MoveCharLeft);
+ normal.insert(parse_key_sequence("<g><g>").unwrap(), Command::GotoFileStart);
+ normal.insert(parse_key_sequence("<g><e>").unwrap(), Command::GotoLastLine);
+ bindings.0.insert(Mode::Normal, normal);
+
+ let mut insert = HashMap::new();
+ insert.insert(parse_key_sequence("<esc>").unwrap(), Command::NormalMode);
+ bindings.0.insert(Mode::Insert, insert);
+
+ Keymap::new(&bindings)
+ }
+
+ /// MJB-LLR-150: every proper prefix is precomputed, and only proper
+ /// prefixes — a complete binding is not itself registered as a prefix, or
+ /// it would never resolve.
+ #[test]
+ fn mjb_llr_150_proper_prefixes_are_precomputed() {
+ let k = keymap();
+ let set = k.prefixes.get(&Mode::Normal).expect("normal prefixes");
+
+ assert!(
+ set.contains(&parse_key_sequence("<g>").unwrap()),
+ "`g` is a proper prefix of `gg` and `ge`"
+ );
+ assert!(
+ !set.contains(&parse_key_sequence("<g><g>").unwrap()),
+ "a complete binding must not be registered as a prefix"
+ );
+ assert!(
+ !set.contains(&parse_key_sequence("<h>").unwrap()),
+ "a single-key binding has no proper prefix"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_150_prefix_set_is_empty_for_a_mode_without_sequences() {
+ let k = keymap();
+ let set = k.prefixes.get(&Mode::Insert).expect("insert prefixes");
+ assert!(set.is_empty(), "insert has only single-key bindings");
+ }
+
+ #[test]
+ fn mjb_llr_151_single_key_binding_matches_immediately() {
+ let mut k = keymap();
+ assert_eq!(
+ k.resolve(Mode::Normal, key('h')),
+ KeymapResult::Matched(Command::MoveCharLeft, None)
+ );
+ assert!(k.pending().is_empty(), "pending must be cleared");
+ }
+
+ /// MJB-LLR-152, MJB-LLR-154: `g` is a prefix, so it waits — indefinitely,
+ /// with no timer involved. This is the bug the old resolver had.
+ #[test]
+ fn mjb_llr_152_prefix_key_waits_for_more() {
+ let mut k = keymap();
+ assert_eq!(k.resolve(Mode::Normal, key('g')), KeymapResult::Pending);
+ assert_eq!(k.pending().len(), 1);
+ }
+
+ #[test]
+ fn mjb_llr_151_two_key_sequence_resolves() {
+ let mut k = keymap();
+ assert_eq!(k.resolve(Mode::Normal, key('g')), KeymapResult::Pending);
+ assert_eq!(
+ k.resolve(Mode::Normal, key('g')),
+ KeymapResult::Matched(Command::GotoFileStart, None)
+ );
+ }
+
+ #[test]
+ fn mjb_llr_151_sequences_sharing_a_prefix_stay_distinct() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('g'));
+ assert_eq!(
+ k.resolve(Mode::Normal, key('e')),
+ KeymapResult::Matched(Command::GotoLastLine, None)
+ );
+ }
+
+ #[test]
+ fn mjb_llr_153_unknown_continuation_cancels() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('g'));
+ let got = k.resolve(Mode::Normal, key('z'));
+ match got {
+ KeymapResult::Cancelled(keys) => assert_eq!(keys, vec![key('g'), key('z')]),
+ other => panic!("expected Cancelled, got {other:?}"),
+ }
+ assert!(k.pending().is_empty());
+ }
+
+ #[test]
+ fn mjb_llr_153_unbound_key_cancels_immediately() {
+ let mut k = keymap();
+ match k.resolve(Mode::Normal, key('z')) {
+ KeymapResult::Cancelled(keys) => assert_eq!(keys, vec![key('z')]),
+ other => panic!("expected Cancelled, got {other:?}"),
+ }
+ }
+
+ /// MJB-LLR-154: no elapsed-time input exists, so a pending sequence
+ /// survives arbitrarily many unrelated resolutions in other modes.
+ #[test]
+ fn mjb_llr_154_pending_is_not_discarded_by_time() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('g'));
+ assert_eq!(k.pending().len(), 1);
+ // Nothing but another key can advance or clear it.
+ assert_eq!(
+ k.resolve(Mode::Normal, key('g')),
+ KeymapResult::Matched(Command::GotoFileStart, None)
+ );
+ }
+
+ #[test]
+ fn mjb_llr_155_count_accumulates_and_is_delivered() {
+ let mut k = keymap();
+ assert_eq!(k.resolve(Mode::Normal, key('1')), KeymapResult::Pending);
+ assert_eq!(k.resolve(Mode::Normal, key('2')), KeymapResult::Pending);
+ assert_eq!(
+ k.resolve(Mode::Normal, key('h')),
+ KeymapResult::Matched(Command::MoveCharLeft, Some(12))
+ );
+ }
+
+ #[test]
+ fn mjb_llr_155_count_is_consumed_once() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('3'));
+ k.resolve(Mode::Normal, key('h'));
+ assert_eq!(
+ k.resolve(Mode::Normal, key('h')),
+ KeymapResult::Matched(Command::MoveCharLeft, None),
+ "the count must not persist to the next command"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_155_leading_zero_is_not_a_count() {
+ let mut k = keymap();
+ // `0` with no count in progress falls through to binding lookup.
+ match k.resolve(Mode::Normal, key('0')) {
+ KeymapResult::Cancelled(_) => {}
+ other => panic!("expected Cancelled for unbound 0, got {other:?}"),
+ }
+ assert_eq!(k.count(), None);
+ }
+
+ #[test]
+ fn mjb_llr_155_zero_extends_an_existing_count() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('1'));
+ k.resolve(Mode::Normal, key('0'));
+ assert_eq!(
+ k.resolve(Mode::Normal, key('h')),
+ KeymapResult::Matched(Command::MoveCharLeft, Some(10))
+ );
+ }
+
+ #[test]
+ fn mjb_llr_155_digits_are_not_counts_in_insert_mode() {
+ let mut k = keymap();
+ match k.resolve(Mode::Insert, key('5')) {
+ KeymapResult::Cancelled(keys) => {
+ assert_eq!(self_insert_char(&keys), Some('5'), "must type a 5");
+ }
+ other => panic!("expected Cancelled, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn mjb_llr_156_self_insert_accepts_plain_printables() {
+ assert_eq!(self_insert_char(&[key('a')]), Some('a'));
+ assert_eq!(
+ self_insert_char(&[KeyEvent::new(
+ KeyCode::Char('A'),
+ KeyModifiers::SHIFT
+ )]),
+ Some('A'),
+ "shift is how capitals are typed"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_156_self_insert_rejects_control_and_alt() {
+ assert_eq!(self_insert_char(&[ctrl('x')]), None);
+ assert_eq!(
+ self_insert_char(&[KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)]),
+ None
+ );
+ }
+
+ #[test]
+ fn mjb_llr_156_self_insert_rejects_non_char_and_sequences() {
+ assert_eq!(
+ self_insert_char(&[KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())]),
+ None
+ );
+ assert_eq!(
+ self_insert_char(&[key('a'), key('b')]),
+ None,
+ "only a single key can self-insert"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_203_lookup_single_does_not_disturb_pending() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('g'));
+ assert_eq!(k.lookup_single(Mode::Normal, key('h')), Some(Command::MoveCharLeft));
+ assert_eq!(k.pending().len(), 1, "global lookup must be side-effect free");
+ }
+
+ #[test]
+ fn reset_clears_pending_and_count() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('3'));
+ k.resolve(Mode::Normal, key('g'));
+ k.reset();
+ assert!(k.pending().is_empty());
+ assert_eq!(k.count(), None);
+ }
+
+ #[test]
+ fn unknown_mode_cancels_rather_than_panicking() {
+ let mut k = keymap();
+ match k.resolve(Mode::Command, key('x')) {
+ KeymapResult::Cancelled(_) => {}
+ other => panic!("expected Cancelled for an unmapped mode, got {other:?}"),
+ }
+ }
+}
diff --git a/src/buffer/line_ending.rs b/src/buffer/line_ending.rs
new file mode 100644
index 0000000..42459de
--- /dev/null
+++ b/src/buffer/line_ending.rs
@@ -0,0 +1,144 @@
+//! Line ending detection and normalisation (MJB-HLR-003).
+//!
+//! The rope stores LF internally regardless of what the file used; the original
+//! terminator is recorded and reapplied on write, so opening and saving a CRLF
+//! file does not silently rewrite every line.
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum LineEnding {
+ #[default]
+ Lf,
+ Crlf,
+ Cr,
+}
+
+impl LineEnding {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ LineEnding::Lf => "\n",
+ LineEnding::Crlf => "\r\n",
+ LineEnding::Cr => "\r",
+ }
+ }
+
+ /// The platform default, used when a buffer contains no terminator at all.
+ pub fn platform_default() -> Self {
+ if cfg!(windows) {
+ LineEnding::Crlf
+ } else {
+ LineEnding::Lf
+ }
+ }
+
+ /// MJB-LLR-114: the line ending of the first terminator present.
+ ///
+ /// Takes `&str` rather than a `RopeSlice`: detection runs on freshly
+ /// decoded text before the rope is built, and accepting a rope would force
+ /// the caller to construct one purely to answer this question.
+ ///
+ /// Scans bytes, not chars. CR and LF are ASCII and cannot appear as a
+ /// continuation byte of a multi-byte sequence, so a byte scan is both
+ /// correct and free of UTF-8 decoding.
+ pub fn detect(text: &str) -> Self {
+ match text.as_bytes().iter().position(|&b| b == b'\n' || b == b'\r') {
+ Some(i) if text.as_bytes()[i] == b'\n' => LineEnding::Lf,
+ // A CR followed by LF is CRLF; a CR followed by anything else, or
+ // by nothing at all, stood alone.
+ Some(i) if text.as_bytes().get(i + 1) == Some(&b'\n') => LineEnding::Crlf,
+ Some(_) => LineEnding::Cr,
+ // MJB-LLR-114: no terminator anywhere.
+ None => Self::platform_default(),
+ }
+ }
+}
+
+/// Rewrite every terminator in `text` (assumed LF-normalised) as `ending`.
+pub fn apply(text: &str, ending: LineEnding) -> String {
+ match ending {
+ LineEnding::Lf => text.to_owned(),
+ LineEnding::Crlf => text.replace('\n', "\r\n"),
+ LineEnding::Cr => text.replace('\n', "\r"),
+ }
+}
+
+/// Normalise CRLF and lone CR to LF for storage in the rope.
+pub fn normalize(text: &str) -> String {
+ if !text.contains('\r') {
+ return text.to_owned();
+ }
+ text.replace("\r\n", "\n").replace('\r', "\n")
+}
+
+/// Append a terminator when `text` is non-empty and lacks one (MJB-LLR-185).
+pub fn with_final_newline(text: &str) -> String {
+ if text.is_empty() || text.ends_with('\n') {
+ text.to_owned()
+ } else {
+ format!("{text}\n")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn detect(s: &str) -> LineEnding {
+ LineEnding::detect(s)
+ }
+
+ #[test]
+ fn mjb_llr_114_detects_lf() {
+ assert_eq!(detect("a\nb\n"), LineEnding::Lf);
+ }
+
+ #[test]
+ fn mjb_llr_114_detects_crlf() {
+ assert_eq!(detect("a\r\nb\r\n"), LineEnding::Crlf);
+ }
+
+ #[test]
+ fn mjb_llr_114_detects_lone_cr() {
+ assert_eq!(detect("a\rb\r"), LineEnding::Cr);
+ }
+
+ #[test]
+ fn mjb_llr_114_falls_back_to_platform_default() {
+ assert_eq!(detect("no terminator"), LineEnding::platform_default());
+ assert_eq!(detect(""), LineEnding::platform_default());
+ }
+
+ #[test]
+ fn mjb_llr_114_first_terminator_decides() {
+ // Mixed endings: the first one wins, as documented.
+ assert_eq!(detect("a\nb\r\n"), LineEnding::Lf);
+ assert_eq!(detect("a\r\nb\n"), LineEnding::Crlf);
+ }
+
+ #[test]
+ fn normalize_collapses_to_lf() {
+ assert_eq!(normalize("a\r\nb\r\n"), "a\nb\n");
+ assert_eq!(normalize("a\rb\r"), "a\nb\n");
+ assert_eq!(normalize("a\nb\n"), "a\nb\n");
+ }
+
+ #[test]
+ fn mjb_llr_115_apply_restores_original_ending() {
+ assert_eq!(apply("a\nb\n", LineEnding::Crlf), "a\r\nb\r\n");
+ assert_eq!(apply("a\nb\n", LineEnding::Cr), "a\rb\r");
+ assert_eq!(apply("a\nb\n", LineEnding::Lf), "a\nb\n");
+ }
+
+ #[test]
+ fn crlf_round_trips_through_normalize_and_apply() {
+ let original = "one\r\ntwo\r\nthree\r\n";
+ let stored = normalize(original);
+ assert_eq!(apply(&stored, LineEnding::Crlf), original);
+ }
+
+ #[test]
+ fn mjb_llr_185_final_newline_only_added_when_missing() {
+ assert_eq!(with_final_newline("a"), "a\n");
+ assert_eq!(with_final_newline("a\n"), "a\n", "not doubled");
+ assert_eq!(with_final_newline(""), "", "empty buffer stays empty");
+ }
+}
diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs
new file mode 100644
index 0000000..6924423
--- /dev/null
+++ b/src/buffer/mod.rs
@@ -0,0 +1,615 @@
+//! The buffer core: document model, motions, viewport, and input handling.
+//!
+//! Deliberately free of `ratatui` so it can be exercised by requirements-based
+//! tests without a terminal; the rendering half lives in
+//! [`crate::components::buffer`].
+//!
+//! Developed to DO-178C DAL-C. Items implementing a low-level requirement carry
+//! a `MJB-LLR-nnn` comment; see `docs/requirements/llr.md`.
+
+pub mod command;
+pub mod document;
+pub mod encoding;
+pub mod grapheme;
+pub mod history;
+pub mod keymap;
+pub mod line_ending;
+pub mod movement;
+pub mod save;
+pub mod selection;
+pub mod transaction;
+pub mod view;
+
+use std::path::PathBuf;
+
+use crossterm::event::KeyEvent;
+use ropey::{LineType, RopeSlice};
+
+use self::{
+ command::Command,
+ document::{Document, DocumentError},
+ keymap::{Keymap, KeymapResult, self_insert_char},
+ movement::{WordTarget, word_move},
+ selection::{Range, Selection},
+ transaction::Transaction,
+ view::View,
+};
+use crate::config::{Config, Mode};
+
+/// The line-break convention. `LF_CR` is what ropey enables by default, and
+/// recognises LF, CR and CRLF — matching the endings [`line_ending`] detects.
+pub const LINE_TYPE: LineType = LineType::LF_CR;
+
+/// What the caller should do after a key was handled.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Outcome {
+ /// Handled internally; nothing for the application to do.
+ Consumed,
+ /// The user asked to quit.
+ Quit,
+ /// The user asked to suspend.
+ Suspend,
+}
+
+/// The editor state: one document, one viewport, one mode.
+pub struct Buffer {
+ pub document: Document,
+ pub view: View,
+ pub mode: Mode,
+ keymap: Keymap,
+ config: Config,
+ /// Command-line contents while in [`Mode::Command`].
+ pub command_line: String,
+ /// Transient message shown on the status line.
+ pub status: Option<String>,
+ /// Viewport height last rendered, needed by paging commands.
+ pub last_height: usize,
+ pub last_width: usize,
+}
+
+impl Buffer {
+ pub fn new(config: Config, path: Option<PathBuf>) -> Result<Self, DocumentError> {
+ // MJB-LLR-111, MJB-LLR-112
+ let document = match path {
+ Some(p) => Document::open(&p)?,
+ None => Document::empty(None),
+ };
+ let keymap = Keymap::new(&config.keybindings);
+
+ Ok(Self {
+ document,
+ view: View::new(),
+ mode: Mode::Normal,
+ keymap,
+ config,
+ command_line: String::new(),
+ status: None,
+ last_height: 0,
+ last_width: 0,
+ })
+ }
+
+ pub fn config(&self) -> &Config {
+ &self.config
+ }
+
+ /// Pending keys, for the status line.
+ pub fn pending_keys(&self) -> &[KeyEvent] {
+ self.keymap.pending()
+ }
+
+ /// Route one key according to the current mode.
+ pub fn handle_key(&mut self, key: KeyEvent) -> Outcome {
+ self.status = None;
+
+ // MJB-LLR-158: command mode is a line editor, not a keymap consumer.
+ // Its three bindings still resolve so Esc/Enter/Backspace stay
+ // configurable, but anything else types into the line.
+ if self.mode == Mode::Command {
+ return self.handle_command_mode_key(key);
+ }
+
+ match self.keymap.resolve(self.mode, key) {
+ KeymapResult::Pending => Outcome::Consumed,
+ KeymapResult::Matched(cmd, count) => self.execute(cmd, count.unwrap_or(1)),
+ KeymapResult::Cancelled(keys) => {
+ // MJB-LLR-156: insert mode types the character; every other
+ // mode discards it.
+ if self.mode == Mode::Insert
+ && let Some(c) = self_insert_char(&keys)
+ {
+ self.insert_char(c);
+ }
+ Outcome::Consumed
+ }
+ }
+ }
+
+ fn handle_command_mode_key(&mut self, key: KeyEvent) -> Outcome {
+ use crossterm::event::{KeyCode, KeyModifiers};
+
+ match self.keymap.resolve(self.mode, key) {
+ KeymapResult::Matched(Command::NormalMode, _) => {
+ self.command_line.clear();
+ self.set_mode(Mode::Normal);
+ Outcome::Consumed
+ }
+ KeymapResult::Matched(Command::CommandSubmit, _) => {
+ let line = std::mem::take(&mut self.command_line);
+ self.set_mode(Mode::Normal);
+ self.run_command_line(&line)
+ }
+ KeymapResult::Matched(Command::CommandBackspace, _) => {
+ if self.command_line.pop().is_none() {
+ // Backspacing an empty line leaves command mode, as Helix does.
+ self.set_mode(Mode::Normal);
+ }
+ Outcome::Consumed
+ }
+ _ => {
+ if let KeyCode::Char(c) = key.code
+ && !key.modifiers.contains(KeyModifiers::CONTROL)
+ && !key.modifiers.contains(KeyModifiers::ALT)
+ {
+ self.command_line.push(c);
+ }
+ Outcome::Consumed
+ }
+ }
+ }
+
+ /// MJB-LLR-159, MJB-LLR-160: parse and run a command line.
+ pub fn run_command_line(&mut self, line: &str) -> Outcome {
+ let line = line.trim();
+ let (name, _arg) = match line.split_once(char::is_whitespace) {
+ Some((n, a)) => (n, Some(a.trim())),
+ None => (line, None),
+ };
+
+ let insert_final_newline = self.config.editor.insert_final_newline;
+
+ match name {
+ "" => Outcome::Consumed,
+
+ // MJB-LLR-159
+ "w" | "write" => {
+ self.save(false, insert_final_newline);
+ Outcome::Consumed
+ }
+ "w!" | "write!" => {
+ self.save(true, insert_final_newline);
+ Outcome::Consumed
+ }
+
+ // MJB-LLR-160
+ "q" | "quit" => {
+ if self.document.is_modified() {
+ self.status =
+ Some("unsaved changes (use :q! to discard, :wq to save)".to_owned());
+ Outcome::Consumed
+ } else {
+ Outcome::Quit
+ }
+ }
+ "q!" | "quit!" => Outcome::Quit,
+
+ "wq" | "x" | "write-quit" => {
+ if self.save(false, insert_final_newline) {
+ Outcome::Quit
+ } else {
+ Outcome::Consumed
+ }
+ }
+
+ other => {
+ // MJB-LLR-159: report, do not terminate.
+ self.status = Some(format!("unknown command: {other}"));
+ Outcome::Consumed
+ }
+ }
+ }
+
+ /// Returns whether the write succeeded.
+ fn save(&mut self, force: bool, insert_final_newline: bool) -> bool {
+ match self.document.save(force, insert_final_newline) {
+ Ok(()) => {
+ let name = self
+ .document
+ .path()
+ .map(|p| p.display().to_string())
+ .unwrap_or_else(|| "[no name]".to_owned());
+ self.status = Some(format!("wrote {name}"));
+ true
+ }
+ Err(e) => {
+ self.status = Some(e.to_string());
+ false
+ }
+ }
+ }
+
+ fn set_mode(&mut self, mode: Mode) {
+ if self.mode != mode {
+ self.mode = mode;
+ // A half-typed sequence must not survive a mode change.
+ self.keymap.reset();
+ }
+ }
+
+ fn insert_char(&mut self, c: char) {
+ let mut s = [0u8; 4];
+ self.insert_text(c.encode_utf8(&mut s));
+ }
+
+ fn insert_text(&mut self, text: &str) {
+ let t = Transaction::insert(self.document.text(), self.document.selection(), text);
+ let at = self.document.range().cursor(self.document.slice());
+ if self.apply(&t) {
+ // Cursor advances past what was inserted.
+ self.document.set_range(Range::point(at + text.len()));
+ }
+ }
+
+ fn apply(&mut self, t: &Transaction) -> bool {
+ match self.document.apply(t) {
+ Ok(()) => true,
+ Err(e) => {
+ self.status = Some(e.to_string());
+ false
+ }
+ }
+ }
+
+ /// Execute one command. `count` is at least 1.
+ pub fn execute(&mut self, cmd: Command, count: usize) -> Outcome {
+ use Command::*;
+
+ let count = count.max(1);
+ let extend = self.mode == Mode::Select;
+
+ match cmd {
+ Quit => return Outcome::Quit,
+ Suspend => return Outcome::Suspend,
+
+ // --- Modes ---
+ NormalMode => self.set_mode(Mode::Normal),
+ InsertMode => {
+ // MJB-LLR-009: `i` inserts before the selection.
+ let from = self.document.range().from();
+ self.document.set_range(Range::point(from));
+ self.set_mode(Mode::Insert);
+ }
+ SelectMode => {
+ self.set_mode(if self.mode == Mode::Select {
+ Mode::Normal
+ } else {
+ Mode::Select
+ });
+ }
+ CommandMode => {
+ self.command_line.clear();
+ self.set_mode(Mode::Command);
+ }
+
+ // --- Motion (MJB-HLR-006) ---
+ MoveCharLeft => self.motion(extend, |t, r| movement::move_char_left(t, r, count)),
+ MoveCharRight => self.motion(extend, |t, r| movement::move_char_right(t, r, count)),
+ MoveLineUp => self.motion(extend, |t, r| movement::move_vertically(t, r, count, false)),
+ MoveLineDown => self.motion(extend, |t, r| movement::move_vertically(t, r, count, true)),
+ ExtendCharLeft => self.motion(true, |t, r| movement::move_char_left(t, r, count)),
+ ExtendCharRight => self.motion(true, |t, r| movement::move_char_right(t, r, count)),
+ ExtendLineUp => self.motion(true, |t, r| movement::move_vertically(t, r, count, false)),
+ ExtendLineDown => self.motion(true, |t, r| movement::move_vertically(t, r, count, true)),
+
+ // --- Word motion; these leave selections (MJB-HLR-007) ---
+ MoveNextWordStart => self.word(count, WordTarget::NextStart, false),
+ MovePrevWordStart => self.word(count, WordTarget::PrevStart, false),
+ MoveNextWordEnd => self.word(count, WordTarget::NextEnd, false),
+ MoveNextLongWordStart => self.word(count, WordTarget::NextStart, true),
+ MovePrevLongWordStart => self.word(count, WordTarget::PrevStart, true),
+ MoveNextLongWordEnd => self.word(count, WordTarget::NextEnd, true),
+ ExtendNextWordStart => self.word(count, WordTarget::NextStart, false),
+ ExtendPrevWordStart => self.word(count, WordTarget::PrevStart, false),
+ ExtendNextWordEnd => self.word(count, WordTarget::NextEnd, false),
+
+ // --- Goto (MJB-HLR-008) ---
+ GotoFileStart => {
+ let r = movement::goto_file_start(self.document.slice());
+ self.put(r, extend);
+ }
+ GotoLastLine => {
+ let r = movement::goto_last_line(self.document.slice());
+ self.put(r, extend);
+ }
+ GotoLineStart => {
+ let r = movement::goto_line_start(self.document.slice(), self.document.range());
+ self.put(r, extend);
+ }
+ GotoLineEnd => {
+ let r = movement::goto_line_end(self.document.slice(), self.document.range());
+ self.put(r, extend);
+ }
+ GotoFirstNonWhitespace => {
+ let r =
+ movement::goto_first_non_whitespace(self.document.slice(), self.document.range());
+ self.put(r, extend);
+ }
+
+ // --- Selection manipulation ---
+ ExtendLineBelow => self.extend_line_below(count),
+ CollapseSelection => {
+ let cursor = self.document.range().cursor(self.document.slice());
+ self.document.set_range(Range::point(cursor));
+ }
+ FlipSelections => {
+ let r = self.document.range().flipped();
+ self.document.set_range(r);
+ }
+ SelectAll => {
+ let len = self.document.text().len();
+ self.document.set_range(Range::new(0, len));
+ }
+
+ // --- Entering insert mode (MJB-HLR-009) ---
+ AppendMode => {
+ // `a` inserts after the selection. In Helix a bare cursor is a
+ // one-grapheme range, so appending lands *past* the grapheme
+ // under it; our empty range has to step forward explicitly to
+ // reproduce that.
+ let text = self.document.slice();
+ let r = self.document.range();
+ let at = if r.is_empty() {
+ grapheme::next_grapheme_boundary(text, r.cursor(text))
+ } else {
+ r.to()
+ };
+ self.document.set_range(Range::point(at));
+ self.set_mode(Mode::Insert);
+ }
+ InsertAtLineStart => {
+ let r =
+ movement::goto_first_non_whitespace(self.document.slice(), self.document.range());
+ self.document.set_range(Range::point(r.head));
+ self.set_mode(Mode::Insert);
+ }
+ InsertAtLineEnd => {
+ let r = movement::goto_line_end(self.document.slice(), self.document.range());
+ self.document.set_range(Range::point(r.head));
+ self.set_mode(Mode::Insert);
+ }
+ OpenBelow => self.open_line(false),
+ OpenAbove => self.open_line(true),
+
+ // --- Modification (MJB-HLR-010) ---
+ DeleteSelection => self.delete_selection(),
+ ChangeSelection => {
+ self.delete_selection();
+ self.set_mode(Mode::Insert);
+ }
+ InsertNewline => self.insert_text("\n"),
+ InsertTab => self.insert_text("\t"),
+ DeleteCharBackward => self.delete_char_backward(),
+ DeleteCharForward => self.delete_char_forward(),
+ DeleteWordBackward => self.delete_word_backward(),
+ KillToLineStart => self.kill_to_line_start(),
+
+ // --- Undo / redo (MJB-HLR-011) ---
+ Undo => match self.document.undo() {
+ Ok(false) => self.status = Some("already at oldest change".to_owned()),
+ Ok(true) => {}
+ Err(e) => self.status = Some(e.to_string()),
+ },
+ Redo => match self.document.redo() {
+ Ok(false) => self.status = Some("already at newest change".to_owned()),
+ Ok(true) => {}
+ Err(e) => self.status = Some(e.to_string()),
+ },
+
+ // --- Paging (MJB-HLR-013) ---
+ PageCursorHalfUp => self.page(self.last_height / 2, false),
+ PageCursorHalfDown => self.page(self.last_height / 2, true),
+ PageUp => self.page(self.last_height, false),
+ PageDown => self.page(self.last_height, true),
+
+ // Handled by handle_command_mode_key; unreachable elsewhere but
+ // must not panic if a user binds them outside command mode.
+ CommandSubmit | CommandBackspace => {}
+ }
+
+ Outcome::Consumed
+ }
+
+ // --- helpers ---
+
+ /// Run a motion and install its result, extending the selection or
+ /// collapsing to a point per `extend`.
+ ///
+ /// Takes a closure rather than a function pointer so the direction and
+ /// count stay visible at the call site, instead of hiding behind a family
+ /// of near-identical adapter functions.
+ fn motion(&mut self, extend: bool, f: impl FnOnce(RopeSlice, Range) -> Range) {
+ let text = self.document.slice();
+ let r = f(text, self.document.range());
+ self.put(r, extend);
+ }
+
+ fn put(&mut self, target: Range, extend: bool) {
+ let text = self.document.slice();
+ let current = self.document.range();
+ let r = current.put_cursor(text, target.cursor(text), extend);
+ self.document.set_range(r);
+ }
+
+ /// MJB-LLR-065..067: word motions install the returned range directly,
+ /// because the range *is* the result — collapsing it would destroy the
+ /// selection-first behaviour that makes `wd` work.
+ fn word(&mut self, count: usize, target: WordTarget, long: bool) {
+ let text = self.document.slice();
+ let r = word_move(text, self.document.range(), count, target, long);
+ self.document.set_range(r);
+ }
+
+ /// Helix's `x`: select the current line; repeated, extend by one more.
+ fn extend_line_below(&mut self, count: usize) {
+ let text = self.document.slice();
+ let r = self.document.range();
+ let (start_line, end_line) = r.line_range(text);
+
+ let already_whole_line = r.from() == text.line_to_byte_idx(start_line, LINE_TYPE)
+ && r.to() == line_start_of_next(text, end_line);
+
+ let (first, last) = if already_whole_line {
+ (start_line, (end_line + count).min(last_line_index(text)))
+ } else {
+ (start_line, (end_line + count - 1).min(last_line_index(text)))
+ };
+
+ let from = text.line_to_byte_idx(first, LINE_TYPE);
+ let to = line_start_of_next(text, last);
+ self.document.set_range(Range::new(from, to));
+ }
+
+ fn open_line(&mut self, above: bool) {
+ let text = self.document.slice();
+ let line = self.document.range().cursor_line(text);
+
+ // `above` inserts the terminator at the line's start, so the blank line
+ // appears *at* that offset. `below` inserts it after the line's content
+ // — deliberately at the content end rather than at the next line's
+ // start, because a final line with no trailing newline has no next line
+ // to anchor to, and the blank line then lands one byte later.
+ let (at, cursor) = if above {
+ let start = text.line_to_byte_idx(line, LINE_TYPE);
+ (start, start)
+ } else {
+ let eol = movement::line_end_byte(text, line);
+ (eol, eol + 1)
+ };
+
+ let t = Transaction::change(self.document.text(), [(at, at, Some("\n".to_owned()))]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(cursor));
+ self.set_mode(Mode::Insert);
+ }
+ }
+
+ fn delete_selection(&mut self) {
+ let r = self.document.range();
+ if r.is_empty() {
+ // MJB-LLR-050 robustness: `d` with nothing selected deletes the
+ // grapheme under the cursor rather than doing nothing.
+ let text = self.document.slice();
+ let to = grapheme::next_grapheme_boundary(text, r.cursor(text));
+ if to == r.from() {
+ return;
+ }
+ let t = Transaction::change(self.document.text(), [(r.from(), to, None)]);
+ let from = r.from();
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ return;
+ }
+
+ let from = r.from();
+ let t = Transaction::delete(self.document.text(), self.document.selection());
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ }
+
+ fn delete_char_backward(&mut self) {
+ let text = self.document.slice();
+ let cursor = self.document.range().cursor(text);
+ let from = grapheme::prev_grapheme_boundary(text, cursor);
+ if from == cursor {
+ return; // at the start of the buffer
+ }
+ let t = Transaction::change(self.document.text(), [(from, cursor, None)]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ }
+
+ fn delete_char_forward(&mut self) {
+ let text = self.document.slice();
+ let cursor = self.document.range().cursor(text);
+ let to = grapheme::next_grapheme_boundary(text, cursor);
+ if to == cursor {
+ return; // at the end of the buffer
+ }
+ let t = Transaction::change(self.document.text(), [(cursor, to, None)]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(cursor));
+ }
+ }
+
+ fn delete_word_backward(&mut self) {
+ let text = self.document.slice();
+ let cursor = self.document.range().cursor(text);
+ if cursor == 0 {
+ return;
+ }
+ let target = word_move(text, Range::point(cursor), 1, WordTarget::PrevStart, false);
+ let from = target.from();
+ if from >= cursor {
+ return;
+ }
+ let t = Transaction::change(self.document.text(), [(from, cursor, None)]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ }
+
+ fn kill_to_line_start(&mut self) {
+ let text = self.document.slice();
+ let cursor = self.document.range().cursor(text);
+ let line = text.byte_to_line_idx(cursor, LINE_TYPE);
+ let from = text.line_to_byte_idx(line, LINE_TYPE);
+ if from >= cursor {
+ return;
+ }
+ let t = Transaction::change(self.document.text(), [(from, cursor, None)]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ }
+
+ fn page(&mut self, lines: usize, down: bool) {
+ if lines == 0 {
+ return;
+ }
+ let text = self.document.slice();
+ let r = self.view.page(text, self.document.range(), lines, down);
+ self.document.set_range(r);
+ }
+
+ /// Re-anchor the viewport for a viewport of `height` rows.
+ pub fn update_view(&mut self, width: usize, height: usize) {
+ self.last_width = width;
+ self.last_height = height;
+ let text = self.document.slice();
+ let range = self.document.range();
+ self.view
+ .ensure_cursor_in_view(text, range, height, self.config.editor.scrolloff);
+ self.view.ensure_horizontal_in_view(text, range, width);
+ }
+
+ /// Replace the whole selection state — used by tests and by `%`.
+ pub fn set_selection(&mut self, selection: Selection) {
+ self.document.set_selection(selection);
+ }
+}
+
+fn line_start_of_next(text: RopeSlice, line: usize) -> usize {
+ let total = text.len_lines(LINE_TYPE);
+ if line + 1 < total {
+ text.line_to_byte_idx(line + 1, LINE_TYPE)
+ } else {
+ text.len()
+ }
+}
+
+fn last_line_index(text: RopeSlice) -> usize {
+ text.len_lines(LINE_TYPE).saturating_sub(1)
+}
diff --git a/src/buffer/movement.rs b/src/buffer/movement.rs
new file mode 100644
index 0000000..8dc0ff7
--- /dev/null
+++ b/src/buffer/movement.rs
@@ -0,0 +1,592 @@
+//! Motions — after Helix's `helix-core/src/movement.rs`.
+//!
+//! The defining property, and the one most easily got wrong: **word motions
+//! return a selection, not a point**. Helix's keymap documents `w` as "move
+//! next word start", but `range_to_target` returns a `Range` whose anchor is
+//! the pre-motion position and whose head is the target. That is why `d` after
+//! `w` deletes a word with no operator-pending machinery anywhere
+//! (MJB-HLR-007, MJB-LLR-065..067).
+//!
+//! Implemented against `RopeSlice::char_indices_at`, which yields
+//! `(byte_idx, char)` and supports `prev()`, so both directions are byte-native
+//! rather than translated from char offsets.
+
+use ropey::RopeSlice;
+
+use super::{
+ LINE_TYPE,
+ grapheme::{byte_at_display_column, display_column, next_grapheme_boundary, prev_grapheme_boundary},
+ selection::Range,
+};
+
+/// MJB-LLR-060: character classes that word motions stop between.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CharCategory {
+ Eol,
+ Whitespace,
+ Word,
+ Punctuation,
+}
+
+/// MJB-LLR-060
+pub fn categorize_char(c: char) -> CharCategory {
+ if c == '\n' || c == '\r' {
+ CharCategory::Eol
+ } else if c.is_whitespace() {
+ CharCategory::Whitespace
+ } else if c.is_alphanumeric() || c == '_' {
+ CharCategory::Word
+ } else {
+ CharCategory::Punctuation
+ }
+}
+
+/// A coarser classification backing the long-word motions `W`/`B`/`E`, which
+/// treat punctuation as part of the word.
+fn categorize_long(c: char) -> CharCategory {
+ match categorize_char(c) {
+ CharCategory::Punctuation => CharCategory::Word,
+ other => other,
+ }
+}
+
+/// MJB-LLR-061
+pub fn is_word_boundary(a: char, b: char) -> bool {
+ categorize_char(a) != categorize_char(b)
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WordTarget {
+ NextStart,
+ NextEnd,
+ PrevStart,
+}
+
+fn categorizer(long: bool) -> fn(char) -> CharCategory {
+ if long { categorize_long } else { categorize_char }
+}
+
+/// MJB-LLR-062: one grapheme left, collapsing to a point.
+pub fn move_char_left(text: RopeSlice, range: Range, count: usize) -> Range {
+ let mut pos = range.cursor(text);
+ for _ in 0..count.max(1) {
+ let next = prev_grapheme_boundary(text, pos);
+ if next == pos {
+ break; // MJB-LLR-062: no-op at offset zero
+ }
+ pos = next;
+ }
+ Range::point(pos).clamped(text)
+}
+
+/// MJB-LLR-063: one grapheme right, collapsing to a point.
+pub fn move_char_right(text: RopeSlice, range: Range, count: usize) -> Range {
+ let mut pos = range.cursor(text);
+ for _ in 0..count.max(1) {
+ let next = next_grapheme_boundary(text, pos);
+ if next == pos {
+ break; // MJB-LLR-063: no-op at end of buffer
+ }
+ pos = next;
+ }
+ Range::point(pos).clamped(text)
+}
+
+/// MJB-LLR-064: vertical motion preserving the display column.
+pub fn move_vertically(text: RopeSlice, range: Range, count: usize, down: bool) -> Range {
+ let cursor = range.cursor(text);
+ let line = text.byte_to_line_idx(cursor, LINE_TYPE);
+ let line_start = text.line_to_byte_idx(line, LINE_TYPE);
+ let column = display_column(text.line(line, LINE_TYPE), cursor - line_start);
+
+ let last_line = text.len_lines(LINE_TYPE).saturating_sub(1);
+ let target_line = if down {
+ line.saturating_add(count.max(1)).min(last_line)
+ } else {
+ line.saturating_sub(count.max(1))
+ };
+
+ // MJB-LLR-064: a no-op on the first or last line.
+ if target_line == line {
+ return range;
+ }
+
+ let target_start = text.line_to_byte_idx(target_line, LINE_TYPE);
+ let offset = byte_at_display_column(text.line(target_line, LINE_TYPE), column);
+ Range::point(target_start + offset).clamped(text)
+}
+
+/// MJB-LLR-065..069: word motion.
+///
+/// Returns a range spanning from the pre-motion cursor to the target, so the
+/// traversed text ends up selected.
+pub fn word_move(
+ text: RopeSlice,
+ range: Range,
+ count: usize,
+ target: WordTarget,
+ long: bool,
+) -> Range {
+ let cat = categorizer(long);
+
+ // The anchor is the position the whole traversal started from and does not
+ // move; only the head advances, once per count. Re-deriving the start from
+ // the partial result each iteration would restart from the *cursor* — one
+ // grapheme behind the head — so `2w` would stall inside the first gap
+ // instead of reaching the second word.
+ let anchor = range.cursor(text);
+ let mut head = anchor;
+
+ for _ in 0..count.max(1) {
+ let next = match target {
+ WordTarget::NextStart => next_word_start(text, head, cat),
+ WordTarget::NextEnd => next_word_end(text, head, cat),
+ WordTarget::PrevStart => prev_word_start(text, head, cat),
+ };
+ if next == head {
+ break; // MJB-LLR-069: at the buffer boundary
+ }
+ head = next;
+ }
+
+ if head == anchor {
+ range
+ } else {
+ Range::new(anchor, head).clamped(text)
+ }
+}
+
+/// Characters that separate words rather than belonging to one.
+fn is_separator(category: CharCategory) -> bool {
+ matches!(category, CharCategory::Whitespace | CharCategory::Eol)
+}
+
+/// The char starting at byte `i`, with its start index.
+fn char_at(text: RopeSlice, i: usize) -> Option<(usize, char)> {
+ (i < text.len()).then(|| text.char_indices_at(i).next())?
+}
+
+/// The char ending at byte `i` — the one immediately before it.
+fn char_before(text: RopeSlice, i: usize) -> Option<(usize, char)> {
+ (i > 0).then(|| text.char_indices_at(i).prev())?
+}
+
+/// MJB-LLR-065: first character of the word after `from`.
+///
+/// Runs out whatever category the cursor sits on, then skips separators.
+fn next_word_start(text: RopeSlice, from: usize, cat: fn(char) -> CharCategory) -> usize {
+ let mut i = from;
+
+ if let Some((_, c)) = char_at(text, i) {
+ let run = cat(c);
+ while let Some((s, ch)) = char_at(text, i) {
+ if cat(ch) != run {
+ break;
+ }
+ i = s + ch.len_utf8();
+ }
+ }
+
+ // MJB-LLR-068
+ while let Some((s, c)) = char_at(text, i) {
+ if !is_separator(cat(c)) {
+ break;
+ }
+ i = s + c.len_utf8();
+ }
+
+ i
+}
+
+/// MJB-LLR-067: one past the last character of the word after `from`.
+///
+/// Steps off the current character first so `e` always advances, even when it
+/// already sits on the final character of a word.
+fn next_word_end(text: RopeSlice, from: usize, cat: fn(char) -> CharCategory) -> usize {
+ let mut i = from;
+
+ if let Some((s, c)) = char_at(text, i) {
+ i = s + c.len_utf8();
+ }
+
+ // MJB-LLR-068
+ while let Some((s, c)) = char_at(text, i) {
+ if !is_separator(cat(c)) {
+ break;
+ }
+ i = s + c.len_utf8();
+ }
+
+ if let Some((_, c)) = char_at(text, i) {
+ let run = cat(c);
+ while let Some((s, ch)) = char_at(text, i) {
+ if cat(ch) != run {
+ break;
+ }
+ i = s + ch.len_utf8();
+ }
+ }
+
+ i
+}
+
+/// MJB-LLR-066: first character of the word before `from`.
+fn prev_word_start(text: RopeSlice, from: usize, cat: fn(char) -> CharCategory) -> usize {
+ let mut i = from;
+
+ // MJB-LLR-068: skip separators immediately behind the cursor.
+ while let Some((s, c)) = char_before(text, i) {
+ if !is_separator(cat(c)) {
+ break;
+ }
+ i = s;
+ }
+
+ let Some((_, c)) = char_before(text, i) else {
+ return i; // MJB-LLR-069: nothing but separators behind us
+ };
+ let run = cat(c);
+
+ while let Some((s, ch)) = char_before(text, i) {
+ if cat(ch) != run {
+ break;
+ }
+ i = s;
+ }
+
+ i
+}
+
+// --- Goto commands (MJB-HLR-008) ---
+
+/// MJB-LLR-070
+pub fn goto_file_start(text: RopeSlice) -> Range {
+ let _ = text;
+ Range::point(0)
+}
+
+/// MJB-LLR-071
+pub fn goto_last_line(text: RopeSlice) -> Range {
+ let last = last_content_line(text);
+ Range::point(text.line_to_byte_idx(last, LINE_TYPE)).clamped(text)
+}
+
+/// MJB-LLR-072
+pub fn goto_line_start(text: RopeSlice, range: Range) -> Range {
+ let line = range.cursor_line(text);
+ Range::point(text.line_to_byte_idx(line, LINE_TYPE)).clamped(text)
+}
+
+/// MJB-LLR-073: the last character of the line, excluding its terminator.
+pub fn goto_line_end(text: RopeSlice, range: Range) -> Range {
+ let line = range.cursor_line(text);
+ Range::point(line_end_byte(text, line)).clamped(text)
+}
+
+/// First non-whitespace character of the cursor's line.
+pub fn goto_first_non_whitespace(text: RopeSlice, range: Range) -> Range {
+ let line = range.cursor_line(text);
+ let start = text.line_to_byte_idx(line, LINE_TYPE);
+ let slice = text.line(line, LINE_TYPE);
+
+ let mut offset = 0;
+ for (i, c) in slice.char_indices() {
+ if !c.is_whitespace() || matches!(categorize_char(c), CharCategory::Eol) {
+ offset = i;
+ break;
+ }
+ offset = i + c.len_utf8();
+ }
+ Range::point(start + offset).clamped(text)
+}
+
+/// Byte offset just past the last non-terminator character of `line`.
+///
+/// Inspects the final bytes rather than materialising the line: LF and CR are
+/// single-byte ASCII and cannot occur as a continuation byte of a multi-byte
+/// character, so testing the trailing bytes is unambiguous.
+pub fn line_end_byte(text: RopeSlice, line: usize) -> usize {
+ let start = text.line_to_byte_idx(line, LINE_TYPE);
+ let slice = text.line(line, LINE_TYPE);
+ let mut end = slice.len();
+
+ if end > 0 && slice.byte(end - 1) == b'\n' {
+ end -= 1;
+ if end > 0 && slice.byte(end - 1) == b'\r' {
+ end -= 1; // CRLF
+ }
+ } else if end > 0 && slice.byte(end - 1) == b'\r' {
+ end -= 1; // lone CR
+ }
+
+ start + end
+}
+
+/// The last line holding content.
+///
+/// A buffer ending in a newline reports a trailing empty line; the cursor
+/// should land on the last line with text on it.
+pub fn last_content_line(text: RopeSlice) -> usize {
+ let lines = text.len_lines(LINE_TYPE);
+ if lines == 0 {
+ return 0;
+ }
+ let last = lines - 1;
+ if last > 0 && text.line(last, LINE_TYPE).len() == 0 {
+ last - 1
+ } else {
+ last
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+
+ fn r(s: &str) -> Rope {
+ Rope::from_str(s)
+ }
+
+ #[test]
+ fn mjb_llr_060_categories() {
+ assert_eq!(categorize_char('a'), CharCategory::Word);
+ assert_eq!(categorize_char('_'), CharCategory::Word);
+ assert_eq!(categorize_char('7'), CharCategory::Word);
+ assert_eq!(categorize_char(' '), CharCategory::Whitespace);
+ assert_eq!(categorize_char('\n'), CharCategory::Eol);
+ assert_eq!(categorize_char('.'), CharCategory::Punctuation);
+ }
+
+ #[test]
+ fn mjb_llr_061_word_boundary_is_category_change() {
+ assert!(is_word_boundary('a', ' '));
+ assert!(is_word_boundary('a', '.'));
+ assert!(!is_word_boundary('a', 'b'));
+ }
+
+ #[test]
+ fn mjb_llr_062_move_char_left_stops_at_zero() {
+ let t = r("abc");
+ let s = t.slice(..);
+ assert_eq!(move_char_left(s, Range::point(0), 1), Range::point(0));
+ assert_eq!(move_char_left(s, Range::point(2), 1), Range::point(1));
+ }
+
+ #[test]
+ fn mjb_llr_063_move_char_right_stops_at_end() {
+ let t = r("abc");
+ let s = t.slice(..);
+ assert_eq!(move_char_right(s, Range::point(3), 1), Range::point(3));
+ assert_eq!(move_char_right(s, Range::point(0), 1), Range::point(1));
+ }
+
+ #[test]
+ fn mjb_llr_063_move_char_right_skips_whole_multibyte_char() {
+ let t = r("文a");
+ let s = t.slice(..);
+ assert_eq!(move_char_right(s, Range::point(0), 1), Range::point(3));
+ }
+
+ #[test]
+ fn mjb_llr_064_vertical_motion_preserves_column() {
+ let t = r("abcdef\nghijkl\n");
+ let s = t.slice(..);
+ let down = move_vertically(s, Range::point(3), 1, true);
+ assert_eq!(down.cursor(s), 7 + 3, "same column on the next line");
+ let up = move_vertically(s, down, 1, false);
+ assert_eq!(up.cursor(s), 3);
+ }
+
+ #[test]
+ fn mjb_llr_064_vertical_motion_clamps_to_short_line() {
+ let t = r("abcdef\nxy\n");
+ let s = t.slice(..);
+ let down = move_vertically(s, Range::point(5), 1, true);
+ // Line "xy" has no column 5; clamp to its end.
+ assert_eq!(down.cursor(s), 7 + 2);
+ }
+
+ #[test]
+ fn mjb_llr_064_vertical_motion_is_noop_at_edges() {
+ let t = r("abc\ndef\n");
+ let s = t.slice(..);
+ let up = move_vertically(s, Range::point(1), 1, false);
+ assert_eq!(up, Range::point(1), "no-op on the first line");
+ }
+
+ /// The behaviour that distinguishes Helix from Vim: `w` leaves a selection.
+ #[test]
+ fn mjb_llr_065_next_word_start_produces_a_selection() {
+ let t = r("hello world");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, false);
+ assert!(!got.is_empty(), "w must leave a selection, not a point");
+ assert_eq!(got.anchor, 0, "anchor stays at the pre-motion cursor");
+ assert_eq!(got.head, 6, "head lands on the next word's first char");
+ }
+
+ #[test]
+ fn mjb_llr_067_next_word_end_spans_the_word() {
+ let t = r("hello world");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(0), 1, WordTarget::NextEnd, false);
+ assert_eq!(got.anchor, 0);
+ assert_eq!(got.head, 5, "inclusive of the word's last character");
+ }
+
+ #[test]
+ fn mjb_llr_066_prev_word_start_spans_backward() {
+ let t = r("hello world");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(6), 1, WordTarget::PrevStart, false);
+ assert_eq!(got.anchor, 6, "anchor stays at the pre-motion cursor");
+ assert_eq!(got.head, 0);
+ }
+
+ #[test]
+ fn mjb_llr_068_word_motion_stops_at_punctuation() {
+ let t = r("foo.bar");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, false);
+ assert_eq!(got.head, 3, "punctuation is its own category");
+ }
+
+ #[test]
+ fn mjb_llr_068_long_word_motion_absorbs_punctuation() {
+ let t = r("foo.bar baz");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, true);
+ assert_eq!(got.head, 8, "W treats foo.bar as one word");
+ }
+
+ /// MJB-LLR-065 with a count. Regression guard: when each iteration
+ /// re-derived its start from the partial range's *cursor* — one grapheme
+ /// behind the head — `2w` stalled inside the first gap instead of reaching
+ /// the second word.
+ #[test]
+ fn mjb_llr_065_counted_next_word_start_advances_once_per_count() {
+ let t = r("aaa bbb ccc ddd");
+ let s = t.slice(..);
+
+ let one = word_move(s, Range::point(0), 1, WordTarget::NextStart, false);
+ assert_eq!((one.anchor, one.head), (0, 4));
+
+ let two = word_move(s, Range::point(0), 2, WordTarget::NextStart, false);
+ assert_eq!(
+ (two.anchor, two.head),
+ (0, 8),
+ "2w must reach the third word's start, not stall in a gap"
+ );
+
+ let three = word_move(s, Range::point(0), 3, WordTarget::NextStart, false);
+ assert_eq!((three.anchor, three.head), (0, 12));
+ }
+
+ #[test]
+ fn mjb_llr_066_counted_prev_word_start_advances_once_per_count() {
+ let t = r("aaa bbb ccc");
+ let s = t.slice(..);
+ let two = word_move(s, Range::point(10), 2, WordTarget::PrevStart, false);
+ assert_eq!(two.anchor, 10, "anchor stays at the origin");
+ assert_eq!(two.head, 4, "two words back");
+ }
+
+ #[test]
+ fn mjb_llr_067_counted_next_word_end_advances_once_per_count() {
+ let t = r("aaa bbb ccc");
+ let s = t.slice(..);
+ let two = word_move(s, Range::point(0), 2, WordTarget::NextEnd, false);
+ assert_eq!((two.anchor, two.head), (0, 7), "end of the second word");
+ }
+
+ /// A count larger than the remaining words must saturate, not overshoot.
+ #[test]
+ fn mjb_llr_069_counted_motion_saturates_at_the_buffer_end() {
+ let t = r("aaa bbb");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(0), 99, WordTarget::NextStart, false);
+ assert!(got.head <= s.len());
+ assert_eq!(got.anchor, 0);
+ }
+
+ #[test]
+ fn mjb_llr_069_word_motion_is_noop_at_boundaries() {
+ let t = r("abc");
+ let s = t.slice(..);
+ let end = Range::point(3);
+ assert_eq!(word_move(s, end, 1, WordTarget::NextStart, false), end);
+ let start = Range::point(0);
+ assert_eq!(word_move(s, start, 1, WordTarget::PrevStart, false), start);
+ }
+
+ #[test]
+ fn mjb_llr_068_word_motion_crosses_line_endings() {
+ let t = r("foo\nbar");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, false);
+ assert_eq!(got.head, 4, "newline is skipped as a separator");
+ }
+
+ #[test]
+ fn mjb_llr_070_goto_file_start() {
+ let t = r("abc\ndef");
+ assert_eq!(goto_file_start(t.slice(..)), Range::point(0));
+ }
+
+ #[test]
+ fn mjb_llr_071_goto_last_line() {
+ let t = r("abc\ndef\n");
+ let s = t.slice(..);
+ // Trailing newline must not park the cursor on the phantom line.
+ assert_eq!(goto_last_line(s).cursor(s), 4);
+ }
+
+ #[test]
+ fn mjb_llr_071_goto_last_line_without_trailing_newline() {
+ let t = r("abc\ndef");
+ let s = t.slice(..);
+ assert_eq!(goto_last_line(s).cursor(s), 4);
+ }
+
+ #[test]
+ fn mjb_llr_072_goto_line_start() {
+ let t = r("abc\ndef\n");
+ let s = t.slice(..);
+ assert_eq!(goto_line_start(s, Range::point(6)).cursor(s), 4);
+ }
+
+ #[test]
+ fn mjb_llr_073_goto_line_end_excludes_terminator() {
+ let t = r("abc\ndef\n");
+ let s = t.slice(..);
+ assert_eq!(goto_line_end(s, Range::point(0)).cursor(s), 3);
+ }
+
+ #[test]
+ fn mjb_llr_073_goto_line_end_handles_crlf() {
+ let t = r("abc\r\ndef");
+ let s = t.slice(..);
+ assert_eq!(goto_line_end(s, Range::point(0)).cursor(s), 3);
+ }
+
+ #[test]
+ fn goto_first_non_whitespace_skips_indent() {
+ let t = r(" indented\n");
+ let s = t.slice(..);
+ assert_eq!(goto_first_non_whitespace(s, Range::point(0)).cursor(s), 4);
+ }
+
+ #[test]
+ fn empty_buffer_motions_are_safe() {
+ let t = r("");
+ let s = t.slice(..);
+ assert_eq!(move_char_left(s, Range::point(0), 1), Range::point(0));
+ assert_eq!(move_char_right(s, Range::point(0), 1), Range::point(0));
+ assert_eq!(goto_line_end(s, Range::point(0)).cursor(s), 0);
+ assert_eq!(goto_last_line(s).cursor(s), 0);
+ let w = word_move(s, Range::point(0), 1, WordTarget::NextStart, false);
+ assert_eq!(w, Range::point(0));
+ }
+}
diff --git a/src/buffer/save.rs b/src/buffer/save.rs
new file mode 100644
index 0000000..70dacd0
--- /dev/null
+++ b/src/buffer/save.rs
@@ -0,0 +1,387 @@
+//! File writing — ported from Helix's `helix-view/src/document.rs::save_impl`.
+//!
+//! Two distinct fallbacks, both required by MJB-HLR-017 and easy to conflate:
+//!
+//! 1. **copy instead of rename** when the target is a symlink or hardlink.
+//! Renaming the backup into place would break the link; copying preserves it.
+//! 2. **restore on failure** — the backup lives in the target's *own directory*
+//! so the rename can never cross a filesystem, and is put back if the write
+//! fails partway.
+//!
+//! The ordering matters: every check that can reject the write runs before
+//! anything on disk is touched.
+
+use std::{
+ fs,
+ io::{self, Write},
+ path::{Path, PathBuf},
+};
+
+#[derive(Debug, thiserror::Error)]
+pub enum SaveError {
+ #[error("no file name associated with this buffer")]
+ NoPath,
+ #[error("path is read only: {0}")]
+ ReadOnly(PathBuf),
+ #[error("can't save file, parent directory does not exist (use :w! to create it): {0}")]
+ NoParent(PathBuf),
+ #[error("io error: {0}")]
+ Io(#[from] io::Error),
+}
+
+/// MJB-LLR-131: writable without modifying anything.
+///
+/// A path that does not exist is *not* read-only — it may still be creatable.
+pub fn readonly(path: &Path) -> bool {
+ match fs::metadata(path) {
+ Ok(md) => md.permissions().readonly(),
+ Err(e) if e.kind() == io::ErrorKind::NotFound => false,
+ Err(_) => true,
+ }
+}
+
+/// MJB-LLR-130: follow a symlink to its target so the link itself survives.
+///
+/// A relative link target is resolved against the link's own directory.
+pub fn resolve_write_path(path: &Path) -> PathBuf {
+ match fs::read_link(path) {
+ Ok(target) => {
+ if target.is_relative() {
+ path.parent()
+ .map(|parent| parent.join(&target))
+ .unwrap_or(target)
+ } else {
+ target
+ }
+ }
+ Err(_) => path.to_path_buf(),
+ }
+}
+
+/// MJB-LLR-133: a rename would destroy the link, so the backup must be a copy.
+pub fn must_copy(path: &Path) -> bool {
+ if fs::symlink_metadata(path)
+ .map(|md| md.file_type().is_symlink())
+ .unwrap_or(false)
+ {
+ return true;
+ }
+ hard_link_count(path) > 1
+}
+
+#[cfg(unix)]
+fn hard_link_count(path: &Path) -> u64 {
+ use std::os::unix::fs::MetadataExt;
+ fs::metadata(path).map(|md| md.nlink()).unwrap_or(1)
+}
+
+#[cfg(not(unix))]
+fn hard_link_count(_path: &Path) -> u64 {
+ 1
+}
+
+/// Copy permissions from `from` onto `to` (MJB-LLR-136).
+fn copy_permissions(from: &Path, to: &Path) -> io::Result<()> {
+ let perms = fs::metadata(from)?.permissions();
+ fs::set_permissions(to, perms)
+}
+
+/// MJB-LLR-134: a backup path beside the target, so `rename` stays within one
+/// filesystem and cannot fail with a cross-device link error.
+pub(crate) fn backup_path(target: &Path) -> PathBuf {
+ let name = target
+ .file_name()
+ .map(|n| n.to_string_lossy().into_owned())
+ .unwrap_or_else(|| "buffer".to_owned());
+ let dir = target.parent().unwrap_or_else(|| Path::new("."));
+ // The process id keeps concurrent instances from colliding without
+ // needing a random source.
+ dir.join(format!(".{name}.mojibake-{}.bak", std::process::id()))
+}
+
+/// Write `bytes` to `path`, honouring MJB-LLR-130 through MJB-LLR-136.
+///
+/// `force` corresponds to `:w!` and permits creating a missing parent
+/// directory (MJB-LLR-132).
+pub fn write_atomic(path: &Path, bytes: &[u8], force: bool) -> Result<(), SaveError> {
+ // --- Checks that can reject the write, before touching the filesystem ---
+
+ // MJB-LLR-130
+ let write_path = resolve_write_path(path);
+
+ // MJB-LLR-131
+ if readonly(&write_path) {
+ return Err(SaveError::ReadOnly(write_path));
+ }
+
+ // MJB-LLR-132
+ if let Some(parent) = write_path.parent()
+ && !parent.as_os_str().is_empty()
+ && !parent.exists()
+ {
+ if force {
+ fs::create_dir_all(parent)?;
+ } else {
+ return Err(SaveError::NoParent(parent.to_path_buf()));
+ }
+ }
+
+ // --- Backup (MJB-LLR-133, MJB-LLR-134) ---
+
+ let exists = write_path.exists();
+ let copy_mode = exists && must_copy(&write_path);
+ let backup = if exists {
+ let backup = backup_path(&write_path);
+ let made = if copy_mode {
+ fs::copy(&write_path, &backup).map(|_| ())
+ } else {
+ fs::rename(&write_path, &backup)
+ };
+ // A backup we could not make is not fatal; the write proceeds without
+ // the safety net rather than refusing to save at all.
+ match made {
+ Ok(()) => Some(backup),
+ Err(_) => None,
+ }
+ } else {
+ None
+ };
+
+ // --- The write itself ---
+
+ let result = (|| -> io::Result<()> {
+ let mut file = fs::File::create(&write_path)?;
+ file.write_all(bytes)?;
+ file.sync_all()?;
+ Ok(())
+ })();
+
+ match (result, backup) {
+ (Ok(()), Some(backup)) => {
+ // MJB-LLR-136
+ let _ = copy_permissions(&backup, &write_path);
+ let _ = fs::remove_file(&backup);
+ Ok(())
+ }
+ (Ok(()), None) => Ok(()),
+ (Err(e), Some(backup)) => {
+ // MJB-LLR-135: put the original back.
+ if copy_mode {
+ let _ = fs::copy(&backup, &write_path);
+ let _ = fs::remove_file(&backup);
+ } else {
+ let _ = fs::rename(&backup, &write_path);
+ }
+ Err(SaveError::Io(e))
+ }
+ (Err(e), None) => Err(SaveError::Io(e)),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn tmp() -> tempfile::TempDir {
+ tempfile::tempdir().unwrap()
+ }
+
+ #[test]
+ fn writes_a_new_file() {
+ let d = tmp();
+ let p = d.path().join("new.txt");
+ write_atomic(&p, b"hello", false).unwrap();
+ assert_eq!(fs::read(&p).unwrap(), b"hello");
+ }
+
+ #[test]
+ fn overwrites_an_existing_file() {
+ let d = tmp();
+ let p = d.path().join("f.txt");
+ fs::write(&p, b"old contents that are longer").unwrap();
+ write_atomic(&p, b"new", false).unwrap();
+ assert_eq!(fs::read(&p).unwrap(), b"new");
+ }
+
+ #[test]
+ fn mjb_llr_136_no_backup_file_is_left_behind() {
+ let d = tmp();
+ let p = d.path().join("f.txt");
+ fs::write(&p, b"old").unwrap();
+ write_atomic(&p, b"new", false).unwrap();
+
+ let leftovers: Vec<_> = fs::read_dir(d.path())
+ .unwrap()
+ .filter_map(Result::ok)
+ .map(|e| e.file_name().to_string_lossy().into_owned())
+ .filter(|n| n.contains("mojibake"))
+ .collect();
+ assert!(leftovers.is_empty(), "stray backups: {leftovers:?}");
+ }
+
+ #[test]
+ fn mjb_llr_132_missing_parent_is_refused_without_force() {
+ let d = tmp();
+ let p = d.path().join("missing").join("f.txt");
+ let err = write_atomic(&p, b"x", false).unwrap_err();
+ assert!(matches!(err, SaveError::NoParent(_)));
+ assert!(!p.exists());
+ }
+
+ #[test]
+ fn mjb_llr_132_force_creates_the_parent() {
+ let d = tmp();
+ let p = d.path().join("a").join("b").join("f.txt");
+ write_atomic(&p, b"x", true).unwrap();
+ assert_eq!(fs::read(&p).unwrap(), b"x");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_131_readonly_target_is_refused() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let d = tmp();
+ let p = d.path().join("ro.txt");
+ fs::write(&p, b"original").unwrap();
+ fs::set_permissions(&p, fs::Permissions::from_mode(0o444)).unwrap();
+
+ let err = write_atomic(&p, b"replacement", false).unwrap_err();
+ assert!(matches!(err, SaveError::ReadOnly(_)));
+ assert_eq!(
+ fs::read(&p).unwrap(),
+ b"original",
+ "a refused write must not truncate the file"
+ );
+ }
+
+ /// MJB-LLR-130, MJB-LLR-133: the link must survive and its target change.
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_130_write_follows_symlink_without_replacing_it() {
+ let d = tmp();
+ let target = d.path().join("target.txt");
+ let link = d.path().join("link.txt");
+ fs::write(&target, b"before").unwrap();
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+
+ write_atomic(&link, b"after", false).unwrap();
+
+ assert!(
+ fs::symlink_metadata(&link).unwrap().file_type().is_symlink(),
+ "the symlink must still be a symlink"
+ );
+ assert_eq!(fs::read(&target).unwrap(), b"after", "target updated");
+ }
+
+ /// MJB-LLR-133: a hardlinked file must keep its link count.
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_133_hardlink_is_detected_and_preserved() {
+ let d = tmp();
+ let a = d.path().join("a.txt");
+ let b = d.path().join("b.txt");
+ fs::write(&a, b"before").unwrap();
+ fs::hard_link(&a, &b).unwrap();
+
+ assert!(must_copy(&a), "hardlinked file must use copy mode");
+
+ write_atomic(&a, b"after", false).unwrap();
+ assert_eq!(fs::read(&a).unwrap(), b"after");
+ assert_eq!(
+ fs::read(&b).unwrap(),
+ b"after",
+ "the hard link must still point at the same inode"
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_130_relative_symlink_resolves_against_its_own_directory() {
+ let d = tmp();
+ let target = d.path().join("t.txt");
+ let link = d.path().join("l.txt");
+ fs::write(&target, b"x").unwrap();
+ std::os::unix::fs::symlink("t.txt", &link).unwrap();
+
+ assert_eq!(resolve_write_path(&link), target);
+ }
+
+ #[test]
+ fn mjb_llr_131_missing_file_is_not_readonly() {
+ let d = tmp();
+ assert!(
+ !readonly(&d.path().join("does-not-exist")),
+ "a creatable path must not be reported read-only"
+ );
+ }
+
+ /// MJB-LLR-134: the backup must live in the target's own directory. A
+ /// backup in a temp dir elsewhere would make the rename cross a filesystem
+ /// boundary and fail with EXDEV.
+ #[test]
+ fn mjb_llr_134_backup_is_created_beside_the_target() {
+ let target = Path::new("/some/deep/directory/file.txt");
+ let backup = backup_path(target);
+ assert_eq!(
+ backup.parent(),
+ target.parent(),
+ "backup must sit beside the target, not in a temp directory"
+ );
+ assert_ne!(backup, target);
+ assert!(
+ backup
+ .file_name()
+ .unwrap()
+ .to_string_lossy()
+ .starts_with('.'),
+ "backup should be hidden"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_134_backup_path_handles_a_bare_file_name() {
+ // No parent component: must not panic.
+ let backup = backup_path(Path::new("file.txt"));
+ assert!(backup.to_string_lossy().contains("file.txt"));
+ }
+
+ #[test]
+ fn mjb_llr_133_plain_file_does_not_need_copy_mode() {
+ let d = tmp();
+ let p = d.path().join("plain.txt");
+ fs::write(&p, b"x").unwrap();
+ assert!(!must_copy(&p));
+ }
+
+ /// MJB-LLR-135: when the write cannot even be created, the original
+ /// contents must still be on disk afterwards.
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_135_failed_write_restores_the_original() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let d = tmp();
+ let sub = d.path().join("sub");
+ fs::create_dir(&sub).unwrap();
+ let p = sub.join("f.txt");
+ fs::write(&p, b"original").unwrap();
+
+ // Make the *directory* unwritable so File::create fails after the
+ // backup has been taken. The file itself stays writable, so the
+ // read-only pre-check does not short-circuit the test.
+ fs::set_permissions(&sub, fs::Permissions::from_mode(0o500)).unwrap();
+ let result = write_atomic(&p, b"replacement", false);
+ fs::set_permissions(&sub, fs::Permissions::from_mode(0o700)).unwrap();
+
+ if result.is_err() {
+ assert_eq!(
+ fs::read(&p).unwrap(),
+ b"original",
+ "a failed write must restore the previous contents"
+ );
+ }
+ // Running as root defeats the permission bits; the assertion above is
+ // skipped in that case rather than reporting a false failure.
+ }
+}
diff --git a/src/buffer/selection.rs b/src/buffer/selection.rs
new file mode 100644
index 0000000..e30d617
--- /dev/null
+++ b/src/buffer/selection.rs
@@ -0,0 +1,344 @@
+//! Selection model — byte-indexed, following Helix's `helix-core/src/selection.rs`.
+//!
+//! This is what makes the editor selection-first rather than Vim-like: a motion
+//! leaves a *range*, and an operator such as `d` acts on that range. There is no
+//! operator-pending state anywhere in the editor.
+//!
+//! Conventions, preserved exactly from Helix:
+//!
+//! - A range is **half-open**: inclusive of `from()`, exclusive of `to()`,
+//! regardless of whether `head` precedes or follows `anchor`.
+//! - The visible block cursor spans one grapheme *inward* from the head, so a
+//! forward range `0..1` shows its cursor on byte 0, not byte 1.
+//!
+//! Per MJB-LLR-009 a `Selection` holds exactly one range. It is a struct rather
+//! than a bare `Range` so that multi-cursor support can be added later without
+//! reworking call sites.
+
+use ropey::RopeSlice;
+
+use super::grapheme::{next_grapheme_boundary, prev_grapheme_boundary};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Direction {
+ Forward,
+ Backward,
+}
+
+/// MJB-LLR-001: a range over the buffer, both offsets in **bytes**.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct Range {
+ /// The side that stays put when extending.
+ pub anchor: usize,
+ /// The side that moves when extending.
+ pub head: usize,
+}
+
+impl Range {
+ pub fn new(anchor: usize, head: usize) -> Self {
+ Self { anchor, head }
+ }
+
+ /// A zero-width range at `head`.
+ pub fn point(head: usize) -> Self {
+ Self { anchor: head, head }
+ }
+
+ /// MJB-LLR-011: clamp both offsets into the buffer and snap them to char
+ /// boundaries. Byte indexing permits offsets that char indexing could not
+ /// express, and ropey panics on them — see MJB-DR-002.
+ pub fn clamped(self, text: RopeSlice) -> Self {
+ let len = text.len();
+ Self {
+ anchor: text.floor_char_boundary(self.anchor.min(len)),
+ head: text.floor_char_boundary(self.head.min(len)),
+ }
+ }
+
+ /// MJB-LLR-002: lower bound, inclusive.
+ pub fn from(&self) -> usize {
+ self.anchor.min(self.head)
+ }
+
+ /// MJB-LLR-002: upper bound, exclusive.
+ pub fn to(&self) -> usize {
+ self.anchor.max(self.head)
+ }
+
+ /// MJB-LLR-003
+ pub fn is_empty(&self) -> bool {
+ self.anchor == self.head
+ }
+
+ /// Byte length of the span.
+ pub fn len(&self) -> usize {
+ self.to() - self.from()
+ }
+
+ /// MJB-LLR-004
+ pub fn direction(&self) -> Direction {
+ if self.head < self.anchor {
+ Direction::Backward
+ } else {
+ Direction::Forward
+ }
+ }
+
+ /// MJB-LLR-005: the byte offset the block cursor is drawn at.
+ ///
+ /// For a forward range the head sits *past* the last selected grapheme, so
+ /// the cursor steps back one grapheme to land on it.
+ pub fn cursor(&self, text: RopeSlice) -> usize {
+ if self.head > self.anchor {
+ prev_grapheme_boundary(text, self.head)
+ } else {
+ self.head
+ }
+ }
+
+ /// MJB-LLR-006, MJB-LLR-007: move the cursor to `byte_idx`.
+ ///
+ /// Without `extend` this collapses to a point. With `extend` the anchor is
+ /// nudged by one grapheme when the range flips direction across it, so the
+ /// anchored grapheme stays selected — this is Helix's `put_cursor`.
+ pub fn put_cursor(self, text: RopeSlice, byte_idx: usize, extend: bool) -> Self {
+ if !extend {
+ return Range::point(byte_idx).clamped(text);
+ }
+
+ let anchor = if self.head >= self.anchor && byte_idx < self.anchor {
+ next_grapheme_boundary(text, self.anchor)
+ } else if self.head < self.anchor && byte_idx >= self.anchor {
+ prev_grapheme_boundary(text, self.anchor)
+ } else {
+ self.anchor
+ };
+
+ if anchor <= byte_idx {
+ Range::new(anchor, next_grapheme_boundary(text, byte_idx)).clamped(text)
+ } else {
+ Range::new(anchor, byte_idx).clamped(text)
+ }
+ }
+
+ /// The line the cursor lies on.
+ pub fn cursor_line(&self, text: RopeSlice) -> usize {
+ text.byte_to_line_idx(self.cursor(text), super::LINE_TYPE)
+ }
+
+ /// MJB-LLR-008: inclusive span of line indices the range covers.
+ pub fn line_range(&self, text: RopeSlice) -> (usize, usize) {
+ let lt = super::LINE_TYPE;
+ let start = text.byte_to_line_idx(self.from(), lt);
+ // An exclusive upper bound sitting exactly on a line start belongs to
+ // the previous line, otherwise `x` on a full line would report two.
+ let end_byte = if self.to() > self.from() {
+ self.to() - 1
+ } else {
+ self.to()
+ };
+ let end = text.byte_to_line_idx(end_byte.min(text.len()), lt);
+ (start, end)
+ }
+
+ /// Flip anchor and head, keeping the same span.
+ pub fn flipped(self) -> Self {
+ Range::new(self.head, self.anchor)
+ }
+}
+
+/// MJB-LLR-009: exactly one range, with `primary_index` pinned at zero.
+///
+/// The vector and index exist so the multi-cursor shape is already in place;
+/// the invariant is asserted, not assumed.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Selection {
+ ranges: Vec<Range>,
+ primary_index: usize,
+}
+
+impl Default for Selection {
+ fn default() -> Self {
+ Self::point(0)
+ }
+}
+
+impl Selection {
+ pub fn single(range: Range) -> Self {
+ Self {
+ ranges: vec![range],
+ primary_index: 0,
+ }
+ }
+
+ pub fn point(byte_idx: usize) -> Self {
+ Self::single(Range::point(byte_idx))
+ }
+
+ /// MJB-LLR-010
+ pub fn primary(&self) -> Range {
+ self.ranges[self.primary_index]
+ }
+
+ pub fn set_primary(&mut self, range: Range) {
+ self.ranges[self.primary_index] = range;
+ }
+
+ pub fn ranges(&self) -> &[Range] {
+ &self.ranges
+ }
+
+ /// MJB-LLR-009: the single-range invariant, checked rather than assumed.
+ pub fn invariant_holds(&self) -> bool {
+ self.ranges.len() == 1 && self.primary_index == 0
+ }
+
+ /// Clamp every range into `text`.
+ pub fn clamped(mut self, text: RopeSlice) -> Self {
+ for r in &mut self.ranges {
+ *r = r.clamped(text);
+ }
+ self
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+
+ /// MJB-LLR-001: offsets are bytes, not characters. A range over a
+ /// multi-byte character must report its byte extent.
+ #[test]
+ fn mjb_llr_001_offsets_are_byte_indices() {
+ let r = Rope::from_str("文字");
+ let s = r.slice(..);
+ assert_eq!(s.len(), 6, "two 3-byte characters");
+
+ let whole = Range::new(0, 6).clamped(s);
+ assert_eq!(whole.len(), 6, "length is in bytes, not characters");
+
+ // A single character spans three byte offsets.
+ let first = Range::new(0, 3).clamped(s);
+ assert_eq!(first.len(), 3);
+ }
+
+ #[test]
+ fn mjb_llr_002_from_and_to_ignore_direction() {
+ assert_eq!(Range::new(2, 5).from(), 2);
+ assert_eq!(Range::new(2, 5).to(), 5);
+ assert_eq!(Range::new(5, 2).from(), 2, "backward range still orders");
+ assert_eq!(Range::new(5, 2).to(), 5);
+ }
+
+ #[test]
+ fn mjb_llr_003_is_empty() {
+ assert!(Range::point(3).is_empty());
+ assert!(!Range::new(3, 4).is_empty());
+ }
+
+ #[test]
+ fn mjb_llr_004_direction() {
+ assert_eq!(Range::new(1, 5).direction(), Direction::Forward);
+ assert_eq!(Range::new(5, 1).direction(), Direction::Backward);
+ assert_eq!(
+ Range::point(2).direction(),
+ Direction::Forward,
+ "an empty range is forward by convention"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_005_cursor_steps_back_on_forward_range() {
+ let r = Rope::from_str("abcdef");
+ let s = r.slice(..);
+ // Forward 0..1 selects byte 0, so the cursor is drawn on byte 0.
+ assert_eq!(Range::new(0, 1).cursor(s), 0);
+ assert_eq!(Range::new(0, 3).cursor(s), 2);
+ // A backward range's head already sits on the cursor.
+ assert_eq!(Range::new(3, 0).cursor(s), 0);
+ assert_eq!(Range::point(4).cursor(s), 4);
+ }
+
+ #[test]
+ fn mjb_llr_005_cursor_respects_grapheme_clusters() {
+ let r = Rope::from_str("文字");
+ let s = r.slice(..);
+ // Head past the first wide char: cursor lands on its start, not mid-char.
+ assert_eq!(Range::new(0, 3).cursor(s), 0);
+ }
+
+ #[test]
+ fn mjb_llr_006_put_cursor_without_extend_collapses() {
+ let r = Rope::from_str("abcdef");
+ let s = r.slice(..);
+ let got = Range::new(0, 4).put_cursor(s, 2, false);
+ assert_eq!(got, Range::point(2));
+ }
+
+ #[test]
+ fn mjb_llr_007_put_cursor_with_extend_keeps_anchor() {
+ let r = Rope::from_str("abcdef");
+ let s = r.slice(..);
+ let got = Range::new(1, 2).put_cursor(s, 4, true);
+ assert_eq!(got.anchor, 1, "anchor stays put when extending forward");
+ assert_eq!(got.head, 5, "head lands one grapheme past the target");
+ }
+
+ #[test]
+ fn mjb_llr_007_put_cursor_extend_flips_direction() {
+ let r = Rope::from_str("abcdef");
+ let s = r.slice(..);
+ // Forward range extended to before its anchor must flip and nudge the
+ // anchor forward one grapheme so the anchored byte stays selected.
+ let got = Range::new(2, 4).put_cursor(s, 0, true);
+ assert_eq!(got.direction(), Direction::Backward);
+ assert_eq!(got.anchor, 3);
+ assert_eq!(got.head, 0);
+ }
+
+ #[test]
+ fn mjb_llr_011_clamped_snaps_into_bounds_and_onto_char_boundary() {
+ let r = Rope::from_str("文");
+ let s = r.slice(..);
+ assert_eq!(Range::new(0, 99).clamped(s).head, 3, "clamped to length");
+ assert_eq!(
+ Range::new(0, 1).clamped(s).head,
+ 0,
+ "an offset inside a multi-byte char snaps back to its start"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_008_line_range() {
+ let r = Rope::from_str("aa\nbb\ncc\n");
+ let s = r.slice(..);
+ assert_eq!(Range::point(0).line_range(s), (0, 0));
+ // Exactly one full line, terminator included, is still one line.
+ assert_eq!(Range::new(0, 3).line_range(s), (0, 0));
+ assert_eq!(Range::new(0, 6).line_range(s), (0, 1));
+ }
+
+ #[test]
+ fn mjb_llr_009_selection_invariant() {
+ let sel = Selection::point(0);
+ assert!(sel.invariant_holds());
+ assert_eq!(sel.ranges().len(), 1);
+ }
+
+ #[test]
+ fn mjb_llr_010_primary_round_trips() {
+ let mut sel = Selection::point(0);
+ sel.set_primary(Range::new(1, 4));
+ assert_eq!(sel.primary(), Range::new(1, 4));
+ }
+
+ #[test]
+ fn flipped_preserves_span() {
+ let r = Range::new(2, 7).flipped();
+ assert_eq!((r.anchor, r.head), (7, 2));
+ assert_eq!(r.from(), 2);
+ assert_eq!(r.to(), 7);
+ }
+}
diff --git a/src/buffer/transaction.rs b/src/buffer/transaction.rs
new file mode 100644
index 0000000..b58bed9
--- /dev/null
+++ b/src/buffer/transaction.rs
@@ -0,0 +1,424 @@
+//! Change sets and transactions — after Helix's `helix-core/src/transaction.rs`.
+//!
+//! Every buffer modification is expressed as a [`Transaction`]. Undo is not a
+//! separate mechanism: it is the *inverse* transaction, computed against the
+//! document as it stood before the change (MJB-HLR-011).
+//!
+//! All counts are **byte** lengths.
+
+use ropey::Rope;
+
+use super::selection::Selection;
+
+/// MJB-LLR-040
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Operation {
+ /// Leave `n` bytes untouched.
+ Retain(usize),
+ /// Remove `n` bytes.
+ Delete(usize),
+ /// Insert this text.
+ Insert(String),
+}
+
+impl Operation {
+ /// Bytes of the *pre-application* document this operation consumes.
+ fn consumed(&self) -> usize {
+ match self {
+ Operation::Retain(n) | Operation::Delete(n) => *n,
+ Operation::Insert(_) => 0,
+ }
+ }
+
+ /// Bytes this operation contributes to the *post-application* document.
+ fn produced(&self) -> usize {
+ match self {
+ Operation::Retain(n) => *n,
+ Operation::Delete(_) => 0,
+ Operation::Insert(s) => s.len(),
+ }
+ }
+}
+
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum ChangeError {
+ #[error("change set expects a document of {expected} bytes, got {actual}")]
+ LengthMismatch { expected: usize, actual: usize },
+ #[error("operation boundary at byte {0} is not a character boundary")]
+ NonCharBoundary(usize),
+ #[error("operation at byte {0} extends past the end of the document")]
+ OutOfBounds(usize),
+}
+
+/// MJB-LLR-041
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct ChangeSet {
+ changes: Vec<Operation>,
+ /// Document length this change set requires before application.
+ len: usize,
+ /// Document length after application.
+ len_after: usize,
+}
+
+impl ChangeSet {
+ pub fn new(rope: &Rope) -> Self {
+ let len = rope.len();
+ Self {
+ changes: Vec::new(),
+ len,
+ len_after: len,
+ }
+ }
+
+ pub fn from_ops(ops: Vec<Operation>) -> Self {
+ let len = ops.iter().map(Operation::consumed).sum();
+ let len_after = ops.iter().map(Operation::produced).sum();
+ Self {
+ changes: ops,
+ len,
+ len_after,
+ }
+ }
+
+ pub fn ops(&self) -> &[Operation] {
+ &self.changes
+ }
+
+ pub fn len(&self) -> usize {
+ self.len
+ }
+
+ pub fn len_after(&self) -> usize {
+ self.len_after
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.changes
+ .iter()
+ .all(|op| matches!(op, Operation::Retain(_)))
+ }
+
+ /// MJB-LLR-042, MJB-LLR-043, MJB-LLR-044: apply to `rope`.
+ ///
+ /// Validation runs to completion *before* any mutation, so a rejected
+ /// change set leaves the rope untouched rather than half-applied.
+ pub fn apply(&self, rope: &mut Rope) -> Result<(), ChangeError> {
+ // MJB-LLR-042
+ if rope.len() != self.len {
+ return Err(ChangeError::LengthMismatch {
+ expected: self.len,
+ actual: rope.len(),
+ });
+ }
+
+ // MJB-LLR-044: verify every boundary first. ropey panics on a non-char
+ // boundary, and a panic is not an acceptable failure mode (MJB-DR-002).
+ let mut probe = 0usize;
+ for op in &self.changes {
+ if !rope.is_char_boundary(probe) {
+ return Err(ChangeError::NonCharBoundary(probe));
+ }
+ probe += op.consumed();
+ if probe > rope.len() {
+ return Err(ChangeError::OutOfBounds(probe));
+ }
+ }
+ if !rope.is_char_boundary(probe) {
+ return Err(ChangeError::NonCharBoundary(probe));
+ }
+
+ // MJB-LLR-043: apply front-to-back in a single pass.
+ //
+ // `pos` tracks the cursor in the *output*, which is what makes this
+ // work without buffering the edits or walking backwards: `Retain`
+ // advances over text present in both images, `Delete` removes at `pos`
+ // and so leaves it pointing at the next surviving byte, and `Insert`
+ // advances past what it added. Later offsets therefore stay valid as
+ // the rope shifts beneath them.
+ let mut pos = 0usize;
+ for op in &self.changes {
+ match op {
+ Operation::Retain(n) => pos += n,
+ Operation::Delete(n) => rope.remove(pos..pos + n),
+ Operation::Insert(s) => {
+ rope.insert(pos, s);
+ pos += s.len();
+ }
+ }
+ }
+
+ debug_assert_eq!(rope.len(), self.len_after);
+ Ok(())
+ }
+
+ /// MJB-LLR-045: the change set that undoes this one.
+ ///
+ /// MJB-LLR-046: applying this change set and then its inverse reproduces
+ /// the original contents exactly — the property undo rests on.
+ ///
+ /// `original` must be the document as it stood *before* this change set was
+ /// applied — deleted text is recovered from it.
+ pub fn invert(&self, original: &Rope) -> ChangeSet {
+ let mut ops = Vec::with_capacity(self.changes.len());
+ let mut pos = 0usize;
+
+ for op in &self.changes {
+ match op {
+ Operation::Retain(n) => {
+ ops.push(Operation::Retain(*n));
+ pos += n;
+ }
+ Operation::Delete(n) => {
+ let text: String = original.slice(pos..pos + n).chunks().collect();
+ ops.push(Operation::Insert(text));
+ pos += n;
+ }
+ Operation::Insert(s) => ops.push(Operation::Delete(s.len())),
+ }
+ }
+
+ ChangeSet {
+ changes: ops,
+ len: self.len_after,
+ len_after: self.len,
+ }
+ }
+}
+
+/// MJB-LLR-047: a change set plus the selection that should result from it.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Transaction {
+ pub changes: ChangeSet,
+ pub selection: Option<Selection>,
+}
+
+impl Transaction {
+ pub fn new(changes: ChangeSet) -> Self {
+ Self {
+ changes,
+ selection: None,
+ }
+ }
+
+ pub fn with_selection(mut self, selection: Selection) -> Self {
+ self.selection = Some(selection);
+ self
+ }
+
+ /// MJB-LLR-048: build from `(from, to, Option<text>)` triples, which must
+ /// arrive ordered by ascending `from` and must not overlap.
+ pub fn change<I>(rope: &Rope, changes: I) -> Self
+ where
+ I: IntoIterator<Item = (usize, usize, Option<String>)>,
+ {
+ let mut ops = Vec::new();
+ let mut pos = 0usize;
+
+ for (from, to, text) in changes {
+ if from > pos {
+ ops.push(Operation::Retain(from - pos));
+ }
+ if to > from {
+ ops.push(Operation::Delete(to - from));
+ }
+ if let Some(s) = text
+ && !s.is_empty()
+ {
+ ops.push(Operation::Insert(s));
+ }
+ pos = to.max(from);
+ }
+
+ let len = rope.len();
+ if pos < len {
+ ops.push(Operation::Retain(len - pos));
+ }
+
+ Self::new(ChangeSet::from_ops(ops))
+ }
+
+ /// MJB-LLR-049: insert `text` at the selection's cursor.
+ pub fn insert(rope: &Rope, selection: &Selection, text: &str) -> Self {
+ let at = selection.primary().cursor(rope.slice(..));
+ Self::change(rope, [(at, at, Some(text.to_owned()))])
+ }
+
+ /// MJB-LLR-050: delete the selection's primary span.
+ pub fn delete(rope: &Rope, selection: &Selection) -> Self {
+ let r = selection.primary();
+ Self::change(rope, [(r.from(), r.to(), None)])
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn rope(s: &str) -> Rope {
+ Rope::from_str(s)
+ }
+
+ /// MJB-LLR-040: operation counts are byte lengths, so a multi-byte
+ /// character contributes its byte width.
+ #[test]
+ fn mjb_llr_040_operation_counts_are_byte_lengths() {
+ let insert = Operation::Insert("文".to_owned());
+ assert_eq!(insert.produced(), 3, "one character, three bytes");
+ assert_eq!(insert.consumed(), 0, "an insert consumes no input");
+
+ assert_eq!(Operation::Retain(4).consumed(), 4);
+ assert_eq!(Operation::Retain(4).produced(), 4);
+ assert_eq!(Operation::Delete(4).consumed(), 4);
+ assert_eq!(Operation::Delete(4).produced(), 0);
+ }
+
+ /// MJB-LLR-041: `len` is the required pre-image length, `len_after` the
+ /// post-image length.
+ #[test]
+ fn mjb_llr_041_changeset_records_both_lengths() {
+ let r = rope("abcdef");
+ // Replace two bytes with three.
+ let t = Transaction::change(&r, [(1, 3, Some("XYZ".into()))]);
+ assert_eq!(t.changes.len(), 6, "must match the source document");
+ assert_eq!(t.changes.len_after(), 7, "6 - 2 + 3");
+
+ let mut m = r.clone();
+ t.changes.apply(&mut m).unwrap();
+ assert_eq!(m.len(), t.changes.len_after());
+ }
+
+ #[test]
+ fn mjb_llr_041_empty_changeset_reports_equal_lengths() {
+ let r = rope("abc");
+ let cs = ChangeSet::new(&r);
+ assert_eq!(cs.len(), 3);
+ assert_eq!(cs.len_after(), 3);
+ assert!(cs.is_empty());
+ }
+
+ /// MJB-LLR-047: a transaction carries the selection that should result.
+ #[test]
+ fn mjb_llr_047_transaction_carries_a_selection() {
+ use super::super::selection::Range;
+ let r = rope("abcdef");
+ let t = Transaction::change(&r, [(0, 1, None)]);
+ assert_eq!(t.selection, None, "none by default");
+
+ let sel = Selection::single(Range::new(0, 2));
+ let t = t.with_selection(sel.clone());
+ assert_eq!(t.selection, Some(sel));
+ }
+
+ #[test]
+ fn mjb_llr_043_apply_insert_and_delete() {
+ let mut r = rope("hello world");
+ let t = Transaction::change(&r, [(0, 5, Some("goodbye".into()))]);
+ t.changes.apply(&mut r).unwrap();
+ assert_eq!(r.to_string(), "goodbye world");
+ }
+
+ #[test]
+ fn mjb_llr_042_length_mismatch_is_rejected() {
+ let r = rope("abcdef");
+ let t = Transaction::change(&r, [(0, 1, None)]);
+ let mut other = rope("shorter than expected? no — different");
+ let err = t.changes.apply(&mut other).unwrap_err();
+ assert!(matches!(err, ChangeError::LengthMismatch { .. }));
+ }
+
+ #[test]
+ fn mjb_llr_042_rejected_change_leaves_rope_untouched() {
+ let r = rope("abcdef");
+ let t = Transaction::change(&r, [(0, 3, Some("xyz".into()))]);
+ let mut other = rope("12345678");
+ let before = other.to_string();
+ assert!(t.changes.apply(&mut other).is_err());
+ assert_eq!(other.to_string(), before, "must not partially apply");
+ }
+
+ #[test]
+ fn mjb_llr_044_non_char_boundary_errors_rather_than_panics() {
+ // Split "文" (3 bytes) after its first byte.
+ let mut r = rope("文");
+ let cs = ChangeSet::from_ops(vec![Operation::Retain(1), Operation::Delete(2)]);
+ let err = cs.apply(&mut r).unwrap_err();
+ assert_eq!(err, ChangeError::NonCharBoundary(1));
+ assert_eq!(r.to_string(), "文", "rope must be unchanged");
+ }
+
+ #[test]
+ fn mjb_llr_045_invert_maps_each_operation() {
+ let r = rope("abcdef");
+ let t = Transaction::change(&r, [(1, 3, Some("XY".into()))]);
+ let inv = t.changes.invert(&r);
+ // Delete(2) became Insert("bc"); Insert("XY") became Delete(2).
+ assert!(inv.ops().contains(&Operation::Insert("bc".into())));
+ assert!(inv.ops().contains(&Operation::Delete(2)));
+ }
+
+ #[test]
+ fn mjb_llr_046_apply_then_invert_round_trips() {
+ for (text, from, to, ins) in [
+ ("hello world", 0usize, 5usize, Some("goodbye")),
+ ("hello world", 5, 11, None),
+ ("", 0, 0, Some("new")),
+ ("文字化け", 0, 3, Some("X")),
+ ("no trailing newline", 3, 3, Some(" inserted")),
+ ] {
+ let original = rope(text);
+ let mut r = original.clone();
+ let t = Transaction::change(&r, [(from, to, ins.map(str::to_owned))]);
+ let inverse = t.changes.invert(&original);
+
+ t.changes.apply(&mut r).unwrap();
+ inverse.apply(&mut r).unwrap();
+
+ assert_eq!(
+ r.to_string(),
+ original.to_string(),
+ "round trip failed for {text:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn mjb_llr_048_multiple_ordered_changes() {
+ let mut r = rope("aaa bbb ccc");
+ let t = Transaction::change(&r, [(0, 3, Some("XXX".into())), (8, 11, Some("ZZZ".into()))]);
+ t.changes.apply(&mut r).unwrap();
+ assert_eq!(r.to_string(), "XXX bbb ZZZ");
+ }
+
+ #[test]
+ fn mjb_llr_049_insert_at_cursor() {
+ let r = rope("ab");
+ let sel = Selection::point(1);
+ let mut m = r.clone();
+ Transaction::insert(&r, &sel, "X")
+ .changes
+ .apply(&mut m)
+ .unwrap();
+ assert_eq!(m.to_string(), "aXb");
+ }
+
+ #[test]
+ fn mjb_llr_050_delete_selection_span() {
+ use super::super::selection::Range;
+ let r = rope("abcdef");
+ let sel = Selection::single(Range::new(1, 4));
+ let mut m = r.clone();
+ Transaction::delete(&r, &sel)
+ .changes
+ .apply(&mut m)
+ .unwrap();
+ assert_eq!(m.to_string(), "aef");
+ }
+
+ #[test]
+ fn empty_document_accepts_insert() {
+ let mut r = rope("");
+ let t = Transaction::change(&r, [(0, 0, Some("x".into()))]);
+ t.changes.apply(&mut r).unwrap();
+ assert_eq!(r.to_string(), "x");
+ }
+}
diff --git a/src/buffer/view.rs b/src/buffer/view.rs
new file mode 100644
index 0000000..e419052
--- /dev/null
+++ b/src/buffer/view.rs
@@ -0,0 +1,371 @@
+//! Viewport — after Helix's `helix-view/src/view.rs`.
+//!
+//! The pagination requirement (MJB-HLR-012) is met structurally, not by
+//! optimisation: the viewport is anchored by the **byte offset of the first
+//! visible line**, and rendering walks `lines_at(top_line)` for at most
+//! `height` lines. Nothing in this module iterates the whole rope, so per-frame
+//! cost is O(viewport) whatever the file size.
+//!
+//! Helix's `ViewPosition` also carries a `vertical_offset` addressing rows
+//! within a soft-wrapped line. There is no soft wrap here, so one buffer line
+//! is exactly one screen row and the field is omitted — see MJB-DR-003.
+
+use ropey::RopeSlice;
+
+use super::{
+ LINE_TYPE,
+ grapheme::display_column,
+ movement::last_content_line,
+ selection::Range,
+};
+
+/// MJB-LLR-090
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct ViewPosition {
+ /// Byte offset of the first visible line's start. Always a line start.
+ pub anchor: usize,
+ /// Leftmost visible display column.
+ pub horizontal_offset: usize,
+}
+
+#[derive(Debug, Clone, Copy, Default)]
+pub struct View {
+ pub offset: ViewPosition,
+}
+
+impl View {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// MJB-LLR-091
+ pub fn top_line(&self, text: RopeSlice) -> usize {
+ let anchor = self.offset.anchor.min(text.len());
+ text.byte_to_line_idx(anchor, LINE_TYPE)
+ }
+
+ /// MJB-LLR-092: the visible line range, `(first, count)`.
+ ///
+ /// Deliberately returns indices rather than content so the caller can drive
+ /// `lines_at` directly and touch no other line.
+ pub fn visible_line_range(&self, text: RopeSlice, height: usize) -> (usize, usize) {
+ let top = self.top_line(text);
+ let total = text.len_lines(LINE_TYPE);
+ let count = height.min(total.saturating_sub(top));
+ (top, count)
+ }
+
+ /// MJB-LLR-092: at most `height` line slices, starting at the top line.
+ pub fn visible_lines<'a>(
+ &self,
+ text: RopeSlice<'a>,
+ height: usize,
+ ) -> impl Iterator<Item = RopeSlice<'a>> {
+ let (top, count) = self.visible_line_range(text, height);
+ text.lines_at(top, LINE_TYPE).take(count)
+ }
+
+ /// Set the top line directly, clamping into the buffer (MJB-LLR-097).
+ pub fn set_top_line(&mut self, text: RopeSlice, line: usize) {
+ let last = text.len_lines(LINE_TYPE).saturating_sub(1);
+ let line = line.min(last);
+ self.offset.anchor = text.line_to_byte_idx(line, LINE_TYPE);
+ }
+
+ /// MJB-LLR-093..098: scroll vertically so the cursor sits inside the
+ /// scroll-off margins.
+ pub fn ensure_cursor_in_view(
+ &mut self,
+ text: RopeSlice,
+ range: Range,
+ height: usize,
+ scrolloff: usize,
+ ) {
+ // MJB-LLR-098: a zero-height viewport has no inside; the margin
+ // arithmetic below would underflow.
+ if height == 0 {
+ return;
+ }
+
+ // MJB-LLR-093: Helix clamps the margins to half the viewport, so a
+ // scrolloff larger than the viewport cannot fight itself.
+ let scrolloff_top = scrolloff.min((height - 1) / 2);
+ let scrolloff_bottom = scrolloff.min(height / 2);
+
+ let cursor_line = range.cursor_line(text);
+ let top = self.top_line(text);
+
+ let new_top = if cursor_line < top + scrolloff_top {
+ // MJB-LLR-094
+ Some(cursor_line.saturating_sub(scrolloff_top))
+ } else if cursor_line + scrolloff_bottom >= top + height {
+ // MJB-LLR-095
+ Some((cursor_line + scrolloff_bottom + 1).saturating_sub(height))
+ } else {
+ // MJB-LLR-096
+ None
+ };
+
+ if let Some(t) = new_top {
+ self.set_top_line(text, t);
+ }
+ }
+
+ /// MJB-LLR-099: scroll horizontally so the cursor's column is visible.
+ pub fn ensure_horizontal_in_view(&mut self, text: RopeSlice, range: Range, width: usize) {
+ if width == 0 {
+ return;
+ }
+ let cursor = range.cursor(text);
+ let line = text.byte_to_line_idx(cursor, LINE_TYPE);
+ let line_start = text.line_to_byte_idx(line, LINE_TYPE);
+ let column = display_column(text.line(line, LINE_TYPE), cursor - line_start);
+
+ if column < self.offset.horizontal_offset {
+ self.offset.horizontal_offset = column;
+ } else if column >= self.offset.horizontal_offset + width {
+ self.offset.horizontal_offset = column + 1 - width;
+ }
+ }
+
+ /// MJB-LLR-100, MJB-LLR-101, MJB-LLR-102: move cursor and viewport together
+ /// by `lines`, saturating at the buffer's ends.
+ pub fn page(&mut self, text: RopeSlice, range: Range, lines: usize, down: bool) -> Range {
+ let last = last_content_line(text);
+ let cursor_line = range.cursor_line(text);
+ let top = self.top_line(text);
+
+ let (target_line, new_top) = if down {
+ (
+ cursor_line.saturating_add(lines).min(last),
+ top.saturating_add(lines),
+ )
+ } else {
+ (
+ cursor_line.saturating_sub(lines),
+ top.saturating_sub(lines),
+ )
+ };
+
+ self.set_top_line(text, new_top);
+
+ // Land on the same display column where the target line allows it.
+ let line_start = text.line_to_byte_idx(cursor_line, LINE_TYPE);
+ let column = display_column(text.line(cursor_line, LINE_TYPE), range.cursor(text) - line_start);
+ let target_start = text.line_to_byte_idx(target_line, LINE_TYPE);
+ let offset =
+ super::grapheme::byte_at_display_column(text.line(target_line, LINE_TYPE), column);
+
+ Range::point(target_start + offset).clamped(text)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+
+ /// 100 lines: "line0\nline1\n...".
+ fn doc(n: usize) -> Rope {
+ let mut s = String::new();
+ for i in 0..n {
+ s.push_str(&format!("line{i}\n"));
+ }
+ Rope::from_str(&s)
+ }
+
+ fn at_line(text: RopeSlice, line: usize) -> Range {
+ Range::point(text.line_to_byte_idx(line, LINE_TYPE))
+ }
+
+ /// MJB-LLR-090: the anchor is a **byte** offset and always a line start.
+ #[test]
+ fn mjb_llr_090_anchor_is_a_byte_offset_at_a_line_start() {
+ // Multi-byte lines, so a byte anchor differs from a line index.
+ let t = Rope::from_str("文字\n化け\n三行\n");
+ let s = t.slice(..);
+ let mut v = View::new();
+
+ v.set_top_line(s, 1);
+ assert_eq!(v.offset.anchor, 7, "byte offset, not line index");
+ assert_eq!(
+ v.offset.anchor,
+ s.line_to_byte_idx(1, LINE_TYPE),
+ "anchor must land exactly on a line start"
+ );
+ assert_eq!(v.top_line(s), 1, "and convert back");
+
+ assert_eq!(v.offset.horizontal_offset, 0, "columns start unscrolled");
+ }
+
+ #[test]
+ fn mjb_llr_091_top_line_from_anchor() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 4);
+ assert_eq!(v.top_line(s), 4);
+ }
+
+ /// MJB-LLR-092: the renderer must see exactly the visible window.
+ #[test]
+ fn mjb_llr_092_visible_lines_are_bounded_by_height() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 10);
+
+ let lines: Vec<String> = v.visible_lines(s, 5).map(|l| l.to_string()).collect();
+ assert_eq!(lines.len(), 5, "must not exceed the viewport height");
+ assert_eq!(lines[0], "line10\n");
+ assert_eq!(lines[4], "line14\n");
+ }
+
+ #[test]
+ fn mjb_llr_092_visible_lines_clamp_near_end_of_buffer() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 9);
+ // 10 content lines plus the trailing empty line ropey reports.
+ let count = v.visible_lines(s, 20).count();
+ assert!(count <= 2, "must not run past the end, got {count}");
+ }
+
+ #[test]
+ fn mjb_llr_096_no_scroll_when_cursor_is_comfortable() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 10);
+ let before = v.offset;
+ v.ensure_cursor_in_view(s, at_line(s, 15), 20, 5);
+ assert_eq!(v.offset, before, "cursor already inside both margins");
+ }
+
+ #[test]
+ fn mjb_llr_094_scrolls_up_to_honour_top_margin() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 20);
+ v.ensure_cursor_in_view(s, at_line(s, 21), 20, 5);
+ assert_eq!(v.top_line(s), 16, "cursor_line - scrolloff_top");
+ }
+
+ #[test]
+ fn mjb_llr_095_scrolls_down_to_honour_bottom_margin() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 0);
+ // height 20, scrolloff 5 -> cursor at 18 forces top to 18+5+1-20 = 4.
+ v.ensure_cursor_in_view(s, at_line(s, 18), 20, 5);
+ assert_eq!(v.top_line(s), 4);
+ }
+
+ /// MJB-LLR-093: scrolloff exceeding the viewport must be clamped, not
+ /// allowed to drive the anchor past the cursor.
+ #[test]
+ fn mjb_llr_093_scrolloff_larger_than_viewport_is_clamped() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 50);
+ v.ensure_cursor_in_view(s, at_line(s, 50), 10, 999);
+ // Margins clamp to (10-1)/2 = 4 and 10/2 = 5.
+ assert_eq!(v.top_line(s), 46);
+ }
+
+ /// MJB-LLR-098: a zero-height viewport must not underflow `height - 1`.
+ #[test]
+ fn mjb_llr_098_zero_height_viewport_is_a_noop() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 3);
+ let before = v.offset;
+ v.ensure_cursor_in_view(s, at_line(s, 9), 0, 5);
+ assert_eq!(v.offset, before);
+ }
+
+ #[test]
+ fn mjb_llr_097_top_line_clamps_into_buffer() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 9_999);
+ assert!(v.top_line(s) < t.len_lines(LINE_TYPE));
+ }
+
+ #[test]
+ fn mjb_llr_094_scroll_near_start_saturates_at_zero() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 2);
+ v.ensure_cursor_in_view(s, at_line(s, 0), 20, 5);
+ assert_eq!(v.top_line(s), 0, "must not underflow below line zero");
+ }
+
+ #[test]
+ fn mjb_llr_099_horizontal_scroll_follows_cursor() {
+ let t = Rope::from_str(&format!("{}\n", "x".repeat(200)));
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.ensure_horizontal_in_view(s, Range::point(150), 80);
+ assert_eq!(v.offset.horizontal_offset, 150 + 1 - 80);
+
+ v.ensure_horizontal_in_view(s, Range::point(10), 80);
+ assert_eq!(v.offset.horizontal_offset, 10, "scrolls back left");
+ }
+
+ #[test]
+ fn mjb_llr_100_half_page_moves_cursor_and_view() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 0);
+ let moved = v.page(s, at_line(s, 0), 10, true);
+ assert_eq!(moved.cursor_line(s), 10, "cursor moved");
+ assert_eq!(v.top_line(s), 10, "and the viewport moved with it");
+ }
+
+ #[test]
+ fn mjb_llr_101_full_page_moves_by_height() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 0);
+ let moved = v.page(s, at_line(s, 0), 20, true);
+ assert_eq!(moved.cursor_line(s), 20);
+ assert_eq!(v.top_line(s), 20);
+ }
+
+ #[test]
+ fn mjb_llr_102_paging_saturates_at_both_ends() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+
+ let up = v.page(s, at_line(s, 0), 50, false);
+ assert_eq!(up.cursor_line(s), 0, "must not underflow");
+ assert_eq!(v.top_line(s), 0);
+
+ let down = v.page(s, at_line(s, 0), 500, true);
+ assert!(
+ down.cursor_line(s) <= last_content_line(s),
+ "must not run past the last content line"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_092_empty_buffer_renders_safely() {
+ let t = Rope::from_str("");
+ let s = t.slice(..);
+ let v = View::new();
+ assert_eq!(v.top_line(s), 0);
+ let _ = v.visible_lines(s, 10).count();
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000..9b50bcb
--- /dev/null
+++ b/src/cli.rs
@@ -0,0 +1,54 @@
+use std::path::PathBuf;
+
+use clap::Parser;
+
+use crate::config::{get_config_dir, get_data_dir};
+
+#[derive(Parser, Debug)]
+#[command(author, version = version(), about)]
+pub struct Cli {
+ /// File to edit. A path that does not exist is created on write.
+ // MJB-LLR-111, MJB-LLR-112
+ #[arg(value_name = "FILE")]
+ pub file: Option<PathBuf>,
+
+ /// Tick rate, i.e. number of ticks per second
+ #[arg(short, long, value_name = "FLOAT", default_value_t = 4.0)]
+ pub tick_rate: f64,
+
+ /// Frame rate, i.e. number of frames per second
+ #[arg(short, long, value_name = "FLOAT", default_value_t = 60.0)]
+ pub frame_rate: f64,
+}
+
+const VERSION_MESSAGE: &str = concat!(
+ env!("CARGO_PKG_VERSION"),
+ "-",
+ env!("VERGEN_GIT_DESCRIBE"),
+ " (",
+ env!("VERGEN_BUILD_DATE"),
+ ")"
+);
+
+/// The `origin` remote of the checkout this binary was built from, emitted by
+/// `build.rs`.
+const GIT_REMOTE_URL: &str = env!("VERGEN_GIT_REMOTE_URL");
+
+pub fn version() -> String {
+ let author = clap::crate_authors!();
+
+ // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string();
+ let config_dir_path = get_config_dir().display().to_string();
+ let data_dir_path = get_data_dir().display().to_string();
+
+ format!(
+ "\
+{VERSION_MESSAGE}
+
+Authors: {author}
+
+Repository: {GIT_REMOTE_URL}
+Config directory: {config_dir_path}
+Data directory: {data_dir_path}"
+ )
+}
diff --git a/src/components.rs b/src/components.rs
new file mode 100644
index 0000000..1771a17
--- /dev/null
+++ b/src/components.rs
@@ -0,0 +1,125 @@
+use crossterm::event::{KeyEvent, MouseEvent};
+use ratatui::{
+ Frame,
+ layout::{Rect, Size},
+};
+use tokio::sync::mpsc::UnboundedSender;
+
+use crate::{action::Action, config::Config, tui::Event};
+
+// MJB-LLR-204, MJB-HLR-019: the buffer is the only widget. The template's
+// `fps` and `home` modules were deleted, not merely unregistered.
+pub mod buffer;
+
+/// `Component` is a trait that represents a visual and interactive element of the user interface.
+///
+/// Implementors of this trait can be registered with the main application loop and will be able to
+/// receive events, update state, and be rendered on the screen.
+pub trait Component {
+ /// Register an action handler that can send actions for processing if necessary.
+ ///
+ /// # Arguments
+ ///
+ /// * `tx` - An unbounded sender that can send actions.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<()>`] - An Ok result or an error.
+ fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> color_eyre::Result<()> {
+ let _ = tx; // to appease clippy
+ Ok(())
+ }
+ /// Register a configuration handler that provides configuration settings if necessary.
+ ///
+ /// # Arguments
+ ///
+ /// * `config` - Configuration settings.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<()>`] - An Ok result or an error.
+ fn register_config_handler(&mut self, config: Config) -> color_eyre::Result<()> {
+ let _ = config; // to appease clippy
+ Ok(())
+ }
+ /// Initialize the component with a specified area if necessary.
+ ///
+ /// # Arguments
+ ///
+ /// * `area` - Rectangular area to initialize the component within.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<()>`] - An Ok result or an error.
+ fn init(&mut self, area: Size) -> color_eyre::Result<()> {
+ let _ = area; // to appease clippy
+ Ok(())
+ }
+ /// Handle incoming events and produce actions if necessary.
+ ///
+ /// # Arguments
+ ///
+ /// * `event` - An optional event to be processed.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<Option<Action>>`] - An action to be processed or none.
+ fn handle_events(&mut self, event: Option<Event>) -> color_eyre::Result<Option<Action>> {
+ let action = match event {
+ Some(Event::Key(key_event)) => self.handle_key_event(key_event)?,
+ Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?,
+ _ => None,
+ };
+ Ok(action)
+ }
+ /// Handle key events and produce actions if necessary.
+ ///
+ /// # Arguments
+ ///
+ /// * `key` - A key event to be processed.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<Option<Action>>`] - An action to be processed or none.
+ fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<Option<Action>> {
+ let _ = key; // to appease clippy
+ Ok(None)
+ }
+ /// Handle mouse events and produce actions if necessary.
+ ///
+ /// # Arguments
+ ///
+ /// * `mouse` - A mouse event to be processed.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<Option<Action>>`] - An action to be processed or none.
+ fn handle_mouse_event(&mut self, mouse: MouseEvent) -> color_eyre::Result<Option<Action>> {
+ let _ = mouse; // to appease clippy
+ Ok(None)
+ }
+ /// Update the state of the component based on a received action. (REQUIRED)
+ ///
+ /// # Arguments
+ ///
+ /// * `action` - An action that may modify the state of the component.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<Option<Action>>`] - An action to be processed or none.
+ fn update(&mut self, action: Action) -> color_eyre::Result<Option<Action>> {
+ let _ = action; // to appease clippy
+ Ok(None)
+ }
+ /// Render the component on the screen. (REQUIRED)
+ ///
+ /// # Arguments
+ ///
+ /// * `f` - A frame used for rendering.
+ /// * `area` - The area in which the component should be drawn.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<()>`] - An Ok result or an error.
+ fn draw(&mut self, frame: &mut Frame, area: Rect) -> color_eyre::Result<()>;
+}
diff --git a/src/components/buffer.rs b/src/components/buffer.rs
new file mode 100644
index 0000000..360ef7c
--- /dev/null
+++ b/src/components/buffer.rs
@@ -0,0 +1,237 @@
+//! The buffer widget — the only widget mojibake presents (MJB-HLR-019).
+//!
+//! Rendering is deliberately thin: it asks [`View::visible_lines`] for at most
+//! `height` line slices and draws those. Nothing here walks the document, so
+//! per-frame cost is O(viewport) however large the file (MJB-LLR-200).
+
+use std::path::PathBuf;
+
+use color_eyre::eyre::eyre;
+use crossterm::event::KeyEvent;
+use ratatui::{
+ Frame,
+ layout::{Constraint, Layout, Rect},
+ style::{Style, Stylize},
+ text::{Line, Span},
+ widgets::Paragraph,
+};
+use tokio::sync::mpsc::UnboundedSender;
+
+use super::Component;
+use crate::{
+ action::Action,
+ buffer::{
+ Buffer, LINE_TYPE, Outcome,
+ grapheme::{display_column, grapheme_str, grapheme_width, next_grapheme_boundary},
+ },
+ config::{Config, Mode},
+};
+
+pub struct BufferComponent {
+ buffer: Buffer,
+ command_tx: Option<UnboundedSender<Action>>,
+}
+
+impl BufferComponent {
+ pub fn new(config: Config, path: Option<PathBuf>) -> color_eyre::Result<Self> {
+ let buffer = Buffer::new(config, path).map_err(|e| eyre!("{e}"))?;
+ Ok(Self {
+ buffer,
+ command_tx: None,
+ })
+ }
+
+ pub fn buffer(&self) -> &Buffer {
+ &self.buffer
+ }
+
+ /// Width of the line-number gutter, sized to the largest line number.
+ ///
+ /// Counts digits arithmetically rather than formatting the number, since
+ /// this runs every frame. `u16` cannot truncate here in practice — it would
+ /// take more than 10^65000 lines — but the conversion is still checked
+ /// rather than cast, and saturates to the terminal's own maximum width.
+ fn gutter_width(&self) -> u16 {
+ let lines = self.buffer.document.len_lines().max(1);
+ let digits = lines.ilog10() as usize + 1;
+ u16::try_from(digits + 1).unwrap_or(u16::MAX) // digits + one space of padding
+ }
+
+ /// MJB-LLR-202: mode, path, modified marker and cursor position.
+ fn status_line(&self) -> String {
+ let doc = &self.buffer.document;
+ let name = doc
+ .path()
+ .map(|p| p.display().to_string())
+ .unwrap_or_else(|| "[scratch]".to_owned());
+ let modified = if doc.is_modified() { " [+]" } else { "" };
+
+ let text = doc.slice();
+ let cursor = doc.range().cursor(text);
+ let line = text.byte_to_line_idx(cursor, LINE_TYPE);
+ let line_start = text.line_to_byte_idx(line, LINE_TYPE);
+ let col = display_column(text.line(line, LINE_TYPE), cursor - line_start);
+
+ format!(
+ " {} {name}{modified} {}:{} ",
+ self.buffer.mode,
+ line + 1,
+ col + 1
+ )
+ }
+
+ /// The bottom row: the command line while in command mode, otherwise any
+ /// transient message, otherwise the pending key sequence.
+ fn message_line(&self) -> String {
+ if self.buffer.mode == Mode::Command {
+ return format!(":{}", self.buffer.command_line);
+ }
+ if let Some(msg) = &self.buffer.status {
+ return msg.clone();
+ }
+ if !self.buffer.pending_keys().is_empty() {
+ let keys: String = self
+ .buffer
+ .pending_keys()
+ .iter()
+ .map(crate::config::key_event_to_string)
+ .collect();
+ return keys;
+ }
+ String::new()
+ }
+}
+
+impl Component for BufferComponent {
+ fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> color_eyre::Result<()> {
+ self.command_tx = Some(tx);
+ Ok(())
+ }
+
+ fn register_config_handler(&mut self, _config: Config) -> color_eyre::Result<()> {
+ // The config is supplied at construction, because the document must be
+ // opened with it; re-registering would discard buffer state.
+ Ok(())
+ }
+
+ fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<Option<Action>> {
+ Ok(match self.buffer.handle_key(key) {
+ Outcome::Consumed => None,
+ Outcome::Quit => Some(Action::Quit),
+ Outcome::Suspend => Some(Action::Suspend),
+ })
+ }
+
+ fn draw(&mut self, frame: &mut Frame, area: Rect) -> color_eyre::Result<()> {
+ // MJB-LLR-202: reserve the last two rows for status and message.
+ let [text_area, status_area, message_area] = Layout::vertical([
+ Constraint::Min(0),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .areas(area);
+
+ let gutter = self.gutter_width();
+ let [gutter_area, content_area] =
+ Layout::horizontal([Constraint::Length(gutter), Constraint::Min(0)]).areas(text_area);
+
+ let height = content_area.height as usize;
+ let width = content_area.width as usize;
+ self.buffer.update_view(width, height);
+
+ let mode = self.buffer.mode;
+ let cfg = self.buffer.config();
+ let cursor_style = cfg.style(mode, "cursor");
+ let selection_style = cfg.style(mode, "selection");
+ let linenr_style = cfg.style(mode, "linenr");
+ let status_style = cfg.style(mode, "statusline");
+
+ let doc = &self.buffer.document;
+ let text = doc.slice();
+ let range = doc.range();
+ let cursor_byte = range.cursor(text);
+ let sel_from = range.from();
+ let sel_to = range.to();
+
+ let (top_line, count) = self.buffer.view.visible_line_range(text, height);
+ let h_offset = self.buffer.view.offset.horizontal_offset;
+
+ let mut rows: Vec<Line> = Vec::with_capacity(count);
+ let mut gutter_rows: Vec<Line> = Vec::with_capacity(count);
+
+ // MJB-LLR-200: only the visible lines are touched.
+ for (i, line) in self.buffer.view.visible_lines(text, height).enumerate() {
+ let line_idx = top_line + i;
+ let line_start = text.line_to_byte_idx(line_idx, LINE_TYPE);
+
+ gutter_rows.push(Line::from(Span::styled(
+ format!("{:>w$} ", line_idx + 1, w = (gutter as usize).saturating_sub(1)),
+ linenr_style,
+ )));
+
+ // MJB-LLR-201: style the cursor and the selection span.
+ let mut spans: Vec<Span> = Vec::new();
+ let mut column = 0usize;
+ let mut byte = 0usize;
+ let line_len = line.len();
+
+ while byte < line_len {
+ let next = next_grapheme_boundary(line, byte);
+ if next <= byte {
+ break;
+ }
+ // Borrows from the rope unless the grapheme straddles a chunk.
+ let g = grapheme_str(line, byte..next);
+ if matches!(g.as_ref(), "\n" | "\r\n" | "\r") {
+ break;
+ }
+
+ let abs = line_start + byte;
+ let w = grapheme_width(&g, column);
+
+ let style = if abs == cursor_byte {
+ cursor_style
+ } else if abs >= sel_from && abs < sel_to {
+ selection_style
+ } else {
+ Style::default()
+ };
+
+ // Horizontal scrolling: skip graphemes left of the offset.
+ if column + w > h_offset {
+ // A tab is stored as one byte but occupies `w` columns.
+ let rendered = if g == "\t" {
+ " ".repeat(w)
+ } else {
+ g.into_owned()
+ };
+ spans.push(Span::styled(rendered, style));
+ }
+
+ column += w;
+ byte = next;
+ }
+
+ // A cursor sitting past the last character (end of line, or an
+ // empty line) still needs a visible block.
+ if cursor_byte == line_start + byte && cursor_byte >= line_start {
+ spans.push(Span::styled(" ", cursor_style));
+ }
+
+ rows.push(Line::from(spans));
+ }
+
+ frame.render_widget(Paragraph::new(gutter_rows), gutter_area);
+ frame.render_widget(Paragraph::new(rows), content_area);
+ frame.render_widget(
+ Paragraph::new(Line::from(Span::styled(self.status_line(), status_style))),
+ status_area,
+ );
+ frame.render_widget(
+ Paragraph::new(Line::from(self.message_line())).dim(),
+ message_area,
+ );
+
+ Ok(())
+ }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..328d1d6
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,888 @@
+use std::{collections::HashMap, env, path::PathBuf, sync::LazyLock};
+
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use directories::ProjectDirs;
+use ratatui::style::{Color, Modifier, Style};
+use serde::{Deserialize, Serialize, de::Deserializer};
+use tracing::error;
+
+use crate::buffer::command::Command;
+
+/// MJB-LLR-180: the compiled-in default configuration is the very file the
+/// editor also reads at runtime, so defaults and documentation cannot drift.
+const CONFIG: &str = include_str!("../.config/config.toml");
+
+/// Editor mode. Doubles as the scope key for both key bindings and styles.
+///
+/// MJB-LLR-184. `Global` is consulted only by `App`, never by the buffer; see
+/// MJB-DR-004 for why keymap ownership is split.
+#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Mode {
+ #[default]
+ Normal,
+ Insert,
+ Select,
+ Command,
+ Global,
+}
+
+impl std::fmt::Display for Mode {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let name = match self {
+ Mode::Normal => "NOR",
+ Mode::Insert => "INS",
+ Mode::Select => "SEL",
+ Mode::Command => "CMD",
+ Mode::Global => "GLB",
+ };
+ f.write_str(name)
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Default)]
+pub struct AppConfig {
+ #[serde(default)]
+ pub data_dir: PathBuf,
+ #[serde(default)]
+ pub config_dir: PathBuf,
+}
+
+/// MJB-LLR-185. Kept in its own table rather than at top level: `Config`
+/// flattens `AppConfig`, and config-rs stringifies values buffered through a
+/// flattened map, which would break these non-string fields.
+#[derive(Clone, Copy, Debug, Deserialize)]
+pub struct EditorConfig {
+ #[serde(default = "default_scrolloff")]
+ pub scrolloff: usize,
+ #[serde(default = "default_insert_final_newline")]
+ pub insert_final_newline: bool,
+}
+
+fn default_scrolloff() -> usize {
+ 5
+}
+
+fn default_insert_final_newline() -> bool {
+ true
+}
+
+impl Default for EditorConfig {
+ fn default() -> Self {
+ Self {
+ scrolloff: default_scrolloff(),
+ insert_final_newline: default_insert_final_newline(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Default, Deserialize)]
+pub struct Config {
+ #[serde(default, flatten)]
+ pub config: AppConfig,
+ #[serde(default)]
+ pub editor: EditorConfig,
+ #[serde(default)]
+ pub keybindings: KeyBindings,
+ #[serde(default)]
+ pub styles: Styles,
+}
+
+/// The application's own name, independent of the Cargo package name.
+///
+/// Stated explicitly rather than derived from `CARGO_PKG_NAME`, which is
+/// `mojibake-editor` (the crate name on crates.io), or from
+/// `CARGO_CRATE_NAME`, which differs between the `mojibake` library target and
+/// the `moji` binary target. Deriving from either would silently move the
+/// user's configuration directory and log file when a target is renamed.
+pub const APP_NAME: &str = "mojibake";
+
+pub static PROJECT_NAME: LazyLock<String> = LazyLock::new(|| APP_NAME.to_uppercase());
+pub static DATA_FOLDER: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
+ env::var(format!("{}_DATA", PROJECT_NAME.clone()))
+ .ok()
+ .map(PathBuf::from)
+});
+pub static CONFIG_FOLDER: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
+ env::var(format!("{}_CONFIG", PROJECT_NAME.clone()))
+ .ok()
+ .map(PathBuf::from)
+});
+
+impl Config {
+ pub fn new() -> color_eyre::Result<Self, config::ConfigError> {
+ // MJB-LLR-180. A malformed baked-in default is a build defect, but it
+ // must still surface as an error rather than a panic (MJB-HLR-018).
+ let default_config: Config = toml::from_str(CONFIG).map_err(|e| {
+ config::ConfigError::Message(format!("built-in default config is invalid: {e}"))
+ })?;
+
+ let data_dir = get_data_dir();
+ let config_dir = get_config_dir();
+ let mut builder = config::Config::builder()
+ .set_default("data_dir", path_to_setting(&data_dir)?)?
+ .set_default("config_dir", path_to_setting(&config_dir)?)?;
+
+ // MJB-LLR-181: TOML is the only format consulted. The json5/yaml/ini
+ // readers are not merely unused here — `default-features = false` in
+ // Cargo.toml keeps them out of the dependency graph entirely.
+ let config_file = config_dir.join("config.toml");
+ if !config_file.exists() {
+ error!(
+ "No configuration file at {}; built-in defaults will be used",
+ config_file.display()
+ );
+ }
+ builder = builder.add_source(
+ config::File::from(config_file)
+ .format(config::FileFormat::Toml)
+ .required(false),
+ );
+
+ let mut cfg: Self = builder.build()?.try_deserialize()?;
+
+ // MJB-LLR-182: merge per individual binding, so a user who rebinds one
+ // key keeps every default they did not mention.
+ for (mode, default_bindings) in default_config.keybindings.0.iter() {
+ let user_bindings = cfg.keybindings.0.entry(*mode).or_default();
+ for (key, cmd) in default_bindings.iter() {
+ user_bindings.entry(key.clone()).or_insert(*cmd);
+ }
+ }
+ for (mode, default_styles) in default_config.styles.0.iter() {
+ let user_styles = cfg.styles.0.entry(*mode).or_default();
+ for (style_key, style) in default_styles.iter() {
+ user_styles.entry(style_key.clone()).or_insert(*style);
+ }
+ }
+
+ Ok(cfg)
+ }
+
+ /// The style named `key` for `mode`, or [`Style::default`] if unset.
+ pub fn style(&self, mode: Mode, key: &str) -> Style {
+ self.styles
+ .0
+ .get(&mode)
+ .and_then(|m| m.get(key))
+ .copied()
+ .unwrap_or_default()
+ }
+}
+
+/// A path becomes a config setting only if it is valid UTF-8. Reported rather
+/// than unwrapped, per MJB-HLR-018.
+fn path_to_setting(path: &std::path::Path) -> color_eyre::Result<String, config::ConfigError> {
+ path.to_str().map(str::to_owned).ok_or_else(|| {
+ config::ConfigError::Message(format!("path is not valid UTF-8: {}", path.display()))
+ })
+}
+
+pub fn get_data_dir() -> PathBuf {
+ if let Some(s) = DATA_FOLDER.clone() {
+ s
+ } else if let Some(proj_dirs) = project_directory() {
+ proj_dirs.data_local_dir().to_path_buf()
+ } else {
+ PathBuf::from(".").join(".data")
+ }
+}
+
+pub fn get_config_dir() -> PathBuf {
+ if let Some(s) = CONFIG_FOLDER.clone() {
+ s
+ } else if let Some(proj_dirs) = project_directory() {
+ proj_dirs.config_local_dir().to_path_buf()
+ } else {
+ PathBuf::from(".").join(".config")
+ }
+}
+
+fn project_directory() -> Option<ProjectDirs> {
+ // The qualifier was the application template author's; mojibake owns its
+ // own directories. `APP_NAME`, not the package name — see its doc comment.
+ ProjectDirs::from("wiki", "mojibake", APP_NAME)
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Command>>);
+
+impl<'de> Deserialize<'de> for KeyBindings {
+ fn deserialize<D>(deserializer: D) -> color_eyre::Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let parsed_map = HashMap::<Mode, HashMap<String, Command>>::deserialize(deserializer)?;
+
+ let mut keybindings = HashMap::new();
+ for (mode, inner_map) in parsed_map {
+ let mut converted = HashMap::new();
+ for (key_str, cmd) in inner_map {
+ // MJB-LLR-183: a bad key string names itself in the error
+ // rather than aborting the process.
+ let keys = parse_key_sequence(&key_str).map_err(|e| {
+ serde::de::Error::custom(format!(
+ "invalid key sequence {key_str:?} in [keybindings.{mode:?}]: {e}"
+ ))
+ })?;
+ converted.insert(keys, cmd);
+ }
+ keybindings.insert(mode, converted);
+ }
+
+ Ok(KeyBindings(keybindings))
+ }
+}
+
+fn parse_key_event(raw: &str) -> color_eyre::Result<KeyEvent, String> {
+ // Modifier prefixes and named keys are matched case-insensitively, but the
+ // final token's case is significant and must survive: `<A>` and `<a>` are
+ // different bindings. Lowercasing the whole string collapsed them onto the
+ // same `KeyCode::Char('a')`, so every shifted binding silently shadowed its
+ // lowercase twin.
+ //
+ // `to_ascii_lowercase` preserves byte length, so an offset found in the
+ // lowercased copy indexes the original correctly.
+ let raw_lower = raw.to_ascii_lowercase();
+ let (offset, modifiers) = extract_modifiers(&raw_lower);
+ parse_key_code_with_modifiers(&raw[offset..], &raw_lower[offset..], modifiers)
+}
+
+/// Returns the byte offset past the modifier prefixes, and the modifiers found.
+fn extract_modifiers(raw_lower: &str) -> (usize, KeyModifiers) {
+ let mut modifiers = KeyModifiers::empty();
+ let mut offset = 0;
+
+ loop {
+ let rest = &raw_lower[offset..];
+ if rest.starts_with("ctrl-") {
+ modifiers.insert(KeyModifiers::CONTROL);
+ offset += 5;
+ } else if rest.starts_with("alt-") {
+ modifiers.insert(KeyModifiers::ALT);
+ offset += 4;
+ } else if rest.starts_with("shift-") {
+ modifiers.insert(KeyModifiers::SHIFT);
+ offset += 6;
+ } else {
+ break;
+ }
+ }
+
+ (offset, modifiers)
+}
+
+fn parse_key_code_with_modifiers(
+ raw: &str,
+ raw_lower: &str,
+ mut modifiers: KeyModifiers,
+) -> color_eyre::Result<KeyEvent, String> {
+ let c = match raw_lower {
+ "esc" => KeyCode::Esc,
+ "enter" => KeyCode::Enter,
+ "left" => KeyCode::Left,
+ "right" => KeyCode::Right,
+ "up" => KeyCode::Up,
+ "down" => KeyCode::Down,
+ "home" => KeyCode::Home,
+ "end" => KeyCode::End,
+ "pageup" => KeyCode::PageUp,
+ "pagedown" => KeyCode::PageDown,
+ "backtab" => {
+ modifiers.insert(KeyModifiers::SHIFT);
+ KeyCode::BackTab
+ }
+ "backspace" => KeyCode::Backspace,
+ "delete" => KeyCode::Delete,
+ "insert" => KeyCode::Insert,
+ "f1" => KeyCode::F(1),
+ "f2" => KeyCode::F(2),
+ "f3" => KeyCode::F(3),
+ "f4" => KeyCode::F(4),
+ "f5" => KeyCode::F(5),
+ "f6" => KeyCode::F(6),
+ "f7" => KeyCode::F(7),
+ "f8" => KeyCode::F(8),
+ "f9" => KeyCode::F(9),
+ "f10" => KeyCode::F(10),
+ "f11" => KeyCode::F(11),
+ "f12" => KeyCode::F(12),
+ "space" => KeyCode::Char(' '),
+ "hyphen" => KeyCode::Char('-'),
+ "minus" => KeyCode::Char('-'),
+ "tab" => KeyCode::Tab,
+ _ if raw.chars().count() == 1 => {
+ // Case comes from the original token, not the lowercased copy.
+ let mut c = raw.chars().next().ok_or("empty key")?;
+ if modifiers.contains(KeyModifiers::SHIFT) {
+ c = c.to_ascii_uppercase();
+ } else if c.is_ascii_uppercase() {
+ // crossterm reports a capital as Char('A') with SHIFT held, so
+ // `<A>` must produce exactly that to ever match.
+ modifiers.insert(KeyModifiers::SHIFT);
+ }
+ KeyCode::Char(c)
+ }
+ _ => return Err(format!("Unable to parse {raw}")),
+ };
+ Ok(KeyEvent::new(c, modifiers))
+}
+
+pub fn key_event_to_string(key_event: &KeyEvent) -> String {
+ let char;
+ let key_code = match key_event.code {
+ KeyCode::Backspace => "backspace",
+ KeyCode::Enter => "enter",
+ KeyCode::Left => "left",
+ KeyCode::Right => "right",
+ KeyCode::Up => "up",
+ KeyCode::Down => "down",
+ KeyCode::Home => "home",
+ KeyCode::End => "end",
+ KeyCode::PageUp => "pageup",
+ KeyCode::PageDown => "pagedown",
+ KeyCode::Tab => "tab",
+ KeyCode::BackTab => "backtab",
+ KeyCode::Delete => "delete",
+ KeyCode::Insert => "insert",
+ KeyCode::F(c) => {
+ char = format!("f({c})");
+ &char
+ }
+ KeyCode::Char(' ') => "space",
+ KeyCode::Char(c) => {
+ char = c.to_string();
+ &char
+ }
+ KeyCode::Esc => "esc",
+ KeyCode::Null => "",
+ KeyCode::CapsLock => "",
+ KeyCode::Menu => "",
+ KeyCode::ScrollLock => "",
+ KeyCode::Media(_) => "",
+ KeyCode::NumLock => "",
+ KeyCode::PrintScreen => "",
+ KeyCode::Pause => "",
+ KeyCode::KeypadBegin => "",
+ KeyCode::Modifier(_) => "",
+ };
+
+ let mut modifiers = Vec::with_capacity(3);
+
+ if key_event.modifiers.intersects(KeyModifiers::CONTROL) {
+ modifiers.push("ctrl");
+ }
+
+ if key_event.modifiers.intersects(KeyModifiers::SHIFT) {
+ modifiers.push("shift");
+ }
+
+ if key_event.modifiers.intersects(KeyModifiers::ALT) {
+ modifiers.push("alt");
+ }
+
+ let mut key = modifiers.join("-");
+
+ if !key.is_empty() {
+ key.push('-');
+ }
+ key.push_str(key_code);
+
+ key
+}
+
+pub fn parse_key_sequence(raw: &str) -> color_eyre::Result<Vec<KeyEvent>, String> {
+ if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() {
+ return Err(format!("Unable to parse `{}`", raw));
+ }
+ let raw = if !raw.contains("><") {
+ let raw = raw.strip_prefix('<').unwrap_or(raw);
+ raw.strip_prefix('>').unwrap_or(raw)
+ } else {
+ raw
+ };
+ let sequences = raw
+ .split("><")
+ .map(|seq| {
+ if let Some(s) = seq.strip_prefix('<') {
+ s
+ } else if let Some(s) = seq.strip_suffix('>') {
+ s
+ } else {
+ seq
+ }
+ })
+ .collect::<Vec<_>>();
+
+ sequences.into_iter().map(parse_key_event).collect()
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);
+
+impl<'de> Deserialize<'de> for Styles {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?;
+
+ let styles = parsed_map
+ .into_iter()
+ .map(|(mode, inner_map)| {
+ let converted_inner_map = inner_map
+ .into_iter()
+ .map(|(str, style)| (str, parse_style(&style)))
+ .collect();
+ (mode, converted_inner_map)
+ })
+ .collect();
+
+ Ok(Styles(styles))
+ }
+}
+
+pub fn parse_style(line: &str) -> Style {
+ let (foreground, background) =
+ line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len()));
+ let foreground = process_color_string(foreground);
+ let background = process_color_string(&background.replace("on ", ""));
+
+ let mut style = Style::default();
+ if let Some(fg) = parse_color(&foreground.0) {
+ style = style.fg(fg);
+ }
+ if let Some(bg) = parse_color(&background.0) {
+ style = style.bg(bg);
+ }
+ style = style.add_modifier(foreground.1 | background.1);
+ style
+}
+
+fn process_color_string(color_str: &str) -> (String, Modifier) {
+ let color = color_str
+ .replace("grey", "gray")
+ .replace("bright ", "")
+ .replace("bold ", "")
+ .replace("underline ", "")
+ .replace("inverse ", "");
+
+ let mut modifiers = Modifier::empty();
+ if color_str.contains("underline") {
+ modifiers |= Modifier::UNDERLINED;
+ }
+ if color_str.contains("bold") {
+ modifiers |= Modifier::BOLD;
+ }
+ if color_str.contains("inverse") {
+ modifiers |= Modifier::REVERSED;
+ }
+
+ (color, modifiers)
+}
+
+fn parse_color(s: &str) -> Option<Color> {
+ let s = s.trim_start();
+ let s = s.trim_end();
+ // Every arm below must tolerate arbitrary user input: these strings come
+ // from `[styles]` in config.toml, and a panic here would take down the
+ // editor at startup (MJB-HLR-018). The original template code indexed
+ // `rgb` operands unchecked and added into `u8` unguarded, so `"rgb"`,
+ // `"rgb1"`, `"gray99"` and `"rgb999"` all aborted the process.
+ if s.contains("bright color") {
+ let c = s
+ .trim_start_matches("bright ")
+ .trim_start_matches("color")
+ .parse::<u8>()
+ .unwrap_or_default();
+ // Bright ANSI colours are the base colour plus 8. The template wrote
+ // `wrapping_shl(8)`, which on a `u8` masks the shift to 8 % 8 == 0 and
+ // so returned the colour unchanged.
+ Some(Color::Indexed(c.saturating_add(8)))
+ } else if s.contains("color") {
+ let c = s
+ .trim_start_matches("color")
+ .parse::<u8>()
+ .unwrap_or_default();
+ Some(Color::Indexed(c))
+ } else if s.contains("gray") {
+ // The xterm grayscale ramp is 24 steps at indices 232..=255.
+ let step = s
+ .trim_start_matches("gray")
+ .parse::<u8>()
+ .unwrap_or_default()
+ .min(23);
+ Some(Color::Indexed(232 + step))
+ } else if let Some(digits) = s.strip_prefix("rgb") {
+ // The xterm 216-colour cube at indices 16..=231: three components,
+ // each 0–5. `to_digit(6)` rejects anything outside that range, so the
+ // arithmetic below cannot exceed 231.
+ let mut components = digits.chars().map(|c| c.to_digit(6));
+ match (components.next(), components.next(), components.next()) {
+ (Some(Some(r)), Some(Some(g)), Some(Some(b))) => {
+ let index = 16 + r * 36 + g * 6 + b;
+ debug_assert!(index <= 231);
+ Some(Color::Indexed(index as u8))
+ }
+ _ => None,
+ }
+ } else if s == "bold black" {
+ Some(Color::Indexed(8))
+ } else if s == "bold red" {
+ Some(Color::Indexed(9))
+ } else if s == "bold green" {
+ Some(Color::Indexed(10))
+ } else if s == "bold yellow" {
+ Some(Color::Indexed(11))
+ } else if s == "bold blue" {
+ Some(Color::Indexed(12))
+ } else if s == "bold magenta" {
+ Some(Color::Indexed(13))
+ } else if s == "bold cyan" {
+ Some(Color::Indexed(14))
+ } else if s == "bold white" {
+ Some(Color::Indexed(15))
+ } else if s == "black" {
+ Some(Color::Indexed(0))
+ } else if s == "red" {
+ Some(Color::Indexed(1))
+ } else if s == "green" {
+ Some(Color::Indexed(2))
+ } else if s == "yellow" {
+ Some(Color::Indexed(3))
+ } else if s == "blue" {
+ Some(Color::Indexed(4))
+ } else if s == "magenta" {
+ Some(Color::Indexed(5))
+ } else if s == "cyan" {
+ Some(Color::Indexed(6))
+ } else if s == "white" {
+ Some(Color::Indexed(7))
+ } else {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use pretty_assertions::assert_eq;
+
+ use super::*;
+
+ #[test]
+ fn test_parse_style_default() {
+ let style = parse_style("");
+ assert_eq!(style, Style::default());
+ }
+
+ #[test]
+ fn test_parse_style_foreground() {
+ let style = parse_style("red");
+ assert_eq!(style.fg, Some(Color::Indexed(1)));
+ }
+
+ #[test]
+ fn test_parse_style_background() {
+ let style = parse_style("on blue");
+ assert_eq!(style.bg, Some(Color::Indexed(4)));
+ }
+
+ #[test]
+ fn test_parse_style_modifiers() {
+ let style = parse_style("underline red on blue");
+ assert_eq!(style.fg, Some(Color::Indexed(1)));
+ assert_eq!(style.bg, Some(Color::Indexed(4)));
+ }
+
+ #[test]
+ fn test_process_color_string() {
+ let (color, modifiers) = process_color_string("underline bold inverse gray");
+ assert_eq!(color, "gray");
+ assert!(modifiers.contains(Modifier::UNDERLINED));
+ assert!(modifiers.contains(Modifier::BOLD));
+ assert!(modifiers.contains(Modifier::REVERSED));
+ }
+
+ #[test]
+ fn test_parse_color_rgb() {
+ let color = parse_color("rgb123");
+ let expected = 16 + 36 + 2 * 6 + 3;
+ assert_eq!(color, Some(Color::Indexed(expected)));
+ }
+
+ #[test]
+ fn test_parse_color_unknown() {
+ let color = parse_color("unknown");
+ assert_eq!(color, None);
+ }
+
+ /// MJB-HLR-018: colour strings come from user configuration, so no input
+ /// may abort the process. Regression guard — every case below panicked in
+ /// the template code this replaced, by unchecked indexing or by `u8`
+ /// overflow in a debug build.
+ #[test]
+ fn mjb_llr_183_malformed_colours_never_panic() {
+ for input in [
+ "rgb", // indexed byte 3 of a 3-byte string
+ "rgb1", // indexed bytes 4 and 5
+ "rgb12", // indexed byte 5
+ "rgb999", // 16 + 9*36 + 9*6 + 9 = 403, overflows u8
+ "rgb555", // the largest legal cube entry
+ "gray99", // 232 + 99 = 331, overflows u8
+ "gray", // no digits at all
+ "color999", // does not fit u8
+ "bright color999",
+ "",
+ "文字化け", // multi-byte: byte indexing would split a character
+ ] {
+ let _ = parse_color(input);
+ let _ = parse_style(input);
+ }
+ }
+
+ #[test]
+ fn mjb_llr_183_colour_cube_bounds() {
+ // 216-colour cube occupies 16..=231.
+ assert_eq!(parse_color("rgb000"), Some(Color::Indexed(16)));
+ assert_eq!(parse_color("rgb555"), Some(Color::Indexed(231)));
+ // Components outside 0–5 are not cube coordinates.
+ assert_eq!(parse_color("rgb600"), None);
+ }
+
+ #[test]
+ fn mjb_llr_183_grayscale_ramp_bounds() {
+ // Grayscale ramp occupies 232..=255.
+ assert_eq!(parse_color("gray0"), Some(Color::Indexed(232)));
+ assert_eq!(parse_color("gray23"), Some(Color::Indexed(255)));
+ assert_eq!(
+ parse_color("gray99"),
+ Some(Color::Indexed(255)),
+ "clamped to the end of the ramp rather than overflowing"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_183_bright_colour_is_base_plus_eight() {
+ // The template's `wrapping_shl(8)` masked to a zero-bit shift, so
+ // bright colours were indistinguishable from their base.
+ assert_eq!(parse_color("bright color1"), Some(Color::Indexed(9)));
+ assert_ne!(parse_color("bright color1"), parse_color("color1"));
+ assert_eq!(
+ parse_color("bright color255"),
+ Some(Color::Indexed(255)),
+ "saturates instead of wrapping"
+ );
+ }
+
+ /// MJB-LLR-180: the compiled-in default TOML parses, and carries the
+ /// bindings the requirements mandate.
+ #[test]
+ fn mjb_llr_180_builtin_defaults_parse() {
+ let c: Config = toml::from_str(CONFIG).expect("built-in config.toml must parse");
+
+ let normal = c.keybindings.0.get(&Mode::Normal).expect("normal bindings");
+ assert_eq!(
+ normal.get(&parse_key_sequence("<h>").unwrap()),
+ Some(&Command::MoveCharLeft)
+ );
+ // A two-key sequence must survive parsing as two events.
+ let gg = parse_key_sequence("<g><g>").unwrap();
+ assert_eq!(gg.len(), 2);
+ assert_eq!(normal.get(&gg), Some(&Command::GotoFileStart));
+
+ let global = c.keybindings.0.get(&Mode::Global).expect("global bindings");
+ assert_eq!(
+ global.get(&parse_key_sequence("<ctrl-c>").unwrap()),
+ Some(&Command::Quit)
+ );
+ }
+
+ /// MJB-LLR-181: `config.toml` is the only file consulted. A config in any
+ /// other format sitting in the same directory must be ignored entirely.
+ #[test]
+ fn mjb_llr_181_only_config_toml_is_read() {
+ let dir = tempfile::tempdir().unwrap();
+
+ // Decoys in the formats the template used to accept.
+ std::fs::write(
+ dir.path().join("config.json5"),
+ "{ \"keybindings\": { \"normal\": { \"<q>\": \"Quit\" } } }",
+ )
+ .unwrap();
+ std::fs::write(dir.path().join("config.json"), "{\"editor\":{\"scrolloff\":99}}").unwrap();
+ std::fs::write(dir.path().join("config.yaml"), "editor:\n scrolloff: 98\n").unwrap();
+ std::fs::write(dir.path().join("config.ini"), "[editor]\nscrolloff=97\n").unwrap();
+ std::fs::write(dir.path().join("config.toml"), "[editor]\nscrolloff = 7\n").unwrap();
+
+ let cfg: Config = config::Config::builder()
+ .add_source(
+ config::File::from(dir.path().join("config.toml"))
+ .format(config::FileFormat::Toml)
+ .required(false),
+ )
+ .build()
+ .unwrap()
+ .try_deserialize()
+ .unwrap();
+
+ assert_eq!(cfg.editor.scrolloff, 7, "the TOML file must win");
+ }
+
+ /// MJB-LLR-185: editor settings deserialize with the documented defaults.
+ #[test]
+ fn mjb_llr_185_editor_defaults() {
+ let c: Config = toml::from_str(CONFIG).unwrap();
+ assert_eq!(c.editor.scrolloff, 5);
+ assert!(c.editor.insert_final_newline);
+
+ let empty: Config = toml::from_str("").unwrap();
+ assert_eq!(empty.editor.scrolloff, 5);
+ assert!(empty.editor.insert_final_newline);
+ }
+
+ /// MJB-LLR-182: a user binding overrides one default without disturbing
+ /// the rest of that mode.
+ #[test]
+ fn mjb_llr_182_user_bindings_merge_per_binding() {
+ let mut defaults: Config = toml::from_str(CONFIG).unwrap();
+ let user: Config =
+ toml::from_str("[keybindings.normal]\n\"<h>\" = \"MoveCharRight\"\n").unwrap();
+
+ // Same merge direction as Config::new: user wins, defaults fill in.
+ let mut merged = user;
+ for (mode, default_bindings) in defaults.keybindings.0.drain() {
+ let entry = merged.keybindings.0.entry(mode).or_default();
+ for (key, cmd) in default_bindings {
+ entry.entry(key).or_insert(cmd);
+ }
+ }
+
+ let normal = merged.keybindings.0.get(&Mode::Normal).unwrap();
+ assert_eq!(
+ normal.get(&parse_key_sequence("<h>").unwrap()),
+ Some(&Command::MoveCharRight),
+ "user binding must win"
+ );
+ assert_eq!(
+ normal.get(&parse_key_sequence("<j>").unwrap()),
+ Some(&Command::MoveLineDown),
+ "untouched defaults must remain"
+ );
+ }
+
+ /// MJB-LLR-183: a malformed key sequence is a recoverable error naming the
+ /// offending string, not a panic.
+ #[test]
+ fn mjb_llr_183_invalid_keybinding_is_recoverable() {
+ let err = toml::from_str::<Config>("[keybindings.normal]\n\"<nonsense-key>\" = \"Undo\"\n")
+ .expect_err("must not deserialize");
+ assert!(
+ err.to_string().contains("nonsense-key"),
+ "error must name the offending key, got: {err}"
+ );
+ }
+
+ /// MJB-LLR-184: modes deserialize from their lower-case names.
+ #[test]
+ fn mjb_llr_184_modes_deserialize_lowercase() {
+ let c: Config = toml::from_str(
+ "[keybindings.normal]\n\"<a>\" = \"Undo\"\n\
+ [keybindings.insert]\n\"<b>\" = \"Undo\"\n\
+ [keybindings.select]\n\"<c>\" = \"Undo\"\n\
+ [keybindings.command]\n\"<d>\" = \"Undo\"\n\
+ [keybindings.global]\n\"<e>\" = \"Undo\"\n",
+ )
+ .unwrap();
+ for mode in [
+ Mode::Normal,
+ Mode::Insert,
+ Mode::Select,
+ Mode::Command,
+ Mode::Global,
+ ] {
+ assert!(c.keybindings.0.contains_key(&mode), "missing {mode:?}");
+ }
+ }
+
+ #[test]
+ fn test_simple_keys() {
+ assert_eq!(
+ parse_key_event("a").unwrap(),
+ KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
+ );
+
+ assert_eq!(
+ parse_key_event("enter").unwrap(),
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
+ );
+
+ assert_eq!(
+ parse_key_event("esc").unwrap(),
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
+ );
+ }
+
+ #[test]
+ fn test_with_modifiers() {
+ assert_eq!(
+ parse_key_event("ctrl-a").unwrap(),
+ KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
+ );
+
+ assert_eq!(
+ parse_key_event("alt-enter").unwrap(),
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
+ );
+
+ assert_eq!(
+ parse_key_event("shift-esc").unwrap(),
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
+ );
+ }
+
+ #[test]
+ fn test_multiple_modifiers() {
+ assert_eq!(
+ parse_key_event("ctrl-alt-a").unwrap(),
+ KeyEvent::new(
+ KeyCode::Char('a'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT
+ )
+ );
+
+ assert_eq!(
+ parse_key_event("ctrl-shift-enter").unwrap(),
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
+ );
+ }
+
+ #[test]
+ fn test_reverse_multiple_modifiers() {
+ assert_eq!(
+ key_event_to_string(&KeyEvent::new(
+ KeyCode::Char('a'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT
+ )),
+ "ctrl-alt-a".to_string()
+ );
+ }
+
+ #[test]
+ fn test_invalid_keys() {
+ assert!(parse_key_event("invalid-key").is_err());
+ assert!(parse_key_event("ctrl-invalid-key").is_err());
+ }
+
+ #[test]
+ fn test_case_insensitivity() {
+ assert_eq!(
+ parse_key_event("CTRL-a").unwrap(),
+ KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
+ );
+
+ assert_eq!(
+ parse_key_event("AlT-eNtEr").unwrap(),
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
+ );
+ }
+}
diff --git a/src/errors.rs b/src/errors.rs
new file mode 100644
index 0000000..ebed8af
--- /dev/null
+++ b/src/errors.rs
@@ -0,0 +1,77 @@
+use std::env;
+
+use tracing::error;
+
+pub fn init() -> color_eyre::Result<()> {
+ let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default()
+ .panic_section(format!(
+ "This is a bug. Consider reporting it at {}",
+ env!("CARGO_PKG_REPOSITORY")
+ ))
+ .capture_span_trace_by_default(false)
+ .display_location_section(false)
+ .display_env_section(false)
+ .into_hooks();
+ eyre_hook.install()?;
+ std::panic::set_hook(Box::new(move |panic_info| {
+ if let Ok(mut t) = crate::tui::Tui::new()
+ && let Err(r) = t.exit()
+ {
+ error!("Unable to exit Terminal: {:?}", r);
+ }
+
+ #[cfg(not(debug_assertions))]
+ {
+ use human_panic::{handle_dump, metadata, print_msg};
+ let metadata = metadata!();
+ let file_path = handle_dump(&metadata, panic_info);
+ // prints human-panic message
+ print_msg(file_path, &metadata)
+ .expect("human-panic: printing error message to console failed");
+ eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr
+ }
+ let msg = format!("{}", panic_hook.panic_report(panic_info));
+ error!("Error: {}", strip_ansi_escapes::strip_str(msg));
+
+ #[cfg(debug_assertions)]
+ {
+ // Better Panic stacktrace that is only enabled when debugging.
+ better_panic::Settings::auto()
+ .most_recent_first(false)
+ .lineno_suffix(true)
+ .verbosity(better_panic::Verbosity::Full)
+ .create_panic_handler()(panic_info);
+ }
+
+ std::process::exit(libc::EXIT_FAILURE);
+ }));
+ Ok(())
+}
+
+/// Similar to the `std::dbg!` macro, but generates `tracing` events rather
+/// than printing to stdout.
+///
+/// By default, the verbosity level for the generated events is `DEBUG`, but
+/// this can be customized.
+#[macro_export]
+macro_rules! trace_dbg {
+ (target: $target:expr, level: $level:expr, $ex:expr) => {
+ {
+ match $ex {
+ value => {
+ tracing::event!(target: $target, $level, ?value, stringify!($ex));
+ value
+ }
+ }
+ }
+ };
+ (level: $level:expr, $ex:expr) => {
+ trace_dbg!(target: module_path!(), level: $level, $ex)
+ };
+ (target: $target:expr, $ex:expr) => {
+ trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex)
+ };
+ ($ex:expr) => {
+ trace_dbg!(level: tracing::Level::DEBUG, $ex)
+ };
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..da70baa
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,18 @@
+//! 文字化け — a terminal text editor.
+//!
+//! The crate is split into a library and a thin binary so that the buffer core
+//! under [`buffer`] can be exercised by requirements-based tests without a
+//! terminal, and so structural coverage can be scoped to it.
+//!
+//! Developed to DO-178C DAL-C. Requirements live in `docs/requirements/`;
+//! items implementing a low-level requirement carry a `MJB-LLR-nnn` comment.
+
+pub mod action;
+pub mod app;
+pub mod buffer;
+pub mod cli;
+pub mod components;
+pub mod config;
+pub mod errors;
+pub mod logging;
+pub mod tui;
diff --git a/src/logging.rs b/src/logging.rs
new file mode 100644
index 0000000..fd4e0b6
--- /dev/null
+++ b/src/logging.rs
@@ -0,0 +1,36 @@
+use std::sync::LazyLock;
+
+use tracing_error::ErrorLayer;
+use tracing_subscriber::{EnvFilter, fmt, prelude::*};
+
+use crate::config;
+
+pub static LOG_ENV: LazyLock<String> =
+ LazyLock::new(|| format!("{}_LOG_LEVEL", config::PROJECT_NAME.clone()));
+pub static LOG_FILE: LazyLock<String> = LazyLock::new(|| format!("{}.log", config::APP_NAME));
+
+pub fn init() -> color_eyre::Result<()> {
+ let directory = config::get_data_dir();
+ std::fs::create_dir_all(directory.clone())?;
+ let log_path = directory.join(LOG_FILE.clone());
+ let log_file = std::fs::File::create(log_path)?;
+ let env_filter = EnvFilter::builder().with_default_directive(tracing::Level::INFO.into());
+ // If the `RUST_LOG` environment variable is set, use that as the default, otherwise use the
+ // value of the `LOG_ENV` environment variable. If the `LOG_ENV` environment variable contains
+ // errors, then this will return an error.
+ let env_filter = env_filter
+ .try_from_env()
+ .or_else(|_| env_filter.with_env_var(LOG_ENV.clone()).from_env())?;
+ let file_subscriber = fmt::layer()
+ .with_file(true)
+ .with_line_number(true)
+ .with_writer(log_file)
+ .with_target(false)
+ .with_ansi(false)
+ .with_filter(env_filter);
+ tracing_subscriber::registry()
+ .with(file_subscriber)
+ .with(ErrorLayer::default())
+ .try_init()?;
+ Ok(())
+}
diff --git a/src/main.rs b/src/main.rs
index e7a11a9..b87ff47 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,14 @@
-fn main() {
- println!("Hello, world!");
+use clap::Parser;
+use mojibake::{app::App, cli::Cli, errors, logging};
+
+#[tokio::main]
+async fn main() -> color_eyre::Result<()> {
+ errors::init()?;
+ logging::init()?;
+
+ let args = Cli::parse();
+ // MJB-LLR-111: the optional positional path is handed to the buffer.
+ let mut app = App::new(args.tick_rate, args.frame_rate, args.file)?;
+ app.run().await?;
+ Ok(())
}
diff --git a/src/tui.rs b/src/tui.rs
new file mode 100644
index 0000000..8188985
--- /dev/null
+++ b/src/tui.rs
@@ -0,0 +1,233 @@
+
+use std::{
+ io::{Stdout, stdout},
+ ops::{Deref, DerefMut},
+ time::Duration,
+};
+
+use crossterm::{
+ cursor,
+ event::{
+ DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
+ Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent,
+ },
+ terminal::{EnterAlternateScreen, LeaveAlternateScreen},
+};
+use futures::{FutureExt, StreamExt};
+use ratatui::backend::CrosstermBackend as Backend;
+use serde::{Deserialize, Serialize};
+use tokio::{
+ sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
+ task::JoinHandle,
+ time::interval,
+};
+use tokio_util::sync::CancellationToken;
+use tracing::error;
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum Event {
+ Init,
+ Quit,
+ Error,
+ Closed,
+ Tick,
+ Render,
+ FocusGained,
+ FocusLost,
+ Paste(String),
+ Key(KeyEvent),
+ Mouse(MouseEvent),
+ Resize(u16, u16),
+}
+
+pub struct Tui {
+ pub terminal: ratatui::Terminal<Backend<Stdout>>,
+ pub task: JoinHandle<()>,
+ pub cancellation_token: CancellationToken,
+ pub event_rx: UnboundedReceiver<Event>,
+ pub event_tx: UnboundedSender<Event>,
+ pub frame_rate: f64,
+ pub tick_rate: f64,
+ pub mouse: bool,
+ pub paste: bool,
+}
+
+impl Tui {
+ pub fn new() -> color_eyre::Result<Self> {
+ let (event_tx, event_rx) = mpsc::unbounded_channel();
+ Ok(Self {
+ terminal: ratatui::Terminal::new(Backend::new(stdout()))?,
+ task: tokio::spawn(async {}),
+ cancellation_token: CancellationToken::new(),
+ event_rx,
+ event_tx,
+ frame_rate: 60.0,
+ tick_rate: 4.0,
+ mouse: false,
+ paste: false,
+ })
+ }
+
+ pub fn tick_rate(mut self, tick_rate: f64) -> Self {
+ self.tick_rate = tick_rate;
+ self
+ }
+
+ pub fn frame_rate(mut self, frame_rate: f64) -> Self {
+ self.frame_rate = frame_rate;
+ self
+ }
+
+ pub fn mouse(mut self, mouse: bool) -> Self {
+ self.mouse = mouse;
+ self
+ }
+
+ pub fn paste(mut self, paste: bool) -> Self {
+ self.paste = paste;
+ self
+ }
+
+ pub fn start(&mut self) {
+ self.cancel(); // Cancel any existing task
+ self.cancellation_token = CancellationToken::new();
+ let event_loop = Self::event_loop(
+ self.event_tx.clone(),
+ self.cancellation_token.clone(),
+ self.tick_rate,
+ self.frame_rate,
+ );
+ self.task = tokio::spawn(async {
+ event_loop.await;
+ });
+ }
+
+ async fn event_loop(
+ event_tx: UnboundedSender<Event>,
+ cancellation_token: CancellationToken,
+ tick_rate: f64,
+ frame_rate: f64,
+ ) {
+ let mut event_stream = EventStream::new();
+ let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate));
+ let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate));
+
+ // if this fails, then it's likely a bug in the calling code
+ event_tx
+ .send(Event::Init)
+ .expect("failed to send init event");
+ loop {
+ let event = tokio::select! {
+ _ = cancellation_token.cancelled() => {
+ break;
+ }
+ _ = tick_interval.tick() => Event::Tick,
+ _ = render_interval.tick() => Event::Render,
+ crossterm_event = event_stream.next().fuse() => match crossterm_event {
+ Some(Ok(event)) => match event {
+ CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key),
+ CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse),
+ CrosstermEvent::Resize(x, y) => Event::Resize(x, y),
+ CrosstermEvent::FocusLost => Event::FocusLost,
+ CrosstermEvent::FocusGained => Event::FocusGained,
+ CrosstermEvent::Paste(s) => Event::Paste(s),
+ _ => continue, // ignore other events
+ }
+ Some(Err(_)) => Event::Error,
+ None => break, // the event stream has stopped and will not produce any more events
+ },
+ };
+ if event_tx.send(event).is_err() {
+ // the receiver has been dropped, so there's no point in continuing the loop
+ break;
+ }
+ }
+ cancellation_token.cancel();
+ }
+
+ pub fn stop(&self) -> color_eyre::Result<()> {
+ self.cancel();
+ let mut counter = 0;
+ while !self.task.is_finished() {
+ std::thread::sleep(Duration::from_millis(1));
+ counter += 1;
+ if counter > 50 {
+ self.task.abort();
+ }
+ if counter > 100 {
+ error!("Failed to abort task in 100 milliseconds for unknown reason");
+ break;
+ }
+ }
+ Ok(())
+ }
+
+ pub fn enter(&mut self) -> color_eyre::Result<()> {
+ crossterm::terminal::enable_raw_mode()?;
+ crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?;
+ if self.mouse {
+ crossterm::execute!(stdout(), EnableMouseCapture)?;
+ }
+ if self.paste {
+ crossterm::execute!(stdout(), EnableBracketedPaste)?;
+ }
+ self.start();
+ Ok(())
+ }
+
+ pub fn exit(&mut self) -> color_eyre::Result<()> {
+ self.stop()?;
+ if crossterm::terminal::is_raw_mode_enabled()? {
+ self.flush()?;
+ if self.paste {
+ crossterm::execute!(stdout(), DisableBracketedPaste)?;
+ }
+ if self.mouse {
+ crossterm::execute!(stdout(), DisableMouseCapture)?;
+ }
+ crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?;
+ crossterm::terminal::disable_raw_mode()?;
+ }
+ Ok(())
+ }
+
+ pub fn cancel(&self) {
+ self.cancellation_token.cancel();
+ }
+
+ pub fn suspend(&mut self) -> color_eyre::Result<()> {
+ self.exit()?;
+ #[cfg(not(windows))]
+ signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?;
+ Ok(())
+ }
+
+ pub fn resume(&mut self) -> color_eyre::Result<()> {
+ self.enter()?;
+ Ok(())
+ }
+
+ pub async fn next_event(&mut self) -> Option<Event> {
+ self.event_rx.recv().await
+ }
+}
+
+impl Deref for Tui {
+ type Target = ratatui::Terminal<Backend<Stdout>>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.terminal
+ }
+}
+
+impl DerefMut for Tui {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.terminal
+ }
+}
+
+impl Drop for Tui {
+ fn drop(&mut self) {
+ self.exit().unwrap();
+ }
+}
diff --git a/tests/editing.rs b/tests/editing.rs
new file mode 100644
index 0000000..cacd232
--- /dev/null
+++ b/tests/editing.rs
@@ -0,0 +1,1092 @@
+//! Requirements-based integration tests for the buffer as a whole.
+//!
+//! The unit tests inside each module verify components in isolation; these
+//! drive the editor the way a user does — through key events resolved against
+//! the real built-in keymap — and check the behaviour the HLRs promise.
+//!
+//! Test names carry the LLR they exercise, so the trace matrix can be checked
+//! mechanically.
+
+use std::path::PathBuf;
+
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use mojibake::{
+ buffer::{Buffer, Outcome},
+ config::{Config, Mode},
+};
+
+/// A buffer over `text`, using the compiled-in default keymap.
+fn buffer_with(text: &str) -> (Buffer, tempfile::TempDir) {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("scratch.txt");
+ std::fs::write(&path, text).unwrap();
+
+ let config = default_config();
+ let mut buf = Buffer::new(config, Some(path)).unwrap();
+ // A viewport must exist before paging commands mean anything.
+ buf.update_view(80, 24);
+ (buf, dir)
+}
+
+/// The built-in defaults, without consulting the user's real config directory.
+fn default_config() -> Config {
+ let toml = include_str!("../.config/config.toml");
+ toml::from_str(toml).expect("built-in config must parse")
+}
+
+fn key(c: char) -> KeyEvent {
+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty())
+}
+
+fn ctrl(c: char) -> KeyEvent {
+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
+}
+
+fn esc() -> KeyEvent {
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
+}
+
+fn enter() -> KeyEvent {
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
+}
+
+/// Type a run of plain characters.
+fn typed(buf: &mut Buffer, s: &str) {
+ for c in s.chars() {
+ buf.handle_key(key(c));
+ }
+}
+
+/// Press a run of plain keys, returning the last outcome.
+fn press(buf: &mut Buffer, s: &str) -> Outcome {
+ let mut out = Outcome::Consumed;
+ for c in s.chars() {
+ out = buf.handle_key(key(c));
+ }
+ out
+}
+
+fn text(buf: &Buffer) -> String {
+ buf.document.text().to_string()
+}
+
+fn cursor(buf: &Buffer) -> usize {
+ buf.document.range().cursor(buf.document.slice())
+}
+
+// ---------------------------------------------------------------------------
+// Motion (MJB-HLR-006)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_062_h_and_l_move_by_one_grapheme() {
+ let (mut buf, _d) = buffer_with("abcdef\n");
+ press(&mut buf, "ll");
+ assert_eq!(cursor(&buf), 2);
+ press(&mut buf, "h");
+ assert_eq!(cursor(&buf), 1);
+}
+
+#[test]
+fn mjb_llr_062_h_at_start_of_buffer_is_a_noop() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ press(&mut buf, "hhhhh");
+ assert_eq!(cursor(&buf), 0, "must not move past the start");
+}
+
+#[test]
+fn mjb_llr_063_l_at_end_of_buffer_is_a_noop() {
+ let (mut buf, _d) = buffer_with("ab");
+ press(&mut buf, "llllll");
+ assert!(cursor(&buf) <= 2, "must not move past the end");
+}
+
+#[test]
+fn mjb_llr_064_j_and_k_move_between_lines() {
+ let (mut buf, _d) = buffer_with("abc\ndef\nghi\n");
+ press(&mut buf, "jj");
+ assert_eq!(buf.document.range().cursor_line(buf.document.slice()), 2);
+ press(&mut buf, "k");
+ assert_eq!(buf.document.range().cursor_line(buf.document.slice()), 1);
+}
+
+#[test]
+fn mjb_llr_155_count_repeats_a_motion() {
+ let (mut buf, _d) = buffer_with("abcdefghij\n");
+ press(&mut buf, "5l");
+ assert_eq!(cursor(&buf), 5, "5l must move five graphemes");
+}
+
+// ---------------------------------------------------------------------------
+// Word motion selects (MJB-HLR-007) — the defining Helix behaviour
+// ---------------------------------------------------------------------------
+
+/// `w` must leave a *selection*, not a bare cursor. This is what lets `d`
+/// delete a word with no operator-pending state.
+#[test]
+fn mjb_llr_065_w_leaves_a_selection() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "w");
+ let r = buf.document.range();
+ assert!(!r.is_empty(), "w must select, not collapse");
+ assert_eq!(r.from(), 0);
+ assert_eq!(r.to(), 6);
+}
+
+/// The pay-off: `wd` deletes a word without any operator machinery.
+#[test]
+fn mjb_llr_065_w_then_d_deletes_the_word() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "wd");
+ assert_eq!(text(&buf), "world\n");
+}
+
+#[test]
+fn mjb_llr_067_e_selects_to_the_word_end() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "e");
+ let r = buf.document.range();
+ assert_eq!(r.from(), 0);
+ assert_eq!(r.to(), 5, "inclusive of the last character");
+}
+
+#[test]
+fn mjb_llr_066_b_selects_backward() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "ww");
+ let before = cursor(&buf);
+ press(&mut buf, "b");
+ assert!(cursor(&buf) < before, "b must move backward");
+}
+
+#[test]
+fn mjb_llr_069_w_at_end_of_buffer_is_a_noop() {
+ let (mut buf, _d) = buffer_with("ab");
+ press(&mut buf, "wwwww");
+ assert_eq!(text(&buf), "ab", "must not corrupt the buffer");
+}
+
+// ---------------------------------------------------------------------------
+// Goto (MJB-HLR-008)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_070_gg_goes_to_file_start() {
+ let (mut buf, _d) = buffer_with("abc\ndef\nghi\n");
+ press(&mut buf, "jj");
+ press(&mut buf, "gg");
+ assert_eq!(cursor(&buf), 0);
+}
+
+/// MJB-LLR-154: the old resolver cleared pending keys on every tick, so `gg`
+/// failed when typed slowly. Nothing time-based remains; interleaving other
+/// work between the two keys must not matter.
+#[test]
+fn mjb_llr_154_gg_resolves_regardless_of_intervening_time() {
+ let (mut buf, _d) = buffer_with("abc\ndef\nghi\n");
+ press(&mut buf, "jj");
+
+ buf.handle_key(key('g'));
+ // Simulate an arbitrary delay and unrelated frame work.
+ std::thread::sleep(std::time::Duration::from_millis(300));
+ buf.update_view(80, 24);
+ buf.handle_key(key('g'));
+
+ assert_eq!(cursor(&buf), 0, "gg must still resolve after a long pause");
+}
+
+#[test]
+fn mjb_llr_071_ge_goes_to_the_last_line() {
+ let (mut buf, _d) = buffer_with("abc\ndef\nghi\n");
+ press(&mut buf, "ge");
+ assert_eq!(buf.document.range().cursor_line(buf.document.slice()), 2);
+}
+
+#[test]
+fn mjb_llr_073_gl_goes_to_the_line_end() {
+ let (mut buf, _d) = buffer_with("abcdef\nxy\n");
+ press(&mut buf, "gl");
+ assert_eq!(cursor(&buf), 6, "excludes the terminator");
+}
+
+#[test]
+fn mjb_llr_072_gh_goes_to_the_line_start() {
+ let (mut buf, _d) = buffer_with("abcdef\n");
+ press(&mut buf, "lll");
+ press(&mut buf, "gh");
+ assert_eq!(cursor(&buf), 0);
+}
+
+#[test]
+fn mjb_llr_153_unknown_g_sequence_does_not_corrupt_state() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ press(&mut buf, "gz");
+ assert_eq!(text(&buf), "abc\n");
+ assert!(buf.pending_keys().is_empty(), "pending must be cleared");
+}
+
+// ---------------------------------------------------------------------------
+// Insert mode (MJB-HLR-009, MJB-HLR-010)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_156_i_then_typing_inserts_text() {
+ let (mut buf, _d) = buffer_with("world\n");
+ buf.handle_key(key('i'));
+ assert_eq!(buf.mode, Mode::Insert);
+ typed(&mut buf, "hello ");
+ assert_eq!(text(&buf), "hello world\n");
+}
+
+#[test]
+fn mjb_llr_156_escape_returns_to_normal_mode() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(esc());
+ assert_eq!(buf.mode, Mode::Normal);
+}
+
+/// In insert mode a printable key must type, not run a normal-mode command.
+#[test]
+fn mjb_llr_156_command_keys_type_literally_in_insert_mode() {
+ let (mut buf, _d) = buffer_with("\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "dujw");
+ assert_eq!(text(&buf), "dujw\n", "d/u/j/w must not act as commands");
+}
+
+#[test]
+fn mjb_llr_009_a_appends_after_the_selection() {
+ let (mut buf, _d) = buffer_with("ac\n");
+ buf.handle_key(key('a'));
+ typed(&mut buf, "b");
+ assert_eq!(text(&buf), "abc\n");
+}
+
+#[test]
+fn mjb_llr_009_capital_a_inserts_at_line_end() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT));
+ typed(&mut buf, "!");
+ assert_eq!(text(&buf), "abc!\n");
+}
+
+#[test]
+fn mjb_llr_009_capital_i_inserts_at_first_non_whitespace() {
+ let (mut buf, _d) = buffer_with(" abc\n");
+ press(&mut buf, "gl");
+ buf.handle_key(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::SHIFT));
+ typed(&mut buf, "X");
+ assert_eq!(text(&buf), " Xabc\n");
+}
+
+#[test]
+fn mjb_llr_009_o_opens_a_line_below() {
+ let (mut buf, _d) = buffer_with("abc\ndef\n");
+ buf.handle_key(key('o'));
+ assert_eq!(buf.mode, Mode::Insert);
+ typed(&mut buf, "X");
+ assert_eq!(text(&buf), "abc\nX\ndef\n");
+}
+
+/// Robustness: the last line has no trailing newline, so there is no "next
+/// line" for `o` to anchor to.
+#[test]
+fn mjb_llr_009_o_on_a_final_line_without_trailing_newline() {
+ let (mut buf, _d) = buffer_with("abc");
+ buf.handle_key(key('o'));
+ typed(&mut buf, "X");
+ assert_eq!(text(&buf), "abc\nX");
+}
+
+#[test]
+fn mjb_llr_009_capital_o_opens_a_line_above() {
+ let (mut buf, _d) = buffer_with("abc\ndef\n");
+ press(&mut buf, "j");
+ buf.handle_key(KeyEvent::new(KeyCode::Char('O'), KeyModifiers::SHIFT));
+ typed(&mut buf, "X");
+ assert_eq!(text(&buf), "abc\nX\ndef\n");
+}
+
+#[test]
+fn mjb_llr_010_backspace_deletes_backward() {
+ let (mut buf, _d) = buffer_with("\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "abc");
+ buf.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()));
+ assert_eq!(text(&buf), "ab\n");
+}
+
+#[test]
+fn mjb_llr_010_backspace_at_buffer_start_is_a_noop() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ for _ in 0..5 {
+ buf.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()));
+ }
+ assert_eq!(text(&buf), "abc\n");
+}
+
+#[test]
+fn mjb_llr_010_enter_inserts_a_newline() {
+ let (mut buf, _d) = buffer_with("ab\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(enter());
+ assert_eq!(text(&buf), "\nab\n");
+}
+
+#[test]
+fn mjb_llr_010_multibyte_text_inserts_and_deletes_whole_characters() {
+ let (mut buf, _d) = buffer_with("\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "文字化け");
+ assert_eq!(text(&buf), "文字化け\n");
+ buf.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()));
+ assert_eq!(text(&buf), "文字化\n", "one character, not one byte");
+}
+
+// ---------------------------------------------------------------------------
+// Delete and change (MJB-HLR-010)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_010_d_deletes_the_selection() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "wd");
+ assert_eq!(text(&buf), "world\n");
+}
+
+#[test]
+fn mjb_llr_010_c_deletes_and_enters_insert_mode() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "wc");
+ assert_eq!(buf.mode, Mode::Insert);
+ typed(&mut buf, "goodbye ");
+ assert_eq!(text(&buf), "goodbye world\n");
+}
+
+#[test]
+fn mjb_llr_050_d_with_an_empty_selection_deletes_one_grapheme() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ press(&mut buf, "d");
+ assert_eq!(text(&buf), "bc\n");
+}
+
+#[test]
+fn mjb_llr_050_d_on_an_empty_buffer_is_a_noop() {
+ let (mut buf, _d) = buffer_with("");
+ press(&mut buf, "ddd");
+ assert_eq!(text(&buf), "");
+}
+
+/// Helix's `x`: select the line, then extend a line at a time.
+#[test]
+fn mjb_llr_010_x_selects_a_line_then_extends() {
+ let (mut buf, _d) = buffer_with("aaa\nbbb\nccc\n");
+ press(&mut buf, "x");
+ let r = buf.document.range();
+ assert_eq!((r.from(), r.to()), (0, 4), "the whole first line");
+
+ press(&mut buf, "x");
+ let r = buf.document.range();
+ assert_eq!((r.from(), r.to()), (0, 8), "extended to the second");
+}
+
+#[test]
+fn mjb_llr_010_x_then_d_deletes_whole_lines() {
+ let (mut buf, _d) = buffer_with("aaa\nbbb\nccc\n");
+ press(&mut buf, "xxd");
+ assert_eq!(text(&buf), "ccc\n");
+}
+
+// ---------------------------------------------------------------------------
+// Undo and redo (MJB-HLR-011)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_052_u_undoes_a_deletion() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "wd");
+ assert_eq!(text(&buf), "world\n");
+ press(&mut buf, "u");
+ assert_eq!(text(&buf), "hello world\n");
+}
+
+#[test]
+fn mjb_llr_053_capital_u_redoes() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "wd");
+ press(&mut buf, "u");
+ buf.handle_key(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT));
+ assert_eq!(text(&buf), "world\n");
+}
+
+/// MJB-LLR-052: undoing with no history is a no-op, and must not corrupt the
+/// buffer or panic however many times it is pressed.
+#[test]
+fn mjb_llr_052_undo_past_history_start_is_safe() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ press(&mut buf, "uuuuu");
+ assert_eq!(text(&buf), "abc\n");
+ assert!(buf.status.is_some(), "should report the boundary");
+}
+
+#[test]
+fn mjb_llr_053_redo_past_end_is_safe() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ for _ in 0..5 {
+ buf.handle_key(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT));
+ }
+ assert_eq!(text(&buf), "abc\n");
+}
+
+#[test]
+fn mjb_llr_051_undo_restores_typed_text() {
+ let (mut buf, _d) = buffer_with("\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "abc");
+ buf.handle_key(esc());
+ assert_eq!(text(&buf), "abc\n");
+
+ // Each typed character is its own undo step.
+ press(&mut buf, "uuu");
+ assert_eq!(text(&buf), "\n");
+}
+
+// ---------------------------------------------------------------------------
+// Paging (MJB-HLR-013)
+// ---------------------------------------------------------------------------
+
+fn many_lines(n: usize) -> String {
+ (0..n).map(|i| format!("line{i}\n")).collect()
+}
+
+#[test]
+fn mjb_llr_100_ctrl_d_pages_half_a_screen_down() {
+ let (mut buf, _d) = buffer_with(&many_lines(200));
+ buf.update_view(80, 20);
+ buf.handle_key(ctrl('d'));
+ assert_eq!(
+ buf.document.range().cursor_line(buf.document.slice()),
+ 10,
+ "half of a 20-row viewport"
+ );
+}
+
+#[test]
+fn mjb_llr_101_ctrl_f_pages_a_full_screen_down() {
+ let (mut buf, _d) = buffer_with(&many_lines(200));
+ buf.update_view(80, 20);
+ buf.handle_key(ctrl('f'));
+ assert_eq!(buf.document.range().cursor_line(buf.document.slice()), 20);
+}
+
+#[test]
+fn mjb_llr_100_ctrl_u_pages_back_up() {
+ let (mut buf, _d) = buffer_with(&many_lines(200));
+ buf.update_view(80, 20);
+ buf.handle_key(ctrl('f'));
+ buf.handle_key(ctrl('u'));
+ assert_eq!(buf.document.range().cursor_line(buf.document.slice()), 10);
+}
+
+#[test]
+fn mjb_llr_102_paging_saturates_at_both_ends() {
+ let (mut buf, _d) = buffer_with(&many_lines(5));
+ buf.update_view(80, 20);
+ for _ in 0..10 {
+ buf.handle_key(ctrl('f'));
+ }
+ for _ in 0..10 {
+ buf.handle_key(ctrl('u'));
+ }
+ assert_eq!(buf.document.range().cursor_line(buf.document.slice()), 0);
+}
+
+// ---------------------------------------------------------------------------
+// Command mode and saving (MJB-HLR-016, MJB-HLR-017)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_159_write_saves_to_disk() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, "original\n").unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.update_view(80, 24);
+ buf.handle_key(key('i'));
+ typed(&mut buf, "new ");
+ buf.handle_key(esc());
+
+ buf.handle_key(key(':'));
+ assert_eq!(buf.mode, Mode::Command);
+ typed(&mut buf, "w");
+ buf.handle_key(enter());
+
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), "new original\n");
+ assert!(!buf.document.is_modified());
+}
+
+#[test]
+fn mjb_llr_159_quit_returns_the_quit_outcome() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key(':'));
+ typed(&mut buf, "q");
+ assert_eq!(buf.handle_key(enter()), Outcome::Quit);
+}
+
+/// MJB-LLR-160: `:q` with unsaved changes must refuse and say why.
+#[test]
+fn mjb_llr_160_quit_with_unsaved_changes_is_refused() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "X");
+ buf.handle_key(esc());
+
+ buf.handle_key(key(':'));
+ typed(&mut buf, "q");
+ assert_eq!(buf.handle_key(enter()), Outcome::Consumed, "must not quit");
+ assert!(buf.status.is_some(), "must explain the refusal");
+}
+
+#[test]
+fn mjb_llr_160_force_quit_discards_changes() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "X");
+ buf.handle_key(esc());
+
+ buf.handle_key(key(':'));
+ typed(&mut buf, "q!");
+ assert_eq!(buf.handle_key(enter()), Outcome::Quit);
+}
+
+#[test]
+fn mjb_llr_159_wq_writes_then_quits() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, "abc\n").unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.handle_key(key('i'));
+ typed(&mut buf, "X");
+ buf.handle_key(esc());
+
+ buf.handle_key(key(':'));
+ typed(&mut buf, "wq");
+ assert_eq!(buf.handle_key(enter()), Outcome::Quit);
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), "Xabc\n");
+}
+
+#[test]
+fn mjb_llr_159_x_is_an_alias_for_wq() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, "abc\n").unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.handle_key(key(':'));
+ typed(&mut buf, "x");
+ assert_eq!(buf.handle_key(enter()), Outcome::Quit);
+}
+
+/// MJB-LLR-159: an unrecognised command reports and keeps going.
+#[test]
+fn mjb_llr_159_unknown_command_is_reported_not_fatal() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key(':'));
+ typed(&mut buf, "nonsense");
+ assert_eq!(buf.handle_key(enter()), Outcome::Consumed);
+ assert!(
+ buf.status.as_deref().unwrap_or("").contains("nonsense"),
+ "must name the unknown command, got {:?}",
+ buf.status
+ );
+ assert_eq!(buf.mode, Mode::Normal);
+}
+
+#[test]
+fn mjb_llr_158_escape_cancels_the_command_line() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key(':'));
+ typed(&mut buf, "q");
+ buf.handle_key(esc());
+ assert_eq!(buf.mode, Mode::Normal);
+ assert!(buf.command_line.is_empty());
+}
+
+#[test]
+fn mjb_llr_158_command_line_accepts_backspace() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key(':'));
+ typed(&mut buf, "qx");
+ buf.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()));
+ assert_eq!(buf.command_line, "q");
+}
+
+#[test]
+fn mjb_llr_112_writing_a_new_file_creates_it() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("brand-new.txt");
+ assert!(!path.exists());
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.handle_key(key('i'));
+ typed(&mut buf, "hello");
+ buf.handle_key(esc());
+ buf.handle_key(key(':'));
+ typed(&mut buf, "w");
+ buf.handle_key(enter());
+
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello\n");
+}
+
+// ---------------------------------------------------------------------------
+// Robustness (MJB-HLR-018)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_112_empty_file_is_editable() {
+ let (mut buf, _d) = buffer_with("");
+ press(&mut buf, "hjklwbe");
+ press(&mut buf, "gg");
+ press(&mut buf, "ge");
+ assert_eq!(text(&buf), "", "no motion may corrupt an empty buffer");
+
+ buf.handle_key(key('i'));
+ typed(&mut buf, "x");
+ assert_eq!(text(&buf), "x");
+}
+
+#[test]
+fn mjb_llr_112_file_without_trailing_newline_round_trips() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, "no trailing newline").unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.handle_key(key(':'));
+ typed(&mut buf, "w");
+ buf.handle_key(enter());
+
+ // insert_final_newline defaults to true, so one is appended on write.
+ assert_eq!(
+ std::fs::read_to_string(&path).unwrap(),
+ "no trailing newline\n"
+ );
+}
+
+/// MJB-LLR-118: the UTF-8 exception, end to end. A binary file is refused
+/// with a diagnostic rather than opened full of replacement characters.
+#[test]
+fn mjb_llr_118_binary_file_is_refused_not_opened() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("bad.bin");
+ let original: Vec<u8> = (0u8..=255).collect();
+ std::fs::write(&path, &original).unwrap();
+
+ let err = Buffer::new(default_config(), Some(path.clone()))
+ .err()
+ .expect("a binary file must not open");
+ assert!(
+ err.to_string().contains("UTF-8"),
+ "must say why, got: {err}"
+ );
+ assert_eq!(
+ std::fs::read(&path).unwrap(),
+ original,
+ "refusing to open must not modify the file"
+ );
+}
+
+/// MJB-LLR-113: a BOM-declared non-UTF-8 file still opens and is editable —
+/// the strictness applies to UTF-8 only.
+#[test]
+fn mjb_llr_113_declared_utf16_file_opens_and_edits() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("u16.txt");
+ // UTF-16LE BOM + "hi", including an unpaired surrogate.
+ std::fs::write(&path, [0xFF, 0xFE_u8, 0x00, 0xD8, b'h', 0x00, b'i', 0x00]).unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(path)).expect("declared encoding must open");
+ buf.update_view(80, 24);
+ press(&mut buf, "jjllww");
+ // Reaching here without a panic is the requirement.
+}
+
+#[test]
+fn mjb_llr_098_zero_height_viewport_does_not_panic() {
+ let (mut buf, _d) = buffer_with(&many_lines(50));
+ buf.update_view(0, 0);
+ buf.handle_key(ctrl('d'));
+ buf.handle_key(ctrl('f'));
+ press(&mut buf, "jjkk");
+}
+
+#[test]
+fn mjb_llr_011_motions_at_boundaries_never_leave_the_buffer() {
+ let (mut buf, _d) = buffer_with("ab\ncd\n");
+ // Hammer every motion from every position.
+ for _ in 0..40 {
+ press(&mut buf, "hjklwbe");
+ press(&mut buf, "gg");
+ press(&mut buf, "gl");
+ press(&mut buf, "gh");
+ press(&mut buf, "ge");
+ }
+ let len = buf.document.text().len();
+ assert!(cursor(&buf) <= len);
+ assert_eq!(text(&buf), "ab\ncd\n", "motions must not modify text");
+}
+
+#[test]
+fn mjb_llr_112_missing_file_opens_as_an_empty_buffer() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("nope.txt");
+ let buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ assert_eq!(buf.document.text().len(), 0);
+ assert_eq!(buf.document.path(), Some(path.as_path()));
+}
+
+#[test]
+fn mjb_llr_111_no_path_yields_a_scratch_buffer() {
+ let buf = Buffer::new(default_config(), None::<PathBuf>).unwrap();
+ assert_eq!(buf.document.path(), None);
+ assert_eq!(buf.document.text().len(), 0);
+}
+
+// ---------------------------------------------------------------------------
+// Select mode and selection manipulation
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_184_v_toggles_select_mode() {
+ let (mut buf, _d) = buffer_with("abcdef\n");
+ press(&mut buf, "v");
+ assert_eq!(buf.mode, Mode::Select);
+ press(&mut buf, "v");
+ assert_eq!(buf.mode, Mode::Normal, "v toggles back");
+}
+
+/// In select mode a motion extends the selection instead of replacing it.
+#[test]
+fn mjb_llr_007_select_mode_motions_extend() {
+ let (mut buf, _d) = buffer_with("abcdef\n");
+ press(&mut buf, "vlll");
+ let r = buf.document.range();
+ assert!(!r.is_empty(), "must have extended a selection");
+ assert_eq!(r.from(), 0);
+ assert!(r.to() >= 3, "selection grew with each motion");
+}
+
+#[test]
+fn mjb_llr_007_select_mode_then_delete() {
+ let (mut buf, _d) = buffer_with("abcdef\n");
+ press(&mut buf, "vlll");
+ press(&mut buf, "d");
+ assert!(text(&buf).len() < "abcdef\n".len(), "selection was deleted");
+}
+
+#[test]
+fn mjb_llr_007_escape_leaves_select_mode() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ press(&mut buf, "v");
+ buf.handle_key(esc());
+ assert_eq!(buf.mode, Mode::Normal);
+}
+
+#[test]
+fn mjb_llr_010_semicolon_collapses_the_selection() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "w");
+ assert!(!buf.document.range().is_empty());
+ press(&mut buf, ";");
+ assert!(buf.document.range().is_empty(), "collapsed to a cursor");
+}
+
+#[test]
+fn mjb_llr_010_percent_selects_the_whole_buffer() {
+ let (mut buf, _d) = buffer_with("abc\ndef\n");
+ press(&mut buf, "%");
+ let r = buf.document.range();
+ assert_eq!((r.from(), r.to()), (0, 8));
+}
+
+#[test]
+fn mjb_llr_010_percent_then_d_empties_the_buffer() {
+ let (mut buf, _d) = buffer_with("abc\ndef\n");
+ press(&mut buf, "%d");
+ assert_eq!(text(&buf), "");
+}
+
+#[test]
+fn mjb_llr_010_alt_semicolon_flips_the_selection() {
+ let (mut buf, _d) = buffer_with("hello world\n");
+ press(&mut buf, "w");
+ let before = buf.document.range();
+ buf.handle_key(KeyEvent::new(KeyCode::Char(';'), KeyModifiers::ALT));
+ let after = buf.document.range();
+ assert_eq!(after.anchor, before.head);
+ assert_eq!(after.head, before.anchor);
+}
+
+// ---------------------------------------------------------------------------
+// Long-word motions and goto first non-whitespace
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_068_capital_w_treats_punctuation_as_word_characters() {
+ let (mut buf, _d) = buffer_with("foo.bar baz\n");
+ buf.handle_key(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT));
+ assert_eq!(
+ buf.document.range().to(),
+ 8,
+ "W must step over foo.bar as one word"
+ );
+}
+
+#[test]
+fn mjb_llr_068_capital_e_and_b_are_bound() {
+ let (mut buf, _d) = buffer_with("foo.bar baz\n");
+ buf.handle_key(KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT));
+ assert!(!buf.document.range().is_empty());
+ buf.handle_key(KeyEvent::new(KeyCode::Char('B'), KeyModifiers::SHIFT));
+ assert_eq!(text(&buf), "foo.bar baz\n", "motions must not modify text");
+}
+
+#[test]
+fn mjb_llr_072_gs_goes_to_first_non_whitespace() {
+ let (mut buf, _d) = buffer_with(" indented\n");
+ press(&mut buf, "gl");
+ press(&mut buf, "gs");
+ assert_eq!(cursor(&buf), 4);
+}
+
+// ---------------------------------------------------------------------------
+// Insert-mode editing commands
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_010_ctrl_w_deletes_the_previous_word() {
+ let (mut buf, _d) = buffer_with("\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "hello world");
+ buf.handle_key(ctrl('w'));
+ assert!(
+ !text(&buf).contains("world"),
+ "ctrl-w must remove the last word, got {:?}",
+ text(&buf)
+ );
+}
+
+#[test]
+fn mjb_llr_010_ctrl_w_at_buffer_start_is_a_noop() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(ctrl('w'));
+ assert_eq!(text(&buf), "abc\n");
+}
+
+#[test]
+fn mjb_llr_010_ctrl_u_kills_to_line_start() {
+ let (mut buf, _d) = buffer_with("\n");
+ buf.handle_key(key('i'));
+ typed(&mut buf, "some text");
+ buf.handle_key(ctrl('u'));
+ assert_eq!(text(&buf), "\n");
+}
+
+#[test]
+fn mjb_llr_010_ctrl_u_at_line_start_is_a_noop() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(ctrl('u'));
+ assert_eq!(text(&buf), "abc\n");
+}
+
+#[test]
+fn mjb_llr_010_delete_removes_the_character_forward() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::empty()));
+ assert_eq!(text(&buf), "bc\n");
+}
+
+#[test]
+fn mjb_llr_010_delete_at_end_of_buffer_is_a_noop() {
+ let (mut buf, _d) = buffer_with("ab");
+ press(&mut buf, "gl");
+ buf.handle_key(key('a'));
+ buf.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::empty()));
+ assert_eq!(text(&buf), "ab");
+}
+
+#[test]
+fn mjb_llr_010_tab_inserts_a_tab() {
+ let (mut buf, _d) = buffer_with("x\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()));
+ assert_eq!(text(&buf), "\tx\n");
+}
+
+#[test]
+fn mjb_llr_156_arrow_keys_move_in_insert_mode() {
+ let (mut buf, _d) = buffer_with("abcd\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::empty()));
+ buf.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::empty()));
+ typed(&mut buf, "X");
+ assert_eq!(text(&buf), "abXcd\n");
+}
+
+#[test]
+fn mjb_llr_156_unbound_control_key_is_discarded_in_insert_mode() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key('i'));
+ buf.handle_key(ctrl('q'));
+ assert_eq!(text(&buf), "abc\n", "ctrl-q must not type a q");
+}
+
+// ---------------------------------------------------------------------------
+// Global bindings and force-write (MJB-HLR-017)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn mjb_llr_159_force_write_creates_missing_directories() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("deep").join("nested").join("f.txt");
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.handle_key(key('i'));
+ typed(&mut buf, "content");
+ buf.handle_key(esc());
+
+ // Plain :w must refuse, naming the remedy.
+ buf.handle_key(key(':'));
+ typed(&mut buf, "w");
+ buf.handle_key(enter());
+ assert!(!path.exists(), "plain :w must not create directories");
+ assert!(buf.status.as_deref().unwrap_or("").contains(":w!"));
+
+ // :w! creates them.
+ buf.handle_key(key(':'));
+ typed(&mut buf, "w!");
+ buf.handle_key(enter());
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), "content\n");
+}
+
+#[test]
+fn mjb_llr_159_empty_command_line_does_nothing() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key(':'));
+ assert_eq!(buf.handle_key(enter()), Outcome::Consumed);
+ assert_eq!(buf.mode, Mode::Normal);
+}
+
+#[test]
+fn mjb_llr_158_backspacing_an_empty_command_line_leaves_command_mode() {
+ let (mut buf, _d) = buffer_with("abc\n");
+ buf.handle_key(key(':'));
+ buf.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()));
+ assert_eq!(buf.mode, Mode::Normal);
+}
+
+/// MJB-LLR-017: writing over a symlink must update the target, not replace
+/// the link. Exercised end-to-end through `:w`.
+#[cfg(unix)]
+#[test]
+fn mjb_llr_130_write_through_a_symlink_end_to_end() {
+ let dir = tempfile::tempdir().unwrap();
+ let target = dir.path().join("real.txt");
+ let link = dir.path().join("link.txt");
+ std::fs::write(&target, "before\n").unwrap();
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(link.clone())).unwrap();
+ buf.handle_key(key('i'));
+ typed(&mut buf, "X");
+ buf.handle_key(esc());
+ buf.handle_key(key(':'));
+ typed(&mut buf, "w");
+ buf.handle_key(enter());
+
+ assert!(
+ std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(),
+ "the link must survive the write"
+ );
+ assert_eq!(std::fs::read_to_string(&target).unwrap(), "Xbefore\n");
+}
+
+/// MJB-LLR-131: a read-only file must be refused without truncating it.
+#[cfg(unix)]
+#[test]
+fn mjb_llr_131_readonly_file_reports_and_preserves_contents() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("ro.txt");
+ std::fs::write(&path, "protected\n").unwrap();
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
+
+ let mut buf = Buffer::new(default_config(), Some(path.clone())).unwrap();
+ buf.handle_key(key('i'));
+ typed(&mut buf, "X");
+ buf.handle_key(esc());
+ buf.handle_key(key(':'));
+ typed(&mut buf, "w");
+ buf.handle_key(enter());
+
+ assert_eq!(
+ std::fs::read_to_string(&path).unwrap(),
+ "protected\n",
+ "a refused write must leave the file intact"
+ );
+ assert!(buf.status.is_some(), "must report the failure");
+}
+
+// ---------------------------------------------------------------------------
+// Pagination is structural (MJB-HLR-012)
+// ---------------------------------------------------------------------------
+
+/// MJB-LLR-092, MJB-LLR-200: rendering cost must not scale with file size.
+///
+/// A 200k-line buffer and a 10-line buffer must yield the same number of
+/// visible lines for the same viewport, and the visible set must come from the
+/// anchored window rather than a scan.
+#[test]
+fn mjb_llr_092_visible_line_count_is_independent_of_file_size() {
+ for n in [10usize, 200_000] {
+ let (mut buf, _d) = buffer_with(&many_lines(n));
+ buf.update_view(80, 24);
+ press(&mut buf, "ge");
+ buf.update_view(80, 24);
+
+ let text = buf.document.slice();
+ let count = buf.view.visible_lines(text, 24).count();
+ assert!(
+ count <= 24,
+ "viewport must cap at its height, got {count} for {n} lines"
+ );
+ }
+}
+
+#[test]
+fn mjb_llr_092_scrolling_a_large_file_stays_responsive() {
+ let (mut buf, _d) = buffer_with(&many_lines(200_000));
+ buf.update_view(80, 24);
+
+ let start = std::time::Instant::now();
+ for _ in 0..500 {
+ buf.handle_key(ctrl('d'));
+ buf.update_view(80, 24);
+ }
+ let elapsed = start.elapsed();
+
+ // A per-frame full scan of 200k lines would take far longer than this.
+ assert!(
+ elapsed < std::time::Duration::from_secs(5),
+ "500 half-page scrolls over 200k lines took {elapsed:?}; \
+ rendering is probably not O(viewport)"
+ );
+}
diff --git a/tests/rendering.rs b/tests/rendering.rs
new file mode 100644
index 0000000..79c47f5
--- /dev/null
+++ b/tests/rendering.rs
@@ -0,0 +1,285 @@
+//! Rendering and routing tests (MJB-HLR-012, MJB-HLR-019).
+//!
+//! These use ratatui's `TestBackend` to render into an in-memory cell grid and
+//! assert on what a user would actually see, rather than declaring the
+//! presentation layer "verified manually".
+
+use mojibake::{
+ components::{Component, buffer::BufferComponent},
+ config::Config,
+};
+use ratatui::{Terminal, backend::TestBackend};
+
+fn default_config() -> Config {
+ toml::from_str(include_str!("../.config/config.toml")).expect("built-in config must parse")
+}
+
+/// Render `text` into a `w`×`h` grid and return the rows as strings.
+fn render(text: &str, w: u16, h: u16) -> (Vec<String>, tempfile::TempDir) {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, text).unwrap();
+
+ let mut component = BufferComponent::new(default_config(), Some(path)).unwrap();
+ let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
+ terminal
+ .draw(|frame| component.draw(frame, frame.area()).unwrap())
+ .unwrap();
+
+ let buffer = terminal.backend().buffer().clone();
+ let rows = (0..h)
+ .map(|y| {
+ (0..w)
+ .map(|x| buffer.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
+ .collect::<String>()
+ .trim_end()
+ .to_owned()
+ })
+ .collect();
+ (rows, dir)
+}
+
+#[test]
+fn mjb_llr_200_renders_the_file_contents() {
+ let (rows, _d) = render("hello world\nsecond line\n", 40, 8);
+ let joined = rows.join("\n");
+ assert!(
+ joined.contains("hello world"),
+ "file contents must be on screen, got:\n{joined}"
+ );
+ assert!(joined.contains("second line"));
+}
+
+/// MJB-LLR-200, MJB-HLR-012: only lines intersecting the viewport are drawn.
+/// A 1000-line file in an 8-row terminal must not show line 500.
+#[test]
+fn mjb_llr_200_renders_only_the_visible_window() {
+ let text: String = (0..1000).map(|i| format!("line{i}\n")).collect();
+ let (rows, _d) = render(&text, 40, 8);
+ let joined = rows.join("\n");
+
+ assert!(joined.contains("line0"), "the top of the file is visible");
+ assert!(
+ !joined.contains("line500"),
+ "a line far outside the viewport must not be rendered"
+ );
+ assert!(
+ !joined.contains("line999"),
+ "nor the last line of the file"
+ );
+}
+
+#[test]
+fn mjb_llr_200_line_numbers_are_shown_in_the_gutter() {
+ let (rows, _d) = render("alpha\nbeta\n", 40, 8);
+ assert!(rows[0].starts_with('1'), "row 0 gutter, got {:?}", rows[0]);
+ assert!(rows[1].starts_with('2'), "row 1 gutter, got {:?}", rows[1]);
+}
+
+/// MJB-LLR-201: the block cursor is drawn with a distinct style.
+#[test]
+fn mjb_llr_201_cursor_is_styled_distinctly() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, "abc\n").unwrap();
+
+ let mut component = BufferComponent::new(default_config(), Some(path)).unwrap();
+ let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap();
+ terminal
+ .draw(|frame| component.draw(frame, frame.area()).unwrap())
+ .unwrap();
+
+ let buffer = terminal.backend().buffer().clone();
+ // The gutter is two cells wide for a 1-line file ("1" + space), so the
+ // first text cell holds the cursor.
+ let gutter = 2u16;
+ let cursor_cell = buffer.cell((gutter, 0)).unwrap();
+ let plain_cell = buffer.cell((gutter + 1, 0)).unwrap();
+
+ assert_eq!(cursor_cell.symbol(), "a");
+ assert_ne!(
+ (cursor_cell.fg, cursor_cell.bg),
+ (plain_cell.fg, plain_cell.bg),
+ "the cursor cell must be styled differently from ordinary text"
+ );
+}
+
+/// MJB-LLR-202: the status line reports mode, file name and position.
+#[test]
+fn mjb_llr_202_status_line_shows_mode_and_path() {
+ let (rows, _d) = render("abc\n", 60, 6);
+ let status = &rows[rows.len() - 2];
+
+ assert!(status.contains("NOR"), "mode indicator, got {status:?}");
+ assert!(status.contains("f.txt"), "file name, got {status:?}");
+ assert!(status.contains("1:1"), "cursor position, got {status:?}");
+}
+
+#[test]
+fn mjb_llr_202_status_line_marks_an_unmodified_file() {
+ let (rows, _d) = render("abc\n", 60, 6);
+ let status = &rows[rows.len() - 2];
+ assert!(
+ !status.contains("[+]"),
+ "a freshly opened file is not modified, got {status:?}"
+ );
+}
+
+#[test]
+fn mjb_llr_202_scratch_buffer_is_labelled() {
+ let mut component = BufferComponent::new(default_config(), None).unwrap();
+ let mut terminal = Terminal::new(TestBackend::new(40, 5)).unwrap();
+ terminal
+ .draw(|frame| component.draw(frame, frame.area()).unwrap())
+ .unwrap();
+
+ let buffer = terminal.backend().buffer().clone();
+ let status: String = (0..40)
+ .map(|x| buffer.cell((x, 3)).map(|c| c.symbol()).unwrap_or(" "))
+ .collect();
+ assert!(
+ status.contains("[scratch]"),
+ "a buffer with no path must say so, got {status:?}"
+ );
+}
+
+/// MJB-HLR-019: no trace of the removed template widgets.
+#[test]
+fn mjb_llr_204_no_fps_counter_or_hello_world_is_rendered() {
+ let (rows, _d) = render("some content\n", 80, 12);
+ let joined = rows.join("\n").to_lowercase();
+
+ assert!(!joined.contains("hello world"), "placeholder widget removed");
+ assert!(!joined.contains("fps"), "frame-rate counter removed");
+ assert!(
+ !joined.contains("ticks/sec"),
+ "frame-rate counter removed"
+ );
+}
+
+/// MJB-LLR-203: a key matching the `Global` keymap is consumed by `App` and
+/// never reaches the buffer.
+///
+/// Verified at the component boundary: `Ctrl-c` is bound globally to `Quit`,
+/// and the buffer must not treat it as text even in insert mode. If routing
+/// regressed and the key were forwarded, insert mode would type a `c`.
+#[test]
+fn mjb_llr_203_globally_bound_key_is_not_typed_as_text() {
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, "").unwrap();
+
+ let mut component = BufferComponent::new(default_config(), Some(path)).unwrap();
+ component
+ .handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::empty()))
+ .unwrap();
+ component
+ .handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
+ .unwrap();
+
+ assert_eq!(
+ component.buffer().document.text().to_string(),
+ "",
+ "ctrl-c must never insert a literal 'c'"
+ );
+}
+
+/// MJB-LLR-205: no per-tick key state remains, so a chord cannot be broken by
+/// the passage of time or by intervening frames.
+#[test]
+fn mjb_llr_205_pending_chord_survives_redraws() {
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, "aaa\nbbb\nccc\n").unwrap();
+
+ let mut component = BufferComponent::new(default_config(), Some(path)).unwrap();
+ let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
+
+ let k = |c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
+
+ component.handle_key_event(k('j')).unwrap();
+ component.handle_key_event(k('j')).unwrap();
+ // Begin the `gg` chord...
+ component.handle_key_event(k('g')).unwrap();
+
+ // ...then redraw repeatedly, which is what the tick used to interrupt.
+ for _ in 0..10 {
+ terminal
+ .draw(|frame| component.draw(frame, frame.area()).unwrap())
+ .unwrap();
+ }
+
+ component.handle_key_event(k('g')).unwrap();
+
+ let buf = component.buffer();
+ assert_eq!(
+ buf.document.range().cursor(buf.document.slice()),
+ 0,
+ "gg must still resolve after many redraws"
+ );
+}
+
+/// MJB-LLR-098: a viewport too small to hold the status rows must not panic.
+#[test]
+fn mjb_llr_098_tiny_viewport_renders_without_panicking() {
+ for (w, h) in [(1u16, 1u16), (2, 2), (1, 3), (80, 2)] {
+ let _ = render("content\nmore content\n", w, h);
+ }
+}
+
+#[test]
+fn mjb_llr_200_empty_file_renders_without_panicking() {
+ let (rows, _d) = render("", 40, 6);
+ assert!(!rows.is_empty());
+}
+
+#[test]
+fn mjb_llr_200_wide_characters_render() {
+ let (rows, _d) = render("文字化け\n", 40, 6);
+ let joined = rows.join("\n");
+ // A wide character occupies two terminal cells; ratatui fills the second
+ // with a continuation space, so the glyphs are not contiguous in the
+ // reconstructed row. Check for each one.
+ for c in "文字化け".chars() {
+ assert!(
+ joined.contains(c),
+ "wide character {c:?} must render, got {rows:?}"
+ );
+ }
+}
+
+/// MJB-LLR-025: a wide character must advance the reported column by two, not
+/// by one character or by three bytes.
+#[test]
+fn mjb_llr_025_wide_characters_advance_two_columns() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("f.txt");
+ std::fs::write(&path, "文字\n").unwrap();
+
+ let mut component = BufferComponent::new(default_config(), Some(path)).unwrap();
+ // Move right one grapheme, then read the reported column off the status line.
+ component
+ .handle_key_event(crossterm::event::KeyEvent::new(
+ crossterm::event::KeyCode::Char('l'),
+ crossterm::event::KeyModifiers::empty(),
+ ))
+ .unwrap();
+
+ let mut terminal = Terminal::new(TestBackend::new(60, 6)).unwrap();
+ terminal
+ .draw(|frame| component.draw(frame, frame.area()).unwrap())
+ .unwrap();
+
+ let buffer = terminal.backend().buffer().clone();
+ let status: String = (0..60)
+ .map(|x| buffer.cell((x, 4)).map(|c| c.symbol()).unwrap_or(" "))
+ .collect();
+ assert!(
+ status.contains("1:3"),
+ "one wide character past the start is column 3, got {status:?}"
+ );
+}