Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e789dc8
chore(rust): add var-core, the pure functional core port
AndreasKoestler Jul 12, 2026
2f3ef90
docs(rust): plan the full port — ADRs 0006/0007, design spec, task plan
AndreasKoestler Jul 12, 2026
6d7be7c
chore(rust): add var facade + registry/plan/trace conformance gates
AndreasKoestler Jul 12, 2026
625e88e
chore(rust): add var-config reader + its conformance corpus gate
AndreasKoestler Jul 12, 2026
6932288
chore(rust): add var-runner shell (discovery, run, render, drift store)
AndreasKoestler Jul 12, 2026
5f5acd4
chore(rust): add var-cargotest adapter (libtest-mimic, ADR 0007)
AndreasKoestler Jul 12, 2026
d5847fb
chore(rust): run the sample through the var-cargotest adapter
AndreasKoestler Jul 12, 2026
af8e060
chore(rust): wire the sample into the gate — make, CI, README, releas…
AndreasKoestler Jul 13, 2026
d4108c5
test(py/var-core): add sentences and step-role unit tests
AndreasKoestler Jul 12, 2026
eca7730
chore(rust): add a stimulus/sensor Steps builder and author via it
AndreasKoestler Jul 13, 2026
00d20c9
chore(rust): tree-sitter dialect + LSP/website integration for Rust
AndreasKoestler Jul 13, 2026
d327142
chore(rust): add a parked crates.io release target
AndreasKoestler Jul 13, 2026
138c9a1
chore(rust): custom-type capture groups, variadic handlers, UTF-16 ke…
AndreasKoestler Jul 13, 2026
8ebce28
chore(rust): declare the panic-hook exception, scanner-plugin note, p…
AndreasKoestler Jul 13, 2026
e0d3b83
Words
Jul 17, 2026
f315741
chore(rust): pin examples/rust-cargotest to the 1.97 toolchain
Jul 18, 2026
ccb4f5c
chore(website): show Rust step files in the front-page examples
Jul 18, 2026
aab140d
chore(website): fail the build when an <Editor> lacks a port's examples
Jul 18, 2026
07851f0
docs: note the front-page <Editor> examples in the add-a-port checklist
Jul 18, 2026
256b04b
chore(rust): capture step source file/line automatically, drop the FI…
Jul 18, 2026
1b04896
chore(rust): accept bare closures as step handlers via IntoHandler
Jul 18, 2026
1f264a1
docs: record step-location capture and handler-arity ergonomics as au…
Jul 18, 2026
5ab7edd
chore(website): syntax-highlight Rust in the editors, and gate every …
Jul 18, 2026
c4fb9c3
docs: add CodeMirror editor highlighting to the add-a-port checklist
Jul 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion .claude/skills/adding-a-language-port/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ on the "skip for v1" list; drift promoted it to required.)

## Author-API fork points (decide explicitly, don't copy blindly)

The facade shape is **not** identical across ports — two decisions legitimately
The facade shape is **not** identical across ports — these decisions legitimately
fork on the target language's idioms. Record your choice in the design doc:

- **Registration mechanism.** TS/Python use a **module-scope mutable
Expand All @@ -229,6 +229,27 @@ fork on the target language's idioms. Record your choice in the design doc:
returns the whole next state). This changes the executor's merge step and the
sensor slot contract, so decide it in Task 1 — it's the single biggest
author-API fork.
- **Step source location (file/line).** The registry records each step's source
`file`/`line`; the file's *stem* becomes the trace's `stepFile` (shared
cross-language, so it must be the canonical `<name>.steps` stem, not the
physical path). TS/Python read it automatically from the imported module
(`Error().stack`). A port with an **injected Registrar** (no import) should use
the language's native **call-site capture** rather than making authors pass
`file`/`line` per step: Rust marks `stimulus`/`sensor` `#[track_caller]` and
reads `Location::caller()` — because its conformance fixtures are real
`<name>.steps.rs` files reached via `#[path]`, `file_stem` of that path yields
the canonical stem for free (`line` is diagnostic-only — in no golden). Reach
for hand-passed identifiers only if the language has no call-site-location
facility.
- **Handler shape (arity).** A handler is `(state, …captures) → partial|value`.
Don't make authors name the arity or wrap the closure if the language can infer
it. Rust uses an `IntoHandler<Args>` trait (the axum/bevy pattern) with one impl
per capture-count, so `sensor("…", |state, a| …)` infers each `Value` parameter
from the bare closure — while an already-built handler (async, variadic, or a
no-op) passes through a `Handler`-typed impl. Keep explicit fixed-arity/variadic
constructors in the *core* for its own tests; the closure sugar belongs in the
*facade*. (The 3+-capture and async forms stay explicit — a bare 2-arg closure
can't disambiguate `(Value,Value)` from `(Value,Vec)`.)

## Test-framework adapter pattern

Expand Down Expand Up @@ -318,6 +339,25 @@ suite:
`<Tabs syncKey="lang">` group across `reference/*` and `how-to/*` (and the
get-started steps tabs), sourcing correct snippets from the new port's example
and conformance step files. The label must match `languages.json` exactly.
- **Front-page `<Editor>` examples**: the interactive editors in
`typescript/packages/website/src/components/examples/*.astro`
(DeepThought/Library/RomanNumerals) hard-code one `<File uri="…">` tab per
language, imported `?raw` from the `examples/<lang>-*` project. Add a `<File>`
(steps, plus the logic file where the other languages have one) for the new
language's `.<ext>` to each editor. This is a *distinct* surface from the docs
`<Tabs>` above and is the one most often forgotten — but `Editor.astro` now
asserts at build time that every port in `languages.json` has a code tab, so a
missing one is a hard build error (message names the language). It's caught in
the PR gate because `make typescript` / the CI `test` job build the website
(`pnpm --filter @oselvar/website... build`); run either to surface what you owe.
- **CodeMirror editor highlighting**: add the language's syntax highlighter to
`CM_LANGUAGE` in `typescript/packages/website/src/lib/cm-languages.ts` — an
official `@codemirror/lang-<lang>` (Lezer, like ts/java/python) if one exists,
else a `StreamLanguage.define(<legacy-mode>)` from `@codemirror/legacy-modes`
(like kotlin/ruby). `CM_LANGUAGE` is a `Record<SiteLang, …>`, so a missing port
is a type error; `tests/cm-languages.test.ts` also asserts every `SiteLang` has
a working highlighter, so it's a red test in the `pnpm check` gate (the website
itself isn't type-checked in CI, so that test — not tsc — is the enforcement).
- **Tree-sitter dialect** (the LSP/editor authoring surface — a *required*
deliverable now, not deferred): create
`typescript/packages/var-language/src/tree-sitter-dialects/<lang>.ts`
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Rust

on:
push:
branches: [main]
paths:
- 'rust/**'
- 'conformance/**'
- 'examples/**'
- '.github/workflows/rust.yml'
pull_request:
paths:
- 'rust/**'
- 'conformance/**'
- 'examples/**'
- '.github/workflows/rust.yml'
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

# The pure functional core: fmt + clippy (-D warnings) + the conformance
# harness (var-doc golden gate over 15 bundles + drift/hash units).
- run: cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
working-directory: rust

# Standalone sample project (see examples/rust-cargotest): runs the
# Markdown specs through var-core via `cargo test`.
- run: cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
working-directory: examples/rust-cargotest
6 changes: 6 additions & 0 deletions .github/workflows/typescript.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ jobs:

- run: pnpm check

# Build the website on PRs too: `pnpm build`/`pnpm check` exclude it, but
# its <Editor> components assert at build time that every port in
# languages.json has example files, so this is where a forgotten port's
# missing examples become a red build instead of surfacing only at deploy.
- run: pnpm --filter @oselvar/website... build

deploy-website:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ pnpm workspace · biome · vitest (for the core's own tests) · knip · jscpd ·
`.github/workflows/` (`typescript.yml`, `python.yml`, `java.yml`, `ruby.yml` —
all also trigger on `conformance/**`).
- **Trunk-based development.** We commit small, working increments straight to `main` — no long-lived feature branches. Keep each commit self-contained and green (build + tests pass), so trunk is always releasable.
- **Type-check is a separate gate.** vitest runs source through esbuild/tsx, which strips types without checking them — a fully green suite can still fail `tsc`. Run `pnpm -r build` (exit 0) before calling any change done, especially after touching a shared type, an AST node, or a package's public exports (new required fields and new exports are the usual culprits). Note `pnpm build` excludes both website packages — the Starlight website is built (and deployed to https://var.oselvar.com) only by the `deploy-website` CI job via `pnpm --filter @oselvar/website... build`; the legacy `packages/website` is never built. To check the website locally: `pnpm --filter @oselvar/website build`.
- **Type-check is a separate gate.** vitest runs source through esbuild/tsx, which strips types without checking them — a fully green suite can still fail `tsc`. Run `pnpm -r build` (exit 0) before calling any change done, especially after touching a shared type, an AST node, or a package's public exports (new required fields and new exports are the usual culprits). Note `pnpm build` and `pnpm check` both exclude the website packages — the Starlight website is built separately via `pnpm --filter @oselvar/website... build`, run in two CI places: the `test` job (a PR gate — its `<Editor>` components assert every port in `languages.json` has example files, so a forgotten port fails the build) and the `deploy-website` job (which also deploys to https://var.oselvar.com). The legacy `packages/website` is never built. To check the website locally: `pnpm --filter @oselvar/website build`.
- `pnpm -r build` only type-checks each package's `src/` (its `tsconfig.json` emits with `rootDir: src`). **Test files (`tests/**`) are type-checked by `pnpm typecheck`** (root `tsconfig.tests.json`, `noEmit`, covers every non-website package's `tests/`). It's part of `pnpm check`, so run `pnpm check` (or `pnpm typecheck` alone) after touching tests — a green vitest run does *not* mean the tests type-check. Note `expectTypeOf` assertions are validated here by `tsc`, not by vitest (we don't run `vitest --typecheck`).
- **Dogfood specs**: `examples/typescript-vitest` (package
`@oselvar/example-typescript-vitest`, a pnpm workspace member via
Expand Down
15 changes: 11 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Build and test every language port from the repo root.
#
# make # same as `make check`: all four ports
# make # same as `make check`: every port
# make typescript # pnpm build + pnpm check (lint, typecheck, test, knip, jscpd)
# make python # pytest + ruff + no-reexports gate + examples/python-pytest
# make java # spotless:apply (formats Java + Kotlin, incl. the JVM sample
Expand All @@ -10,21 +10,22 @@
# make ruby # bundle + rake (rubocop + rspec + purity gate) +
# # examples/ruby-rspec and examples/ruby-minitest (Ruby 3.2,
# # pinned in ruby/.tool-versions)
# make rust # cargo fmt/clippy/test (var-core) + examples/rust-cargotest
# make coverage # test with coverage in all four ports (reports below)
#
# Each target runs the same gate as that port's CI workflow in .github/workflows/.

.PHONY: check commits typescript python java ruby coverage changelog prepare release
.PHONY: check commits typescript python java ruby rust coverage changelog prepare release

check: commits typescript python java ruby
check: commits typescript python java ruby rust

# Commits since the last release tag must be conventional (they drive the
# changelog and the version bump — see cliff.toml and CLAUDE.md).
commits:
release/lint-commits.sh

typescript:
cd typescript && pnpm install && pnpm build && pnpm check
cd typescript && pnpm install && pnpm build && pnpm check && pnpm --filter @oselvar/website... build

python:
# Drop any .venv left pointing at an old checkout path (e.g. after a repo
Expand All @@ -48,6 +49,12 @@ ruby:
cd examples/ruby-rspec && bundle install && bundle exec rspec
cd examples/ruby-minitest && bundle install && bundle exec rake test

# Rust port: pure cargo (var-core), then the standalone sample project (which
# depends on var-core by path and runs the Markdown specs via `cargo test`).
rust:
cd rust && cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
cd examples/rust-cargotest && cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test

# Coverage reports: typescript/coverage/index.html, python/htmlcov/index.html,
# java/<module>/target/site/jacoco/index.html (jacoco runs on every verify),
# ruby/coverage/index.html. lcov files (typescript/coverage/lcov.info,
Expand Down
4 changes: 4 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
- [x] Remove @navikt/ds-css
- [x] Tabs for examples on front page and tutorial
- [ ] Prerender all editors
- [ ] Nicer frontpage - inspiration:
- [ ] https://www.kaggle.com/
- [ ] https://www.val.town/
- [ ] https://fly.io/

## Terminiology
- [ ] Oath: an .md doc with 1 or more examples
Expand Down
58 changes: 58 additions & 0 deletions conformance/bundles/01-roman-numerals/numerals.steps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//! Rust sibling of `numerals.steps.ts` (bundle `01-roman-numerals`).
//!
//! Full-replacement state (ADR 0006): the `{result}` map is the whole state.

use std::collections::BTreeMap;
use var::{HandlerError, Registry, Steps, Value};

fn roman(n: i64) -> Option<&'static str> {
match n {
1 => Some("I"),
4 => Some("IV"),
9 => Some("IX"),
40 => Some("XL"),
_ => None,
}
}

