calc_rs
A strongly typed, spreadsheet-style expression engine — currency, tonnage, percentages, dates, durations, and tables are distinct first-class types, not just decorated numbers. A Rust rewrite of Attica-oss/calc, usable as a CLI, a Rust library, or a Python extension module.
Install
CLI — a prebuilt binary, no Rust toolchain needed:
uv tool install calc-rust # then run: calc_rs
uvx --from calc-rust calc_rs '$5.00 * 3'
pipx install calc-rust
or from source:
cargo install --path . --bin calc_rs
Python library (import calc_rs) — also ships the CLI as
python -m calc_rs, for locked-down environments that block standalone
executables but allow pip packages:
pip install calc-rs-lang
python -m calc_rs '$5.00 * 3'
python -m calc_rs # REPL
(The pure-Python front end has no line editing or syntax highlighting,
but the same value formatting — tables and all — via calc_rs.render.)
CLI usage
For the full language reference — every type, operator, cast, and built-in function, with worked examples — see docs/USAGE.md. The quick tour below covers the basics.
calc_rs '$5.00 * 3' # $15.00 (currency)
calc_rs # start the REPL
calc_rs --bare '1 + 2 * 3' # 7 (value only, for scripts)
calc_rs path/to/script.calc # run a file — same as any other interpreter
calc_rs run main.calc # run a project: main.calc can `import` its sibling files
That last form is what makes an editor's "run current file" binding a
one-liner — see docs/LSP.md for editor setup (diagnostics,
completion, hover) and this repo's own .zed/tasks.json
for a worked Zed example (task: spawn → "calc: run current file").
A project is just a directory of .calc files — import "lib/tax" loads
lib/tax.calc, relative to the entry file's folder, and everything it
declares (fn, struct, enum, impl) lands in scope. See
Projects and modules and the runnable
examples/project/.
Statements are separated by ; or a newline, so scripts read
naturally:
calc_rs $'let price = $12.50\nlet qty = 3\nprice * qty' # $37.50
let is a one-time declaration for the rest of the session — let x = 1 a second time is an error. let mut x = 1 allows reassigning
x later, to the same type freely or a different type only via an
explicit cast (let x = x::TEXT). See
docs/USAGE.md for the full rule.
struct/enum declarations persist the same way fn does, and a
type name (like every enum variant) must start uppercase:
struct Person { name: text, age: int }
enum Status { Open, Pending, Closed }
let ada = Person { name: "Ada", age: 30 }
ada::name # "Ada" — field access
match Status::Open { Status::Open => "new", _ => "old" }
A struct can grow methods in a separate impl block — Self stands for
the enclosing struct, and receiver::method(args) calls one:
impl Person {
fn is_adult(self: Self) -> boolean = self::age >= 18
}
ada::is_adult() # TRUE
See docs/USAGE.md for field-access
rules, nominal typing, the struct-literal/match-subject
disambiguation, and impl blocks and
methods.
:: is genuinely overloaded between a scalar cast (x::DATE) and field
access (t::date, ada::name) — the target's case, as written, decides
which every time, and a column/field name is itself always normalized to
lower_snake_case ("Date of Service" → date_of_service), so a real
field can never be shaped like an uppercase cast target in the first place.
See docs/USAGE.md.
range(5) is [0, 1, 2, 3, 4]; range(start, stop[, step]) and the
half-open 1..10 (== range(1, 10)) give integer sequences to iterate
or pipe: range(1, 101) |> sum. 1..=10 is the inclusive counterpart
([1, .., 10]); single-quoted 'a'..'d'/'a'..='d' do the same over
chars (['a','b','c']/['a','b','c','d']) — a char literal like
'a' is distinct from the double-quoted text "a".
x! is postfix factorial (5! == 120, ints 0..=20), and A @ B
is real matrix multiplication (distinct from A * B's elementwise
one): matrix(array(1,2),array(3,4)) @ matrix(array(5,6),array(7,8))
== [[19,22],[43,50]].
Conditionals
if COND then A else B is an expression — it has a value, so it nests
and pipes like any other. It's sugar for the lazy if(COND, A, B)
builtin: only the taken branch is evaluated, and both branches must
have the same type.
if score >= 90 then "A"
else if score >= 80 then "B"
else "C"
match compares one value against a list of alternatives and usually
needs a _ catch-all last:
match status {
"OPEN" => 1,
"PENDING" => 2,
_ => 0,
}
Every arm must share a type and each pattern must be comparable to the
subject with =. The subject (status, here) is evaluated exactly
once no matter how many patterns it's checked against; patterns past
the one that matches are never evaluated. (if, then, else,
match, and _ are ordinary identifiers everywhere else —
if(...) is still the builtin call.)
_ can be left out only when every case is provably covered some
other way — currently just a match over an enum with every variant
named by a literal Enum::Variant pattern:
enum Status { Open, Closed }
match Status::Open { Status::Open => 1, Status::Closed => 0 } // no '_' needed
See docs/USAGE.md for why _ is
otherwise mandatory (there's no general exhaustiveness checking) and
what does/doesn't count as covering a variant.
Text
split("a,b,c", ",") # ["a", "b", "c"]
concat(" / ", false, parts) # "a / b / c" (also works on a column)
replace("2026-01-02", "-", "") # "20260102"
contains(s, "err") · starts_with(s, "S") · ends_with(s, ".csv")
trim(" x ") · pad_left("7", 3) · pad_right("7", 3)
Double-quoted strings interpolate ${…}: "Hi ${name}, ${n + 1} rows".
Each ${expr} is cast to text; write a literal ${ as \${. The
expression can hold balanced braces ("${match n { 1 => 10, _ => 0 }}")
but not its own " — use concat(...) for that.
Sequences
map / filter / reduce / any / all / reverse work on an array
or a column. Their expression argument is row-scoped like a table
verb's, with [_] bound to the current element (and [acc] to the
running total in reduce):
range(1, 11)
|> filter([_] % 2 == 0) # [2, 4, 6, 8, 10]
|> map([_] * [_]) # [4, 16, 36, 64, 100]
|> reduce(0, [acc] + [_]) # 220
any(scores, [_] < 50) # is anyone failing?
map and filter return a plain array; reduce returns whatever its
starting value's type is, and its expression must match that type.
Equality is = (or ==); % and // stay integer when both operands
are integers. sort works on arrays/columns too, ascending or
sort(arr, "desc").
range(start, ∞) (or start..∞) gives a lazy sequence instead — nothing
computed until take() asks for it. map/filter chain onto it without
ever materializing an intermediate array:
1..∞ |> map(|x| x * 2) |> filter(|x| x % 3 == 0) |> take(5) # [6, 12, 18, 24, 30]
Closure params in that position (|x|) take their type from the sequence
and can't carry an annotation — same rule as [_]. reduce/any/all/
sort don't accept a lazy sequence yet; take() it into an array first.
Standard library
import "name" brings a bundled module of ordinary calc fns into
scope — math (clamp/lerp/sign), stats (mean/median/
variance/stdev/zscore), finance (npv/fv/pv/pmt), and
calendar (is_weekend/next_business_day/fiscal_quarter):
import "stats"
import "finance"
median(scores)
npv(0.08, cashflows)
See docs/USAGE.md for every
function's signature — and its source, stdlib/*.calc, if
you want to see map/filter/reduce used for real.
Prelude
fn and let definitions persist across a REPL session but not
between them. To keep a library of helpers, put them in a .calc file:
~/.config/calc_rs/prelude.calc is loaded automatically, or pass
--prelude FILE (--no-prelude skips it). It applies to one-shot runs
too.
# ~/.config/calc_rs/prelude.calc
fn age(dob: date) -> int = today()::YEAR - dob::YEAR
let vat = 20%
calc_rs 'age(1990-06-15)' # 35
calc_rs --prelude team.calc 'headcount |> avg'
The REPL is a plain line editor with live syntax highlighting. A newline
inside an expression is insignificant (1 +⏎2 is 3), and the prompt
keeps reading while a line has an open (/[ or ends on an operator —
so multi-line table(...) / sort(filter(...)) just work, with each
continuation line auto-indented to its bracket depth. Press Enter on a
blank line twice to force-submit anyway and let the parser point at
whatever's unbalanced. ↑/↓ recall previous input.
REPL commands start with : so they never shadow your own names —
:help, :vars (what's bound), :clear, :reset (forget every
variable), :quit (or Ctrl-D). A trailing ; on an expression hides
its value and shows only its type.
Tab completes function names, plus the variables and fns you've
defined this session (sel⇥ → select; several matches fill in as far
as they agree). Once you open a call, a dimmed signature trails the
cursor — round( → x: number, digits: int = 0) — shrinking as you
fill in arguments.
Editor support
calc_rs --lsp runs the same binary as a Language Server (diagnostics,
completion, hover) — cargo install --path ., then see
docs/LSP.md for editor wiring.
Tables
Tables are ordinary values — build, edit, query, and persist them with functions, in the REPL or one-shot:
table("item:text, qty:int, price:currency") # empty, typed
column("day", "Mon", "Tue") # a named column
table(column("x", 1, 2), column("y", 3, 4)) # from columns
table(Item, rows) # from an array of Item rows, in one pass
append(t, "Bolt", 250, $0.08) # add a row (type-checked)
append(t, Item { item: "Bolt", qty: 250, price: $0.08 }) # or the whole row as a struct
extend(t, "total", [qty] * [price]) # add a computed column
setcell(t, 0, "qty", 5) # replace one cell
droprow(t, 2) · rename(t, "price", "unit_price")
select(t, "item", "total") · filter(t, [qty] > 100)
sort(t, [total], "desc") · groupby(t, "item", "total", "sum")
lookup(t, "port", ports, "code", "name") # add a matched column
open("sales.json") # read a JSON workbench file
save(sort(open("sales.json"), [total], "desc"), "ranked.json")
load("shipments.csv", "date:date, port:text, box:container, amount:currency")
load imports CSV against a declared schema and casts every cell to its
type. A bad cell doesn't become text — it's reported with its line,
column, raw value, and the reason, and load fails:
shipments.csv: 2 problem(s):
row 45, column "date": "2026-13-01" is not a valid date.
row 184, column "box": Invalid container check digit: expected 6, got 0.
A declared struct works in place of the schema string, for both table()
and load() — write the shape once, reuse it everywhere that shape recurs,
rather than retyping the same "name:type, ..." string every time:
struct Shipment { date: date, port: text, box: container, amount: currency }
table(Shipment) # an empty table of that shape
load("jan.csv", Shipment) # and the same shape for every file
load("feb.csv", Shipment)
Each verb returns a new table, so they compose. [col] inside
filter / sort / extend refers to that column in the current row.
The |> operator threads a value into the next call's first argument —
x |> f(a) is f(x, a) — so a pipeline reads top-to-bottom instead of
inside-out. In the REPL a line that opens with |> continues from the
last result (|> f runs _ |> f), so you can grow a pipeline one
line at a time:
open("sales.json")
|> filter([region] == "EU")
|> extend("net", [gross] - [tax])
|> groupby("region", "net", "sum")
|> sort([sum_net], "desc")
|> save("eu_by_region.json")
Python usage
import calc_rs
calc_rs.evaluate("$5.00 * 3") # Currency($15.00)
calc_rs.evaluate("1 + 2 * 3") # 7
calc_rs.check("$5.00 * 3") # 'currency' (no evaluation)
calc = calc_rs.Calculator()
calc.set("price", calc_rs.Currency("12.50"))
calc.eval("let qty = 3")
calc.eval("price * qty") # Currency($37.50)
calc.get("qty") # 3
Currency, tonnage, percent, duration, and complex results come back as
dedicated classes — Currency/Tonnage/Percent expose .amount,
Duration exposes .months/.days/.seconds, Complex exposes
.real/.imag. Numbers, text, booleans, and dates/times come back as
native Python types. The wheel ships a type stub and py.typed.
Rust library
[dependencies]
calc_rs = { git = "https://github.com/Attica-oss/calc_rs" }
Everything the common cases need is re-exported at the crate root (or
grab it in one line with use calc_rs::prelude::*):
use calc_rs::prelude::*;
// one-shot: an expression or a full `let x = 5; x + 1` script
let out = evaluate("$5.00 * 3")?;
assert_eq!(out.to_string(), "$15.00");
assert_eq!(out.ty.to_string(), "currency");
// a session that remembers variables and `_`, like the REPL
let mut calc = Calculator::new();
calc.eval("let price = $12.50")?;
calc.eval("let qty = 3")?;
assert_eq!(calc.eval("price * qty")?.to_string(), "$37.50");
// type-check without evaluating
assert_eq!(calc.check("price * qty")?.to_string(), "currency");
evaluate and Calculator::eval return an Evaluated — value
(a Value), ty (the inferred Type), bindings (every let the
script made), assigned, and used_vars. For a custom function
registry, drop to calc_rs::run_script(source, vars, functions).
Notebook
A marimo notebook backed by this engine lives in
notebook/, replicating the expression-editor and table-builder workflow of
the original Python project's notebook:
pip install -e '.[notebook]'
maturin develop --release
marimo edit notebook/calc_notebook.py
Development
cargo test # engine tests
maturin develop --release # build + install the Python extension
License
MIT — see LICENSE.
Release files for calc-rs-lang 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| calc_rs_lang-0.2.1.tar.gz | 352.5 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| calc_rs_lang-0.2.1-cp310-abi3-win_amd64.whl | CPython 3.10 | abi3 | Windows x86-64 | Details |
| calc_rs_lang-0.2.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.10 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| calc_rs_lang-0.2.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.10 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| calc_rs_lang-0.2.1-cp310-abi3-macosx_11_0_arm64.whl | CPython 3.10 | abi3 | macOS 11.0+ ARM64 | Details |
| calc_rs_lang-0.2.1-cp310-abi3-macosx_10_12_x86_64.whl | CPython 3.10 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 9.0 MB
Release files / calc_rs_lang-0.2.1.tar.gz
| Download URL | calc_rs_lang-0.2.1.tar.gz |
|---|---|
| Size | 352.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5d6ead70bcb68b5a239bc470658f4a8bd7f91c441a3a3d33199a5bbde63b14e5
|
|
BLAKE2b-256 checksum How to use checksums |
b9a1c3b9b99458924bac8cd6ac9ee96effdf3a693a11111c6ac2435aa7756f3a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency logRelease files / calc_rs_lang-0.2.1-cp310-abi3-win_amd64.whl
| Download URL | calc_rs_lang-0.2.1-cp310-abi3-win_amd64.whl |
|---|---|
| Size | 1.5 MB |
| Tags | CPython 3.10 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
a29103e6026464d8ea8aef2e6036f496204c42f01e25ba3529b16490a3ebf83d
|
|
BLAKE2b-256 checksum How to use checksums |
832c925c6fc611f9e402c7200964f493696436984eb09fb328f208a12a0b0eec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency logRelease files / calc_rs_lang-0.2.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | calc_rs_lang-0.2.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 1.9 MB |
| Tags | CPython 3.10 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
3784e6ce7f8f316a8c7828e10d7126c6725522a974ae671997782f2529016c9b
|
|
BLAKE2b-256 checksum How to use checksums |
c1e82152ea8d42d78b9124842aed680b2a28d3b72460bebd4c2e031991225863
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency logRelease files / calc_rs_lang-0.2.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | calc_rs_lang-0.2.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 1.9 MB |
| Tags | CPython 3.10 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
b64757c52c7154791a0e906c4a98774ad1c8506c389aef6a8c18fc45e5b90c3a
|
|
BLAKE2b-256 checksum How to use checksums |
c1771e0b0b76267c49a582a6115f65c2f063ac76528ffa48da8fce21fd74e162
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency logRelease files / calc_rs_lang-0.2.1-cp310-abi3-macosx_11_0_arm64.whl
| Download URL | calc_rs_lang-0.2.1-cp310-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 1.7 MB |
| Tags | CPython 3.10 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
86b8be6f5ed7b82099bba04148848a3f714dc69d30b41a11056914576d7af85c
|
|
BLAKE2b-256 checksum How to use checksums |
cd099a9916b508470c8eaf8bab42fec5d9833e2ea050cdeb5201cc926c4a170c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency logRelease files / calc_rs_lang-0.2.1-cp310-abi3-macosx_10_12_x86_64.whl
| Download URL | calc_rs_lang-0.2.1-cp310-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 1.7 MB |
| Tags | CPython 3.10 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
b394407a04369a9ac7b1b65afa9731569ef083e767ec90ed935339d874599db0
|
|
BLAKE2b-256 checksum How to use checksums |
d5b6efcd353f906e292d0757c82792767f1058a5ea96eaba772fc83e32396acf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency log