https://github.com/zemse/xls
A terminal spreadsheet: read/write XLS (BIFF8), XLSX and CSV with a built-in formula engine, CLI and TUI.
https://github.com/zemse/xls
Last synced: 14 days ago
JSON representation
A terminal spreadsheet: read/write XLS (BIFF8), XLSX and CSV with a built-in formula engine, CLI and TUI.
- Host: GitHub
- URL: https://github.com/zemse/xls
- Owner: zemse
- License: apache-2.0
- Created: 2026-06-06T09:26:27.000Z (21 days ago)
- Default Branch: main
- Last Pushed: 2026-06-06T21:56:17.000Z (20 days ago)
- Last Synced: 2026-06-09T12:24:12.669Z (18 days ago)
- Language: Rust
- Size: 479 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE-APACHE
Awesome Lists containing this project
README
# xls
**A spreadsheet for your terminal.** `xls` reads and writes Excel `.xls`
(BIFF8), `.xlsx` (OOXML) and `.csv` files, evaluates Excel formulas with a
built-in engine covering all 522 worksheet functions (incl. dynamic-array spill
and `LAMBDA`), and ships both a scriptable
CLI and an interactive, mouse-aware TUI — all in a single Rust crate with no
external runtime.
```
┌ Data ───────────────────────────────────────────────┐
│ A B C D │
│ 1 Region Units Price Total │
│ 2 North 120 12.50 =B2*C2 → 1500.00 │
│ 3 South 90 12.50 =B3*C3 → 1125.00 │
│ 4 ──────────────────────────────────────── │
│ 5 Total =SUM(B2:B3) =SUM(D2:D3) → 2625.00 │
└──────────────────────────────────────────────────────┘
NORMAL D5 =SUM(D2:D3) number *modified
```
> Status: pre-release (`0.1`). The core (data model, formula engine, file I/O)
> is well-tested, including dynamic-array spill and `LAMBDA`.
## Why
- **It writes `.xls`.** No other pure-Rust crate writes the legacy BIFF8 binary
format. `xls` owns the format end-to-end (read *and* write).
- **Real formulas.** A from-scratch engine — lexer, Pratt parser, dependency
graph, topological recalculation, circular-reference detection — not a
thin wrapper.
- **Round-trip fidelity.** Styles, number formats, merged cells and frozen panes
are preserved; unrecognized parts (charts, drawings, pivots, VBA) are kept
verbatim so saving never destroys data.
- **One crate, one binary.** `core` / `cli` / `tui` are internal modules behind
Cargo features, so `cargo install xls` ships exactly one artifact and the
library build never pulls UI dependencies.
## Install
```sh
# from crates.io (once published)
cargo install xls
# from source
git clone https://github.com/zemse/xls && cd xls
cargo install --path .
```
## CLI
```sh
# Read / inspect
xls info report.xlsx # format, sheets, dimensions, date system
xls get report.xlsx Sheet1!B2 # print a cell's displayed value
xls get report.xlsx 'A1:J200' --format csv # dump a range (table|csv|tsv|json|jsonl|md)
xls get report.xlsx 'A1:J200' --format json --header # array of objects keyed by header row
xls get report.xlsx 'A1:J200' --raw --dates iso # stored values; dates as ISO/serial
xls eval data.csv '=AVERAGE(A1:A10)' # evaluate a formula against a file
xls eval data.csv '=C2:C9' --format csv # array results print as a grid
xls format report.xlsx C2 # print a cell's number format (DATE/NUMBER/GENERAL)
xls head report.xlsx -n 20 # first / last N rows (head / tail)
xls grep report.xlsx ZANMAI # matching cells with their addresses
xls profile report.xlsx amount # column stats + "numbers stored as text" warning
xls diff before.xlsx after.xlsx --key date # keyed row diff (omit --key for cell-by-cell)
# Reshape / combine (read-only verbs print to stdout)
xls pivot report.xlsx --rows category --values amount --agg sum # group + aggregate
xls filter report.xlsx 'amount>1000' --format csv # rows matching a predicate
xls join a.xlsx b.xlsx --on id # inner-join two sheets on a key
xls query report.xlsx "SELECT category, SUM(amount) AS total FROM Sheet1 GROUP BY category ORDER BY total DESC"
# read-only SQL: sheets are tables (row 0 = header), WHERE/GROUP BY/JOIN/ORDER BY/LIMIT
# Write / mutate (each edits in place and saves)
xls new book.xlsx # create an empty workbook
xls set report.xlsx A1 '=SUM(B:B)' # set a cell (formula/number/text), recalc, save
xls batch report.xlsx --set A1=1 --set B2=hi # many edits, one atomic open/save
xls clear report.xlsx A1:B10 # clear a range
xls fill report.xlsx A1:A10 0 # set a whole range to one value
xls sort report.xlsx --by amount --desc # stable multi-key sort (header kept)
xls dedup report.xlsx --on id # drop duplicate rows by key column(s)
xls append base.xlsx new.xlsx # append rows, aligned by header name
xls to-number report.xlsx H1:H200 # force-convert any numeric text in a range
xls to-date report.xlsx A2:A83 --format dd/mm/yyyy # force-convert text dates to real dates
xls format-set report.xlsx C2:C154 'dd/mm/yyyy' # set a number format
xls autofit report.xlsx # fit column widths to content
xls style report.xlsx A1:D1 --bold --bg FFFF00 # basic styling
xls copy report.xlsx A1:B3 D1 # copy a range to an anchor (move = copy+clear)
xls insert-row report.xlsx 3 -n 2 # insert 2 blank rows before row 3
xls delete-col report.xlsx C # delete column C (letter or number)
xls add-sheet report.xlsx Summary # add / delete / rename sheets
xls rename-sheet report.xlsx Sheet1 Data
# Safety flags on any mutating command
xls set report.xlsx A1 x --dry-run # print the diff, write nothing
xls set report.xlsx A1 x --backup # write report.xlsx.bak first
xls set report.xlsx A1 x --output copy.xlsx # write a copy, leave the input
# Named ranges & Excel tables
xls table add report.xlsx A1:C20 --name Sales # define a table over a range
xls get report.xlsx 'Sales[Amount]' --format csv # read a table column
xls eval report.xlsx '=SUM(Sales[Amount])' # structured references in formulas
xls name add report.xlsx TaxRate 'Sheet1!$E$1' # define a named range
xls info report.xlsx # lists named ranges + tables
# Convert / import / interactive
xls export old.xls --format xlsx # convert between formats
xls export report.xlsx -f csv -o - # stream a text format to stdout
xls export huge.xlsx -f csv -o out.csv --stream # memory-bounded export of huge files
xls import data.csv --into book.xlsx # add a CSV as a sheet
xls open book.xlsx # launch the interactive TUI
cat data.csv | xls eval - '=SUM(A:A)' # read CSV from stdin with `-`
cat data.csv | xls set - A1 x --output out.csv # stdin write needs --output
# Password-protected (encrypted) workbooks
xls info statement.xlsx # without -p: reports the encryption scheme
xls get statement.xlsx B5 -p secret # decrypt in-memory with --password / -p
```
`--password`/`-p` is a global flag accepted by any command. Without it, an
encrypted file is reported with its scheme (e.g. *ECMA-376 agile encryption:
AES-128-CBC, SHA1*) instead of opening. Mutating an encrypted file and saving
writes an **unencrypted** copy (re-encryption is not supported), and prints a
warning.
Run `xls --help` for the full reference and `xls --help` per subcommand.
> **Numbers stored as text:** numeric functions (`SUM`, `AVERAGE`, `MIN`, …)
> coerce numeric-looking text (e.g. `6,000.00`, `1,51,302.63` from bank/CSV
> exports) at evaluation time, so such cells contribute without rewriting your
> data — the cells stay text and the file is untouched. `COUNT` stays strict
> (so `COUNT` < `COUNTA` flags text-stored numbers). To convert permanently,
> use `xls to-number ` or the TUI `:tonum` on a selection.
>
> **Dates stored as text:** likewise, exports often store dates as text
> (`"04/04/2025"`). `xls to-date --format dd/mm/yyyy` parses them
> into real date serials and applies the format, so `get --raw --dates serial`
> (or `iso`) then emits machine-readable values. `xls profile` flags text-stored
> dates the same way it flags text-stored numbers.
## TUI
`xls open ` (or just `xls `) launches a modal, vim-flavored editor:
- **Navigate** — arrows, `Tab`/`Enter`, `Ctrl+Arrow` (jump to data edge),
`Home`/`End`, `Ctrl+Home`/`Ctrl+End`, `PgUp`/`PgDn`.
- **Select** — `Shift+Arrow` for ranges; mouse click & drag.
- **Edit** — type or `F2` to edit, `Esc` cancels, `Enter`/`Tab` commits and
recalculates. Formula bar with live address.
- **Mouse** — click to select, drag to extend, wheel to scroll, click sheet tabs.
- **Clipboard** — `Ctrl+C`/`X`/`V` (internal ranges + system clipboard).
- **Undo/redo** — `Ctrl+Z` / `Ctrl+Y`.
- **Dialogs** — `Ctrl+G`/`F5` go-to, `Ctrl+F` find, `:` command palette,
`Ctrl+S` save.
Virtual scrolling renders only visible cells, with frozen rows/columns and
number-format-aware display.
## Library
`default-features = false` gives a UI-free library exposing just `core`:
```toml
[dependencies]
xls = { version = "0.1", default-features = false }
```
```rust
use xls::core::{Workbook, Cell};
use xls::core::formula::Engine;
let mut wb = Workbook::new();
let s = wb.sheet_mut(0).unwrap();
s.set_a1("A1", Cell::Number(2.0));
s.set_a1("A2", Cell::Number(3.0));
s.set_a1("A3", Cell::Formula { expr: "=A1+A2".into(), cached: Default::default() });
Engine::new().recalc(&mut wb);
assert_eq!(wb.display_cell(0, 2, 0), "5");
xls::core::save_path(&wb, std::path::Path::new("out.xlsx")).unwrap();
```
## Formula coverage
All of Excel's standard worksheet functions are implemented one-by-one, grouped
by Microsoft category, each with known-answer tests (**all 522 implemented**).
| Category | Examples |
|----------|----------|
| Logical | `IF` `IFS` `SWITCH` `AND` `OR` `XOR` `IFERROR` |
| Math & Trig | `SUM` `SUMIFS` `SUMPRODUCT` `ROUND` `MOD` trig, `MDETERM` `MMULT` `SUBTOTAL` `AGGREGATE` |
| Statistical | `AVERAGEIFS` `MEDIAN` `STDEV.S` `PERCENTILE.INC` `RANK.EQ` `NORM.DIST` `CHISQ.TEST` `FREQUENCY` |
| Text | `LEFT` `MID` `SUBSTITUTE` `TEXT` `TEXTJOIN` `TEXTBEFORE` `REGEXEXTRACT` `TEXTSPLIT` |
| Lookup & Reference | `VLOOKUP` `XLOOKUP` `INDEX` `MATCH` `OFFSET` `INDIRECT` `XMATCH` |
| Dynamic arrays | `SORT` `SORTBY` `UNIQUE` `FILTER` `SEQUENCE` `VSTACK`/`HSTACK` `TAKE`/`DROP` (with cell **spill**) |
| LAMBDA | `LAMBDA` `LET` `MAP` `REDUCE` `SCAN` `BYROW`/`BYCOL` `MAKEARRAY` |
| Date & Time | `DATE` `EDATE` `EOMONTH` `NETWORKDAYS` `YEARFRAC` `WEEKNUM` |
| Financial | `PMT` `NPV` `IRR` `XIRR` `PRICE` `YIELD` `DURATION` `MIRR` |
| Engineering | base conversions, `BITAND`, `CONVERT`, `ERF`, complex `IM*` |
| Information | `ISNUMBER` `ISERROR` `N` `TYPE` `CELL` `SHEET` |
| Database | `DSUM` `DAVERAGE` `DGET` `DCOUNT` … |
Dynamic-array results **spill** into neighboring cells (with `#SPILL!` on
obstruction), and `LAMBDA` values are first-class (callable via `LET` and the
higher-order helpers). Operators broadcast element-wise over ranges/arrays
(`A1:A10>5`); scalar *functions* don't auto-map over an array argument — use
`MAP` for that (e.g. `MAP(SEQUENCE(10), LAMBDA(x, MOD(x,2)))`). **Stubbed** (documented `#N/A`): functions needing
external data, a host app, or a cube/OLAP connection (`WEBSERVICE`,
`STOCKHISTORY`, `CUBE*`, `RTD`, …).
## Format support
| Format | Read | Write | Notes |
|--------|:----:|:-----:|-------|
| XLSX (OOXML) | ✅ | ✅ | styles, shared strings, formulas, merges, frozen panes, named ranges, tables; opaque parts preserved; streaming reads for huge files |
| XLS (BIFF8) | ✅ | ✅ | the only pure-Rust BIFF8 **writer**; formulas stored as cached values |
| CSV / TSV | ✅ | ✅ | delimiter auto-detection, BOM handling, `encoding_rs` transcoding, type inference |
Password-protected files are decrypted with `--password` (ECMA-376 agile and
standard encryption — AES with SHA-1/256/384/512); legacy binary RC4/XOR and the
rare "extensible" scheme are detected and named but not decrypted.
## Building & testing
```sh
cargo test # full suite (CLI + TUI features)
cargo test --no-default-features # core only — fast, no UI deps
cargo clippy --all-targets --all-features -- -D warnings
cargo bench # recalc + xlsx round-trip timings
cargo run --example gen_fixtures # regenerate the binary test fixtures
```
The data model uses sparse storage (`BTreeMap` keyed by cell position), so a
sheet with a handful of cells in a million-row grid costs only those cells.
## License
Licensed under either of [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE) at
your option.