pub fn register(r: Registry) -> Registry {
let mut s = Steps::from_registry(r);
s.stimulus("I convert {int} to roman numerals", |_state, n| {
let n = if let Value::Int(i) = n { i } else { 0 };
let mut m = BTreeMap::new();
if let Some(s) = roman(n) {
m.insert("result".to_string(), Value::from(s));
}
Ok(Some(Value::Map(m)))
});
s.sensor("The result is {word}", |state, expected| {
// {word} greedily captures trailing punctuation ("I." not "I"); strip
// it, then throw on mismatch rather than returning (which would make
// the core compare the RAW captured "I." and wrongly fail). Returning
// None opts out, matching the .ts/.java sensors.
let expected = if let Value::String(s) = expected {
s
} else {
String::new()
};
let cleaned = expected.trim_end_matches(['.', '!', '?']);
let result = match &state {
Value::Map(m) => match m.get("result") {
Some(Value::String(s)) => s.clone(),
_ => String::new(),
},
_ => String::new(),
};
if cleaned != result {
return Err(HandlerError::new(format!(
"expected {cleaned} but got {result}"
)));
}
Ok(None)
});
s.into_registry()
}

pub fn state() -> Value {
Value::Map(BTreeMap::new())
}
40 changes: 40 additions & 0 deletions conformance/bundles/02-context-isolation/counter.steps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//! Rust sibling of `counter.steps.ts` (bundle `02-context-isolation`).

