# leo-rs
A Rust implementation of [Leo](https://github.com/leo-editor/leo-editor)'s model layer (`leolib`) and a terminal front end that consumes it (`leotui`).
Leo's outline model was separated from its Qt front end in `leo/leolib`; this port keeps that boundary. `leolib` reads and writes `.leo` files and the external files they refer to, and knows nothing about how any of it is shown. `leotui` is one front end over that crate. Nothing in `leolib` depends on it.

## Status
Verified against `leo/core/LeoPyRef.leo` from the Leo repository:
| nodes read from the `.leo` file | 536, identical gnx/headline/body to Python `leolib` |
| nodes read with all external files | 11,581, identical to Python `leolib` |
| `.leo` file rewritten | byte-identical to the file read |
| external files tangled | 381 of 381 byte-identical to the files on disk |
| `@auto` trees, 1,000 files across 8 languages | 998 identical to Leo's importers; the 2 differences are a deliberate fix |
| `@auto` files written back | 1,008 of 1,010 byte-identical; the 2 exceptions fail in Leo too |
Those figures come from runs against a leo-editor checkout. What `cargo test` checks every time is the conformance corpus in `demo/`: each outline there has an expected file written by Python Leo (`scripts/make_corpus.py`), and leo-editor checks Python Leo against a copy of the same files. The `@auto` tree comparison needs a Python Leo: see `docs/dev/compare-importers.py`.
## Layout
```text
crates/leolib the model. No view, ever.
crates/leotui the terminal front end.
```
`leolib` has one runtime dependency for XML parsing (`quick-xml`), one for regular expressions (`regex`), and `once_cell`. `leotui` adds `ratatui` and `crossterm`.
## Installing
```sh
cargo install leotui --locked # from crates.io
cargo install --path crates/leotui --locked # from a checkout
cargo add leolib # the library, in your own crate
```
`--locked` builds with the `Cargo.lock` shipped in the package. A checkout build uses the workspace's `lto = true`; the crates.io build does not, as cargo drops workspace profiles from a published package.
## Using leolib
```rust
let mut outline = leolib::open_outline("myfile.leo", true)?;
for p in outline.all_unique_positions() {
println!("{}", p.h(&outline));
}
let root = outline.root_position().unwrap();
outline.set_body(&root, "edited with no window in sight\n");
leolib::save(&mut outline, "")?;
leolib::write_external_files(&mut outline, true);
```
`leolib::Document` adds an undo history and the structural commands (insert, delete, clone, copy, paste, move, mark) on top of an `Outline`.
## Using leotui
```sh
leotui FILE.leo
leotui FILE.leo --dump # one frame, no terminal
leotui FILE.leo --dump --press F1 # press keys, then dump
leotui --keys # the binding table
```
or during development
```sh
cargo run -p leotui -- FILE.leo
cargo run -p leotui -- FILE.leo --dump # one frame, no terminal
cargo run -p leotui -- FILE.leo --dump --press F1 # press keys, then dump
cargo run -p leotui -- --keys # the binding table
```
leotui is modal. The pane decides what a key means -- Leo's own `!tree`/`!body` rule -- and `:` reaches every command by name, as Leo's minibuffer does. `F1` shows the bindings in the app; `leotui --keys` prints the same table.
| `NORMAL` | the default | |
| `INSERT` | `i` `a` `I` `A` `o` `O` `s` `S` `c` `C` in the body | `Escape` commits, `Ctrl-c` abandons |
| `VISUAL` | `v` `V` in the body | an operator, or `Escape` |
| `HEADLINE` | `e` in the outline | `Enter` commits, `Escape` abandons |
| `COMMAND` | `:` | `Enter` runs it, `Escape` abandons |
| `SEARCH` | `/` `?` | `Enter` keeps the match, `Escape` goes back |
| `HELP` | `F1` | `q` |
### Cheatsheet
**Outline: moving**
| `j` `k` `Down` `Up` | next, previous visible node |
| `h` `Left` | fold this node, or step out to the parent |
| `l` `Right` `Enter` | unfold this node, or step in to the first child |
| `gg` `Alt-Home` | first node |
| `G` `Alt-End` | last visible node |
| `gp` | parent |
| `{` `}` | previous, next sibling |
| `[m` `]m` | previous, next marked node |
| `]c` `Alt-n` | next clone of this node |
**Outline: folding**
| `Space` `za` | fold or unfold this node |
| `zo` `Alt-]` | unfold this node |
| `zc` `Alt-[` | fold this node |
| `zR` | unfold every node |
| `zM` `Alt--` | fold every node |
| `zr` `zm` | unfold one level further, fold one level back |
| `zx` | fold everything except the path to this node |
| `z1` `z2` `z3` `z4` `z5` `z6` `z7` `z8` `z9` | unfold to that level |
**Outline: moving a node**
| `>>` `Shift-Right` | **indent**: make this node a child of the one above |
| `<<` `Shift-Left` | **deindent**: move this node out one level |
| `J` `Shift-Down` | move this node down |
| `K` `Shift-Up` | move this node up |
| `g>` | demote: make the *following siblings* children of this node |
| `g<` | promote: make this node's *children* its siblings |
`>>` moves the node you are on. `g>` and `g<` move other nodes around it.
**Outline: creating and removing**
| `o` `Insert` | insert a node after this one |
| `O` | insert a node before this one |
| `a` `Ctrl-Insert` | insert a node as the first child |
| `e` | edit the headline |
| `i` | edit the body |
| `dd` | cut this node to the clipboard |
| `Delete` `Backspace` | delete this node |
| `yy` `p` | copy, paste after this node |
| `` ` `` | clone this node |
| `m` `M` | mark or unmark this node, clear every mark |
**Body: a vim buffer**
| `h j k l` | left, down, up, right |
| `w W b B e E ge` | by word: forwards, back, to the end |
| `0 ^ $ gg G { } %` | line start and end, file, paragraph, matching bracket |
| `f F t T ; ,` | to a character on the line, and repeat |
| `H M L` | top, middle, bottom of the pane |
| `d c y > < gu gU g~` | delete, change, yank, indent, unindent, case |
| `iw aw i" a( ip` | word, quoted, bracketed, paragraph |
| `x X r s S D C Y J ~` | delete, replace, substitute, join, case |
| `i a I A o O` | enter INSERT at the usual vim place |
| `v V` | select charwise, linewise |
| `p P` | put the text register after, before |
| `.` | repeat the last change |
Operators take a count, a motion and a text object: `2d3w`, `ciw`, `da"`, `>>`. One change is one undo, so `A`, two hundred characters and `Escape` is one `u`.
**Both panes**
| `Tab` `Shift-Tab` | move between the outline and the body |
| `Escape` | in the body, go back to the outline |
| `:` | the command line |
| `/` `?` | search headlines and bodies, forwards, backwards |
| `n` `N` | next match, previous match |
| `u` `Ctrl-z` | undo |
| `Ctrl-r` | redo |
| `Ctrl-s` | write the `.leo` file |
| `w` | write the changed external files |
| `Ctrl-f` `Ctrl-b` `PageDown` `PageUp` | a screen down, up |
| `Ctrl-d` `Ctrl-u` | half a screen down, up |
| `Ctrl-w <` `Ctrl-w >` | narrow, widen the pane that has focus |
| `Ctrl-Left` `Ctrl-Right` | give the body, the outline more room |
| `:set syntax` `:set nosyntax` | colour the body, or leave it plain |
| `F1` | help |
| `q` | quit |
**The `:` command line**
Every Leo command name, with Tab completion and Up/Down history, plus the vim spellings: `:w` `:w path` `:q` `:q!` `:wq` `:x` `:e path` `:h cmd`. `:N` selects
the Nth visible row. `:set` takes several options at once, as vim does: `:set search=all|headlines split=N wrap number syntax colors=true|256|16`. `name:value` works as `name=value`, and `:set name?` or `:set` alone shows values.
`/` searches every headline and body in outline order, whichever pane has focus, and lands on the match: a headline in the outline, body text under the body's cursor. The pattern is a Rust `regex`, with smartcase. Matches stay highlighted until `:noh`, and `:set search=headlines` leaves bodies out.
`:import-at-file path` imports a file as an `@file` tree, and asks before writing sentinels into it.
`:[range]s/pattern/replacement/[flags]` substitutes in the current node's body, as one undo step. The pattern is a Rust `regex`, with smartcase as in `/`; an empty pattern reuses the last search. The replacement takes `&`, `\1`-`\9` and `\r`. Ranges are `%`, `.`, `$`, `N` and `N,M`; flags are `g`, `i`, `I` and `n`. `:bufdo %s/pattern/replacement/[flags]` does the same in every node's body, as one undo step.
`:set split=N` sets the outline's width in percent, and saves it as `split-ratio` in `~/.config/leotui/config.toml`.
**Leo's own chords**
These need a terminal speaking the kitty keyboard protocol (kitty, foot, wezterm, ghostty, alacritty, iTerm2). A legacy terminal cannot send them -- `Ctrl-I` *is* Tab -- so each has a portable binding above. `--no-kitty-keys` turns the protocol off.
| `Ctrl-i` | insert a node |
| `Ctrl-m` | mark |
| `Ctrl-[` `Ctrl-]` | promote, demote |
| ``Ctrl-` `` | clone |
| `Ctrl-Shift-z` | redo |
| `Ctrl-h` | edit the headline |
The body is coloured by the language declared at the node: an `@language` directive in the node or an ancestor, or the nearest `@<file>` node's extension. A node with neither is left plain, so prose is never coloured as code. `@language` lines inside a body move it from that line on, so one node can hold Python and then C; `@nocolor`, `@color` and `@killcolor` work as they do in Leo. `:set nosyntax` turns it off.
Twelve languages -- C, C++, CSS, Go, HTML, Java, JavaScript, JSON, Python, Rust, shell, TypeScript -- are parsed with tree-sitter, which tells a function from a field from a type. Every other language Leo knows a comment delimiter for runs a line scanner instead: comments, strings, numbers, and keywords from Leo's colorizer modes for 33 of them.
Colours come from a Helix theme, read from `~/.config/leotui/themes` or `~/.config/helix/themes`. Nothing is vendored, so the themes are whichever ones you already have. The default is `sonokai`, and without a file of that name leotui uses the terminal's sixteen colours.
`:theme` names the current one. `:theme NAME` changes it, and the themes on disk are listed above the command line as you type. Tab and the arrow keys move through the list, applying each as they land on it, so the outline shows the theme before Enter accepts it. Escape puts back the one you started with. Enter saves the choice to `~/.config/leotui/config.toml`, rewriting only its `theme` line, and the next launch starts there. `--theme NAME` picks a theme for one launch without saving it.
Truecolor is used where the terminal reports it, and reduced to the 256-colour cube or the terminal's sixteen where it does not; `:set colors=true|256|16` overrides the guess.
Flags in the left column: `>` selected, `*` marked, `C` cloned, `~` dirty. `@<file>` nodes are green. The design, and what is still to come, is in `docs/dev/tui-design.md`.
## `@auto`
An `@auto` file is the user's own source, with no sentinels in it. Its structure comes from the language, through a port of Leo's importers.
| block languages | c, c++, c#, coffeescript, cython, dart, java, javascript, lua, pascal, perl, php, pug, python, rust, scheme, lisp/clojure, tcl, typescript |
| section languages | ini, xml, html |
| line-oriented | org, otl, markdown, treepad |
The file is regenerated from the tree alone, so an importer that dropped a line would overwrite the user's source. Every import is therefore checked: the tree is written back and compared with the file before it is kept, and a tree that fails leaves the whole file in the node's body with an error. Leo does not check this.
Two things an import can change even when it succeeds, both as in Leo: leading tabs become blanks to match `@tabwidth`, and an XML or HTML file gets adjacent tags split onto separate lines. `ReadResult::warnings` names the files it happened to, because the next write changes them on disk.
`@auto-rst` is not ported: its reader and writer are a separate mechanism in Leo, not an importer.
## What else is not ported
- **`@shadow`.** Deprecated in Leo.
- **Unknown attributes are opaque.** Leo pickles them. They round-trip as the hex strings the file spells, and are written back unchanged.
- **`.leojs`** (the JSON outline format).
See `docs/dev/porting-notes.md` for the places this port deliberately differs from Leo, and why.
## Building
```text
make build # cargo build --workspace
make test # cargo test --workspace
make corpus LEO_EDITOR=/path/to/leo-editor # demo/'s expected files against Python Leo
make lint # rustfmt --check and clippy -D warnings
make audit # Cargo.lock against the RustSec advisories (cargo-audit)
```