Metadata-Version: 2.4
Name: calc-rs-lang
Version: 0.2.1
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Libraries
Requires-Dist: marimo>=0.24.0 ; extra == 'notebook'
Requires-Dist: anywidget>=0.11.0 ; extra == 'notebook'
Requires-Dist: traitlets>=5.16.1 ; extra == 'notebook'
Requires-Dist: maturin>=1.15.0 ; extra == 'notebook'
Provides-Extra: notebook
License-File: LICENSE
Summary: A strongly typed, spreadsheet-style expression engine
Keywords: calculator,expression,spreadsheet,currency,rust
Author: Attica-oss
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Repository, https://github.com/Attica-oss/calc_rs

# 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](https://github.com/Attica-oss/calc), usable as a CLI, a
Rust library, or a Python extension module.

## Install

**CLI** — a prebuilt binary, no Rust toolchain needed:

```sh
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:

```sh
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:

```sh
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](docs/USAGE.md). The quick tour below covers the
basics.

```sh
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](docs/LSP.md) for editor setup (diagnostics,
completion, hover) and this repo's own [.zed/tasks.json](.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](docs/USAGE.md#projects-and-modules) and the runnable
[`examples/project/`](examples/project).

Statements are separated by `;` **or a newline**, so scripts read
naturally:

```sh
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](docs/USAGE.md#value-bindings-and-mutability) for the full rule.

`struct`/`enum` declarations persist the same way `fn` does, and a
type name (like every enum variant) must start uppercase:

```calc
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:

```calc
impl Person {
  fn is_adult(self: Self) -> boolean = self::age >= 18
}
ada::is_adult()                        # TRUE
```

See [docs/USAGE.md](docs/USAGE.md#structs-and-enums) for field-access
rules, nominal typing, the struct-literal/`match`-subject
disambiguation, and [impl blocks and
methods](docs/USAGE.md#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](docs/USAGE.md#casts-and-field-access-never-collide).

`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.

```calc
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:

```calc
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:

```calc
enum Status { Open, Closed }
match Status::Open { Status::Open => 1, Status::Closed => 0 }   // no '_' needed
```

See [docs/USAGE.md](docs/USAGE.md#exhaustiveness) for why `_` is
otherwise mandatory (there's no general exhaustiveness checking) and
what does/doesn't count as covering a variant.

### Text

```calc
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`):

```calc
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:

```calc
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 `fn`s 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`):

```calc
import "stats"
import "finance"

median(scores)
npv(0.08, cashflows)
```

See [docs/USAGE.md](docs/USAGE.md#standard-library) for every
function's signature — and its source, [`stdlib/*.calc`](stdlib), 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.

```calc
# ~/.config/calc_rs/prelude.calc
fn age(dob: date) -> int = today()::YEAR - dob::YEAR
let vat = 20%
```

```sh
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 `fn`s 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](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:

```calc
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

```python
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

```toml
[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::*`):

```rust
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](https://marimo.io) notebook backed by this engine lives in
`notebook/`, replicating the expression-editor and table-builder workflow of
the original Python project's notebook:

```sh
pip install -e '.[notebook]'
maturin develop --release
marimo edit notebook/calc_notebook.py
```

## Development

```sh
cargo test                     # engine tests
maturin develop --release      # build + install the Python extension
```

## License

MIT — see [LICENSE](LICENSE).