use std::collections::BTreeMap;
use var::{HandlerError, Registry, Steps, Value};

fn count_of(state: &Value) -> i64 {
match state {
Value::Map(m) => match m.get("count") {
Some(Value::Int(i)) => *i,
_ => 0,
},
_ => 0,
}
}

pub fn register(r: Registry) -> Registry {
let mut s = Steps::from_registry(r);
s.stimulus("I increment", |state| {
let next = count_of(&state) + 1;
Ok(Some(Value::Map(BTreeMap::from([(
"count".to_string(),
Value::Int(next),
)]))))
});
s.sensor("The count is {int}", |state, n| {
let count = count_of(&state);
let expected = if let Value::Int(i) = n { i } else { 0 };
if count != expected {
return Err(HandlerError::new(format!(
"expected {expected} but got {count}"
)));
}
Ok(None)
});
s.into_registry()
}

pub fn state() -> Value {
Value::Map(BTreeMap::from([("count".to_string(), Value::Int(0))]))
}
19 changes: 19 additions & 0 deletions conformance/bundles/03-expected-failure/division.steps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//! Rust sibling of `division.steps.ts` (bundle `03-expected-failure`).

use var::{HandlerError, Registry, Steps, Value};

pub fn register(r: Registry) -> Registry {
let mut s = Steps::from_registry(r);
s.stimulus("I divide {int} by {int}", |state, _a, b| {
let b = if let Value::Int(i) = b { i } else { 0 };
if b == 0 {
return Err(HandlerError::new("division by zero"));
}
Ok(Some(state))
});
s.into_registry()
}

