- Rust toolchain (stable)
- Python 3.10+
- maturin:
pip install maturin - fastship: installed by
pip install -e '.[dev]'
src/
lib.rs public API, error type, module declarations
engine.rs single- and multi-buffer edit engines producing EditResult
lnhash.rs lnhash hashing/formatting/parsing
parse.rs compact command parsing (script and args modes)
commands.rs shared structured command fields/parser
files.rs file/cell paths, notebook JSON, views, edit/write orchestration
python.rs PyO3 bindings (incl. exhash_argv used by the CLI)
python/exhash/
__init__.py Python validation, result wrappers, and display formatting
_cli.py exhash/lnhashview console-script entry points
skill.py pyskills entry point exposing exhash APIs for LLM tools
tests/
test_exhash.py Python API tests
test_commands.py engine command coverage
test_cli.py CLI console-script tests
For local development, build and install the extension:
maturin developship-rs-build builds the distributable wheel. The exhash and lnhashview commands are Python console scripts (python/exhash/_cli.py) over the extension; there are no separate Rust binaries.
pytest -qThe existing Python API/CLI regression suite exercises the shared Rust file/cell
implementation. cargo test additionally tests the Rust API with no Python feature.
cargo check --no-default-features verifies embedding without PyO3.
edit_text verifies lnhashes command-by-command immediately before each command executes. A single-line address can match the line's current hash or a recorded call-start hash from an earlier in-place edit. Records are inserted only once per line. A structural edit drops records at and below its topmost affected line; range addresses do not use the fallback.
The $ (last line) and % (whole file) address forms are resolved against the current buffer and do not require hashes.
edit_text_with_sw exposes configurable shift width for < and >; edit_text defaults to sw=4.
In CLI and Python file-helper flows, a missing file is treated as empty input only when the parsed command set is valid against an empty buffer (for example 0|0000|a); otherwise the original file-not-found error is preserved.
Rust edit_files resolves optional path: and notebook-cell prefixes, loads every
referenced buffer, and calls edit_buffers_with_sw once. Rust owns validation,
transfers, diffs, and file/notebook writes. Every command succeeds before writes
begin, but this is not an atomic multi-file transaction: an OS write failure can
leave earlier writes completed. Notebook source form, metadata, outputs, and
trailing newlines are preserved; each notebook is read/written once. JSON uses
arbitrary-precision numbers so unrelated notebook metadata cannot be rounded.
Python file_exhash and cell_exhash are thin adapters over this core.
File views and file_exhash normalize CR, CRLF, and LF line endings on read. Unicode separators remain line content, and Python result wrappers use the Rust engine's original lines so no-op detection and diffs agree. No-op file edits leave the original bytes untouched.
lnhashview range requests clamp end past EOF to the last available line, while invalid start values still error.
Publishing is handled by GitHub Actions in .github/workflows/ci.yml and is triggered by pushing a tag matching v*.
Release flow is: release first, then bump.
- Confirm tests pass:
pytest -q-
Confirm the release version in
Cargo.toml([package].version).pyproject.tomlgets the Python package version from Cargo viadynamic = ["version"]. -
Release:
ship-releaseIt tags v<version>, pushes branch and tag, then bumps Cargo.toml, refreshes the editable install, and pushes the bump to main (no tag). No need to wait for publish to finish first.
No local build is required for release; CI runs the release build, creates a GitHub Release, and publishes to PyPI.
The commands are Python console scripts declared in [project.scripts] (python/exhash/_cli.py). exhash and exhash-cell handle argument parsing and delegate compact parsing,
editing, notebook serialization, and atomic replacement to the extension. lnhashview and lnhashview-cell provide the corresponding address views. exhash-open is a fastcore call_parse wrapper over the document outline API.
The Rust core takes commands three ways:
- Structural (PyO3
exhashbinding): the Python wrapper validates tuple command specs and passes them through as tuples;commands.rsbuildsCommand/Subcommandvalues directly (command_from_fields), with no string round-trip. Address strings are parsed byparse::command_from_parts; field validation (substitute flags, transliterate counts) is shared with the compact parser viasubst_from_parts/translit_from_parts. Global commands carry their subcommand as a nested tuple; text fields are verbatim, so there is no delimiter choice or escaping anywhere on this path. A trailing.line in ana/i/cpayload is literal text and the binding warns about this common mistake. - Multi-buffer structural (
edit_buffersbinding): Python passes ordered(target, text)buffers and target-resolved command tuples. Rust keeps one engine per target and executes the full call, including cross-targetm/t, before returning per-target results. - Compact ex-style strings, where strings are the input medium:
parse_commands_from_script(&str): for script strings; commands are separated by newlines. Single-linea/i/ctext may be inline; if omitted, following lines up to.are used as the text block.parse_commands_from_args(&[String], &mut BufRead): used by theexhashCLI via theexhash_argvbinding; each arg is a command. Single-linea/i/ctext may be inline. One command may instead read a multiline text block from stdin through EOF.
File-qualified addresses and notebook cell prefixes are resolved by files.rs.
The public Rust CommandField representation supports strings and nested arrays,
so Python and Luau use the same parser without a delimiter/string round trip.
The engine itself still treats target identifiers as opaque strings.
Commands preserve newlines in text fields. This is used by a/i/c payloads and by s pattern/replacement; replacement newlines split lines during editing. Commands without text fields do not take text. In compact strings, substitute parsing keeps Rust regex escapes intact (\d, \w, etc.) while allowing escaped command delimiters (\/); compact transliteration uses y/src/dst/. Tuple fields need no escaping at all.