diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
| commit | 8e16347b0eb329e84892af8ece36886324c95f62 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 | |
| parent | c6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff) | |
| parent | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff) | |
Establishes the first working baseline: moji <file> opens a file into a
ropey rope and edits it with Helix selection-first modal editing, under a
DO-178C DAL-C requirements and traceability process.
Prior to this, main tracked four files and src/main.rs was still
println!("Hello, world!") — there was no buildable state to build on.
Verified on a fresh clone of the branch with no untracked files:
cargo build; clippy --all-targets -D warnings clean; 294 tests passing;
scripts/check-trace.sh reports 98/98 requirements traced in both
directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
44 files changed, 14209 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" @@ -0,0 +1,3 @@ +export MOJIBAKE_CONFIG=`pwd`/.config +export MOJIBAKE_DATA=`pwd`/.data +export MOJIBAKE_LOG_LEVEL=debug @@ -1 +1,14 @@ /target + +# Runtime data directory: logs and state, not source. +# `.envrc` points MOJIBAKE_DATA here during development. +/.data/ + +# Coverage output +/lcov.info +*.profraw + +# Local AI assistant instructions — personal, not part of the project +CLAUDE.md +CLAUDE.local.md +.claude/ 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" @@ -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 @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2026, rottedfm + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. @@ -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:?}" + ); +} |