pub fn state() -> Value {
Value::Null
}
15 changes: 15 additions & 0 deletions conformance/bundles/04-tables-and-docstrings/echo.steps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//! Rust sibling of `echo.steps.ts` (bundle `04-tables-and-docstrings`).

use var::{Registry, Steps, Value};

pub fn register(r: Registry) -> Registry {
let mut s = Steps::from_registry(r);
// The doc string is this sensor's only slot, so it is returned bare; the
// core compares it against the input (compareDocString); equal passes.
s.sensor("I echo the following:", |_state, doc| Ok(Some(doc)));
s.into_registry()
}

pub fn state() -> Value {
Value::Null
}
15 changes: 15 additions & 0 deletions conformance/bundles/05-ambiguous-match/cukes.steps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//! Rust sibling of `cukes.steps.ts` (bundle `05-ambiguous-match`).

use var::{Registry, Steps, Value};

pub fn register(r: Registry) -> Registry {
let mut s = Steps::from_registry(r);
// Both expressions match "I have 5 cukes" → ambiguous-match diagnostic.
s.stimulus("I have {int} cukes", |_state, _n| Ok(None));
s.stimulus("I have 5 cukes", |_state| Ok(None));
s.into_registry()
}

pub fn state() -> Value {
Value::Null
}
17 changes: 17 additions & 0 deletions conformance/bundles/06-doc-string-mismatch/echo.steps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//! Rust sibling of `echo.steps.ts` (bundle `06-doc-string-mismatch`).

use var::{Registry, Steps, Value};

pub fn register(r: Registry) -> Registry {
let mut s = Steps::from_registry(r);
// Returns the WRONG string (bare — the doc string is the only slot); the
// core compares it to the doc string and throws DocStringMismatchError.
s.sensor("I echo the following:", |_state, _doc| {
Ok(Some(Value::from("goodbye")))
});
s.into_registry()
}

pub fn state() -> Value {
Value::Null
}
21 changes: 21 additions & 0 deletions conformance/bundles/07-row-check-mismatch/report.steps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//! Rust sibling of `report.steps.ts` (bundle `07-row-check-mismatch`).

use std::collections::BTreeMap;
use var::{Registry, Steps, Value};

pub fn register(r: Registry) -> Registry {
let mut s = Steps::from_registry(r);
// Header-bound row step: returns its computed columns; the core diffs them
// against the row cells (rowChecks). score 99 ≠ 10 → CellMismatchError.
s.sensor("I report the score and grade", |_state, _row| {
Ok(Some(Value::Map(BTreeMap::from([
("score".to_string(), Value::from("99")),
("grade".to_string(), Value::from("A")),
]))))
});
s.into_registry()
}

pub fn state() -> Value {
Value::Null
}
13 changes: 13 additions & 0 deletions conformance/bundles/08-string-capture/greet.steps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! Rust sibling of `greet.steps.ts` (bundle `08-string-capture`).

use var::{Registry, Steps, Value};

pub fn register(r: Registry) -> Registry {
let mut s = Steps::from_registry(r);
s.stimulus("I greet {string}", |_state, _name| Ok(None));
s.into_registry()
}

pub fn state() -> Value {
Value::Null
}
Loading