From f2ce26001205c62b8697738b7f629816d1a87975 Mon Sep 17 00:00:00 2001 From: neo Date: Tue, 20 Oct 2026 01:01:47 +0200 Subject: [PATCH 1/2] Ignore the Claude Code folders --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index c3ec7d2..57f050a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ rustc-ice-*.txt # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Claude Code +.agents/ +.claude/ +skills-lock.json From 7f9872b1262fa94c581250e6879917af1e69d8aa Mon Sep 17 00:00:00 2001 From: neo Date: Tue, 20 Oct 2026 01:02:06 +0200 Subject: [PATCH 2/2] Use Rust crates instead of programs and hand-written parts; fix the security audit's findings Security (from the audit): - git ran repository-chosen programs on file open (core.fsmonitor), in the explorer (clean filters), the commit pickers (textconv) and git log/show (gpg.program). git is now read with gix, which takes filter and diff drivers only from the user's and the system's config, never from the opened folder's .git/config. - The pickers, the reload after a shell command and the statusline's .git/HEAD read only regular files, with bounded reads: a FIFO or a symlink to /dev/zero no longer hangs or exhausts lim. - Panic backups and save temp files are new files, owner-only (0600), and a link planted at their name is not followed. - Bidi controls are drawn as `?`; the Windows clipboard read is bounded. Crates for what lim did with programs or by hand: - gix for git (gitsigns base, status, file list, log/graph, show, stash, browse) in src/repo.rs; imara-diff (through gix) for gitsigns. - syntect with bat's grammars (two-face) for highlighting; pulldown-cmark for the Markdown preview; jsonc-parser for the config. - ureq with rustls for the update's HTTPS (no libcurl or WinHTTP FFI). - arboard for the clipboard, open for the default app. - data-encoding, percent-encoding, sha2, getrandom and jiff for the text tools' encodings, hashes, random bytes and time zones. Dependencies are optimized in debug builds so the perf budgets hold in `cargo test`. The old explorer passed `git status --relative`, which git rejects, so it never showed statuses; it does now. --- CLAUDE.md | 16 +- Cargo.lock | 2413 +++++++++++++++++++++++++++++++++ Cargo.toml | 17 + README.md | 15 +- docs/architecture.md | 32 +- docs/development.md | 2 +- src/buffer/git.rs | 307 +---- src/buffer/mod.rs | 74 +- src/config/json.rs | 225 +-- src/editor/clipboard.rs | 168 +-- src/editor/explorer.rs | 37 +- src/editor/extras.rs | 33 +- src/editor/mod.rs | 7 +- src/editor/picker.rs | 45 +- src/editor/sources.rs | 85 +- src/editor/textops.rs | 258 +--- src/editor/tools/generated.rs | 9 +- src/editor/tools/time.rs | 12 +- src/fuzz.rs | 44 +- src/main.rs | 2 + src/render/finder.rs | 47 +- src/render/markdown.rs | 595 ++++---- src/render/mod.rs | 39 +- src/render/row.rs | 10 +- src/repo.rs | 602 ++++++++ src/syntax/lang.rs | 1219 +---------------- src/syntax/mod.rs | 513 +++---- src/term/mod.rs | 2 +- src/term/sys/unix.rs | 165 +-- src/term/sys/windows.rs | 163 +-- src/update.rs | 25 +- 31 files changed, 4025 insertions(+), 3156 deletions(-) create mode 100644 src/repo.rs diff --git a/CLAUDE.md b/CLAUDE.md index 39cd6bd..43597d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -lim: a Vim/AstroNvim-style modal editor in Rust (edition 2024, MSRV 1.88) with **zero crates**, for Linux, macOS -and Windows. The terminal is driven by raw FFI: libc in `src/term/sys/unix.rs`, kernel32 in `src/term/sys/windows.rs`. -Both expose the same functions, selected with `#[cfg]` + `#[path]` in `term/mod.rs`. Never add a dependency. +lim: a Vim/AstroNvim-style modal editor in Rust (edition 2024, MSRV 1.88) for Linux, macOS and Windows. The +terminal is driven by raw FFI: libc in `src/term/sys/unix.rs`, kernel32 in `src/term/sys/windows.rs`. Both expose the +same functions, selected with `#[cfg]` + `#[path]` in `term/mod.rs`. Use a Rust crate rather than running a program +or hand-writing what one does: gix for git (`src/repo.rs`, never `Command::new("git")`), syntect + two-face for +highlighting, pulldown-cmark, jsonc-parser, ureq (rustls), arboard, open, and small crates for encodings, hashes, +random bytes and time zones (see `docs/architecture.md`). A program runs only where that is the feature (config +`commands`, the shell, `t…` tools). ## Commands @@ -40,9 +44,9 @@ wsl -d Ubuntu --cd /mnt/d/Code/Github/Lim -e bash -lc 'LIM_NVIM=$HOME/.local/nvi keymaps picker all read from it, so add or change keys there and nowhere else. - `buffer/`: text lines plus an undo `History` that records each replaced line range. The same ranges shift marks and jump-list entries, so a new kind of edit has to go through it. -- `syntax/`: a keyword highlighter that caches its per-line start state and invalidates it from the first edited - line. -- `config/`: a hand-written JSON parser feeding `Options`. `lim --print-config` prints the documented defaults +- `syntax/`: syntect (bat's grammars via two-face), scopes mapped to lim's groups; caches each line's start state + and colors and invalidates them from the first edited line. +- `config/`: jsonc-parser (comments, trailing commas) feeding `Options`. `lim --print-config` prints the documented defaults (also in `config.example.json`). ## Rules diff --git a/Cargo.lock b/Cargo.lock index 1b1988c..d0f0e19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,2419 @@ # It is not intended for manual editing. version = 4 +[[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 = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "arc-swap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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 = "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 = "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 = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[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 = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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 = "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 = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "0.3.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4deef977660e373c458df605a913d4ab121db0ccd18d1ad6976dff7d37cddb53" +dependencies = [ + "defmt 1.2.0", +] + +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror", +] + +[[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 = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[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 = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "faster-hex" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" +dependencies = [ + "autocfg", + "defmt 0.3.101", + "heapless", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[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 = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gix" +version = "0.89.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2156d2e07fc3afa7632a82bf3cb0db91f995645a2852f642105c9ac9b52e1cb2" +dependencies = [ + "gix-actor", + "gix-attributes", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-note", + "gix-object", + "gix-odb", + "gix-pack", + "gix-parallel", + "gix-path", + "gix-pathspec", + "gix-protocol", + "gix-quote", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-stream", + "gix-zlib", + "nonempty", + "smallvec", +] + +[[package]] +name = "gix-actor" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2127da41042965ed205568a52443465806944f71c8acd73f48ec94b8cd8e798e" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-attributes" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0feca0b8919aba9f123b75de54062dd61eb37d2a2130f34be520da6ed8b8ef72" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-parallel", + "gix-path", + "gix-quote", + "gix-trace", + "smallvec", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "408cb9e482e0dcc5fc481850556348dda6981ec054a4a8e81a8e1bbf84f85eef" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-chunk" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626335faf30ed235744642c2309aa370683b8bb2757f1c22a807e274dff60074" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac560c05e19455016a4a94b33a77debe59aaed8783da32a73d31c9975947152e" +dependencies = [ + "bstr", + "gix-error", + "gix-path", + "gix-quote", + "gix-trace", +] + +[[package]] +name = "gix-commitgraph" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfede9936d4b789867c09722576aea4ceb0e3aacf962eef035ebab0686b41a80" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65a9c000fa9a9154f22ffc8f8d74426e9300d759c2029e8701e2ba39d5d464ea" +dependencies = [ + "bstr", + "gix-config-value", + "gix-error", + "gix-glob", + "gix-parallel", + "gix-path", + "gix-ref", + "gix-sec", + "gix-trace", + "gix-utils", + "smallvec", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab206c2fccfd35e0de7fb1b424997cfd42912062c66dae024e08a06e22d46674" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-error", + "gix-path", + "gix-utils", + "libc", +] + +[[package]] +name = "gix-date" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7eeeecc599082f32b5ea6a8c4c734e38be9730e2c09e3fe2851bb4692393e9" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.69.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa640963fdcbc6b4718e48593c3917f5ae764b59fb5a96313cb5a757246d041d" +dependencies = [ + "bstr", + "gix-attributes", + "gix-command", + "gix-error", + "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", +] + +[[package]] +name = "gix-dir" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e916ef77f19161b738ae9913662bc36fed1e48256a943f40277b3a585841da" +dependencies = [ + "bstr", + "gix-discover", + "gix-error", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", +] + +[[package]] +name = "gix-discover" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7d0f8fd2c7e4b458eb3081362d49b54bd0131d23ecd49be802085a11f0d3cee" +dependencies = [ + "bstr", + "dunce", + "gix-error", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", +] + +[[package]] +name = "gix-error" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb3308a948a01b94ef13d9ee7a9b28c1303d8d9a9dc33b401ff40c2184d9e04a" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-filter" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d8d932ee4043f7d77d15ee58d2be2bc95accea5dd1d77fd145de1b7c504551b" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-error", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", +] + +[[package]] +name = "gix-fs" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44a05d043e670c055cd8b53d078562dc0bc1e322bff6b0376b407d37cf72dcc5" +dependencies = [ + "bstr", + "gix-error", + "gix-parallel", + "gix-path", + "gix-utils", + "libc", + "walkdir", +] + +[[package]] +name = "gix-glob" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b0d03ab50fa841621031960679caf7fe94940968d9a0736d12a580f2c03e525" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-fs", + "gix-path", + "gix-trace", + "gix-utils", +] + +[[package]] +name = "gix-hash" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ca156064e378d8c657084365846cedb8dc498c6ea4500965a64d74f12af2be" +dependencies = [ + "faster-hex", + "gix-error", + "gix-utils", + "sha1dc", +] + +[[package]] +name = "gix-hashtable" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916cb6b2ec42ffaca624e7867bb382a4673015cb4ffcd7330b48085d02e4c615" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb1c93f60845d493a72fde38ae237498bc4e4464f45afa4af23e53f8295a3ba1" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238" +dependencies = [ + "bstr", + "hashbrown 0.17.1", +] + +[[package]] +name = "gix-index" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6db8b20a3dba8836daf90b24199600bb27dcfce3a127ec24ae61e15a7c5b68" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-error", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-parallel", + "gix-trace", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", +] + +[[package]] +name = "gix-lock" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2f7f5bd6b9fcd0b0dc58b0e6a708f3a47c3313876571dfe800c926c069aa5c" +dependencies = [ + "gix-error", + "gix-fs", + "gix-tempfile", + "gix-utils", +] + +[[package]] +name = "gix-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f11f5429ca73cc3758c9f53af56a9fba100a8701c5b4bc30e29b8b0b15288991" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gix-note" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd364f8bdceaed9e7f3ae40f324b16e39f20e9c7e6a6a058f7c6ec66c3e6205c" +dependencies = [ + "gix-error", + "gix-hash", + "gix-object", +] + +[[package]] +name = "gix-object" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e38f50177e211642af154f9a7f710b7a3a637e635f9b6adb9d51973b0f9209" +dependencies = [ + "bstr", + "gix-actor", + "gix-command", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", +] + +[[package]] +name = "gix-odb" +version = "0.86.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca444f33f2140876ed6b5313c2b550b4af699008193c96f3bff5023afa9ef4ea" +dependencies = [ + "arc-swap", + "crc32fast", + "gix-error", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-parallel", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "gix-zlib", + "memmap2", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-pack" +version = "0.76.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5024cc503cd29461a705ae90b9eae772fa8f0e6fafc1c48513877dc374de05" +dependencies = [ + "clru", + "crc32fast", + "crossbeam-deque", + "gix-chunk", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-parallel", + "gix-path", + "gix-trace", + "gix-utils", + "gix-zlib", + "memmap2", + "smallvec", + "uluru", +] + +[[package]] +name = "gix-packetline" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e51b227f6a925fe9308858ac91170dd00fa2880f5480fc81e8a4594fe2c35" +dependencies = [ + "bstr", + "faster-hex", + "gix-error", + "gix-trace", +] + +[[package]] +name = "gix-parallel" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994cf1eec247ba4be7f9e7c0d58cc73f83eecb5fcb9feb595df5ba3d2078546" +dependencies = [ + "crossbeam-channel", + "gix-trace", + "once_cell", + "parking_lot", +] + +[[package]] +name = "gix-path" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d563d4d0c975ed0df4e853f9ec962a2ba46633b1d324e1a5582541fcae0c6b" +dependencies = [ + "bstr", + "gix-error", + "gix-trace", + "gix-validate", +] + +[[package]] +name = "gix-pathspec" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3b7f03e516a8435d18c3cc2d446925940c81d409145ac21edb5a576f0625e4" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-error", + "gix-glob", + "gix-path", +] + +[[package]] +name = "gix-protocol" +version = "0.67.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832b5dc46a3c0a6f6fa8f8e571c11074add4b545c6789799ae6d6c36ae1a294e" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "gix-hash", + "gix-macros", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "nonempty", +] + +[[package]] +name = "gix-quote" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f92bc1b13c09e88388578e8aa1e987ec35f28fca158c13b1baa7ab1a85b124" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.69.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "073bc0928e1f747823b1eaed94918119dacef4c616532bc3d17e5b7457863471" +dependencies = [ + "gix-actor", + "gix-error", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-parallel", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", +] + +[[package]] +name = "gix-refspec" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc7010dae0df2900aa77cc67f8aca042cd80e35938ccc179010d863b1d42a0c" +dependencies = [ + "bstr", + "gix-error", + "gix-hash", + "gix-validate", + "smallvec", +] + +[[package]] +name = "gix-revision" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63210c1943b67fa700806c3c3486fc16a92b3ea871f5cf092340b8eec10a0ca1" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "800479fbbf1e7f94f663e07943a91add5499343e7aac611a81e6a0460fd44af6" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", +] + +[[package]] +name = "gix-sec" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5640f642a1b471616d48230b1def7ca743cb30903441ff84800cafbab3c0fddf" +dependencies = [ + "bitflags 2.13.2", + "gix-path", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-shallow" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bc0720a3d6b10f18e3ac382a7780bcfc4c6bee94a8b5857b8e4ec94c50d754" +dependencies = [ + "bstr", + "gix-error", + "gix-hash", + "gix-lock", + "nonempty", +] + +[[package]] +name = "gix-status" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3174a9f3fb991cf25a7a609ab6c50f19baa7d5f8ce05a48c202f7db4ce3ba" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-error", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-parallel", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "hashbrown 0.16.1", + "portable-atomic", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-submodule" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d945257bd0d624ca844a5a36c79d748df080f355b0ac92d68793875aa529a3f" +dependencies = [ + "bstr", + "gix-config", + "gix-error", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", +] + +[[package]] +name = "gix-tempfile" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9275c8ae0646d840ae0156d3f90d39b7beea6e31a398afb9ee72feb293a269c" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-trace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133f2de116104d637541831b6acb157898556d5eeec363ab2eb1c583adc6bb56" + +[[package]] +name = "gix-transport" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e43d6b0097ea9f2f266d5d6c43b8f615ed3da7c686ec8771cd17c1cb33cb0de" +dependencies = [ + "bstr", + "gix-command", + "gix-error", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-sec", + "gix-trace", + "gix-url", + "gix-utils", +] + +[[package]] +name = "gix-traverse" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a668a6e7f7688e5a43eaf161f1471ffc559de1a5fd8471e861c64d59d1699e1e" +dependencies = [ + "bitflags 2.13.2", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", +] + +[[package]] +name = "gix-url" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04317dea7a5dd5253bbcab060c230f3de6a57d17cc9600c6cd7030bf4795b414" +dependencies = [ + "bstr", + "gix-error", + "gix-path", + "gix-utils", + "percent-encoding", +] + +[[package]] +name = "gix-utils" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8e770b8c34311044d1addc7b18957db0ac27183707e1140dc90e6f674a6354e" +dependencies = [ + "bstr", + "bytes", + "fastrand", + "getrandom 0.4.3", + "gix-error", + "prodash", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbcd59cc52742468bc5c8a0e7123390d154f45e6cbd37461b0dcecdc681f2179" +dependencies = [ + "bstr", + "gix-error", +] + +[[package]] +name = "gix-worktree" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a285c0f6b41e504e8bb81ca3f861c4da00cb3d5ced3210a4591112f22da6d38f" +dependencies = [ + "bstr", + "gix-attributes", + "gix-error", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d2789610e8179d4e0e8e51170a52a66483c103a8ad3c7db4d075b571856cb6" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-parallel", + "gix-path", + "gix-trace", + "gix-traverse", + "gix-utils", + "parking_lot", +] + +[[package]] +name = "gix-zlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c2edb206bf06f3c801f4f6789c696396171558ab9b4c7f1fe3ba4d39b00b0f4" +dependencies = [ + "gix-error", + "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.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[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 = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[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.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt 1.2.0", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt 1.2.0", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[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 = "jsonc-parser" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ecded084f9b9a718d39668a42657c32253663d1f6afb3fbb9134f0305f73f48" +dependencies = [ + "indexmap", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + [[package]] name = "lim" version = "0.2.0" +dependencies = [ + "arboard", + "data-encoding", + "getrandom 0.4.3", + "gix", + "jiff", + "jsonc-parser", + "open", + "percent-encoding", + "pulldown-cmark", + "sha2", + "syntect", + "two-face", + "ureq", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[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 = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[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.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf52d87428247c5aa3760ac8e7d8b13ec7b3579b955a8c3f311668b6860acba5" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "unicase", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[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 = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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 = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sha1dc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b0ebf6537e3e8db8bb7d21ecb6d7399738c953c757945c6b95e46c4e95df7b" + +[[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 = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror", + "walkdir", +] + +[[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", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + +[[package]] +name = "two-face" +version = "0.5.2+bat-0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915be7adc2ff6f4338acbf71f3eda0a146f46003b992a1669c674308610efdac" +dependencies = [ + "serde", + "serde_derive", + "syntect", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[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.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", + "winreg", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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 = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "webpki-roots" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +dependencies = [ + "rustls-pki-types", +] + +[[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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +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_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 = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" diff --git a/Cargo.toml b/Cargo.toml index 9e04915..e56eb9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,23 @@ license = "MIT" repository = "https://github.com/Neotoxic-off/Lim" [dependencies] +data-encoding = "2.9" +getrandom = "0.4" +jiff = "0.2" +percent-encoding = "2.3" +sha2 = "0.10" +arboard ={ version = "3.7", default-features = false, features = ["wayland-data-control"] } +gix = { version = "0.89", default-features = false, features = ["sha1", "status", "revision", "max-performance-safe"] } +open = "5.4" +ureq = { version = "3.4", default-features = false, features = ["rustls", "win-system-proxy"] } +jsonc-parser = { version = "0.34", features = ["preserve_order"] } +pulldown-cmark = { version = "0.13", default-features = false } +syntect = { version = "5.3", default-features = false, features = ["parsing", "regex-fancy"] } +two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] } + +# The crates (syntect's regexes, gix) are optimized even in debug builds, so `cargo test` keeps its perf budgets. +[profile.dev.package."*"] +opt-level = 3 [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 5f2c688..07f8beb 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ lim is that editor with all of it **built in**: - **No Lua.** One JSON file for options, colors and keymaps, or the `,` settings screen. - **No plugins.** File tree, pickers, live grep, which-key, git signs, completion, sessions, 30 themes, text tools: part of the binary, tested together. `pu` gets the latest release. -- **No dependencies.** Zero crates, nothing to install beside the binary; it runs the same on Linux, macOS +- **No dependencies.** Nothing to install beside the binary, not even git; it runs the same on Linux, macOS and Windows. - **Nothing breaks after an update.** There is no ecosystem to drift out of sync. - **Vim inside.** Operators, text objects, registers, macros, marks, regex `:s` and `:g`, `:norm`, visual @@ -68,7 +68,7 @@ the start screen. | Motions | `h j k l w b e ge W B E gE 0 ^ $ g_ { } % gg G H M L f F t T ; , n N * #`, `gn` (`cgn` then `.`), page and line scrolling, `` (word / page), `zz zt zb` · `:` and `/` history | | Text objects | `iw` `aw` `iW` `aW` `ip` `ap` `it` `at` (HTML/XML tags) `i(` `a(` `i[` `a[` `i{` `a{` `i<` `a<` `i"` `a"` `i'` `a'` `` i` `` `` a` `` | | NvChad look | 30 colorschemes (NvChad's onedark by default) · NvChad-style statusline (rounded, slanted, arrow or flat ends) · NvChad tabufline with jump labels and clickable tabs, close marks, light/dark and close-all buttons, aligned with the windows when the file tree is open · git branch and +/~/- counts in the statusline, and a hydra spinner while background commands (`:upgrade`, config commands) run · nvim-tree look for the file tree · optional winbar path and relative numbers · sign column · cursor line number (or the whole cursorline) · borderless NvChad-style floats with pill titles · notifications · start screen with the hydra and nvdash buttons | -| Code | keyword highlighting for 40+ languages and formats (Rust, Python, JS/TS, C/C++, C#, Java, Kotlin, Go, PHP, Ruby, Swift, Dart, Scala, Lua, SQL, HTML/XML, CSS/SCSS, Markdown, JSON/YAML/TOML, shell, PowerShell, batch, Makefile, Dockerfile, CMake, Zig, Haskell, Elixir, R, Perl, asm, Nix, Terraform...) with multi-line comments and strings, HTML tags and config keys · blink.cmp-style completion from the words of open buffers · autopairs · auto-indent · comment toggling | +| Code | syntax highlighting with Sublime grammars (syntect, bat's set) for 200+ languages and formats (Rust, Python, JS/TS, C/C++, C#, Java, Kotlin, Go, PHP, Ruby, Swift, Dart, Scala, Lua, SQL, HTML/XML, CSS/SCSS, Markdown, JSON/YAML/TOML, shell, batch, Makefile, Dockerfile, CMake, Zig, Haskell, Elixir, R, Perl, asm, Nix, Terraform...) · blink.cmp-style completion from the words of open buffers · autopairs · auto-indent · comment toggling | | Helpers | indent guides · matching bracket · references of the word under the cursor (`]r`) · URLs (`gx`) · `#RRGGBB` shown in its color · `TODO:`/`FIX:`/`HACK:`… highlighted (`]T`) · Markdown preview beside the file, live as you type (`um`) | | Git | signs against the index · hunk jumps · graph of every branch, log, file log, status and stash pickers with diffs · `git browse` · status colors in the file tree | | Files | fuzzy picker with preview (files, git files, grep, buffers, recent, smart, lines, marks, registers, notifications, keymaps, commands, TODOs, sessions) · neo-tree style explorer with create/rename/copy/move/delete · `:rename` · sessions | @@ -107,8 +107,8 @@ curl.exe -fsSLo $env:TEMP\lim-install.bat https://raw.githubusercontent.com/Neot **Update** from inside lim with `pu` (or `:upgrade`): it asks GitHub for the latest release and, when it is newer than yours, downloads the binary for your platform and puts it in place of the running one, in the background -while you keep editing. No Rust needed: lim talks HTTPS through the system (WinHTTP on Windows, libcurl on Linux and -macOS). A notification says when it is done; restart lim to use the new version. Release binaries exist for Linux +while you keep editing. No Rust or curl needed: lim talks HTTPS itself (rustls, HTTPS only, the proxy from +`HTTPS_PROXY` or Windows' settings). A notification says when it is done; restart lim to use the new version. Release binaries exist for Linux x86_64 and ARM64, macOS (Apple silicon and Intel) and Windows x86_64; elsewhere, run the installer again. Without the installer: `cargo install --git https://github.com/Neotoxic-off/Lim --locked`, or from a clone @@ -118,8 +118,7 @@ Runs on Linux, macOS and Windows 10+ (Windows Terminal or the console, not mintt [Nerd Font](https://www.nerdfonts.com) for the default icons (or `"iconset": "unicode"` / `"ascii"`). Truecolor is detected; other terminals get the 256-color palette. -Optional, picked up when installed: `wl-copy`/`xclip`/`xsel`/`pbcopy` (clipboard; Windows uses its own), -`node`, `python3`, `btm`, `gdu`, `xdg-open`/`open` (`gx`, `go`; Windows uses `explorer`). +Optional, picked up when installed: `node`, `python3`, `btm`, `gdu`, and the TUI tools of `t…`. ## Usage @@ -153,8 +152,8 @@ search every key and command), or `` and wait for which-key. ## Limits -Not there, by design: LSP (`l…`, `gd`, diagnostics, language-aware completion; buffer-word completion is there), debugger, treesitter (a keyword -highlighter stands in), plugins, scripting the editor (config commands only filter text through a process), an +Not there, by design: LSP (`l…`, `gd`, diagnostics, language-aware completion; buffer-word completion is there), debugger, treesitter (syntect +grammars stand in), plugins, scripting the editor (config commands only filter text through a process), an embedded terminal (`` suspends to your shell), Vim tab pages, line wrapping, folds, spell checking, persistent undo. Files are read whole into memory, as in Vim: text files over 512 MB and binaries over 64 MB (their hex dump takes about 7 times their size) are refused with a message. diff --git a/docs/architecture.md b/docs/architecture.md index 603513a..2e6c803 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,16 +1,26 @@ # How lim is built -No crates: the terminal is driven through a handful of libc calls (`tcsetattr`, `ioctl`, `select`, `read`, -`signal`) or, on Windows, kernel32 console calls with VT input and output, so both send the same escape -sequences to one key decoder. The Windows clipboard is user32. HTTPS, for the update, goes through the system: -WinHTTP on Windows, and on Linux and macOS libcurl, loaded with `dlopen` when needed so lim does not link -against it. Everything else is the standard library. +The terminal is driven through a handful of libc calls (`tcsetattr`, `ioctl`, `select`, `read`, `signal`) or, +on Windows, kernel32 console calls with VT input and output, so both send the same escape sequences to one key +decoder. + +Crates do what would otherwise mean running programs, binding system libraries or writing a parser by hand: +gix reads git repositories (`src/repo.rs`: signs, statuses, the file list, the git pickers, git browse) and +imara-diff (through gix) diffs for gitsigns; syntect with bat's grammars (two-face) highlights, pulldown-cmark +reads Markdown for the preview, jsonc-parser reads the config; ureq with rustls fetches the update over HTTPS; +arboard is the system clipboard and open hands URLs and files to the default app; data-encoding, +percent-encoding, sha2, getrandom and jiff do the encodings, hashes, random bytes and time zones. + +No git process runs, and nothing a repository's own `.git/config` names runs either (gix takes filter and diff +drivers only from the user's and the system's config), so opening a folder from anywhere is safe. Programs still +run where that is the feature: config `commands`, the shell and the `t…` tools. ``` src/ ├── main.rs startup, event loop, panic recovery ├── update.rs :upgrade / pu: the latest GitHub release's binary, swapped in ├── error.rs Vim-worded errors (E32, E37, E492…) +├── repo.rs git through gix: index text, status, file list, log/graph, show, stashes, browse ├── term/ raw mode, window size, resize events, key decoding, HTTPS (sys/unix.rs, sys/windows.rs) ├── buffer/ lines, undo history, motions, text objects, git signs, file I/O, hex dumps ├── editor/ modes, operators, registers, keymaps, the built-in binding table, windows, picker, @@ -18,8 +28,8 @@ src/ │ text tools, reverse-engineering commands, config commands │ └── tools/ the x tools, one module each (calc, units, bases, time, color, json, case, │ generated text), and their popup -├── syntax/ keyword highlighter with multi-line state, language tables, todo-comments -├── config/ JSON reader, key notation, options/palette/highlights/keymaps/commands +├── syntax/ syntect highlighting with per-line state, language facts, todo-comments +├── config/ JSON (jsonc-parser), key notation, options/palette/highlights/keymaps/commands └── render/ cell grid, themes and palettes, icon sets, windows, bars, floats, picker, tree, start screen ``` @@ -33,10 +43,10 @@ src/ - **Undo** stores the lines an edit replaced, not copies of the file, and keeps 1000 steps. The same line-range changes move marks and jump-list anchors, so they follow the text. - **Completion** words are cached per buffer and re-read only when that buffer's text changed. -- **Highlighting** state at each line start is cached and invalidated from the first edited line. A far jump - finds its start state with a scan that only tracks comments and strings (skipping straight to the bytes that - can open one) and leaves a checkpoint every 4096 lines, so it stays exact: the first `G` in a 3-million-line - file takes about 80 ms, later jumps well under a millisecond. Only the visible part of a long line is colored. +- **Highlighting** is syntect with bat's grammars (two-face). The parse state at each line start and the colors + of each drawn line are cached, and invalidated from the first edited line, so a line is parsed once. A far jump + parses from 300 lines above it (Vim's `syntax sync minlines`), so a comment opened further up is missed until + scrolled through. Lines over 20,000 bytes are drawn plain. The grammars load on a background thread at start. - **Live grep** reads the project once per picker and indexes its trigrams: each key greps only the files that hold every trigram of the query. - **Hex buffers** are ordinary lines in a dump format; only the hex digits count when writing, so every Vim edit diff --git a/docs/development.md b/docs/development.md index 353c2ae..eeaba7c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ # Development -Rust 1.88+, no dependencies. The code layout and design are in [How lim is built](architecture.md). +Rust 1.88+; the crates are listed in `Cargo.toml` (gix, syntect + two-face, pulldown-cmark, jsonc-parser, ureq, arboard, open and small encoders). The code layout and design are in [How lim is built](architecture.md). ```sh cargo test # 160+ tests: behaviour, fuzzers on every input, perf budgets diff --git a/src/buffer/git.rs b/src/buffer/git.rs index c3c02f0..e7dfbd6 100644 --- a/src/buffer/git.rs +++ b/src/buffer/git.rs @@ -1,8 +1,8 @@ //! gitsigns: which lines are added, changed or deleted against the git index. -use std::collections::HashMap; use std::path::Path; -use std::process::{Command, Output, Stdio}; + +use gix::diff::blob::{Algorithm, Diff, InternedInput}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Sign { @@ -30,13 +30,10 @@ pub struct Counts { pub removed: usize, } -/// Diagonal steps one diff may take (a few ms); a rewrite needing more marks what is left changed wholesale. -const MAX_STEPS: usize = 2_000_000; - impl GitState { - /// Reads the staged version via `git show :./name`; no repo, untracked file or no git → no signs. + /// Reads the staged version from the index; no repo or untracked file → no signs. pub fn load(&mut self, path: &Path) { - self.base = index_text(path).map(|text| { + self.base = crate::repo::index_text(path).map(|text| { let body: &str = text.strip_suffix('\n').unwrap_or(&text); body.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_owned()).collect() }); @@ -65,249 +62,31 @@ impl GitState { } } -fn index_text(path: &Path) -> Option { - let dir: &Path = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); - let name: String = path.file_name()?.to_string_lossy().into_owned(); - let output: Output = Command::new("git") - .arg("-C") - .arg(dir) - .arg("show") - .arg(format!(":./{name}")) - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -/// One sign slot per line of `new`. +/// One sign slot per line of `new`: imara-diff's Myers diff (its histogram is several times slower on big +/// rewrites), each hunk marking its lines changed (as many as it +/// deleted) then added, or the line above a pure deletion. pub fn diff(old: &[String], new: &[String]) -> Vec> { + let mut input: InternedInput<&str> = InternedInput::default(); + input.update_before(old.iter().map(String::as_str)); + input.update_after(new.iter().map(String::as_str)); let mut signs: Vec> = vec![None; new.len()]; - let prefix: usize = old.iter().zip(new).take_while(|(a, b)| a == b).count(); - let suffix: usize = old[prefix..].iter().rev().zip(new[prefix..].iter().rev()).take_while(|(a, b)| a == b).count(); - let (old_mid, new_mid): (&[String], &[String]) = - (&old[prefix..old.len() - suffix], &new[prefix..new.len() - suffix]); - if old_mid.is_empty() && new_mid.is_empty() { - return signs; - } - let ops: Vec = Myers::ops(old_mid, new_mid); - // Each run of deletions/insertions between kept lines becomes one hunk. - let (mut line, mut deleted, mut inserted): (usize, usize, usize) = (prefix, 0, 0); - let flush = |line: usize, deleted: usize, inserted: usize, signs: &mut Vec>| { - let start: usize = line - inserted; - for (i, slot) in signs.iter_mut().enumerate().skip(start).take(inserted) { + for hunk in Diff::compute(Algorithm::Myers, &input).hunks() { + let deleted: usize = hunk.before.len(); + let start: usize = usize::try_from(hunk.after.start).unwrap_or(0); + let end: usize = usize::try_from(hunk.after.end).unwrap_or(0).min(signs.len()); + for (i, slot) in signs.iter_mut().enumerate().take(end).skip(start) { *slot = Some(if i - start < deleted { Sign::Change } else { Sign::Add }); } - if deleted > inserted { - let at: usize = if line > 0 { line - 1 } else { 0 }; - if let Some(slot) = signs.get_mut(at) - && slot.is_none() - { - *slot = Some(Sign::Delete); - } - } - }; - for op in ops { - match op { - Op::Keep => { - flush(line, deleted, inserted, &mut signs); - (deleted, inserted) = (0, 0); - line += 1; - } - Op::Delete => deleted += 1, - Op::Insert => { - inserted += 1; - line += 1; - } + if deleted > end.saturating_sub(start) + && let Some(slot) = signs.get_mut(end.saturating_sub(1)) + && slot.is_none() + { + *slot = Some(Sign::Delete); } } - flush(line, deleted, inserted, &mut signs); signs } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Op { - Keep, - Delete, - Insert, -} - -/// Myers' O(ND) diff in linear space (the middle snake, split and recursed), over line ids. -struct Myers { - a: Vec, - b: Vec, - ops: Vec, - steps: usize, - /// Furthest x reached forward, and furthest y backward, per diagonal (box-relative); reused between snakes. - forward: Vec, - backward: Vec, -} - -fn signed(v: usize) -> isize { - isize::try_from(v).unwrap_or(isize::MAX) -} - -fn unsigned(v: isize) -> usize { - usize::try_from(v).unwrap_or(0) -} - -/// A middle snake: from the point before its one edit to the end of its diagonal run. -type Snake = ((usize, usize), (usize, usize)); - -impl Myers { - fn ops<'a>(old: &'a [String], new: &'a [String]) -> Vec { - let mut ids: HashMap<&'a str, usize> = HashMap::new(); - let mut id = |line: &'a String| -> usize { - let next: usize = ids.len(); - *ids.entry(line.as_str()).or_insert(next) - }; - let a: Vec = old.iter().map(&mut id).collect(); - let b: Vec = new.iter().map(&mut id).collect(); - // Lines missing from the other side can never be kept: diff without them (as xdiff does), so a rewrite - // costs nothing to search, then put them back as deletions and insertions. - let mut seen: [Vec; 2] = [vec![false; ids.len()], vec![false; ids.len()]]; - for (side, lines) in seen.iter_mut().zip([&a, &b]) { - for &line in lines { - side[line] = true; - } - } - let a_kept: Vec = (0..a.len()).filter(|&i| seen[1][a[i]]).collect(); - let b_kept: Vec = (0..b.len()).filter(|&j| seen[0][b[j]]).collect(); - let mut myers: Self = Self { - a: a_kept.iter().map(|&i| a[i]).collect(), - b: b_kept.iter().map(|&j| b[j]).collect(), - ops: Vec::with_capacity(a_kept.len() + b_kept.len()), - steps: 0, - forward: Vec::new(), - backward: Vec::new(), - }; - myers.compare(0, a_kept.len(), 0, b_kept.len()); - let mut ops: Vec = Vec::with_capacity(a.len() + b.len()); - let (mut i, mut j, mut fi, mut fj): (usize, usize, usize, usize) = (0, 0, 0, 0); - for op in myers.ops { - let (to_i, to_j): (usize, usize) = match op { - Op::Keep => (a_kept[fi], b_kept[fj]), - Op::Delete => (a_kept[fi] + 1, j), - Op::Insert => (i, b_kept[fj] + 1), - }; - ops.extend(std::iter::repeat_n(Op::Delete, to_i - i)); - ops.extend(std::iter::repeat_n(Op::Insert, to_j - j)); - (i, j) = (to_i, to_j); - if op == Op::Keep { - ops.push(Op::Keep); - (i, j) = (i + 1, j + 1); - } - fi += usize::from(op != Op::Insert); - fj += usize::from(op != Op::Delete); - } - ops.extend(std::iter::repeat_n(Op::Delete, a.len() - i)); - ops.extend(std::iter::repeat_n(Op::Insert, b.len() - j)); - ops - } - - /// Ops turning `a[left..right]` into `b[top..bottom]`. - fn compare(&mut self, mut left: usize, mut right: usize, mut top: usize, mut bottom: usize) { - while left < right && top < bottom && self.a[left] == self.b[top] { - self.ops.push(Op::Keep); - (left, top) = (left + 1, top + 1); - } - let mut kept: usize = 0; - while left < right && top < bottom && self.a[right - 1] == self.b[bottom - 1] { - (right, bottom, kept) = (right - 1, bottom - 1, kept + 1); - } - // Both sides non-empty with different ends: at least 2 edits, so each part below costs less. - let snake: Option = - if left == right || top == bottom { None } else { self.middle_snake(left, right, top, bottom) }; - if let Some(((px, py), (x, y))) = snake { - self.compare(left, px, top, py); - self.compare(px, x, py, y); - self.compare(x, right, y, bottom); - } else { - self.ops.extend(std::iter::repeat_n(Op::Delete, right - left)); - self.ops.extend(std::iter::repeat_n(Op::Insert, bottom - top)); - } - self.ops.extend(std::iter::repeat_n(Op::Keep, kept)); - } - - /// The middle snake of the box, searched from both ends at once; `None` once over `MAX_STEPS`. - fn middle_snake(&mut self, left: usize, right: usize, top: usize, bottom: usize) -> Option { - let (w, h): (isize, isize) = (signed(right - left), signed(bottom - top)); - let max: isize = (w + h + 1) / 2; - let ix = |k: isize| unsigned(k + max + 1); - // Only the seeds need setting: every other slot is written before it is read. - let mut forward: Vec = std::mem::take(&mut self.forward); - let mut backward: Vec = std::mem::take(&mut self.backward); - if forward.len() <= ix(max + 1) { - forward.resize(ix(max + 1) + 1, 0); - backward.resize(ix(max + 1) + 1, 0); - } - (forward[ix(1)], backward[ix(1)]) = (0, h); - let snake: Option = self.search(&mut forward, &mut backward, (left, top), (w, h)); - (self.forward, self.backward) = (forward, backward); - snake - } - - /// The two-ended search of `middle_snake` in a `w` x `h` box at `origin`. - fn search( - &mut self, - forward: &mut [isize], - backward: &mut [isize], - (left, top): (usize, usize), - (w, h): (isize, isize), - ) -> Option { - let delta: isize = w - h; - let odd: bool = delta.rem_euclid(2) == 1; - let max: isize = (w + h + 1) / 2; - let ix = |k: isize| unsigned(k + max + 1); - let same = |me: &Self, x: isize, y: isize| me.a[left + unsigned(x)] == me.b[top + unsigned(y)]; - let at = |x: isize, y: isize| (left + unsigned(x), top + unsigned(y)); - for d in 0..=max { - self.steps += unsigned(2 * d + 1); - if self.steps > MAX_STEPS { - return None; - } - for k in (-d..=d).rev().step_by(2) { - let (px, mut x): (isize, isize) = if k == -d || (k != d && forward[ix(k - 1)] < forward[ix(k + 1)]) { - (forward[ix(k + 1)], forward[ix(k + 1)]) - } else { - (forward[ix(k - 1)], forward[ix(k - 1)] + 1) - }; - let mut y: isize = x - k; - let py: isize = if d == 0 || x != px { y } else { y - 1 }; - while x < w && y < h && same(self, x, y) { - (x, y) = (x + 1, y + 1); - } - forward[ix(k)] = x; - let c: isize = k - delta; - if odd && (-(d - 1)..=d - 1).contains(&c) && y >= backward[ix(c)] { - return Some((at(px, py), at(x, y))); - } - } - for c in (-d..=d).rev().step_by(2) { - let (py, mut y): (isize, isize) = if c == -d || (c != d && backward[ix(c - 1)] > backward[ix(c + 1)]) { - (backward[ix(c + 1)], backward[ix(c + 1)]) - } else { - (backward[ix(c - 1)], backward[ix(c - 1)] - 1) - }; - let k: isize = c + delta; - let mut x: isize = y + k; - let px: isize = if d == 0 || y != py { x } else { x + 1 }; - while x > 0 && y > 0 && same(self, x - 1, y - 1) { - (x, y) = (x - 1, y - 1); - } - backward[ix(c)] = y; - if !odd && (-d..=d).contains(&k) && x <= forward[ix(k)] { - return Some((at(x, y), at(px, py))); - } - } - } - None - } -} - #[cfg(test)] mod tests { use super::*; @@ -336,47 +115,7 @@ mod tests { assert_eq!(state.counts(), Counts::default(), "recounted with each version"); } - /// Myers' ops rebuild `new` from `old` and keep as many lines as a plain LCS table finds. - #[test] - fn myers_is_a_shortest_edit() { - let mut seed: u32 = 7; - let mut next = |n: u32| { - seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); - (seed >> 16) % n - }; - for _ in 0..300 { - let mut sides: [Vec; 2] = [Vec::new(), Vec::new()]; - for side in &mut sides { - for _ in 0..next(12) { - side.push(next(4).to_string()); - } - } - let [old, new]: [Vec; 2] = sides; - let ops: Vec = Myers::ops(&old, &new); - let (mut i, mut j, mut kept): (usize, usize, usize) = (0, 0, 0); - for op in &ops { - match op { - Op::Keep => { - assert_eq!(old[i], new[j], "{old:?} -> {new:?}"); - (i, j, kept) = (i + 1, j + 1, kept + 1); - } - Op::Delete => i += 1, - Op::Insert => j += 1, - } - } - assert_eq!((i, j), (old.len(), new.len())); - let mut table: Vec> = vec![vec![0; new.len() + 1]; old.len() + 1]; - for i in (0..old.len()).rev() { - for j in (0..new.len()).rev() { - table[i][j] = - if old[i] == new[j] { table[i + 1][j + 1] + 1 } else { table[i + 1][j].max(table[i][j + 1]) }; - } - } - assert_eq!(kept, table[0][0], "{old:?} -> {new:?}"); - } - } - - /// A rewrite touching every 10th line of 20k gets exact signs, fast; a reversal stays within the step budget. + /// A rewrite touching every 10th line of 20k gets exact signs; unrelated or reversed text is all changed. #[test] fn big_scattered_rewrite() { let old: Vec = (0..20_000).map(|n| format!("line {n}")).collect(); @@ -385,8 +124,8 @@ mod tests { let signs: Vec> = diff(&old, &new); assert!(signs.iter().enumerate().all(|(n, s)| *s == (n % 10 == 0).then_some(Sign::Change))); let unrelated: Vec = (0..20_000).map(|n| format!("other {n}")).collect(); - assert!(diff(&old, &unrelated).iter().all(|s| *s == Some(Sign::Change)), "no shared line: nothing to search"); + assert!(diff(&old, &unrelated).iter().all(|s| *s == Some(Sign::Change)), "no shared line"); let reversed: Vec = old.iter().rev().cloned().collect(); - assert_eq!(diff(&old, &reversed).len(), 20_000, "over the step budget: the rest is changed wholesale"); + assert_eq!(diff(&old, &reversed).len(), 20_000); } } diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs index d04dc27..87603cb 100644 --- a/src/buffer/mod.rs +++ b/src/buffer/mod.rs @@ -10,12 +10,14 @@ mod width; use std::collections::HashMap; use std::ffi::OsStr; -use std::fs::{self, File}; +use std::fs::{self, File, OpenOptions}; use std::io::{self, Write}; use std::ops::Range; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; +use syntect::parsing::SyntaxReference; + use crate::error::Error; use crate::syntax::{self, Hl, Lang}; use history::{Change, History}; @@ -43,6 +45,8 @@ pub struct Buffer { pub top: usize, pub left: usize, pub lang: Option<&'static Lang>, + /// The syntect grammar that highlights it (by file name and extension; also for files with no `lang`). + pub grammar: Option<&'static SyntaxReference>, /// `m{a-z}` marks, plus `'` (position before the last jump) and `^` (last insert). pub marks: HashMap, /// Name shown for buffers without a file (`git show` output…). @@ -77,6 +81,7 @@ impl Buffer { body.split('\n').map(|l| if crlf { l.strip_suffix('\r').unwrap_or(l) } else { l }.to_owned()).collect(); Self { lang: path.as_deref().and_then(syntax::lang_for), + grammar: path.as_deref().and_then(syntax::grammar_for), marks: HashMap::new(), title: None, hex: false, @@ -124,7 +129,7 @@ impl Buffer { let mut buf: Self = Self::from_text(Some(path.into()), ""); buf.lines = hex::dump(e.as_bytes(), 0); buf.hex = true; - buf.lang = None; + (buf.lang, buf.grammar) = (None, None); Ok(buf) } } @@ -149,6 +154,7 @@ impl Buffer { self.history.seal(); self.hex = !self.hex; self.lang = if self.hex { None } else { self.path.as_deref().and_then(syntax::lang_for) }; + self.grammar = if self.hex { None } else { self.path.as_deref().and_then(syntax::grammar_for) }; self.syntax.invalidate(0); self.cursor = Pos::default(); Ok(()) @@ -180,7 +186,7 @@ impl Buffer { write_atomic(&path, &out).map_err(|e| Error::Io(path.clone(), e))?; let summary: String = format!("\"{}\" {}L, {}B written", path.display(), self.lines.len(), out.len()); if self.path.is_none() { - self.lang = syntax::lang_for(&path); + (self.lang, self.grammar) = (syntax::lang_for(&path), syntax::grammar_for(&path)); self.syntax.invalidate(0); self.path = Some(path.clone()); } @@ -204,9 +210,10 @@ impl Buffer { out } - /// Emergency copy after an internal error; leaves the buffer's own state alone. + /// Emergency copy after an internal error; leaves the buffer's own state alone. The copy is new (a link or file + /// already at `path` is not followed or reused) and private, as it may sit in a shared temp folder. pub fn write_copy(&self, path: &Path) -> io::Result<()> { - fs::write(path, self.contents()) + new_file(path, true)?.write_all(self.contents().as_bytes()) } /// Restores the invariants (non-empty, cursor and scroll in range) after an internal error. @@ -240,7 +247,7 @@ impl Buffer { /// `:saveas`: this buffer now edits `path`. pub fn set_path(&mut self, path: PathBuf) { - self.lang = syntax::lang_for(&path); + (self.lang, self.grammar) = (syntax::lang_for(&path), syntax::grammar_for(&path)); self.syntax.invalidate(0); self.path = Some(path); } @@ -251,7 +258,8 @@ impl Buffer { if self.is_dirty() { return; } - if fs::metadata(&path).is_ok_and(|m| m.len() > MAX_TEXT) { + // As in `open`: devices and FIFOs (the file may have been swapped for one) would block or never end. + if !fs::metadata(&path).is_ok_and(|m| m.is_file() && m.len() <= MAX_TEXT) { return; } let Ok(bytes): Result, io::Error> = fs::read(&path) else { return }; @@ -531,10 +539,10 @@ impl Buffer { } } - /// Highlight groups for line `y`, one per char; None for files without a known language. + /// Highlight groups for line `y`, one per char; None for files without a grammar. /// Groups for the first `limit` chars of line `y`. pub fn highlight_line(&mut self, y: usize, limit: usize) -> Option> { - Some(self.syntax.line(&self.lines, self.lang?, y, limit)) + Some(self.syntax.line(&self.lines, self.grammar?, y, limit)) } fn byte(&self, p: Pos) -> usize { @@ -608,13 +616,24 @@ pub fn canonical(path: &Path) -> PathBuf { resolved } +/// Creates `path`, failing if anything (a planted symlink included) is already there; `private` makes it 0600. +fn new_file(path: &Path, private: bool) -> io::Result { + let mut options: OpenOptions = OpenOptions::new(); + options.write(true).create_new(true); + if private { + crate::term::owner_only(&mut options); + } + options.open(path) +} + /// Writes through a temp file + rename, so a crash or full disk never leaves a truncated file. /// Follows symlinks and keeps permissions; falls back to an in-place write when the directory is read-only. fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> { let target: PathBuf = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); let Some(name): Option<&OsStr> = target.file_name() else { return fs::write(&target, data) }; let tmp: PathBuf = target.with_file_name(format!(".{}.lim-{}.tmp", name.to_string_lossy(), std::process::id())); - let staged: Result<(), io::Error> = File::create(&tmp).and_then(|mut f| { + // Private until the target's permissions are copied, so a 0600 file is never readable while staged. + let staged: Result<(), io::Error> = new_file(&tmp, target.exists()).and_then(|mut f| { f.write_all(data)?; f.sync_all()?; if let Ok(meta) = fs::metadata(&target) { @@ -656,6 +675,41 @@ mod tests { b.lines().join("\n") } + /// A file swapped for a device is not re-read (it would never end). Emergency copies and save temp files are + /// new files, private, and a link planted at their name is not followed. + #[cfg(unix)] + #[test] + fn special_files_and_planted_links() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let dir: PathBuf = std::env::temp_dir().join(format!("lim-special-{}", std::process::id())); + let _: io::Result<()> = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let path: PathBuf = dir.join("notes.txt"); + fs::write(&path, "text\n").unwrap(); + let mut b: Buffer = Buffer::open(&path).unwrap(); + fs::remove_file(&path).unwrap(); + symlink("/dev/zero", &path).unwrap(); + b.reload_if_changed(); + assert_eq!(joined(&b), "text"); + + let copy: PathBuf = dir.join("copy"); + b.write_copy(©).unwrap(); + assert_eq!(fs::metadata(©).unwrap().permissions().mode() & 0o777, 0o600); + let victim: PathBuf = dir.join("victim"); + fs::write(&victim, "keep").unwrap(); + let link: PathBuf = dir.join("link"); + symlink(&victim, &link).unwrap(); + assert!(b.write_copy(&link).is_err()); + + let target: PathBuf = dir.join("t.txt"); + fs::write(&target, "old").unwrap(); + symlink(&victim, dir.join(format!(".t.txt.lim-{}.tmp", std::process::id()))).unwrap(); + write_atomic(&target, b"new").unwrap(); + assert_eq!(fs::read_to_string(&target).unwrap(), "new"); + assert_eq!(fs::read_to_string(&victim).unwrap(), "keep"); + let _: io::Result<()> = fs::remove_dir_all(&dir); + } + #[test] fn edit_undo_redo() { let mut b: Buffer = Buffer::from_text(None, "héllo\nworld\n"); diff --git a/src/config/json.rs b/src/config/json.rs index a00adf1..88c3cd5 100644 --- a/src/config/json.rs +++ b/src/config/json.rs @@ -1,8 +1,10 @@ -//! A small JSON reader (no crates). Accepts `//` and `/* */` comments and trailing commas, -//! since config files are written by hand. +//! JSON read through jsonc-parser: `//` and `/* */` comments and trailing commas are accepted, since config files +//! are written by hand, and nothing looser (quotes, numbers and commas stay strict JSON). use std::fmt::{self, Write}; +use jsonc_parser::{JsonValue, ParseOptions, parse_to_value}; + #[derive(Clone, Debug, PartialEq)] pub enum Json { Null, @@ -27,17 +29,37 @@ impl fmt::Display for ParseError { } } -/// Nesting deeper than this is rejected instead of risking the stack. -const MAX_DEPTH: usize = 64; - +/// The value in `text`; the parser bounds nesting itself, so deep input is an error, not a stack overflow. pub fn parse(text: &str) -> Result { - let mut p: Parser = Parser { chars: text.chars().collect(), pos: 0 }; - let value: Json = p.value(0)?; - p.skip_blank()?; - if p.pos < p.chars.len() { - return Err(p.error("unexpected text after the value")); + let options: ParseOptions = ParseOptions { + allow_comments: true, + allow_trailing_commas: true, + allow_loose_object_property_names: false, + allow_missing_commas: false, + allow_single_quoted_strings: false, + allow_hexadecimal_numbers: false, + allow_unary_plus_numbers: false, + ..ParseOptions::default() + }; + match parse_to_value(text, &options) { + Ok(Some(value)) => Ok(convert(value)), + Ok(None) => Err(ParseError { line: 1, col: 1, message: "unexpected end of file".into() }), + Err(e) => Err(ParseError { line: e.line_display(), col: e.column_display(), message: e.kind().to_string() }), + } +} + +fn convert(value: JsonValue<'_>) -> Json { + match value { + JsonValue::Null => Json::Null, + JsonValue::Boolean(b) => Json::Bool(b), + // The parser has checked the syntax; f64 reads every JSON number (huge ones become infinite). + JsonValue::Number(n) => Json::Number(n.parse().unwrap_or(f64::NAN)), + JsonValue::String(s) => Json::String(s.into_owned()), + JsonValue::Array(items) => Json::Array(items.into_iter().map(convert).collect()), + JsonValue::Object(fields) => { + Json::Object(fields.into_iter().map(|(k, v)| (k.into_owned(), convert(v))).collect()) + } } - Ok(value) } impl Json { @@ -111,187 +133,6 @@ fn write_string(out: &mut String, s: &str) { out.push('"'); } -struct Parser { - chars: Vec, - pos: usize, -} - -impl Parser { - fn error(&self, message: &str) -> ParseError { - let before: &[char] = &self.chars[..self.pos.min(self.chars.len())]; - let line: usize = 1 + before.iter().filter(|&&c| c == '\n').count(); - let col: usize = 1 + before.iter().rev().take_while(|&&c| c != '\n').count(); - ParseError { line, col, message: message.to_owned() } - } - - fn peek(&self) -> Option { - self.chars.get(self.pos).copied() - } - - fn skip_blank(&mut self) -> Result<(), ParseError> { - loop { - match (self.peek(), self.chars.get(self.pos + 1).copied()) { - (Some(c), _) if c.is_whitespace() => self.pos += 1, - (Some('/'), Some('/')) => { - while self.peek().is_some_and(|c| c != '\n') { - self.pos += 1; - } - } - (Some('/'), Some('*')) => { - self.pos += 2; - loop { - match (self.peek(), self.chars.get(self.pos + 1).copied()) { - (Some('*'), Some('/')) => { - self.pos += 2; - break; - } - (Some(_), _) => self.pos += 1, - (None, _) => return Err(self.error("unterminated comment")), - } - } - } - _ => return Ok(()), - } - } - } - - fn expect(&mut self, c: char) -> Result<(), ParseError> { - self.skip_blank()?; - if self.peek() == Some(c) { - self.pos += 1; - Ok(()) - } else { - Err(self.error(&format!("expected '{c}'"))) - } - } - - fn value(&mut self, depth: usize) -> Result { - if depth > MAX_DEPTH { - return Err(self.error("nested too deeply")); - } - self.skip_blank()?; - match self.peek() { - Some('{') => self.object(depth), - Some('[') => self.array(depth), - Some('"') => self.string().map(Json::String), - Some('t') => self.word("true", Json::Bool(true)), - Some('f') => self.word("false", Json::Bool(false)), - Some('n') => self.word("null", Json::Null), - Some(c) if c == '-' || c.is_ascii_digit() => self.number(), - Some(_) => Err(self.error("expected a value")), - None => Err(self.error("unexpected end of file")), - } - } - - fn word(&mut self, word: &str, value: Json) -> Result { - let matches: bool = word.chars().enumerate().all(|(i, c)| self.chars.get(self.pos + i) == Some(&c)); - if !matches { - return Err(self.error("expected a value")); - } - self.pos += word.len(); - Ok(value) - } - - fn number(&mut self) -> Result { - let start: usize = self.pos; - while self.peek().is_some_and(|c| c.is_ascii_digit() || "+-.eE".contains(c)) { - self.pos += 1; - } - let text: String = self.chars[start..self.pos].iter().collect(); - text.parse().map(Json::Number).map_err(|_| self.error("invalid number")) - } - - fn string(&mut self) -> Result { - self.expect('"')?; - let mut out: String = String::new(); - loop { - let Some(c): Option = self.peek() else { return Err(self.error("unterminated string")) }; - self.pos += 1; - match c { - '"' => return Ok(out), - '\\' => { - let Some(esc): Option = self.peek() else { return Err(self.error("unterminated string")) }; - self.pos += 1; - out.push(match esc { - '"' => '"', - '\\' => '\\', - '/' => '/', - 'b' => '\u{8}', - 'f' => '\u{c}', - 'n' => '\n', - 'r' => '\r', - 't' => '\t', - 'u' => self.unicode_escape()?, - _ => return Err(self.error("invalid escape")), - }); - } - '\n' => return Err(self.error("newline inside a string")), - c => out.push(c), - } - } - } - - fn unicode_escape(&mut self) -> Result { - let hex = |p: &mut Self| -> Result { - let digits: String = p.chars.get(p.pos..p.pos + 4).map(|d| d.iter().collect()).unwrap_or_default(); - p.pos += 4; - u32::from_str_radix(&digits, 16).map_err(|_| p.error("invalid \\u escape")) - }; - let high: u32 = hex(self)?; - let code: u32 = if (0xD800..0xDC00).contains(&high) { - if self.peek() != Some('\\') || self.chars.get(self.pos + 1) != Some(&'u') { - return Err(self.error("unpaired surrogate")); - } - self.pos += 2; - let low: u32 = hex(self)?; - 0x10000 + ((high - 0xD800) << 10) + (low.wrapping_sub(0xDC00) & 0x3FF) - } else { - high - }; - char::from_u32(code).ok_or_else(|| self.error("invalid code point")) - } - - fn array(&mut self, depth: usize) -> Result { - self.expect('[')?; - let mut items: Vec = Vec::new(); - loop { - self.skip_blank()?; - if self.peek() == Some(']') { - self.pos += 1; - return Ok(Json::Array(items)); - } - items.push(self.value(depth + 1)?); - self.skip_blank()?; - match self.peek() { - Some(',') => self.pos += 1, - Some(']') => {} - _ => return Err(self.error("expected ',' or ']'")), - } - } - } - - fn object(&mut self, depth: usize) -> Result { - self.expect('{')?; - let mut fields: Vec<(String, Json)> = Vec::new(); - loop { - self.skip_blank()?; - if self.peek() == Some('}') { - self.pos += 1; - return Ok(Json::Object(fields)); - } - let key: String = self.string()?; - self.expect(':')?; - fields.push((key, self.value(depth + 1)?)); - self.skip_blank()?; - match self.peek() { - Some(',') => self.pos += 1, - Some('}') => {} - _ => return Err(self.error("expected ',' or '}'")), - } - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/editor/clipboard.rs b/src/editor/clipboard.rs index 69a7ca7..4a67753 100644 --- a/src/editor/clipboard.rs +++ b/src/editor/clipboard.rs @@ -1,155 +1,33 @@ -//! The system clipboard (`clipboard=unnamedplus`, as `AstroNvim` sets it): wl-copy/wl-paste, xclip, xsel or -//! pbcopy/pbpaste, whichever exists, and the Win32 clipboard on Windows; otherwise copying falls back to OSC 52. +//! The system clipboard (`clipboard=unnamedplus`, as `AstroNvim` sets it) through arboard: Wayland, X11, macOS and +//! Windows; otherwise copying falls back to OSC 52. -/// True when the system clipboard took the text. -pub fn copy(text: &str) -> bool { - !cfg!(test) && system::copy(text) -} - -pub fn paste() -> Option { - if cfg!(test) { None } else { system::paste() } -} +use std::sync::{Mutex, MutexGuard, OnceLock}; -#[cfg(unix)] -mod system { - use std::env; - use std::io::{self, Write}; - use std::process::{Child, Command, Output, Stdio}; - - fn tools() -> (&'static [&'static [&'static str]], &'static [&'static [&'static str]]) { - if cfg!(target_os = "macos") { - return (&[&["pbcopy"]], &[&["pbpaste"]]); - } - if env::var_os("WAYLAND_DISPLAY").is_some() { - return (&[&["wl-copy"]], &[&["wl-paste", "--no-newline"]]); - } - ( - &[&["xclip", "-selection", "clipboard"], &["xsel", "--clipboard", "--input"]], - &[&["xclip", "-selection", "clipboard", "-o"], &["xsel", "--clipboard", "--output"]], - ) - } +use arboard::Clipboard; +use data_encoding::BASE64; - pub fn copy(text: &str) -> bool { - tools().0.iter().any(|argv| { - let Some((program, args)): Option<(&&str, &[&str])> = argv.split_first() else { return false }; - let child: io::Result = Command::new(program) - .args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - let Ok(mut child): io::Result = child else { return false }; - let written: bool = child.stdin.take().is_some_and(|mut stdin| stdin.write_all(text.as_bytes()).is_ok()); - // These tools fork to keep serving the selection, so waiting returns promptly. - child.wait().is_ok_and(|s| s.success()) && written - }) - } - - pub fn paste() -> Option { - tools().1.iter().find_map(|argv| { - let (program, args): (&&str, &[&str]) = argv.split_first()?; - let out: Output = - Command::new(program).args(args).stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?; - out.status.success().then(|| String::from_utf8(out.stdout).ok()).flatten() - }) - } +/// One clipboard handle for the session: on X11 it serves what was copied, which a dropped handle would stop doing. +fn with(f: impl FnOnce(&mut Clipboard) -> Option) -> Option { + static CLIPBOARD: OnceLock>> = OnceLock::new(); + let mut slot: MutexGuard<'_, Option> = + CLIPBOARD.get_or_init(|| Mutex::new(Clipboard::new().ok())).lock().ok()?; + f(slot.as_mut()?) } -/// `CF_UNICODETEXT` through user32/kernel32 FFI; Windows text uses CRLF line ends. -#[cfg(windows)] -mod system { - use std::ffi::c_void; - use std::ptr; - - type Handle = *mut c_void; - - const CF_UNICODETEXT: u32 = 13; - const GMEM_MOVEABLE: u32 = 0x2; - - #[link(name = "user32")] - unsafe extern "system" { - fn OpenClipboard(owner: Handle) -> i32; - fn CloseClipboard() -> i32; - fn EmptyClipboard() -> i32; - fn GetClipboardData(format: u32) -> Handle; - fn SetClipboardData(format: u32, memory: Handle) -> Handle; - } - - unsafe extern "system" { - fn GlobalAlloc(flags: u32, bytes: usize) -> Handle; - fn GlobalFree(memory: Handle) -> Handle; - fn GlobalLock(memory: Handle) -> *mut c_void; - fn GlobalUnlock(memory: Handle) -> i32; - } - - /// Runs `f` with the clipboard open, closing it afterwards. - fn with_clipboard(f: impl FnOnce() -> Option) -> Option { - if unsafe { OpenClipboard(ptr::null_mut()) } == 0 { - return None; - } - let result: Option = f(); - unsafe { CloseClipboard() }; - result - } - - pub fn copy(text: &str) -> bool { - let wide: Vec = text.replace('\n', "\r\n").encode_utf16().chain([0]).collect(); - with_clipboard(|| unsafe { - if EmptyClipboard() == 0 { - return None; - } - let memory: Handle = GlobalAlloc(GMEM_MOVEABLE, wide.len() * size_of::()); - let target: *mut u16 = GlobalLock(memory).cast::(); - if target.is_null() { - GlobalFree(memory); - return None; - } - ptr::copy_nonoverlapping(wide.as_ptr(), target, wide.len()); - GlobalUnlock(memory); - // On success the clipboard owns the memory; on failure it is still ours to free. - if SetClipboardData(CF_UNICODETEXT, memory).is_null() { - GlobalFree(memory); - return None; - } - Some(()) - }) - .is_some() - } +/// True when the system clipboard took the text. +pub fn copy(text: &str) -> bool { + // Windows text uses CRLF line ends. + let text: String = if cfg!(windows) { text.replace('\n', "\r\n") } else { text.to_owned() }; + !cfg!(test) && with(|c| c.set_text(text).ok()).is_some() +} - pub fn paste() -> Option { - with_clipboard(|| unsafe { - let memory: Handle = GetClipboardData(CF_UNICODETEXT); - let source: *const u16 = GlobalLock(memory).cast::().cast_const(); - if source.is_null() { - return None; - } - // CF_UNICODETEXT is NUL-terminated. - let mut len: usize = 0; - while *source.add(len) != 0 { - len += 1; - } - let text: String = String::from_utf16_lossy(std::slice::from_raw_parts(source, len)); - GlobalUnlock(memory); - Some(text.replace("\r\n", "\n")) - }) - } +pub fn paste() -> Option { + if cfg!(test) { None } else { with(|c| c.get_text().ok()).map(|t| t.replace("\r\n", "\n")) } } /// OSC 52 sequence that asks the terminal itself to set the clipboard (works over SSH). pub fn osc52(text: &str) -> String { - format!("\x1b]52;c;{}\x07", base64(text.as_bytes())) -} - -fn base64(bytes: &[u8]) -> String { - const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out: String = String::with_capacity(bytes.len().div_ceil(3) * 4); - for chunk in bytes.chunks(3) { - let n: u32 = chunk.iter().enumerate().fold(0u32, |n, (i, &b)| n | u32::from(b) << (16 - 8 * i)); - for i in 0..4 { - out.push(if i <= chunk.len() { char::from(TABLE[(n >> (18 - 6 * i) & 63) as usize]) } else { '=' }); - } - } - out + format!("\x1b]52;c;{}\x07", BASE64.encode(text.as_bytes())) } #[cfg(test)] @@ -157,11 +35,7 @@ mod tests { use super::*; #[test] - fn base64_matches_rfc4648() { - assert_eq!(base64(b""), ""); - assert_eq!(base64(b"f"), "Zg=="); - assert_eq!(base64(b"fo"), "Zm8="); - assert_eq!(base64(b"foobar"), "Zm9vYmFy"); + fn osc52_is_base64() { assert_eq!(osc52("hi"), "\x1b]52;c;aGk=\x07"); } } diff --git a/src/editor/explorer.rs b/src/editor/explorer.rs index b9f573e..73c9bde 100644 --- a/src/editor/explorer.rs +++ b/src/editor/explorer.rs @@ -1,15 +1,14 @@ //! neo-tree: a file tree on the left (`e` toggles, `o` focuses). -use std::borrow::Cow; +use super::prompt::PromptAction; +use super::{Editor, Mode}; +use crate::buffer::canonical; +use crate::repo; +use crate::term::Key; use std::collections::{HashMap, HashSet}; use std::fs; use std::io; use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; - -use super::prompt::PromptAction; -use super::{Editor, Mode}; -use crate::term::Key; /// neo-tree's default window width. pub const WIDTH: usize = 30; @@ -114,27 +113,19 @@ impl Explorer { } } -/// `git status --porcelain` under `root`, by path relative to it. +/// `git status` under `root`, by path relative to it. fn git_status(root: &Path) -> HashMap { - let output: io::Result = Command::new("git") - .arg("-C") - .arg(root) - .args(["status", "--porcelain", "-z", "--untracked-files=all", "--relative"]) - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output(); - let Ok(output): io::Result = output else { return HashMap::new() }; - let text: Cow<'_, str> = String::from_utf8_lossy(&output.stdout); - text.split('\0') - .filter_map(|record| { - let (code, path): (&str, &str) = (record.get(..2)?, record.get(3..)?); + let base: PathBuf = canonical(root); + repo::status(root) + .into_iter() + .filter_map(|(path, code)| { let status: GitStatus = match code { - "??" => GitStatus::Untracked, - c if c.contains('D') => GitStatus::Deleted, - c if c.starts_with('A') => GitStatus::Added, + ['?', '?'] => GitStatus::Untracked, + c if c.contains(&'D') => GitStatus::Deleted, + ['A', _] => GitStatus::Added, _ => GitStatus::Modified, }; - Some((PathBuf::from(path), status)) + Some((path.strip_prefix(&base).ok()?.to_path_buf(), status)) }) .collect() } diff --git a/src/editor/extras.rs b/src/editor/extras.rs index d8baf8b..304bb56 100644 --- a/src/editor/extras.rs +++ b/src/editor/extras.rs @@ -4,7 +4,6 @@ use std::env; use std::ops::Range; use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; use std::time::SystemTime; use super::bindings::SortBy; @@ -19,25 +18,9 @@ pub fn on_path(program: &str) -> bool { env::var_os("PATH").is_some_and(|paths| env::split_paths(&paths).any(|dir| dir.join(program).is_file())) } -/// xdg-open / open / explorer, detached from the editor. +/// The system's default app for `target`, detached from the editor. pub fn open_external(target: &str) -> bool { - if !may_spawn() { - return false; - } - let opener: &str = if cfg!(windows) { - "explorer" - } else if cfg!(target_os = "macos") { - "open" - } else { - "xdg-open" - }; - Command::new(opener).arg(target).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().is_ok() -} - -fn git(args: &[&str], dir: &Path) -> Option { - let out: Output = - Command::new("git").arg("-C").arg(dir).args(args).stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?; - out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()) + may_spawn() && open::that_detached(target).is_ok() } impl Editor { @@ -251,15 +234,9 @@ impl Editor { /// `go`: the current file and line on the git host. pub(super) fn git_browse(&mut self) { let Some(path): Option = self.buf().path.clone() else { return self.info("Buffer has no file") }; - let dir: PathBuf = - path.parent().filter(|d| !d.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf); - let url: Option = (|| { - let remote: String = git(&["remote", "get-url", "origin"], &dir)?; - let branch: String = git(&["rev-parse", "--abbrev-ref", "HEAD"], &dir)?; - let name: String = path.file_name()?.to_string_lossy().into_owned(); - let file: String = git(&["ls-files", "--full-name", &name], &dir)?; - Some(format!("{}/blob/{branch}/{file}#L{}", web_url(&remote), self.buf().cursor.line + 1)) - })(); + let url: Option = crate::repo::browse(&path).map(|(remote, branch, file)| { + format!("{}/blob/{branch}/{file}#L{}", web_url(&remote), self.buf().cursor.line + 1) + }); match url { Some(url) if open_external(&url) => self.info(format!("Opening {url}")), Some(url) => self.info(url), diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 6e2073f..09cf98a 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -497,7 +497,12 @@ impl Editor { if buf.is_dirty() { let name: String = buf.name().chars().map(|c| if c.is_alphanumeric() || c == '.' { c } else { '_' }).collect(); - let path: PathBuf = std::env::temp_dir().join(format!("lim-recover-{}-{i}-{name}", std::process::id())); + // A random part, so another user of a shared temp folder cannot take the name first. + let mut salt: [u8; 8] = [0; 8]; + let _: Result<(), getrandom::Error> = getrandom::fill(&mut salt); + let salt: u64 = u64::from_le_bytes(salt); + let path: PathBuf = + std::env::temp_dir().join(format!("lim-recover-{}-{i}-{salt:016x}-{name}", std::process::id())); if buf.write_copy(&path).is_ok() { backups.push(path.display().to_string()); } diff --git a/src/editor/picker.rs b/src/editor/picker.rs index 2430b1a..68f4aa9 100644 --- a/src/editor/picker.rs +++ b/src/editor/picker.rs @@ -1,17 +1,15 @@ //! snacks.picker: a fuzzy finder over files, git files, text (grep), buffers, recent files, //! keymaps, commands and lines, with a preview. -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; -use std::process::{Command as Process, Output, Stdio}; - use super::command::Command; use super::{Editor, Mode}; use crate::buffer::{Buffer, Pos, text}; use crate::config::keys; use crate::term::Key; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; /// Listing stops here so a huge tree never stalls the editor. const MAX_FILES: usize = 50_000; @@ -242,30 +240,19 @@ fn list_files(all: bool) -> Vec { } fn git_files() -> Option> { - let out: Output = Process::new("git") - .args(["ls-files", "--cached", "--others", "--exclude-standard", "-z"]) - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output() - .ok()?; - if !out.status.success() { - return None; - } - let text: String = String::from_utf8(out.stdout).ok()?; - Some( - text.split('\0') - .filter(|p| !p.is_empty() && Path::new(p).is_file()) - .take(MAX_FILES) - .map(PathBuf::from) - .collect(), - ) + Some(crate::repo::files(Path::new("."))?.into_iter().filter(|p| p.is_file()).take(MAX_FILES).collect()) +} + +/// A regular file small enough to grep: devices and FIFOs (the walk lists symlinks to them) would block or never end. +fn readable(path: &Path) -> bool { + fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() <= MAX_GREP_BYTES) } /// The text files among `files`: not too big, not binary, valid UTF-8. fn searchable(files: &[PathBuf]) -> Vec<(PathBuf, String)> { files .iter() - .filter(|path| !fs::metadata(path).is_ok_and(|m| m.len() > MAX_GREP_BYTES)) + .filter(|path| readable(path)) .filter_map(|path| { let bytes: Vec = fs::read(path).ok()?; if bytes.iter().take(8000).any(|&b| b == 0) { @@ -560,7 +547,7 @@ impl Editor { fn todo_items(files: &[PathBuf]) -> Vec { let mut items: Vec = Vec::new(); for path in files { - if fs::metadata(path).is_ok_and(|m| m.len() > MAX_GREP_BYTES) { + if !readable(path) { continue; } let Ok(content): io::Result = fs::read_to_string(path) else { continue }; @@ -585,6 +572,14 @@ pub(super) fn file_items(files: Vec) -> Vec { mod tests { use super::*; + /// grep and TODO search only read regular files: a device the walk listed would never end. + #[cfg(unix)] + #[test] + fn devices_are_not_searched() { + assert!(!readable(Path::new("/dev/zero"))); + assert!(readable(Path::new("Cargo.toml"))); + } + /// Only files holding every trigram of the folded query are grepped; short queries search them all. #[test] fn trigram_index_narrows_grep() { diff --git a/src/editor/sources.rs b/src/editor/sources.rs index 44f6f1b..59c3706 100644 --- a/src/editor/sources.rs +++ b/src/editor/sources.rs @@ -2,22 +2,18 @@ //! working directory, notifications, registers, marks, colorschemes, icon sets and sessions. use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; use super::picker::{Item, PickerKind, Preview, Target, file_items}; use super::{Editor, Message, Mode, Picker, session}; -use crate::buffer::{Buffer, Pos}; +use crate::buffer::{Buffer, Pos, canonical}; +use crate::repo; /// Git log entries listed at most. -const MAX_COMMITS: &str = "500"; +const MAX_COMMITS: usize = 500; -fn git(args: &[&str]) -> Option { - let out: Output = Command::new("git").args(args).stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?; - out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()) -} - -fn lines_of(output: &str) -> impl Iterator { - output.lines().filter(|l| !l.trim().is_empty()) +/// The git pickers work on the repository of the working directory. +fn here() -> &'static Path { + Path::new(".") } impl Editor { @@ -26,61 +22,40 @@ impl Editor { pub(super) fn extra_items(&mut self, kind: PickerKind) -> Option> { let items: Vec = match kind { PickerKind::GitGraph => { - let out: String = git(&[ - "log", - "--graph", - "--all", - "--decorate", - "--date-order", - "--date=short", - "--pretty=format:%h %ad %d %s", - "-n", - MAX_COMMITS, - ])?; + let commits: Vec = repo::log(here(), true, None, MAX_COMMITS)?; // Lines with a commit open it; the ones that only draw the graph do nothing. - lines_of(&out) + repo::graph(&commits) + .into_iter() .map(|l| { let hash: &str = l.trim_start_matches(|c: char| "*|/\\_ .-".contains(c)); let hash: &str = hash.split(' ').next().unwrap_or_default(); let is_commit: bool = hash.len() >= 7 && hash.bytes().all(|b| b.is_ascii_hexdigit()); let target: Target = if is_commit { Target::Commit(hash.to_owned()) } else { Target::Nothing }; - Item { label: l.to_owned(), target } + Item { label: l, target } }) .collect() } PickerKind::GitLog { file } => { - let mut args: Vec<&str> = vec!["log", "--date=short", "--pretty=format:%h %ad %s", "-n", MAX_COMMITS]; - let path: Option = self.buf().path.as_ref().map(|p| p.display().to_string()); - if file { - args.push("--"); - args.push(path.as_deref()?); - } - let out: String = git(&args)?; - lines_of(&out) - .map(|l| Item { - label: l.to_owned(), - target: Target::Commit(l.split(' ').next().unwrap_or_default().to_owned()), - }) + let path: Option = if file { Some(self.buf().path.clone()?) } else { None }; + repo::log(here(), false, path.as_deref(), MAX_COMMITS)? + .into_iter() + .map(|c| Item { target: Target::Commit(c.hash()), label: c.line }) .collect() } PickerKind::GitStatus => { - let out: String = git(&["status", "--porcelain", "--untracked-files=all"])?; - lines_of(&out) - .filter_map(|l| { - let path: String = l.get(3..)?.rsplit(" -> ").next()?.to_owned(); - Some(Item { label: l.to_owned(), target: Target::File(PathBuf::from(path), None) }) - }) - .collect() - } - PickerKind::GitStash => { - let out: String = git(&["stash", "list"])?; - lines_of(&out) - .map(|l| Item { - label: l.to_owned(), - target: Target::Stash(l.split(':').next().unwrap_or_default().to_owned()), + let cwd: PathBuf = canonical(here()); + repo::status(here()) + .into_iter() + .map(|(path, [x, y])| { + let path: PathBuf = path.strip_prefix(&cwd).map_or_else(|_| path.clone(), Path::to_path_buf); + Item { label: format!("{x}{y} {}", path.display()), target: Target::File(path, None) } }) .collect() } + PickerKind::GitStash => repo::stashes(here()) + .into_iter() + .map(|(name, label)| Item { label, target: Target::Stash(name) }) + .collect(), PickerKind::ConfigFiles => { let path: PathBuf = crate::config::path()?; let mut items: Vec = file_items(vec![path.clone()]); @@ -162,10 +137,8 @@ impl Editor { let Some(picker): &mut Option = &mut self.picker else { return }; let target: Option = picker.selected_item().map(|i| i.target.clone()); let preview: Option<(String, String)> = match &target { - Some(Target::Commit(hash)) => { - git(&["show", "--stat", "--patch", hash]).map(|o| (format!("commit {hash}"), o)) - } - Some(Target::Stash(name)) => git(&["stash", "show", "-p", name]).map(|o| (name.clone(), o)), + Some(Target::Commit(hash)) => repo::show(here(), hash, true, true).map(|o| (format!("commit {hash}"), o)), + Some(Target::Stash(name)) => repo::show(here(), name, false, false).map(|o| (name.clone(), o)), _ => None, }; picker.preview = @@ -177,8 +150,10 @@ impl Editor { pub(super) fn accept_extra(&mut self, target: Target) { match target { - Target::Commit(hash) => self.show_scratch(&format!("commit {hash}"), git(&["show", &hash])), - Target::Stash(name) => self.show_scratch(&name, git(&["stash", "show", "-p", &name])), + Target::Commit(hash) => { + self.show_scratch(&format!("commit {hash}"), repo::show(here(), &hash, true, false)); + } + Target::Stash(name) => self.show_scratch(&name, repo::show(here(), &name, false, false)), Target::Session { name, dir, delete: false } => self.load_session(&name, dir), Target::Session { name, dir, delete: true } => self.delete_session(&name, dir), Target::Theme(name) => self.set_colorscheme(&name, true), diff --git a/src/editor/textops.rs b/src/editor/textops.rs index 5c32db2..9ca7461 100644 --- a/src/editor/textops.rs +++ b/src/editor/textops.rs @@ -2,7 +2,9 @@ //! (`:base64`, `:base32`, `:hex`, `:url`, `:rot13`), `:sha256`, `:wc`, `:calc`, `:case` and `:json`. Sort, case, trim and reverse are //! already `:sort`, `gU`/`gu`, `:s` and `:g/^/m0`. -use std::fmt::{self, Write}; +use data_encoding::{BASE32, BASE64, Encoding, HEXLOWER, Specification}; +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; +use sha2::{Digest, Sha256}; use super::Editor; use super::re::hex_bytes; @@ -49,16 +51,13 @@ impl Editor { new.dedup(); new } - TextOp::Base64 { decode: false } => vec![base64(text.as_bytes())], - TextOp::Base32 { decode: false } => vec![base32(text.as_bytes())], - TextOp::Hex { decode: false } => vec![text.bytes().fold(String::new(), |mut hex, b| { - let _: fmt::Result = write!(hex, "{b:02x}"); - hex - })], + TextOp::Base64 { decode: false } => vec![BASE64.encode(text.as_bytes())], + TextOp::Base32 { decode: false } => vec![BASE32.encode(text.as_bytes())], + TextOp::Hex { decode: false } => vec![HEXLOWER.encode(text.as_bytes())], TextOp::Base64 { decode: true } | TextOp::Base32 { decode: true } | TextOp::Hex { decode: true } => { let bytes: Option> = match op { - TextOp::Base64 { .. } => unbase64(&text), - TextOp::Base32 { .. } => unbase32(&text), + TextOp::Base64 { .. } => decode(&text, &BASE64), + TextOp::Base32 { .. } => decode(&text, &BASE32), _ => { let digits: String = text.replace("0x", "").replace("\\x", ""); hex_bytes(&digits.replace(|c: char| c == ',' || c.is_whitespace(), "")) @@ -78,7 +77,7 @@ impl Editor { .ok_or_else(|| Error::Vim("E475: Invalid argument: bad %XX or not UTF-8".into()))?, TextOp::Rot13 => lines.iter().map(|l| l.chars().map(rot13).collect()).collect(), TextOp::Sha256 => { - let hash: String = sha256(text.as_bytes()); + let hash: String = HEXLOWER.encode(&Sha256::digest(text.as_bytes())); self.info(hash); return Ok(()); } @@ -114,64 +113,36 @@ impl Editor { } } -const BASE32: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - -/// RFC 4648 base32, `=` padded. -fn base32(data: &[u8]) -> String { - let mut out: String = String::with_capacity(data.len().div_ceil(5) * 8); - for chunk in data.chunks(5) { - let mut block: [u8; 8] = [0; 8]; - block[3..3 + chunk.len()].copy_from_slice(chunk); - let n: u64 = u64::from_be_bytes(block); - // 1 to 5 input bytes give 2, 4, 5, 7 or 8 significant chars. - let used: usize = [0, 2, 4, 5, 7, 8][chunk.len()]; - for i in 0..8 { - let index: u64 = n >> (35 - 5 * i) & 31; - out.push(if i < used { char::from(BASE32[usize::try_from(index).unwrap_or(0)]) } else { '=' }); - } - } - out +/// Decodes leniently, as people paste: blanks and `=` padding are skipped (base64 stops at the first `=`), and a +/// lowercase base32 is accepted; None on any other byte outside the alphabet. +fn decode(text: &str, base: &Encoding) -> Option> { + let mut spec: Specification = base.specification(); + spec.padding = None; + spec.check_trailing_bits = false; + let digits: String = text + .split('=') + .next() + .unwrap_or_default() + .chars() + .filter(|c| !c.is_ascii_whitespace()) + .map(|c| if spec.symbols.len() == 32 { c.to_ascii_uppercase() } else { c }) + .collect(); + spec.encoding().ok()?.decode(digits.as_bytes()).ok() } -/// Blanks and `=` are skipped, lowercase is accepted; None on a char outside the alphabet. -fn unbase32(text: &str) -> Option> { - let mut out: Vec = Vec::new(); - let (mut acc, mut bits): (u32, u32) = (0, 0); - for c in text.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') { - let value: u32 = u32::try_from(BASE32.iter().position(|b| *b == c.to_ascii_uppercase())?).ok()?; - acc = (acc << 5 | value) & 0xffff; - bits += 5; - if bits >= 8 { - bits -= 8; - out.push(u8::try_from(acc >> bits & 0xff).ok()?); - } - } - Some(out) -} +/// Everything but letters, digits and `-_.~` is %-escaped. +const URL_ESCAPED: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~'); fn url_encode(line: &str) -> String { - line.bytes().fold(String::with_capacity(line.len()), |mut out, b| { - if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { - out.push(char::from(b)); - } else { - let _: fmt::Result = write!(out, "%{b:02X}"); - } - out - }) + utf8_percent_encode(line, URL_ESCAPED).to_string() } -/// None on a `%` without two hex digits, or bytes that are not UTF-8. +/// None on a `%` without two hex digits (the crate would keep it as is), or bytes that are not UTF-8. fn url_decode(line: &str) -> Option { - let mut out: Vec = Vec::with_capacity(line.len()); - let mut bytes = line.bytes(); - while let Some(b) = bytes.next() { - out.push(if b == b'%' { - *hex_bytes(&String::from_utf8(vec![bytes.next()?, bytes.next()?]).ok()?)?.first()? - } else { - b - }); - } - String::from_utf8(out).ok() + let escapes_ok: bool = line + .match_indices('%') + .all(|(i, _)| line.as_bytes().get(i + 1..i + 3).is_some_and(|h| h.iter().all(u8::is_ascii_hexdigit))); + escapes_ok.then(|| percent_decode_str(line).decode_utf8().ok().map(String::from)).flatten() } fn rot13(c: char) -> char { @@ -183,170 +154,19 @@ fn rot13(c: char) -> char { } } -const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -fn base64(data: &[u8]) -> String { - let mut out: String = String::with_capacity(data.len().div_ceil(3) * 4); - for chunk in data.chunks(3) { - let b: [u8; 3] = [chunk[0], chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; - let sextets: [u8; 4] = [b[0] >> 2, (b[0] & 3) << 4 | b[1] >> 4, (b[1] & 15) << 2 | b[2] >> 6, b[2] & 63]; - for (i, s) in sextets.into_iter().enumerate() { - out.push(if i <= chunk.len() { char::from(BASE64[usize::from(s)]) } else { '=' }); - } - } - out -} - -/// Blanks are skipped and decoding stops at the first `=`; None on any other byte outside the alphabet. -fn unbase64(text: &str) -> Option> { - let mut out: Vec = Vec::new(); - let (mut acc, mut bits): (u32, u32) = (0, 0); - for c in text.bytes().filter(|c| !c.is_ascii_whitespace()).take_while(|c| *c != b'=') { - let value: u32 = u32::try_from(BASE64.iter().position(|b| *b == c)?).ok()?; - acc = (acc << 6 | value) & 0xff_ffff; - bits += 6; - if bits >= 8 { - bits -= 8; - out.push(u8::try_from(acc >> bits & 0xff).ok()?); - } - } - Some(out) -} - -const K: [u32; 64] = [ - 0x428a_2f98, - 0x7137_4491, - 0xb5c0_fbcf, - 0xe9b5_dba5, - 0x3956_c25b, - 0x59f1_11f1, - 0x923f_82a4, - 0xab1c_5ed5, - 0xd807_aa98, - 0x1283_5b01, - 0x2431_85be, - 0x550c_7dc3, - 0x72be_5d74, - 0x80de_b1fe, - 0x9bdc_06a7, - 0xc19b_f174, - 0xe49b_69c1, - 0xefbe_4786, - 0x0fc1_9dc6, - 0x240c_a1cc, - 0x2de9_2c6f, - 0x4a74_84aa, - 0x5cb0_a9dc, - 0x76f9_88da, - 0x983e_5152, - 0xa831_c66d, - 0xb003_27c8, - 0xbf59_7fc7, - 0xc6e0_0bf3, - 0xd5a7_9147, - 0x06ca_6351, - 0x1429_2967, - 0x27b7_0a85, - 0x2e1b_2138, - 0x4d2c_6dfc, - 0x5338_0d13, - 0x650a_7354, - 0x766a_0abb, - 0x81c2_c92e, - 0x9272_2c85, - 0xa2bf_e8a1, - 0xa81a_664b, - 0xc24b_8b70, - 0xc76c_51a3, - 0xd192_e819, - 0xd699_0624, - 0xf40e_3585, - 0x106a_a070, - 0x19a4_c116, - 0x1e37_6c08, - 0x2748_774c, - 0x34b0_bcb5, - 0x391c_0cb3, - 0x4ed8_aa4a, - 0x5b9c_ca4f, - 0x682e_6ff3, - 0x748f_82ee, - 0x78a5_636f, - 0x84c8_7814, - 0x8cc7_0208, - 0x90be_fffa, - 0xa450_6ceb, - 0xbef9_a3f7, - 0xc671_78f2, -]; - -/// SHA-256 (FIPS 180-4) as lowercase hex. -#[expect(clippy::many_single_char_names, reason = "the working variables keep their names from the standard")] -fn sha256(data: &[u8]) -> String { - let mut h: [u32; 8] = - [0x6a09_e667, 0xbb67_ae85, 0x3c6e_f372, 0xa54f_f53a, 0x510e_527f, 0x9b05_688c, 0x1f83_d9ab, 0x5be0_cd19]; - let bits: u64 = u64::try_from(data.len()).unwrap_or_default().wrapping_mul(8); - let mut message: Vec = data.to_vec(); - message.push(0x80); - while message.len() % 64 != 56 { - message.push(0); - } - message.extend_from_slice(&bits.to_be_bytes()); - for block in message.as_chunks::<64>().0 { - let mut w: [u32; 64] = [0; 64]; - for (slot, word) in w.iter_mut().zip(block.as_chunks::<4>().0) { - *slot = u32::from_be_bytes(*word); - } - for i in 16..64 { - let s0: u32 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); - let s1: u32 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); - w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1); - } - let mut v: [u32; 8] = h; - for (k, wi) in K.iter().zip(w) { - let [a, b, c, d, e, f, g, hh]: [u32; 8] = v; - let t1: u32 = hh - .wrapping_add(e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25)) - .wrapping_add((e & f) ^ (!e & g)) - .wrapping_add(*k) - .wrapping_add(wi); - let t2: u32 = - (a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22)).wrapping_add((a & b) ^ (a & c) ^ (b & c)); - v = [t1.wrapping_add(t2), a, b, c, d.wrapping_add(t1), e, f, g]; - } - for (x, y) in h.iter_mut().zip(v) { - *x = x.wrapping_add(y); - } - } - h.iter().fold(String::with_capacity(64), |mut hex, x| { - let _: fmt::Result = write!(hex, "{x:08x}"); - hex - }) -} - #[cfg(test)] mod tests { use super::*; #[test] fn computes() { - assert_eq!(base64(b"hi\n"), "aGkK"); - assert_eq!(base64(b"ab"), "YWI="); - assert_eq!(unbase64("aGkK\n").unwrap(), b"hi\n"); - assert_eq!(unbase64("YW\nI=").unwrap(), b"ab"); - assert_eq!(unbase64("a*"), None); - assert_eq!(sha256(b"hi\n"), "98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4"); - assert_eq!(sha256(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); - // 56 bytes: the padding spills into a second block. - assert_eq!( - sha256(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), - "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" - ); - assert_eq!(base32(b"foobar"), "MZXW6YTBOI======"); - assert_eq!(base32(b"f"), "MY======"); - assert_eq!(unbase32("MZXW6YTBOI======").unwrap(), b"foobar"); - assert_eq!(unbase32("mzxw6\n").unwrap(), b"foo"); - assert_eq!(unbase32("M1"), None); + assert_eq!(decode("aGkK\n", &BASE64).unwrap(), b"hi\n"); + assert_eq!(decode("YW\nI=", &BASE64).unwrap(), b"ab", "blanks and padding are skipped"); + assert_eq!(decode("YWI", &BASE64).unwrap(), b"ab", "padding may be missing"); + assert_eq!(decode("a*", &BASE64), None); + assert_eq!(decode("MZXW6YTBOI======", &BASE32).unwrap(), b"foobar"); + assert_eq!(decode("mzxw6\n", &BASE32).unwrap(), b"foo", "lowercase base32"); + assert_eq!(decode("M1", &BASE32), None); assert_eq!(url_encode("a b/é~"), "a%20b%2F%C3%A9~"); assert_eq!(url_decode("a%20b%2F%C3%A9~").unwrap(), "a b/é~"); for bad in ["%2", "%zz", "%ff", "%é"] { diff --git a/src/editor/tools/generated.rs b/src/editor/tools/generated.rs index 56feca4..439e62b 100644 --- a/src/editor/tools/generated.rs +++ b/src/editor/tools/generated.rs @@ -4,8 +4,7 @@ use std::collections::hash_map::RandomState; use std::fmt::Write; use std::hash::BuildHasher; -use super::time::{datetime, now}; -use crate::term; +use super::time::{datetime, now, utc_offset}; /// Text the `x` keys insert at the cursor. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -17,10 +16,10 @@ pub enum Generated { Lorem, } -/// Random bytes from the system's source (`/dev/urandom`, `RtlGenRandom`); if that fails, `SipHash` over a counter +/// Random bytes from the system's source (getrandom); if that fails, `SipHash` over a counter /// keyed by std's per-process random keys. fn random_bytes(n: usize) -> Vec { - random_bytes_from(n, term::random) + random_bytes_from(n, |bytes| getrandom::fill(bytes).is_ok()) } /// `random_bytes` with the system source given, so the fallback can be tested. @@ -67,7 +66,7 @@ consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum impl Generated { pub fn text(self) -> String { let now: i64 = now(); - let local: String = datetime(now, term::utc_offset(now)); + let local: String = datetime(now, utc_offset(now)); match self { Self::Date => local[..10].to_owned(), Self::DateTime => local, diff --git a/src/editor/tools/time.rs b/src/editor/tools/time.rs index 2f3aedb..66d0b65 100644 --- a/src/editor/tools/time.rs +++ b/src/editor/tools/time.rs @@ -2,12 +2,18 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use crate::term; +use jiff::Timestamp; +use jiff::tz::TimeZone; pub(super) fn now() -> i64 { SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0)) } +/// Seconds east of UTC in the system's time zone at unix time `ts` (0 when it is out of range). +pub(super) fn utc_offset(ts: i64) -> i64 { + Timestamp::from_second(ts).map_or(0, |t| i64::from(TimeZone::system().to_offset(t).seconds())) +} + /// Days since 1970-01-01 of a civil date (proleptic Gregorian), after Howard Hinnant's `days_from_civil`. fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { let y: i64 = if month <= 2 { year - 1 } else { year }; @@ -48,7 +54,7 @@ fn zone(offset: i64) -> String { /// A unix time described: local and UTC dates, weekday. fn describe(ts: i64) -> String { - let offset: i64 = term::utc_offset(ts); + let offset: i64 = utc_offset(ts); let weekday: &str = WEEKDAYS[usize::try_from(ts.div_euclid(86_400).rem_euclid(7)).unwrap_or(0)]; format!("{ts} · {} {} · {} UTC · {weekday}", datetime(ts, offset), zone(offset), datetime(ts, 0)) } @@ -84,7 +90,7 @@ fn parse_date(words: &[&str]) -> Option<(i64, usize)> { let secs: i64 = clock.iter().chain([&0, &0, &0]).take(3).fold(0, |acc, n| acc * 60 + n); let local: i64 = days_from_civil(y, m, d) * 86_400 + secs; // The offset in effect at that local time: taken at the UTC guess, which is right outside the hour of a switch. - Some((if utc { local } else { local - term::utc_offset(local) }, used)) + Some((if utc { local } else { local - utc_offset(local) }, used)) } /// `now`, a unix time (seconds, or milliseconds when it is that large), a date, or two dates (their difference). diff --git a/src/fuzz.rs b/src/fuzz.rs index 0664d64..a38a681 100644 --- a/src/fuzz.rs +++ b/src/fuzz.rs @@ -15,7 +15,7 @@ use crate::config::{Config, json, keys}; use crate::editor::Editor; use crate::editor::tools::{self, Tool, WordCase}; use crate::render::{Screen, markdown}; -use crate::syntax::{self, Lang, State}; +use crate::syntax; use crate::term::{ColorDepth, Key, Mouse, MouseKind, Size}; /// A tiny deterministic generator (PCG-style), so a failing seed replays exactly. @@ -504,32 +504,38 @@ fn markdown_and_highlighting_survive_any_document() { }); } -/// The region-only scan that far jumps use agrees with the full highlighter, line after line. +/// Delimiter soup through every kind of grammar: one group per char, through edits that drop cached states. #[test] -fn highlighter_end_states_agree() { +fn highlighter_survives_any_lines() { let pieces: &[&str] = &[ "/*", "*/", "//", "--", "--[[", "[[", "]]", "\"\"\"", "'''", "=begin", "=end", "=pod", "=cut", "<#", "#>", - "{-", "-}", "", "```", "#", ";", "::", "x", "1.5", " ", + "{-", "-}", "", "```", "#", ";", "::", "x", "1.5", " ", "\"", "'", "\\", "${", "}", "", ]; let names: [&str; 16] = [ - "x.rs", "x.lua", "x.py", "x.rb", "x.pl", "x.ps1", "x.md", "x.hs", "x.js", "x.c", "x.html", "x.go", "x.sql", - "x.sh", "x.lisp", "x.bat", + "x.rs", "x.lua", "x.py", "x.rb", "x.pl", "x.yaml", "x.md", "x.hs", "x.js", "x.c", "x.html", "x.go", "x.sql", + "x.sh", "x.json", "x.bat", ]; - fuzz("end-state", 600, |rng, log| { + fuzz("highlight", 300, |rng, log| { let name: &str = rng.pick::<&str>(&names); - let Some(lang): Option<&Lang> = syntax::lang_for(Path::new(name)) else { return Ok(()) }; - let mut state: State = State::default(); - for _ in 0..6 { - let line: String = (0..rng.below(16)) - .map(|_| if rng.chance(50) { *rng.pick(pieces) } else { *rng.pick(UNICODE) }) - .collect(); - log.push(format!("{line:?}")); - let chars: Vec = line.chars().collect(); - let want: State = syntax::highlight(&chars, lang, state).1; - if syntax::fast_end_state(&line, lang, state) != want { - return Err(format!("{name}: {line:?}")); + let Some(grammar) = syntax::grammar_for(Path::new(name)) else { return Err(format!("no grammar for {name}")) }; + let mut lines: Vec = (0..=rng.below(8)) + .map(|_| { + (0..rng.below(16)) + .map(|_| if rng.chance(50) { *rng.pick(pieces) } else { *rng.pick(UNICODE) }) + .collect() + }) + .collect(); + log.push(format!("{name}: {lines:?}")); + let mut cache: syntax::Cache = syntax::Cache::default(); + for round in 0..3 { + for (y, line) in lines.iter().enumerate() { + if cache.line(&lines, grammar, y, usize::MAX).len() != line.chars().count() { + return Err(format!("{name}: line {y} {line:?}")); + } } - state = want; + let y: usize = rng.below(lines.len()); + lines[y] = rng.pick(pieces).repeat(round + 1); + cache.invalidate(y); } Ok(()) }); diff --git a/src/main.rs b/src/main.rs index 7dd0317..ad33f7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ mod fuzz; #[cfg(test)] mod perf; mod render; +mod repo; mod syntax; mod term; mod update; @@ -58,6 +59,7 @@ fn main() -> ExitCode { println!("{}", config::path().map(|p| p.display().to_string()).unwrap_or_default()); return ExitCode::SUCCESS; } + syntax::preload(); let (config, mut warnings): (config::Config, Vec) = config::load(); let colorscheme: String = render::initial_colorscheme(&config); warnings.extend(render::apply_colorscheme(&colorscheme, &config)); diff --git a/src/render/finder.rs b/src/render/finder.rs index 6c21e73..57fd6b8 100644 --- a/src/render/finder.rs +++ b/src/render/finder.rs @@ -1,16 +1,19 @@ //! snacks.picker's default layout: input and list on the left, preview on the right. -use std::fs; +use std::fs::{self, File}; +use std::io::Read; use std::path::Path; use std::sync::Arc; +use syntect::parsing::SyntaxReference; + use super::Theme; use super::row::{Cell, Grid, Row}; use super::theme::{Group, Rgb, Style, theme}; use crate::buffer::{Buffer, text}; use crate::editor::{Editor, Item, Picker, Rect, Target}; use crate::render::icons::Icons; -use crate::syntax::{self, State}; +use crate::syntax; /// Previews read at most this much of a file. const MAX_PREVIEW_BYTES: usize = 256 * 1024; @@ -142,12 +145,14 @@ fn preview(ed: &Editor, grid: &mut Grid, area: Rect, fill: Style, border: Style) grid.float(area, border, fill, &shown.title); let rows: usize = area.h.saturating_sub(2); let first: usize = shown.focus.saturating_sub(rows / 3); - let lang: Option<&syntax::Lang> = shown.path.and_then(syntax::lang_for); + let grammar: Option<&SyntaxReference> = shown.path.and_then(syntax::grammar_for); + let mut cache: syntax::Cache = syntax::Cache::default(); let number_width: usize = (first + rows).to_string().len(); for (r, line) in shown.lines.iter().enumerate().skip(first).take(rows) { // The item's own line stands out, unless it is the first (a file opened at its top). let focused: bool = r == shown.focus && shown.focus > 0; - let row: Vec = code_row(line, r + 1, number_width, lang, focused, fill, area.w - 2); + let groups: Option> = grammar.map(|g| cache.line(&shown.lines, g, r, area.w)); + let row: Vec = code_row(line, r + 1, number_width, groups.as_deref(), focused, fill, area.w - 2); grid.put(area.x + 1, area.y + 1 + r - first, &row); } } @@ -189,19 +194,26 @@ fn of_buffer(title: String, buf: &Buffer, focus: usize) -> Shown<'_> { /// The start of a text file, at most `MAX_PREVIEW_BYTES`; nothing for a binary one. fn read_preview(path: &Path) -> Vec { - let Some(mut bytes): Option> = fs::read(path).ok().filter(|b| !b.iter().take(8000).any(|&c| c == 0)) else { + // Devices and FIFOs (a symlink in the project can point at one) would block or never end. + if !fs::metadata(path).is_ok_and(|m| m.is_file()) { return Vec::new(); - }; - bytes.truncate(MAX_PREVIEW_BYTES); + } + let mut bytes: Vec = Vec::new(); + let limit: u64 = u64::try_from(MAX_PREVIEW_BYTES).unwrap_or(u64::MAX); + if File::open(path).and_then(|f| f.take(limit).read_to_end(&mut bytes)).is_err() + || bytes.iter().take(8000).any(|&c| c == 0) + { + return Vec::new(); + } String::from_utf8_lossy(&bytes).lines().map(str::to_owned).collect() } -/// A line of code with its number, highlighted for `lang`, tabs expanded. +/// A line of code with its number, colored by `groups` (one per char), tabs expanded. fn code_row( line: &str, number: usize, number_width: usize, - lang: Option<&syntax::Lang>, + groups: Option<&[syntax::Hl]>, focused: bool, fill: Style, width: usize, @@ -211,11 +223,9 @@ fn code_row( let plain: Style = Style { bg, ..fill }; let mut row: Row = Row::new(width); row.text(t.on(Group::LineNr, bg), &format!("{number:>number_width$} ")); - let chars: Vec = line.chars().collect(); - let groups: Option> = lang.map(|l| syntax::highlight(&chars, l, State::default()).0); let mut col: usize = 0; - for (i, &c) in chars.iter().enumerate() { - let style: Style = groups.as_ref().and_then(|g| g.get(i)).map_or(plain, |&g| t.syntax(g, bg)); + for (i, c) in line.chars().enumerate() { + let style: Style = groups.and_then(|g| g.get(i)).map_or(plain, |&g| t.syntax(g, bg)); if c == '\t' { for _ in 0..text::tab_cells(col) { row.cell(style, ' '); @@ -236,6 +246,17 @@ mod tests { use crate::render::row::Cell; use crate::term::{ColorDepth, Key}; + /// A preview reads a bounded start of a regular file, and nothing from a device (it would never end). + #[cfg(unix)] + #[test] + fn previews_skip_devices() { + assert_eq!(read_preview(Path::new("/dev/zero")), Vec::::new()); + let path: std::path::PathBuf = std::env::temp_dir().join(format!("lim-preview-{}", std::process::id())); + fs::write(&path, "x\n".repeat(MAX_PREVIEW_BYTES)).unwrap(); + assert_eq!(read_preview(&path).len(), MAX_PREVIEW_BYTES / 2); + let _: std::io::Result<()> = fs::remove_file(&path); + } + /// The theme picker shows its sample code, highlighted, even over an empty buffer. #[test] fn the_theme_picker_previews_a_sample() { diff --git a/src/render/markdown.rs b/src/render/markdown.rs index f31a8aa..f3bd94f 100644 --- a/src/render/markdown.rs +++ b/src/render/markdown.rs @@ -3,9 +3,12 @@ //! boxes, quotes, fenced code, tables and rules; paragraphs wrap to the panel. use std::cell::RefCell; +use std::ops::Range; use std::rc::Rc; use std::sync::Arc; +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; + use super::row::{Grid, Row}; use super::theme::{Group, Rgb, Style, Theme, theme}; use crate::buffer::{Buffer, text}; @@ -53,115 +56,6 @@ pub fn is_markdown(buf: &Buffer) -> bool { buf.lang.is_some_and(|l| l.kind == Kind::Markdown) } -/// `**bold**`, `*italic*` / `_italic_`, `` `code` ``, `[text](url)`, `![alt](src)`, `~~gone~~`, `\*` escapes. -fn inline(source: &str, base: Look) -> Vec { - let chars: Vec = source.chars().collect(); - let mut text: Inline = - Inline { base, spans: Vec::new(), current: String::new(), bold: false, italic: false, dim: false }; - let mut i: usize = 0; - while let Some(&c) = chars.get(i) { - let next: Option = chars.get(i + 1).copied(); - // A code span or a link takes several chars; an unclosed one is plain text. - let span: Option = match c { - '`' => code_span(&chars, i, &mut text), - '!' | '[' => link(&chars, i, &mut text), - _ => None, - }; - if let Some(len) = span { - i += len; - continue; - } - i += match c { - '\\' if next.is_some_and(|n| n.is_ascii_punctuation()) => { - text.current.push(next.unwrap_or(c)); - 2 - } - '*' | '_' if next == Some(c) => text.toggle(|t| &mut t.bold, 2), - '~' if next == Some('~') => text.toggle(|t| &mut t.dim, 2), - // `_` inside a word (snake_case) is not emphasis. - '*' | '_' if c == '*' || i == 0 || !chars[i - 1].is_alphanumeric() || text.italic => { - text.toggle(|t| &mut t.italic, 1) - } - _ => { - text.current.push(c); - 1 - } - }; - } - text.flush(); - text.spans -} - -/// The spans read so far, the text not yet in one, and the emphasis it is in. -struct Inline { - base: Look, - spans: Vec, - current: String, - bold: bool, - italic: bool, - dim: bool, -} - -impl Inline { - /// What the current text looks like: struck-through wins over bold, bold over italic. - fn look(&self) -> Look { - match (self.dim, self.bold, self.italic) { - (true, _, _) => Look::Dim, - (_, true, _) => Look::Bold, - (_, _, true) => Look::Italic, - _ => self.base, - } - } - - /// Ends the text in the current emphasis as a span. - fn flush(&mut self) { - if !self.current.is_empty() { - let look: Look = self.look(); - self.spans.push(span(std::mem::take(&mut self.current), look)); - } - } - - /// A `**`, `~~` or `*` marker: the text so far keeps its emphasis, the emphasis flips. Returns `len`, the - /// marker's length. - fn toggle(&mut self, emphasis: fn(&mut Self) -> &mut bool, len: usize) -> usize { - self.flush(); - let flag: &mut bool = emphasis(self); - *flag = !*flag; - len - } -} - -/// `` `code` `` at `i`: a code span, and the chars it took; None when the backtick is not closed. -fn code_span(chars: &[char], i: usize, text: &mut Inline) -> Option { - let len: usize = chars[i + 1..].iter().position(|&x| x == '`')?; - text.flush(); - text.spans.push(span(chars[i + 1..i + 1 + len].iter().collect::(), Look::Code)); - Some(len + 2) -} - -/// `[text](url)` (the text, styled as a link) or `![alt](src)` (a dimmed placeholder) at `i`, and the chars it -/// took; None when the brackets do not make one. -fn link(chars: &[char], i: usize, text: &mut Inline) -> Option { - let image: bool = chars[i] == '!'; - let open: usize = if image { i + 1 } else { i }; - if chars.get(open) != Some(&'[') { - return None; - } - let close: usize = open + chars[open..].iter().position(|&x| x == ']')?; - if chars.get(close + 1) != Some(&'(') { - return None; - } - let end: usize = close + chars[close..].iter().position(|&x| x == ')')?; - text.flush(); - let label: String = chars[open + 1..close].iter().collect(); - if image { - text.spans.push(span(format!("▣ {label}"), Look::Dim)); - } else { - text.spans.extend(inline(&label, Look::Link)); - } - Some(end + 1 - i) -} - /// Word-wraps `spans` into lines of `width` cells: the first starts with `first`, the rest with `rest`. fn wrap(spans: Vec, width: usize, first: Vec, rest: &[Span], source: usize) -> Vec { let prefix_width = |p: &[Span]| p.iter().map(|s| text::str_width(&s.text)).sum::(); @@ -212,213 +106,331 @@ fn rule(width: usize, ch: &str, source: usize) -> Line { Line { spans: vec![span(ch.repeat(width), Look::Rule)], source, block: false } } -/// `- item`, `* item`, `+ item`, `1. item`, `2) item`: (indent, marker as written, text after it). -fn list_item(line: &str) -> Option<(usize, &str, &str)> { - let indent: usize = line.len() - line.trim_start().len(); - let t: &str = line.trim_start(); - let marker_len: usize = if t.starts_with(['-', '*', '+']) { - 1 - } else { - let digits: usize = t.bytes().take_while(u8::is_ascii_digit).count(); - if digits == 0 || !matches!(t.as_bytes().get(digits), Some(b'.' | b')')) { - return None; - } - digits + 1 - }; - let rest: &str = t[marker_len..].strip_prefix(' ')?; - Some((indent, &t[..marker_len], rest)) +/// A list item being read: its first line's prefix (indent and bullet) until used, then the hanging indent. +struct Item { + first: Option>, + rest: Vec, } -fn is_rule(t: &str) -> bool { - let compact: String = t.chars().filter(|c| !c.is_whitespace()).collect(); - compact.len() >= 3 && ['-', '*', '_'].iter().any(|&m| compact.chars().all(|c| c == m)) +/// A table being read: its rows of cells (the first is the header) and the source line of each row. +#[derive(Default)] +struct Table { + rows: Vec<(usize, Vec>)>, + cell: Vec, } -fn heading(t: &str) -> Option<(u8, &str)> { - let level: usize = t.bytes().take_while(|&b| b == b'#').count(); - let rest: &str = t[level..].strip_prefix(' ').or_else(|| (level == t.len()).then_some(""))?; - (1..=6).contains(&level).then(|| (u8::try_from(level).unwrap_or(6), rest.trim_end_matches(['#', ' ']))) +/// The preview being built from pulldown-cmark's events. +struct Renderer { + width: usize, + out: Vec, + /// Byte offset where each source line starts. + starts: Vec, + /// Inline text of the current block, and the look its text gets. + spans: Vec, + base: Look, + bold: usize, + italic: usize, + dim: usize, + link: usize, + /// The alt text of an image being read. + image: Option, + quote: usize, + /// Each open list: its next number when ordered. + lists: Vec>, + items: Vec, + code: bool, + table: Option, + /// The source line where the current block starts, and where the last one ended. + start: usize, + end: Option, } -fn table_cells(line: &str) -> Vec { - let t: &str = line.trim(); - let t: &str = t.strip_prefix('|').unwrap_or(t); - let t: &str = t.strip_suffix('|').unwrap_or(t); - t.split('|').map(|c| c.trim().to_owned()).collect() -} +impl Renderer { + fn line_of(&self, offset: usize) -> usize { + self.starts.partition_point(|&s| s <= offset).saturating_sub(1) + } -fn is_table_separator(line: &str) -> bool { - let cells: Vec = table_cells(line); - !cells.is_empty() && cells.iter().all(|c| !c.is_empty() && c.chars().all(|ch| matches!(ch, '-' | ':'))) -} + /// What text looks like now: struck-through wins over bold, bold over italic, italic over a link's look. + fn look(&self) -> Look { + match (self.dim, self.bold, self.italic, self.link) { + (1.., _, _, _) => Look::Dim, + (_, 1.., _, _) => Look::Bold, + (_, _, 1.., _) => Look::Italic, + (_, _, _, 1..) => Look::Link, + _ => self.base, + } + } -/// Starts a block other than a paragraph (so a paragraph stops before it). -fn starts_block(line: &str) -> bool { - let t: &str = line.trim_start(); - t.is_empty() - || heading(t).is_some() - || t.starts_with("```") - || t.starts_with("~~~") - || t.starts_with('>') - || t.starts_with('|') - || is_rule(t) - || list_item(line).is_some() -} + fn blank(&mut self) { + if self.out.last().is_some_and(|l| !l.spans.is_empty()) { + self.out.push(Line { spans: Vec::new(), source: self.start, block: false }); + } + } -/// The whole document as preview lines `width` cells wide. -#[expect(clippy::too_many_lines, reason = "one arm per Markdown block")] -pub fn render(lines: &[String], width: usize) -> Vec { - let width: usize = width.max(10); - let mut out: Vec = Vec::new(); - let blank = |out: &mut Vec, source: usize| { - if out.last().is_some_and(|l: &Line| !l.spans.is_empty()) { - out.push(Line { spans: Vec::new(), source, block: false }); + /// A block starts at source line `line`: a blank line before it when the source has one (or `always`). + fn open(&mut self, line: usize, always: bool) { + self.start = line; + if always || self.end.is_some_and(|end| line > end + 1) { + self.blank(); } - }; - let mut i: usize = 0; - while i < lines.len() { - let line: &str = &lines[i]; - let t: &str = line.trim(); - if t.is_empty() { - blank(&mut out, i); - i += 1; - continue; + } + + fn push_text(&mut self, text: &str, look: Look) { + match (&mut self.image, &mut self.table) { + (Some(alt), _) => alt.push_str(text), + (None, Some(table)) => table.cell.push(span(text, look)), + (None, None) => self.spans.push(span(text, look)), + } + } + + /// Wraps the inline text read so far, after a list bullet or a quote bar when inside one. + fn flush(&mut self) { + if self.spans.is_empty() { + return; } - if t.starts_with("```") || t.starts_with("~~~") { - let fence: &str = &t[..3]; - let lang: &str = t[3..].trim(); - blank(&mut out, i); - if !lang.is_empty() { - out.push(Line { spans: vec![span(format!(" {lang}"), Look::Dim)], source: i, block: true }); + let bar: Vec = if self.quote > 0 { vec![span("▌ ", Look::Quote)] } else { Vec::new() }; + let (mut first, mut rest): (Vec, Vec) = match self.items.last_mut() { + Some(item) => (item.first.take().unwrap_or_else(|| item.rest.clone()), item.rest.clone()), + None => (Vec::new(), Vec::new()), + }; + first.splice(0..0, bar.iter().cloned()); + rest.splice(0..0, bar); + let spans: Vec = std::mem::take(&mut self.spans); + self.out.extend(wrap(spans, self.width, first, &rest, self.start)); + } + + fn start(&mut self, tag: Tag<'_>, line: usize) { + match tag { + Tag::Paragraph => self.open(line, false), + Tag::Heading { level, .. } => { + self.open(line, true); + self.base = Look::Heading(match level { + HeadingLevel::H1 => 1, + HeadingLevel::H2 => 2, + HeadingLevel::H3 => 3, + HeadingLevel::H4 => 4, + HeadingLevel::H5 => 5, + HeadingLevel::H6 => 6, + }); } - i += 1; - while i < lines.len() && !lines[i].trim_start().starts_with(fence) { - let code: String = lines[i].replace('\t', " "); - out.push(Line { spans: vec![span(format!(" {code}"), Look::CodeBlock)], source: i, block: true }); - i += 1; + Tag::BlockQuote(_) => { + self.open(line, false); + self.quote += 1; + self.base = Look::Quote; } - i += 1; - blank(&mut out, i.min(lines.len()) - 1); - continue; - } - if let Some((level, title)) = heading(t) { - blank(&mut out, i); - out.extend(wrap(inline(title, Look::Heading(level)), width, Vec::new(), &[], i)); - match level { - 1 => out.push(rule(width, "━", i)), - 2 => out.push(rule(width, "─", i)), - _ => {} + Tag::CodeBlock(kind) => { + self.flush(); + self.open(line, true); + self.code = true; + if let CodeBlockKind::Fenced(info) = kind + && let Some(lang) = info.split_whitespace().next() + { + self.out.push(Line { spans: vec![span(format!(" {lang}"), Look::Dim)], source: line, block: true }); + } } - i += 1; - continue; - } - if is_rule(t) { - out.push(rule(width, "─", i)); - i += 1; - continue; + Tag::List(number) => { + self.flush(); + self.lists.push(number); + } + Tag::Item => { + self.open(line, false); + let depth: usize = self.lists.len().saturating_sub(1); + let pad: String = " ".repeat(depth); + let bullet: String = match self.lists.last_mut() { + Some(Some(n)) => { + *n += 1; + format!("{}.", *n - 1) + } + _ => ["•", "◦", "▪"][depth % 3].to_owned(), + }; + let hang: String = format!("{pad}{}", " ".repeat(text::str_width(&bullet) + 1)); + let first: Vec = vec![span(pad, Look::Text), span(format!("{bullet} "), Look::Marker)]; + self.items.push(Item { first: Some(first), rest: vec![span(hang, Look::Text)] }); + } + Tag::Table(_) => { + self.open(line, true); + self.table = Some(Table::default()); + } + Tag::TableHead | Tag::TableRow => { + if let Some(table) = &mut self.table { + table.rows.push((line, Vec::new())); + } + } + Tag::Emphasis => self.italic += 1, + Tag::Strong => self.bold += 1, + Tag::Strikethrough => self.dim += 1, + Tag::Link { .. } => self.link += 1, + Tag::Image { .. } => self.image = Some(String::new()), + _ => {} } - if t.starts_with('|') && lines.get(i + 1).is_some_and(|l| is_table_separator(l)) { - let start: usize = i; - let mut rows: Vec> = vec![table_cells(t)]; - i += 2; - while i < lines.len() && lines[i].trim().starts_with('|') { - rows.push(table_cells(&lines[i])); - i += 1; + } + + fn end(&mut self, tag: TagEnd) { + match tag { + TagEnd::Paragraph => self.flush(), + TagEnd::Heading(level) => { + self.flush(); + match level { + HeadingLevel::H1 => self.out.push(rule(self.width, "━", self.start)), + HeadingLevel::H2 => self.out.push(rule(self.width, "─", self.start)), + _ => {} + } + self.base = Look::Text; + } + TagEnd::BlockQuote(_) => { + self.quote = self.quote.saturating_sub(1); + self.base = if self.quote > 0 { Look::Quote } else { Look::Text }; + } + TagEnd::CodeBlock => { + self.code = false; + self.blank(); + } + TagEnd::List(_) => { + self.lists.pop(); + } + TagEnd::Item => { + self.flush(); + self.items.pop(); } - let columns: usize = rows.iter().map(Vec::len).max().unwrap_or(0); - let cell_text = |c: &str| inline(c, Look::Text).into_iter().map(|s| s.text).collect::(); - let widths: Vec = (0..columns) - .map(|c| rows.iter().map(|r| r.get(c).map_or(0, |s| text::str_width(&cell_text(s)))).max().unwrap_or(0)) - .collect(); - let border = |left: &str, mid: &str, right: &str| -> Line { - let body: Vec = widths.iter().map(|w| "─".repeat(w + 2)).collect(); - Line { - spans: vec![span(format!("{left}{}{right}", body.join(mid)), Look::Rule)], - source: start, - block: false, + TagEnd::TableCell => { + if let Some(table) = &mut self.table { + let cell: Vec = std::mem::take(&mut table.cell); + if let Some((_, row)) = table.rows.last_mut() { + row.push(cell); + } } - }; - blank(&mut out, start); - out.push(border("┌", "┬", "┐")); - for (r, row) in rows.iter().enumerate() { - let mut spans: Vec = vec![span("│", Look::Rule)]; - for (c, w) in widths.iter().enumerate() { - let cell: &str = row.get(c).map_or("", String::as_str); - let look: Look = if r == 0 { Look::Bold } else { Look::Text }; - spans.push(span(" ", look)); - let inner: Vec = inline(cell, look); - let used: usize = inner.iter().map(|s| text::str_width(&s.text)).sum(); - spans.extend(inner); - spans.push(span(" ".repeat(w - used + 1), look)); - spans.push(span("│", Look::Rule)); + } + TagEnd::Table => { + if let Some(table) = self.table.take() { + self.out.extend(table_lines(table.rows)); } - out.push(Line { spans, source: start + r + usize::from(r > 0), block: false }); - if r == 0 { - out.push(border("├", "┼", "┤")); + } + TagEnd::Emphasis => self.italic = self.italic.saturating_sub(1), + TagEnd::Strong => self.bold = self.bold.saturating_sub(1), + TagEnd::Strikethrough => self.dim = self.dim.saturating_sub(1), + TagEnd::Link => self.link = self.link.saturating_sub(1), + TagEnd::Image => { + if let Some(alt) = self.image.take() { + self.push_text(&format!("▣ {alt}"), Look::Dim); } } - out.push(border("└", "┴", "┘")); - continue; + _ => {} } - if t.starts_with('>') { - let start: usize = i; - let mut quote: Vec = Vec::new(); - while i < lines.len() && lines[i].trim_start().starts_with('>') { - let body: &str = lines[i].trim_start()[1..].trim_start(); - quote.push(body.to_owned()); - i += 1; + } + + fn event(&mut self, event: Event<'_>, range: Range) { + let line: usize = self.line_of(range.start); + let last: usize = self.line_of(range.end.saturating_sub(1).max(range.start)); + // Where content last ended (a block's own range can take in the blank lines after it). + let content: bool = + matches!(event, Event::Text(_) | Event::Code(_) | Event::Html(_) | Event::InlineHtml(_) | Event::Rule); + match event { + Event::Start(tag) => self.start(tag, line), + Event::End(tag) => self.end(tag), + Event::Text(t) if self.code => { + for (i, code) in t.lines().enumerate() { + let code: String = code.replace('\t', " "); + let source: usize = line + i; + self.out.push(Line { spans: vec![span(format!(" {code}"), Look::CodeBlock)], source, block: true }); + } } - let bar: Vec = vec![span("▌ ", Look::Quote)]; - out.extend(wrap(inline("e.join(" "), Look::Quote), width, bar.clone(), &bar, start)); - continue; - } - if let Some((indent, marker, rest)) = list_item(line) { - let depth: usize = indent / 2; - let pad: String = " ".repeat(depth); - let (bullet, rest): (String, &str) = if let Some(after) = rest.strip_prefix("[ ] ") { - ("☐".into(), after) - } else if let Some(after) = rest.strip_prefix("[x] ").or_else(|| rest.strip_prefix("[X] ")) { - ("☑".into(), after) - } else if marker.ends_with(['.', ')']) { - (marker.to_owned(), rest) - } else { - (["•", "◦", "▪"][depth % 3].to_owned(), rest) - }; - let start: usize = i; - let mut body: String = rest.to_owned(); - i += 1; - // Lazy continuation: following lines that start no block belong to the item. - while i < lines.len() && !starts_block(&lines[i]) { - body.push(' '); - body.push_str(lines[i].trim()); - i += 1; + Event::Text(t) => self.push_text(&t, self.look()), + Event::Code(t) => self.push_text(&t, Look::Code), + Event::SoftBreak | Event::HardBreak => self.push_text(" ", self.look()), + Event::TaskListMarker(done) => { + if let Some(item) = self.items.last_mut() { + let pad: String = + item.first.as_ref().and_then(|f| f.first()).map(|s| s.text.clone()).unwrap_or_default(); + item.first = + Some(vec![span(pad.clone(), Look::Text), span(if done { "☑ " } else { "☐ " }, Look::Marker)]); + item.rest = vec![span(format!("{pad} "), Look::Text)]; + } } - let hang: usize = text::str_width(&bullet) + 1; - let first: Vec = vec![span(pad.clone(), Look::Text), span(format!("{bullet} "), Look::Marker)]; - let rest_prefix: Vec = vec![span(format!("{pad}{}", " ".repeat(hang)), Look::Text)]; - out.extend(wrap(inline(&body, Look::Text), width, first, &rest_prefix, start)); - continue; - } - if t.starts_with('<') { - out.push(Line { spans: vec![span(t, Look::Dim)], source: i, block: false }); - i += 1; - continue; + Event::Rule => { + self.open(line, false); + self.out.push(rule(self.width, "─", line)); + } + Event::Html(t) => { + self.open(line, false); + self.out.push(Line { spans: vec![span(t.trim_end(), Look::Dim)], source: line, block: false }); + } + Event::InlineHtml(t) => self.push_text(&t, Look::Dim), + _ => {} } - let start: usize = i; - let mut paragraph: Vec<&str> = vec![t]; - i += 1; - while i < lines.len() && !starts_block(&lines[i]) { - paragraph.push(lines[i].trim()); - i += 1; + if content { + self.end = Some(last); } - out.extend(wrap(inline(¶graph.join(" "), Look::Text), width, Vec::new(), &[], start)); } - while out.last().is_some_and(|l| l.spans.is_empty()) { - out.pop(); +} + +/// A table with box borders: column widths fit the widest cell, and the header row is bold. +fn table_lines(rows: Vec<(usize, Vec>)>) -> Vec { + let cell_width = |cell: &[Span]| cell.iter().map(|s| text::str_width(&s.text)).sum::(); + let columns: usize = rows.iter().map(|(_, cells)| cells.len()).max().unwrap_or(0); + let widths: Vec = (0..columns) + .map(|c| rows.iter().map(|(_, cells)| cells.get(c).map_or(0, |cell| cell_width(cell))).max().unwrap_or(0)) + .collect(); + let start: usize = rows.first().map_or(0, |(line, _)| *line); + let border = |left: &str, mid: &str, right: &str| -> Line { + let body: Vec = widths.iter().map(|w| "─".repeat(w + 2)).collect(); + Line { spans: vec![span(format!("{left}{}{right}", body.join(mid)), Look::Rule)], source: start, block: false } + }; + let mut out: Vec = vec![border("┌", "┬", "┐")]; + for (r, (source, cells)) in rows.into_iter().enumerate() { + let mut spans: Vec = vec![span("│", Look::Rule)]; + for (c, w) in widths.iter().enumerate() { + let cell: &[Span] = cells.get(c).map_or(&[], Vec::as_slice); + let header = |look: Look| if r == 0 && look == Look::Text { Look::Bold } else { look }; + let pad: Look = header(Look::Text); + spans.push(span(" ", pad)); + spans.extend(cell.iter().map(|s| span(s.text.clone(), header(s.look)))); + spans.push(span(" ".repeat(w - cell_width(cell) + 1), pad)); + spans.push(span("│", Look::Rule)); + } + out.push(Line { spans, source, block: false }); + if r == 0 { + out.push(border("├", "┼", "┤")); + } } + out.push(border("└", "┴", "┘")); out } +/// The whole document as preview lines `width` cells wide. +pub fn render(lines: &[String], width: usize) -> Vec { + let source: String = lines.join("\n"); + let mut starts: Vec = vec![0]; + starts.extend(source.match_indices('\n').map(|(i, _)| i + 1)); + let mut r: Renderer = Renderer { + width: width.max(10), + out: Vec::new(), + starts, + spans: Vec::new(), + base: Look::Text, + bold: 0, + italic: 0, + dim: 0, + link: 0, + image: None, + quote: 0, + lists: Vec::new(), + items: Vec::new(), + code: false, + table: None, + start: 0, + end: None, + }; + let options: Options = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS; + for (event, range) in Parser::new_ext(&source, options).into_offset_iter() { + r.event(event, range); + } + r.flush(); + while r.out.last().is_some_and(|l| l.spans.is_empty()) { + r.out.pop(); + } + r.out +} + fn style_of(t: &Theme, look: Look, bg: Rgb) -> Style { let on = |group: Group| t.on(group, bg); match look { @@ -532,7 +544,16 @@ mod tests { #[test] fn inline_rules() { - let looks = |s: &str| inline(s, Look::Text).into_iter().map(|s| (s.text, s.look)).collect::>(); + // The parser may cut a run of text into several events: neighbours with the same look are joined. + let looks = |s: &str| { + render(&doc(s), 80).remove(0).spans.into_iter().fold(Vec::<(String, Look)>::new(), |mut runs, s| { + match runs.last_mut() { + Some((text, look)) if *look == s.look => text.push_str(&s.text), + _ => runs.push((s.text, s.look)), + } + runs + }) + }; assert_eq!(looks("snake_case_name"), vec![("snake_case_name".into(), Look::Text)]); assert_eq!(looks("*it*"), vec![("it".into(), Look::Italic)]); assert_eq!(looks("\\*not\\*"), vec![("*not*".into(), Look::Text)]); diff --git a/src/render/mod.rs b/src/render/mod.rs index e48ccc0..d57467d 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -19,11 +19,12 @@ pub use theme::Theme; use theme::install; mod view; +use std::env; use std::fmt::{self, Write}; -use std::io; -use std::path::PathBuf; +use std::fs::{self, File}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::{env, fs}; use crate::buffer::{Buffer, text}; use crate::config::Options; @@ -83,7 +84,7 @@ impl Context { fn git_branch(mut dir: PathBuf) -> Option { loop { - if let Ok(head) = fs::read_to_string(dir.join(".git/HEAD")) { + if let Some(head) = read_head(&dir.join(".git/HEAD")) { let head: &str = head.trim(); return Some( head.strip_prefix("ref: refs/heads/").map_or_else(|| head.chars().take(7).collect(), str::to_owned), @@ -95,6 +96,22 @@ fn git_branch(mut dir: PathBuf) -> Option { } } +/// The start of a `HEAD` file. Any parent folder can hold one (`/tmp/.git`, an unpacked archive), so a FIFO or +/// device there, which would block or never end, is skipped, and the read is bounded. +/// It runs every frame: the path is only checked first on Unix, where opening a FIFO blocks. +fn read_head(path: &Path) -> Option { + if cfg!(unix) && !fs::metadata(path).is_ok_and(|m| m.is_file()) { + return None; + } + let file: File = File::open(path).ok()?; + if !file.metadata().is_ok_and(|m| m.is_file()) { + return None; + } + let mut head: String = String::new(); + file.take(256).read_to_string(&mut head).ok()?; + Some(head) +} + /// A tool's popup, border included: room for a few questions and answers, not a window's worth. const POPUP_WIDTH: usize = 56; const POPUP_HEIGHT: usize = 10; @@ -378,6 +395,20 @@ mod tests { use crate::buffer::Buffer; use crate::term::Key; + /// The branch comes from the nearest parent's `.git/HEAD`; a device there (it would never end) is skipped. + #[cfg(unix)] + #[test] + fn branch_skips_a_device_head() { + let dir: PathBuf = env::temp_dir().join(format!("lim-head-{}", std::process::id())); + let _: io::Result<()> = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("outer/.git")).unwrap(); + fs::create_dir_all(dir.join("outer/inner/.git")).unwrap(); + fs::write(dir.join("outer/.git/HEAD"), "ref: refs/heads/main\n").unwrap(); + std::os::unix::fs::symlink("/dev/zero", dir.join("outer/inner/.git/HEAD")).unwrap(); + assert_eq!(git_branch(dir.join("outer/inner")), Some("main".into())); + let _: io::Result<()> = fs::remove_dir_all(&dir); + } + /// Every mode and odd sizes render without panicking. #[test] fn draws_any_size_and_mode() { diff --git a/src/render/row.rs b/src/render/row.rs index 3963cc5..d43a519 100644 --- a/src/render/row.rs +++ b/src/render/row.rs @@ -46,8 +46,11 @@ impl Row { /// Draws `c` over as many cells as it takes; a wide char that does not fit becomes padding. pub fn cell(&mut self, style: Style, c: char) { - // File contents must never reach the terminal as control sequences. - let c: char = if c.is_control() { '?' } else { c }; + // File contents must never reach the terminal as control sequences, nor as bidi controls that make a terminal + // reorder the text (shown text differing from the file: "Trojan Source"). + let bidi: bool = + matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'); + let c: char = if c.is_control() || bidi { '?' } else { c }; match text::char_width(c) { 0 => { if let Some(last) = self.cells.iter_mut().rev().find(|c| !c.wide_tail) { @@ -277,6 +280,9 @@ mod tests { row.text(style, "a\x1b[2Jbcdef"); assert_eq!(encode(&row.finish(style), ColorDepth::Ansi256), "\x1b[0;38;5;231;48;5;16ma?[2"); let mut row: Row = Row::new(4); + row.text(style, "a\u{202e}b\u{200f}"); + assert_eq!(encode(&row.finish(style), ColorDepth::Ansi256), "\x1b[0;38;5;231;48;5;16ma?b?", "bidi controls"); + let mut row: Row = Row::new(4); row.text(style, "a日本"); // 本 needs 2 cells, 1 is left: padded instead let cells: Vec = row.finish(style); assert_eq!(cells.len(), 4); diff --git a/src/repo.rs b/src/repo.rs new file mode 100644 index 0000000..a8a972f --- /dev/null +++ b/src/repo.rs @@ -0,0 +1,602 @@ +//! git through gix: gitsigns' base, statuses, the file list, the git pickers and git browse. No git process runs. +//! An opened folder may come from anyone (an archive, a copied checkout), so nothing its own `.git/config` names +//! is run: gix only takes filter and diff drivers from the user's and the system's config. + +use std::collections::{BTreeMap, HashMap}; +use std::fmt::Write; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; + +use gix::bstr::{BString, ByteSlice}; +use gix::config::Source; +use gix::config::file::Metadata; +use gix::diff::blob::unified_diff::{ConsumeHunk, ContextSize, DiffLineKind, HunkHeader}; +use gix::diff::blob::{Algorithm, Diff, InternedInput, UnifiedDiff}; +use gix::object::tree::diff::ChangeDetached; +use gix::revision::walk::Sorting; +use gix::sec::Trust; +use gix::traverse::commit::simple::CommitTimeOrder; +use gix::{Commit as GixCommit, ObjectId, Repository}; + +use crate::buffer::canonical; + +/// The repository holding `dir`. +pub fn open(dir: &Path) -> Option { + let options: gix::open::Options = gix::open::Options::default().filter_config_section(trusted); + let map: gix::sec::trust::Mapping = + gix::sec::trust::Mapping { full: options.clone(), reduced: options }; + let repo: gix::ThreadSafeRepository = + gix::ThreadSafeRepository::discover_opts(dir, gix::discover::upwards::Options::default(), map).ok()?; + Some(repo.to_thread_local()) +} + +/// Config sections whose programs gix may run: the user's and the system's, never the repository's own. +fn trusted(meta: &Metadata) -> bool { + meta.trust == Trust::Full && !matches!(meta.source, Source::Local | Source::Worktree) +} + +/// `path` relative to the work tree, `/`-separated, as the index and trees name it. +fn rela_path(repo: &Repository, path: &Path) -> Option { + let root: PathBuf = canonical(repo.workdir()?); + let full: PathBuf = canonical(path); + let rel: &Path = full.strip_prefix(&root).ok()?; + let parts: Vec = rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect(); + Some(parts.join("/")) +} + +/// The folder of a file path, `.` for a bare name. +fn parent(path: &Path) -> &Path { + path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")) +} + +/// The staged version of `path` (`git show :./name`); None outside a repo or for an untracked file. +pub fn index_text(path: &Path) -> Option { + let repo: Repository = open(parent(path))?; + let rel: String = rela_path(&repo, path)?; + let index: gix::worktree::Index = repo.index().ok()?; + let entry: &gix::index::Entry = index.entry_by_path(rel.as_bytes().as_bstr())?; + String::from_utf8(repo.find_object(entry.id).ok()?.detach().data).ok() +} + +/// Changed files under the repository of `dir`, as `git status --porcelain` codes (`" M"`, `"A "`, `"??"`...), by +/// absolute path. +pub fn status(dir: &Path) -> Vec<(PathBuf, [char; 2])> { + use gix::status::Item; + use gix::status::index_worktree::iter::Summary; + let Some(repo): Option = open(dir) else { return Vec::new() }; + let Some(root): Option = repo.workdir().map(canonical) else { return Vec::new() }; + let Ok(platform) = repo.status(gix::progress::Discard) else { return Vec::new() }; + let no_patterns: Vec = Vec::new(); + let Ok(items) = platform.untracked_files(gix::status::UntrackedFiles::Files).into_iter(no_patterns) else { + return Vec::new(); + }; + let mut codes: BTreeMap = BTreeMap::new(); + for item in items.flatten() { + let slot: &mut [char; 2] = codes.entry(item.location().to_str_lossy().into_owned()).or_insert([' ', ' ']); + match &item { + Item::TreeIndex(change) => { + slot[0] = match change { + gix::diff::index::Change::Addition { .. } => 'A', + gix::diff::index::Change::Deletion { .. } => 'D', + gix::diff::index::Change::Modification { .. } => 'M', + gix::diff::index::Change::Rewrite { .. } => 'R', + }; + } + Item::IndexWorktree(change) => match change.summary() { + Some(Summary::Added) => *slot = ['?', '?'], + Some(Summary::Removed) => slot[1] = 'D', + Some(Summary::Renamed | Summary::Copied) => slot[1] = 'R', + Some(Summary::Conflict) => *slot = ['U', 'U'], + Some(Summary::IntentToAdd) => slot[1] = 'A', + Some(Summary::Modified | Summary::TypeChange) => slot[1] = 'M', + None => {} + }, + } + } + codes.into_iter().filter(|(_, code)| *code != [' ', ' ']).map(|(path, code)| (root.join(path), code)).collect() +} + +/// Tracked and untracked, not ignored, files under `dir`, relative to it (`git ls-files --cached --others +/// --exclude-standard`); None outside a repo. +pub fn files(dir: &Path) -> Option> { + use gix::dir::entry::Status; + static NEVER: AtomicBool = AtomicBool::new(false); + let repo: Repository = open(dir)?; + let root: PathBuf = canonical(repo.workdir()?); + let here: PathBuf = canonical(dir); + let options: gix::dirwalk::Options = + repo.dirwalk_options().ok()?.emit_tracked(true).emit_untracked(gix::dir::walk::EmissionMode::Matching); + let index: gix::worktree::IndexPersistedOrInMemory = repo.index_or_empty().ok()?.into(); + let no_patterns: Vec = Vec::new(); + let walk: gix::dirwalk::Iter = repo.dirwalk_iter(index, no_patterns, (&NEVER).into(), options).ok()?; + let mut files: Vec = Vec::new(); + for item in walk.flatten() { + if !matches!(item.entry.status, Status::Tracked | Status::Untracked) { + continue; + } + let full: PathBuf = root.join(item.entry.rela_path.to_str_lossy().as_ref()); + if let Ok(rel) = full.strip_prefix(&here) { + files.push(rel.to_path_buf()); + } + } + files.sort(); + Some(files) +} + +/// One commit as the git pickers show it. +pub struct Commit { + pub id: ObjectId, + pub parents: Vec, + /// `%h %ad %d %s`: short hash, author date, ` (refs)` when `all`, subject. + pub line: String, +} + +impl Commit { + pub fn hash(&self) -> String { + self.id.to_hex_with_len(7).to_string() + } +} + +/// The newest `max` commits of the repository holding `dir`, by date: from every ref (`--all`, with their names) or +/// from HEAD only, and when `file` is given, only those changing it. +pub fn log(dir: &Path, all: bool, file: Option<&Path>, max: usize) -> Option> { + let repo: Repository = open(dir)?; + let file: Option = match file { + Some(path) => Some(rela_path(&repo, path)?), + None => None, + }; + let names: HashMap> = if all { ref_names(&repo) } else { HashMap::new() }; + let tips: Vec = if all { names.keys().copied().collect() } else { vec![repo.head_id().ok()?.detach()] }; + let walk = repo.rev_walk(tips).sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst)).all().ok()?; + let mut commits: Vec = Vec::new(); + // The file's blob by commit: a commit's parent is mostly the next one walked, so each tree is read once. + let mut blobs: HashMap> = HashMap::new(); + for info in walk.flatten() { + let parents: Vec = info.parent_ids.iter().copied().collect(); + if let Some(path) = &file { + let mut blob = |id: ObjectId| *blobs.entry(id).or_insert_with(|| path_id(&repo, id, path)); + if blob(info.id) == parents.first().and_then(|&p| blob(p)) { + continue; + } + } + let commit: GixCommit<'_> = info.object().ok()?; + let date: String = commit + .author() + .ok() + .and_then(|a| a.time().ok()) + .map(|t| t.format_or_unix(gix::date::time::format::SHORT)) + .unwrap_or_default(); + let refs: String = names.get(&info.id).map(|n| format!(" ({})", n.join(", "))).unwrap_or_default(); + let subject: String = commit.message().map(|m| m.summary().to_str_lossy().into_owned()).unwrap_or_default(); + let hash: String = info.id.to_hex_with_len(7).to_string(); + commits.push(Commit { id: info.id, parents, line: format!("{hash} {date} {refs} {subject}") }); + if commits.len() >= max { + break; + } + } + Some(commits) +} + +/// The blob `path` has in commit `id`. +fn path_id(repo: &Repository, id: ObjectId, path: &str) -> Option { + let tree: gix::Tree<'_> = repo.find_commit(id).ok()?.tree().ok()?; + Some(tree.lookup_entry_by_path(path).ok()??.object_id()) +} + +/// The names `--decorate` shows, by the commit they point at: `HEAD -> main`, `origin/main`, `tag: v1`. +fn ref_names(repo: &Repository) -> HashMap> { + use gix::reference::Category; + let mut names: HashMap> = HashMap::new(); + let head: Option = repo.head_name().ok().flatten().map(|n| n.shorten().to_string()); + if let Ok(id) = repo.head_id() { + names.entry(id.detach()).or_default().push(if head.is_some() { String::new() } else { "HEAD".into() }); + } + let Ok(platform) = repo.references() else { return names }; + let Ok(all) = platform.all() else { return names }; + for mut reference in all.flatten() { + let short: String = reference.name().shorten().to_string(); + let name: String = match reference.name().category() { + Some(Category::LocalBranch) if head.as_deref() == Some(short.as_str()) => format!("HEAD -> {short}"), + Some(Category::LocalBranch | Category::RemoteBranch) => short, + Some(Category::Tag) => format!("tag: {short}"), + _ => continue, + }; + let Ok(id) = reference.peel_to_id() else { continue }; + if repo.find_commit(id).is_ok() { + names.entry(id.detach()).or_default().push(name); + } + } + for list in names.values_mut() { + list.retain(|n| !n.is_empty()); + } + names +} + +/// `git log --graph` lines for `commits`, newest first: a lane per open line of history, `*` on each commit, and +/// connector rows (`/` where a lane ends in another, `\` where a merge opens one) that hold no commit. +pub fn graph(commits: &[Commit]) -> Vec { + let mut lanes: Vec> = Vec::new(); + let mut rows: Vec = Vec::new(); + for commit in commits { + let waiting: Vec = (0..lanes.len()).filter(|&i| lanes[i] == Some(commit.id)).collect(); + // Lanes that all lead to this commit first join the leftmost one. + if let [_, joining @ ..] = waiting.as_slice() + && !joining.is_empty() + { + let mut joint: Vec = vec![' '; lanes.len() * 2]; + for (i, lane) in lanes.iter().enumerate() { + if joining.contains(&i) { + joint[i * 2 - 1] = '/'; + } else if lane.is_some() { + joint[i * 2] = '|'; + } + } + for &i in joining { + lanes[i] = None; + } + rows.push(joint.into_iter().collect::().trim_end().to_owned()); + } + let col: usize = waiting.first().copied().unwrap_or_else(|| { + lanes.iter().position(Option::is_none).unwrap_or_else(|| { + lanes.push(None); + lanes.len() - 1 + }) + }); + let row: String = (0..lanes.len()) + .map(|i| { + if i == col { + "*" + } else if lanes[i].is_some() { + "|" + } else { + " " + } + }) + .collect::>() + .join(" "); + rows.push(format!("{} {}", row.trim_end(), commit.line)); + // Its first parent takes its lane; a merge's other parents open new ones. + lanes[col] = commit.parents.first().copied(); + let mut opened: Vec = Vec::new(); + for parent in commit.parents.iter().skip(1) { + if lanes.contains(&Some(*parent)) { + continue; + } + let free: usize = (col + 1..lanes.len()).find(|&i| lanes[i].is_none()).unwrap_or_else(|| { + lanes.push(None); + lanes.len() - 1 + }); + lanes[free] = Some(*parent); + opened.push(free); + } + if !opened.is_empty() { + let mut fork: Vec = vec![' '; lanes.len() * 2]; + for (i, lane) in lanes.iter().enumerate() { + if opened.contains(&i) { + fork[i * 2 - 1] = '\\'; + } else if lane.is_some() { + fork[i * 2] = '|'; + } + } + rows.push(fork.into_iter().collect::().trim_end().to_owned()); + } + while lanes.last() == Some(&None) { + lanes.pop(); + } + } + rows +} + +/// `git stash list`: `(stash@{n}, "stash@{n}: message")`, newest first. +pub fn stashes(dir: &Path) -> Vec<(String, String)> { + let Some(repo): Option = open(dir) else { return Vec::new() }; + let Ok(stash) = repo.find_reference("refs/stash") else { return Vec::new() }; + let mut platform = stash.log_iter(); + let Ok(Some(entries)) = platform.rev() else { return Vec::new() }; + entries + .flatten() + .enumerate() + .map(|(n, entry)| { + let name: String = format!("stash@{{{n}}}"); + let label: String = format!("{name}: {}", entry.message.to_str_lossy()); + (name, label) + }) + .collect() +} + +/// What `rev` (a hash, `stash@{n}`) changed against its first parent, as `git show` prints it: a header with the +/// author, date and message (`header`), a `--stat` summary (`stat`), then the patch. +pub fn show(dir: &Path, rev: &str, header: bool, stat: bool) -> Option { + let repo: Repository = open(dir)?; + let commit: GixCommit<'_> = repo.rev_parse_single(rev).ok()?.object().ok()?.peel_to_commit().ok()?; + let new: gix::Tree<'_> = commit.tree().ok()?; + let old: gix::Tree<'_> = match commit.parent_ids().next() { + Some(parent) => parent.object().ok()?.peel_to_commit().ok()?.tree().ok()?, + None => repo.empty_tree(), + }; + let changes: Vec = repo.diff_tree_to_tree(&old, &new, None).ok()?; + let mut out: String = String::new(); + if header { + let author: Option> = commit.author().ok(); + let _: std::fmt::Result = writeln!(out, "commit {}", commit.id); + if let Some(author) = author { + let date: String = + author.time().map(|t| t.format_or_unix(gix::date::time::format::DEFAULT)).unwrap_or_default(); + let _: std::fmt::Result = write!(out, "Author: {} <{}>\nDate: {date}\n", author.name, author.email); + } + out.push('\n'); + let message: String = commit.message_raw_sloppy().to_str_lossy().into_owned(); + for line in message.trim_end().lines() { + let _: std::fmt::Result = writeln!(out, " {line}"); + } + out.push('\n'); + } + let patches: Vec<(String, Patch)> = + changes.iter().filter_map(|change| Some((location(change), patch(&repo, change)?))).collect(); + if stat { + out.push_str(&stats(&patches)); + out.push('\n'); + } + for (_, p) in &patches { + out.push_str(&p.text); + } + Some(out) +} + +/// One file's part of a diff. +struct Patch { + text: String, + added: usize, + removed: usize, +} + +fn location(change: &ChangeDetached) -> String { + change.location().to_str_lossy().into_owned() +} + +/// A file change as a unified diff (no textconv or external driver: the blobs as stored); None for trees. +fn patch(repo: &Repository, change: &ChangeDetached) -> Option { + let blob = |id: ObjectId| repo.find_object(id).ok().map(|o| o.detach().data); + let path: String = location(change); + let (old, new, from, to): (Option>, Option>, String, String) = match change { + ChangeDetached::Addition { entry_mode, id, .. } if !entry_mode.is_tree() => { + (None, blob(*id), "/dev/null".into(), format!("b/{path}")) + } + ChangeDetached::Deletion { entry_mode, id, .. } if !entry_mode.is_tree() => { + (blob(*id), None, format!("a/{path}"), "/dev/null".into()) + } + ChangeDetached::Modification { entry_mode, previous_id, id, .. } if !entry_mode.is_tree() => { + (blob(*previous_id), blob(*id), format!("a/{path}"), format!("b/{path}")) + } + ChangeDetached::Rewrite { source_location, source_id, id, entry_mode, .. } if !entry_mode.is_tree() => { + (blob(*source_id), blob(*id), format!("a/{}", source_location.to_str_lossy()), format!("b/{path}")) + } + _ => return None, + }; + let (old, new): (Vec, Vec) = (old.unwrap_or_default(), new.unwrap_or_default()); + let mut text: String = format!("diff --git a/{path} b/{path}\n"); + if old.contains(&0) || new.contains(&0) { + text.push_str("Binary files differ\n"); + return Some(Patch { text, added: 0, removed: 0 }); + } + let (old, new): (String, String) = + (String::from_utf8_lossy(&old).into_owned(), String::from_utf8_lossy(&new).into_owned()); + let input: InternedInput<&str> = InternedInput::new(old.as_str(), new.as_str()); + let diff: Diff = gix::diff::blob::diff_with_slider_heuristics(Algorithm::Histogram, &input); + let (mut added, mut removed): (usize, usize) = (0, 0); + for hunk in diff.hunks() { + removed += hunk.before.len(); + added += hunk.after.len(); + } + let hunks: String = + UnifiedDiff::new(&diff, &input, Hunks(String::new()), ContextSize::symmetrical(3)).consume().ok()?; + if !hunks.is_empty() { + let _: std::fmt::Result = write!(text, "--- {from}\n+++ {to}\n{hunks}"); + } + Some(Patch { text, added, removed }) +} + +/// `--stat`: a line per file with its count of changed lines and a +/- bar, then the totals. +fn stats(patches: &[(String, Patch)]) -> String { + const BAR: usize = 50; + let width: usize = patches.iter().map(|(path, _)| path.chars().count()).max().unwrap_or(0); + let most: usize = patches.iter().map(|(_, p)| p.added + p.removed).max().unwrap_or(0).max(1); + let mut out: String = String::new(); + let (mut added, mut removed): (usize, usize) = (0, 0); + for (path, p) in patches { + let total: usize = p.added + p.removed; + let scale = |n: usize| if most > BAR { (n * BAR).div_ceil(most) } else { n }; + let bar: String = format!("{}{}", "+".repeat(scale(p.added)), "-".repeat(scale(p.removed))); + let _: std::fmt::Result = writeln!(out, " {path: io::Result<()> { + let _: std::fmt::Result = writeln!( + self.0, + "@@ -{},{} +{},{} @@", + header.before_hunk_start, header.before_hunk_len, header.after_hunk_start, header.after_hunk_len + ); + for (kind, line) in lines { + self.0.push(match kind { + DiffLineKind::Context => ' ', + DiffLineKind::Add => '+', + DiffLineKind::Remove => '-', + }); + self.0.push_str(String::from_utf8_lossy(line).trim_end_matches(['\n', '\r'])); + self.0.push('\n'); + } + Ok(()) + } + + fn finish(self) -> String { + self.0 + } +} + +/// For `go`: the `origin` remote's URL, the branch (`HEAD` when detached) and `path` in the repository. +pub fn browse(path: &Path) -> Option<(String, String, String)> { + let repo: Repository = open(parent(path))?; + let remote: String = repo.config_snapshot().string("remote.origin.url")?.to_string(); + let branch: String = repo.head_name().ok().flatten().map_or_else(|| "HEAD".to_owned(), |n| n.shorten().to_string()); + Some((remote, branch, rela_path(&repo, path)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::process::Command; + + /// A fresh repository under the temp dir, built with the git CLI (only tests use it). + fn fixture(name: &str) -> (PathBuf, impl Fn(&[&str]) -> String) { + let dir: PathBuf = std::env::temp_dir().join(format!("lim-repo-{name}-{}", std::process::id())); + let _: io::Result<()> = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let root: PathBuf = dir.clone(); + let git = move |args: &[&str]| { + let out: std::process::Output = Command::new("git") + .arg("-C") + .arg(&root) + .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]) + .args(args) + .output() + .unwrap(); + assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8(out.stdout).unwrap() + }; + git(&["init", "-q", "-b", "main"]); + (dir, git) + } + + /// Statuses, the file list, history, the graph, a commit's patch, stashes and browse read a real repository. + #[test] + fn reads_a_repository() { + let (dir, git) = fixture("read"); + fs::write(dir.join(".gitignore"), "*.log\n").unwrap(); + fs::write(dir.join("a.txt"), "one\n").unwrap(); + git(&["add", "."]); + git(&["commit", "-qm", "first"]); + fs::write(dir.join("a.txt"), "two\n").unwrap(); + fs::write(dir.join("b.txt"), "b\n").unwrap(); + git(&["add", "."]); + git(&["commit", "-qm", "second"]); + git(&["tag", "v1"]); + git(&["remote", "add", "origin", "git@github.com:me/proj.git"]); + fs::write(dir.join("a.txt"), "three\n").unwrap(); + fs::write(dir.join("d.txt"), "d\n").unwrap(); + git(&["add", "d.txt"]); + fs::write(dir.join("c.txt"), "c\n").unwrap(); + fs::write(dir.join("e.log"), "ignored\n").unwrap(); + + assert_eq!(index_text(&dir.join("a.txt")).as_deref(), Some("two\n")); + assert_eq!(index_text(&dir.join("c.txt")), None, "untracked"); + let root: PathBuf = canonical(&dir); + let codes: Vec<(String, String)> = status(&dir) + .into_iter() + .map(|(p, [x, y])| (p.strip_prefix(&root).unwrap().display().to_string(), format!("{x}{y}"))) + .collect(); + let expected: [(&str, &str); 3] = [("a.txt", " M"), ("c.txt", "??"), ("d.txt", "A ")]; + assert_eq!(codes, expected.map(|(p, c)| (p.to_owned(), c.to_owned()))); + let listed: Vec = files(&dir).unwrap(); + let names: [&str; 5] = [".gitignore", "a.txt", "b.txt", "c.txt", "d.txt"]; + assert_eq!(listed, names.map(PathBuf::from), "tracked and untracked, not ignored"); + + let head: Vec = log(&dir, false, None, 10).unwrap(); + assert_eq!(head.len(), 2); + assert!(head[0].line.ends_with(" second") && head[1].line.ends_with(" first"), "{}", head[0].line); + let of_b: Vec = log(&dir, false, Some(&dir.join("b.txt")), 10).unwrap(); + assert_eq!(of_b.iter().map(|c| c.id).collect::>(), [head[0].id]); + let all: Vec = log(&dir, true, None, 10).unwrap(); + assert!(all[0].line.contains(" (HEAD -> main, tag: v1) second"), "{}", all[0].line); + let rows: Vec = graph(&all); + assert!(rows.len() == 2 && rows.iter().all(|r| r.starts_with("* ")), "{rows:?}"); + + let shown: String = show(&dir, &head[0].hash(), true, true).unwrap(); + for part in [ + &format!("commit {}\n", head[0].id), + "Author: t \n", + "\n second\n", + " a.txt | 2 +-\n", + " 2 files changed, 2 insertions(+), 1 deletion(-)\n", + "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1,1 +1,1 @@\n-one\n+two\n", + "--- /dev/null\n+++ b/b.txt\n", + ] { + assert!(shown.contains(part), "{part:?} missing from:\n{shown}"); + } + + git(&["stash", "-q"]); + let stashed: Vec<(String, String)> = stashes(&dir); + assert_eq!(stashed.len(), 1); + assert!(stashed[0].1.starts_with("stash@{0}: WIP on main"), "{:?}", stashed[0]); + assert!(show(&dir, &stashed[0].0, false, false).unwrap().contains("-two\n+three\n")); + + let (remote, branch, file): (String, String, String) = browse(&dir.join("a.txt")).unwrap(); + assert_eq!((remote.as_str(), branch.as_str(), file.as_str()), ("git@github.com:me/proj.git", "main", "a.txt")); + let _: io::Result<()> = fs::remove_dir_all(&dir); + } + + /// A merge opens a lane for its second parent, which joins back where the branches met. + #[test] + fn graph_draws_merges() { + let id = |n: u8| ObjectId::from_hex(format!("{n:040x}").as_bytes()).unwrap(); + let commit = |n: u8, parents: &[u8], line: &str| Commit { + id: id(n), + parents: parents.iter().map(|&p| id(p)).collect(), + line: line.to_owned(), + }; + let commits: [Commit; 3] = [commit(3, &[1, 2], "merge"), commit(2, &[1], "topic"), commit(1, &[], "root")]; + assert_eq!(graph(&commits), ["* merge", "|\\", "| * topic", "|/", "* root"]); + } + + /// Filter and diff drivers a repository's own config names never run, though the attributes select them. + #[cfg(unix)] + #[test] + fn repository_programs_never_run() { + use std::os::unix::fs::PermissionsExt; + let (dir, git) = fixture("drivers"); + let marker: PathBuf = dir.join(".git/marker"); + let hook: PathBuf = dir.join(".git/hook.sh"); + fs::write(&hook, format!("#!/bin/sh\necho ran >> '{}'\ncat\n", marker.display())).unwrap(); + fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap(); + fs::write(dir.join(".gitattributes"), "*.txt filter=evil diff=evil\n").unwrap(); + for text in ["one\n", "two\n"] { + fs::write(dir.join("a.txt"), text).unwrap(); + git(&["add", "."]); + git(&["commit", "-qm", text]); + } + let hook: String = hook.display().to_string(); + for key in ["core.fsmonitor", "filter.evil.clean", "filter.evil.smudge", "diff.evil.textconv"] { + git(&["config", key, &hook]); + } + // Same size, newer mtime: status has to compare the content, through the clean filter. + fs::write(dir.join("a.txt"), "TWO\n").unwrap(); + + assert_eq!(status(&dir).len(), 1); + assert!(files(&dir).is_some()); + assert_eq!(index_text(&dir.join("a.txt")).as_deref(), Some("two\n")); + let head: Vec = log(&dir, true, Some(&dir.join("a.txt")), 10).unwrap(); + assert!(show(&dir, &head[0].hash(), true, true).unwrap().contains("-one\n+two\n")); + assert!(!marker.exists(), "a repository program ran"); + git(&["status", "--porcelain"]); + assert!(marker.exists(), "control: git itself runs it"); + let _: io::Result<()> = fs::remove_dir_all(&dir); + } +} diff --git a/src/syntax/lang.rs b/src/syntax/lang.rs index 24c9cb2..5377435 100644 --- a/src/syntax/lang.rs +++ b/src/syntax/lang.rs @@ -1,4 +1,5 @@ -//! Per-language word lists, comment leader, quotes and devicon. +//! Per-language facts the editor needs beyond colors (syntect's grammars do those): comment leaders, quotes, +//! the kind of file and its devicon. use std::path::Path; @@ -7,15 +8,15 @@ use super::Hl; const BLOCK_COMMENT: (&str, &str, Hl) = ("/*", "*/", Hl::Comment); const HTML_COMMENT: (&str, &str, Hl) = ("", Hl::Comment); -/// Extra rules beyond the word lists, for languages that are not code. +/// What kind of text a language is. #[derive(Clone, Copy, PartialEq, Eq)] pub enum Kind { Code, - /// `key:` / `key =` (and `"key":`) are properties, `[section]` a type. + /// Settings: `key: value`, `key = value`, `[section]`. Config, - /// `", Hl::Comment)], - keywords: &[ - "begin", "break", "catch", "class", "continue", "do", "else", "elseif", "end", "enum", "exit", "filter", - "finally", "for", "foreach", "function", "if", "in", "param", "process", "return", "switch", "throw", "trap", - "try", "until", "using", "while", "and", "or", "not", "eq", "ne", "gt", "lt", "ge", "le", "like", "match", - ], - builtins: &["string", "int", "bool", "array", "hashtable", "object"], - ignore_case: true, - ..BASE -}; +const POWERSHELL: Lang = + Lang { icon: '\u{f0a0a}', icon_color: 0x4273ca, comment: "#", regions: &[("<#", "#>", Hl::Comment)], ..BASE }; -const BATCH: Lang = Lang { - icon: '\u{e629}', - icon_color: 0xc1f12e, - comment: "::", - quotes: "\"", - regions: &[], - keywords: &[ - "call", - "cd", - "cls", - "copy", - "defined", - "del", - "do", - "echo", - "else", - "endlocal", - "equ", - "errorlevel", - "exist", - "exit", - "for", - "geq", - "goto", - "gtr", - "if", - "in", - "leq", - "lss", - "mkdir", - "neq", - "not", - "off", - "on", - "pause", - "popd", - "pushd", - "rem", - "set", - "setlocal", - "shift", - "start", - ], - consts: &[], - ignore_case: true, - ..BASE -}; +const BATCH: Lang = Lang { icon: '\u{e629}', icon_color: 0xc1f12e, comment: "::", quotes: "\"", regions: &[], ..BASE }; -const MAKE: Lang = Lang { - icon: '\u{e779}', - icon_color: 0x6d8086, - comment: "#", - regions: &[], - keywords: &[ - "define", "endef", "else", "endif", "export", "ifdef", "ifeq", "ifndef", "ifneq", "include", "override", - "unexport", "vpath", - ], - builtins: &[ - "shell", - "wildcard", - "patsubst", - "subst", - "foreach", - "call", - "eval", - "filter", - "notdir", - "basename", - "dir", - "addprefix", - "addsuffix", - ], - ..BASE -}; +const MAKE: Lang = Lang { icon: '\u{e779}', icon_color: 0x6d8086, comment: "#", regions: &[], ..BASE }; -const DOCKER: Lang = Lang { - icon: '\u{f0868}', - icon_color: 0x458ee6, - comment: "#", - regions: &[], - keywords: &[ - "ADD", - "ARG", - "AS", - "CMD", - "COPY", - "ENTRYPOINT", - "ENV", - "EXPOSE", - "FROM", - "HEALTHCHECK", - "LABEL", - "MAINTAINER", - "ONBUILD", - "RUN", - "SHELL", - "STOPSIGNAL", - "USER", - "VOLUME", - "WORKDIR", - ], - ..BASE -}; +const DOCKER: Lang = Lang { icon: '\u{f0868}', icon_color: 0x458ee6, comment: "#", regions: &[], ..BASE }; -const CMAKE: Lang = Lang { - icon: '\u{e615}', - icon_color: 0x6d8086, - comment: "#", - quotes: "\"", - regions: &[], - keywords: &[ - "if", - "elseif", - "else", - "endif", - "foreach", - "endforeach", - "while", - "endwhile", - "function", - "endfunction", - "macro", - "endmacro", - "return", - "set", - "unset", - "option", - "project", - "add_executable", - "add_library", - "add_subdirectory", - "target_link_libraries", - "target_include_directories", - "target_compile_options", - "target_compile_definitions", - "find_package", - "include", - "install", - "message", - "cmake_minimum_required", - "list", - "string", - "file", - ], - consts: &["on", "off", "true", "false", "yes", "no"], - ignore_case: true, - ..BASE -}; +const CMAKE: Lang = Lang { icon: '\u{e615}', icon_color: 0x6d8086, comment: "#", quotes: "\"", regions: &[], ..BASE }; -const CONFIG: Lang = Lang { - icon: '\u{e615}', - icon_color: 0x6d8086, - comment: "#", - regions: &[], - consts: &["true", "false", "null", "yes", "no"], - kind: Kind::Config, - ..BASE -}; +const CONFIG: Lang = + Lang { icon: '\u{e615}', icon_color: 0x6d8086, comment: "#", regions: &[], kind: Kind::Config, ..BASE }; -const JSON: Lang = Lang { - icon: '\u{e60b}', - icon_color: 0xcbcb41, - comment: "//", - quotes: "\"", - regions: &[BLOCK_COMMENT], - consts: &["true", "false", "null"], - ..CONFIG -}; +const JSON: Lang = + Lang { icon: '\u{e60b}', icon_color: 0xcbcb41, comment: "//", quotes: "\"", regions: &[BLOCK_COMMENT], ..CONFIG }; -const ZIG: Lang = Lang { - icon: '\u{e6a9}', - icon_color: 0xf69a1b, - regions: &[], - keywords: &[ - "align", - "allowzero", - "and", - "anyframe", - "anytype", - "asm", - "break", - "catch", - "comptime", - "const", - "continue", - "defer", - "else", - "enum", - "errdefer", - "error", - "export", - "extern", - "fn", - "for", - "if", - "inline", - "noalias", - "noinline", - "nosuspend", - "opaque", - "or", - "orelse", - "packed", - "pub", - "resume", - "return", - "struct", - "suspend", - "switch", - "test", - "threadlocal", - "try", - "union", - "unreachable", - "usingnamespace", - "var", - "volatile", - "while", - ], - builtins: &[ - "u8", - "u16", - "u32", - "u64", - "usize", - "i8", - "i16", - "i32", - "i64", - "isize", - "f32", - "f64", - "bool", - "void", - "type", - "anyerror", - "noreturn", - "comptime_int", - ], - consts: &["true", "false", "null", "undefined"], - ..BASE -}; +const ZIG: Lang = Lang { icon: '\u{e6a9}', icon_color: 0xf69a1b, regions: &[], ..BASE }; const HASKELL: Lang = Lang { icon: '\u{e61f}', @@ -1234,123 +221,18 @@ const HASKELL: Lang = Lang { comment: "--", quotes: "\"", regions: &[("{-", "-}", Hl::Comment)], - keywords: &[ - "as", - "case", - "class", - "data", - "deriving", - "do", - "else", - "forall", - "hiding", - "if", - "import", - "in", - "infix", - "infixl", - "infixr", - "instance", - "let", - "module", - "newtype", - "of", - "qualified", - "then", - "type", - "where", - ], - consts: &["True", "False", "Nothing", "Just"], ..BASE }; -const ELIXIR: Lang = Lang { - icon: '\u{e62d}', - icon_color: 0xa074c4, - comment: "#", - regions: &[("\"\"\"", "\"\"\"", Hl::Str)], - keywords: &[ - "after", - "alias", - "and", - "catch", - "cond", - "def", - "defimpl", - "defmacro", - "defmodule", - "defp", - "defprotocol", - "defstruct", - "do", - "else", - "end", - "fn", - "for", - "if", - "import", - "in", - "not", - "or", - "quote", - "raise", - "receive", - "require", - "rescue", - "try", - "unless", - "unquote", - "use", - "when", - "with", - ], - consts: &["true", "false", "nil"], - ..BASE -}; +const ELIXIR: Lang = + Lang { icon: '\u{e62d}', icon_color: 0xa074c4, comment: "#", regions: &[("\"\"\"", "\"\"\"", Hl::Str)], ..BASE }; -const R: Lang = Lang { - icon: '\u{f07d4}', - icon_color: 0x2266ba, - comment: "#", - regions: &[], - keywords: &["break", "else", "for", "function", "if", "in", "next", "repeat", "return", "while"], - builtins: &["library", "require", "c", "list", "print"], - consts: &["TRUE", "FALSE", "NULL", "NA", "Inf", "NaN"], - ..BASE -}; +const R: Lang = Lang { icon: '\u{f07d4}', icon_color: 0x2266ba, comment: "#", regions: &[], ..BASE }; -const PERL: Lang = Lang { - icon: '\u{e769}', - icon_color: 0x519aba, - comment: "#", - regions: &[("=pod", "=cut", Hl::Comment)], - keywords: &[ - "my", "our", "local", "sub", "if", "elsif", "else", "unless", "while", "until", "for", "foreach", "last", - "next", "redo", "return", "use", "require", "package", "do", "eval", "and", "or", "not", - ], - builtins: &["print", "printf", "die", "shift", "push", "pop"], - consts: &["undef"], - ..BASE -}; +const PERL: Lang = + Lang { icon: '\u{e769}', icon_color: 0x519aba, comment: "#", regions: &[("=pod", "=cut", Hl::Comment)], ..BASE }; -const ASM: Lang = Lang { - icon: '\u{e637}', - icon_color: 0x0091bd, - comment: ";", - regions: &[], - keywords: &[ - "mov", "push", "pop", "call", "ret", "jmp", "je", "jne", "jz", "jnz", "cmp", "add", "sub", "mul", "div", "inc", - "dec", "lea", "and", "or", "xor", "not", "shl", "shr", "int", "nop", "syscall", "section", "global", "extern", - "db", "dw", "dd", "dq", - ], - builtins: &[ - "eax", "ebx", "ecx", "edx", "esi", "edi", "esp", "ebp", "rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rsp", "rbp", - "r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", - ], - consts: &[], - ignore_case: true, - ..BASE -}; +const ASM: Lang = Lang { icon: '\u{e637}', icon_color: 0x0091bd, comment: ";", regions: &[], ..BASE }; const NIX: Lang = Lang { icon: '\u{f313}', @@ -1358,32 +240,11 @@ const NIX: Lang = Lang { comment: "#", quotes: "\"", regions: &[BLOCK_COMMENT, ("''", "''", Hl::Str)], - keywords: &["assert", "else", "if", "in", "inherit", "let", "or", "rec", "then", "with", "import"], - builtins: &["builtins", "pkgs", "lib"], ..BASE }; -const TERRAFORM: Lang = Lang { - icon: '\u{f1062}', - icon_color: 0x5f43e9, - comment: "#", - quotes: "\"", - keywords: &[ - "resource", - "data", - "variable", - "output", - "module", - "provider", - "locals", - "terraform", - "for", - "in", - "if", - ], - kind: Kind::Config, - ..BASE -}; +const TERRAFORM: Lang = + Lang { icon: '\u{f1062}', icon_color: 0x5f43e9, comment: "#", quotes: "\"", kind: Kind::Config, ..BASE }; #[cfg(test)] mod tests { diff --git a/src/syntax/mod.rs b/src/syntax/mod.rs index 59c60ca..4495187 100644 --- a/src/syntax/mod.rs +++ b/src/syntax/mod.rs @@ -1,9 +1,14 @@ -//! Keyword-based highlighting standing in for treesitter, with multi-line regions -//! (block comments, long strings) carried from line to line. +//! Syntax highlighting through syntect with bat's grammars (two-face). Each line's start state is cached, so an +//! edit re-parses from the edited line on; scopes map to lim's highlight groups, so the themes stay as they are. mod lang; mod todo; +use std::path::Path; +use std::sync::OnceLock; + +use syntect::parsing::{ParseState, Scope, ScopeError, ScopeStack, ScopeStackOp, SyntaxReference, SyntaxSet}; + pub use lang::Kind; pub use lang::{Lang, lang_for}; pub use todo::{Todo, todo}; @@ -24,369 +29,257 @@ pub enum Hl { Property, } -/// Highlighter state at a line boundary: inside which of `Lang::regions`, if any. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct State { - region: Option, +/// A line further than this past the parsed ones restarts the parse this many lines above it, as Vim's `syntax +/// sync minlines` does, so a jump into a huge file stays fast. +/// shortcut: a comment or string opened more than `SYNC` lines above a far jump is missed until scrolled through. +const SYNC: usize = 300; +/// Longer lines are drawn plain and not parsed (the state carries over), as editors do, so they stay fast. +const MAX_LINE: usize = 20_000; + +/// Extensions bat has no grammar for, and the one that fits them. +const ALIASES: &[(&str, &str)] = &[ + ("h", "c"), + ("ino", "cpp"), + ("cu", "cpp"), + ("jsx", "js"), + ("mjs", "js"), + ("cjs", "js"), + ("mts", "ts"), + ("cts", "ts"), + ("astro", "html"), + ("plist", "xml"), + ("mdx", "md"), + ("jsonc", "json"), + ("json5", "json"), + ("s", "asm"), + ("conf", "ini"), +]; + +/// Scope prefixes and their group, the first matching anywhere in the scope stack winning: a JSON key is a +/// string inside `meta.mapping.key`, and must read as a property. +const RULES: &[(&str, Hl)] = &[ + ("comment", Hl::Comment), + ("meta.mapping.key", Hl::Property), + ("support.type.property-name", Hl::Property), + ("entity.name.tag.yaml", Hl::Property), + ("entity.other.attribute-name", Hl::Property), + ("variable.other.member", Hl::Property), + ("variable.other.property", Hl::Property), + ("string", Hl::Str), + ("markup.raw", Hl::Str), + ("markup.heading", Hl::Keyword), + ("constant.numeric", Hl::Num), + ("constant", Hl::Const), + ("support.constant", Hl::Const), + ("meta.preprocessor", Hl::Macro), + ("support.macro", Hl::Macro), + ("entity.name.function.macro", Hl::Macro), + ("entity.name.function", Hl::Func), + ("support.function", Hl::Func), + ("variable.function", Hl::Func), + ("entity.name.type", Hl::Type), + ("entity.name.class", Hl::Type), + ("entity.name.struct", Hl::Type), + ("entity.name.enum", Hl::Type), + ("entity.name.section", Hl::Type), + ("support.type", Hl::Type), + ("support.class", Hl::Type), + ("variable.language", Hl::Builtin), + ("support.variable", Hl::Builtin), + // Operators stay plain, as before; `storage` (Rust's `fn`, `let`, C's `int`) reads as a keyword. + ("keyword.operator", Hl::Text), + ("keyword", Hl::Keyword), + ("storage", Hl::Keyword), + ("entity.name.tag", Hl::Keyword), +]; + +fn syntaxes() -> &'static SyntaxSet { + static SET: OnceLock = OnceLock::new(); + SET.get_or_init(two_face::syntax::extra_newlines) +} + +fn rules() -> &'static [(Scope, Hl)] { + static PARSED: OnceLock> = OnceLock::new(); + PARSED.get_or_init(|| RULES.iter().filter_map(|&(name, hl)| Some((Scope::new(name).ok()?, hl))).collect()) +} + +/// The grammar of a file: by its name (`Makefile`, `Dockerfile`, `.gitignore`), else its extension. +pub fn grammar_for(path: &Path) -> Option<&'static SyntaxReference> { + let set: &SyntaxSet = syntaxes(); + let name: &str = path.file_name()?.to_str()?; + if let Some(grammar) = set.find_syntax_by_extension(name) { + return Some(grammar); + } + let ext: String = path.extension()?.to_str()?.to_ascii_lowercase(); + let ext: &str = ALIASES.iter().find(|(from, _)| *from == ext).map_or(ext.as_str(), |(_, to)| to); + set.find_syntax_by_extension(ext) } -/// A jump further than this past the window of known states moves the window instead of growing it. -const SYNC: usize = 2000; -/// Lines between the saved start states a far jump scans from. -const CHECKPOINT: usize = 4096; +/// The group of the text under `stack`. +fn group(stack: &ScopeStack) -> Hl { + let scopes: &[Scope] = stack.as_slice(); + rules().iter().find(|(prefix, _)| scopes.iter().any(|s| prefix.is_prefix_of(*s))).map_or(Hl::Text, |&(_, hl)| hl) +} + +/// The parser and scope stack at a line start. +type State = (ParseState, ScopeStack); + +/// Parses `line` from `state`, leaving it at the next line's start; with `out`, pushes one group per char for the +/// first `limit` chars. +fn parse(line: &str, state: &mut State, out: Option<(&mut Vec, usize)>) { + let ops: Vec<(usize, ScopeStackOp)> = if line.len() > MAX_LINE { + Vec::new() + } else { + state.0.parse_line(&format!("{line}\n"), syntaxes()).unwrap_or_default() + }; + let stack: &mut ScopeStack = &mut state.1; + let mut ops = ops.iter().peekable(); + if let Some((groups, limit)) = out { + let mut current: Hl = group(stack); + for (byte, _) in line.char_indices().take(limit) { + let mut moved: bool = false; + while let Some((_, op)) = ops.next_if(|(at, _)| *at <= byte) { + let _: Result<(), ScopeError> = stack.apply(op); + moved = true; + } + if moved { + current = group(stack); + } + groups.push(current); + } + } + for (_, op) in ops { + let _: Result<(), ScopeError> = stack.apply(op); + } +} -/// Start states of a window of lines (from `base` on), and of every `CHECKPOINT`-th line scanned so far; edits -/// truncate both. States between are found with `end_state`, which only tracks regions, so a jump to the end of -/// a 3M-line file stays exact (a comment opened on line 1 still colors it) and fast. -#[derive(Debug, Default)] +/// Start states of a window of lines, from `base` on, and the groups of the lines drawn (with the char limit +/// they were drawn to), so each line is parsed once until an edit truncates both. +#[derive(Default)] pub struct Cache { base: usize, starts: Vec, - checkpoints: Vec, + groups: Vec)>>, +} + +impl std::fmt::Debug for Cache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Cache {{ base: {}, states: {} }}", self.base, self.starts.len()) + } } impl Cache { - /// Forgets states after `line`: its own start state only depends on earlier lines. + /// Forgets what follows from `line` on: its own start state only depends on earlier lines. pub fn invalidate(&mut self, line: usize) { if line < self.base { - (self.base, self.starts) = (0, Vec::new()); + (self.base, self.starts, self.groups) = (0, Vec::new(), Vec::new()); } else { self.starts.truncate(line - self.base + 1); + self.groups.truncate(line - self.base); } - self.checkpoints.truncate(line / CHECKPOINT + 1); } /// Groups for the first `limit` chars of line `y` (a long line is drawn only in part). - pub fn line(&mut self, lines: &[String], lang: &Lang, y: usize, limit: usize) -> Vec { + pub fn line(&mut self, lines: &[String], grammar: &SyntaxReference, y: usize, limit: usize) -> Vec { let known: usize = self.base + self.starts.len().saturating_sub(1); if self.starts.is_empty() || y < self.base || y > known + SYNC { - let base: usize = y.saturating_sub(SYNC); - let start: State = self.state_at(lines, lang, base); - (self.base, self.starts) = (base, vec![start]); + self.base = y.saturating_sub(SYNC); + (self.starts, self.groups) = (vec![(ParseState::new(grammar), ScopeStack::new())], Vec::new()); } - let firsts: Firsts = firsts(lang); while self.base + self.starts.len() <= y { let i: usize = self.starts.len() - 1; - let end: State = end_state(lines[self.base + i].as_bytes(), lang, &firsts, self.starts[i]); - self.starts.push(end); + let mut next: State = self.starts[i].clone(); + parse(&lines[self.base + i], &mut next, None); + self.starts.push(next); } - let chars: Vec = lines[y].chars().take(limit).collect(); - highlight(&chars, lang, self.starts[y - self.base]).0 - } - - /// The start state of line `y`, scanned from the nearest state known above it. - fn state_at(&mut self, lines: &[String], lang: &Lang, y: usize) -> State { - if self.checkpoints.is_empty() { - self.checkpoints.push(State::default()); + let i: usize = y - self.base; + if let Some(Some((drawn, groups))) = self.groups.get(i) + && *drawn == limit + { + return groups.clone(); } - let n: usize = (y / CHECKPOINT).min(self.checkpoints.len() - 1); - let (mut line, mut state): (usize, State) = (n * CHECKPOINT, self.checkpoints[n]); - let known: usize = self.base + self.starts.len().saturating_sub(1); - if !self.starts.is_empty() && (line..=y).contains(&known) { - (line, state) = (known, self.starts[known - self.base]); + let mut state: State = self.starts[i].clone(); + let mut groups: Vec = Vec::new(); + parse(&lines[y], &mut state, Some((&mut groups, limit))); + // Drawing the last known line also gives the next line's start state. + if self.starts.len() == i + 1 { + self.starts.push(state); } - let firsts: Firsts = firsts(lang); - while line < y { - state = end_state(lines[line].as_bytes(), lang, &firsts, state); - line += 1; - if line.is_multiple_of(CHECKPOINT) && line / CHECKPOINT == self.checkpoints.len() { - self.checkpoints.push(state); - } + if self.groups.len() <= i { + self.groups.resize(i + 1, None); } - state + self.groups[i] = Some((limit, groups.clone())); + groups } } -/// `end_state` for the fuzzer, which checks it against `highlight`. -#[cfg(test)] -pub fn fast_end_state(line: &str, lang: &Lang, state: State) -> State { - end_state(line.as_bytes(), lang, &firsts(lang), state) -} - -/// Which bytes can start a region, a comment or a quote of a language. -type Firsts = [bool; 256]; - -fn firsts(lang: &Lang) -> Firsts { - let mut firsts: Firsts = [false; 256]; - let openers = lang.regions.iter().map(|(open, ..)| *open).chain([lang.comment]).filter_map(|p| p.bytes().next()); - for b in openers.chain(lang.quotes.bytes()) { - firsts[usize::from(b)] = true; - } - firsts -} - -/// The state after `line`, like `highlight(..).1` without coloring anything. Every region, comment and quote -/// delimiter is ASCII and none starts with a word char, so bytes can be skipped up to the next possible one. -fn end_state(line: &[u8], lang: &Lang, firsts: &Firsts, state: State) -> State { - if lang.kind == Kind::Markdown && state.region.is_none() && line.first() == Some(&b'#') { - return state; - } - let at = |i: usize, pat: &str| line[i..].starts_with(pat.as_bytes()); - let close = |from: usize, close: &str, escapes: bool| { - let mut i: usize = from; - while i < line.len() { - if escapes && line[i] == b'\\' { - i += 2; - } else if at(i, close) { - return Some(i + close.len()); - } else { - i += 1; - } - } - None - }; - let mut region: Option = state.region; - let mut i: usize = 0; - loop { - if let Some(r) = region { - let (_, closer, group): (&str, &str, Hl) = lang.regions[r]; - let Some(end): Option = close(i, closer, group == Hl::Str) else { return State { region } }; - (i, region) = (end, None); - continue; - } - let Some(skip): Option = - line.get(i..).and_then(|rest| rest.iter().position(|&b| firsts[usize::from(b)])) - else { - return State::default(); - }; - i += skip; - if let Some(r) = lang.regions.iter().position(|(open, ..)| at(i, open)) { - (i, region) = (i + lang.regions[r].0.len(), Some(r)); - } else if !lang.comment.is_empty() && at(i, lang.comment) { - return State::default(); - } else if lang.quotes.as_bytes().contains(&line[i]) { - i = close(i + 1, &char::from(line[i]).to_string(), true).unwrap_or(line.len()); - } else { - i += 1; - } - } -} - -/// True when `pat` (ASCII) occurs in `line` at `i`. -fn at(line: &[char], i: usize, pat: &str) -> bool { - pat.chars().enumerate().all(|(k, c)| line.get(i + k) == Some(&c)) -} - -/// Index just past the region closer, honoring backslash escapes in strings. -fn close_of(line: &[char], from: usize, close: &str, escapes: bool) -> Option { - let mut i: usize = from; - while i < line.len() { - if escapes && line[i] == '\\' { - i += 2; - } else if at(line, i, close) { - return Some(i + close.len()); - } else { - i += 1; - } - } - None -} - -/// One group per char of `line`, and the state the next line starts in. -pub fn highlight(line: &[char], lang: &Lang, state: State) -> (Vec, State) { - if lang.kind == Kind::Markdown && state.region.is_none() && line.first() == Some(&'#') { - return (vec![Hl::Keyword; line.len()], state); - } - let mut hl: Vec = vec![Hl::Text; line.len()]; - let mut region: Option = state.region; - let mut i: usize = 0; - while i < line.len() || region.is_some() { - if let Some(r) = region { - let (_, close, group): (&str, &str, Hl) = lang.regions[r]; - if let Some(end) = close_of(line, i, close, group == Hl::Str) { - hl[i..end].fill(group); - i = end; - region = None; - } else { - hl[i..].fill(group); - return (hl, State { region }); - } - continue; - } - if let Some(r) = lang.regions.iter().position(|(open, ..)| at(line, i, open)) { - let (open, _, group): (&str, &str, Hl) = lang.regions[r]; - hl[i..i + open.len()].fill(group); - i += open.len(); - region = Some(r); - continue; - } - let c: char = line[i]; - let start: usize = i; - if !lang.comment.is_empty() && at(line, i, lang.comment) { - hl[i..].fill(Hl::Comment); - break; - } else if lang.quotes.contains(c) { - i = close_of(line, i + 1, &c.to_string(), true).unwrap_or(line.len()); - let key: bool = lang.kind == Kind::Config && line[i..].iter().find(|c| **c != ' ') == Some(&':'); - hl[start..i].fill(if key { Hl::Property } else { Hl::Str }); - } else if c.is_ascii_digit() { - while i < line.len() && (line[i].is_alphanumeric() || line[i] == '_' || line[i] == '.') { - i += 1; - } - hl[start..i].fill(Hl::Num); - } else if c.is_alphabetic() || c == '_' { - while i < line.len() && (line[i].is_alphanumeric() || line[i] == '_') { - i += 1; - } - let word: String = line[start..i].iter().collect(); - let next: Option = line[i..].iter().find(|c| **c != ' ').copied(); - hl[start..i].fill(classify(&word, next, &line[..start], lang)); - } else { - i += 1; - } - } - (hl, State::default()) -} - -/// The group of `word`, given the first non-blank char after it and the text before it. -fn classify(word: &str, next: Option, before: &[char], lang: &Lang) -> Hl { - let prev: Option = before.last().copied(); - match lang.kind { - Kind::Markup if prev == Some('<') || before.ends_with(&['<', '/']) => return Hl::Keyword, - Kind::Markup if next == Some('=') => return Hl::Property, - Kind::Config if matches!(next, Some(':' | '=')) => return Hl::Property, - Kind::Config if prev == Some('[') => return Hl::Type, - _ => {} - } - let key: String = if lang.ignore_case { word.to_ascii_lowercase() } else { word.to_owned() }; - let key: &str = &key; - if lang.keywords.contains(&key) { - Hl::Keyword - } else if lang.consts.contains(&key) { - Hl::Const - } else if lang.builtins.contains(&key) { - Hl::Builtin - } else if next == Some('!') && lang.macros { - Hl::Macro - } else if next == Some('(') { - Hl::Func - } else if word.starts_with(char::is_uppercase) { - if word.chars().any(char::is_lowercase) { Hl::Type } else { Hl::Const } - } else if prev == Some('.') { - Hl::Property - } else { - Hl::Text - } +/// Loads the grammars on a background thread, so the first file opened does not wait for them. +pub fn preload() { + std::thread::spawn(syntaxes); } #[cfg(test)] mod tests { use super::*; - use std::path::Path; - fn lang(file: &str) -> &'static Lang { - lang_for(Path::new(file)).unwrap() - } - - fn hl(s: &str, file: &str) -> Vec { - highlight(&s.chars().collect::>(), lang(file), State::default()).0 + /// Groups of each line of `text` in the file type of `file`. + fn hl(text: &str, file: &str) -> Vec> { + let lines: Vec = text.lines().map(str::to_owned).collect(); + let grammar: &SyntaxReference = grammar_for(Path::new(file)).unwrap(); + let mut cache: Cache = Cache::default(); + (0..lines.len()).map(|y| cache.line(&lines, grammar, y, usize::MAX)).collect() } #[test] fn tokens() { - let h: Vec = hl(r#"fn main() { let s = "a\"b"; // hi"#, "x.rs"); - assert_eq!(h[0], Hl::Keyword); - assert_eq!(h[3], Hl::Func); - assert_eq!(h[20], Hl::Str); - assert_eq!(h[25], Hl::Str); // escaped quote stays inside the string - assert_eq!(h[27], Hl::Text); + let h: Vec = hl(r#"fn main() { let s = "a\"b"; // hi"#, "x.rs").remove(0); + assert_eq!(h[0], Hl::Keyword, "fn"); + assert_eq!(h[3], Hl::Func, "main"); + assert_eq!(h[12], Hl::Keyword, "let"); + assert_eq!((h[20], h[23]), (Hl::Str, Hl::Str), "the escaped quote stays inside the string"); assert_eq!(h.last(), Some(&Hl::Comment)); - let h: Vec = hl("x = 42 -- c", "a.lua"); + let h: Vec = hl("x = 42 -- c", "a.lua").remove(0); assert_eq!((h[4], h[8]), (Hl::Num, Hl::Comment)); } #[test] fn multi_line_regions() { - let lines: Vec = - ["let a = 1; /* start", "still comment", "end */ let b", "--[[ lua"].map(String::from).into(); - let rust: &Lang = lang("x.rs"); + let text: &str = "let a = 1; /* start\nstill comment\nend */ let b"; + let lines: Vec> = hl(text, "x.rs"); + assert!(lines[1].iter().all(|h| *h == Hl::Comment)); + assert_eq!((lines[2][5], lines[2][7]), (Hl::Comment, Hl::Keyword)); + let mut source: Vec = text.lines().map(str::to_owned).collect(); + let grammar: &SyntaxReference = grammar_for(Path::new("x.rs")).unwrap(); let mut cache: Cache = Cache::default(); - assert!(cache.line(&lines, rust, 1, usize::MAX).iter().all(|h| *h == Hl::Comment)); - let third: Vec = cache.line(&lines, rust, 2, usize::MAX); - assert_eq!((third[5], third[7]), (Hl::Comment, Hl::Keyword)); + assert_eq!(cache.line(&source, grammar, 2, usize::MAX)[0], Hl::Comment); + source[0] = "let a = 1;".into(); cache.invalidate(0); - assert_eq!(cache.line(&lines, rust, 2, usize::MAX)[7], Hl::Keyword); - let lua: &Lang = lang("x.lua"); - assert_ne!(highlight(&lines[3].chars().collect::>(), lua, State::default()).1, State::default()); + assert_eq!(cache.line(&source, grammar, 1, usize::MAX)[0], Hl::Text, "the comment is gone once re-parsed"); } - /// A far jump keeps only a window of states, yet stays exact: a comment opened on line 1 still colors it. + /// A far jump parses only `SYNC` lines above it; a huge line is drawn plain without being parsed. #[test] - fn far_lines_stay_exact() { - let mut lines: Vec = vec!["/* open".into()]; - lines.extend((0..3 * CHECKPOINT).map(|_| "let x".to_owned())); - let rust: &Lang = lang("x.rs"); - let mut cache: Cache = Cache::default(); - let far: usize = 3 * CHECKPOINT; - assert_eq!(cache.line(&lines, rust, far, usize::MAX)[0], Hl::Comment); - assert_eq!((cache.base, cache.starts.len(), cache.checkpoints.len()), (far - SYNC, SYNC + 1, 3)); - lines[0] = "// closed".into(); - cache.invalidate(0); - assert_eq!(cache.checkpoints.len(), 1, "checkpoints below the edit are dropped"); - assert_eq!(cache.line(&lines, rust, far, usize::MAX)[0], Hl::Keyword); - lines[2 * CHECKPOINT + 5] = "/*".into(); - cache.invalidate(2 * CHECKPOINT + 5); - assert_eq!(cache.line(&lines, rust, 5, usize::MAX)[0], Hl::Keyword, "back to the top, from a checkpoint"); - assert_eq!(cache.line(&lines, rust, far, usize::MAX)[0], Hl::Comment); - lines[0] = "/* open".into(); - lines[2 * CHECKPOINT + 5] = "let x".into(); + fn far_jumps_and_huge_lines_stay_bounded() { + let mut lines: Vec = (0..10 * SYNC).map(|_| "let x = 1;".to_owned()).collect(); + lines.push("x".repeat(MAX_LINE + 1)); + let grammar: &SyntaxReference = grammar_for(Path::new("x.rs")).unwrap(); let mut cache: Cache = Cache::default(); - assert_eq!(cache.line(&lines, rust, SYNC + 500, usize::MAX)[0], Hl::Comment); - assert_eq!(cache.line(&lines, rust, 2 * SYNC + 1000, usize::MAX)[0], Hl::Comment, "from the window"); - assert_eq!(cache.checkpoints.len(), 1); + assert_eq!(cache.line(&lines, grammar, 9 * SYNC, usize::MAX)[0], Hl::Keyword); + assert_eq!((cache.base, cache.starts.len()), (8 * SYNC, SYNC + 2), "and the next line's start state"); + assert!(cache.line(&lines, grammar, 10 * SYNC, 100).iter().all(|h| *h == Hl::Text)); } - /// `end_state` agrees with the full highlighter on every kind of delimiter. #[test] - fn end_state_matches_highlight() { - let files: [&str; 16] = [ - "x.rs", "x.lua", "x.py", "x.rb", "x.pl", "x.ps1", "x.md", "x.hs", "x.js", "x.c", "x.html", "x.go", "x.sql", - "x.sh", "x.json", "x.toml", - ]; - let samples: [&str; 18] = [ - "", - "plain é words 1.5", - "a /* b */ c /* d", - "*/ x \"str /* not\" y", - "s = \"esc \\\" /*\" // c /*", - "--[[ lua ]] [[ str", - "]] x -- c [[", - "\"\"\" py ''' x", - "x ''' y \"\"\" z", - "=begin ruby", - "=end =pod", - "=cut <# ps #> <#", - "# title ```", - "``` `tick` {- hs -} {-", - "-} 'a\\'b' \"\\", - "日本 /* 語 */ \"é", - "x \\", - ]; - for (file, lang) in files.iter().filter_map(|f| Some((f, lang_for(Path::new(f))?))) { - let firsts: Firsts = firsts(lang); - let mut state: State = State::default(); - for line in samples { - let chars: Vec = line.chars().collect(); - let want: State = highlight(&chars, lang, state).1; - assert_eq!(end_state(line.as_bytes(), lang, &firsts, state), want, "{line:?} in {file}"); - state = want; - } + fn grammars_and_non_code() { + for file in ["Makefile", "Dockerfile", ".gitignore", "x.toml", "x.yaml", "x.ts", "x.h", "x.jsonc", "x.go"] { + assert!(grammar_for(Path::new(file)).is_some(), "{file}"); } - } - - #[test] - fn non_code_rules() { - let h: Vec = hl(r#"it's"#, "x.html"); - assert_eq!((h[1], h[3], h[8], h[14], h[18]), (Hl::Keyword, Hl::Property, Hl::Str, Hl::Text, Hl::Keyword)); - let h: Vec = hl("name: true # c", "x.yaml"); + let h: Vec = hl(r#"it's"#, "x.html").remove(0); + assert_eq!((h[1], h[3], h[9]), (Hl::Keyword, Hl::Property, Hl::Str)); + let h: Vec = hl("name: true # c", "x.yaml").remove(0); assert_eq!((h[0], h[6], h[13]), (Hl::Property, Hl::Const, Hl::Comment)); - let h: Vec = hl(r#"{"k": "v"}"#, "x.json"); - assert_eq!((h[1], h[7]), (Hl::Property, Hl::Str)); - assert_eq!(hl("[server]", "x.toml")[1], Hl::Type); - let h: Vec = hl("SELECT x FROM t", "q.sql"); - assert_eq!((h[0], h[7], h[9]), (Hl::Keyword, Hl::Text, Hl::Keyword)); - assert!(hl("# Title", "a.md").iter().all(|h| *h == Hl::Keyword)); - assert!(hl("a: b", "x.css").iter().all(|h| *h != Hl::Comment), "no line comment in CSS"); + let h: Vec = hl(r#"{"k": "v"}"#, "x.json").remove(0); + assert_eq!((h[2], h[7]), (Hl::Property, Hl::Str)); + assert!(hl("# Title", "a.md").remove(0).iter().skip(2).all(|h| *h == Hl::Keyword)); } } diff --git a/src/term/mod.rs b/src/term/mod.rs index e5b6885..2e53747 100644 --- a/src/term/mod.rs +++ b/src/term/mod.rs @@ -18,7 +18,7 @@ use std::{ }; pub use input::{Event, Input, Key, Mouse, MouseKind}; -pub use sys::{https_get, random, utc_offset}; +pub use sys::owner_only; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Size { diff --git a/src/term/sys/unix.rs b/src/term/sys/unix.rs index b2e9c63..5bc0983 100644 --- a/src/term/sys/unix.rs +++ b/src/term/sys/unix.rs @@ -1,9 +1,9 @@ //! Raw libc bindings for termios, window size, select, read and SIGWINCH. //! Layouts are the 64-bit little-endian Linux (glibc/musl) and macOS ABIs. -use std::ffi::{CStr, CString, c_char, c_long, c_void}; +use std::ffi::c_void; use std::io; -use std::mem::{self, MaybeUninit}; +use std::mem::MaybeUninit; use std::ptr; use std::sync::atomic::{AtomicBool, Ordering}; @@ -180,162 +180,7 @@ pub fn take_resized() -> bool { RESIZED.swap(false, Ordering::Relaxed) } -/// `struct tm` as glibc and macOS lay it out (`tm_gmtoff` and `tm_zone` follow the POSIX fields). -#[repr(C)] -struct Tm { - sec: i32, - min: i32, - hour: i32, - mday: i32, - mon: i32, - year: i32, - wday: i32, - yday: i32, - isdst: i32, - gmtoff: i64, - zone: *const u8, -} - -unsafe extern "C" { - fn tzset(); - fn localtime_r(time: *const i64, result: *mut Tm) -> *mut Tm; -} - -/// Seconds the local time zone is ahead of UTC at unix time `ts` (daylight saving included). -pub fn utc_offset(ts: i64) -> i64 { - let mut tm: Tm = Tm { - sec: 0, - min: 0, - hour: 0, - mday: 0, - mon: 0, - year: 0, - wday: 0, - yday: 0, - isdst: 0, - gmtoff: 0, - zone: ptr::null(), - }; - let done: *mut Tm = unsafe { - tzset(); - localtime_r(&raw const ts, &raw mut tm) - }; - if done.is_null() { 0 } else { tm.gmtoff } -} - -const RTLD_NOW: i32 = 2; -const CURLOPT_URL: i32 = 10_002; -const CURLOPT_WRITEDATA: i32 = 10_001; -const CURLOPT_USERAGENT: i32 = 10_018; -const CURLOPT_WRITEFUNCTION: i32 = 20_011; -const CURLOPT_FOLLOWLOCATION: i32 = 52; -const CURLOPT_NOSIGNAL: i32 = 99; -const CURLOPT_CONNECTTIMEOUT: i32 = 78; -const CURLOPT_LOW_SPEED_LIMIT: i32 = 19; -const CURLOPT_LOW_SPEED_TIME: i32 = 20; -const CURLINFO_RESPONSE_CODE: i32 = 0x0020_0002; - -/// Where libcurl lives: macOS ships it, and Linux has it wherever `curl` is installed. -const LIBCURL: &[&CStr] = if cfg!(target_os = "macos") { - &[c"/usr/lib/libcurl.4.dylib"] -} else { - &[c"libcurl.so.4", c"libcurl-gnutls.so.4", c"libcurl.so"] -}; - -#[cfg_attr(target_os = "linux", link(name = "dl"))] -unsafe extern "C" { - fn dlopen(file: *const c_char, mode: i32) -> *mut c_void; - fn dlsym(handle: *mut c_void, name: *const c_char) -> *mut c_void; -} - -type Easy = *mut c_void; -type Sink = extern "C" fn(*const u8, usize, usize, *mut c_void) -> usize; - -/// The handful of libcurl calls a GET needs, looked up at run time so lim does not link against it. -struct Curl { - init: unsafe extern "C" fn() -> Easy, - setopt: unsafe extern "C" fn(Easy, i32, ...) -> i32, - perform: unsafe extern "C" fn(Easy) -> i32, - getinfo: unsafe extern "C" fn(Easy, i32, ...) -> i32, - strerror: unsafe extern "C" fn(i32) -> *const c_char, - cleanup: unsafe extern "C" fn(Easy), -} - -/// # Safety -/// `T` must be the function pointer type of the C function `name`. -unsafe fn symbol(lib: *mut c_void, name: &CStr) -> Option { - let found: *mut c_void = unsafe { dlsym(lib, name.as_ptr()) }; - (!found.is_null()).then(|| unsafe { mem::transmute_copy::<*mut c_void, T>(&found) }) -} - -fn libcurl() -> Option { - let lib: *mut c_void = - LIBCURL.iter().map(|path| unsafe { dlopen(path.as_ptr(), RTLD_NOW) }).find(|lib| !lib.is_null())?; - // SAFETY: each type below is the signature libcurl documents for that function. - unsafe { - Some(Curl { - init: symbol(lib, c"curl_easy_init")?, - setopt: symbol(lib, c"curl_easy_setopt")?, - perform: symbol(lib, c"curl_easy_perform")?, - getinfo: symbol(lib, c"curl_easy_getinfo")?, - strerror: symbol(lib, c"curl_easy_strerror")?, - cleanup: symbol(lib, c"curl_easy_cleanup")?, - }) - } -} - -extern "C" fn collect(data: *const u8, size: usize, count: usize, body: *mut c_void) -> usize { - let len: usize = size.saturating_mul(count); - // SAFETY: `body` is the `Vec` handed to CURLOPT_WRITEDATA, and `data` holds `len` bytes. - unsafe { (*body.cast::>()).extend_from_slice(std::slice::from_raw_parts(data, len)) }; - len -} - -/// The body of `url`, through the system's libcurl (its TLS and certificates), redirects followed, in this -/// process. Any status but 200 is an error (`HTTP 404`). -pub fn https_get(url: &str) -> Result, String> { - let curl: Curl = libcurl().ok_or("libcurl not found (install curl)")?; - let url: CString = CString::new(url).map_err(|e| e.to_string())?; - let easy: Easy = unsafe { (curl.init)() }; - if easy.is_null() { - return Err("curl_easy_init failed".into()); - } - let mut body: Vec = Vec::new(); - let mut status: c_long = 0; - let sink: Sink = collect; - let code: i32 = unsafe { - (curl.setopt)(easy, CURLOPT_URL, url.as_ptr()); - (curl.setopt)(easy, CURLOPT_USERAGENT, c"lim".as_ptr()); - (curl.setopt)(easy, CURLOPT_FOLLOWLOCATION, c_long::from(1u8)); - (curl.setopt)(easy, CURLOPT_NOSIGNAL, c_long::from(1u8)); - (curl.setopt)(easy, CURLOPT_CONNECTTIMEOUT, c_long::from(30u8)); - // Give up when under 1 byte/s for a minute, however long the whole download takes. - (curl.setopt)(easy, CURLOPT_LOW_SPEED_LIMIT, c_long::from(1u8)); - (curl.setopt)(easy, CURLOPT_LOW_SPEED_TIME, c_long::from(60u8)); - (curl.setopt)(easy, CURLOPT_WRITEFUNCTION, sink); - (curl.setopt)(easy, CURLOPT_WRITEDATA, (&raw mut body).cast::()); - let code: i32 = (curl.perform)(easy); - (curl.getinfo)(easy, CURLINFO_RESPONSE_CODE, &raw mut status); - (curl.cleanup)(easy); - code - }; - if code != 0 { - let why: *const c_char = unsafe { (curl.strerror)(code) }; - let why: String = if why.is_null() { - format!("curl error {code}") - } else { - unsafe { CStr::from_ptr(why) }.to_string_lossy().into() - }; - return Err(why); - } - if status != 200 { - return Err(format!("HTTP {status}")); - } - Ok(body) -} - -/// Fills `buf` from the kernel's random source; false if it cannot be read. -pub fn random(buf: &mut [u8]) -> bool { - use std::io::Read; - std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(buf)).is_ok() +/// Files `options` creates are readable by their owner only (0600). +pub fn owner_only(options: &mut std::fs::OpenOptions) { + std::os::unix::fs::OpenOptionsExt::mode(options, 0o600); } diff --git a/src/term/sys/windows.rs b/src/term/sys/windows.rs index d93983a..ab80f3c 100644 --- a/src/term/sys/windows.rs +++ b/src/term/sys/windows.rs @@ -4,7 +4,6 @@ use std::ffi::c_void; use std::io; -use std::ptr; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; type Handle = *mut c_void; @@ -86,166 +85,8 @@ unsafe extern "system" { fn ReadConsoleInputW(console: Handle, records: *mut InputRecord, len: u32, read: *mut u32) -> i32; } -/// `TIME_ZONE_INFORMATION`: biases in minutes west of UTC; the names and switch dates are not read. -#[repr(C)] -struct TimeZoneInformation { - bias: i32, - standard_name: [u16; 32], - standard_date: [u16; 8], - standard_bias: i32, - daylight_name: [u16; 32], - daylight_date: [u16; 8], - daylight_bias: i32, -} - -/// `GetTimeZoneInformation`'s answer while daylight saving time is on. -const TIME_ZONE_ID_DAYLIGHT: u32 = 2; - -unsafe extern "system" { - fn GetTimeZoneInformation(info: *mut TimeZoneInformation) -> u32; -} - -#[link(name = "advapi32")] -unsafe extern "system" { - /// `RtlGenRandom`, the system's cryptographic random source. - #[link_name = "SystemFunction036"] - fn RtlGenRandom(buf: *mut u8, len: u32) -> u8; -} - -/// Seconds the local time zone is ahead of UTC now (Windows gives the current rule, not one per date). -pub fn utc_offset(_ts: i64) -> i64 { - let mut info: TimeZoneInformation = TimeZoneInformation { - bias: 0, - standard_name: [0; 32], - standard_date: [0; 8], - standard_bias: 0, - daylight_name: [0; 32], - daylight_date: [0; 8], - daylight_bias: 0, - }; - let zone: u32 = unsafe { GetTimeZoneInformation(&raw mut info) }; - let extra: i32 = if zone == TIME_ZONE_ID_DAYLIGHT { info.daylight_bias } else { info.standard_bias }; - -i64::from(info.bias + extra) * 60 -} - -/// Fills `buf` from the system's random source; false if it fails. -pub fn random(buf: &mut [u8]) -> bool { - buf.chunks_mut(1 << 16).all(|chunk| { - let len: u32 = u32::try_from(chunk.len()).unwrap_or(0); - unsafe { RtlGenRandom(chunk.as_mut_ptr(), len) != 0 } - }) -} - -const WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY: u32 = 4; -const WINHTTP_FLAG_SECURE: u32 = 0x0080_0000; -const WINHTTP_QUERY_STATUS_CODE: u32 = 19; -const WINHTTP_QUERY_FLAG_NUMBER: u32 = 0x2000_0000; -const HTTPS_PORT: u16 = 443; - -#[link(name = "winhttp")] -unsafe extern "system" { - fn WinHttpOpen(agent: *const u16, access: u32, proxy: *const u16, bypass: *const u16, flags: u32) -> Handle; - fn WinHttpConnect(session: Handle, server: *const u16, port: u16, reserved: u32) -> Handle; - fn WinHttpOpenRequest( - connect: Handle, - verb: *const u16, - object: *const u16, - version: *const u16, - referrer: *const u16, - accept: *const *const u16, - flags: u32, - ) -> Handle; - fn WinHttpSendRequest( - request: Handle, - headers: *const u16, - headers_len: u32, - optional: *const c_void, - optional_len: u32, - total_len: u32, - context: usize, - ) -> i32; - fn WinHttpReceiveResponse(request: Handle, reserved: *mut c_void) -> i32; - fn WinHttpQueryHeaders( - request: Handle, - level: u32, - name: *const u16, - buffer: *mut c_void, - len: *mut u32, - index: *mut u32, - ) -> i32; - fn WinHttpReadData(request: Handle, buffer: *mut c_void, len: u32, read: *mut u32) -> i32; - fn WinHttpCloseHandle(handle: Handle) -> i32; -} - -/// A `WinHTTP` handle, closed when dropped. -struct Internet(Handle); - -impl Drop for Internet { - fn drop(&mut self) { - unsafe { WinHttpCloseHandle(self.0) }; - } -} - -fn internet(handle: Handle, what: &str) -> Result { - if handle.is_null() { Err(format!("{what}: {}", io::Error::last_os_error())) } else { Ok(Internet(handle)) } -} - -fn wide(s: &str) -> Vec { - s.encode_utf16().chain([0]).collect() -} - -/// The body of `url` (`https://` only), through `WinHTTP`: the system's TLS, certificates and proxy, redirects -/// followed. Any status but 200 is an error (`HTTP 404`). -pub fn https_get(url: &str) -> Result, String> { - let rest: &str = url.strip_prefix("https://").ok_or("not an https URL")?; - let (host, path): (&str, &str) = rest.split_at(rest.find('/').unwrap_or(rest.len())); - let path: &str = if path.is_empty() { "/" } else { path }; - let null: *const u16 = ptr::null(); - let session: Internet = internet( - unsafe { WinHttpOpen(wide("lim").as_ptr(), WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, null, null, 0) }, - "WinHttpOpen", - )?; - let connect: Internet = - internet(unsafe { WinHttpConnect(session.0, wide(host).as_ptr(), HTTPS_PORT, 0) }, "WinHttpConnect")?; - let request: Internet = internet( - unsafe { - WinHttpOpenRequest( - connect.0, - wide("GET").as_ptr(), - wide(path).as_ptr(), - null, - null, - ptr::null(), - WINHTTP_FLAG_SECURE, - ) - }, - "WinHttpOpenRequest", - )?; - let fail = |e: io::Error| format!("{host}: {e}"); - check(unsafe { WinHttpSendRequest(request.0, null, 0, ptr::null(), 0, 0, 0) }).map_err(fail)?; - check(unsafe { WinHttpReceiveResponse(request.0, ptr::null_mut()) }).map_err(fail)?; - let (mut status, mut len): (u32, u32) = (0, 4); - let level: u32 = WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER; - check(unsafe { - WinHttpQueryHeaders(request.0, level, null, (&raw mut status).cast(), &raw mut len, ptr::null_mut()) - }) - .map_err(fail)?; - if status != 200 { - return Err(format!("HTTP {status}")); - } - let mut body: Vec = Vec::new(); - let mut chunk: Vec = vec![0; 1 << 16]; - loop { - let mut read: u32 = 0; - let room: u32 = u32::try_from(chunk.len()).unwrap_or(u32::MAX); - check(unsafe { WinHttpReadData(request.0, chunk.as_mut_ptr().cast(), room, &raw mut read) }).map_err(fail)?; - let read: usize = usize::try_from(read).unwrap_or(0); - if read == 0 { - return Ok(body); - } - body.extend_from_slice(&chunk[..read]); - } -} +/// Nothing to do: a new file inherits its folder's ACL, and the user's folders (temp included) are private. +pub fn owner_only(_options: &mut std::fs::OpenOptions) {} /// The input and output console modes, saved on entry and restored on exit. #[derive(Clone, Copy)] diff --git a/src/update.rs b/src/update.rs index a4403a1..2e5a138 100644 --- a/src/update.rs +++ b/src/update.rs @@ -1,17 +1,38 @@ //! `:upgrade` / `pu`: asks GitHub for the latest release and, when it is newer, downloads the binary built -//! for this platform and puts it in place of this one. HTTPS goes through the system (`WinHTTP`, libcurl), in process. +//! for this platform and puts it in place of this one. HTTPS goes through ureq and rustls, in process. use std::fs; use std::io; use std::path::PathBuf; use std::process::{Command, Output, Stdio}; +use std::time::Duration; use std::{env, iter}; use crate::config::json::{self, Json}; -use crate::term::https_get; const LATEST: &str = "https://api.github.com/repos/Neotoxic-off/Lim/releases/latest"; const DOWNLOAD: &str = "https://github.com/Neotoxic-off/Lim/releases/download"; +/// A release binary is a few MB; anything far bigger is not one. +const MAX_BODY: u64 = 64 * 1024 * 1024; + +/// The body of `url`, over HTTPS only (redirects too), with the proxy from the environment or Windows' settings. +/// Any status but 200 is an error (`HTTP 404`). +fn https_get(url: &str) -> Result, String> { + let agent: ureq::Agent = ureq::Agent::config_builder() + .https_only(true) + .http_status_as_error(false) + .user_agent("lim") + .timeout_connect(Some(Duration::from_secs(15))) + .timeout_global(Some(Duration::from_secs(300))) + .build() + .into(); + let mut response: ureq::http::Response = agent.get(url).call().map_err(|e| e.to_string())?; + let status: u16 = response.status().as_u16(); + if status != 200 { + return Err(format!("HTTP {status}")); + } + response.body_mut().with_config().limit(MAX_BODY).read_to_vec().map_err(|e| e.to_string()) +} /// Runs on a background thread while editing goes on; what it returns becomes a notification. pub fn run() -> Result {