diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 00000000..263b97a5 --- /dev/null +++ b/.github/workflows/rust.yml @@ -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 diff --git a/Makefile b/Makefile index 9fdcbd0a..8e8ac020 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -10,13 +10,14 @@ # 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). @@ -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//target/site/jacoco/index.html (jacoco runs on every verify), # ruby/coverage/index.html. lcov files (typescript/coverage/lcov.info, diff --git a/conformance/bundles/01-roman-numerals/numerals.steps.rs b/conformance/bundles/01-roman-numerals/numerals.steps.rs new file mode 100644 index 00000000..318b7af8 --- /dev/null +++ b/conformance/bundles/01-roman-numerals/numerals.steps.rs @@ -0,0 +1,70 @@ +//! 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::{Handler, HandlerError, Registry, Steps, Value}; + +pub const FILE: &str = "numerals.steps.rs"; + +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", + FILE, + 1, + Handler::sync1(|_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}", + FILE, + 2, + Handler::sync1(|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()) +} diff --git a/conformance/bundles/02-context-isolation/counter.steps.rs b/conformance/bundles/02-context-isolation/counter.steps.rs new file mode 100644 index 00000000..e3b9bc1c --- /dev/null +++ b/conformance/bundles/02-context-isolation/counter.steps.rs @@ -0,0 +1,52 @@ +//! Rust sibling of `counter.steps.ts` (bundle `02-context-isolation`). + +use std::collections::BTreeMap; +use var::{Handler, HandlerError, Registry, Steps, Value}; + +pub const FILE: &str = "counter.steps.rs"; + +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", + FILE, + 1, + Handler::sync0(|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}", + FILE, + 2, + Handler::sync1(|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))])) +} diff --git a/conformance/bundles/03-expected-failure/division.steps.rs b/conformance/bundles/03-expected-failure/division.steps.rs new file mode 100644 index 00000000..5423318d --- /dev/null +++ b/conformance/bundles/03-expected-failure/division.steps.rs @@ -0,0 +1,26 @@ +//! Rust sibling of `division.steps.ts` (bundle `03-expected-failure`). + +use var::{Handler, HandlerError, Registry, Steps, Value}; + +pub const FILE: &str = "division.steps.rs"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + s.stimulus( + "I divide {int} by {int}", + FILE, + 1, + Handler::sync2(|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 +} diff --git a/conformance/bundles/04-tables-and-docstrings/echo.steps.rs b/conformance/bundles/04-tables-and-docstrings/echo.steps.rs new file mode 100644 index 00000000..e13ac420 --- /dev/null +++ b/conformance/bundles/04-tables-and-docstrings/echo.steps.rs @@ -0,0 +1,22 @@ +//! Rust sibling of `echo.steps.ts` (bundle `04-tables-and-docstrings`). + +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "echo.steps.rs"; + +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:", + FILE, + 1, + Handler::sync1(|_state, doc| Ok(Some(doc))), + ); + s.into_registry() +} + +pub fn state() -> Value { + Value::Null +} diff --git a/conformance/bundles/05-ambiguous-match/cukes.steps.rs b/conformance/bundles/05-ambiguous-match/cukes.steps.rs new file mode 100644 index 00000000..a22e3663 --- /dev/null +++ b/conformance/bundles/05-ambiguous-match/cukes.steps.rs @@ -0,0 +1,22 @@ +//! Rust sibling of `cukes.steps.ts` (bundle `05-ambiguous-match`). + +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "cukes.steps.rs"; + +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", + FILE, + 1, + Handler::sync1(|_state, _n| Ok(None)), + ); + s.stimulus("I have 5 cukes", FILE, 2, Handler::sync0(|_state| Ok(None))); + s.into_registry() +} + +pub fn state() -> Value { + Value::Null +} diff --git a/conformance/bundles/06-doc-string-mismatch/echo.steps.rs b/conformance/bundles/06-doc-string-mismatch/echo.steps.rs new file mode 100644 index 00000000..3089802c --- /dev/null +++ b/conformance/bundles/06-doc-string-mismatch/echo.steps.rs @@ -0,0 +1,22 @@ +//! Rust sibling of `echo.steps.ts` (bundle `06-doc-string-mismatch`). + +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "echo.steps.rs"; + +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:", + FILE, + 1, + Handler::sync1(|_state, _doc| Ok(Some(Value::from("goodbye")))), + ); + s.into_registry() +} + +pub fn state() -> Value { + Value::Null +} diff --git a/conformance/bundles/07-row-check-mismatch/report.steps.rs b/conformance/bundles/07-row-check-mismatch/report.steps.rs new file mode 100644 index 00000000..0bb983cc --- /dev/null +++ b/conformance/bundles/07-row-check-mismatch/report.steps.rs @@ -0,0 +1,28 @@ +//! Rust sibling of `report.steps.ts` (bundle `07-row-check-mismatch`). + +use std::collections::BTreeMap; +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "report.steps.rs"; + +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", + FILE, + 1, + Handler::sync1(|_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 +} diff --git a/conformance/bundles/08-string-capture/greet.steps.rs b/conformance/bundles/08-string-capture/greet.steps.rs new file mode 100644 index 00000000..cdedcbe1 --- /dev/null +++ b/conformance/bundles/08-string-capture/greet.steps.rs @@ -0,0 +1,20 @@ +//! Rust sibling of `greet.steps.ts` (bundle `08-string-capture`). + +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "greet.steps.rs"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + s.stimulus( + "I greet {string}", + FILE, + 1, + Handler::sync1(|_state, _name| Ok(None)), + ); + s.into_registry() +} + +pub fn state() -> Value { + Value::Null +} diff --git a/conformance/bundles/09-expected-message-mismatch/boom.steps.rs b/conformance/bundles/09-expected-message-mismatch/boom.steps.rs new file mode 100644 index 00000000..de62c778 --- /dev/null +++ b/conformance/bundles/09-expected-message-mismatch/boom.steps.rs @@ -0,0 +1,22 @@ +//! Rust sibling of `boom.steps.ts` (bundle `09-expected-message-mismatch`). + +use var::{Handler, HandlerError, Registry, Steps, Value}; + +pub const FILE: &str = "boom.steps.rs"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + // Throws a message that does NOT contain the expected substring "expected + // message", so the expected-failure is NOT satisfied → the example fails. + s.stimulus( + "I always boom", + FILE, + 1, + Handler::sync0(|_state| Err(HandlerError::new("actual different error"))), + ); + s.into_registry() +} + +pub fn state() -> Value { + Value::Null +} diff --git a/conformance/bundles/10-error-fence-without-step/cukes.steps.rs b/conformance/bundles/10-error-fence-without-step/cukes.steps.rs new file mode 100644 index 00000000..8949ca48 --- /dev/null +++ b/conformance/bundles/10-error-fence-without-step/cukes.steps.rs @@ -0,0 +1,23 @@ +//! Rust sibling of `cukes.steps.ts` (bundle `10-error-fence-without-step`). + +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "cukes.steps.rs"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + // The prose matches no step, so the `error` fence has nothing to run → + // error-fence-without-step diagnostic, and the example is dropped. This + // step exists only so the registry matches the other ports'. + s.stimulus( + "I have {int} cukes", + FILE, + 1, + Handler::sync1(|_state, _n| Ok(None)), + ); + s.into_registry() +} + +pub fn state() -> Value { + Value::Null +} diff --git a/conformance/bundles/11-emoji-offsets/greet.steps.rs b/conformance/bundles/11-emoji-offsets/greet.steps.rs new file mode 100644 index 00000000..c2e5a170 --- /dev/null +++ b/conformance/bundles/11-emoji-offsets/greet.steps.rs @@ -0,0 +1,22 @@ +//! Rust sibling of `greet.steps.ts` (bundle `11-emoji-offsets`). + +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "greet.steps.rs"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + // The list item is followed by a table, appended as a trailing arg, so this + // sensor's slots are {string} + the table (returns nothing → passes). + s.sensor( + "I greet {string}", + FILE, + 1, + Handler::sync2(|_state, _name, _table| Ok(None)), + ); + s.into_registry() +} + +pub fn state() -> Value { + Value::Null +} diff --git a/conformance/bundles/12-combining-marks/greet.steps.rs b/conformance/bundles/12-combining-marks/greet.steps.rs new file mode 100644 index 00000000..5b101365 --- /dev/null +++ b/conformance/bundles/12-combining-marks/greet.steps.rs @@ -0,0 +1,20 @@ +//! Rust sibling of `greet.steps.ts` (bundle `12-combining-marks`). + +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "greet.steps.rs"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + s.sensor( + "I greet {string}", + FILE, + 1, + Handler::sync1(|_state, _name| Ok(None)), + ); + s.into_registry() +} + +pub fn state() -> Value { + Value::Null +} diff --git a/conformance/bundles/13-custom-parameter-type/airports.steps.rs b/conformance/bundles/13-custom-parameter-type/airports.steps.rs new file mode 100644 index 00000000..7859917f --- /dev/null +++ b/conformance/bundles/13-custom-parameter-type/airports.steps.rs @@ -0,0 +1,58 @@ +//! Rust sibling of `airports.steps.ts` (bundle `13-custom-parameter-type`). + +use std::collections::BTreeMap; +use std::rc::Rc; +use var::{Handler, HandlerError, ParseFn, Registry, Steps, Value}; + +pub const FILE: &str = "airports.steps.rs"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + // Custom {airport} parameter type: IATA code, lowercased by parse. The + // sensor asserts the lowercasing, so an identity parse would fail. + let parse: ParseFn = Rc::new(|g: &[&str]| Value::from(g[0].to_lowercase())); + s.param("airport", "[A-Z]{3}", parse); + + s.stimulus( + "I fly to {airport}", + FILE, + 1, + Handler::sync1(|_state, dest| { + Ok(Some(Value::Map(BTreeMap::from([( + "dest".to_string(), + dest, + )])))) + }), + ); + s.sensor( + "The destination code is {word}", + FILE, + 2, + Handler::sync1(|state, expected| { + let expected = if let Value::String(s) = expected { + s + } else { + String::new() + }; + let cleaned = expected.trim_end_matches(['.', '!', '?']); + let dest = match &state { + Value::Map(m) => match m.get("dest") { + Some(Value::String(s)) => s.clone(), + _ => String::new(), + }, + _ => String::new(), + }; + if cleaned != dest { + return Err(HandlerError::new(format!( + "expected {cleaned} but got {dest}" + ))); + } + Ok(None) + }), + ); + s.into_registry() +} + +pub fn state() -> Value { + Value::Null +} diff --git a/conformance/bundles/14-stateless-steps/squares.steps.rs b/conformance/bundles/14-stateless-steps/squares.steps.rs new file mode 100644 index 00000000..f8c80f6e --- /dev/null +++ b/conformance/bundles/14-stateless-steps/squares.steps.rs @@ -0,0 +1,34 @@ +//! Rust sibling of `squares.steps.ts` (bundle `14-stateless-steps`). +//! +//! Pure steps — nothing to arrange or evolve — so `state()` is the bare +//! [`Value::Null`] every handler ignores. + +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "squares.steps.rs"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + s.stimulus( + "I warm up my mental math", + FILE, + 1, + Handler::sync0(|_state| Ok(None)), + ); + // Two slots ({int}, {int}); the handler uses only the first and returns + // both computed columns [n, n*n] for positional comparison. + s.sensor( + "The square of {int} is {int}.", + FILE, + 2, + Handler::sync2(|_state, n, _square| { + let n = if let Value::Int(i) = n { i } else { 0 }; + Ok(Some(Value::List(vec![Value::Int(n), Value::Int(n * n)]))) + }), + ); + s.into_registry() +} + +pub fn state() -> Value { + Value::Null +} diff --git a/conformance/bundles/15-custom-parameter-format/money.steps.rs b/conformance/bundles/15-custom-parameter-format/money.steps.rs new file mode 100644 index 00000000..06e28a81 --- /dev/null +++ b/conformance/bundles/15-custom-parameter-format/money.steps.rs @@ -0,0 +1,41 @@ +//! Rust sibling of `money.steps.ts` (bundle `15-custom-parameter-format`). +//! +//! Money is encoded as a bare [`Value::Float`] (pounds); `format` renders it +//! back in document notation, so the pinned mismatch reads `£2.60` / `£2.55`. + +use std::rc::Rc; +use var::{FormatFn, Handler, ParseFn, Registry, Steps, Value}; + +pub const FILE: &str = "money.steps.rs"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + let parse: ParseFn = Rc::new(|g: &[&str]| { + let raw = g[0]; + let value = raw + .strip_prefix('£') + .unwrap_or(raw) + .parse::() + .unwrap_or(0.0); + Value::Float(value) + }); + let format: FormatFn = Rc::new(|v: &Value| match v { + Value::Float(x) => Some(format!("£{x:.2}")), + _ => None, + }); + s.param_with_format("money", r"£\d+\.\d{2}", parse, format); + + // Returns the WRONG money on purpose; the golden pins the formatted actual + // "£2.60", proving mismatches render through `format`. + s.sensor( + "The late fee is {money}", + FILE, + 1, + Handler::sync1(|_state, _expected| Ok(Some(Value::Float(2.6)))), + ); + s.into_registry() +} + +pub fn state() -> Value { + Value::Null +} diff --git a/doc/adr/0006-rust-port.md b/doc/adr/0006-rust-port.md new file mode 100644 index 00000000..ed02b9c1 --- /dev/null +++ b/doc/adr/0006-rust-port.md @@ -0,0 +1,70 @@ +# ADR 0006 — Rust as the sixth language port (full pipeline) + +- **Status:** Proposed +- **Date:** 2026-07-12 +- **Deciders:** Andreas Koestler +- **Tags:** rust, cross-language, port + +## Context + +TypeScript (reference), Python, Java, Kotlin, and Ruby are complete ports (ADRs +[0001](0001-second-language-python.md), [0004](0004-ruby-port.md)). Rust is +picked up next. Per the [`adding-a-language-port`](../../.claude/skills/adding-a-language-port/SKILL.md) +skill, the first decision is mechanical: **does the target share a runtime with +an already-ported language?** Rust does not (no interop with the JS, CPython, +JVM, or Ruby runtimes), so — like Python and Ruby, and unlike Kotlin-over-Java — +Rust is a **full pipeline port** gated on all four conformance artifacts, not a +facade over an existing engine. + +### Why Rust, why now + +- A **dependency-light, GC-free native core** widens where var can run: CLI + tools, embedded/systems test suites, and — the strategic pull — a **WebAssembly** + target for the browser playground and the website's live spec runner, which + today shells out to the TS core. +- Rust's ownership model makes the project's **immutable-by-construction** + principle a compiler guarantee rather than a runtime convention (Python/Ruby + need a `deep_freeze` helper; Rust needs none). +- It exercises the port seams against a **statically-typed, non-UTF-16** language + a second time (after the JVM), validating that the shared conformance corpus is + genuinely language-neutral. + +### Current state + +`var-core` is already ported and conformance-green on the **var-doc** artifact +(209 tests, ported 1:1 from the Java suite; drift/hash unit-gated). A standalone +`examples/rust-cargotest` sample runs the six shared example specs via +`cargo test` and matches the Python samples byte-for-byte. What remains is the +rest of the package shape and the three deferred golden gates — see the +[completion plan](../superpowers/plans/2026-07-12-rust-port-completion.md). + +## Decision + +Rust is a **full pipeline port** against the TypeScript reference, gated on all +four conformance artifacts (`var-doc`, `registry`, `plan`, `trace`) × 15 bundles +plus the config corpus, with drift unit-gated. The package shape mirrors the +other full ports (`var-core`, `var` facade, `var-config`, `var-runner`, one +test-framework adapter). Two author-API forks are settled by what `var-core` +already implements, matching the JVM ports rather than the dynamic ones: + +- **Registration:** an **injected Registrar** (`register(Registry) -> Registry`), + not a module-scope accumulator — Rust has no clean import-for-side-effect. +- **State evolution:** **full replacement** (a `stimulus` returns the whole next + state as a `Value`), not TS/Python shallow partial-merge. + +## Consequences + +- The three deferred golden gates (`registry`/`plan`/`trace`) require per-bundle + `*.steps.rs` fixtures and live in the `var` facade crate's conformance harness, + mirroring Java's `var` module. +- **cucumber-expressions divergence (accepted risk):** no official Rust port of + the `20.0.0` line the other ports pin exists. `var-core` uses the community + `cucumber-expressions` `0.5` crate for the grammar AST only, hand-writing the + regexp generation and argument extraction; `{float}` is omitted (needs + lookahead the `regex` crate lacks; unused by the corpus). The + `registry`/`plan` golden gates are the acceptance test for this deviation. +- The `regex` crate has **no lookahead**, so custom parameter types authored with + lookahead (e.g. the `library` sample's money type) must use a lookahead-free + equivalent. Recorded here so it is not mistaken for a bug. +- The cargo test-framework integration mechanism is its own decision: + [ADR 0007](0007-rust-cargo-test-integration.md). diff --git a/doc/adr/0007-rust-cargo-test-integration.md b/doc/adr/0007-rust-cargo-test-integration.md new file mode 100644 index 00000000..1613e18c --- /dev/null +++ b/doc/adr/0007-rust-cargo-test-integration.md @@ -0,0 +1,88 @@ +# ADR 0007 — Rust cargo test integration via `libtest-mimic` + +- **Status:** Proposed +- **Date:** 2026-07-12 +- **Deciders:** Andreas Koestler +- **Tags:** rust, cargo, libtest, test-runner-adapter, cross-language + +## Context + +Rust is a full pipeline port ([ADR 0006](0006-rust-port.md)). Like every other +port, its test-framework adapter must give **one independently +selectable/reportable test per Markdown example**, with failures rendered +anchored to the `.md` source span, and a drift gate (ADR 0002). The "framework" +in Rust is `cargo test` over the built-in libtest harness. + +The obstacle is specific to Rust. libtest's `#[test]` set is **fixed at compile +time**, but var's examples are **data-driven** — a header-bound table expands to +one example per row, known only after parsing the `.md` at runtime. So the +standard attribute macro cannot express the test set. + +Worse, `var-core` is deliberately **single-threaded**: handlers are `Rc` +closures and the threaded state is `Rc`-shared, so a planned example is **not +`Send`**. libtest (and `libtest-mimic`) require each test body to be +`FnOnce() + Send + 'static`, because the default runner moves each test to its +own thread. A closure that captures a planned example cannot cross that bound. + +### Options considered + +**A. `build.rs` code generation.** Parse every spec at build time and emit one +`#[test] fn` per example into `OUT_DIR`. Gives real `#[test]`s and native IDE +selection, but adds a build-dependency on `var-core` + the facade, regenerates +on every spec edit, and duplicates the parse (build time *and* run time). Heavy. + +**B. Custom `harness = false` binary with a hand-rolled reporter.** Full control, +single process, no `Send` needed — but reimplements everything `cargo test` +already gives (filter args, `--list`, `--nocapture`, output format, exit codes). + +**C. Make `var-core` `Send`** (`Rc`→`Arc`, `Send` closures) so `libtest-mimic`'s +threaded runner works unchanged. Rejected: invasive to the proven, deliberately +single-threaded core, for no user benefit. + +**D. `libtest-mimic`, keeping all `Rc` state thread-local.** `libtest-mimic` is +the community crate for exactly this shape — build a `Vec` at runtime and +hand it to a `harness = false` binary; `cargo test` then reports, filters, and +lists each `Trial` like a native test. The `Send` bound is satisfied by making +each `Trial` closure capture **only owned `Send` data** — the spec path plus the +example's index — and **re-derive its single example inside the closure** +(re-read the file, rebuild the registry, re-parse, re-plan, run just that +example). No `Rc` value ever crosses the thread boundary, so `var-core` stays +untouched. Enumeration (names, counts, `.md` line) happens once in `main` on the +main thread, where `Rc` is fine. + +## Decision + +Adopt **option D**: the adapter crate (`var-cargotest`) is a `harness = false` +library that exposes a `main`-style entry the sample's `tests/specs.rs` calls. +It: + +- reads `var.config.json` (via `var-config`) and globs the specs (via + `var-runner`), then parses/plans each once to **enumerate** examples — one + `Trial` per example, named by the pytest/unittest display rule (innermost + heading or body-derived name, de-duplicated with `[n]`), located at the `.md` + line; +- gives each `Trial` a closure capturing only `(spec_path, example_index)` as + owned data; the closure re-derives and runs that one example through + `var-runner`, mapping a `StepFailure` to `libtest_mimic::Failed` with the + core's `.md`-anchored render; +- emits the **drift** reconciliation as additional failing `Trial`s (one per + drifted paragraph), honouring `--var-update` / `VAR_UPDATE` (ADR 0002); +- delegates **all** pipeline/rendering logic to `var-runner`/`var-core` — the + adapter owns only the libtest binding. + +The current `examples/rust-cargotest` uses a stopgap (one plain `#[test]` per +spec file, printing per-example lines) precisely because it predates this +decision and could not satisfy `Send`. Phase 5 of the completion plan refactors +it onto this adapter. + +## Consequences + +- Real `cargo test` UX: `cargo test ` selects examples, `--list` + enumerates them, `--nocapture` shows output — no bespoke CLI. +- Per-example re-parse/re-plan inside each `Trial` is redundant work, but cheap + at corpus scale and the price of keeping `var-core` single-threaded. +- Adds a `libtest-mimic` dependency to the adapter (and thus the sample); the + core and runner stay dependency-light. +- `.md`-line location is set on the `Trial`; libtest cannot point a failure at an + arbitrary source file the way JUnit's `TestSource` can, so the anchored span + also travels in the rendered failure message (as it already does). diff --git a/doc/superpowers/plans/2026-07-12-rust-port-completion.md b/doc/superpowers/plans/2026-07-12-rust-port-completion.md new file mode 100644 index 00000000..56241a5c --- /dev/null +++ b/doc/superpowers/plans/2026-07-12-rust-port-completion.md @@ -0,0 +1,118 @@ +# Rust port completion — task plan + +**REQUIRED SUB-SKILL:** superpowers:executing-plans or +superpowers:subagent-driven-development. Load the +[`adding-a-language-port`](../../../.claude/skills/adding-a-language-port/SKILL.md) +skill. + +Design: [`2026-07-12-rust-facade-runner-adapter-design.md`](../specs/2026-07-12-rust-facade-runner-adapter-design.md). +ADRs: [0006](../../adr/0006-rust-port.md) (port), [0007](../../adr/0007-rust-cargo-test-integration.md) (cargo adapter). + +## Goal + +Bring the Rust port from "`var-core` + var-doc gate + a standalone sample" to a +**complete port**: all four conformance artifacts × 15 bundles + the config +corpus green, the full crate shape (`var`, `var-config`, `var-runner`, +`var-cargotest`), a tree-sitter dialect, and repo/release integration — the +sample refactored onto the shipped crates. + +## Done already + +- `var-core` (pipeline + diffs + drift/hash + conformance projections); **var-doc** + golden gate over 15 bundles; 209 tests. +- `examples/rust-cargotest` (stopgap inline runner) matching the Python samples + byte-for-byte; `make rust` + `.github/workflows/rust.yml` run it; + `examples/README.md` row; inert crates.io pin block in `70-var-examples.sh`. + +## Global constraints + +- **Translate, don't redesign** — the TS module + its `*.test.ts` are the spec. +- Every core module is proven by **reproducing shared goldens byte-for-byte**; + drift is the one unit-gated feature. Never hand-write new conformance tests. +- Adapters/runner contain **no pipeline logic** — delegate to `var-core`. +- Purity: `var-core` imports nothing from `var`/`var-runner` (grep gate). +- Each task ends green + `cargo fmt --check` + `cargo clippy -D warnings` + one commit. +- Commits: `chore(rust)`/`docs(rust)`/`test(...)` until the crates.io release + target lands (Phase 7); only then may `feat(rust/)` be used. + +## Dependency order + +`P0` → {`P1`, `P2`, `P6`} → `P3` → `P4` → `P5` → `P7` → `P8`. +`P2` (config) and `P6` (tree-sitter, TS-side) are independent — schedule anytime. + +--- + +### P0 — Decisions + docs — DONE in this change +ADR 0006, ADR 0007, the design spec, and this plan. No code. + +### P1 — `var` facade + the three deferred golden gates (L) +1. `rust/var` crate skeleton; re-export the authoring surface; `build_registry` + chaining injected `register(Registry)` fns. Purity grep gate. +2. Author `conformance/bundles//*.steps.rs` for all 15 bundles (same + expressions + deterministic handlers as `.steps.ts`; step files serialized by + stem). **Test-first: wire the gate red, then fill fixtures.** +3. `registry.json` gate byte-for-byte over 15 bundles (`to_registry_artifact`). +4. `plan.json` gate (`to_plan_artifact`). +5. `trace.json` gate (`run_conformance`). + Each of 3–5 is its own green commit. Exit: 4/4 artifacts × 15 bundles. + +### P2 — `var-config` (S/M) +1. Reader for `{docs:{include,exclude}, steps, snippets, scannerPlugins}`; + strict/fail-loud. +2. Reproduce `conformance/config/cases/*` (8) byte-for-byte (golden / expect-error). + +### P3 — `var-runner` (M) +1. `glob_to_regex` + `find_specs`/`match_spec` (port the shared semantics). +2. `load_steps`, `plan_spec`, `run_spec`, `render_failure`. +3. Filesystem `BaselineStore` + `reconcile_drift`; port `hash`/`drift` unit tests + (already in core — re-use) and add a runner-level drift test. + +### P4 — `var-cargotest` adapter (M/L) — ADR 0007 +1. `harness = false` crate; enumerate examples in `main`; one `Trial` per example + (display-name rule; `.md` line), closures capturing only `(spec_path, index)`. +2. Failure → `.md`-anchored render; drift `Trial`s + `--var-update`/`VAR_UPDATE`. +3. Dogfood: run the conformance bundles through the adapter, assert against + `trace.json`; add a `var.lock.json` drift fixture test. + +### P5 — Refactor the sample onto the crates (S) +Point `examples/rust-cargotest` at `var` + `var-cargotest`; delete `src/runner.rs` +and the inline config reader. Behaviour unchanged (still 30 examples, byte-for-byte +vs Python). + +### P6 — Tree-sitter dialect (M) — independent, TS-side +1. `var-language/src/tree-sitter-dialects/rust.ts` (`LanguageSpec`: step-def + + param-type queries, `decodeString`, `extractHandlerParams`, `resolveRegexp`), + queries verified against the real grammar. +2. Wire: `tree-sitter-scanner.ts` (SPECS/EXTENSIONS/`LanguageId`), both grammar + loaders (`var-lsp`, `var-language` test loader), VS Code bundler copy list, + both `knip.json` ignore blocks. +3. Prove: `extraction-conformance.test.ts` (identical `(kind, expression)` / + `(name, regexp)` sets as TS on every `*.steps.rs`) + `tree-sitter-scanner-rust.test.ts`. + Exit: `language-coverage.test.ts` green. + +### P7 — Repo + release integration (M) +1. `languages.json`: `rust` entry (label, icon, `ext=.rs`, stepsGlob, + `hasCli:false`, install/scaffold/run) + add id to the `SiteLang` union. +2. Website docs: `` across `reference/*`, `how-to/*`, and + the get-started tabs. +3. `release/targets/NN-crates-io.sh` publish target + add Rust to the release + channels → the `70-var-examples.sh` pin block goes live. +4. `release/lint-commits.sh` consumer-scope regex + message: add `rust`; + `cliff.toml`: add the crates.io/Rust changelog section. +5. `make rust` + CI: build/test all crates, run all four gates + the config corpus. + +### P8 — Full-port verify (S) +4 artifacts × 15 bundles + config corpus 8/8 byte-for-byte; `language-coverage` +green; sample on real crates green; `make rust` + `rust.yml` green. Update the +[`adding-a-language-port`](../../../.claude/skills/adding-a-language-port/SKILL.md) +status line to list Rust as complete. + +## Risks + +- **cucumber-expressions `0.5` (community) vs pinned `20.0.0`** — divergence + surfaces first at P1's registry/plan gates; those are the acceptance test. + Confirm no bundle needs `{float}` or regex lookahead. +- **`Rc`/`Send`** — settled by ADR 0007 (thread-local re-derive); revisit only if + a bundle proves it insufficient. +- **crates.io name availability** (`var-core`, `var`, `var-config`, `var-runner`, + `var-cargotest`) — check before P7. diff --git a/doc/superpowers/specs/2026-07-12-rust-facade-runner-adapter-design.md b/doc/superpowers/specs/2026-07-12-rust-facade-runner-adapter-design.md new file mode 100644 index 00000000..beb17431 --- /dev/null +++ b/doc/superpowers/specs/2026-07-12-rust-facade-runner-adapter-design.md @@ -0,0 +1,92 @@ +# Rust facade + config + runner + cargo adapter — design + +Date: 2026-07-12 +Status: design, pending implementation (TDD) + +The remaining runtime of the Rust port ([ADR 0006](../../adr/0006-rust-port.md)), +sitting on the already-conformance-green `var-core`. Scope: the `var` author +facade (and, hosted there, the three deferred `registry`/`plan`/`trace` golden +gates), the `var-config` reader, the `var-runner` imperative shell, and the +`var-cargotest` adapter ([ADR 0007](../../adr/0007-rust-cargo-test-integration.md)). +Python and Ruby are the closest precedents (dynamically-vs-statically typed +aside, both are full ports whose runner/adapter sit on a proven core); read +[`2026-07-07-ruby-runner-adapters.md`](../plans/2026-07-07-ruby-runner-adapters.md) +and [`2026-06-30-var-pytest-plugin-design.md`](2026-06-30-var-pytest-plugin-design.md) +alongside. + +## Why this scope + +`var-core` proves the pipeline against the shared goldens, but only the +**var-doc** artifact is gated today; `registry`/`plan`/`trace` need per-bundle +step fixtures that only exist once an author API (the facade) can register +steps. So the facade is both the public authoring surface **and** the host of +the remaining conformance gates — the same coupling Java uses (its `var` module +owns those gates). Everything below the adapter is proven by reproducing goldens +byte-for-byte; the adapter is proven by dogfooding the bundles against +`trace.json` and a drift fixture. + +## Crates (target) + +``` +rust/ + var-core/ # done + var/ # facade: authoring API + registry/plan/trace conformance harness + var-config/ # var.config.json reader (own conformance corpus) + var-runner/ # discovery, load-steps, plan/run, render, filesystem BaselineStore + var-cargotest/ # libtest-mimic adapter (ADR 0007) +``` + +Purity gate (mirrors the Python `lint_no_reexports`/`grep` gate): `var-core` +must not depend on `var`/`var-runner`; the adapter must contain no pipeline +logic. Enforce with a `cargo-deny`/grep check in `make rust`. + +### `var` facade + +- Re-exports the authoring surface over `var-core::registry`: `create_registry`, + `add_step`, `define_parameter_type[_with_format]`, `Handler::sync{0,1,2}`/`async0`, + `Value`, `StepKind`. The **injected-Registrar** pattern (ADR 0006) — no global + accumulator; `build_registry` chains `register(Registry) -> Registry` fns. +- **Conformance harness** (the deferred gates): for each of the 15 bundles, load + its `*.steps.rs` fixture, then assert byte-for-byte: + - `registry.json` via `to_registry_artifact`, + - `plan.json` via `to_plan_artifact`, + - `trace.json` via `run_conformance` (executor events projected inline). + All three projections already live in `var-core::conformance`; the gate + the + fixtures are new. +- **Fixtures:** author `conformance/bundles//*.steps.rs` for all 15 bundles, + registering the same expressions + deterministic handlers as the `.steps.ts`. + Serialize step-def files by stem (`numerals.steps`) so goldens stay shared. + +### `var-config` + +- Strict, fail-loud reader of the canonical `{ docs: {include, exclude}, steps, + snippets, scannerPlugins }` shape. Missing file → empty; malformed/unknown-key + → error starting with the path. +- **Done = reproduces `conformance/config/cases/*` (8 cases) byte-for-byte** + (`golden.json` via `var-core`'s canonical JSON, or the `expect-error.txt` + marker → load must error). + +### `var-runner` + +- `find_specs`/`match_spec` with the hand-rolled `glob_to_regex` (`**`, `*`, `?`, + `../`) matching the other runners — **not** a platform glob. +- `load_steps` (chains the facade `register` fns for the workspace), `plan_spec`, + `run_spec` (returns per-example run thunks), `render_failure` (reuses core + diff payloads). +- Filesystem `BaselineStore` (`var.lock.json` read/write) + `reconcile_drift` + (core owns the format + `stringify_var_lock`/`parse_var_lock`). + +### `var-cargotest` adapter + +Per ADR 0007: `libtest-mimic`, enumerate in `main`, per-example `Trial` closures +capturing only `(spec_path, index)` and re-deriving thread-locally; drift as +extra `Trial`s with `--var-update`/`VAR_UPDATE`. + +## Non-goals (this sub-project) + +- Snippet/step-def generation (deferred, per skill). +- A `var` CLI (`var init`) — TS/Python only; not on the core/runner/adapter path. +- The **tree-sitter dialect** and **repo/release integration** (languages.json, + website tabs, crates.io publish, cliff/lint-commits scope) are tracked in the + [completion plan](../plans/2026-07-12-rust-port-completion.md) but are not part + of the runtime design above. diff --git a/examples/README.md b/examples/README.md index 2b01f55e..1ad9fc28 100644 --- a/examples/README.md +++ b/examples/README.md @@ -20,6 +20,7 @@ whole team, and checked against the code on every test run. | [`python-unittest`](python-unittest) | Python + unittest | `uv run python -m unittest` | [![python-unittest](https://github.com/oselvar/var-examples/actions/workflows/python-unittest.yml/badge.svg)](https://github.com/oselvar/var-examples/actions/workflows/python-unittest.yml) | | [`ruby-rspec`](ruby-rspec) | Ruby + RSpec | `bundle exec rspec` | [![ruby-rspec](https://github.com/oselvar/var-examples/actions/workflows/ruby-rspec.yml/badge.svg)](https://github.com/oselvar/var-examples/actions/workflows/ruby-rspec.yml) | | [`ruby-minitest`](ruby-minitest) | Ruby + Minitest | `bundle exec rake test` | [![ruby-minitest](https://github.com/oselvar/var-examples/actions/workflows/ruby-minitest.yml/badge.svg)](https://github.com/oselvar/var-examples/actions/workflows/ruby-minitest.yml) | +| [`rust-cargotest`](rust-cargotest) | Rust + cargo test | `cargo test` | [![rust-cargotest](https://github.com/oselvar/var-examples/actions/workflows/rust-cargotest.yml/badge.svg)](https://github.com/oselvar/var-examples/actions/workflows/rust-cargotest.yml) | `typescript-vitest` implements the full example set; the other projects implement a feature-covering subset — `hello-var` (basic steps), diff --git a/examples/rust-cargotest/.gitignore b/examples/rust-cargotest/.gitignore new file mode 100644 index 00000000..96ef6c0b --- /dev/null +++ b/examples/rust-cargotest/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/examples/rust-cargotest/Cargo.toml b/examples/rust-cargotest/Cargo.toml new file mode 100644 index 00000000..80df31b0 --- /dev/null +++ b/examples/rust-cargotest/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "var-example-rust-cargotest" +version = "0.0.1" +edition = "2024" +publish = false +description = "Standalone sample: run Markdown specs as `cargo test` tests with Vár" + +# In this monorepo the Vár crates resolve from source (path deps), so the sample +# gates trunk against the local build. A real project depends on the published +# crates instead. +[dependencies] +# The engine (steps are authored against it) and the cargo-test adapter. +var-core = { path = "../../rust/var-core" } +var-cargotest = { path = "../../rust/var-cargotest" } +# The ergonomic author facade (`var::Steps`), used by the step definitions. +var = { path = "../../rust/var" } + +[dev-dependencies] +# The `unit` test drives discovery and a single example directly. +var-config = { path = "../../rust/var-config" } +var-runner = { path = "../../rust/var-runner" } + +[lib] +name = "example" +path = "src/lib.rs" + +# The specs run through the var-cargotest adapter, which owns the libtest +# harness — hence `harness = false` and a `main` in tests/specs.rs. +[[test]] +name = "specs" +path = "tests/specs.rs" +harness = false + +[[test]] +name = "unit" +path = "tests/unit.rs" diff --git a/examples/rust-cargotest/README.md b/examples/rust-cargotest/README.md new file mode 100644 index 00000000..48e89443 --- /dev/null +++ b/examples/rust-cargotest/README.md @@ -0,0 +1,57 @@ +# Vár sample: Rust + cargo test + +A small, standalone sample project that runs Markdown specs as tests with +[Vár](https://var.oselvar.com), driven by `cargo test`. Copy it as the starting +point for your own project. + +The `.md` files at the project root are the specs — they run as tests. + +## Run it + +```sh +cargo test # one test per spec, all green +cargo test -- --nocapture # also prints one line per example (30 total) +cargo test --test specs yahtzee # run a single spec +``` + +Each Markdown spec becomes one `cargo test` test; every example in it is run +and printed as `spec.md::name`, mirroring `pytest -v` / `python -m unittest -v` +in the sibling Python samples. (Because var-core is single-threaded — `Rc`, not +`Send` — the samples group examples per spec rather than emitting one libtest +item per example.) + +## How it fits together + +- **`var.config.json`** is the single source of truth: `docs.include` globs the + Markdown specs. (`steps` is carried for parity with the other ports; Rust + compiles its step files in, so there is nothing to glob at runtime.) +- **`src/steps/*.rs`** define the steps. Rust has no import-for-side-effect, so + — like the Java/Kotlin ports and unlike TypeScript/Python — each file exposes + a `register(Registry) -> Registry` that adds its steps explicitly, and + `steps::build_registry` chains them. The threaded state is a **full + replacement** value (var-core's model): a stimulus returns the whole next + state; a sensor returns a value for Vár to compare against what the Markdown + says. +- **`src/*_example.rs`** are the sample's domain code — ordinary modules the + steps call, just like your production code. +- **`src/runner.rs`** is the small imperative shell (read config, glob specs, + plan/run each example, render failures). In a full port this would be a + shared `var-runner` crate; here it lives in the sample to keep it to a single + crate depending only on `var-core`. + +## Notes for the Rust port + +- var-core's dynamic `Value` is a **closed enum**, so — unlike the Python/Java + ports, which hold a `Money`/`date` object in the threaded state — `library` + encodes money as pennies (`Value::Int`) and a date as a `{year, month, day}` + map, with `parse`/`format` custom parameter types converting at the edge. +- The `money` parameter type uses a lookahead-free regexp + (`£\d+(?:\.\d+)?|\d+p`): var-core's matcher compiles with the `regex` crate, + which has no lookahead, so it drops the empty-match guards of the Python + pattern (the covered corpus is identical). + +## Versioning note + +In the [oselvar/var](https://github.com/oselvar/var) monorepo this sample +resolves `var-core` from a `path` dependency, gating trunk against the local +build. A released project would depend on the published crate instead. diff --git a/examples/rust-cargotest/deep-thought.md b/examples/rust-cargotest/deep-thought.md new file mode 120000 index 00000000..22e74dbe --- /dev/null +++ b/examples/rust-cargotest/deep-thought.md @@ -0,0 +1 @@ +../typescript-vitest/deep-thought.md \ No newline at end of file diff --git a/examples/rust-cargotest/hello-var.md b/examples/rust-cargotest/hello-var.md new file mode 120000 index 00000000..086b11f2 --- /dev/null +++ b/examples/rust-cargotest/hello-var.md @@ -0,0 +1 @@ +../typescript-vitest/hello-var.md \ No newline at end of file diff --git a/examples/rust-cargotest/library.md b/examples/rust-cargotest/library.md new file mode 120000 index 00000000..df3c7fd1 --- /dev/null +++ b/examples/rust-cargotest/library.md @@ -0,0 +1 @@ +../typescript-vitest/library.md \ No newline at end of file diff --git a/examples/rust-cargotest/roman-numerals.md b/examples/rust-cargotest/roman-numerals.md new file mode 120000 index 00000000..54d8086d --- /dev/null +++ b/examples/rust-cargotest/roman-numerals.md @@ -0,0 +1 @@ +../typescript-vitest/roman-numerals.md \ No newline at end of file diff --git a/examples/rust-cargotest/src/lib.rs b/examples/rust-cargotest/src/lib.rs new file mode 100644 index 00000000..1555f251 --- /dev/null +++ b/examples/rust-cargotest/src/lib.rs @@ -0,0 +1,14 @@ +//! Standalone sample: run Markdown specs as `cargo test` tests with Vár. +//! +//! - the domain modules (`*_example`) are the code under test; +//! - `steps` holds the step definitions plus the registry/context glue. +//! +//! `tests/specs.rs` wires it into `cargo test` via the `var-cargotest` +//! adapter — one libtest item per Markdown example. Discovery, planning, +//! running, rendering, and drift all live in the shared `var-*` crates now, so +//! the sample carries no runner of its own. + +pub mod library_example; +pub mod roman_numerals_example; +pub mod steps; +pub mod yahtzee_example; diff --git a/examples/rust-cargotest/src/library_example.rs b/examples/rust-cargotest/src/library_example.rs new file mode 100644 index 00000000..bfcc1941 --- /dev/null +++ b/examples/rust-cargotest/src/library_example.rs @@ -0,0 +1,105 @@ +//! The library domain (the `library.md` domain): loans, late fees and the +//! borrow rule. A port of `examples/python-pytest/src/library_example`. +//! +//! Money is carried as whole **pennies** (`i64`) rather than a `Money` value +//! type: var-core's dynamic [`Value`](var_core::value::Value) is a closed enum, +//! so — unlike the Python/Java ports, which hold a `Money`/`date` object in the +//! threaded state — the Rust steps encode money as an integer and dates as a +//! `{year, month, day}` map. The GBP currency is implicit. + +/// Late fee per day overdue, in pennies (Python `FEE_PER_DAY = gbp(0.5)`). +pub const FEE_PER_DAY: i64 = 50; + +const MONTHS: [&str; 12] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +/// A calendar date. Comparison and day-arithmetic go through [`Date::serial`]. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Date { + pub year: i64, + pub month: i64, + pub day: i64, +} + +impl Date { + /// Days since 1970-01-01 (Howard Hinnant's `days_from_civil`); only the + /// difference between two serials is ever used, so the epoch is arbitrary. + pub fn serial(self) -> i64 { + let (y, m, d) = (self.year, self.month, self.day); + let y = if m <= 2 { y - 1 } else { y }; + let era = (if y >= 0 { y } else { y - 399 }) / 400; + let yoe = y - era * 400; // [0, 399] + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365] + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] + era * 146097 + doe - 719468 + } +} + +/// `June 6, 2026` → `Date { 2026, 6, 6 }`. +pub fn parse_date(raw: &str) -> Date { + let (month_day, year) = raw + .split_once(", ") + .unwrap_or_else(|| panic!("not a date: {raw}")); + let (month, day) = month_day + .split_once(' ') + .unwrap_or_else(|| panic!("not a date: {raw}")); + let month = MONTHS + .iter() + .position(|m| *m == month) + .unwrap_or_else(|| panic!("not a month: {month}")) as i64 + + 1; + Date { + year: year.parse().expect("year"), + month, + day: day.parse().expect("day"), + } +} + +/// The inverse of [`parse_date`]: `Date { 2026, 6, 6 }` → `June 6, 2026`. +pub fn format_date(d: Date) -> String { + format!("{} {}, {}", MONTHS[(d.month - 1) as usize], d.day, d.year) +} + +/// `£2.50` → `250`, `50p` → `50` (both in pennies). +pub fn parse_money(raw: &str) -> i64 { + if let Some(pence) = raw.strip_suffix('p') { + pence.parse().expect("pence") + } else if let Some(pounds) = raw.strip_prefix('£') { + (pounds.parse::().expect("pounds") * 100.0).round() as i64 + } else { + panic!("not money: {raw}") + } +} + +/// The inverse of [`parse_money`]: mismatches render as `£2.60` / `50p`, never +/// as a raw integer. +pub fn format_money(pennies: i64) -> String { + if pennies < 100 { + format!("{pennies}p") + } else { + format!("£{:.2}", pennies as f64 / 100.0) + } +} + +/// Fee for returning a loan: 50p per day past the due date. +pub fn late_fee(due: Date, returned_on: Date) -> i64 { + let days_late = (returned_on.serial() - due.serial()).max(0); + days_late * FEE_PER_DAY +} + +/// A member may borrow as long as none of their loans is overdue. +pub fn may_borrow(dues: &[Date], on: Date) -> bool { + dues.iter().all(|due| due.serial() >= on.serial()) +} diff --git a/examples/rust-cargotest/src/roman_numerals_example.rs b/examples/rust-cargotest/src/roman_numerals_example.rs new file mode 100644 index 00000000..8fa8e247 --- /dev/null +++ b/examples/rust-cargotest/src/roman_numerals_example.rs @@ -0,0 +1,30 @@ +//! Decimal → Roman numeral conversion (the `roman-numerals.md` domain). +//! +//! A straight port of `examples/python-pytest/src/roman_numerals_example`. + +const NUMERALS: &[(&str, u32)] = &[ + ("M", 1000), + ("CM", 900), + ("D", 500), + ("CD", 400), + ("C", 100), + ("XC", 90), + ("L", 50), + ("XL", 40), + ("X", 10), + ("IX", 9), + ("V", 5), + ("IV", 4), + ("I", 1), +]; + +pub fn to_roman(mut num: u32) -> String { + let mut result = String::new(); + for (letter, value) in NUMERALS { + while num >= *value { + num -= *value; + result.push_str(letter); + } + } + result +} diff --git a/examples/rust-cargotest/src/steps/deep_thought.rs b/examples/rust-cargotest/src/steps/deep_thought.rs new file mode 100644 index 00000000..e737767d --- /dev/null +++ b/examples/rust-cargotest/src/steps/deep_thought.rs @@ -0,0 +1,17 @@ +//! Steps for `deep-thought.md`. + +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "deep_thought.steps"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + // A one-slot sensor: the return IS the answer, compared against the {int}. + s.sensor( + "life, the universe and everything is {int}", + FILE, + 1, + Handler::sync1(|_state, _answer| Ok(Some(Value::Int(42)))), + ); + s.into_registry() +} diff --git a/examples/rust-cargotest/src/steps/hello_var.rs b/examples/rust-cargotest/src/steps/hello_var.rs new file mode 100644 index 00000000..fc55c3ff --- /dev/null +++ b/examples/rust-cargotest/src/steps/hello_var.rs @@ -0,0 +1,54 @@ +//! Steps for `hello-var.md`. + +use super::{as_int, as_str, smap}; +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "hello_var.steps"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + + // stimulus: greet a name, storing the greeting. + s.stimulus( + "I greet {string}", + FILE, + 1, + Handler::sync1(|state, name| { + let mut m = smap(&state); + m.insert( + "greeting".to_string(), + Value::from(format!("Hello, {}!", as_str(&name))), + ); + Ok(Some(Value::Map(m))) + }), + ); + + // sensor: the stored greeting. + s.sensor( + "the greeting should be {string}", + FILE, + 5, + Handler::sync1(|state, _expected| Ok(smap(&state).get("greeting").cloned())), + ); + + // stimulus: evaluate an integer addition, storing the result. + s.stimulus( + "expression `{int}+{int}`", + FILE, + 10, + Handler::sync2(|state, a, b| { + let mut m = smap(&state); + m.insert("result".to_string(), Value::Int(as_int(&a) + as_int(&b))); + Ok(Some(Value::Map(m))) + }), + ); + + // sensor: the stored result. + s.sensor( + "evaluate to `{int}`", + FILE, + 15, + Handler::sync1(|state, _expected| Ok(smap(&state).get("result").cloned())), + ); + s.into_registry() +} diff --git a/examples/rust-cargotest/src/steps/library.rs b/examples/rust-cargotest/src/steps/library.rs new file mode 100644 index 00000000..dc1da40e --- /dev/null +++ b/examples/rust-cargotest/src/steps/library.rs @@ -0,0 +1,167 @@ +//! Steps for `library.md`. +//! +//! Custom parameter types pair `parse` with `format`, so a mismatch renders in +//! the document's own notation (money, dates, an emphasised title). Money is +//! encoded as pennies (`Value::Int`), a date as a `{year, month, day}` map, and +//! a title as its bare text (`Value::String`) — see [`crate::library_example`]. + +use super::{as_int, smap, vmap}; +use crate::library_example::{ + Date, FEE_PER_DAY, format_date, format_money, late_fee, may_borrow, parse_date, parse_money, +}; +use std::rc::Rc; +use var::{FormatFn, Handler, ParseFn, Registry, Steps, Value}; + +pub const FILE: &str = "library.steps"; + +fn date_value(d: Date) -> Value { + vmap(vec![ + ("year", Value::Int(d.year)), + ("month", Value::Int(d.month)), + ("day", Value::Int(d.day)), + ]) +} + +fn value_date(v: &Value) -> Date { + let m = smap(v); + Date { + year: as_int(&m["year"]), + month: as_int(&m["month"]), + day: as_int(&m["day"]), + } +} + +fn loan_due(loan: &Value) -> Date { + value_date(&smap(loan)["due"]) +} + +fn loans_of(state: &Value) -> Vec { + match smap(state).get("loans") { + Some(Value::List(l)) => l.clone(), + _ => Vec::new(), + } +} + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + // --- custom parameter types (parse + display format) -------------------- + + let date_parse: ParseFn = Rc::new(|g: &[&str]| date_value(parse_date(g[0]))); + let date_format: FormatFn = Rc::new(|v: &Value| Some(format_date(value_date(v)))); + s.param_with_format( + "date", + r"[A-Z][a-z]+ \d{1,2}, \d{4}", + date_parse, + date_format, + ); + + // £2.50 and 50p, both as pennies. var-core's matcher compiles with the + // `regex` crate, which has no lookahead — so this is the corpus-covering + // subset of cucumber-expressions' float regexp (no scientific notation, no + // empty-match guards), not the exact Python pattern. + let money_parse: ParseFn = Rc::new(|g: &[&str]| Value::Int(parse_money(g[0]))); + let money_format: FormatFn = Rc::new(|v: &Value| match v { + Value::Int(pennies) => Some(format_money(*pennies)), + _ => None, + }); + s.param_with_format("money", r"£\d+(?:\.\d+)?|\d+p", money_parse, money_format); + + // The emphasised run IS the parameter: the markers live in the pattern, + // parse strips them, format restores them. Markup is notation, like £2.50. + let title_parse: ParseFn = Rc::new(|g: &[&str]| { + let raw = g[0]; + let inner = raw + .strip_prefix('*') + .and_then(|s| s.strip_suffix('*')) + .unwrap_or(raw); + Value::from(inner.to_string()) + }); + let title_format: FormatFn = Rc::new(|v: &Value| match v { + Value::String(t) => Some(format!("*{t}*")), + _ => None, + }); + s.param_with_format("title", r"\*[^*]+\*", title_parse, title_format); + + // --- steps -------------------------------------------------------------- + + s.stimulus( + "borrowed {title}, due back on {date}", + FILE, + 1, + Handler::sync2(|state, title, due| { + let mut m = smap(&state); + let mut loans = loans_of(&state); + loans.push(vmap(vec![("title", title), ("due", due)])); + m.insert("loans".to_string(), Value::List(loans)); + Ok(Some(Value::Map(m))) + }), + ); + + s.stimulus( + "returns it on {date}", + FILE, + 5, + Handler::sync1(|state, returned_on| { + let returned = value_date(&returned_on); + let mut fee = 0; + for loan in loans_of(&state) { + fee += late_fee(loan_due(&loan), returned); + } + let mut m = smap(&state); + m.insert("fee".to_string(), Value::Int(fee)); + Ok(Some(Value::Map(m))) + }), + ); + + s.sensor( + "owes a {money} late fee", + FILE, + 10, + Handler::sync1(|state, _expected| Ok(smap(&state).get("fee").cloned())), + ); + + s.sensor( + "{money} for each day overdue", + FILE, + 14, + Handler::sync1(|_state, _expected| Ok(Some(Value::Int(FEE_PER_DAY)))), + ); + + s.stimulus( + "asks to borrow {title} on {date}", + FILE, + 18, + Handler::sync2(|state, _title, on| { + let on = value_date(&on); + let dues: Vec = loans_of(&state).iter().map(loan_due).collect(); + let mut m = smap(&state); + m.insert("granted".to_string(), Value::Bool(may_borrow(&dues, on))); + Ok(Some(Value::Map(m))) + }), + ); + + s.sensor( + "the library refuses", + FILE, + 24, + Handler::sync0(|state| { + if matches!(smap(&state).get("granted"), Some(Value::Bool(true))) { + panic!("expected the library to refuse"); + } + Ok(None) + }), + ); + + s.sensor( + "the library agrees", + FILE, + 30, + Handler::sync0(|state| { + if !matches!(smap(&state).get("granted"), Some(Value::Bool(true))) { + panic!("expected the library to agree"); + } + Ok(None) + }), + ); + s.into_registry() +} diff --git a/examples/rust-cargotest/src/steps/mod.rs b/examples/rust-cargotest/src/steps/mod.rs new file mode 100644 index 00000000..f986eb84 --- /dev/null +++ b/examples/rust-cargotest/src/steps/mod.rs @@ -0,0 +1,78 @@ +//! Step definitions for every spec, plus the registry/context glue. +//! +//! Rust has no import-for-side-effect story, so — like the Java/Kotlin ports, +//! and unlike TypeScript/Python's module-scope accumulator — each step file +//! exposes a `register(Registry) -> Registry` that adds its steps explicitly, +//! and [`build_registry`] chains them. The threaded state is a **full +//! replacement** value (var-core's model), not a shallow-merged partial: a +//! `stimulus` returns the whole next state. + +use std::collections::BTreeMap; +use var_core::value::Value; + +pub mod deep_thought; +pub mod hello_var; +pub mod library; +pub mod roman_numerals; +pub mod tables_and_docstrings; +pub mod yahtzee; + +use var_core::registry::{Registry, create_registry}; + +/// The combined registry for all specs. +pub fn build_registry() -> Registry { + let r = create_registry(); + let r = hello_var::register(r); + let r = deep_thought::register(r); + let r = tables_and_docstrings::register(r); + let r = yahtzee::register(r); + let r = roman_numerals::register(r); + library::register(r) +} + +/// Fresh initial state per step file (var-core keys context by a step's source +/// file). Files whose steps are pure return [`Value::Null`]. A plain `fn` (not +/// a closure) so the adapter can move it across the libtest thread boundary. +pub fn context_value(file: &str) -> Value { + match file { + hello_var::FILE => vmap(vec![ + ("greeting", Value::from("")), + ("result", Value::Int(0)), + ]), + library::FILE => vmap(vec![ + ("loans", Value::List(vec![])), + ("fee", Value::Int(0)), + ("granted", Value::Bool(false)), + ]), + _ => Value::Null, + } +} + +// --- shared Value helpers --------------------------------------------------- + +/// Builds a [`Value::Map`] from `(key, value)` pairs. +pub(crate) fn vmap(pairs: Vec<(&str, Value)>) -> Value { + Value::Map(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) +} + +/// Clones the underlying map of a [`Value::Map`] (empty for anything else). +pub(crate) fn smap(v: &Value) -> BTreeMap { + match v { + Value::Map(m) => m.clone(), + _ => BTreeMap::new(), + } +} + +pub(crate) fn as_int(v: &Value) -> i64 { + match v { + Value::Int(i) => *i, + _ => panic!("expected an integer, got {v:?}"), + } +} + +pub(crate) fn as_str(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + _ => panic!("expected a string, got {v:?}"), + } +} diff --git a/examples/rust-cargotest/src/steps/roman_numerals.rs b/examples/rust-cargotest/src/steps/roman_numerals.rs new file mode 100644 index 00000000..23a74bae --- /dev/null +++ b/examples/rust-cargotest/src/steps/roman_numerals.rs @@ -0,0 +1,28 @@ +//! Steps for `roman-numerals.md`. + +use super::{as_str, smap, vmap}; +use crate::roman_numerals_example::to_roman; +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "roman_numerals.steps"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + // Header-bound table: one example per row, the row keyed by header + // (decimal, roman). The returned {decimal, roman} is checked cell by cell. + s.sensor( + "a decimal and a roman number", + FILE, + 1, + Handler::sync1(|_state, row| { + let m = smap(&row); + let decimal = as_str(&m["decimal"]); + let roman = to_roman(decimal.parse().expect("decimal")); + Ok(Some(vmap(vec![ + ("decimal", Value::from(decimal)), + ("roman", Value::from(roman)), + ]))) + }), + ); + s.into_registry() +} diff --git a/examples/rust-cargotest/src/steps/tables_and_docstrings.rs b/examples/rust-cargotest/src/steps/tables_and_docstrings.rs new file mode 100644 index 00000000..30f5dfd8 --- /dev/null +++ b/examples/rust-cargotest/src/steps/tables_and_docstrings.rs @@ -0,0 +1,56 @@ +//! Steps for `tables-and-docstrings.md`. + +use super::{as_str, vmap}; +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "tables_and_docstrings.steps"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + // Whole-table mode: the table arrives as a list of rows (header row first). + // It is this sensor's only slot, so return the reproduced table bare — Vár + // compares every cell. + s.sensor( + "Uppercase each one:", + FILE, + 1, + Handler::sync1(|_state, table| { + let rows = match &table { + Value::List(rows) => rows, + other => panic!("expected a table, got {other:?}"), + }; + let out: Vec = rows + .iter() + .skip(1) // drop the header row + .map(|row| { + let before = match row { + Value::List(cells) => as_str(&cells[0]), + other => panic!("expected a row, got {other:?}"), + }; + let after = before.to_uppercase(); + vmap(vec![ + ("before", Value::from(before)), + ("after", Value::from(after)), + ]) + }) + .collect(); + Ok(Some(Value::List(out))) + }), + ); + + // Doc-string mode: two slots ({word} plus the trailing doc string), so + // return one element per slot. + s.sensor( + "Greet {word}:", + FILE, + 10, + Handler::sync2(|_state, name, _doc| { + let name = as_str(&name); + Ok(Some(Value::List(vec![ + Value::from(name.clone()), + Value::from(format!("Hello, {name}!\n")), + ]))) + }), + ); + s.into_registry() +} diff --git a/examples/rust-cargotest/src/steps/yahtzee.rs b/examples/rust-cargotest/src/steps/yahtzee.rs new file mode 100644 index 00000000..903f9bea --- /dev/null +++ b/examples/rust-cargotest/src/steps/yahtzee.rs @@ -0,0 +1,33 @@ +//! Steps for `yahtzee.md`. + +use super::{as_str, smap, vmap}; +use crate::yahtzee_example::score; +use var::{Handler, Registry, Steps, Value}; + +pub const FILE: &str = "yahtzee.steps"; + +pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + // Header-bound table: the paragraph names every header cell (dice, + // category, score), so this sensor runs once per row with the row as a map + // keyed by header. Returning {"score": …} checks that column; the other + // columns are inputs. + s.sensor( + "Examples of dice, category and score", + FILE, + 1, + Handler::sync1(|_state, row| { + let m = smap(&row); + let dice: Vec = as_str(&m["dice"]) + .split(',') + .map(|d| d.trim().parse().expect("die")) + .collect(); + let category = as_str(&m["category"]); + Ok(Some(vmap(vec![( + "score", + Value::Int(score(&dice, &category)), + )]))) + }), + ); + s.into_registry() +} diff --git a/examples/rust-cargotest/src/yahtzee_example.rs b/examples/rust-cargotest/src/yahtzee_example.rs new file mode 100644 index 00000000..d9e6aaae --- /dev/null +++ b/examples/rust-cargotest/src/yahtzee_example.rs @@ -0,0 +1,86 @@ +//! Yahtzee scoring (the `yahtzee.md` domain). +//! +//! A straight port of `examples/python-pytest/src/yahtzee_example` — `score` +//! takes the five dice and a category and returns the box's score. + +use std::collections::HashMap; + +pub fn score(dice: &[i64], category: &str) -> i64 { + let mut counts: HashMap = HashMap::new(); + for &d in dice { + *counts.entry(d).or_insert(0) += 1; + } + let total: i64 = dice.iter().sum(); + + let sum_of = |face: i64| counts.get(&face).copied().unwrap_or(0) * face; + + // n-of-a-kind: the highest face appearing at least n times, scored n*face. + let of_a_kind = |n: i64| { + counts + .iter() + .filter(|&(_, &c)| c >= n) + .map(|(&face, _)| face) + .max() + .map_or(0, |face| n * face) + }; + + let mut sorted = dice.to_vec(); + sorted.sort_unstable(); + let sorted_dice: String = sorted.iter().map(|d| d.to_string()).collect(); + + match category { + "ones" => sum_of(1), + "twos" => sum_of(2), + "threes" => sum_of(3), + "fours" => sum_of(4), + "fives" => sum_of(5), + "sixes" => sum_of(6), + "pair" => of_a_kind(2), + "two pairs" => { + let pairs: Vec = counts + .iter() + .filter(|&(_, &c)| c >= 2) + .map(|(&face, _)| face) + .collect(); + if pairs.len() >= 2 { + pairs.iter().map(|face| 2 * face).sum() + } else { + 0 + } + } + "three of a kind" => of_a_kind(3), + "four of a kind" => of_a_kind(4), + "small straight" => { + if sorted_dice == "12345" { + 15 + } else { + 0 + } + } + "large straight" => { + if sorted_dice == "23456" { + 20 + } else { + 0 + } + } + "full house" => { + let mut cs: Vec = counts.values().copied().collect(); + cs.sort_unstable(); + if counts.len() == 2 && cs == [2, 3] { + total + } else { + 0 + } + } + "Yahtzee" => { + if counts.len() == 1 { + 50 + } else { + 0 + } + } + "chance" => total, + other => panic!("Unknown category: {other}"), + } +} diff --git a/examples/rust-cargotest/tables-and-docstrings.md b/examples/rust-cargotest/tables-and-docstrings.md new file mode 120000 index 00000000..3d5c5d1e --- /dev/null +++ b/examples/rust-cargotest/tables-and-docstrings.md @@ -0,0 +1 @@ +../typescript-vitest/tables-and-docstrings.md \ No newline at end of file diff --git a/examples/rust-cargotest/tests/specs.rs b/examples/rust-cargotest/tests/specs.rs new file mode 100644 index 00000000..9e360c94 --- /dev/null +++ b/examples/rust-cargotest/tests/specs.rs @@ -0,0 +1,15 @@ +//! Runs every Markdown spec matched by `var.config.json` as `cargo test` tests +//! — one libtest item per example — through the `var-cargotest` adapter. +//! +//! `cargo test` reports each as `spec.md::name`; `cargo test ` +//! selects, `--list` enumerates. Set `VAR_UPDATE=1` to accept drift. + +use std::path::Path; + +fn main() { + var_cargotest::run( + Path::new(env!("CARGO_MANIFEST_DIR")), + example::steps::build_registry, + example::steps::context_value, + ); +} diff --git a/examples/rust-cargotest/tests/unit.rs b/examples/rust-cargotest/tests/unit.rs new file mode 100644 index 00000000..477b9bba --- /dev/null +++ b/examples/rust-cargotest/tests/unit.rs @@ -0,0 +1,58 @@ +//! Checks the adapter's libtest harness can't express as items: that the config +//! globs discover exactly the six specs, and that a deliberately-wrong +//! expectation renders a cell mismatch (hello-var.md's "watch it fail"). + +use example::steps::{build_registry, context_value}; +use std::path::Path; +use var_cargotest::run_one; +use var_config::read_var_config; +use var_runner::find_specs; + +fn root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) +} + +#[test] +fn discovery_matches_config() { + let config = read_var_config(root()).unwrap(); + let mut names: Vec = find_specs(&config, root()) + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + names.sort(); + assert_eq!( + names, + vec![ + "deep-thought.md", + "hello-var.md", + "library.md", + "roman-numerals.md", + "tables-and-docstrings.md", + "yahtzee.md", + ] + ); +} + +#[test] +fn a_mutated_expectation_fails_with_a_cell_mismatch() { + let source = std::fs::read_to_string(root().join("hello-var.md")) + .unwrap() + .replace("\"Hello, world!\"", "\"Hello, Vár!\""); + let err = run_one( + "hello-var.md", + &source, + "hello-var.md", + build_registry, + context_value, + 0, + ) + .expect_err("expected a failure"); + // Expected column is the source token as written (quotes included); actual + // is what the sensor returned. + assert!( + err.contains("Cell mismatch") + && err.contains("Hello, Vár!") + && err.contains("Hello, world!"), + "unexpected failure rendering:\n{err}" + ); +} diff --git a/examples/rust-cargotest/var.config.json b/examples/rust-cargotest/var.config.json new file mode 100644 index 00000000..d1c507f3 --- /dev/null +++ b/examples/rust-cargotest/var.config.json @@ -0,0 +1,14 @@ +{ + "$schema": "../../conformance/config/var.config.schema.json", + "docs": { + "include": [ + "*.md" + ], + "exclude": [ + "README.md" + ] + }, + "steps": [ + "src/steps/*.rs" + ] +} diff --git a/examples/rust-cargotest/var.lock.json b/examples/rust-cargotest/var.lock.json new file mode 100644 index 00000000..0b93484b --- /dev/null +++ b/examples/rust-cargotest/var.lock.json @@ -0,0 +1,75 @@ +{ + "version": 1, + "specs": { + "deep-thought.md": { + "sourceHash": "fnv1a:a4027d33", + "examples": [ + { + "name": "The answer to the great question of life, the universe and everything is 42", + "line": 3 + } + ] + }, + "hello-var.md": { + "sourceHash": "fnv1a:bf848cd6", + "examples": [ + { + "name": "First I greet \"world\" okay? I think the greeting should be \"Hello, world!\"", + "line": 5 + }, + { + "name": "The expression `1+1` should evaluate to `2`", + "line": 11 + } + ] + }, + "library.md": { + "sourceHash": "fnv1a:b04b787c", + "examples": [ + { + "name": "Maya borrowed *Emma*, due back on June 1, 2026. She returns it on June 6, 2026 and owes a £2.50 late fee — 50p for each day overdue", + "line": 3 + }, + { + "name": "Noor borrowed *Kindred*, due back on June 1, 2026. When she asks to borrow *Beloved* on June 10, 2026, the library refuses: an overdue book blocks new loans", + "line": 6 + }, + { + "name": "Ben borrowed *Dune*, due back on June 12, 2026. When he asks to borrow *Hyperion* on June 10, 2026, the library agrees", + "line": 9 + } + ] + }, + "roman-numerals.md": { + "sourceHash": "fnv1a:a3b97c9a", + "examples": [ + { + "name": "Each row gives an example of a decimal and a roman number:", + "line": 3 + } + ] + }, + "tables-and-docstrings.md": { + "sourceHash": "fnv1a:e9f7dc76", + "examples": [ + { + "name": "Uppercase each one:", + "line": 6 + }, + { + "name": "Greet Bob:", + "line": 16 + } + ] + }, + "yahtzee.md": { + "sourceHash": "fnv1a:c10cc12e", + "examples": [ + { + "name": "Examples of dice, category and score:", + "line": 7 + } + ] + } + } +} diff --git a/examples/rust-cargotest/yahtzee.md b/examples/rust-cargotest/yahtzee.md new file mode 120000 index 00000000..73bafbd0 --- /dev/null +++ b/examples/rust-cargotest/yahtzee.md @@ -0,0 +1 @@ +../typescript-vitest/yahtzee.md \ No newline at end of file diff --git a/languages.json b/languages.json index c376de0a..f595cb13 100644 --- a/languages.json +++ b/languages.json @@ -59,5 +59,16 @@ "install": { "lang": "bash", "code": "bundle add oselvar-var-rspec" }, "scaffold": { "lang": "bash", "code": "bundle exec var init" }, "run": { "lang": "bash", "code": "bundle exec rspec" } + }, + { + "id": "rust", + "label": "Rust", + "icon": "seti:rust", + "ext": ".rs", + "stepsGlob": "var-examples/**/*.steps.rs", + "hasCli": false, + "install": { "lang": "bash", "code": "cargo add var-cargotest --dev" }, + "scaffold": null, + "run": { "lang": "bash", "code": "cargo test" } } ] diff --git a/python/packages/var-core/tests/test_sentences.py b/python/packages/var-core/tests/test_sentences.py new file mode 100644 index 00000000..39a0bc6a --- /dev/null +++ b/python/packages/var-core/tests/test_sentences.py @@ -0,0 +1,74 @@ +"""Port of typescript/packages/var-core/tests/sentences.test.ts (plus the +astral-offset case the Java/Rust ports added).""" + +from var_core.sentences import Sentence, split_sentences + + +def _texts(sentences): + return [s.text for s in sentences] + + +def test_splits_a_paragraph_on_periods_question_marks_exclamation_marks(): + result = split_sentences("First sentence. Second one? Third one!") + assert _texts(result) == ["First sentence.", "Second one?", "Third one!"] + + +def test_keeps_offsets_relative_to_the_input_text(): + result = split_sentences("Alpha. Beta.") + assert result == ( + Sentence(text="Alpha.", start_offset=0, end_offset=6), + Sentence(text="Beta.", start_offset=7, end_offset=12), + ) + + +def test_does_not_split_inside_numeric_literals(): + result = split_sentences("The price is $1.50 today.") + assert _texts(result) == ["The price is $1.50 today."] + + +def test_does_not_split_on_common_abbreviations(): + result = split_sentences("Use e.g. coffee. It works.") + assert _texts(result) == ["Use e.g. coffee.", "It works."] + + +def test_treats_a_blank_line_as_a_sentence_boundary(): + result = split_sentences("First.\n\nSecond.") + assert _texts(result) == ["First.", "Second."] + + +def test_treats_a_backtick_code_span_as_a_single_token(): + result = split_sentences("Run `npm test` first. Then `git push`.") + assert _texts(result) == ["Run `npm test` first.", "Then `git push`."] + + +def test_the_final_sentence_does_not_require_a_terminator(): + result = split_sentences("Alpha. Beta") + assert _texts(result) == ["Alpha.", "Beta"] + + +def test_does_not_split_on_terminators_inside_a_double_quoted_string(): + result = split_sentences('Alpha "with . and ? inside" beta. Gamma.') + assert _texts(result) == ['Alpha "with . and ? inside" beta.', "Gamma."] + + +def test_splits_on_a_single_newline_gherkin_style_line_per_step(): + result = split_sentences('Given I greet "world"\nThen the greeting is "Hello, world!"') + assert _texts(result) == [ + 'Given I greet "world"', + 'Then the greeting is "Hello, world!"', + ] + + +def test_splits_between_terminators_outside_quoted_strings_ignoring_those_inside(): + result = split_sentences('Alpha "with ! inside". Beta "and ? inside"!') + assert _texts(result) == ['Alpha "with ! inside".', 'Beta "and ? inside"!'] + + +def test_astral_character_keeps_utf16_offsets_correct(): + # 🎉 is one code point but two UTF-16 code units, so the first sentence's + # end offset is 14 (11 + 2 + 1), matching the sibling ports. + text = "Party time 🎉! Next one." + result = split_sentences(text) + assert _texts(result) == ["Party time 🎉!", "Next one."] + assert result[0].start_offset == 0 + assert result[0].end_offset == 14 diff --git a/python/packages/var-core/tests/test_step_role.py b/python/packages/var-core/tests/test_step_role.py new file mode 100644 index 00000000..04fb9ad2 --- /dev/null +++ b/python/packages/var-core/tests/test_step_role.py @@ -0,0 +1,23 @@ +"""Port of typescript/packages/var-core/tests/step-role.test.ts. + +`infer_step_role` is purely structural: a step with nothing after it is the +observation (sensor); anything followed by other steps is driving the software +(stimulus).""" + +from var_core.step_role import infer_step_role + + +def test_nothing_after_means_sensor_expectation_last(): + assert infer_step_role({"before": ["stimulus"], "after": []}) == "sensor" + + +def test_no_neighbours_at_all_means_sensor(): + assert infer_step_role({"before": [], "after": []}) == "sensor" + + +def test_steps_follow_means_stimulus(): + assert infer_step_role({"before": [], "after": ["sensor"]}) == "stimulus" + + +def test_steps_on_both_sides_means_stimulus(): + assert infer_step_role({"before": ["stimulus"], "after": ["stimulus"]}) == "stimulus" diff --git a/release/targets/65-crates-io.sh b/release/targets/65-crates-io.sh new file mode 100755 index 00000000..48047163 --- /dev/null +++ b/release/targets/65-crates-io.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Publish every Rust workspace crate to crates.io. Idempotent per crate. +# +# PARKED until the Rust port is ready to ship: the crates are `publish = false` +# in their Cargo.toml and their crates.io names are unclaimed, so this target is +# disabled and simply reports OK. To go live: verify/claim the crate names, flip +# each crate's `publish = false` to a real version, add `rust` to the consumer +# scopes in release/lint-commits.sh + cliff.toml, un-inert the Cargo pin block +# in 70-var-examples.sh, and set DISABLED=0 here. +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/../lib.sh" +VERSION="$1" + +DISABLED=1 +if [[ "$DISABLED" == "1" ]]; then + warn "crates-io: target parked — see the header in ${BASH_SOURCE[0]} to enable" + exit 0 +fi + +cd "$REPO_ROOT/rust" + +# Publish in dependency order so a crate's deps exist on crates.io when it is +# pushed. crates.io indexes each publish before the next `cargo publish` can +# resolve it, so a brief wait between crates may be needed. +crates=( + var-core + var-config + var + var-runner + var-cargotest +) + +for name in "${crates[@]}"; do + if cargo search "$name" 2>/dev/null | grep -q "^$name = \"$VERSION\""; then + log "crates-io: $name $VERSION already published" + continue + fi + if [[ "${DRY_RUN:-0}" == "1" ]]; then + log "crates-io: would publish $name $VERSION" + continue + fi + (cd "$name" && cargo publish) + log "crates-io: published $name $VERSION" +done +log "crates-io: done" diff --git a/release/targets/70-var-examples.sh b/release/targets/70-var-examples.sh index 0137725c..4985f2a7 100755 --- a/release/targets/70-var-examples.sh +++ b/release/targets/70-var-examples.sh @@ -53,6 +53,7 @@ rsync -a --copy-links \ --exclude '.pytest_cache/' \ --exclude 'uv.lock' \ --exclude 'Gemfile.lock' \ + --exclude 'Cargo.lock' \ examples/ "$DEST"/ # Pin the JVM samples to the released Maven Central artifacts (idempotent even @@ -85,6 +86,13 @@ perl -pi -e "s/\"(pytest-var|oselvar-var[\\w-]*)\"/\"\$1==$VERSION\"/" \ perl -pi -e "s|, path: \"\\.\\./\\.\\./ruby/packages/[\\w-]+\"|, \"$VERSION\"|" \ "$DEST"/ruby-*/Gemfile +# Pin the Rust sample to the released crates.io version: swap the var-core +# path dependency for a version constraint. Inert until var-core is published +# to crates.io (the Rust port has no release target yet); kept here so the +# sync stays correct the moment it is. +perl -pi -e "s|var-core = \{ path = \"\\.\\./\\.\\./rust/var-core\" \}|var-core = \"$VERSION\"|" \ + "$DEST"/rust-*/Cargo.toml + git -C "$DEST" add -A if git -C "$DEST" diff --cached --quiet; then log "var-examples: already in sync with $TAG" diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 00000000..92e83d24 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,442 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cucumber-expressions" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6401038de3af44fe74e6fccdb8a5b7db7ba418f480c8e9ad584c6f65c05a27a6" +dependencies = [ + "derive_more", + "either", + "nom", + "nom_locate", + "regex", + "regex-syntax", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "escape8259" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libtest-mimic" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e6ba06f0ade6e504aff834d7c34298e5155c6baca353cc6a4aaff2f9fd7f33" +dependencies = [ + "anstream", + "anstyle", + "clap", + "escape8259", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "var" +version = "0.0.0" +dependencies = [ + "var-core", +] + +[[package]] +name = "var-cargotest" +version = "0.0.0" +dependencies = [ + "libtest-mimic", + "var-config", + "var-core", + "var-runner", +] + +[[package]] +name = "var-config" +version = "0.0.0" +dependencies = [ + "serde_json", + "var-core", +] + +[[package]] +name = "var-core" +version = "0.0.0" +dependencies = [ + "cucumber-expressions", + "regex", +] + +[[package]] +name = "var-runner" +version = "0.0.0" +dependencies = [ + "regex", + "var-config", + "var-core", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 00000000..7d8faf04 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "3" +members = ["var-core", "var", "var-config", "var-runner", "var-cargotest"] diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml new file mode 100644 index 00000000..5059b153 --- /dev/null +++ b/rust/rust-toolchain.toml @@ -0,0 +1,7 @@ +# Pinned like java/.tool-versions pins JDK 21: "1.97" tracks 1.97.x patch +# releases; bump deliberately, not to whatever `stable` happens to be. +# Components listed so rustup auto-installs fmt/clippy with the pinned +# toolchain (CI's setup action only adds them to `stable`). +[toolchain] +channel = "1.97" +components = ["rustfmt", "clippy"] diff --git a/rust/var-cargotest/Cargo.toml b/rust/var-cargotest/Cargo.toml new file mode 100644 index 00000000..60d26df0 --- /dev/null +++ b/rust/var-cargotest/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "var-cargotest" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +var-core = { path = "../var-core" } +var-config = { path = "../var-config" } +var-runner = { path = "../var-runner" } +libtest-mimic = "0.8" + +[lib] +name = "var_cargotest" +path = "src/lib.rs" diff --git a/rust/var-cargotest/src/lib.rs b/rust/var-cargotest/src/lib.rs new file mode 100644 index 00000000..371e2bdb --- /dev/null +++ b/rust/var-cargotest/src/lib.rs @@ -0,0 +1,111 @@ +//! `var-cargotest` — the `cargo test` adapter (ADR 0007). +//! +//! Turns every Markdown example matched by `var.config.json` into one +//! `libtest-mimic` test, reported/filtered/listed by `cargo test` like a native +//! `#[test]`. var-core is single-threaded (`Rc`, not `Send`), so each test body +//! captures only owned `Send` data — the spec path/source plus `fn` pointers to +//! the step registry + context factory — and **re-derives its one example +//! thread-locally** (re-parse, re-plan, run index `i`). No `Rc` value crosses a +//! thread boundary. +//! +//! Usage from a consumer's `tests/specs.rs` (with `harness = false`): +//! ```ignore +//! fn main() { +//! var_cargotest::run( +//! std::path::Path::new(env!("CARGO_MANIFEST_DIR")), +//! my_steps::build_registry, // fn() -> Registry +//! my_steps::context_value, // fn(&str) -> Value +//! ); +//! } +//! ``` +#![allow(clippy::result_large_err)] + +use std::path::Path; + +use libtest_mimic::{Arguments, Failed, Trial}; +use var_core::drift::{self, reconcile_drift}; +use var_core::parse::parse; +use var_core::registry::Registry; +use var_core::value::Value; +use var_runner::{ + FileBaselineStore, example_names, find_specs, plan_spec, render_failure, run_example, +}; + +/// Build a registry (`fn`, not a closure — must be `Send + Copy`). +pub type BuildRegistry = fn() -> Registry; +/// Map a step file to its fresh initial state. +pub type ContextFactory = fn(&str) -> Value; + +/// Re-derive and run one example by index. This is what each example `Trial` +/// closure calls; kept public so it is unit-testable. +pub fn run_one( + spec_file: &str, + source: &str, + rel: &str, + build_registry: BuildRegistry, + context: ContextFactory, + index: usize, +) -> Result<(), String> { + let registry = build_registry(); + let execution = plan_spec(spec_file, source, ®istry); + let context_factory = move |file: &str| context(file); + run_example(&execution, &context_factory, index) + .map_err(|failure| render_failure(&failure, source, rel)) +} + +/// Enumerate every example (and any drift) as `libtest-mimic` trials. Drift is +/// reconciled here, on the main thread: a clean run rewrites `var.lock.json`; +/// `VAR_UPDATE=1` accepts drift instead of failing. +pub fn trials(root: &Path, build_registry: BuildRegistry, context: ContextFactory) -> Vec { + let config = read_config(root); + let update = matches!(std::env::var("VAR_UPDATE").as_deref(), Ok("1") | Ok("true")); + let mut trials = Vec::new(); + + for spec_path in find_specs(&config, root) { + let source = std::fs::read_to_string(&spec_path).unwrap_or_default(); + let spec_file = spec_path + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(); + let rel = spec_path + .strip_prefix(root) + .unwrap_or(&spec_path) + .to_string_lossy() + .into_owned(); + + let registry = build_registry(); + let execution = plan_spec(&spec_file, &source, ®istry); + + for (index, display) in example_names(&execution).into_iter().enumerate() { + let (sf, src, r) = (spec_file.clone(), source.clone(), rel.clone()); + trials.push(Trial::test(format!("{rel}::{display}"), move || { + run_one(&sf, &src, &r, build_registry, context, index).map_err(Failed::from) + })); + } + + // Drift reconciliation (main thread): rewrites the baseline on a clean + // run; each drifted paragraph becomes a failing trial (ADR 0002). + let mut store = FileBaselineStore::new(root); + let doc = parse(&spec_file, &source); + for drifted in reconcile_drift(&mut store, &rel, &source, &doc, &execution, update) { + let message = drift::message(&drifted); + trials.push(Trial::test( + format!("{rel}::var:drift:{}", drifted.line), + move || Err(Failed::from(message)), + )); + } + } + trials +} + +/// The `harness = false` entry point: parse `cargo test` args, build the trials, +/// run, and exit with the appropriate status. Never returns. +pub fn run(root: &Path, build_registry: BuildRegistry, context: ContextFactory) { + let args = Arguments::from_args(); + libtest_mimic::run(&args, trials(root, build_registry, context)).exit(); +} + +fn read_config(root: &Path) -> var_config::VarConfig { + var_config::read_var_config(root).unwrap_or_else(|e| panic!("{e}")) +} diff --git a/rust/var-cargotest/tests/adapter.rs b/rust/var-cargotest/tests/adapter.rs new file mode 100644 index 00000000..5f1d3b66 --- /dev/null +++ b/rust/var-cargotest/tests/adapter.rs @@ -0,0 +1,38 @@ +//! Unit tests for the adapter's per-example runner (the libtest binding itself +//! is exercised end-to-end by the sample project in examples/rust-cargotest). + +use var_cargotest::run_one; +use var_core::handler::Handler; +use var_core::registry::{Registry, add_step, create_registry}; +use var_core::step_kind::StepKind; +use var_core::value::Value; + +fn build_registry() -> Registry { + add_step( + &create_registry(), + "the answer is {int}", + "s.rs", + 1, + Handler::sync1(|_state, _expected| Ok(Some(Value::Int(42)))), + Some(StepKind::Sensor), + ) + .unwrap() +} + +fn context(_file: &str) -> Value { + Value::Null +} + +#[test] +fn a_matching_example_passes() { + let source = "# Q\n\nthe answer is 42."; + assert!(run_one("q.md", source, "q.md", build_registry, context, 0).is_ok()); +} + +#[test] +fn a_mismatching_example_fails_with_a_rendered_message() { + let source = "# Q\n\nthe answer is 41."; + let err = run_one("q.md", source, "q.md", build_registry, context, 0).unwrap_err(); + assert!(err.contains("Cell mismatch"), "unexpected render: {err}"); + assert!(err.contains("41") && err.contains("42")); +} diff --git a/rust/var-config/Cargo.toml b/rust/var-config/Cargo.toml new file mode 100644 index 00000000..cdd450c3 --- /dev/null +++ b/rust/var-config/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "var-config" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde_json = "1" + +# Test-only: the conformance corpus is compared through var-core's canonical +# JSON. The reader itself stays pure (serde_json only). +[dev-dependencies] +var-core = { path = "../var-core" } + +[lib] +name = "var_config" +path = "src/lib.rs" + +[[test]] +name = "conformance" +path = "tests/conformance.rs" diff --git a/rust/var-config/src/lib.rs b/rust/var-config/src/lib.rs new file mode 100644 index 00000000..03f33734 --- /dev/null +++ b/rust/var-config/src/lib.rs @@ -0,0 +1,117 @@ +//! `var-config` — the strict, fail-loud reader for `var.config.json`. +//! +//! Port of `@oselvar/var-config` / Python `var_config`. The canonical shape is +//! `{ docs: { include, exclude }, steps, snippets, scannerPlugins }`; every key +//! is optional and defaults to empty. A missing file yields the empty config +//! (tools no-op); malformed JSON, wrong types, or unknown keys fail loudly with +//! the file path. Proven by the shared corpus at `conformance/config/cases/`. + +use std::collections::BTreeMap; +use std::path::Path; + +const KNOWN_KEYS: &[&str] = &["$schema", "docs", "steps", "snippets", "scannerPlugins"]; +const KNOWN_DOCS_KEYS: &[&str] = &["include", "exclude"]; + +/// The parsed configuration. All fields default to empty. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct VarConfig { + pub docs_include: Vec, + pub docs_exclude: Vec, + pub steps: Vec, + pub snippets: BTreeMap, + pub scanner_plugins: Vec, +} + +/// Read `/var.config.json`. Missing file → empty config. Any malformed +/// input → `Err(message)` beginning with the file path. +pub fn read_var_config(root: &Path) -> Result { + let path = root.join("var.config.json"); + let loc = path.display(); + if !path.is_file() { + return Ok(VarConfig::default()); + } + let text = std::fs::read_to_string(&path).map_err(|e| format!("{loc}: {e}"))?; + let data: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("{loc}: invalid JSON: {e}"))?; + let obj = data + .as_object() + .ok_or_else(|| format!("{loc}: top level must be an object"))?; + + let unknown: Vec<&str> = obj + .keys() + .map(String::as_str) + .filter(|k| !KNOWN_KEYS.contains(k)) + .collect(); + if !unknown.is_empty() { + return Err(format!("{loc}: unknown key(s): {}", unknown.join(", "))); + } + + let docs = obj.get("docs").cloned().unwrap_or(serde_json::Value::Null); + let (docs_include, docs_exclude) = if docs.is_null() { + (Vec::new(), Vec::new()) + } else { + let docs_obj = docs + .as_object() + .ok_or_else(|| format!("{loc}: 'docs' must be an object"))?; + let unknown_docs: Vec<&str> = docs_obj + .keys() + .map(String::as_str) + .filter(|k| !KNOWN_DOCS_KEYS.contains(k)) + .collect(); + if !unknown_docs.is_empty() { + return Err(format!( + "{loc}: unknown docs key(s): {}", + unknown_docs.join(", ") + )); + } + ( + string_array(docs_obj.get("include"), "docs.include", &loc)?, + string_array(docs_obj.get("exclude"), "docs.exclude", &loc)?, + ) + }; + + Ok(VarConfig { + docs_include, + docs_exclude, + steps: string_array(obj.get("steps"), "steps", &loc)?, + snippets: string_map(obj.get("snippets"), &loc)?, + scanner_plugins: string_array(obj.get("scannerPlugins"), "scannerPlugins", &loc)?, + }) +} + +fn string_array( + value: Option<&serde_json::Value>, + key: &str, + loc: &impl std::fmt::Display, +) -> Result, String> { + match value { + None | Some(serde_json::Value::Null) => Ok(Vec::new()), + Some(serde_json::Value::Array(items)) => items + .iter() + .map(|v| { + v.as_str() + .map(str::to_string) + .ok_or_else(|| format!("{loc}: '{key}' must be an array of strings")) + }) + .collect(), + Some(_) => Err(format!("{loc}: '{key}' must be an array of strings")), + } +} + +fn string_map( + value: Option<&serde_json::Value>, + loc: &impl std::fmt::Display, +) -> Result, String> { + match value { + None | Some(serde_json::Value::Null) => Ok(BTreeMap::new()), + Some(serde_json::Value::Object(entries)) => entries + .iter() + .map(|(k, v)| { + v.as_str() + .map(|s| (k.clone(), s.to_string())) + .ok_or_else(|| format!("{loc}: 'snippets' must be an object of strings")) + }) + .collect(), + Some(_) => Err(format!("{loc}: 'snippets' must be an object of strings")), + } +} diff --git a/rust/var-config/tests/conformance.rs b/rust/var-config/tests/conformance.rs new file mode 100644 index 00000000..d4d5a699 --- /dev/null +++ b/rust/var-config/tests/conformance.rs @@ -0,0 +1,72 @@ +//! Config conformance gate: reproduce `conformance/config/cases/*` byte-for-byte. +//! A case with `expect-error.txt` must fail to load; otherwise the projected +//! config, serialized with var-core's canonical JSON, must equal `golden.json`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use var_config::{VarConfig, read_var_config}; +use var_core::canonical_json::canonical_stringify; +use var_core::value::Value; + +fn cases_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../conformance/config/cases") +} + +fn list(strings: &[String]) -> Value { + Value::List(strings.iter().map(|s| Value::from(s.as_str())).collect()) +} + +/// Project to `{ docs: { include, exclude }, steps, snippets, scannerPlugins }`. +fn artifact(config: &VarConfig) -> Value { + let docs = Value::map(vec![ + ("include".to_string(), list(&config.docs_include)), + ("exclude".to_string(), list(&config.docs_exclude)), + ]); + let snippets = Value::map( + config + .snippets + .iter() + .map(|(k, v)| (k.clone(), Value::from(v.as_str()))), + ); + Value::map(vec![ + ("docs".to_string(), docs), + ("steps".to_string(), list(&config.steps)), + ("snippets".to_string(), snippets), + ("scannerPlugins".to_string(), list(&config.scanner_plugins)), + ]) +} + +#[test] +fn config_cases_match_golden() { + let mut dirs: Vec = fs::read_dir(cases_dir()) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.is_dir()) + .collect(); + dirs.sort(); + assert!(!dirs.is_empty(), "no config cases found"); + + let mut fails = Vec::new(); + for dir in dirs { + let name = dir.file_name().unwrap().to_string_lossy().into_owned(); + let result = read_var_config(&dir); + if dir.join("expect-error.txt").is_file() { + if result.is_ok() { + fails.push(format!("{name}: expected an error, got Ok")); + } + continue; + } + match result { + Err(e) => fails.push(format!("{name}: unexpected error: {e}")), + Ok(config) => { + let actual = canonical_stringify(&artifact(&config)); + let expected = fs::read_to_string(dir.join("golden.json")).unwrap(); + if actual != expected { + fails.push(format!("{name}: golden mismatch")); + } + } + } + } + assert!(fails.is_empty(), "config conformance failures: {fails:#?}"); +} diff --git a/rust/var-core/Cargo.toml b/rust/var-core/Cargo.toml new file mode 100644 index 00000000..f78a22d1 --- /dev/null +++ b/rust/var-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "var-core" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +regex = "1" +cucumber-expressions = { version = "0.5", features = ["into-regex"] } + +[lib] +name = "var_core" +path = "src/lib.rs" diff --git a/rust/var-core/src/ast.rs b/rust/var-core/src/ast.rs new file mode 100644 index 00000000..4bfb7880 --- /dev/null +++ b/rust/var-core/src/ast.rs @@ -0,0 +1,140 @@ +//! AST node types produced by the scanner/structurer — port of `ast.ts` / +//! `Ast.java`. Pure data; the sealed `Block`/`TableOrFence` interfaces become +//! Rust enums (exhaustive `match` replaces `instanceof`). Immutability is by +//! construction (owned fields, no mutation) — Java's `List.copyOf` defensive +//! copies have no Rust analog. + +use crate::span::Span; + +/// Maps a block-text offset to its source offset (both UTF-16). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SegmentOffset { + pub text_offset: usize, + pub source_offset: usize, +} + +impl SegmentOffset { + pub fn new(text_offset: usize, source_offset: usize) -> SegmentOffset { + SegmentOffset { + text_offset, + source_offset, + } + } +} + +/// A markdown heading (`#`..`######`); `level` is 1–6. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Heading { + pub level: usize, + pub text: String, + pub span: Span, +} + +/// A markdown paragraph. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Paragraph { + pub text: String, + pub span: Span, + pub segment_map: Vec, +} + +/// A single list item (`-`/`*` or numbered). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ListItem { + pub text: String, + pub span: Span, + pub segment_map: Vec, + pub ordered: bool, + pub marker_span: Span, +} + +/// A markdown blockquote (`>`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Blockquote { + pub text: String, + pub span: Span, + pub segment_map: Vec, +} + +/// One row of a table: `cells` and `cell_spans` are parallel, same-length. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Row { + pub cells: Vec, + pub cell_spans: Vec, + pub span: Span, +} + +/// A markdown table: a header [`Row`] plus zero or more data rows. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Table { + pub span: Span, + pub header: Row, + pub rows: Vec, +} + +/// A fenced code block; `info` is the text after the opening fence. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Fence { + pub span: Span, + pub info: String, + pub body: String, + pub body_span: Span, +} + +/// A thematic break (`---`/`***`/`___`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ThematicBreak { + pub span: Span, +} + +/// A markdown block node — the closed union the structurer matches over. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Block { + Heading(Heading), + Paragraph(Paragraph), + ListItem(ListItem), + Blockquote(Blockquote), + Table(Table), + Fence(Fence), + ThematicBreak(ThematicBreak), +} + +impl Block { + /// The block's source span (exhaustive over the union). + pub fn span(&self) -> Span { + match self { + Block::Heading(h) => h.span, + Block::Paragraph(p) => p.span, + Block::ListItem(l) => l.span, + Block::Blockquote(b) => b.span, + Block::Table(t) => t.span, + Block::Fence(f) => f.span, + Block::ThematicBreak(t) => t.span, + } + } +} + +/// The block kinds that may appear as a [`VarDoc`] orphan attachment (`Table | Fence`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TableOrFence { + Table(Table), + Fence(Fence), +} + +/// One matched example: the heading scope above it (outer→inner) plus its body +/// blocks (first is the candidate primary block, then any trailing attachments). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Example { + pub scope_stack: Vec, + pub span: Span, + pub body: Vec, +} + +/// A parsed source file: its matched examples plus unattached table/fence blocks. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VarDoc { + pub path: String, + pub source: String, + pub examples: Vec, + pub orphan_attachments: Vec, +} diff --git a/rust/var-core/src/canonical_json.rs b/rust/var-core/src/canonical_json.rs new file mode 100644 index 00000000..3c75530f --- /dev/null +++ b/rust/var-core/src/canonical_json.rs @@ -0,0 +1,123 @@ +//! Hand-rolled canonical JSON serializer — port of `CanonicalJson.java` (concept +//! of `canonicalStringify`). Reproduces `JSON.stringify(sortKeys(v), null, 2) + +//! "\n"` byte-for-byte: recursively key-sorted objects, 2-space indent, LF + +//! trailing newline, raw non-ASCII, control chars as `\uXXXX`. + +use crate::value::Value; +use std::collections::BTreeMap; +use std::fmt::Write; + +/// Serializes `value` to canonical JSON, with a trailing `"\n"`. +pub fn canonical_stringify(value: &Value) -> String { + let mut out = String::new(); + write_value(&mut out, value, 0); + out.push('\n'); + out +} + +fn write_value(out: &mut String, value: &Value, depth: usize) { + match value { + Value::Null => out.push_str("null"), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Int(i) => { + let _ = write!(out, "{i}"); + } + Value::Float(d) => write_number(out, *d), + Value::String(s) => write_string(out, s), + Value::List(list) => write_array(out, list, depth), + Value::Map(map) => write_object(out, map, depth), + } +} + +fn write_object(out: &mut String, map: &BTreeMap, depth: usize) { + if map.is_empty() { + out.push_str("{}"); + return; + } + // The goldens were generated by JS `sort()` / Java `TreeMap`, which order by + // UTF-16 code units — NOT the code-point order `BTreeMap` gives. + // The two differ only for keys mixing astral characters (≥ U+10000) with + // U+E000..U+FFFF, but byte-exactness is the whole contract, so re-sort. + let mut entries: Vec<(&String, &Value)> = map.iter().collect(); + entries.sort_by(|(a, _), (b, _)| { + a.encode_utf16() + .collect::>() + .cmp(&b.encode_utf16().collect::>()) + }); + out.push_str("{\n"); + let n = entries.len(); + for (i, (key, val)) in entries.into_iter().enumerate() { + indent(out, depth + 1); + write_string(out, key); + out.push_str(": "); + write_value(out, val, depth + 1); + if i + 1 < n { + out.push(','); + } + out.push('\n'); + } + indent(out, depth); + out.push('}'); +} + +fn write_array(out: &mut String, list: &[Value], depth: usize) { + if list.is_empty() { + out.push_str("[]"); + return; + } + out.push_str("[\n"); + let n = list.len(); + for (i, item) in list.iter().enumerate() { + indent(out, depth + 1); + write_value(out, item, depth + 1); + if i + 1 < n { + out.push(','); + } + out.push('\n'); + } + indent(out, depth); + out.push(']'); +} + +fn write_string(out: &mut String, s: &str) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{0008}' => out.push_str("\\b"), + '\u{000c}' => out.push_str("\\f"), + c if (c as u32) < 0x20 => { + let _ = write!(out, "\\u{:04x}", c as u32); + } + // Non-ASCII (and all other) characters are emitted raw. + c => out.push(c), + } + } + out.push('"'); +} + +fn write_number(out: &mut String, d: f64) { + // A finite integral double serializes as an integer (matching Java's + // `(long) d` when `d == Math.rint(d)`, and JS `JSON.stringify`). + // + // Known divergences from JS, none reachable by the corpus (its floats are + // small, e.g. bundle 15's 2.55/2.6): `d as i64` saturates beyond i64::MAX, + // where JS prints e.g. 1e21 as "1e+21"; and Rust's f64 `Display` is the + // shortest round-trip form, which differs from JS's number-to-string for + // some exotic magnitudes. Revisit if a golden ever pins such a value. + if d.is_finite() && d == d.trunc() { + let _ = write!(out, "{}", d as i64); + } else { + let _ = write!(out, "{d}"); + } +} + +fn indent(out: &mut String, depth: usize) { + for _ in 0..depth { + out.push_str(" "); + } +} diff --git a/rust/var-core/src/cell_diff.rs b/rust/var-core/src/cell_diff.rs new file mode 100644 index 00000000..1f1f3442 --- /dev/null +++ b/rust/var-core/src/cell_diff.rs @@ -0,0 +1,171 @@ +//! Table row/cell comparison — port of `cell-diff.ts` / `CellDiff.java`. +//! `Object` + `instanceof Map`/`List` duck-typing becomes matching on [`Value`]. + +use crate::ast::Table; +use crate::error::StepError; +use crate::span::Span; +use crate::value::Value; + +/// The verdict for one checked column: expected vs actual, plus raw values and +/// whether a parameter-type `format` produced `actual` (inline-parameter path). +#[derive(Clone, Debug, PartialEq)] +pub struct CellDiff { + pub column: String, + pub span: Span, + pub expected: String, + pub actual: String, + pub ok: bool, + pub expected_value: Option, + pub actual_value: Option, + pub formatted: bool, +} + +impl CellDiff { + /// The five-component form (row/table paths): raw values `None`, not formatted. + pub fn new( + column: impl Into, + span: Span, + expected: impl Into, + actual: impl Into, + ok: bool, + ) -> CellDiff { + CellDiff { + column: column.into(), + span, + expected: expected.into(), + actual: actual.into(), + ok, + expected_value: None, + actual_value: None, + formatted: false, + } + } +} + +/// One checked column of one header-bound row. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RowCheck { + pub column: String, + pub value: String, + pub span: Span, +} + +impl RowCheck { + pub fn new(column: impl Into, value: impl Into, span: Span) -> RowCheck { + RowCheck { + column: column.into(), + value: value.into(), + span, + } + } +} + +/// Display rules 2–4 of the mismatch-rendering chain: a string as-is, anything +/// else a best-effort stringification. +pub fn render_cell_value(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Int(i) => i.to_string(), + Value::Float(d) => format!("{d}"), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), + // Port-native fallback (deliberately outside conformance): a bundle that + // pins an object-valued actual must give the parameter type a `format`. + Value::List(_) | Value::Map(_) => format!("{value:?}"), + } +} + +/// Compares a row step's returned map against the row's cells. Only columns +/// present on `returned` are checked; a non-map/`None` return checks nothing. +pub fn compare_row(returned: Option<&Value>, checks: &[RowCheck]) -> Vec { + let Some(Value::Map(obj)) = returned else { + return Vec::new(); + }; + let mut diffs = Vec::new(); + for check in checks { + let Some(value) = obj.get(&check.column) else { + continue; + }; + let actual = render_cell_value(value); + let ok = actual == check.value; + diffs.push(CellDiff::new( + check.column.clone(), + check.span, + check.value.clone(), + actual, + ok, + )); + } + diffs +} + +/// Compares a whole-table step's returned table against the input table. `None` +/// checks nothing; type/shape problems return [`StepError::ReturnShape`]. +pub fn compare_table(returned: Option<&Value>, input: &Table) -> Result, StepError> { + let rows = match returned { + None => return Ok(Vec::new()), + Some(Value::List(rows)) => rows, + Some(other) => { + return Err(StepError::ReturnShape(format!( + "expected a table (array of rows), got {}", + other.type_name() + ))); + } + }; + let columns = &input.header.cells; + let data_rows = &input.rows; + if rows.len() != data_rows.len() { + return Err(StepError::ReturnShape(format!( + "expected {} row(s), got {}", + data_rows.len(), + rows.len() + ))); + } + let all_arrays = rows.iter().all(|r| matches!(r, Value::List(_))); + let all_records = rows.iter().all(|r| matches!(r, Value::Map(_))); + if !all_arrays && !all_records { + return Err(StepError::ReturnShape( + "table rows must be all arrays or all objects".to_string(), + )); + } + + let mut diffs = Vec::new(); + for (i, (data_row, ret)) in data_rows.iter().zip(rows).enumerate() { + if all_arrays { + if let Value::List(cells) = ret { + if cells.len() != columns.len() { + return Err(StepError::ReturnShape(format!( + "row {}: expected {} column(s), got {}", + i, + columns.len(), + cells.len() + ))); + } + } + } + for (j, column) in columns.iter().enumerate() { + let actual_value: &Value = if all_arrays { + let Value::List(cells) = ret else { + unreachable!() + }; + &cells[j] + } else { + let Value::Map(rec) = ret else { unreachable!() }; + match rec.get(column) { + Some(v) => v, + None => { + return Err(StepError::ReturnShape(format!( + "row {i}: missing column \"{column}\"" + ))); + } + } + }; + let expected = data_row.cells.get(j).map_or("", |c| c.as_str()); + let actual = render_cell_value(actual_value); + let span = data_row.cell_spans.get(j).copied().unwrap_or(data_row.span); + let ok = actual == expected; + diffs.push(CellDiff::new(column.clone(), span, expected, actual, ok)); + } + } + Ok(diffs) +} diff --git a/rust/var-core/src/conformance.rs b/rust/var-core/src/conformance.rs new file mode 100644 index 00000000..baf1d824 --- /dev/null +++ b/rust/var-core/src/conformance.rs @@ -0,0 +1,529 @@ +//! Projects pipeline output into the plain [`Value`] wire artifacts the +//! conformance goldens pin — port of `conformance.ts` / `Conformance.java`. +//! Covers all four projections (var-doc, registry, plan, trace). + +use crate::ast::{ + Block, Blockquote, Example, Fence, Heading, ListItem, Paragraph, Row, SegmentOffset, Table, + TableOrFence, ThematicBreak, VarDoc, +}; +use crate::diagnostics::{Diagnostic, DiagnosticCode, Severity}; +use crate::error::{StepError, StepFailure}; +use crate::execute::{ExecutePorts, StepObservation, StepOutcome, collect_examples}; +use crate::offsets::utf16_slice; +use crate::plan::{ExecutionPlan, PlannedExample, PlannedStep, plan}; +use crate::registry::Registry; +use crate::span::Span; +use crate::value::Value; +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; +use std::rc::Rc; + +pub use crate::expression::parameter_type_names; + +/// All four projected wire artifacts for one bundle. +pub struct BundleArtifacts { + pub var_doc: Value, + pub registry: Value, + pub plan: Value, + pub trace: Value, +} + +fn obj(pairs: Vec<(&str, Value)>) -> Value { + let mut m = BTreeMap::new(); + for (k, v) in pairs { + m.insert(k.to_string(), v); + } + Value::Map(m) +} + +fn vint(n: usize) -> Value { + Value::Int(n as i64) +} + +// ----------------------------------------------------------------------------- +// var-doc projection +// ----------------------------------------------------------------------------- + +/// Projects a parsed [`VarDoc`] to the var-doc wire artifact. +pub fn to_var_doc_artifact(doc: &VarDoc) -> Value { + obj(vec![ + ("path", Value::from(doc.path.as_str())), + ( + "examples", + Value::List(doc.examples.iter().map(example).collect()), + ), + ( + "orphanAttachments", + Value::List(doc.orphan_attachments.iter().map(table_or_fence).collect()), + ), + ]) +} + +fn span(s: Span) -> Value { + obj(vec![ + ("startOffset", vint(s.start_offset)), + ("endOffset", vint(s.end_offset)), + ("startLine", vint(s.start_line)), + ("startCol", vint(s.start_col)), + ("endLine", vint(s.end_line)), + ("endCol", vint(s.end_col)), + ]) +} + +fn segment_offset(o: &SegmentOffset) -> Value { + obj(vec![ + ("textOffset", vint(o.text_offset)), + ("sourceOffset", vint(o.source_offset)), + ]) +} + +fn segment_map(map: &[SegmentOffset]) -> Value { + Value::List(map.iter().map(segment_offset).collect()) +} + +fn row(r: &Row) -> Value { + obj(vec![ + ( + "cells", + Value::List(r.cells.iter().map(|c| Value::from(c.as_str())).collect()), + ), + ( + "cellSpans", + Value::List(r.cell_spans.iter().map(|s| span(*s)).collect()), + ), + ("span", span(r.span)), + ]) +} + +fn table(t: &Table) -> Value { + obj(vec![ + ("kind", Value::from("table")), + ("span", span(t.span)), + ("header", row(&t.header)), + ("rows", Value::List(t.rows.iter().map(row).collect())), + ]) +} + +fn fence(f: &Fence) -> Value { + obj(vec![ + ("kind", Value::from("fence")), + ("span", span(f.span)), + ("info", Value::from(f.info.as_str())), + ("body", Value::from(f.body.as_str())), + ("bodySpan", span(f.body_span)), + ]) +} + +fn heading(h: &Heading) -> Value { + obj(vec![ + ("kind", Value::from("heading")), + ("level", vint(h.level)), + ("text", Value::from(h.text.as_str())), + ("span", span(h.span)), + ]) +} + +fn paragraph(p: &Paragraph) -> Value { + obj(vec![ + ("kind", Value::from("paragraph")), + ("text", Value::from(p.text.as_str())), + ("span", span(p.span)), + ("segmentMap", segment_map(&p.segment_map)), + ]) +} + +fn list_item(l: &ListItem) -> Value { + obj(vec![ + ("kind", Value::from("list_item")), + ("text", Value::from(l.text.as_str())), + ("span", span(l.span)), + ("segmentMap", segment_map(&l.segment_map)), + ("ordered", Value::Bool(l.ordered)), + ("markerSpan", span(l.marker_span)), + ]) +} + +fn blockquote(b: &Blockquote) -> Value { + obj(vec![ + ("kind", Value::from("blockquote")), + ("text", Value::from(b.text.as_str())), + ("span", span(b.span)), + ("segmentMap", segment_map(&b.segment_map)), + ]) +} + +fn thematic_break(t: &ThematicBreak) -> Value { + obj(vec![ + ("kind", Value::from("thematic_break")), + ("span", span(t.span)), + ]) +} + +fn block(b: &Block) -> Value { + match b { + Block::Heading(h) => heading(h), + Block::Paragraph(p) => paragraph(p), + Block::ListItem(l) => list_item(l), + Block::Blockquote(b) => blockquote(b), + Block::Table(t) => table(t), + Block::Fence(f) => fence(f), + Block::ThematicBreak(t) => thematic_break(t), + } +} + +fn table_or_fence(tf: &TableOrFence) -> Value { + match tf { + TableOrFence::Table(t) => table(t), + TableOrFence::Fence(f) => fence(f), + } +} + +fn example(e: &Example) -> Value { + obj(vec![ + ( + "scopeStack", + Value::List( + e.scope_stack + .iter() + .map(|s| Value::from(s.as_str())) + .collect(), + ), + ), + ("span", span(e.span)), + ("body", Value::List(e.body.iter().map(block).collect())), + ]) +} + +// ----------------------------------------------------------------------------- +// registry projection +// ----------------------------------------------------------------------------- + +/// Projects a [`Registry`] to the registry wire artifact. +pub fn to_registry_artifact(registry: &Registry) -> Value { + let steps: Vec = registry + .steps + .iter() + .map(|s| { + obj(vec![ + ("expression", Value::from(s.expression.as_str())), + ( + "parameterTypeNames", + Value::List( + parameter_type_names(&s.expression) + .into_iter() + .map(Value::from) + .collect(), + ), + ), + ]) + }) + .collect(); + let parameter_types: Vec = registry + .custom_parameter_types + .iter() + .map(|p| { + obj(vec![ + ("name", Value::from(p.name.as_str())), + ("regexp", Value::from(p.regexp.as_str())), + ]) + }) + .collect(); + obj(vec![ + ("steps", Value::List(steps)), + ("parameterTypes", Value::List(parameter_types)), + ]) +} + +// ----------------------------------------------------------------------------- +// plan projection +// ----------------------------------------------------------------------------- + +/// Projects an [`ExecutionPlan`] to the plan wire artifact. +pub fn to_plan_artifact(plan: &ExecutionPlan) -> Value { + let source = &plan.var_doc.source; + obj(vec![ + ( + "examples", + Value::List( + plan.examples + .iter() + .map(|ex| planned_example(source, ex)) + .collect(), + ), + ), + ( + "diagnostics", + Value::List(plan.diagnostics.iter().map(diagnostic).collect()), + ), + ]) +} + +fn planned_example(source: &str, ex: &PlannedExample) -> Value { + let mut pairs = vec![ + ("name", Value::from(ex.name.as_str())), + ( + "scopeStack", + Value::List( + ex.scope_stack + .iter() + .map(|s| Value::from(s.as_str())) + .collect(), + ), + ), + ("span", span(ex.span)), + ( + "expectedOutcome", + Value::from(ex.expected_outcome.as_deref().unwrap_or("pass")), + ), + ]; + if let Some(msg) = &ex.expected_error_message { + pairs.push(("expectedErrorMessage", Value::from(msg.as_str()))); + } + pairs.push(( + "steps", + Value::List(ex.steps.iter().map(|s| planned_step(source, s)).collect()), + )); + obj(pairs) +} + +fn planned_step(source: &str, step: &PlannedStep) -> Value { + let param_names = parameter_type_names(&step.step_def.expression); + let args: Vec = step + .param_spans + .iter() + .enumerate() + .map(|(i, ps)| { + obj(vec![ + ( + "value", + Value::from(utf16_slice(source, ps.start_offset, ps.end_offset)), + ), + ( + "parameterType", + param_names + .get(i) + .map_or(Value::Null, |n| Value::from(n.as_str())), + ), + ]) + }) + .collect(); + + let mut pairs = vec![ + ("text", Value::from(step.text.as_str())), + ("matchSpan", span(step.match_span)), + ( + "paramSpans", + Value::List(step.param_spans.iter().map(|s| span(*s)).collect()), + ), + ( + "matchedExpression", + Value::from(step.step_def.expression.as_str()), + ), + ("args", Value::List(args)), + ]; + if let Some(t) = &step.data_table { + pairs.push(("dataTable", table(t))); + } + if let Some(f) = &step.doc_string { + pairs.push(("docString", doc_string(f))); + } + obj(pairs) +} + +fn doc_string(f: &Fence) -> Value { + obj(vec![ + ("content", Value::from(f.body.as_str())), + ("contentType", Value::from(f.info.as_str())), + ("span", span(f.body_span)), + ]) +} + +fn diagnostic(d: &Diagnostic) -> Value { + obj(vec![ + ("code", Value::from(diagnostic_code(d.code))), + ("severity", Value::from(severity(d.severity))), + ("span", span(d.span)), + ]) +} + +fn diagnostic_code(code: DiagnosticCode) -> &'static str { + match code { + DiagnosticCode::AmbiguousMatch => "ambiguous-match", + DiagnosticCode::ErrorFenceWithoutStep => "error-fence-without-step", + DiagnosticCode::Drift => "drift", + } +} + +fn severity(s: Severity) -> &'static str { + match s { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + } +} + +// ----------------------------------------------------------------------------- +// trace projection +// ----------------------------------------------------------------------------- + +/// Projects a caught step failure to the `FailureArtifact` wire shape. `None` +/// error falls through to `"thrown"`. +pub fn to_failure_artifact(failure: Option<&StepFailure>, match_span: Span) -> Value { + let line = match_span.start_line; + let anchor_span = match failure { + Some(f) => crate::failure_anchor::anchor(&f.error, match_span), + None => match_span, + }; + let anchor = span(anchor_span); + + match failure.map(|f| &f.error) { + Some(StepError::CellMismatch(cells)) => { + let failing: Vec = cells.iter().filter(|c| !c.ok).map(failure_cell).collect(); + obj(vec![ + ("kind", Value::from("cell-mismatch")), + ("line", vint(line)), + ("anchor", anchor), + ("cells", Value::List(failing)), + ]) + } + Some(StepError::DocStringMismatch(diff)) => { + let d = obj(vec![ + ("expected", Value::from(diff.expected.as_str())), + ("actual", Value::from(diff.actual.as_str())), + ("span", span(diff.span)), + ]); + obj(vec![ + ("kind", Value::from("doc-string-mismatch")), + ("line", vint(line)), + ("anchor", anchor), + ("diff", d), + ]) + } + Some(StepError::ReturnShape(_)) => kind_line_anchor("return-shape", line, anchor), + Some(StepError::UnexpectedPass) => kind_line_anchor("unexpected-pass", line, anchor), + _ => kind_line_anchor("thrown", line, anchor), + } +} + +fn failure_cell(c: &crate::cell_diff::CellDiff) -> Value { + obj(vec![ + ("column", Value::from(c.column.as_str())), + ("expected", Value::from(c.expected.as_str())), + ("actual", Value::from(c.actual.as_str())), + ("span", span(c.span)), + ]) +} + +fn kind_line_anchor(kind: &str, line: usize, anchor: Value) -> Value { + obj(vec![ + ("kind", Value::from(kind)), + ("line", vint(line)), + ("anchor", anchor), + ]) +} + +/// Recovers the cross-language-shared step-file stem (strip the last extension), +/// e.g. `numerals.steps.rs` → `numerals.steps`. +fn file_stem(path: &str) -> String { + let base = path.rsplit(['/', '\\']).next().unwrap_or(path); + match base.rfind('.') { + Some(dot) if dot > 0 => base[..dot].to_string(), + _ => base.to_string(), + } +} + +/// Runs one bundle end-to-end: plan, execute (recording observations), and +/// project all four wire artifacts. Port of `runConformance`. +pub fn run_conformance( + doc: &VarDoc, + registry: &Registry, + context_factory: &dyn Fn() -> Value, +) -> BundleArtifacts { + let execution = plan(doc, registry); + + let observed: Rc>>> = + Rc::new(RefCell::new(HashMap::new())); + let observed_writer = observed.clone(); + let ports = ExecutePorts { + reporter: Box::new(|_| {}), + create_context: Some(Box::new(|_| context_factory())), + observer: Some(Box::new(move |o: StepObservation| { + observed_writer + .borrow_mut() + .entry(o.example_index) + .or_default() + .push(o); + })), + }; + + let queue = collect_examples(&execution, &ports); + let mut trace_examples = Vec::with_capacity(queue.len()); + for (k, queued) in queue.iter().enumerate() { + let outcome = if queued.run().is_err() { + "fail" + } else { + "pass" + }; + + let planned = &execution.examples[k]; + let empty = Vec::new(); + let obs_map = observed.borrow(); + let obs = obs_map.get(&k).unwrap_or(&empty); + + let mut steps = Vec::with_capacity(planned.steps.len()); + for (i, step) in planned.steps.iter().enumerate() { + let ordinal = i + 1; + // Prefer the first "fail" observation for this ordinal; else the last. + let mut chosen: Option<&StepObservation> = None; + for o in obs { + if o.ordinal != ordinal { + continue; + } + chosen = Some(o); + if o.outcome == StepOutcome::Fail { + break; + } + } + let step_outcome = chosen.map_or("skipped", |o| o.outcome.as_str()); + + let context_key = obj(vec![ + ("exampleName", Value::from(queued.name.as_str())), + ( + "stepFile", + Value::from(file_stem(&step.step_def.expression_source_file).as_str()), + ), + ]); + let mut step_pairs = vec![ + ("exampleName", Value::from(queued.name.as_str())), + ("ordinal", vint(ordinal)), + ("stepText", Value::from(step.text.as_str())), + ( + "matchedExpression", + Value::from(step.step_def.expression.as_str()), + ), + ("contextKey", context_key), + ("outcome", Value::from(step_outcome)), + ]; + if step_outcome == "fail" { + let failure = chosen.and_then(|o| o.error.as_ref()); + step_pairs.push(("failure", to_failure_artifact(failure, step.match_span))); + } + steps.push(obj(step_pairs)); + } + + trace_examples.push(obj(vec![ + ("name", Value::from(queued.name.as_str())), + ("outcome", Value::from(outcome)), + ("steps", Value::List(steps)), + ])); + } + + let trace = obj(vec![("examples", Value::List(trace_examples))]); + + BundleArtifacts { + var_doc: to_var_doc_artifact(doc), + registry: to_registry_artifact(registry), + plan: to_plan_artifact(&execution), + trace, + } +} diff --git a/rust/var-core/src/diagnostics.rs b/rust/var-core/src/diagnostics.rs new file mode 100644 index 00000000..1520c8fc --- /dev/null +++ b/rust/var-core/src/diagnostics.rs @@ -0,0 +1,47 @@ +//! Diagnostics produced by the planner — port of the subset of `diagnostics.ts` +//! that `Plan` needs / `Diagnostics.java`. + +use crate::span::Span; + +/// Diagnostic severity. Only `Error` is constructed today. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Severity { + Error, + Warning, + Info, +} + +/// The closed set of diagnostic codes the planner produces. `Ord` follows the +/// Java enum's declaration order (ordinal), matching its sort semantics. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum DiagnosticCode { + AmbiguousMatch, + ErrorFenceWithoutStep, + Drift, +} + +/// One diagnostic: its code, severity, and the source span it points at. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Diagnostic { + pub code: DiagnosticCode, + pub severity: Severity, + pub span: Span, +} + +/// Builds an `ambiguous-match` diagnostic pointing at `span`. +pub fn ambiguous_match(span: Span) -> Diagnostic { + Diagnostic { + code: DiagnosticCode::AmbiguousMatch, + severity: Severity::Error, + span, + } +} + +/// Builds an `error-fence-without-step` diagnostic pointing at `span`. +pub fn error_fence_without_step(span: Span) -> Diagnostic { + Diagnostic { + code: DiagnosticCode::ErrorFenceWithoutStep, + severity: Severity::Error, + span, + } +} diff --git a/rust/var-core/src/doc_string_diff.rs b/rust/var-core/src/doc_string_diff.rs new file mode 100644 index 00000000..ed6ed08c --- /dev/null +++ b/rust/var-core/src/doc_string_diff.rs @@ -0,0 +1,52 @@ +//! Doc-string comparison — port of `doc-string-diff.ts` / `DocStringDiff.java`. + +use crate::error::StepError; +use crate::span::Span; +use crate::value::Value; + +/// A doc-string content difference: the fence body's span plus expected/actual. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DocStringDiff { + pub span: Span, + pub expected: String, + pub actual: String, +} + +impl DocStringDiff { + pub fn new( + span: Span, + expected: impl Into, + actual: impl Into, + ) -> DocStringDiff { + DocStringDiff { + span, + expected: expected.into(), + actual: actual.into(), + } + } +} + +/// Compares a doc-string step's return against the fence body (exact equality, +/// trailing newline included). `None` → no check. A non-string return → +/// [`StepError::ReturnShape`]. +pub fn compare_doc_string( + returned: Option<&Value>, + content: &str, + span: Span, +) -> Result, StepError> { + let s = match returned { + None => return Ok(None), + Some(Value::String(s)) => s, + Some(other) => { + return Err(StepError::ReturnShape(format!( + "expected a doc string (string), got {}", + other.type_name() + ))); + } + }; + if s == content { + Ok(None) + } else { + Ok(Some(DocStringDiff::new(span, content, s.clone()))) + } +} diff --git a/rust/var-core/src/drift.rs b/rust/var-core/src/drift.rs new file mode 100644 index 00000000..bfd8e35e --- /dev/null +++ b/rust/var-core/src/drift.rs @@ -0,0 +1,486 @@ +//! Spec drift detection — port of `drift.ts` / `Drift.java`. A paragraph the +//! committed `var.lock.json` baseline recorded as an example that now matches no +//! step. Byte-identical to the other ports (FNV-1a fingerprint, insertion-ordered +//! lockfile serializer, Jaccard word-similarity re-identification). + +use crate::ast::VarDoc; +use crate::hash::hash_source; +use crate::plan::{ExecutionPlan, derive_example_name}; +use crate::span::Span; +use crate::value::Value; +use regex::Regex; +use std::collections::{BTreeMap, HashSet}; +use std::sync::LazyLock; + +/// The word-similarity threshold for re-identifying a moved/reworded example. +pub const SIMILARITY_THRESHOLD: f64 = 0.5; + +/// One example-producing paragraph, as recorded in the baseline. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BaselineExample { + pub name: String, + pub line: usize, +} + +/// The committed baseline for one spec file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SpecBaseline { + pub source_hash: String, + pub examples: Vec, +} + +/// The whole `var.lock.json`: every spec keyed by its POSIX path. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VarLock { + pub version: u32, + pub specs: BTreeMap, +} + +/// A paragraph the baseline says was an example and now matches no step. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Drifted { + pub name: String, + pub line: usize, + pub span: Span, +} + +/// Persistence port for `var.lock.json`. The core owns the format; adapters move +/// only raw text. +pub trait BaselineStore { + /// The whole lockfile's contents, or `None` when there is no baseline yet. + fn read(&self) -> Option; + + fn write(&mut self, contents: &str); +} + +static TOKEN_RE: LazyLock = LazyLock::new(|| Regex::new(r"[\p{L}\p{N}]+").unwrap()); + +fn within(inner: Span, outer: Span) -> bool { + inner.start_offset >= outer.start_offset && inner.end_offset <= outer.end_offset +} + +fn is_live(candidate_span: Span, plan: &ExecutionPlan) -> bool { + plan.examples + .iter() + .any(|pe| within(pe.span, candidate_span)) +} + +fn tokenize(text: &str) -> HashSet { + TOKEN_RE + .find_iter(&text.to_lowercase()) + .map(|m| m.as_str().to_string()) + .collect() +} + +fn similarity(a: &HashSet, b: &HashSet) -> f64 { + if a.is_empty() && b.is_empty() { + return 1.0; + } + let intersection = a.iter().filter(|t| b.contains(*t)).count(); + let union = a.len() + b.len() - intersection; + if union == 0 { + 0.0 + } else { + intersection as f64 / union as f64 + } +} + +/// The current example-producing paragraphs, in document order. +pub fn live_examples(var_doc: &VarDoc, plan: &ExecutionPlan) -> Vec { + var_doc + .examples + .iter() + .filter(|c| is_live(c.span, plan)) + .map(|c| BaselineExample { + name: derive_example_name(&c.body), + line: c.span.start_line, + }) + .collect() +} + +/// The full baseline record for a spec: fingerprint plus live examples. +pub fn derive_spec_baseline(source: &str, var_doc: &VarDoc, plan: &ExecutionPlan) -> SpecBaseline { + SpecBaseline { + source_hash: hash_source(source), + examples: live_examples(var_doc, plan), + } +} + +/// Paragraphs the baseline recorded as examples that now match zero steps. +pub fn detect_drift( + baseline: Option<&SpecBaseline>, + var_doc: &VarDoc, + plan: &ExecutionPlan, +) -> Vec { + let Some(baseline) = baseline else { + return Vec::new(); + }; + let candidates = &var_doc.examples; + let n = candidates.len(); + let tokens: Vec> = candidates + .iter() + .map(|c| tokenize(&derive_example_name(&c.body))) + .collect(); + let live: Vec = candidates.iter().map(|c| is_live(c.span, plan)).collect(); + + let mut drifts = Vec::new(); + for b in &baseline.examples { + let b_tokens = tokenize(&b.name); + let mut best_idx: Option = None; + let mut best_score = 0.0f64; + for i in 0..n { + let score = similarity(&b_tokens, &tokens[i]); + if score < SIMILARITY_THRESHOLD { + continue; + } + let line = candidates[i].span.start_line as isize; + let best_line = best_idx.map_or(0, |bi| candidates[bi].span.start_line as isize); + let b_line = b.line as isize; + if best_idx.is_none() + || score > best_score + || (score == best_score && (line - b_line).abs() < (best_line - b_line).abs()) + { + best_idx = Some(i); + best_score = score; + } + } + if let Some(bi) = best_idx { + if !live[bi] { + let cand = &candidates[bi]; + drifts.push(Drifted { + name: b.name.clone(), + line: cand.span.start_line, + span: cand.span, + }); + } + } + } + drifts +} + +/// The human-readable message for a drift. +pub fn message(drifted: &Drifted) -> String { + format!( + "This paragraph was an example and no longer matches any step (drift): \"{}\".\nFix the step so it matches again, or accept it as prose (run in update mode).", + drifted.name + ) +} + +/// One spec's baseline reconciliation against a [`BaselineStore`]. `update` +/// accepts all drift; otherwise detect drift and rewrite the baseline only on a +/// clean run. +pub fn reconcile_drift( + store: &mut dyn BaselineStore, + spec_path: &str, + source: &str, + var_doc: &VarDoc, + plan: &ExecutionPlan, + update: bool, +) -> Vec { + let lock = store.read().as_deref().and_then(parse_var_lock); + let drifts = if update { + Vec::new() + } else { + detect_drift( + lock.as_ref().and_then(|l| l.specs.get(spec_path)), + var_doc, + plan, + ) + }; + if update || drifts.is_empty() { + let next = derive_spec_baseline(source, var_doc, plan); + let mut specs = lock.map_or_else(BTreeMap::new, |l| l.specs); + specs.insert(spec_path.to_string(), next); + store.write(&stringify_var_lock(&VarLock { version: 1, specs })); + } + drifts +} + +/// Serializes `var.lock.json` deterministically (fixed field order, sorted spec +/// paths, two-space indent, trailing newline) — NOT [`crate::canonical_json`]. +pub fn stringify_var_lock(lock: &VarLock) -> String { + let mut sb = String::new(); + sb.push_str("{\n \"version\": 1,\n \"specs\": "); + if lock.specs.is_empty() { + sb.push_str("{}"); + } else { + sb.push_str("{\n"); + let n = lock.specs.len(); + // `BTreeMap` iterates spec paths in sorted order. + for (p, (path, baseline)) in lock.specs.iter().enumerate() { + sb.push_str(" "); + write_json_string(&mut sb, path); + sb.push_str(": {\n \"sourceHash\": "); + write_json_string(&mut sb, &baseline.source_hash); + sb.push_str(",\n \"examples\": "); + if baseline.examples.is_empty() { + sb.push_str("[]"); + } else { + sb.push_str("[\n"); + let en = baseline.examples.len(); + for (e, ex) in baseline.examples.iter().enumerate() { + sb.push_str(" {\n \"name\": "); + write_json_string(&mut sb, &ex.name); + sb.push_str(",\n \"line\": "); + sb.push_str(&ex.line.to_string()); + sb.push_str("\n }"); + if e + 1 < en { + sb.push(','); + } + sb.push('\n'); + } + sb.push_str(" ]"); + } + sb.push_str("\n }"); + if p + 1 < n { + sb.push(','); + } + sb.push('\n'); + } + sb.push_str(" }"); + } + sb.push_str("\n}\n"); + sb +} + +fn write_json_string(sb: &mut String, s: &str) { + use std::fmt::Write; + sb.push('"'); + for c in s.chars() { + match c { + '"' => sb.push_str("\\\""), + '\\' => sb.push_str("\\\\"), + '\n' => sb.push_str("\\n"), + '\r' => sb.push_str("\\r"), + '\t' => sb.push_str("\\t"), + '\u{0008}' => sb.push_str("\\b"), + '\u{000c}' => sb.push_str("\\f"), + c if (c as u32) < 0x20 => { + let _ = write!(sb, "\\u{:04x}", c as u32); + } + c => sb.push(c), + } + } + sb.push('"'); +} + +/// Parses `var.lock.json`; `None` on malformed input (treated as no baseline). +pub fn parse_var_lock(text: &str) -> Option { + let parsed = JsonReader::new(text).parse_whole()?; + let Value::Map(obj) = parsed else { return None }; + if !matches!(obj.get("version"), Some(Value::Int(1))) { + return None; + } + let Some(Value::Map(specs_raw)) = obj.get("specs") else { + return None; + }; + let mut specs = BTreeMap::new(); + for (k, v) in specs_raw { + specs.insert(k.clone(), parse_spec_baseline(v)?); + } + Some(VarLock { version: 1, specs }) +} + +fn parse_spec_baseline(value: &Value) -> Option { + let Value::Map(map) = value else { return None }; + let Some(Value::String(source_hash)) = map.get("sourceHash") else { + return None; + }; + let Some(Value::List(examples_raw)) = map.get("examples") else { + return None; + }; + let mut examples = Vec::new(); + for item in examples_raw { + let Value::Map(e) = item else { return None }; + let Some(Value::String(name)) = e.get("name") else { + return None; + }; + let Some(Value::Int(line)) = e.get("line") else { + return None; + }; + examples.push(BaselineExample { + name: name.clone(), + line: *line as usize, + }); + } + Some(SpecBaseline { + source_hash: source_hash.clone(), + examples, + }) +} + +/// A tiny recursive-descent JSON reader — enough for `var.lock.json`, returning +/// `None` on malformed input (Java's caught-exception → null). +struct JsonReader { + chars: Vec, + i: usize, +} + +impl JsonReader { + fn new(text: &str) -> JsonReader { + JsonReader { + chars: text.chars().collect(), + i: 0, + } + } + + fn parse_whole(&mut self) -> Option { + let v = self.value()?; + self.skip_ws(); + if self.i != self.chars.len() { + return None; + } + Some(v) + } + + fn value(&mut self) -> Option { + self.skip_ws(); + match self.peek()? { + '{' => self.object(), + '[' => self.array(), + '"' => self.string().map(Value::String), + 't' | 'f' => self.boolean(), + 'n' => self.null(), + _ => self.number(), + } + } + + fn object(&mut self) -> Option { + self.expect('{')?; + let mut map = BTreeMap::new(); + self.skip_ws(); + if self.peek()? == '}' { + self.i += 1; + return Some(Value::Map(map)); + } + loop { + self.skip_ws(); + let key = self.string()?; + self.skip_ws(); + self.expect(':')?; + map.insert(key, self.value()?); + self.skip_ws(); + match self.next()? { + '}' => return Some(Value::Map(map)), + ',' => {} + _ => return None, + } + } + } + + fn array(&mut self) -> Option { + self.expect('[')?; + let mut list = Vec::new(); + self.skip_ws(); + if self.peek()? == ']' { + self.i += 1; + return Some(Value::List(list)); + } + loop { + list.push(self.value()?); + self.skip_ws(); + match self.next()? { + ']' => return Some(Value::List(list)), + ',' => {} + _ => return None, + } + } + } + + fn string(&mut self) -> Option { + self.expect('"')?; + let mut out = String::new(); + loop { + match self.next()? { + '"' => return Some(out), + '\\' => match self.next()? { + '"' => out.push('"'), + '\\' => out.push('\\'), + '/' => out.push('/'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + 'b' => out.push('\u{0008}'), + 'f' => out.push('\u{000c}'), + 'u' => { + let code = self.hex4()?; + out.push(char::from_u32(code)?); + } + _ => return None, + }, + c => out.push(c), + } + } + } + + fn hex4(&mut self) -> Option { + if self.i + 4 > self.chars.len() { + return None; + } + let slice: String = self.chars[self.i..self.i + 4].iter().collect(); + self.i += 4; + u32::from_str_radix(&slice, 16).ok() + } + + fn number(&mut self) -> Option { + let start = self.i; + while self.i < self.chars.len() && "-+.eE0123456789".contains(self.chars[self.i]) { + self.i += 1; + } + if self.i == start { + return None; + } + let num: String = self.chars[start..self.i].iter().collect(); + if num.contains(['.', 'e', 'E']) { + num.parse::().ok().map(Value::Float) + } else { + num.parse::().ok().map(Value::Int) + } + } + + fn boolean(&mut self) -> Option { + if self.starts_with("true") { + self.i += 4; + Some(Value::Bool(true)) + } else if self.starts_with("false") { + self.i += 5; + Some(Value::Bool(false)) + } else { + None + } + } + + fn null(&mut self) -> Option { + if self.starts_with("null") { + self.i += 4; + Some(Value::Null) + } else { + None + } + } + + fn starts_with(&self, lit: &str) -> bool { + let lit: Vec = lit.chars().collect(); + self.i + lit.len() <= self.chars.len() && self.chars[self.i..self.i + lit.len()] == lit[..] + } + + fn skip_ws(&mut self) { + while self.i < self.chars.len() && matches!(self.chars[self.i], ' ' | '\n' | '\r' | '\t') { + self.i += 1; + } + } + + fn peek(&self) -> Option { + self.chars.get(self.i).copied() + } + + fn next(&mut self) -> Option { + let c = self.chars.get(self.i).copied()?; + self.i += 1; + Some(c) + } + + fn expect(&mut self, c: char) -> Option<()> { + if self.next()? == c { Some(()) } else { None } + } +} diff --git a/rust/var-core/src/error.rs b/rust/var-core/src/error.rs new file mode 100644 index 00000000..26a16a4c --- /dev/null +++ b/rust/var-core/src/error.rs @@ -0,0 +1,137 @@ +//! The error model: the Rust replacement for Java var-core's typed exception +//! hierarchy (`CellMismatchException`, `DocStringMismatchException`, +//! `ReturnShapeException`, `UnexpectedPassException`, author `AssertionError`). +//! `Result`/panic-catch replace throw; `instanceof` dispatch becomes `match`. + +use crate::cell_diff::CellDiff; +use crate::doc_string_diff::DocStringDiff; +use std::any::Any; + +/// A handler-signalled failure (author `Err(...)` or a caught panic) — the +/// analog of an arbitrary thrown `RuntimeException`/`AssertionError`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HandlerError { + pub message: String, +} + +impl HandlerError { + pub fn new(message: impl Into) -> HandlerError { + HandlerError { + message: message.into(), + } + } + + /// Extracts a message from a `catch_unwind` panic payload (`&str`/`String`, + /// else a generic fallback). + pub fn from_panic(payload: Box) -> HandlerError { + let message = if let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "handler panicked".to_string() + }; + HandlerError { message } + } +} + +/// A step failure verdict — the closed union replacing the exception hierarchy. +#[derive(Clone, Debug, PartialEq)] +pub enum StepError { + /// Table / header-bound row mismatch (only the failing cells). + CellMismatch(Vec), + /// Doc-string body mismatch. + DocStringMismatch(DocStringDiff), + /// Wrong return type/shape — an author mistake, not a value diff. + ReturnShape(String), + /// An `error`-fenced example ran without failing. + UnexpectedPass, + /// An author-signalled failure (`Err`) or a caught panic. + Handler(HandlerError), +} + +impl StepError { + /// The human-readable message (`getMessage()` parity). + pub fn message(&self) -> String { + match self { + StepError::CellMismatch(cells) => cells + .iter() + .map(|c| format!("{}: expected {} but was {}", c.column, c.expected, c.actual)) + .collect::>() + .join("; "), + StepError::DocStringMismatch(diff) => { + format!( + "doc string: expected {} but was {}", + quote(&diff.expected), + quote(&diff.actual) + ) + } + StepError::ReturnShape(msg) => msg.clone(), + StepError::UnexpectedPass => "expected the example to fail, but it passed".to_string(), + StepError::Handler(e) => e.message.clone(), + } + } + + /// The failing cells of a [`StepError::CellMismatch`], else `None` + /// (`isCellMismatchException` parity). + pub fn as_cell_mismatch(&self) -> Option<&[CellDiff]> { + match self { + StepError::CellMismatch(cells) => Some(cells), + _ => None, + } + } + + /// The diff of a [`StepError::DocStringMismatch`], else `None`. + pub fn as_doc_string_mismatch(&self) -> Option<&DocStringDiff> { + match self { + StepError::DocStringMismatch(diff) => Some(diff), + _ => None, + } + } +} + +/// Where a failure points in the `.md` — the structural replacement for Java's +/// synthetic `StackTraceElement` injection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FailureLocation { + pub label: String, + pub path: String, + pub line: usize, +} + +/// A caught step failure plus its (optional) source location. +#[derive(Clone, Debug, PartialEq)] +pub struct StepFailure { + pub error: StepError, + pub location: Option, +} + +impl StepFailure { + /// A failure with no attached location (fallback-line path). + pub fn bare(error: StepError) -> StepFailure { + StepFailure { + error, + location: None, + } + } +} + +/// Mirrors `JSON.stringify`'s quoting of the TS doc-string error message closely +/// enough for a human-readable message (never parsed back). +fn quote(s: &str) -> String { + format!( + "\"{}\"", + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + ) +} + +/// A registration-time (author-wiring) error — never a step failure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RegistryError { + /// A duplicate step expression; the message lists both source positions. + DuplicateStep(String), + /// The cucumber expression failed to compile (e.g. undefined parameter type). + Expression(String), +} diff --git a/rust/var-core/src/execute.rs b/rust/var-core/src/execute.rs new file mode 100644 index 00000000..ed904944 --- /dev/null +++ b/rust/var-core/src/execute.rs @@ -0,0 +1,481 @@ +//! The executor — port of `execute.ts` / `Execute.java`, on the full-replacement +//! state model. Handlers are invoked via boxed closures (no reflection); panics +//! are caught (the `AssertionError`/`Throwable` parity channel); `Future` +//! returns are driven by a small std `block_on`. State is a [`Value`], replaced +//! wholesale by each stimulus. + +use crate::cell_diff::{CellDiff, compare_row, compare_table}; +use crate::diagnostics::Diagnostic; +use crate::doc_string_diff::compare_doc_string; +use crate::error::{FailureLocation, HandlerError, StepError, StepFailure}; +use crate::failure_anchor; +use crate::handler::{Handler, StepReturn}; +use crate::offsets::{utf16_len, utf16_slice}; +use crate::param_diff::compare_params_with_formats; +use crate::plan::{ExecutionPlan, PlannedExample, PlannedStep}; +use crate::step_kind::StepKind; +use crate::value::Value; +use std::cell::Cell; +use std::collections::HashMap; +use std::future::Future; +use std::panic::AssertUnwindSafe; +use std::pin::Pin; +use std::sync::Once; +use std::task::{Context, Poll, Wake, Waker}; + +/// A step's outcome in the conformance trace. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StepOutcome { + Pass, + Fail, + Skipped, +} + +impl StepOutcome { + /// The wire string (`"pass"`/`"fail"`/`"skipped"`). + pub fn as_str(self) -> &'static str { + match self { + StepOutcome::Pass => "pass", + StepOutcome::Fail => "fail", + StepOutcome::Skipped => "skipped", + } + } +} + +/// One executed step's outcome. `example_index` is 0-based; `ordinal` is 1-based. +#[derive(Clone, Debug, PartialEq)] +pub struct StepObservation { + pub example_index: usize, + pub ordinal: usize, + pub outcome: StepOutcome, + pub error: Option, +} + +/// The ports [`collect_examples`]/[`execute_plan`] need. `create_context` maps a +/// step-file to its fresh initial state (`None` → [`Value::Null`] per file); +/// `observer` is optional per-step instrumentation. The lifetime lets the port +/// closures borrow caller locals (e.g. a conformance observer's accumulator). +pub struct ExecutePorts<'a> { + pub reporter: Reporter<'a>, + pub create_context: Option>, + pub observer: Option>, +} + +/// Receives every diagnostic collected during planning. +pub type Reporter<'a> = Box; +/// Maps a step-file to its fresh initial state. +pub type ContextFactory<'a> = Box Value + 'a>; +/// Per-step instrumentation (conformance trace mode). +pub type Observer<'a> = Box; + +impl<'a> ExecutePorts<'a> { + /// Ports with just a reporter (no context factory, no observer). + pub fn new(reporter: Box) -> ExecutePorts<'a> { + ExecutePorts { + reporter, + create_context: None, + observer: None, + } + } +} + +impl ExecutePorts<'static> { + /// Ports that discard diagnostics and observe nothing. + pub fn silent() -> ExecutePorts<'static> { + ExecutePorts::new(Box::new(|_| {})) + } +} + +/// One runnable example: its name and a callback that runs its steps. +pub struct QueuedExample<'a> { + pub name: String, + run: Box Result<(), StepFailure> + 'a>, +} + +impl QueuedExample<'_> { + /// Runs the example's steps; `Err` on the first failure. + pub fn run(&self) -> Result<(), StepFailure> { + (self.run)() + } +} + +/// Reports every diagnostic in `plan`, then returns one [`QueuedExample`] per +/// planned example, in document order (each `run` is lazy). Port of `collectExamples`. +pub fn collect_examples<'a>( + plan: &'a ExecutionPlan, + ports: &'a ExecutePorts<'a>, +) -> Vec> { + for d in &plan.diagnostics { + (ports.reporter)(d); + } + plan.examples + .iter() + .enumerate() + .map(|(i, ex)| QueuedExample { + name: ex.name.clone(), + run: Box::new(move || run_example(plan, ex, i, ports)), + }) + .collect() +} + +/// Runs every example in `plan`, in order, stopping at the first failure. Port +/// of `executePlan`. +pub fn execute_plan<'a>( + plan: &'a ExecutionPlan, + ports: &'a ExecutePorts<'a>, +) -> Result<(), StepFailure> { + for q in collect_examples(plan, ports) { + q.run()?; + } + Ok(()) +} + +// ----------------------------------------------------------------------------- +// One example +// ----------------------------------------------------------------------------- + +fn run_example( + plan: &ExecutionPlan, + ex: &PlannedExample, + example_index: usize, + ports: &ExecutePorts, +) -> Result<(), StepFailure> { + let path = &plan.var_doc.path; + let source = &plan.var_doc.source; + let steps = &ex.steps; + + let mut state_by_file: HashMap = HashMap::new(); + let mut last_return: Option = None; + let mut thrown: Option = None; + + for (i, step) in steps.iter().enumerate() { + let file = &step.step_def.expression_source_file; + let state = match state_by_file.get(file) { + Some(s) => s.clone(), + None => { + let created = create_context(ports, file); + state_by_file.insert(file.clone(), created.clone()); + created + } + }; + + // A trailing data table / doc string is the last handler argument. + let mut call_args = step.args.clone(); + if let Some(table) = &step.data_table { + call_args.push(table_rows(table)); + } else if let Some(fence) = &step.doc_string { + call_args.push(Value::from(fence.body.as_str())); + } + + let step_error: Option = + match invoke_resolve(&step.step_def.handler, state, call_args) { + Err(he) => Some(StepError::Handler(he)), + Ok(returned) => { + last_return = returned.clone(); + match step.step_def.kind { + Some(StepKind::Stimulus) => { + state_by_file.insert(file.clone(), returned.unwrap_or(Value::Null)); + None + } + Some(StepKind::Sensor) => { + // Header-bound rows are checked after the loop via row_checks. + if ex.row_checks.is_none() { + check_sensor_return(source, step, returned).err() + } else { + None + } + } + None => Some(StepError::ReturnShape( + "unknown step kind: null".to_string(), + )), + } + } + }; + + match step_error { + None => observe( + ports, + StepObservation { + example_index, + ordinal: i + 1, + outcome: StepOutcome::Pass, + error: None, + }, + ), + Some(err) => { + let failure = attach_location(err, step, path); + observe( + ports, + StepObservation { + example_index, + ordinal: i + 1, + outcome: StepOutcome::Fail, + error: Some(failure.clone()), + }, + ); + thrown = Some(failure); + break; + } + } + } + + // Header-bound row checks (deferred to after the loop). + if thrown.is_none() { + if let Some(checks) = &ex.row_checks { + if !checks.is_empty() { + let bad: Vec = compare_row(last_return.as_ref(), checks) + .into_iter() + .filter(|d| !d.ok) + .collect(); + if !bad.is_empty() { + let last_step = steps.last().unwrap(); + let failure = attach_location(StepError::CellMismatch(bad), last_step, path); + observe( + ports, + StepObservation { + example_index, + ordinal: steps.len(), + outcome: StepOutcome::Fail, + error: Some(failure.clone()), + }, + ); + thrown = Some(failure); + } + } + } + } + + // Error-fence inversion. + if ex.expected_outcome.as_deref() == Some("fail") { + match thrown { + None => { + return Err(match steps.last() { + Some(last) => attach_location(StepError::UnexpectedPass, last, path), + None => StepFailure::bare(StepError::UnexpectedPass), + }); + } + Some(failure) => { + if let Some(expected_msg) = &ex.expected_error_message { + if !failure.error.message().contains(expected_msg) { + return Err(failure); + } + } + return Ok(()); + } + } + } + + match thrown { + Some(failure) => Err(failure), + None => Ok(()), + } +} + +fn create_context(ports: &ExecutePorts, file: &str) -> Value { + match &ports.create_context { + Some(cc) => cc(file), + None => Value::Null, + } +} + +fn observe(ports: &ExecutePorts, observation: StepObservation) { + if let Some(observer) = &ports.observer { + observer(observation); + } +} + +fn table_rows(table: &crate::ast::Table) -> Value { + let row = + |cells: &[String]| Value::List(cells.iter().map(|c| Value::from(c.as_str())).collect()); + let mut rows = vec![row(&table.header.cells)]; + for r in &table.rows { + rows.push(row(&r.cells)); + } + Value::List(rows) +} + +fn attach_location(error: StepError, step: &PlannedStep, var_path: &str) -> StepFailure { + let anchor = failure_anchor::anchor(&error, step.match_span); + let label = truncate_label(&step.text); + StepFailure { + error, + location: Some(FailureLocation { + label, + path: var_path.to_string(), + line: anchor.start_line, + }), + } +} + +fn truncate_label(text: &str) -> String { + if utf16_len(text) > 60 { + let truncated: String = text.chars().take(60).collect(); + format!("{truncated}…") + } else { + text.to_string() + } +} + +// ----------------------------------------------------------------------------- +// Sensor return comparison +// ----------------------------------------------------------------------------- + +fn check_sensor_return( + source: &str, + step: &PlannedStep, + returned: Option, +) -> Result<(), StepError> { + let Some(returned) = returned else { + return Ok(()); + }; + let extra_count = usize::from(step.data_table.is_some() || step.doc_string.is_some()); + let slot_count = step.args.len() + extra_count; + if slot_count == 0 { + return Err(StepError::ReturnShape( + "this sensor has no parameters, data table or doc string — nothing to compare a return value against \ + (throw to fail, return nothing to pass)" + .to_string(), + )); + } + let slots: Vec = if slot_count == 1 { + // The return IS the single slot's value, never read as a positional list. + vec![returned] + } else { + match returned { + Value::List(list) => { + if list.len() != slot_count { + return Err(StepError::ReturnShape(format!( + "sensor return must have {} element(s), got {}", + slot_count, + list.len() + ))); + } + list + } + other => { + return Err(StepError::ReturnShape(format!( + "a sensor with {} parameters must return a List of {} values, got {}", + slot_count, + slot_count, + other.type_name() + ))); + } + } + }; + + let arg_count = step.args.len(); + if arg_count > 0 { + let source_texts: Vec = step + .param_spans + .iter() + .map(|s| utf16_slice(source, s.start_offset, s.end_offset).to_string()) + .collect(); + let bad: Vec = compare_params_with_formats( + &slots[0..arg_count], + &step.args, + &step.param_spans, + &source_texts, + Some(&step.formats), + ) + .into_iter() + .filter(|d| !d.ok) + .collect(); + if !bad.is_empty() { + return Err(StepError::CellMismatch(bad)); + } + } + + if let Some(table) = &step.data_table { + let bad: Vec = compare_table(Some(&slots[arg_count]), table)? + .into_iter() + .filter(|d| !d.ok) + .collect(); + if !bad.is_empty() { + return Err(StepError::CellMismatch(bad)); + } + } else if let Some(fence) = &step.doc_string { + if let Some(diff) = + compare_doc_string(Some(&slots[arg_count]), &fence.body, fence.body_span)? + { + return Err(StepError::DocStringMismatch(diff)); + } + } + Ok(()) +} + +// ----------------------------------------------------------------------------- +// Handler invocation (panic-catching + async resolution) +// ----------------------------------------------------------------------------- + +thread_local! { + static SUPPRESS_PANIC: Cell = const { Cell::new(false) }; +} + +static HOOK: Once = Once::new(); + +/// Installs a panic hook (once) that suppresses the default stderr print for +/// panics the executor deliberately catches (a handler's assertion-style +/// failure), while leaving genuine test panics untouched on other threads. +/// +/// DECLARED EXCEPTION to the "no globals in the core" rule (see `lib.rs`): +/// `catch_unwind` is the executor's assertion channel — the AssertionError +/// parity with Java — and the process-wide hook is the only way Rust offers to +/// keep a *caught* panic from spewing to stderr. It is `Once`-guarded, chains +/// the previous hook, and gates on a thread-local so it is inert outside +/// [`invoke_resolve`]; observable behaviour is otherwise unchanged. +fn install_hook() { + HOOK.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if SUPPRESS_PANIC.with(Cell::get) { + return; + } + previous(info); + })); + }); +} + +/// Invokes the handler and resolves any `Future`, catching a panic (the +/// assertion-style failure channel) into a [`HandlerError`]. +fn invoke_resolve( + handler: &Handler, + state: Value, + args: Vec, +) -> Result, HandlerError> { + install_hook(); + let caught = SUPPRESS_PANIC.with(|s| { + s.set(true); + let r = std::panic::catch_unwind(AssertUnwindSafe(|| match handler.call(state, args) { + StepReturn::Ready(r) => r, + StepReturn::Pending(fut) => block_on(fut), + })); + s.set(false); + r + }); + match caught { + Ok(r) => r, + Err(payload) => Err(HandlerError::from_panic(payload)), + } +} + +/// A minimal `block_on`: polls the future, parking the thread until its waker +/// unparks it. No dependencies, no unsafe. +fn block_on(mut fut: Pin>>) -> T { + struct ThreadWaker(std::thread::Thread); + impl Wake for ThreadWaker { + fn wake(self: std::sync::Arc) { + self.0.unpark(); + } + fn wake_by_ref(self: &std::sync::Arc) { + self.0.unpark(); + } + } + let waker = Waker::from(std::sync::Arc::new(ThreadWaker(std::thread::current()))); + let mut cx = Context::from_waker(&waker); + loop { + match fut.as_mut().poll(&mut cx) { + Poll::Ready(v) => return v, + Poll::Pending => std::thread::park(), + } + } +} diff --git a/rust/var-core/src/expression.rs b/rust/var-core/src/expression.rs new file mode 100644 index 00000000..9fb0dcdd --- /dev/null +++ b/rust/var-core/src/expression.rs @@ -0,0 +1,348 @@ +//! Cucumber-expression matching — the owned layer over the `cucumber-expressions` +//! crate's grammar parser. Replaces `io.cucumber.cucumberexpressions`. We take the +//! crate's AST parser (the escape-rule-dense part) and own the small, corpus-pinned +//! rest: regex generation with one named group per parameter, built-in + custom +//! parameter types, argument extraction, and `parameter_type_names`. +//! +//! Deviation from the `cucumber-expressions` 20.0.0 line every other port pins +//! (recorded in ADR 0006, `doc/adr/0006-rust-port.md`): there is no official +//! Rust port, so this crate hand-writes the regex generation, and only the +//! `{int}`, `{word}`, and `{string}` built-ins exist. All other 20.0.0 +//! built-ins — `{float}`, `{double}`, `{byte}`, `{short}`, `{long}`, +//! `{biginteger}`, `{bigdecimal}`, and the anonymous `{}` — are omitted: the +//! numeric ones need lookahead the `regex` crate lacks, and none is used by the +//! conformance corpus. Using one fails loudly at registration +//! ("Undefined parameter type {double}"), never silently misparses. + +use crate::value::Value; +use cucumber_expressions::Expression; +use cucumber_expressions::ast::SingleExpression; +use regex::Regex; +use std::rc::Rc; + +/// A parameter-type transform. Receives the type's regexp **capture groups** in +/// order (a group that did not participate arrives as `""` — the closest +/// `&[&str]` can come to Java's `null`/Python's `None`); a regexp with no +/// groups of its own receives the whole matched text as the single element. +/// Mirrors Java's `CaptureGroupTransformer` / Python's `parse(*groups)`. +pub type ParseFn = Rc Value>; + +/// One captured argument of a whole-string match. +#[derive(Clone, Debug, PartialEq)] +pub struct Argument { + /// The transformed value. + pub value: Value, + /// The parameter-type name (the `formats` lookup key). + pub parameter_type_name: String, + /// The captured group's byte offsets within the matched text (`None` if the + /// group did not participate). + pub group: Option<(usize, usize)>, +} + +/// Registry of parameter types (built-ins + author-defined custom types). +#[derive(Clone)] +pub struct ParameterTypeRegistry { + types: Vec, +} + +#[derive(Clone)] +struct ParameterTypeDef { + name: String, + regexp_source: String, + transform: Transform, +} + +#[derive(Clone)] +enum Transform { + Int, + Word, + QuotedString, + Custom(ParseFn), +} + +// Built-in regexps, mirroring cucumber-expressions 20.0.0 (via the crate's own +// expansion). Each is wrapped in one named group per parameter at compile time. +const INT_RE: &str = r"(?:-?\d+)|(?:\d+)"; +const WORD_RE: &str = r"[^\s]+"; +const STRING_RE: &str = r#""[^"\\]*(?:\\.[^"\\]*)*"|'[^'\\]*(?:\\.[^'\\]*)*'"#; + +impl ParameterTypeRegistry { + /// A fresh registry with the built-in `{int}`, `{word}`, `{string}` types. + pub fn new() -> ParameterTypeRegistry { + ParameterTypeRegistry { + types: vec![ + ParameterTypeDef { + name: "int".to_string(), + regexp_source: INT_RE.to_string(), + transform: Transform::Int, + }, + ParameterTypeDef { + name: "word".to_string(), + regexp_source: WORD_RE.to_string(), + transform: Transform::Word, + }, + ParameterTypeDef { + name: "string".to_string(), + regexp_source: STRING_RE.to_string(), + transform: Transform::QuotedString, + }, + ], + } + } + + /// Registers a custom parameter type `name` with a bare regexp source and a + /// transform. + pub fn define(&mut self, name: &str, regexp_source: &str, parse: ParseFn) { + self.types.push(ParameterTypeDef { + name: name.to_string(), + regexp_source: regexp_source.to_string(), + transform: Transform::Custom(parse), + }); + } + + fn lookup(&self, name: &str) -> Option<&ParameterTypeDef> { + self.types.iter().find(|t| t.name == name) + } +} + +impl Default for ParameterTypeRegistry { + fn default() -> Self { + Self::new() + } +} + +/// An expression failed to compile. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExpressionError { + pub message: String, +} + +/// A compiled cucumber expression. +#[derive(Clone)] +pub struct CompiledExpression { + source: String, + regexp_source: String, + anchored: Regex, + params: Vec, +} + +#[derive(Clone)] +struct ParamRef { + group_name: String, + type_name: String, + transform: Transform, + /// Numeric indices of the capture groups the author wrote INSIDE this + /// parameter's own regexp (empty for group-free patterns and built-ins). + /// These are what a custom transform receives, matching Java/Python. + inner_groups: Vec, +} + +impl CompiledExpression { + /// Compiles `source` against `types`. Errors on an undefined parameter type + /// or an un-compilable pattern. + pub fn compile( + source: &str, + types: &ParameterTypeRegistry, + ) -> Result { + let parsed = Expression::parse(source).map_err(|e| ExpressionError { + message: format!("failed to parse cucumber expression: {e}"), + })?; + + let mut regex_str = String::from("^"); + let mut params = Vec::new(); + for se in &parsed.0 { + match se { + SingleExpression::Text(input) => regex_str.push_str(&escape_text(input.fragment())), + SingleExpression::Whitespaces(input) => { + regex_str.push_str(®ex::escape(input.fragment())) + } + SingleExpression::Parameter(p) => { + let name = *p.input.fragment(); + let def = types.lookup(name).ok_or_else(|| ExpressionError { + message: format!("Undefined parameter type {{{name}}}"), + })?; + let group = format!("__p{}", params.len()); + regex_str.push_str(&format!("(?P<{group}>{})", def.regexp_source)); + params.push(ParamRef { + group_name: group, + type_name: name.to_string(), + transform: def.transform.clone(), + inner_groups: Vec::new(), // filled in below, once compiled + }); + } + SingleExpression::Optional(opt) => { + regex_str.push_str("(?:"); + regex_str.push_str(&escape_text(opt.0.fragment())); + regex_str.push_str(")?"); + } + SingleExpression::Alternation(alt) => regex_str.push_str(&alternation_regex(alt)), + } + } + regex_str.push('$'); + + let anchored = Regex::new(®ex_str).map_err(|e| ExpressionError { + message: format!("failed to compile expression regex: {e}"), + })?; + + // Locate each author-written capture group inside its parameter. Every + // construct WE generate is non-capturing (the `__pN` named groups + // aside), so any group between `__pN` and `__p{N+1}` in paren order was + // written inside parameter N's own regexp — those are the groups its + // custom transform receives (Java/Python parity). + let names: Vec> = anchored.capture_names().collect(); + let param_positions: Vec = params + .iter() + .map(|p| { + names + .iter() + .position(|n| *n == Some(p.group_name.as_str())) + .expect("named parameter group exists") + }) + .collect(); + for (n, param) in params.iter_mut().enumerate() { + let start = param_positions[n] + 1; + let end = param_positions.get(n + 1).copied().unwrap_or(names.len()); + param.inner_groups = (start..end).collect(); + } + + Ok(CompiledExpression { + source: source.to_string(), + regexp_source: regex_str, + anchored, + params, + }) + } + + /// The original expression text. + pub fn source(&self) -> &str { + &self.source + } + + /// The anchored regex source (`^...$`), what the matcher strips to scan. + pub fn regexp_source(&self) -> &str { + &self.regexp_source + } + + /// Matches the *entire* `text`, returning the typed arguments. `None` when + /// `text` is not a whole match. + pub fn match_whole(&self, text: &str) -> Option> { + let caps = self.anchored.captures(text)?; + let mut args = Vec::with_capacity(self.params.len()); + for p in &self.params { + match caps.name(&p.group_name) { + Some(m) => { + let value = match &p.transform { + // A custom transform receives its regexp's own capture + // groups (non-participating → ""); with no groups, the + // whole match is the single element. See [`ParseFn`]. + Transform::Custom(f) if !p.inner_groups.is_empty() => { + let groups: Vec<&str> = p + .inner_groups + .iter() + .map(|&i| caps.get(i).map_or("", |g| g.as_str())) + .collect(); + f(&groups) + } + other => apply_transform(other, m.as_str()), + }; + args.push(Argument { + value, + parameter_type_name: p.type_name.clone(), + group: Some((m.start(), m.end())), + }) + } + None => args.push(Argument { + value: Value::Null, + parameter_type_name: p.type_name.clone(), + group: None, + }), + } + } + Some(args) + } +} + +fn apply_transform(transform: &Transform, text: &str) -> Value { + match transform { + Transform::Int => text.parse::().map_or(Value::Null, Value::Int), + Transform::Word => Value::String(text.to_string()), + Transform::QuotedString => Value::String(dequote(text)), + Transform::Custom(f) => f(&[text]), + } +} + +/// Strips a `{string}` token's surrounding quotes and unescapes `\X` → `X`. +fn dequote(s: &str) -> String { + let chars: Vec = s.chars().collect(); + if chars.len() < 2 { + return s.to_string(); + } + let inner = &chars[1..chars.len() - 1]; + let mut out = String::new(); + let mut i = 0; + while i < inner.len() { + if inner[i] == '\\' && i + 1 < inner.len() { + out.push(inner[i + 1]); + i += 2; + } else { + out.push(inner[i]); + i += 1; + } + } + out +} + +/// Unescapes cucumber `\X` sequences in expression text, then regex-escapes so the +/// literal text matches verbatim. +fn escape_text(raw: &str) -> String { + let mut unescaped = String::new(); + let mut chars = raw.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(n) = chars.next() { + unescaped.push(n); + } + } else { + unescaped.push(c); + } + } + regex::escape(&unescaped) +} + +fn alternation_regex( + alt: &cucumber_expressions::ast::Alternation>, +) -> String { + use cucumber_expressions::ast::Alternative; + let mut branches = Vec::new(); + for single in alt.0.iter() { + let mut branch = String::new(); + for alternative in single { + match alternative { + Alternative::Text(t) => branch.push_str(&escape_text(t.fragment())), + Alternative::Optional(o) => { + branch.push_str("(?:"); + branch.push_str(&escape_text(o.0.fragment())); + branch.push_str(")?"); + } + } + } + branches.push(branch); + } + format!("(?:{})", branches.join("|")) +} + +/// Parameter-type names in source order, read from the parsed AST (escaped +/// braces `\{...\}` are literal text, not parameters). +pub fn parameter_type_names(source: &str) -> Vec { + let Ok(parsed) = Expression::parse(source) else { + return Vec::new(); + }; + parsed + .0 + .iter() + .filter_map(|se| match se { + SingleExpression::Parameter(p) => Some((*p.input.fragment()).to_string()), + _ => None, + }) + .collect() +} diff --git a/rust/var-core/src/failure.rs b/rust/var-core/src/failure.rs new file mode 100644 index 00000000..cbafe5fc --- /dev/null +++ b/rust/var-core/src/failure.rs @@ -0,0 +1,64 @@ +//! Converts a caught step failure into the structured [`ExampleFailure`] payload +//! — port of `failure.ts` / `Failure.java`. The Java stack-trace-scraping +//! machinery becomes a structural [`FailureLocation`] lookup by exact path match. + +use crate::error::{StepError, StepFailure}; +use crate::result::{CellFailure, ExampleFailure}; + +/// A caught step failure → the `ExampleResult.failure` payload. `fallback_line` +/// is used when `failure` carries no location matching `spec_path`. +pub fn to_failure(failure: &StepFailure, spec_path: &str, fallback_line: i64) -> ExampleFailure { + let message = failure.error.message(); + + let cells = match &failure.error { + StepError::CellMismatch(cells) => { + let failing: Vec = cells + .iter() + .filter(|c| !c.ok) + .map(|c| CellFailure::new(c.span.start_offset, c.span.end_offset, c.actual.clone())) + .collect(); + (!failing.is_empty()).then_some(failing) + } + _ => None, + }; + + let doc = match &failure.error { + StepError::DocStringMismatch(diff) => Some(CellFailure::new( + diff.span.start_offset, + diff.span.end_offset, + diff.actual.clone(), + )), + _ => None, + }; + + // Structural path match replaces Java's regex-escaped stack-trace scrape. + let line = failure + .location + .as_ref() + .filter(|l| l.path == spec_path) + .map_or(fallback_line, |l| l.line as i64); + + let stack = render_stack(failure); + ExampleFailure { + line, + message, + stack, + cells, + doc, + } +} + +/// Display-only rendering of the failure's location (the Java `stack` field is +/// rendered from structural data, not scraped from it). +fn render_stack(failure: &StepFailure) -> String { + match &failure.location { + Some(l) => format!( + "{}\n at {} ({}:{})", + failure.error.message(), + l.label, + l.path, + l.line + ), + None => failure.error.message(), + } +} diff --git a/rust/var-core/src/failure_anchor.rs b/rust/var-core/src/failure_anchor.rs new file mode 100644 index 00000000..78301cd0 --- /dev/null +++ b/rust/var-core/src/failure_anchor.rs @@ -0,0 +1,17 @@ +//! Where a failure points in the `.md` — port of `failure-anchor.ts` / +//! `FailureAnchor.java`. A mismatch anchors at its first failing span; anything +//! else at the fallback (the step's match start). Crate-private, like Java's +//! package-private class. + +use crate::error::StepError; +use crate::span::Span; + +/// The failure's source anchor: first failing cell span / doc-string body span / +/// the `fallback`. +pub(crate) fn anchor(error: &StepError, fallback: Span) -> Span { + match error { + StepError::CellMismatch(cells) => cells.iter().find(|c| !c.ok).map_or(fallback, |c| c.span), + StepError::DocStringMismatch(diff) => diff.span, + _ => fallback, + } +} diff --git a/rust/var-core/src/handler.rs b/rust/var-core/src/handler.rs new file mode 100644 index 00000000..8ef137b5 --- /dev/null +++ b/rust/var-core/src/handler.rs @@ -0,0 +1,125 @@ +//! Step handlers — the Rust replacement for Java's reflective arity-matched SAM +//! invocation (`Execute.invokeHandler`/`samMethod`). A handler is a boxed closure +//! over `(state, args)`; arity is validated at the constructor. `StepReturn` +//! carries the sync-or-`Future` channel (the analog of "an `Object` that might be +//! a `CompletableFuture`"). + +use crate::error::HandlerError; +use crate::value::Value; +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; + +/// A handler's resolved return: `Ok(None)` = "no assertion" (Java `null`), +/// `Ok(Some(v))` = a value, `Err(_)` = an author-signalled failure (Java `throw`). +pub type HandlerReturn = Result, HandlerError>; + +/// The sync-or-async return channel. +pub enum StepReturn { + Ready(HandlerReturn), + Pending(Pin>>), +} + +/// A registered step handler: a closure over `(state, args_after_state)`. +#[derive(Clone)] +pub struct Handler { + f: Rc) -> StepReturn>, +} + +impl Handler { + /// A no-op handler (arity-agnostic) — used where a handler is never invoked. + pub fn noop() -> Handler { + Handler { + f: Rc::new(|_state, _args| StepReturn::Ready(Ok(None))), + } + } + + /// A synchronous 0-argument handler `(state)`. + pub fn sync0(f: impl Fn(Value) -> HandlerReturn + 'static) -> Handler { + Handler { + f: Rc::new(move |state, args| { + if !args.is_empty() { + return StepReturn::Ready(Err(HandlerError::new( + "no handler with 0 parameter(s)", + ))); + } + StepReturn::Ready(f(state)) + }), + } + } + + /// A synchronous 1-argument handler `(state, a)`. + pub fn sync1(f: impl Fn(Value, Value) -> HandlerReturn + 'static) -> Handler { + Handler { + f: Rc::new(move |state, args| { + if args.len() != 1 { + return StepReturn::Ready(Err(HandlerError::new( + "no handler with 1 parameter(s)", + ))); + } + let mut it = args.into_iter(); + let a = it.next().unwrap(); + StepReturn::Ready(f(state, a)) + }), + } + } + + /// A synchronous 2-argument handler `(state, a, b)`. + pub fn sync2(f: impl Fn(Value, Value, Value) -> HandlerReturn + 'static) -> Handler { + Handler { + f: Rc::new(move |state, args| { + if args.len() != 2 { + return StepReturn::Ready(Err(HandlerError::new( + "no handler with 2 parameter(s)", + ))); + } + let mut it = args.into_iter(); + let a = it.next().unwrap(); + let b = it.next().unwrap(); + StepReturn::Ready(f(state, a, b)) + }), + } + } + + /// An asynchronous 0-argument handler returning a `Future`. + pub fn async0( + f: impl Fn(Value) -> Pin>> + 'static, + ) -> Handler { + Handler { + f: Rc::new(move |state, args| { + if !args.is_empty() { + return StepReturn::Ready(Err(HandlerError::new( + "no handler with 0 parameter(s)", + ))); + } + StepReturn::Pending(f(state)) + }), + } + } + + /// A synchronous handler of any arity: `(state, args)` where `args` holds + /// every capture plus the trailing table/doc string, in slot order. The + /// general escape hatch matching Java's reflective any-arity invocation and + /// Python's `*args` — use it for steps with three or more slots, where the + /// fixed-arity conveniences above stop. + pub fn sync_var(f: impl Fn(Value, Vec) -> HandlerReturn + 'static) -> Handler { + Handler { + f: Rc::new(move |state, args| StepReturn::Ready(f(state, args))), + } + } + + /// As [`Handler::sync_var`], returning a `Future` — the any-arity async form + /// (an async handler with parameters is inexpressible via [`Handler::async0`]). + pub fn async_var( + f: impl Fn(Value, Vec) -> Pin>> + 'static, + ) -> Handler { + Handler { + f: Rc::new(move |state, args| StepReturn::Pending(f(state, args))), + } + } + + /// Invokes the handler with `state` + `args` (captures then trailing attachment). + pub(crate) fn call(&self, state: Value, args: Vec) -> StepReturn { + (self.f)(state, args) + } +} diff --git a/rust/var-core/src/hash.rs b/rust/var-core/src/hash.rs new file mode 100644 index 00000000..3076c6ab --- /dev/null +++ b/rust/var-core/src/hash.rs @@ -0,0 +1,16 @@ +//! FNV-1a (32-bit) change-detector over UTF-16 code units — port of `hash.ts` / +//! `Hash.java`. Byte-identical across every port so `var.lock.json` fingerprints +//! match. The `fnv1a:` prefix namespaces the algorithm. + +const FNV_OFFSET: u32 = 0x811c_9dc5; +const FNV_PRIME: u32 = 0x0100_0193; + +/// Hashes `source` to `fnv1a:<8 hex>` (FNV-1a over UTF-16 code units, wrapping). +pub fn hash_source(source: &str) -> String { + let mut h: u32 = FNV_OFFSET; + for unit in source.encode_utf16() { + h = (h ^ u32::from(unit)).wrapping_mul(FNV_PRIME); + } + // `{:08x}` formats the 32-bit pattern as unsigned lowercase hex. + format!("fnv1a:{h:08x}") +} diff --git a/rust/var-core/src/lib.rs b/rust/var-core/src/lib.rs new file mode 100644 index 00000000..108b57f9 --- /dev/null +++ b/rust/var-core/src/lib.rs @@ -0,0 +1,51 @@ +//! `var-core` — the pure functional core of var, ported from the Java module +//! `com.oselvar.var.core`: parse → match → plan → execute, diffs, drift/hash, +//! canonical JSON, and the conformance projections. No filesystem, network, +//! time, or test-framework dependencies. +//! +//! The sealed Java interfaces (`Block`, `TableOrFence`, `ResolvedSteps`) become +//! Rust enums; the exception hierarchy becomes [`error::StepError`]/`Result`; +//! `Object` duck-typing becomes [`value::Value`]; reflective handler invocation +//! becomes boxed closures ([`handler::Handler`]). + +#![forbid(unsafe_code)] +// `StepFailure` carries the full diff payload the tests consume by value +// (`run().unwrap_err().error`); boxing it would change that public surface. +#![allow(clippy::result_large_err)] +// Nested `if`/`if let` blocks mirror the Java control flow faithfully; stable +// Rust has no `let`-chains to collapse the `if let` cases into. +#![allow(clippy::collapsible_if)] +// Declared exception to "no globals in the core": `execute::install_hook` +// registers a Once-guarded, thread-local-gated process panic hook — the only +// way to silence stderr for the panics `catch_unwind` deliberately catches +// (the AssertionError parity channel). See its doc comment for the guarantees. + +pub mod ast; +pub mod canonical_json; +pub mod cell_diff; +pub mod conformance; +pub mod diagnostics; +pub mod doc_string_diff; +pub mod drift; +pub mod error; +pub mod execute; +pub mod expression; +pub mod failure; +mod failure_anchor; +pub mod handler; +pub mod hash; +pub mod matcher; +pub mod offsets; +pub mod param_diff; +pub mod parse; +pub mod plan; +pub mod registry; +pub mod result; +pub mod scanner; +pub mod sentences; +pub mod span; +pub mod step_kind; +pub mod step_role; +pub mod structurer; +pub mod table_cells; +pub mod value; diff --git a/rust/var-core/src/matcher.rs b/rust/var-core/src/matcher.rs new file mode 100644 index 00000000..a63ae525 --- /dev/null +++ b/rust/var-core/src/matcher.rs @@ -0,0 +1,146 @@ +//! Matches a sentence against a registry's compiled expressions — port of +//! `matcher.ts` / `Matcher.java`. Unanchored substring scan per step, then +//! greedy left-to-right non-overlap resolution. All returned offsets are UTF-16 +//! (regex byte offsets converted at [`Hit`] construction). + +use crate::offsets::utf16_index; +use crate::registry::{FormatFn, Registry, StepRegistration}; +use crate::value::Value; +use regex::Regex; +use std::rc::Rc; + +/// UTF-16 start/end of one captured parameter within the sentence. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ParamSpan { + pub start: usize, + pub end: usize, +} + +/// One successful expression match inside a sentence. `formats` aligns 1:1 with +/// `args` (`None` where the parameter type has no formatter). +#[derive(Clone)] +pub struct Hit { + pub expression: String, + pub step_def: Rc, + pub match_start: usize, + pub match_end: usize, + pub args: Vec, + pub param_spans: Vec, + pub formats: Vec>, +} + +/// Two or more hits that start at the same position with equal length. +#[derive(Clone)] +pub struct AmbiguityCollision { + pub match_start: usize, + pub match_end: usize, + pub candidates: Vec, +} + +/// The tagged result of [`resolve_hits`]. +pub enum ResolvedSteps { + /// The greedy, left-to-right, non-overlapping selection. + Ok(Vec), + /// Every same-start/same-length tie that blocked selection. + Ambiguous(Vec), +} + +/// Every expression match found anywhere in `sentence`, one unanchored scan per +/// registered step, in registration order. Port of `findHits`. Regex byte offsets +/// are converted to UTF-16 at [`Hit`] construction. +pub fn find_hits(sentence: &str, registry: &Registry) -> Vec { + let mut hits = Vec::new(); + for step in ®istry.steps { + let Ok(unanchored) = Regex::new(&strip_anchors(step.compiled.regexp_source())) else { + continue; + }; + for m in unanchored.find_iter(sentence) { + let matched_text = &sentence[m.start()..m.end()]; + let arguments = step.compiled.match_whole(matched_text).unwrap_or_default(); + + let mut args = Vec::with_capacity(arguments.len()); + let mut param_spans = Vec::new(); + let mut formats = Vec::with_capacity(arguments.len()); + for arg in arguments { + formats.push(registry.formats.get(&arg.parameter_type_name).cloned()); + if let Some((gs, ge)) = arg.group { + param_spans.push(ParamSpan { + start: utf16_index(sentence, m.start() + gs), + end: utf16_index(sentence, m.start() + ge), + }); + } + args.push(arg.value); + } + + hits.push(Hit { + expression: step.expression.clone(), + step_def: step.clone(), + match_start: utf16_index(sentence, m.start()), + match_end: utf16_index(sentence, m.end()), + args, + param_spans, + formats, + }); + } + } + hits +} + +/// Strips a compiled expression's `^...$` anchors so an unanchored scan can find +/// it anywhere in the sentence. +fn strip_anchors(source: &str) -> String { + let s = source.strip_prefix('^').unwrap_or(source); + let s = s.strip_suffix('$').unwrap_or(s); + s.to_string() +} + +/// Selects the greedy, left-to-right, non-overlapping subset of `hits`, or +/// reports every same-start/same-length ambiguity. Port of `resolveHits`. +pub fn resolve_hits(hits: Vec) -> ResolvedSteps { + if hits.is_empty() { + return ResolvedSteps::Ok(Vec::new()); + } + let mut sorted = hits; + // Sort by matchStart ascending, then by length descending (stable). + sorted.sort_by(|a, b| { + a.match_start + .cmp(&b.match_start) + .then_with(|| (b.match_end - b.match_start).cmp(&(a.match_end - a.match_start))) + }); + + let mut collisions = Vec::new(); + let mut i = 0; + while i < sorted.len() { + let here_start = sorted[i].match_start; + let here_len = sorted[i].match_end - sorted[i].match_start; + let mut j = i + 1; + while j < sorted.len() + && sorted[j].match_start == here_start + && sorted[j].match_end - sorted[j].match_start == here_len + { + j += 1; + } + if j - i > 1 { + collisions.push(AmbiguityCollision { + match_start: here_start, + match_end: sorted[i].match_end, + candidates: sorted[i..j].to_vec(), + }); + } + i = j; + } + if !collisions.is_empty() { + return ResolvedSteps::Ambiguous(collisions); + } + + let mut steps = Vec::new(); + let mut cursor: isize = -1; + for hit in sorted { + if (hit.match_start as isize) < cursor { + continue; + } + cursor = hit.match_end as isize; + steps.push(hit); + } + ResolvedSteps::Ok(steps) +} diff --git a/rust/var-core/src/offsets.rs b/rust/var-core/src/offsets.rs new file mode 100644 index 00000000..2e073426 --- /dev/null +++ b/rust/var-core/src/offsets.rs @@ -0,0 +1,62 @@ +//! UTF-16 offset helpers — the conversion layer the Python port needed and Java +//! did not. All spans/offsets in the shared conformance goldens are UTF-16 +//! code-unit offsets (Java `String`/`char` are UTF-16 natively); Rust `str` is +//! UTF-8, so byte offsets from `str::find`/the `regex` crate must be converted +//! to UTF-16 at every span-production site. Byte offsets exist only as transient +//! locals; every *stored* offset is UTF-16. + +/// UTF-16 code-unit length of `s` (Java `String.length()`). +pub fn utf16_len(s: &str) -> usize { + s.chars().map(char::len_utf16).sum() +} + +/// Converts a byte index within `s` to a UTF-16 code-unit offset. +/// `byte_idx` must fall on a `char` boundary. +pub fn utf16_index(s: &str, byte_idx: usize) -> usize { + utf16_len(&s[..byte_idx]) +} + +/// Converts a UTF-16 code-unit offset within `s` to a byte index. Clamps to +/// `s.len()` when `u16_idx` runs past the end (mirrors JS `String.slice`). +pub fn byte_index(s: &str, u16_idx: usize) -> usize { + let mut u16 = 0usize; + for (byte, c) in s.char_indices() { + if u16 >= u16_idx { + return byte; + } + u16 += c.len_utf16(); + } + s.len() +} + +/// Java `s.substring(startU16, endU16)` with UTF-16 indices. +pub fn utf16_slice(s: &str, start_u16: usize, end_u16: usize) -> &str { + let start = byte_index(s, start_u16); + let end = byte_index(s, end_u16); + &s[start..end] +} + +/// Java `String.trim()`: strips leading/trailing chars `<= U+0020`. +pub fn java_trim(s: &str) -> &str { + s.trim_matches(|c: char| (c as u32) <= 0x20) +} + +/// Java `String.strip()`: strips leading/trailing `Character.isWhitespace`. +pub fn java_strip(s: &str) -> &str { + s.trim_matches(is_java_whitespace) +} + +/// Java `String.stripLeading()`. +pub fn java_strip_leading(s: &str) -> &str { + s.trim_start_matches(is_java_whitespace) +} + +/// Java `Character.isWhitespace`: Unicode whitespace excluding the no-break +/// spaces U+00A0/U+2007/U+202F, plus the separator range U+001C–U+001F. +fn is_java_whitespace(c: char) -> bool { + match c { + '\u{00A0}' | '\u{2007}' | '\u{202F}' => false, + '\u{001C}'..='\u{001F}' => true, + _ => c.is_whitespace(), + } +} diff --git a/rust/var-core/src/param_diff.rs b/rust/var-core/src/param_diff.rs new file mode 100644 index 00000000..e65a97df --- /dev/null +++ b/rust/var-core/src/param_diff.rs @@ -0,0 +1,64 @@ +//! Parameter comparison — port of `param-diff.ts` / `ParamDiff.java`. Compares a +//! sensor's returned inline actuals against the values captured from the document. + +use crate::cell_diff::CellDiff; +use crate::registry::FormatFn; +use crate::span::Span; +use crate::value::Value; + +/// Compares `returned` against `expected` with no display formatters. +pub fn compare_params( + returned: &[Value], + expected: &[Value], + param_spans: &[Span], + source_texts: &[String], +) -> Vec { + compare_params_with_formats(returned, expected, param_spans, source_texts, None) +} + +/// Compares `returned` against `expected` (the captured args), one [`CellDiff`] +/// per parameter. `source_texts` supplies each diff's `expected` display; +/// `formats` (aligned 1:1, `None` entries where a type has none) renders display +/// strings only, never the verdict (which is structural [`Value`] equality). +pub fn compare_params_with_formats( + returned: &[Value], + expected: &[Value], + param_spans: &[Span], + source_texts: &[String], + formats: Option<&[Option]>, +) -> Vec { + let mut diffs = Vec::with_capacity(expected.len()); + for i in 0..expected.len() { + // Structural equality is the verdict (`Objects.equals` parity). + let ok = returned[i] == expected[i]; + let format = formats.and_then(|f| f.get(i)).and_then(|opt| opt.as_ref()); + let expected_text = if i < source_texts.len() { + source_texts[i].clone() + } else { + render_param_value(&expected[i], format).0 + }; + let (actual_text, via_format) = render_param_value(&returned[i], format); + diffs.push(CellDiff { + column: format!("arg {}", i + 1), + span: param_spans[i], + expected: expected_text, + actual: actual_text, + ok, + expected_value: Some(expected[i].clone()), + actual_value: Some(returned[i].clone()), + formatted: via_format, + }); + } + diffs +} + +/// Renders one side of a parameter diff: the type's `format` when it has one +/// (and it produces a value), else the shared string/primitive chain. +fn render_param_value(value: &Value, format: Option<&FormatFn>) -> (String, bool) { + if let Some(f) = format { + if let Some(rendered) = f(value) { + return (rendered, true); + } + } + (crate::cell_diff::render_cell_value(value), false) +} diff --git a/rust/var-core/src/parse.rs b/rust/var-core/src/parse.rs new file mode 100644 index 00000000..2e4a4b45 --- /dev/null +++ b/rust/var-core/src/parse.rs @@ -0,0 +1,10 @@ +//! Top-level parse entry point: `scan` then `structure` — port of `parse.ts` / +//! `Parse.java`. + +use crate::ast::VarDoc; +use crate::{scanner, structurer}; + +/// Parses `source` into a [`VarDoc`]. +pub fn parse(path: &str, source: &str) -> VarDoc { + structurer::structure(path, source, scanner::scan(source)) +} diff --git a/rust/var-core/src/plan.rs b/rust/var-core/src/plan.rs new file mode 100644 index 00000000..fae930ca --- /dev/null +++ b/rust/var-core/src/plan.rs @@ -0,0 +1,465 @@ +//! The planner — port of `plan.ts` / `Plan.java`. Plans each text-bearing block +//! via the matcher, lifts block offsets to source spans, attaches trailing +//! table/fence nodes, handles the ```` ```error ```` fence, expands header-bound +//! tables into one example per row, and collects diagnostics. + +use crate::ast::{Block, Fence, Row, SegmentOffset, Table, VarDoc}; +use crate::cell_diff::RowCheck; +use crate::diagnostics::{Diagnostic, ambiguous_match, error_fence_without_step}; +use crate::matcher::{Hit, ParamSpan, ResolvedSteps, find_hits, resolve_hits}; +use crate::offsets::{java_trim, utf16_len}; +use crate::registry::{FormatFn, Registry, StepRegistration}; +use crate::sentences::split_sentences; +use crate::span::Span; +use crate::value::Value; +use regex::Regex; +use std::collections::BTreeMap; +use std::rc::Rc; +use std::sync::LazyLock; + +/// The result of planning a whole [`VarDoc`]. +pub struct ExecutionPlan { + pub var_doc: VarDoc, + pub examples: Vec, + pub diagnostics: Vec, +} + +/// One matched-and-runnable example. +pub struct PlannedExample { + pub name: String, + pub scope_stack: Vec, + pub span: Span, + pub steps: Vec, + pub header_binding: Option, + pub row_checks: Option>, + pub expected_outcome: Option, + pub expected_error_message: Option, +} + +/// The binding paragraph shared by every row of a header-bound table. +pub struct HeaderBinding { + pub match_span: Span, + pub param_spans: Vec, + pub step_def: Rc, +} + +/// One matched step: text, source span, captured-parameter spans, args, and +/// attachments. `formats` aligns 1:1 with `args`. +#[derive(Clone)] +pub struct PlannedStep { + pub text: String, + pub match_span: Span, + pub param_spans: Vec, + pub step_def: Rc, + pub args: Vec, + pub formats: Vec>, + pub data_table: Option, + pub doc_string: Option, +} + +static WHITESPACE_RE: LazyLock = LazyLock::new(|| Regex::new(r"\s+").unwrap()); +static WORD_CHAR_RE: LazyLock = LazyLock::new(|| Regex::new(r"^[\p{L}\p{N}_]$").unwrap()); + +/// Plans `doc` against `registry`. Port of `plan()`. +pub fn plan(doc: &VarDoc, registry: &Registry) -> ExecutionPlan { + let source = &doc.source; + let mut examples = Vec::new(); + let mut diagnostics = Vec::new(); + + for ex in &doc.examples { + let mut had_ambiguous = false; + let body = &ex.body; + + // Pass 1: plan each text-bearing block, collecting steps per body index. + let mut steps_by_block: BTreeMap> = BTreeMap::new(); + for (idx, block) in body.iter().enumerate() { + if !is_text_bearing(block) { + continue; + } + let text = text_of(block); + let (block_hits, ambiguities) = plan_block(text, registry); + for collision in &ambiguities { + let span = lift_span(source, block, collision.match_start, collision.match_end); + diagnostics.push(ambiguous_match(span)); + had_ambiguous = true; + } + if !had_ambiguous && !block_hits.is_empty() { + let block_steps: Vec = block_hits + .into_iter() + .map(|hit| PlannedStep { + text: crate::offsets::utf16_slice(text, hit.match_start, hit.match_end) + .to_string(), + match_span: lift_span(source, block, hit.match_start, hit.match_end), + param_spans: hit + .param_spans + .iter() + .map(|p| lift_span(source, block, p.start, p.end)) + .collect(), + step_def: hit.step_def, + args: hit.args, + formats: hit.formats, + data_table: None, + doc_string: None, + }) + .collect(); + steps_by_block.insert(idx, block_steps); + } + } + + // Header-bound table: iterate row by row. + let bound = if had_ambiguous { + None + } else { + detect_header_bound(body, &steps_by_block, source) + }; + if let Some(bound) = bound { + let header_binding = HeaderBinding { + match_span: bound.step.match_span, + param_spans: bound.header_spans.clone(), + step_def: bound.step.step_def.clone(), + }; + let header_cells = &bound.table.header.cells; + for row in &bound.table.rows { + let mut row_object = BTreeMap::new(); + for (i, header) in header_cells.iter().enumerate() { + row_object.insert(header.clone(), Value::from(cell_at(row, i))); + } + let mut row_args = bound.step.args.clone(); + row_args.push(Value::Map(row_object)); + let row_step = PlannedStep { + text: bound.step.text.clone(), + match_span: row.span, + param_spans: bound.step.param_spans.clone(), + step_def: bound.step.step_def.clone(), + args: row_args, + formats: bound.step.formats.clone(), + data_table: None, + doc_string: None, + }; + let row_checks: Vec = header_cells + .iter() + .enumerate() + .map(|(i, header)| { + RowCheck::new(header.clone(), cell_at(row, i), cell_span_at(row, i)) + }) + .collect(); + let mut nested_scope = ex.scope_stack.clone(); + nested_scope.push(bound.step.text.clone()); + examples.push(PlannedExample { + name: row.cells.join(" / "), + scope_stack: nested_scope, + span: row.span, + steps: vec![row_step], + header_binding: Some(HeaderBinding { + match_span: header_binding.match_span, + param_spans: header_binding.param_spans.clone(), + step_def: header_binding.step_def.clone(), + }), + row_checks: Some(row_checks), + expected_outcome: None, + expected_error_message: None, + }); + } + continue; + } + + // An ```error fence anywhere marks the example expected-to-fail. + let error_fence: Option<&Fence> = body.iter().find_map(|b| match b { + Block::Fence(f) if f.info == "error" => Some(f), + _ => None, + }); + + // Pass 2: table/fence immediately after a step-bearing block. + let mut attachments: BTreeMap, Option)> = BTreeMap::new(); + for (idx, here) in body.iter().enumerate().skip(1) { + match here { + Block::Table(table) if steps_by_block.contains_key(&(idx - 1)) => { + attachments.entry(idx - 1).or_default().0 = Some(table.clone()); + } + Block::Fence(fence) + if fence.info != "error" && steps_by_block.contains_key(&(idx - 1)) => + { + attachments.entry(idx - 1).or_default().1 = Some(fence.clone()); + } + _ => {} + } + } + + // Pass 3: rebuild the final step list, applying attachments to the last + // step of each block. + let mut final_steps = Vec::new(); + for idx in 0..body.len() { + let Some(steps_at_idx) = steps_by_block.get(&idx) else { + continue; + }; + let attach = attachments.get(&idx); + let last = steps_at_idx.len() - 1; + for (s, step) in steps_at_idx.iter().enumerate() { + if s == last { + if let Some((data_table, doc_string)) = attach { + let mut with_attach = step.clone(); + with_attach.data_table = data_table.clone(); + with_attach.doc_string = doc_string.clone(); + final_steps.push(with_attach); + continue; + } + } + final_steps.push(step.clone()); + } + } + + let runnable_steps = if had_ambiguous { + Vec::new() + } else { + final_steps.clone() + }; + + if let Some(fence) = error_fence { + if runnable_steps.is_empty() { + diagnostics.push(error_fence_without_step(fence.span)); + } + } + + if final_steps.is_empty() && !had_ambiguous { + continue; + } + + let (expected_outcome, expected_error_message) = match error_fence { + Some(fence) => { + let trimmed = java_trim(&fence.body); + let msg = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; + (Some("fail".to_string()), msg) + } + None => (None, None), + }; + + examples.push(PlannedExample { + name: derive_example_name(body), + scope_stack: ex.scope_stack.clone(), + span: ex.span, + steps: runnable_steps, + header_binding: None, + row_checks: None, + expected_outcome, + expected_error_message, + }); + } + + ExecutionPlan { + var_doc: doc.clone(), + examples, + diagnostics, + } +} + +struct Ambiguity { + match_start: usize, + match_end: usize, +} + +fn plan_block(text: &str, registry: &Registry) -> (Vec, Vec) { + let mut all_steps = Vec::new(); + let mut all_ambiguities = Vec::new(); + for sentence in split_sentences(text) { + let off = sentence.start_offset; + let adjusted: Vec = find_hits(&sentence.text, registry) + .into_iter() + .map(|h| { + let param_spans = h + .param_spans + .iter() + .map(|p| ParamSpan { + start: p.start + off, + end: p.end + off, + }) + .collect(); + Hit { + expression: h.expression, + step_def: h.step_def, + match_start: h.match_start + off, + match_end: h.match_end + off, + args: h.args, + param_spans, + formats: h.formats, + } + }) + .collect(); + match resolve_hits(adjusted) { + ResolvedSteps::Ambiguous(collisions) => { + for c in collisions { + all_ambiguities.push(Ambiguity { + match_start: c.match_start, + match_end: c.match_end, + }); + } + } + ResolvedSteps::Ok(steps) => { + if !steps.is_empty() { + all_steps.extend(steps); + } + } + } + } + (all_steps, all_ambiguities) +} + +struct HeaderBoundResult { + table: Table, + step: PlannedStep, + header_spans: Vec, +} + +fn detect_header_bound( + body: &[Block], + steps_by_block: &BTreeMap>, + source: &str, +) -> Option { + for idx in 1..body.len() { + let Block::Table(table) = &body[idx] else { + continue; + }; + let above = &body[idx - 1]; + if !is_text_bearing(above) { + continue; + } + let Some(steps) = steps_by_block.get(&(idx - 1)) else { + continue; + }; + if steps.is_empty() { + continue; + } + let above_text = text_of(above); + let header_cells = &table.header.cells; + let mut offsets = Vec::with_capacity(header_cells.len()); + let mut any_missing = false; + for cell in header_cells { + match word_offset(above_text, cell) { + Some(o) => offsets.push(o), + None => { + any_missing = true; + offsets.push(0); + } + } + } + if any_missing { + continue; + } + let header_spans: Vec = header_cells + .iter() + .zip(&offsets) + .map(|(cell, &o)| lift_span(source, above, o, o + utf16_len(cell))) + .collect(); + return Some(HeaderBoundResult { + table: table.clone(), + step: steps.last().unwrap().clone(), + header_spans, + }); + } + None +} + +/// UTF-16 offset of `word` in `haystack` as a whole word (case-sensitive), or +/// `None`. Manual scan replacing Java's lookbehind/lookaround regex. +fn word_offset(haystack: &str, word: &str) -> Option { + if word.is_empty() { + return None; + } + let mut from = 0; + while let Some(rel) = haystack[from..].find(word) { + let at = from + rel; + let before_ok = haystack[..at] + .chars() + .next_back() + .is_none_or(|c| !is_word_char(c)); + let after = at + word.len(); + let after_ok = haystack[after..] + .chars() + .next() + .is_none_or(|c| !is_word_char(c)); + if before_ok && after_ok { + return Some(crate::offsets::utf16_index(haystack, at)); + } + from = at + haystack[at..].chars().next().map_or(1, char::len_utf8); + } + None +} + +fn is_word_char(c: char) -> bool { + let mut buf = [0u8; 4]; + WORD_CHAR_RE.is_match(c.encode_utf8(&mut buf)) +} + +/// The example name: the primary block's text with whitespace collapsed and a +/// single trailing terminator stripped. Port of `deriveExampleName`. +pub(crate) fn derive_example_name(body: &[Block]) -> String { + let Some(primary) = body.iter().find(|b| is_text_bearing(b)) else { + return String::new(); + }; + let collapsed = WHITESPACE_RE.replace_all(text_of(primary), " "); + let mut name = java_trim(&collapsed).to_string(); + if let Some(last) = name.chars().last() { + if last == '.' || last == '!' || last == '?' { + name.pop(); + } + } + name +} + +fn is_text_bearing(block: &Block) -> bool { + matches!( + block, + Block::Paragraph(_) | Block::ListItem(_) | Block::Blockquote(_) + ) +} + +fn text_of(block: &Block) -> &str { + match block { + Block::Paragraph(p) => &p.text, + Block::ListItem(l) => &l.text, + Block::Blockquote(b) => &b.text, + _ => panic!("not a text-bearing block"), + } +} + +fn cell_at(row: &Row, i: usize) -> &str { + row.cells.get(i).map_or("", |c| c.as_str()) +} + +fn cell_span_at(row: &Row, i: usize) -> Span { + row.cell_spans.get(i).copied().unwrap_or(row.span) +} + +fn segment_map_of(block: &Block) -> Option<&[SegmentOffset]> { + match block { + Block::Paragraph(p) => Some(&p.segment_map), + Block::ListItem(l) => Some(&l.segment_map), + Block::Blockquote(b) => Some(&b.segment_map), + _ => None, + } +} + +fn lift_span(source: &str, block: &Block, block_start: usize, block_end: usize) -> Span { + match segment_map_of(block) { + Some(sm) => { + let start = lift_segment_offset(sm, block_start); + let end = lift_segment_offset(sm, block_end); + Span::from_offsets(source, start, end) + } + None => block.span(), + } +} + +fn lift_segment_offset(segment_map: &[SegmentOffset], text_offset: usize) -> usize { + let mut best = segment_map.first(); + for entry in segment_map { + if entry.text_offset <= text_offset { + best = Some(entry); + } + } + let best = best.expect("empty segmentMap"); + best.source_offset + (text_offset - best.text_offset) +} diff --git a/rust/var-core/src/registry.rs b/rust/var-core/src/registry.rs new file mode 100644 index 00000000..9ec83eab --- /dev/null +++ b/rust/var-core/src/registry.rs @@ -0,0 +1,140 @@ +//! Step registry — port of `registry.ts` / `Registry.java`. Wraps the owned +//! [`crate::expression`] layer. Persistent-value semantics: `add_step` / +//! `define_parameter_type` return a new [`Registry`]; the argument is unchanged. + +use crate::error::RegistryError; +use crate::expression::{CompiledExpression, ParameterTypeRegistry}; +use crate::handler::Handler; +use crate::step_kind::StepKind; +use crate::value::Value; +use std::collections::HashMap; +use std::rc::Rc; + +pub use crate::expression::ParseFn; + +/// A parameter-type display formatter (the inverse of `parse`): renders a value +/// back in the document's notation. `None` result → fall through to the generic +/// rendering chain. +pub type FormatFn = Rc Option>; + +/// A custom parameter type as registered by an author — name plus bare pattern +/// source (the string the registry artifact serializes). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomParameterType { + pub name: String, + pub regexp: String, +} + +impl CustomParameterType { + pub fn new(name: impl Into, regexp: impl Into) -> CustomParameterType { + CustomParameterType { + name: name.into(), + regexp: regexp.into(), + } + } +} + +/// One registered step: source expression, source location, handler, compiled +/// expression, and role (`kind` may be `None` — the legacy/kindless path). +#[derive(Clone)] +pub struct StepRegistration { + pub expression: String, + pub expression_source_file: String, + pub expression_source_line: usize, + pub handler: Handler, + pub compiled: CompiledExpression, + pub kind: Option, +} + +/// The step registry. +#[derive(Clone)] +pub struct Registry { + pub steps: Vec>, + pub parameter_types: ParameterTypeRegistry, + pub custom_parameter_types: Vec, + pub formats: HashMap, +} + +/// An empty registry with a fresh default parameter-type registry. +pub fn create_registry() -> Registry { + Registry { + steps: Vec::new(), + parameter_types: ParameterTypeRegistry::new(), + custom_parameter_types: Vec::new(), + formats: HashMap::new(), + } +} + +/// Compiles `expression` against `registry`'s parameter types and appends it, +/// returning a new [`Registry`]. Errors on a duplicate expression or an +/// un-compilable one. +pub fn add_step( + registry: &Registry, + expression: &str, + expression_source_file: &str, + expression_source_line: usize, + handler: Handler, + kind: Option, +) -> Result { + for existing in ®istry.steps { + if existing.expression == expression { + return Err(RegistryError::DuplicateStep(format!( + "duplicate step definition for \"{}\" at {}:{} and {}:{}", + expression, + existing.expression_source_file, + existing.expression_source_line, + expression_source_file, + expression_source_line + ))); + } + } + let compiled = CompiledExpression::compile(expression, ®istry.parameter_types) + .map_err(|e| RegistryError::Expression(e.message))?; + let mut steps = registry.steps.clone(); + steps.push(Rc::new(StepRegistration { + expression: expression.to_string(), + expression_source_file: expression_source_file.to_string(), + expression_source_line, + handler, + compiled, + kind, + })); + Ok(Registry { + steps, + parameter_types: registry.parameter_types.clone(), + custom_parameter_types: registry.custom_parameter_types.clone(), + formats: registry.formats.clone(), + }) +} + +/// Registers a custom parameter type and returns a new [`Registry`] recording it. +pub fn define_parameter_type( + registry: &Registry, + name: &str, + regexp: &str, + parse: ParseFn, +) -> Registry { + let mut parameter_types = registry.parameter_types.clone(); + parameter_types.define(name, regexp, parse); + let mut custom_parameter_types = registry.custom_parameter_types.clone(); + custom_parameter_types.push(CustomParameterType::new(name, regexp)); + Registry { + steps: registry.steps.clone(), + parameter_types, + custom_parameter_types, + formats: registry.formats.clone(), + } +} + +/// As [`define_parameter_type`], additionally retaining a display `format`. +pub fn define_parameter_type_with_format( + registry: &Registry, + name: &str, + regexp: &str, + parse: ParseFn, + format: FormatFn, +) -> Registry { + let mut next = define_parameter_type(registry, name, regexp, parse); + next.formats.insert(name.to_string(), format); + next +} diff --git a/rust/var-core/src/result.rs b/rust/var-core/src/result.rs new file mode 100644 index 00000000..5c766b1a --- /dev/null +++ b/rust/var-core/src/result.rs @@ -0,0 +1,57 @@ +//! Immutable run-result records — port of `result.ts` / `Result.java`. The +//! persisted `.var/.json` file is a serialized [`SpecResults`]. + +/// A doc-string / cell mismatch as a source-offset range plus the runtime value. +/// `from`/`to` are absolute UTF-16 source offsets; `to` is exclusive. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CellFailure { + pub from: usize, + pub to: usize, + pub actual: String, +} + +impl CellFailure { + pub fn new(from: usize, to: usize, actual: impl Into) -> CellFailure { + CellFailure { + from, + to, + actual: actual.into(), + } + } +} + +/// An example's run outcome. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + Passed, + Failed, +} + +/// The failure payload of a failed [`ExampleResult`]. `cells`/`doc` are `None` +/// when not applicable. `line` may be a caller-supplied fallback (`-1`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExampleFailure { + pub line: i64, + pub message: String, + pub stack: String, + pub cells: Option>, + pub doc: Option, +} + +/// The run result for one BDD example. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExampleResult { + pub name: String, + pub status: Status, + pub lines: Vec, + pub failure: Option, +} + +/// The persisted run result for one spec file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SpecResults { + pub version: u32, + pub spec_path: String, + pub source_hash: String, + pub examples: Vec, +} diff --git a/rust/var-core/src/scanner.rs b/rust/var-core/src/scanner.rs new file mode 100644 index 00000000..c4a6ce52 --- /dev/null +++ b/rust/var-core/src/scanner.rs @@ -0,0 +1,341 @@ +//! Turns raw Markdown into a flat list of [`Block`] nodes — port of `scanner.ts` +//! / `Scanner.java`. Offsets in stored spans are UTF-16 code units; the line +//! splitter keeps a running (byte, UTF-16) dual cursor and per-line regex offsets +//! are converted from bytes to UTF-16. +//! +//! The `plugins` parameter carried by `scanner.ts`'s (and the Python port's) +//! `scan` signature is intentionally out of scope, following `Scanner.java` — +//! no scanner plugin is needed by this port yet; [`scan`] takes no plugins +//! parameter at all. A `var.config.json` naming `scannerPlugins` therefore has +//! no core hook here until this is ported. + +use crate::ast::{ + Block, Blockquote, Fence, Heading, ListItem, Paragraph, Row, SegmentOffset, Table, + ThematicBreak, +}; +use crate::offsets::{java_trim, utf16_index, utf16_len}; +use crate::span::Span; +use crate::table_cells::parse_row_cells; +use regex::Regex; +use std::sync::LazyLock; + +/// One line of source, with its UTF-16 and byte offsets in the full source. +struct RawLine { + text: String, + start_offset: usize, + end_offset: usize, + start_byte: usize, + end_byte: usize, +} + +// `\1` backreference is expanded into three alternatives (the `regex` crate has +// no backreferences); otherwise these mirror the Java patterns. `[0-9]` keeps the +// ordered-list digit class ASCII. +static THEMATIC_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:-(?:\s*-){2,}|\*(?:\s*\*){2,}|_(?:\s*_){2,})\s*$").unwrap() +}); +static UL_RE: LazyLock = LazyLock::new(|| Regex::new(r"^(\s*)([-*+])\s+(.*)$").unwrap()); +static OL_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^(\s*)([0-9]+)([.)])\s+(.*)$").unwrap()); +static BQ_RE: LazyLock = LazyLock::new(|| Regex::new(r"^>\s?(.*)$").unwrap()); +static FENCE_RE: LazyLock = LazyLock::new(|| Regex::new(r"^(`{3,})\s*(\S*)\s*$").unwrap()); +static ROW_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\|(.+)\|\s*$").unwrap()); +static DELIM_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^\|\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|\s*$").unwrap()); +static HEADING_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^(#{1,6})\s+(.*?)(?:\s+#+)?\s*$").unwrap()); +static HEADING_PREFIX_RE: LazyLock = LazyLock::new(|| Regex::new(r"^#{1,6}\s+").unwrap()); + +/// Scans `source` into a list of [`Block`] nodes. +pub fn scan(source: &str) -> Vec { + let lines = split_lines(source); + let mut blocks = Vec::new(); + + let mut i = 0; + while i < lines.len() { + if java_trim(&lines[i].text).is_empty() { + i += 1; + continue; + } + if let Some((fence, next)) = try_fence(source, &lines, i) { + blocks.push(Block::Fence(fence)); + i = next; + continue; + } + if let Some((table, next)) = try_table(source, &lines, i) { + blocks.push(Block::Table(table)); + i = next; + continue; + } + if let Some(tb) = try_thematic_break(source, &lines[i]) { + blocks.push(Block::ThematicBreak(tb)); + i += 1; + continue; + } + if let Some((quote, next)) = try_blockquote(source, &lines, i) { + blocks.push(Block::Blockquote(quote)); + i = next; + continue; + } + if let Some(heading) = try_heading(source, &lines[i]) { + blocks.push(Block::Heading(heading)); + i += 1; + continue; + } + if let Some(item) = try_list_item(source, &lines[i]) { + blocks.push(Block::ListItem(item)); + i += 1; + continue; + } + let (paragraph, next) = consume_paragraph(source, &lines, i); + blocks.push(Block::Paragraph(paragraph)); + i = next; + } + blocks +} + +fn split_lines(source: &str) -> Vec { + let mut out = Vec::new(); + let mut byte_start = 0; + let mut u16_start = 0; + let mut u16 = 0; + for (byte_i, c) in source.char_indices() { + if c == '\n' { + out.push(RawLine { + text: source[byte_start..byte_i].to_string(), + start_offset: u16_start, + end_offset: u16, + start_byte: byte_start, + end_byte: byte_i, + }); + byte_start = byte_i + 1; + u16_start = u16 + 1; + } + u16 += c.len_utf16(); + } + out.push(RawLine { + text: source[byte_start..].to_string(), + start_offset: u16_start, + end_offset: u16, + start_byte: byte_start, + end_byte: source.len(), + }); + out +} + +fn try_thematic_break(source: &str, line: &RawLine) -> Option { + if !THEMATIC_RE.is_match(&line.text) { + return None; + } + Some(ThematicBreak { + span: Span::from_offsets(source, line.start_offset, line.end_offset), + }) +} + +fn try_heading(source: &str, line: &RawLine) -> Option { + let m = HEADING_RE.captures(&line.text)?; + let hashes = m.get(1).unwrap().as_str(); + let text = java_trim(m.get(2).unwrap().as_str()).to_string(); + Some(Heading { + level: hashes.len(), + text, + span: Span::from_offsets(source, line.start_offset, line.end_offset), + }) +} + +fn try_list_item(source: &str, line: &RawLine) -> Option { + if let Some(ul) = UL_RE.captures(&line.text) { + let text = ul.get(3).unwrap().as_str(); + let marker_start = line.start_offset + utf16_len(ul.get(1).unwrap().as_str()); + let marker_end = marker_start + utf16_len(ul.get(2).unwrap().as_str()); + let text_start = line.start_offset + utf16_index(&line.text, line.text.find(text).unwrap()); + return Some(ListItem { + text: text.to_string(), + span: Span::from_offsets(source, line.start_offset, line.end_offset), + segment_map: vec![SegmentOffset::new(0, text_start)], + ordered: false, + marker_span: Span::from_offsets(source, marker_start, marker_end), + }); + } + if let Some(ol) = OL_RE.captures(&line.text) { + let text = ol.get(4).unwrap().as_str(); + let marker_start = line.start_offset + utf16_len(ol.get(1).unwrap().as_str()); + let marker_end = marker_start + + utf16_len(ol.get(2).unwrap().as_str()) + + utf16_len(ol.get(3).unwrap().as_str()); + let text_start = line.start_offset + utf16_index(&line.text, line.text.find(text).unwrap()); + return Some(ListItem { + text: text.to_string(), + span: Span::from_offsets(source, line.start_offset, line.end_offset), + segment_map: vec![SegmentOffset::new(0, text_start)], + ordered: true, + marker_span: Span::from_offsets(source, marker_start, marker_end), + }); + } + None +} + +fn try_blockquote( + source: &str, + lines: &[RawLine], + start_idx: usize, +) -> Option<(Blockquote, usize)> { + let first = &lines[start_idx]; + let m = BQ_RE.captures(&first.text)?; + let first_segment = m.get(1).unwrap().as_str().to_string(); + + let mut segments = vec![first_segment.clone()]; + let mut segment_map = vec![SegmentOffset::new( + 0, + first.start_offset + utf16_index(&first.text, first.text.find(&first_segment).unwrap()), + )]; + let mut joined_text_offset = utf16_len(&first_segment); + + let mut i = start_idx + 1; + let mut end_offset = first.end_offset; + while i < lines.len() { + let ln = &lines[i]; + let Some(next) = BQ_RE.captures(&ln.text) else { + break; + }; + let segment = next.get(1).unwrap().as_str().to_string(); + joined_text_offset += 1; // newline separator + segment_map.push(SegmentOffset::new( + joined_text_offset, + ln.start_offset + utf16_index(&ln.text, ln.text.find(&segment).unwrap()), + )); + joined_text_offset += utf16_len(&segment); + segments.push(segment); + end_offset = ln.end_offset; + i += 1; + } + let quote = Blockquote { + text: segments.join("\n"), + span: Span::from_offsets(source, first.start_offset, end_offset), + segment_map, + }; + Some((quote, i)) +} + +fn consume_paragraph(source: &str, lines: &[RawLine], start_idx: usize) -> (Paragraph, usize) { + let first = &lines[start_idx]; + let mut end_idx = start_idx; + while end_idx + 1 < lines.len() { + let candidate = &lines[end_idx + 1]; + let t = &candidate.text; + if java_trim(t).is_empty() + || HEADING_PREFIX_RE.is_match(t) + || UL_RE.is_match(t) + || OL_RE.is_match(t) + || BQ_RE.is_match(t) + || FENCE_RE.is_match(t) + || ROW_RE.is_match(t) + || THEMATIC_RE.is_match(t) + { + break; + } + end_idx += 1; + } + let last = &lines[end_idx]; + let paragraph = Paragraph { + text: source[first.start_byte..last.end_byte].to_string(), + span: Span::from_offsets(source, first.start_offset, last.end_offset), + segment_map: vec![SegmentOffset::new(0, first.start_offset)], + }; + (paragraph, end_idx + 1) +} + +fn try_fence(source: &str, lines: &[RawLine], start_idx: usize) -> Option<(Fence, usize)> { + let start = &lines[start_idx]; + let open = FENCE_RE.captures(&start.text)?; + let fence_marker = open.get(1).unwrap().as_str().to_string(); + let info = java_trim(open.get(2).unwrap().as_str()).to_string(); + + let mut i = start_idx + 1; + let mut body_start: Option<(usize, usize)> = None; // (u16, byte) + let mut body_end: Option<(usize, usize)> = None; + let mut end_offset = start.end_offset; + while i < lines.len() { + let ln = &lines[i]; + if let Some(close) = FENCE_RE.captures(&ln.text) { + if close.get(1).unwrap().as_str().len() >= fence_marker.len() { + end_offset = ln.end_offset; + break; + } + } + if body_start.is_none() { + body_start = Some((ln.start_offset, ln.start_byte)); + } + // Include the newline that separates this line from the next. + body_end = Some((ln.end_offset + 1, ln.end_byte + 1)); + i += 1; + } + + let source_u16 = utf16_len(source); + let clamped_end_u16 = body_end.map_or(0, |(u16, _)| u16.min(source_u16)); + let clamped_end_byte = body_end.map_or(0, |(_, byte)| byte.min(source.len())); + let body = match (body_start, body_end) { + (Some((_, sb)), Some(_)) => source[sb..clamped_end_byte].to_string(), + _ => String::new(), + }; + let fallback = start.end_offset; + let body_span = Span::from_offsets( + source, + body_start.map_or(fallback, |(u16, _)| u16), + if body_end.is_some() { + clamped_end_u16 + } else { + fallback + }, + ); + let fence = Fence { + span: Span::from_offsets(source, start.start_offset, end_offset), + info, + body, + body_span, + }; + Some((fence, i + 1)) +} + +fn try_table(source: &str, lines: &[RawLine], start_idx: usize) -> Option<(Table, usize)> { + if start_idx + 1 >= lines.len() { + return None; + } + let header_line = &lines[start_idx]; + let delim_line = &lines[start_idx + 1]; + if !ROW_RE.is_match(&header_line.text) || !DELIM_RE.is_match(&delim_line.text) { + return None; + } + + let header_parsed = parse_row_cells(&header_line.text, header_line.start_offset, source); + let header = Row { + cells: header_parsed.cells, + cell_spans: header_parsed.cell_spans, + span: Span::from_offsets(source, header_line.start_offset, header_line.end_offset), + }; + + let mut rows = Vec::new(); + let mut i = start_idx + 2; + while i < lines.len() { + let ln = &lines[i]; + if !ROW_RE.is_match(&ln.text) { + break; + } + let parsed = parse_row_cells(&ln.text, ln.start_offset, source); + rows.push(Row { + cells: parsed.cells, + cell_spans: parsed.cell_spans, + span: Span::from_offsets(source, ln.start_offset, ln.end_offset), + }); + i += 1; + } + let end_offset = rows + .last() + .map_or(delim_line.end_offset, |r| r.span.end_offset); + let table = Table { + span: Span::from_offsets(source, header_line.start_offset, end_offset), + header, + rows, + }; + Some((table, i)) +} diff --git a/rust/var-core/src/sentences.rs b/rust/var-core/src/sentences.rs new file mode 100644 index 00000000..56ae8951 --- /dev/null +++ b/rust/var-core/src/sentences.rs @@ -0,0 +1,144 @@ +//! Splits a block of text into sentence-level spans so the matcher can try each +//! sentence independently — port of `sentences.ts` / `Sentences.java`. Operates +//! on `char`s with a running UTF-16 offset table (the Python-port approach): the +//! split decisions use BMP terminators, and emitted offsets are UTF-16. + +use crate::offsets::{java_strip, java_strip_leading, utf16_len}; + +/// A sentence: the trimmed text plus its UTF-16 offsets into the input. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Sentence { + pub text: String, + pub start_offset: usize, + pub end_offset: usize, +} + +impl Sentence { + pub fn new(text: impl Into, start_offset: usize, end_offset: usize) -> Sentence { + Sentence { + text: text.into(), + start_offset, + end_offset, + } + } +} + +const ABBREVIATIONS: [&str; 5] = ["e.g.", "i.e.", "etc.", "cf.", "vs."]; + +/// Splits `text` on `.`/`!`/`?`/newline terminators, skipping backtick code-span +/// and double-quoted interiors, and treating decimals and a fixed abbreviation +/// list as non-terminating dots. +pub fn split_sentences(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + + // Prefix table: cp_to_u16[i] = UTF-16 offset of char i; cp_to_u16[n] = total. + let mut cp_to_u16 = vec![0usize; n + 1]; + for i in 0..n { + cp_to_u16[i + 1] = cp_to_u16[i] + chars[i].len_utf16(); + } + + // Mark backtick code spans and double-quoted strings as no-split zones. + let mut skip = vec![false; n]; + let mut j = 0; + while j < n { + let c = chars[j]; + if c == '`' || c == '"' { + match find_char(&chars, j + 1, c) { + Some(close) => { + for entry in skip.iter_mut().take(close + 1).skip(j) { + *entry = true; + } + j = close; + } + None => break, + } + } + j += 1; + } + + let mut out = Vec::new(); + let mut segment_start = 0usize; + let mut i = 0; + while i < n { + if skip[i] { + i += 1; + continue; + } + let ch = chars[i]; + if ch == '\n' || ch == '.' || ch == '!' || ch == '?' { + if ch == '.' && is_inside_number_or_abbrev(&chars, i) { + i += 1; + continue; + } + let end = i + 1; + push_segment(&mut out, &chars, &cp_to_u16, segment_start, end); + i = end; + // Skip following whitespace so the next sentence starts at content. + while i < n && (chars[i] == ' ' || chars[i] == '\n') { + i += 1; + } + segment_start = i; + continue; + } + i += 1; + } + push_segment(&mut out, &chars, &cp_to_u16, segment_start, n); + out +} + +fn find_char(chars: &[char], from: usize, target: char) -> Option { + (from..chars.len()).find(|&k| chars[k] == target) +} + +fn push_segment( + out: &mut Vec, + chars: &[char], + cp_to_u16: &[usize], + start: usize, + end: usize, +) { + if end <= start { + return; + } + let raw: String = chars[start..end].iter().collect(); + let slice = java_strip(&raw); + if slice.is_empty() { + return; + } + let leading = utf16_len(&raw) - utf16_len(java_strip_leading(&raw)); + let trimmed_start = cp_to_u16[start] + leading; + let trimmed_end = trimmed_start + utf16_len(slice); + out.push(Sentence { + text: slice.to_string(), + start_offset: trimmed_start, + end_offset: trimmed_end, + }); +} + +fn is_inside_number_or_abbrev(chars: &[char], dot_pos: usize) -> bool { + let prev = if dot_pos > 0 { + chars[dot_pos - 1] + } else { + '\0' + }; + let next = if dot_pos + 1 < chars.len() { + chars[dot_pos + 1] + } else { + '\0' + }; + if prev.is_ascii_digit() && next.is_ascii_digit() { + return true; + } + // Known abbreviations ending at dot_pos+1. + for abbrev in ABBREVIATIONS { + let len = abbrev.chars().count(); + let from = (dot_pos + 1).saturating_sub(len); + let candidate: String = chars[from..dot_pos + 1].iter().collect(); + if candidate == abbrev { + return true; + } + } + // Lowercase letter following → likely intra-word. + next.is_ascii_lowercase() +} diff --git a/rust/var-core/src/span.rs b/rust/var-core/src/span.rs new file mode 100644 index 00000000..bdfa22ed --- /dev/null +++ b/rust/var-core/src/span.rs @@ -0,0 +1,57 @@ +//! Source positions/ranges anchored to UTF-16 code-unit offsets (1-based +//! line/column). Port of `var-core/src/span.ts` / `Span.java`. + +/// A source range `[start_offset, end_offset)` in UTF-16 code units, with +/// 1-based line/column at each end. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Span { + pub start_offset: usize, + pub end_offset: usize, + pub start_line: usize, + pub start_col: usize, + pub end_line: usize, + pub end_col: usize, +} + +/// A 1-based line/column position. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LineCol { + pub line: usize, + pub col: usize, +} + +impl Span { + /// Computes a [`Span`] for `[start_offset, end_offset)` (UTF-16 offsets) into `source`. + pub fn from_offsets(source: &str, start_offset: usize, end_offset: usize) -> Span { + let start = line_col(source, start_offset); + let end = line_col(source, end_offset); + Span { + start_offset, + end_offset, + start_line: start.line, + start_col: start.col, + end_line: end.line, + end_col: end.col, + } + } +} + +/// Computes the 1-based (line, col) at `offset` (a UTF-16 code-unit index) into +/// `source`. Walks per UTF-16 code unit from the start, exactly like Java's +/// `charAt` loop (so an astral character advances `col` by 2). +pub fn line_col(source: &str, offset: usize) -> LineCol { + let mut line = 1; + let mut col = 1; + for (idx, unit) in source.encode_utf16().enumerate() { + if idx >= offset { + break; + } + if unit == 0x000A { + line += 1; + col = 1; + } else { + col += 1; + } + } + LineCol { line, col } +} diff --git a/rust/var-core/src/step_kind.rs b/rust/var-core/src/step_kind.rs new file mode 100644 index 00000000..31474c9d --- /dev/null +++ b/rust/var-core/src/step_kind.rs @@ -0,0 +1,10 @@ +//! The role a step definition plays — port of `step-role.ts`'s `StepKind` / +//! `StepKind.java`. + +/// A step's role: a stimulus drives the software (arranges + acts); a sensor is +/// the read-only assertion (the only role that returns for comparison). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StepKind { + Stimulus, + Sensor, +} diff --git a/rust/var-core/src/step_role.rs b/rust/var-core/src/step_role.rs new file mode 100644 index 00000000..aebd4035 --- /dev/null +++ b/rust/var-core/src/step_role.rs @@ -0,0 +1,27 @@ +//! Guess a step's role from its neighbours in document order — port of +//! `step-role.ts` / `StepRole.java`. Purely structural (no keyword heuristics). + +use crate::step_kind::StepKind; + +/// The kinds of the steps immediately before and after the step being inferred. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Neighbours { + pub before: Vec, + pub after: Vec, +} + +impl Neighbours { + pub fn new(before: Vec, after: Vec) -> Neighbours { + Neighbours { before, after } + } +} + +/// Guesses a step's role: nothing after it → most likely the observation +/// (sensor); anything followed by other steps → most likely driving (stimulus). +pub fn infer_step_role(neighbours: &Neighbours) -> StepKind { + if neighbours.after.is_empty() { + StepKind::Sensor + } else { + StepKind::Stimulus + } +} diff --git a/rust/var-core/src/structurer.rs b/rust/var-core/src/structurer.rs new file mode 100644 index 00000000..a8dd81c1 --- /dev/null +++ b/rust/var-core/src/structurer.rs @@ -0,0 +1,101 @@ +//! Groups the flat scanner output into [`Example`]s, tracking a heading scope +//! stack — port of `structurer.ts` / `Structurer.java`. + +use crate::ast::{Block, Example, TableOrFence, VarDoc}; +use crate::offsets::utf16_slice; +use crate::span::Span; +use regex::Regex; +use std::sync::LazyLock; + +static BLANK_LINE_RE: LazyLock = LazyLock::new(|| Regex::new(r"\n\s*\n").unwrap()); + +/// Groups `blocks` (scanned from `source`) into a [`VarDoc`]. +pub fn structure(path: &str, source: &str, blocks: Vec) -> VarDoc { + let mut examples: Vec = Vec::new(); + let mut orphan_attachments: Vec = Vec::new(); + let mut scope_stack: Vec<(usize, String)> = Vec::new(); + let mut last_example_idx: Option = None; + let mut attachment_open = false; + + for block in blocks { + match &block { + Block::Heading(heading) => { + // Pop deeper-or-equal-level entries before pushing the new heading. + while scope_stack.last().is_some_and(|e| e.0 >= heading.level) { + scope_stack.pop(); + } + scope_stack.push((heading.level, heading.text.clone())); + attachment_open = false; + } + Block::Paragraph(_) | Block::ListItem(_) | Block::Blockquote(_) => { + let block_span = block.span(); + // Merge when the previous example's last block is an attachment and + // there's no blank line in the source between them. + let do_merge = attachment_open + && last_example_idx.is_some_and(|idx| { + matches!( + examples[idx].body.last(), + Some(Block::Table(_)) | Some(Block::Fence(_)) + ) && !BLANK_LINE_RE.is_match(utf16_slice( + source, + examples[idx].span.end_offset, + block_span.start_offset, + )) + }); + if do_merge { + let idx = last_example_idx.unwrap(); + examples[idx].span = Span::from_offsets( + source, + examples[idx].span.start_offset, + block_span.end_offset, + ); + examples[idx].body.push(block); + } else { + examples.push(Example { + scope_stack: scope_texts(&scope_stack), + span: block_span, + body: vec![block], + }); + last_example_idx = Some(examples.len() - 1); + attachment_open = true; + } + } + Block::Table(_) | Block::Fence(_) => { + let target = if attachment_open { + last_example_idx + } else { + None + }; + if let Some(idx) = target { + let block_span = block.span(); + examples[idx].span = Span::from_offsets( + source, + examples[idx].span.start_offset, + block_span.end_offset, + ); + examples[idx].body.push(block); + } else { + orphan_attachments.push(match block { + Block::Table(t) => TableOrFence::Table(t), + Block::Fence(f) => TableOrFence::Fence(f), + _ => unreachable!(), + }); + } + } + Block::ThematicBreak(_) => { + attachment_open = false; + } + } + } + + VarDoc { + path: path.to_string(), + source: source.to_string(), + examples, + orphan_attachments, + } +} + +fn scope_texts(scope_stack: &[(usize, String)]) -> Vec { + scope_stack.iter().map(|e| e.1.clone()).collect() +} diff --git a/rust/var-core/src/table_cells.rs b/rust/var-core/src/table_cells.rs new file mode 100644 index 00000000..22e73f28 --- /dev/null +++ b/rust/var-core/src/table_cells.rs @@ -0,0 +1,49 @@ +//! Parses a Markdown/Gherkin table row (`| a | b |`) into trimmed cells + each +//! cell's source span — port of `table-cells.ts` / `TableCells.java`. + +use crate::offsets::{java_strip, java_strip_leading, utf16_index, utf16_len}; +use crate::span::Span; + +/// Parallel, same-length trimmed cells and their source spans. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RowCells { + pub cells: Vec, + pub cell_spans: Vec, +} + +/// Splits `line_text` (a `| a | b |` row) into trimmed cells and each cell's +/// source span. `line_start_offset` is the row's UTF-16 start offset in `source`. +pub fn parse_row_cells(line_text: &str, line_start_offset: usize, source: &str) -> RowCells { + let (Some(first), Some(last)) = (line_text.find('|'), line_text.rfind('|')) else { + return RowCells { + cells: Vec::new(), + cell_spans: Vec::new(), + }; + }; + if last <= first { + return RowCells { + cells: Vec::new(), + cell_spans: Vec::new(), + }; + } + // `|` is ASCII, so `first`/`last` byte indices order identically to UTF-16. + let inner = &line_text[first + 1..last]; + let inner_start = utf16_index(line_text, first + 1); + + let mut cells = Vec::new(); + let mut cell_spans = Vec::new(); + let mut cursor = 0usize; + for seg in inner.split('|') { + let trimmed = java_strip(seg); + let leading = utf16_len(seg) - utf16_len(java_strip_leading(seg)); + let abs_start = line_start_offset + inner_start + cursor + leading; + cell_spans.push(Span::from_offsets( + source, + abs_start, + abs_start + utf16_len(trimmed), + )); + cells.push(trimmed.to_string()); + cursor += utf16_len(seg) + 1; // +1 for the '|' delimiter + } + RowCells { cells, cell_spans } +} diff --git a/rust/var-core/src/value.rs b/rust/var-core/src/value.rs new file mode 100644 index 00000000..65db1321 --- /dev/null +++ b/rust/var-core/src/value.rs @@ -0,0 +1,95 @@ +//! The dynamic value model — the Rust replacement for Java var-core's `Object` +//! with `instanceof Map`/`List`/`String` duck-typing (see `CellDiff.java`, +//! `DocStringDiff.java`, `ParamDiff.java`). One closed enum carries handler +//! arguments, handler returns, thread-through state, row objects, table rows, +//! and the conformance wire values. +//! +//! Equality is derived `PartialEq`, the analog of Java's `Objects.equals`: +//! `Int(2) != Float(2.0)` (Java `Integer(2).equals(Double(2.0))` is false), and +//! `Map` equality is order-insensitive (`BTreeMap`), matching `Map.of(...)` +//! vs `LinkedHashMap` equality in the Java tests. + +use std::collections::BTreeMap; + +/// A dynamic JSON-ish value. `BTreeMap` gives order-insensitive map equality and +/// a free recursive key-sort for canonical JSON. +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Null, + Bool(bool), + /// Integer (Java `Integer`/`Long`; `{int}` transforms here). + Int(i64), + /// Floating-point (Java `Double`); serialized as an integer when integral. + Float(f64), + String(String), + List(Vec), + Map(BTreeMap), +} + +impl Value { + /// A short type name (for `ReturnShapeError` messages, mirroring Java's + /// `getClass().getSimpleName()`). + pub fn type_name(&self) -> &'static str { + match self { + Value::Null => "null", + Value::Bool(_) => "Boolean", + Value::Int(_) => "Integer", + Value::Float(_) => "Double", + Value::String(_) => "String", + Value::List(_) => "List", + Value::Map(_) => "Map", + } + } + + /// Builds a [`Value::List`] from anything iterable of `Value`. + pub fn list(items: impl IntoIterator) -> Value { + Value::List(items.into_iter().collect()) + } + + /// Builds a [`Value::Map`] from `(String, Value)` pairs. + pub fn map(entries: impl IntoIterator) -> Value { + Value::Map(entries.into_iter().collect()) + } +} + +impl From for Value { + fn from(v: i64) -> Value { + Value::Int(v) + } +} + +impl From for Value { + fn from(v: i32) -> Value { + Value::Int(i64::from(v)) + } +} + +impl From for Value { + fn from(v: bool) -> Value { + Value::Bool(v) + } +} + +impl From for Value { + fn from(v: f64) -> Value { + Value::Float(v) + } +} + +impl From<&str> for Value { + fn from(v: &str) -> Value { + Value::String(v.to_string()) + } +} + +impl From for Value { + fn from(v: String) -> Value { + Value::String(v) + } +} + +impl From> for Value { + fn from(v: Vec) -> Value { + Value::List(v) + } +} diff --git a/rust/var-core/tests/ast_test.rs b/rust/var-core/tests/ast_test.rs new file mode 100644 index 00000000..f787bbe4 --- /dev/null +++ b/rust/var-core/tests/ast_test.rs @@ -0,0 +1,194 @@ +//! Port of `AstTest.java`. Java's defensive-copy/`UnsupportedOperationException` +//! clauses are dropped — Rust's owned `Vec` fields are immutable by construction +//! — leaving each test's constructor + accessor core. The two reflection tests +//! (`blockPermitsExactlySevenVariants` / `tableOrFencePermitsExactlyTableAndFence`) +//! are dropped: the Rust enums *are* the compiler-enforced closed sets. + +use var_core::ast::{ + Block, Blockquote, Example, Fence, Heading, ListItem, Paragraph, Row, SegmentOffset, Table, + TableOrFence, ThematicBreak, VarDoc, +}; +use var_core::span::Span; + +const SPAN: Span = Span { + start_offset: 0, + end_offset: 5, + start_line: 1, + start_col: 1, + end_line: 1, + end_col: 6, +}; + +#[test] +fn segment_offset_exposes_both_offsets() { + let offset = SegmentOffset::new(3, 7); + assert_eq!(3, offset.text_offset); + assert_eq!(7, offset.source_offset); +} + +#[test] +fn heading_exposes_level_text_and_span_and_is_a_block() { + let heading = Heading { + level: 2, + text: "Title".to_string(), + span: SPAN, + }; + assert_eq!(2, heading.level); + assert_eq!("Title", heading.text); + assert_eq!(SPAN, heading.span); + let _: Block = Block::Heading(heading); +} + +#[test] +fn paragraph_exposes_fields_and_is_a_block() { + let paragraph = Paragraph { + text: "Some text.".to_string(), + span: SPAN, + segment_map: vec![SegmentOffset::new(0, 0)], + }; + assert_eq!("Some text.", paragraph.text); + assert_eq!(SPAN, paragraph.span); + assert_eq!(1, paragraph.segment_map.len()); + let _: Block = Block::Paragraph(paragraph); +} + +#[test] +fn list_item_exposes_fields() { + let marker_span = Span { + start_offset: 0, + end_offset: 2, + start_line: 1, + start_col: 1, + end_line: 1, + end_col: 3, + }; + let list_item = ListItem { + text: "An item".to_string(), + span: SPAN, + segment_map: vec![SegmentOffset::new(0, 0)], + ordered: true, + marker_span, + }; + assert_eq!("An item", list_item.text); + assert_eq!(SPAN, list_item.span); + assert!(list_item.ordered); + assert_eq!(marker_span, list_item.marker_span); + let _: Block = Block::ListItem(list_item); +} + +#[test] +fn blockquote_exposes_fields() { + let blockquote = Blockquote { + text: "Quoted".to_string(), + span: SPAN, + segment_map: vec![SegmentOffset::new(0, 0)], + }; + assert_eq!("Quoted", blockquote.text); + assert_eq!(SPAN, blockquote.span); + let _: Block = Block::Blockquote(blockquote); +} + +#[test] +fn row_exposes_fields() { + let row = Row { + cells: vec!["a".to_string(), "b".to_string()], + cell_spans: vec![SPAN, SPAN], + span: SPAN, + }; + assert_eq!(vec!["a".to_string(), "b".to_string()], row.cells); + assert_eq!(vec![SPAN, SPAN], row.cell_spans); + assert_eq!(SPAN, row.span); +} + +#[test] +fn table_exposes_fields() { + let header = Row { + cells: vec!["h1".to_string(), "h2".to_string()], + cell_spans: vec![SPAN, SPAN], + span: SPAN, + }; + let data_row = Row { + cells: vec!["v1".to_string(), "v2".to_string()], + cell_spans: vec![SPAN, SPAN], + span: SPAN, + }; + let table = Table { + span: SPAN, + header: header.clone(), + rows: vec![data_row], + }; + assert_eq!(SPAN, table.span); + assert_eq!(header, table.header); + assert_eq!(1, table.rows.len()); + let _: Block = Block::Table(table); +} + +#[test] +fn fence_exposes_fields() { + let body_span = Span { + start_offset: 1, + end_offset: 4, + start_line: 1, + start_col: 2, + end_line: 1, + end_col: 5, + }; + let fence = Fence { + span: SPAN, + info: "json".to_string(), + body: "{}".to_string(), + body_span, + }; + assert_eq!(SPAN, fence.span); + assert_eq!("json", fence.info); + assert_eq!("{}", fence.body); + assert_eq!(body_span, fence.body_span); + let _: Block = Block::Fence(fence); +} + +#[test] +fn thematic_break_exposes_span() { + let thematic_break = ThematicBreak { span: SPAN }; + assert_eq!(SPAN, thematic_break.span); + let _: Block = Block::ThematicBreak(thematic_break); +} + +#[test] +fn example_exposes_fields() { + let example = Example { + scope_stack: vec!["Feature".to_string(), "Scenario".to_string()], + span: SPAN, + body: vec![Block::ThematicBreak(ThematicBreak { span: SPAN })], + }; + assert_eq!( + vec!["Feature".to_string(), "Scenario".to_string()], + example.scope_stack + ); + assert_eq!(SPAN, example.span); + assert_eq!(1, example.body.len()); +} + +#[test] +fn var_doc_exposes_fields() { + let example = Example { + scope_stack: vec![], + span: SPAN, + body: vec![Block::ThematicBreak(ThematicBreak { span: SPAN })], + }; + let orphan = TableOrFence::Fence(Fence { + span: SPAN, + info: String::new(), + body: String::new(), + body_span: SPAN, + }); + let doc = VarDoc { + path: "spec.md".to_string(), + source: "# Title".to_string(), + examples: vec![example], + orphan_attachments: vec![orphan], + }; + assert_eq!("spec.md", doc.path); + assert_eq!("# Title", doc.source); + assert_eq!(1, doc.examples.len()); + assert_eq!(1, doc.orphan_attachments.len()); +} diff --git a/rust/var-core/tests/canonical_json_test.rs b/rust/var-core/tests/canonical_json_test.rs new file mode 100644 index 00000000..de029d25 --- /dev/null +++ b/rust/var-core/tests/canonical_json_test.rs @@ -0,0 +1,100 @@ +//! Port of `CanonicalJsonTest.java` / `canonical-json.test.ts`. + +mod common; + +use common::vmap; +use var_core::canonical_json::canonical_stringify; +use var_core::value::Value; + +#[test] +fn sorts_keys_indents_and_trailing_newline() { + let value = vmap(vec![ + ("b", Value::Int(1)), + ( + "a", + Value::list(vec![ + Value::Int(2), + vmap(vec![("d", Value::Int(4)), ("c", Value::Int(3))]), + ]), + ), + ]); + assert_eq!( + "{\n \"a\": [\n 2,\n {\n \"c\": 3,\n \"d\": 4\n }\n ],\n \"b\": 1\n}\n", + canonical_stringify(&value) + ); +} + +#[test] +fn non_ascii_is_emitted_raw() { + let value = vmap(vec![("x", Value::from("café 😀"))]); + assert_eq!("{\n \"x\": \"café 😀\"\n}\n", canonical_stringify(&value)); +} + +#[test] +fn empty_containers_render_on_one_line() { + let value = vmap(vec![("a", Value::list(vec![])), ("b", Value::map(vec![]))]); + assert_eq!( + "{\n \"a\": [],\n \"b\": {}\n}\n", + canonical_stringify(&value) + ); +} + +#[test] +fn sorts_keys_regardless_of_input_map_iteration_order() { + let value1 = vmap(vec![ + ("z", Value::Int(1)), + ("a", Value::Int(2)), + ("m", Value::Int(3)), + ]); + let value2 = vmap(vec![ + ("m", Value::Int(3)), + ("a", Value::Int(2)), + ("z", Value::Int(1)), + ]); + let expected = "{\n \"a\": 2,\n \"m\": 3,\n \"z\": 1\n}\n"; + assert_eq!(expected, canonical_stringify(&value1)); + assert_eq!(expected, canonical_stringify(&value2)); +} + +#[test] +fn escapes_quotes_backslashes_and_control_characters() { + let value = vmap(vec![("s", Value::from("a\"b\\c\nd\te"))]); + assert_eq!( + "{\n \"s\": \"a\\\"b\\\\c\\nd\\te\"\n}\n", + canonical_stringify(&value) + ); +} + +#[test] +fn serializes_numbers_booleans_and_null() { + let value = vmap(vec![ + ("int", Value::Int(1)), + ("long", Value::Int(2)), + ("double", Value::Float(1.5)), + ("bool", Value::Bool(true)), + ("nul", Value::Null), + ]); + assert_eq!( + "{\n \"bool\": true,\n \"double\": 1.5,\n \"int\": 1,\n \"long\": 2,\n \"nul\": null\n}\n", + canonical_stringify(&value) + ); +} + +#[test] +fn serializes_nested_arrays_of_objects() { + let value = Value::list(vec![ + vmap(vec![("b", Value::Int(1))]), + vmap(vec![("a", Value::Int(2))]), + ]); + assert_eq!( + "[\n {\n \"b\": 1\n },\n {\n \"a\": 2\n }\n]\n", + canonical_stringify(&value) + ); +} + +#[test] +fn top_level_scalar_serializes_without_indent_but_with_trailing_newline() { + assert_eq!("\"hello\"\n", canonical_stringify(&Value::from("hello"))); + assert_eq!("42\n", canonical_stringify(&Value::Int(42))); + assert_eq!("null\n", canonical_stringify(&Value::Null)); +} diff --git a/rust/var-core/tests/cell_diff_test.rs b/rust/var-core/tests/cell_diff_test.rs new file mode 100644 index 00000000..4387f79e --- /dev/null +++ b/rust/var-core/tests/cell_diff_test.rs @@ -0,0 +1,227 @@ +//! Port of `CellDiffTest.java` / `cell-diff.test.ts`. + +mod common; + +use common::{vlist, vmap}; +use var_core::ast::{Block, Table}; +use var_core::cell_diff::{CellDiff, RowCheck, compare_row, compare_table}; +use var_core::error::StepError; +use var_core::offsets::utf16_slice; +use var_core::parse::parse; +use var_core::span::Span; +use var_core::value::Value; + +const SPAN: Span = Span { + start_offset: 0, + end_offset: 1, + start_line: 1, + start_col: 1, + end_line: 1, + end_col: 2, +}; + +fn checks() -> Vec { + vec![ + RowCheck::new("dice", "3, 3, 3, 4, 4", SPAN), + RowCheck::new("score", "9", SPAN), + ] +} + +const TABLE_SRC: &str = "# T\n\nthese:\n\n| before | after |\n| ------ | ----- |\n| var | VAR |\n| bdd | BDD |"; + +fn table_of(source: &str) -> Table { + let doc = parse("t.md", source); + doc.examples[0] + .body + .iter() + .find_map(|b| { + if let Block::Table(t) = b { + Some(t.clone()) + } else { + None + } + }) + .expect("no table parsed") +} + +#[test] +fn a_returned_column_that_matches_its_cell_is_ok() { + let diffs = compare_row(Some(&vmap(vec![("score", Value::Int(9))])), &checks()); + assert_eq!(vec![CellDiff::new("score", SPAN, "9", "9", true)], diffs); +} + +#[test] +fn a_returned_column_that_differs_is_not_ok_with_expected_and_actual() { + let diffs = compare_row(Some(&vmap(vec![("score", Value::Int(6))])), &checks()); + assert_eq!(vec![CellDiff::new("score", SPAN, "9", "6", false)], diffs); +} + +#[test] +fn columns_that_are_not_returned_are_inputs_not_checked() { + let diffs = compare_row(Some(&vmap(vec![("score", Value::Int(9))])), &checks()); + let cols: Vec = diffs.iter().map(|d| d.column.clone()).collect(); + assert_eq!(vec!["score".to_string()], cols); +} + +#[test] +fn a_returned_key_that_is_not_a_column_is_ignored() { + assert_eq!( + Vec::::new(), + compare_row(Some(&vmap(vec![("nope", Value::Int(1))])), &checks()) + ); +} + +#[test] +fn null_non_map_return_checks_nothing() { + assert_eq!(Vec::::new(), compare_row(None, &checks())); + assert_eq!( + Vec::::new(), + compare_row(Some(&Value::Int(42)), &checks()) + ); +} + +#[test] +fn cell_mismatch_carries_the_cells_and_is_detectable() { + let err = StepError::CellMismatch(vec![CellDiff::new("score", SPAN, "9", "6", false)]); + assert!(err.as_cell_mismatch().is_some()); + assert!( + StepError::Handler(var_core::error::HandlerError::new("x")) + .as_cell_mismatch() + .is_none() + ); + assert_eq!("6", err.as_cell_mismatch().unwrap()[0].actual); + assert!(err.message().contains("score")); +} + +#[test] +fn compare_table_array_of_arrays_full_match_all_ok() { + let table = table_of(TABLE_SRC); + let diffs = compare_table( + Some(&vlist(vec![ + vlist(vec![Value::from("var"), Value::from("VAR")]), + vlist(vec![Value::from("bdd"), Value::from("BDD")]), + ])), + &table, + ) + .unwrap(); + assert_eq!(4, diffs.len()); + assert!(diffs.iter().all(|d| d.ok)); +} + +#[test] +fn compare_table_array_of_records_full_match_all_ok() { + let table = table_of(TABLE_SRC); + let diffs = compare_table( + Some(&vlist(vec![ + vmap(vec![ + ("before", Value::from("var")), + ("after", Value::from("VAR")), + ]), + vmap(vec![ + ("before", Value::from("bdd")), + ("after", Value::from("BDD")), + ]), + ])), + &table, + ) + .unwrap(); + assert!(diffs.iter().all(|d| d.ok)); +} + +#[test] +fn compare_table_one_wrong_cell_not_ok_with_expected_actual_span() { + let table = table_of(TABLE_SRC); + let diffs = compare_table( + Some(&vlist(vec![ + vlist(vec![Value::from("var"), Value::from("WRONG")]), + vlist(vec![Value::from("bdd"), Value::from("BDD")]), + ])), + &table, + ) + .unwrap(); + let bad: Vec<&CellDiff> = diffs.iter().filter(|d| !d.ok).collect(); + assert_eq!(1, bad.len()); + assert_eq!("after", bad[0].column); + assert_eq!("VAR", bad[0].expected); + assert_eq!("WRONG", bad[0].actual); + assert_eq!( + "VAR", + utf16_slice(TABLE_SRC, bad[0].span.start_offset, bad[0].span.end_offset) + ); +} + +#[test] +fn compare_table_numbers_are_stringified_before_compare() { + let table = table_of("# T\n\nthese:\n\n| n |\n| - |\n| 7 |"); + let diffs = compare_table(Some(&vlist(vec![vlist(vec![Value::Int(7)])])), &table).unwrap(); + assert!(diffs.iter().all(|d| d.ok)); +} + +#[test] +fn compare_table_null_return_checks_nothing() { + let table = table_of(TABLE_SRC); + assert_eq!(Vec::::new(), compare_table(None, &table).unwrap()); +} + +#[test] +fn compare_table_extra_keys_on_a_returned_record_are_ignored() { + let table = table_of(TABLE_SRC); + let diffs = compare_table( + Some(&vlist(vec![ + vmap(vec![ + ("before", Value::from("var")), + ("after", Value::from("VAR")), + ("extra", Value::from("ignored")), + ]), + vmap(vec![ + ("before", Value::from("bdd")), + ("after", Value::from("BDD")), + ("note", Value::Int(123)), + ]), + ])), + &table, + ) + .unwrap(); + assert!(diffs.iter().all(|d| d.ok)); + let cols: Vec = diffs.iter().map(|d| d.column.clone()).collect(); + assert_eq!(vec!["before", "after", "before", "after"], cols); +} + +#[test] +fn compare_table_shape_type_errors_throw_return_shape() { + let table = table_of(TABLE_SRC); + let is_shape = + |r: Result, StepError>| matches!(r.unwrap_err(), StepError::ReturnShape(_)); + assert!(is_shape(compare_table(Some(&Value::from("nope")), &table))); // not a list + assert!(is_shape(compare_table( + Some(&vlist(vec![vlist(vec![ + Value::from("var"), + Value::from("VAR") + ])])), + &table + ))); // wrong row count + assert!(is_shape(compare_table( + Some(&vlist(vec![ + vlist(vec![Value::from("var")]), + vlist(vec![Value::from("bdd")]) + ])), + &table + ))); // wrong width + assert!(is_shape(compare_table( + Some(&vlist(vec![ + vmap(vec![("before", Value::from("var"))]), + vmap(vec![("before", Value::from("bdd"))]), + ])), + &table + ))); // missing key + assert!(is_shape(compare_table( + Some(&vlist(vec![ + vlist(vec![Value::from("var"), Value::from("VAR")]), + vmap(vec![ + ("before", Value::from("bdd")), + ("after", Value::from("BDD")) + ]), + ])), + &table + ))); // mixed forms +} diff --git a/rust/var-core/tests/common/mod.rs b/rust/var-core/tests/common/mod.rs new file mode 100644 index 00000000..18ed5e8d --- /dev/null +++ b/rust/var-core/tests/common/mod.rs @@ -0,0 +1,20 @@ +//! Shared helpers for the ported test suite. +#![allow(dead_code)] + +use std::collections::BTreeMap; +use var_core::value::Value; + +/// Builds a [`Value::Map`] from `(key, value)` pairs (test ergonomics for Java's +/// `Map.of(...)`). +pub fn vmap(pairs: Vec<(&str, Value)>) -> Value { + let mut m = BTreeMap::new(); + for (k, v) in pairs { + m.insert(k.to_string(), v); + } + Value::Map(m) +} + +/// Builds a [`Value::List`] (Java `List.of(...)`). +pub fn vlist(items: Vec) -> Value { + Value::List(items) +} diff --git a/rust/var-core/tests/conformance_test.rs b/rust/var-core/tests/conformance_test.rs new file mode 100644 index 00000000..9b82a0ec --- /dev/null +++ b/rust/var-core/tests/conformance_test.rs @@ -0,0 +1,137 @@ +//! Port of `ConformanceTest.java` (the var-core half): the var-doc golden gate +//! over every bundle in the shared corpus, plus the registry-projection unit +//! tests. The registry/plan/trace golden gates need per-bundle Rust step +//! fixtures and belong to a future `var` facade crate (as in Java, where they +//! live in the `var` module). + +use std::fs; +use std::path::{Path, PathBuf}; +use var_core::canonical_json::canonical_stringify; +use var_core::conformance::{parameter_type_names, to_registry_artifact, to_var_doc_artifact}; +use var_core::handler::Handler; +use var_core::parse::parse; +use var_core::registry::{add_step, create_registry, define_parameter_type}; +use var_core::step_kind::StepKind; +use var_core::value::Value; + +fn bundles_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../conformance/bundles") +} + +fn bundle_dirs() -> Vec { + let dir = bundles_dir(); + assert!( + dir.is_dir(), + "expected conformance corpus at {}", + dir.display() + ); + let mut dirs: Vec = fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.is_dir()) + .collect(); + dirs.sort(); + dirs +} + +#[test] +fn var_doc_matches_golden() { + let mut failures = Vec::new(); + for bundle in bundle_dirs() { + let name = bundle.file_name().unwrap().to_string_lossy().to_string(); + let source = fs::read_to_string(bundle.join("example.md")).unwrap(); + let doc = parse("example.md", &source); + let actual = canonical_stringify(&to_var_doc_artifact(&doc)); + let expected = fs::read_to_string(bundle.join("golden").join("var-doc.json")).unwrap(); + if actual != expected { + failures.push(name); + } + } + assert!( + failures.is_empty(), + "var-doc.json mismatch in bundles: {failures:?}" + ); +} + +#[test] +fn to_registry_artifact_lists_expressions_and_parsed_parameter_type_names() { + let r = add_step( + &create_registry(), + "I have {int} cukes", + "s.ts", + 1, + Handler::noop(), + None, + ) + .unwrap(); + let artifact = to_registry_artifact(&r); + let Value::Map(m) = &artifact else { + panic!("expected map") + }; + assert_eq!(Some(&Value::List(vec![])), m.get("parameterTypes")); + let Value::List(steps) = m.get("steps").unwrap() else { + panic!("expected steps list") + }; + assert_eq!(1, steps.len()); + let Value::Map(step0) = &steps[0] else { + panic!("expected step map") + }; + assert_eq!( + Some(&Value::from("I have {int} cukes")), + step0.get("expression") + ); + assert_eq!( + Some(&Value::List(vec![Value::from("int")])), + step0.get("parameterTypeNames") + ); +} + +#[test] +fn to_registry_artifact_reads_parameter_names_from_the_ast_ignoring_escaped_braces() { + // A naive `{...}` regex would wrongly count the escaped `\{a, b\}`. + assert_eq!( + vec!["int".to_string()], + parameter_type_names("the set \\{a, b\\} has {int} elements") + ); +} + +#[test] +fn registry_artifact_projects_custom_parameter_types() { + let r = create_registry(); + let r = define_parameter_type( + &r, + "airport", + "[A-Z]{3}", + std::rc::Rc::new(|g: &[&str]| Value::from(g[0])), + ); + let r = add_step( + &r, + "I fly to {airport}", + "airports.steps", + 1, + Handler::noop(), + Some(StepKind::Stimulus), + ) + .unwrap(); + let artifact = to_registry_artifact(&r); + let Value::Map(m) = &artifact else { + panic!("expected map") + }; + let mut pt = std::collections::BTreeMap::new(); + pt.insert("name".to_string(), Value::from("airport")); + pt.insert("regexp".to_string(), Value::from("[A-Z]{3}")); + assert_eq!( + Some(&Value::List(vec![Value::Map(pt)])), + m.get("parameterTypes") + ); + let Value::List(steps) = m.get("steps").unwrap() else { + panic!("expected steps list") + }; + let Value::Map(step0) = &steps[0] else { + panic!("expected step map") + }; + assert_eq!( + Some(&Value::List(vec![Value::from("airport")])), + step0.get("parameterTypeNames") + ); +} diff --git a/rust/var-core/tests/diagnostics_test.rs b/rust/var-core/tests/diagnostics_test.rs new file mode 100644 index 00000000..93a03708 --- /dev/null +++ b/rust/var-core/tests/diagnostics_test.rs @@ -0,0 +1,30 @@ +//! Port of `DiagnosticsTest.java`. Java's `assertSame` (identity) becomes value +//! equality — [`Span`] is a `Copy` value type. + +use var_core::diagnostics::{DiagnosticCode, Severity, ambiguous_match, error_fence_without_step}; +use var_core::span::Span; + +const SPAN: Span = Span { + start_offset: 0, + end_offset: 5, + start_line: 1, + start_col: 1, + end_line: 1, + end_col: 6, +}; + +#[test] +fn ambiguous_match_builds_an_error_severity_diagnostic_with_the_given_span() { + let d = ambiguous_match(SPAN); + assert_eq!(DiagnosticCode::AmbiguousMatch, d.code); + assert_eq!(Severity::Error, d.severity); + assert_eq!(SPAN, d.span); +} + +#[test] +fn error_fence_without_step_builds_an_error_severity_diagnostic_with_the_given_span() { + let d = error_fence_without_step(SPAN); + assert_eq!(DiagnosticCode::ErrorFenceWithoutStep, d.code); + assert_eq!(Severity::Error, d.severity); + assert_eq!(SPAN, d.span); +} diff --git a/rust/var-core/tests/doc_string_diff_test.rs b/rust/var-core/tests/doc_string_diff_test.rs new file mode 100644 index 00000000..13a9a6e8 --- /dev/null +++ b/rust/var-core/tests/doc_string_diff_test.rs @@ -0,0 +1,56 @@ +//! Port of `DocStringDiffTest.java` / `doc-string-diff.test.ts`. + +use var_core::doc_string_diff::{DocStringDiff, compare_doc_string}; +use var_core::error::StepError; +use var_core::span::Span; +use var_core::value::Value; + +const SPAN: Span = Span { + start_offset: 0, + end_offset: 6, + start_line: 1, + start_col: 1, + end_line: 1, + end_col: 6, +}; + +#[test] +fn compare_doc_string_equal_content_returns_null() { + assert_eq!( + None, + compare_doc_string(Some(&Value::from("hello\n")), "hello\n", SPAN).unwrap() + ); +} + +#[test] +fn compare_doc_string_null_return_returns_null_asserted_nothing() { + assert_eq!(None, compare_doc_string(None, "hello\n", SPAN).unwrap()); +} + +#[test] +fn compare_doc_string_different_content_returns_diff_with_span_expected_actual() { + assert_eq!( + Some(DocStringDiff::new(SPAN, "hello\n", "bye\n")), + compare_doc_string(Some(&Value::from("bye\n")), "hello\n", SPAN).unwrap() + ); +} + +#[test] +fn compare_doc_string_a_non_string_return_throws_return_shape() { + assert!(matches!( + compare_doc_string(Some(&Value::Int(42)), "hello\n", SPAN).unwrap_err(), + StepError::ReturnShape(_) + )); +} + +#[test] +fn doc_string_mismatch_carries_the_diff_and_is_detectable() { + let err = StepError::DocStringMismatch(DocStringDiff::new(SPAN, "hello\n", "bye\n")); + assert!(err.as_doc_string_mismatch().is_some()); + assert!( + StepError::Handler(var_core::error::HandlerError::new("x")) + .as_doc_string_mismatch() + .is_none() + ); + assert_eq!("bye\n", err.as_doc_string_mismatch().unwrap().actual); +} diff --git a/rust/var-core/tests/drift_test.rs b/rust/var-core/tests/drift_test.rs new file mode 100644 index 00000000..989ff448 --- /dev/null +++ b/rust/var-core/tests/drift_test.rs @@ -0,0 +1,347 @@ +//! Port of `DriftTest.java` / `drift.test.ts` (unit-gated; drift has no golden). + +use std::collections::BTreeMap; +use var_core::drift::{ + BaselineExample, BaselineStore, Drifted, SpecBaseline, VarLock, derive_spec_baseline, + detect_drift, live_examples, message, parse_var_lock, reconcile_drift, stringify_var_lock, +}; +use var_core::handler::Handler; +use var_core::hash::hash_source; +use var_core::parse::parse; +use var_core::plan::{ExecutionPlan, plan}; +use var_core::registry::{Registry, add_step, create_registry}; +use var_core::span::Span; +use var_core::step_kind::StepKind; + +fn reg(with_step: bool) -> Registry { + let r = create_registry(); + if with_step { + add_step( + &r, + "I withdraw {int}", + "steps.ts", + 1, + Handler::noop(), + Some(StepKind::Stimulus), + ) + .unwrap() + } else { + r + } +} + +fn roman_reg(with_step: bool) -> Registry { + let r = create_registry(); + if with_step { + add_step( + &r, + "a decimal and a roman number", + "steps.ts", + 1, + Handler::noop(), + Some(StepKind::Sensor), + ) + .unwrap() + } else { + r + } +} + +fn plan_of(source: &str, r: &Registry) -> ExecutionPlan { + plan(&parse("w.md", source), r) +} + +fn bare(drifts: &[Drifted]) -> Vec { + drifts + .iter() + .map(|d| format!("{}@{}", d.name, d.line)) + .collect() +} + +#[derive(Default)] +struct MemoryStore { + contents: Option, +} + +impl BaselineStore for MemoryStore { + fn read(&self) -> Option { + self.contents.clone() + } + fn write(&mut self, c: &str) { + self.contents = Some(c.to_string()); + } +} + +fn library_lock() -> VarLock { + let mut specs = BTreeMap::new(); + specs.insert( + "library.md".to_string(), + SpecBaseline { + source_hash: "fnv1a:1a2b3c4d".to_string(), + examples: vec![BaselineExample { + name: "I check out".to_string(), + line: 7, + }], + }, + ); + VarLock { version: 1, specs } +} + +#[test] +fn hash_matches_the_typescript_vectors() { + assert_eq!("fnv1a:4f9f2cab", hash_source("hello")); + assert_eq!("fnv1a:1a47e90b", hash_source("abc")); + assert_eq!("fnv1a:4eace75e", hash_source("# Title\n")); +} + +#[test] +fn live_examples_records_one_entry_per_example_producing_paragraph() { + let var_doc = parse("w.md", "I withdraw 40."); + assert_eq!( + vec![BaselineExample { + name: "I withdraw 40".to_string(), + line: 1 + }], + live_examples(&var_doc, &plan_of("I withdraw 40.", ®(true))) + ); +} + +#[test] +fn derive_spec_baseline_carries_the_fingerprint() { + let source = "I withdraw 40."; + let var_doc = parse("w.md", source); + let baseline = derive_spec_baseline(source, &var_doc, &plan_of(source, ®(true))); + assert_eq!(hash_source(source), baseline.source_hash); + assert_eq!( + vec![BaselineExample { + name: "I withdraw 40".to_string(), + line: 1 + }], + baseline.examples + ); +} + +#[test] +fn no_baseline_means_no_drift() { + let var_doc = parse("w.md", "I withdraw 40."); + assert!(detect_drift(None, &var_doc, &plan_of("I withdraw 40.", ®(true))).is_empty()); +} + +#[test] +fn a_renamed_step_drifts() { + let source = "I withdraw 40."; + let var_doc = parse("w.md", source); + let baseline = derive_spec_baseline(source, &var_doc, &plan_of(source, ®(true))); + assert_eq!( + vec!["I withdraw 40@1".to_string()], + bare(&detect_drift( + Some(&baseline), + &var_doc, + &plan_of(source, ®(false)) + )) + ); +} + +#[test] +fn an_in_place_typo_drifts() { + let before = "I withdraw 40."; + let baseline = + derive_spec_baseline(before, &parse("w.md", before), &plan_of(before, ®(true))); + let after = "I withdrraw 40."; + let after_doc = parse("w.md", after); + assert_eq!( + vec!["I withdraw 40@1".to_string()], + bare(&detect_drift( + Some(&baseline), + &after_doc, + &plan_of(after, ®(true)) + )) + ); +} + +#[test] +fn a_deleted_paragraph_is_not_drift() { + let before = "I withdraw 40."; + let baseline = + derive_spec_baseline(before, &parse("w.md", before), &plan_of(before, ®(true))); + let after_doc = parse("w.md", ""); + assert!(detect_drift(Some(&baseline), &after_doc, &plan_of("", ®(true))).is_empty()); +} + +#[test] +fn moving_and_rewording_a_still_matching_example_does_not_drift() { + let before = "I withdraw 40.\n\nI withdraw 10."; + let baseline = + derive_spec_baseline(before, &parse("w.md", before), &plan_of(before, ®(true))); + let after = "I withdraw 11.\n\nI withdraw 40."; + assert!( + detect_drift( + Some(&baseline), + &parse("w.md", after), + &plan_of(after, ®(true)) + ) + .is_empty() + ); +} + +#[test] +fn move_reword_prose_on_old_line_does_not_false_positive() { + let before = "I withdraw 40."; + let baseline = + derive_spec_baseline(before, &parse("w.md", before), &plan_of(before, ®(true))); + let after = "Just some notes.\n\nI withdraw 41."; + assert!( + detect_drift( + Some(&baseline), + &parse("w.md", after), + &plan_of(after, ®(true)) + ) + .is_empty() + ); +} + +#[test] +fn a_paragraph_rewritten_past_recognition_is_not_drift() { + let before = "I withdraw 40."; + let baseline = + derive_spec_baseline(before, &parse("w.md", before), &plan_of(before, ®(true))); + let after = "The branch closed years ago."; + assert!( + detect_drift( + Some(&baseline), + &parse("w.md", after), + &plan_of(after, ®(true)) + ) + .is_empty() + ); +} + +const ROMAN: &str = "Each row gives a decimal and a roman number:\n\n| decimal | roman |\n| ------: | :---- |\n| 3 | III |\n| 9 | IX |\n"; + +#[test] +fn header_bound_table_records_its_binding_paragraph_once() { + let var_doc = parse("r.md", ROMAN); + assert_eq!( + vec![BaselineExample { + name: "Each row gives a decimal and a roman number:".to_string(), + line: 1 + }], + live_examples(&var_doc, &plan(&var_doc, &roman_reg(true))) + ); +} + +#[test] +fn a_header_bound_binding_paragraph_that_stops_matching_drifts() { + let var_doc = parse("r.md", ROMAN); + let baseline = derive_spec_baseline(ROMAN, &var_doc, &plan(&var_doc, &roman_reg(true))); + assert_eq!( + vec!["Each row gives a decimal and a roman number:@1".to_string()], + bare(&detect_drift( + Some(&baseline), + &var_doc, + &plan(&var_doc, &roman_reg(false)) + )) + ); +} + +#[test] +fn reconcile_records_then_reports_and_preserves_on_drift() { + let source = "I withdraw 40."; + let var_doc = parse("w.md", source); + let mut store = MemoryStore::default(); + assert!( + reconcile_drift( + &mut store, + "w.md", + source, + &var_doc, + &plan_of(source, ®(true)), + false + ) + .is_empty() + ); + let before_lock = store.contents.clone(); + let drifts = reconcile_drift( + &mut store, + "w.md", + source, + &var_doc, + &plan_of(source, ®(false)), + false, + ); + assert_eq!(vec!["I withdraw 40@1".to_string()], bare(&drifts)); + assert_eq!(before_lock, store.contents); // preserved while unacknowledged +} + +#[test] +fn reconcile_update_mode_accepts_drift() { + let source = "I withdraw 40."; + let var_doc = parse("w.md", source); + let mut store = MemoryStore::default(); + reconcile_drift( + &mut store, + "w.md", + source, + &var_doc, + &plan_of(source, ®(true)), + false, + ); + assert!( + reconcile_drift( + &mut store, + "w.md", + source, + &var_doc, + &plan_of(source, ®(false)), + true + ) + .is_empty() + ); + let lock = parse_var_lock(store.contents.as_ref().unwrap()).unwrap(); + assert_eq!( + Vec::::new(), + lock.specs.get("w.md").unwrap().examples + ); +} + +const EXPECTED_LOCK: &str = "{\n \"version\": 1,\n \"specs\": {\n \"library.md\": {\n \"sourceHash\": \"fnv1a:1a2b3c4d\",\n \"examples\": [\n {\n \"name\": \"I check out\",\n \"line\": 7\n }\n ]\n }\n }\n}\n"; + +#[test] +fn stringify_matches_the_typescript_serializer_byte_for_byte() { + assert_eq!(EXPECTED_LOCK, stringify_var_lock(&library_lock())); +} + +#[test] +fn parse_round_trips_a_valid_lock() { + let parsed = parse_var_lock(&stringify_var_lock(&library_lock())).unwrap(); + assert_eq!( + "fnv1a:1a2b3c4d", + parsed.specs.get("library.md").unwrap().source_hash + ); + assert_eq!( + vec![BaselineExample { + name: "I check out".to_string(), + line: 7 + }], + parsed.specs.get("library.md").unwrap().examples + ); +} + +#[test] +fn parse_rejects_malformed_input() { + assert!(parse_var_lock("not json").is_none()); + assert!(parse_var_lock("{}").is_none()); + assert!(parse_var_lock("{\"version\":2,\"specs\":{}}").is_none()); + assert!(parse_var_lock("{\"version\":1,\"specs\":{\"a.md\":{\"examples\":[]}}}").is_none()); +} + +#[test] +fn drift_message_names_the_paragraph() { + let d = Drifted { + name: "I withdraw 40".to_string(), + line: 1, + span: Span::from_offsets("I withdraw 40.", 0, 13), + }; + assert!(message(&d).contains("I withdraw 40")); + assert!(!message(&d).trim().is_empty()); +} diff --git a/rust/var-core/tests/execute_test.rs b/rust/var-core/tests/execute_test.rs new file mode 100644 index 00000000..9d598d1d --- /dev/null +++ b/rust/var-core/tests/execute_test.rs @@ -0,0 +1,1057 @@ +//! Port of `ExecuteTest.java` (+ `execute-state`/`execute-roles`). Adaptations +//! (see the plan): the ad-hoc `Fn0/Fn1/Fn2` functional interfaces become +//! [`Handler::sync0/1/2`]; a Java `throw RuntimeException` becomes `Err(_)` +//! (Rust's explicit failure channel), a thrown `AssertionError` becomes `panic!` +//! (the assertion channel) — the executor must handle both identically; a +//! `CompletableFuture` return becomes [`Handler::async0`] driven by the executor's +//! `block_on`. + +mod common; + +use common::vmap; +use std::cell::RefCell; +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; +use std::task::{Context, Poll}; +use var_core::diagnostics::{Diagnostic, DiagnosticCode}; +use var_core::error::{HandlerError, StepError}; +use var_core::execute::{ + ExecutePorts, StepObservation, StepOutcome, collect_examples, execute_plan, +}; +use var_core::failure::to_failure; +use var_core::handler::{Handler, HandlerReturn}; +use var_core::offsets::utf16_slice; +use var_core::parse::parse; +use var_core::plan::{ExecutionPlan, plan}; +use var_core::registry::{Registry, add_step, create_registry}; +use var_core::step_kind::StepKind; +use var_core::value::Value; + +fn int_of(v: &Value) -> i64 { + match v { + Value::Int(i) => *i, + _ => panic!("not an int: {v:?}"), + } +} + +fn reg( + expression: &str, + file: &str, + line: usize, + handler: Handler, + kind: Option, +) -> Registry { + add_step(&create_registry(), expression, file, line, handler, kind).unwrap() +} + +fn plan_of(source: &str, registry: &Registry) -> ExecutionPlan { + plan(&parse("x.md", source), registry) +} + +/// A future that yields `Pending` exactly once (exercising the executor's +/// `block_on` park/resume) then completes — the analog of `supplyAsync`. +struct YieldOnce { + value: Option, + yielded: bool, +} + +impl Future for YieldOnce { + type Output = HandlerReturn; + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + if !this.yielded { + this.yielded = true; + cx.waker().wake_by_ref(); + return Poll::Pending; + } + Poll::Ready(this.value.take().unwrap()) + } +} + +// ----------------------------------------------------------------------------- +// collectExamples: naming, ordering, diagnostics +// ----------------------------------------------------------------------------- + +#[test] +fn collect_examples_returns_one_queued_example_per_planned_example_in_document_order() { + let r = reg( + "I have {int} cukes", + "s.ts", + 1, + Handler::sync1(|_s, _n| Ok(None)), + Some(StepKind::Stimulus), + ); + let p = plan_of("# A\n\nI have 5 cukes\n\n# B\n\nI have 9 cukes", &r); + let ports = ExecutePorts::silent(); + let queued = collect_examples(&p, &ports); + let names: Vec = queued.iter().map(|q| q.name.clone()).collect(); + assert_eq!( + vec!["I have 5 cukes".to_string(), "I have 9 cukes".to_string()], + names + ); +} + +#[test] +fn collect_examples_reports_diagnostics_via_reporter() { + let r = create_registry(); + let r = add_step( + &r, + "I have {int} cukes", + "a.ts", + 1, + Handler::noop(), + Some(StepKind::Stimulus), + ) + .unwrap(); + let r = add_step( + &r, + "I have 5 cukes", + "a.ts", + 2, + Handler::noop(), + Some(StepKind::Stimulus), + ) + .unwrap(); + let p = plan_of("# M\n\nI have 5 cukes", &r); + let got: Rc>> = Rc::new(RefCell::new(Vec::new())); + let got2 = got.clone(); + let ports = ExecutePorts::new(Box::new(move |d: &Diagnostic| got2.borrow_mut().push(*d))); + collect_examples(&p, &ports); + assert_eq!(1, got.borrow().len()); + assert_eq!(DiagnosticCode::AmbiguousMatch, got.borrow()[0].code); +} + +// ----------------------------------------------------------------------------- +// Full-replacement state evolution + inline sensor comparison +// ----------------------------------------------------------------------------- + +#[test] +fn threads_full_replacement_state_and_sensor_compares_return_against_last_captured_arg() { + let seen: Rc>> = Rc::new(RefCell::new(Vec::new())); + let seen2 = seen.clone(); + let r = create_registry(); + let r = add_step( + &r, + "I add {int}", + "s.ts", + 1, + Handler::sync1(|state, n| { + let s = match state { + Value::Null => 0, + Value::Int(i) => i, + _ => 0, + }; + Ok(Some(Value::Int(s + int_of(&n)))) + }), + Some(StepKind::Stimulus), + ) + .unwrap(); + let r = add_step( + &r, + "the total is {int}", + "s.ts", + 2, + Handler::sync1(move |state, expected| { + seen2.borrow_mut().push(expected); + Ok(Some(state)) + }), + Some(StepKind::Sensor), + ) + .unwrap(); + let p = plan_of("# Adding\n\nI add 5. I add 3. the total is 8.", &r); + let ports = ExecutePorts { + reporter: Box::new(|_| {}), + create_context: Some(Box::new(|_| Value::Int(0))), + observer: None, + }; + let queued = collect_examples(&p, &ports); + assert_eq!(1, queued.len()); + assert!(queued[0].run().is_ok()); + assert_eq!(vec![Value::Int(8)], *seen.borrow()); +} + +#[test] +fn an_inline_sensor_mismatch_throws_cell_mismatch_at_its_param_span() { + let r = reg( + "the answer is {int}", + "s.ts", + 1, + Handler::sync1(|_s, _e| Ok(Some(Value::Int(41)))), + Some(StepKind::Sensor), + ); + let p = plan_of("# Q\n\nthe answer is 42.", &r); + let ports = ExecutePorts::silent(); + let err = collect_examples(&p, &ports)[0].run().unwrap_err(); + let StepError::CellMismatch(cells) = &err.error else { + panic!("expected cell mismatch") + }; + assert_eq!(1, cells.len()); + assert_eq!("42", cells[0].expected); + assert_eq!("41", cells[0].actual); + let source = &p.var_doc.source; + assert_eq!( + "42", + utf16_slice(source, cells[0].span.start_offset, cells[0].span.end_offset) + ); +} + +#[test] +fn a_sensor_with_two_parameters_returns_a_positional_list_compared_against_every_capture() { + let r = reg( + "I should have {int} cukes in my {word} belly", + "s.ts", + 1, + Handler::sync2(|_s, count, name| Ok(Some(Value::list(vec![count, name])))), + Some(StepKind::Sensor), + ); + let p = plan_of("# X\n\nI should have 3 cukes in my big belly", &r); + let ports = ExecutePorts::silent(); + assert!(collect_examples(&p, &ports)[0].run().is_ok()); +} + +#[test] +fn a_sensor_with_two_parameters_returning_a_non_list_throws_return_shape() { + let r = reg( + "I should have {int} cukes in my {word} belly", + "s.ts", + 1, + Handler::sync2(|_s, _c, _n| Ok(Some(Value::Int(3)))), + Some(StepKind::Sensor), + ); + let p = plan_of("# X\n\nI should have 3 cukes in my big belly", &r); + let ports = ExecutePorts::silent(); + assert!(matches!( + collect_examples(&p, &ports)[0].run().unwrap_err().error, + StepError::ReturnShape(_) + )); +} + +#[test] +fn a_sensor_with_two_parameters_returning_the_wrong_length_throws_return_shape() { + let r = reg( + "I should have {int} cukes in my {word} belly", + "s.ts", + 1, + Handler::sync2(|_s, _c, _n| Ok(Some(Value::list(vec![Value::Int(3)])))), + Some(StepKind::Sensor), + ); + let p = plan_of("# X\n\nI should have 3 cukes in my big belly", &r); + let ports = ExecutePorts::silent(); + assert!(matches!( + collect_examples(&p, &ports)[0].run().unwrap_err().error, + StepError::ReturnShape(_) + )); +} + +#[test] +fn a_single_parameter_sensor_wrapping_its_value_in_a_list_fails_the_comparison() { + let r = reg( + "the answer is {int}", + "s.ts", + 1, + Handler::sync1(|_s, _e| Ok(Some(Value::list(vec![Value::Int(42)])))), + Some(StepKind::Sensor), + ); + let p = plan_of("# Q\n\nthe answer is 42.", &r); + let ports = ExecutePorts::silent(); + assert!(matches!( + collect_examples(&p, &ports)[0].run().unwrap_err().error, + StepError::CellMismatch(_) + )); +} + +#[test] +fn a_zero_slot_sensor_returning_a_value_throws_return_shape() { + let r = reg( + "the alarm fired", + "s.ts", + 1, + Handler::sync0(|_s| Ok(Some(Value::Bool(true)))), + Some(StepKind::Sensor), + ); + let p = plan_of("# X\n\nthe alarm fired", &r); + let ports = ExecutePorts::silent(); + assert!(matches!( + collect_examples(&p, &ports)[0].run().unwrap_err().error, + StepError::ReturnShape(_) + )); +} + +#[test] +fn a_zero_slot_sensor_returning_null_passes() { + let r = reg( + "the alarm fired", + "s.ts", + 1, + Handler::sync0(|_s| Ok(None)), + Some(StepKind::Sensor), + ); + let p = plan_of("# X\n\nthe alarm fired", &r); + let ports = ExecutePorts::silent(); + assert!(collect_examples(&p, &ports)[0].run().is_ok()); +} + +// ----------------------------------------------------------------------------- +// createContext: once per (example, file), reused across steps +// ----------------------------------------------------------------------------- + +#[test] +fn create_context_is_called_fresh_once_per_example() { + let seen: Rc>> = Rc::new(RefCell::new(Vec::new())); + let seen2 = seen.clone(); + let r = reg( + "I record ctx", + "s.ts", + 1, + Handler::sync0(move |state| { + seen2.borrow_mut().push(state.clone()); + Ok(Some(state)) + }), + Some(StepKind::Stimulus), + ); + let p = plan_of("# A\n\nI record ctx\n\n# B\n\nI record ctx", &r); + let calls = Rc::new(RefCell::new(0)); + let calls2 = calls.clone(); + let create = Box::new(move |_file: &str| { + *calls2.borrow_mut() += 1; + Value::from(format!("init{}", calls2.borrow())) + }); + let ports = ExecutePorts { + reporter: Box::new(|_| {}), + create_context: Some(create), + observer: None, + }; + for q in collect_examples(&p, &ports) { + q.run().unwrap(); + } + assert_eq!(2, *calls.borrow()); + assert_eq!( + vec![Value::from("init1"), Value::from("init2")], + *seen.borrow() + ); +} + +#[test] +fn state_is_threaded_across_steps_sharing_the_same_file_no_new_context_per_step() { + let seen: Rc>> = Rc::new(RefCell::new(Vec::new())); + let seen2 = seen.clone(); + let r = create_registry(); + let r = add_step( + &r, + "I seed", + "s.ts", + 1, + Handler::sync0(|_s| Ok(Some(Value::from("seeded")))), + Some(StepKind::Stimulus), + ) + .unwrap(); + let r = add_step( + &r, + "I record ctx", + "s.ts", + 2, + Handler::sync0(move |state| { + seen2.borrow_mut().push(state.clone()); + Ok(Some(state)) + }), + Some(StepKind::Stimulus), + ) + .unwrap(); + let p = plan_of("# A\n\nI seed\nI record ctx", &r); + let calls = Rc::new(RefCell::new(0)); + let calls2 = calls.clone(); + let create = Box::new(move |_file: &str| { + *calls2.borrow_mut() += 1; + Value::from("unseeded") + }); + let ports = ExecutePorts { + reporter: Box::new(|_| {}), + create_context: Some(create), + observer: None, + }; + collect_examples(&p, &ports)[0].run().unwrap(); + assert_eq!(1, *calls.borrow()); + assert_eq!(vec![Value::from("seeded")], *seen.borrow()); +} + +// ----------------------------------------------------------------------------- +// Trailing data table / doc string as the last handler argument +// ----------------------------------------------------------------------------- + +#[test] +fn a_data_table_attached_to_a_context_step_is_appended_as_the_last_handler_argument() { + let captured: Rc>> = Rc::new(RefCell::new(Vec::new())); + let captured2 = captured.clone(); + let r = reg( + "these books exist:", + "s.ts", + 1, + Handler::sync1(move |state, table| { + captured2.borrow_mut().push(table); + Ok(Some(state)) + }), + Some(StepKind::Stimulus), + ); + let source = "# Library\n\nthese books exist:\n\n| title | author |\n|--------|---------|\n| Lolita | Nabokov |\n| Anna | Tolstoy |"; + let p = plan_of(source, &r); + let ports = ExecutePorts::silent(); + collect_examples(&p, &ports)[0].run().unwrap(); + assert_eq!(1, captured.borrow().len()); + assert_eq!( + Value::list(vec![ + Value::list(vec![Value::from("title"), Value::from("author")]), + Value::list(vec![Value::from("Lolita"), Value::from("Nabokov")]), + Value::list(vec![Value::from("Anna"), Value::from("Tolstoy")]), + ]), + captured.borrow()[0] + ); +} + +#[test] +fn a_doc_string_attached_to_a_context_step_is_appended_as_the_last_handler_argument() { + let captured: Rc>> = Rc::new(RefCell::new(Vec::new())); + let captured2 = captured.clone(); + let r = reg( + "the receipt is:", + "s.ts", + 1, + Handler::sync1(move |state, body| { + captured2.borrow_mut().push(body); + Ok(Some(state)) + }), + Some(StepKind::Stimulus), + ); + let source = "# Library\n\nthe receipt is:\n\n```json\n{\"ok\": true}\n```"; + let p = plan_of(source, &r); + let ports = ExecutePorts::silent(); + collect_examples(&p, &ports)[0].run().unwrap(); + assert_eq!(vec![Value::from("{\"ok\": true}\n")], *captured.borrow()); +} + +// ----------------------------------------------------------------------------- +// Header-bound table: one example per row, row map as the trailing sensor arg +// ----------------------------------------------------------------------------- + +const YAHTZEE: &str = "# Yahtzee\n\neach row lists the dice, the category and the score:\n\n| dice | category | score |\n| ------------- | ---------- | ----- |\n| 3, 3, 3, 4, 4 | full house | 17 |\n| 3, 3, 3, 3, 3 | Yahtzee | 50 |"; + +#[test] +fn header_bound_table_runs_once_per_row_named_by_its_cells_passing_the_row_map() { + let rows: Rc>> = Rc::new(RefCell::new(Vec::new())); + let rows2 = rows.clone(); + let r = reg( + "each row lists the dice, the category and the score", + "s.ts", + 1, + Handler::sync1(move |_state, row| { + rows2.borrow_mut().push(row.clone()); + Ok(Some(row)) + }), + Some(StepKind::Sensor), + ); + let p = plan_of(YAHTZEE, &r); + let ports = ExecutePorts::silent(); + let queued = collect_examples(&p, &ports); + let names: Vec = queued.iter().map(|q| q.name.clone()).collect(); + assert_eq!( + vec![ + "3, 3, 3, 4, 4 / full house / 17".to_string(), + "3, 3, 3, 3, 3 / Yahtzee / 50".to_string() + ], + names + ); + for q in &queued { + q.run().unwrap(); + } + assert_eq!( + vec![ + vmap(vec![ + ("dice", Value::from("3, 3, 3, 4, 4")), + ("category", Value::from("full house")), + ("score", Value::from("17")) + ]), + vmap(vec![ + ("dice", Value::from("3, 3, 3, 3, 3")), + ("category", Value::from("Yahtzee")), + ("score", Value::from("50")) + ]), + ], + *rows.borrow() + ); +} + +#[test] +fn a_mismatching_header_bound_row_throws_cell_mismatch_at_the_cell_span() { + let r = reg( + "each row lists the dice, the category and the score", + "s.ts", + 1, + Handler::sync1(|_state, row| { + let m = match &row { + Value::Map(m) => m.clone(), + _ => panic!("expected map"), + }; + let get = |k: &str| m.get(k).cloned().unwrap_or(Value::Null); + let score = match m.get("score") { + Some(Value::String(s)) => s.clone(), + _ => String::new(), + }; + let score_out = if score == "50" { + "999".to_string() + } else { + score + }; + Ok(Some(vmap(vec![ + ("dice", get("dice")), + ("category", get("category")), + ("score", Value::from(score_out)), + ]))) + }), + Some(StepKind::Sensor), + ); + let p = plan_of(YAHTZEE, &r); + let ports = ExecutePorts::silent(); + let queued = collect_examples(&p, &ports); + assert!(queued[0].run().is_ok()); // 17 -> unchanged -> passes + let err = queued[1].run().unwrap_err(); + let StepError::CellMismatch(cells) = &err.error else { + panic!("expected cell mismatch") + }; + assert_eq!(1, cells.len()); + assert_eq!("score", cells[0].column); + assert_eq!("50", cells[0].expected); + assert_eq!("999", cells[0].actual); + let source = &p.var_doc.source; + assert_eq!( + "50", + utf16_slice(source, cells[0].span.start_offset, cells[0].span.end_offset) + ); +} + +// ----------------------------------------------------------------------------- +// Whole-table sensor (0 captures, table attached) +// ----------------------------------------------------------------------------- + +const UPPERCASE_TABLE: &str = "# T\n\nuppercase each one:\n\n| before | after |\n| ------ | ----- |\n| var | VAR |\n| bdd | BDD |"; + +#[test] +fn a_whole_table_sensor_returning_a_mismatched_table_throws_cell_mismatch_at_the_cell_span() { + let r = reg( + "uppercase each one", + "s.ts", + 1, + Handler::sync1(|_s, _t| { + Ok(Some(Value::list(vec![ + Value::list(vec![Value::from("var"), Value::from("WRONG")]), + Value::list(vec![Value::from("bdd"), Value::from("BDD")]), + ]))) + }), + Some(StepKind::Sensor), + ); + let p = plan_of(UPPERCASE_TABLE, &r); + let ports = ExecutePorts::silent(); + let err = collect_examples(&p, &ports)[0].run().unwrap_err(); + let StepError::CellMismatch(cells) = &err.error else { + panic!("expected cell mismatch") + }; + assert_eq!(1, cells.len()); + assert_eq!("VAR", cells[0].expected); + assert_eq!("WRONG", cells[0].actual); +} + +#[test] +fn a_whole_table_sensor_returning_a_matching_table_passes() { + let r = reg( + "uppercase each one", + "s.ts", + 1, + Handler::sync1(|_s, _t| { + Ok(Some(Value::list(vec![ + vmap(vec![ + ("before", Value::from("var")), + ("after", Value::from("VAR")), + ]), + vmap(vec![ + ("before", Value::from("bdd")), + ("after", Value::from("BDD")), + ]), + ]))) + }), + Some(StepKind::Sensor), + ); + let p = plan_of(UPPERCASE_TABLE, &r); + let ports = ExecutePorts::silent(); + assert!(collect_examples(&p, &ports)[0].run().is_ok()); +} + +#[test] +fn a_whole_table_sensor_returning_the_wrong_type_throws_return_shape() { + let r = reg( + "uppercase each one", + "s.ts", + 1, + Handler::sync1(|_s, _t| Ok(Some(Value::from("not a table")))), + Some(StepKind::Sensor), + ); + let p = plan_of(UPPERCASE_TABLE, &r); + let ports = ExecutePorts::silent(); + assert!(matches!( + collect_examples(&p, &ports)[0].run().unwrap_err().error, + StepError::ReturnShape(_) + )); +} + +// ----------------------------------------------------------------------------- +// Doc-string sensor (0 captures, doc string attached) +// ----------------------------------------------------------------------------- + +const GREETING_DOC: &str = "# T\n\nthe greeting is:\n\n```text\nHello, world!\n```"; + +#[test] +fn a_doc_string_sensor_returning_a_different_string_throws_doc_string_mismatch_at_the_body_span() { + let r = reg( + "the greeting is", + "s.ts", + 1, + Handler::sync1(|_s, _b| Ok(Some(Value::from("Goodbye!\n")))), + Some(StepKind::Sensor), + ); + let p = plan_of(GREETING_DOC, &r); + let ports = ExecutePorts::silent(); + let err = collect_examples(&p, &ports)[0].run().unwrap_err(); + let StepError::DocStringMismatch(diff) = &err.error else { + panic!("expected doc string mismatch") + }; + assert_eq!("Hello, world!\n", diff.expected); + assert_eq!("Goodbye!\n", diff.actual); +} + +#[test] +fn a_doc_string_sensor_returning_the_exact_body_passes() { + let r = reg( + "the greeting is", + "s.ts", + 1, + Handler::sync1(|_s, body| Ok(Some(body))), + Some(StepKind::Sensor), + ); + let p = plan_of(GREETING_DOC, &r); + let ports = ExecutePorts::silent(); + assert!(collect_examples(&p, &ports)[0].run().is_ok()); +} + +// ----------------------------------------------------------------------------- +// error-fence convention: inverts outcome +// ----------------------------------------------------------------------------- + +#[test] +fn error_fence_example_where_the_step_throws_a_matching_message_passes() { + let r = reg( + "I divide {int} by {int}", + "s.ts", + 1, + Handler::sync2(|state, _a, b| { + if int_of(&b) == 0 { + Err(HandlerError::new("division by zero")) + } else { + Ok(Some(state)) + } + }), + Some(StepKind::Stimulus), + ); + let src = "# D\n\nI divide 1 by 0.\n\n```error\ndivision by zero\n```\n"; + let p = plan_of(src, &r); + let ports = ExecutePorts::silent(); + assert!(collect_examples(&p, &ports)[0].run().is_ok()); +} + +#[test] +fn error_fence_example_where_no_throw_throws_unexpected_pass() { + let r = reg( + "I divide {int} by {int}", + "s.ts", + 1, + Handler::sync2(|state, _a, _b| Ok(Some(state))), + Some(StepKind::Stimulus), + ); + let src = "# D\n\nI divide 1 by 1.\n\n```error\n```\n"; + let p = plan_of(src, &r); + let ports = ExecutePorts::silent(); + assert!(matches!( + collect_examples(&p, &ports)[0].run().unwrap_err().error, + StepError::UnexpectedPass + )); +} + +#[test] +fn error_fence_example_with_mismatching_message_rethrows_the_real_error() { + let r = reg( + "I divide {int} by {int}", + "s.ts", + 1, + Handler::sync2(|_s, _a, _b| Err(HandlerError::new("boom"))), + Some(StepKind::Stimulus), + ); + let src = "# D\n\nI divide 1 by 0.\n\n```error\ndivision by zero\n```\n"; + let p = plan_of(src, &r); + let ports = ExecutePorts::silent(); + let err = collect_examples(&p, &ports)[0].run().unwrap_err(); + assert_eq!("boom", err.error.message()); +} + +// ----------------------------------------------------------------------------- +// Return-vs-throw parity: a panicking (assertion-style) sensor +// ----------------------------------------------------------------------------- + +#[test] +fn a_sensor_that_panics_instead_of_returning_a_mismatch_gets_a_located_failure() { + let r = reg( + "the total should be {int}", + "s.ts", + 1, + Handler::sync1(|_s, expected| { + panic!("expected {} but was 41", int_of(&expected)); + }), + Some(StepKind::Sensor), + ); + let p = plan_of("# Q\n\nthe total should be 42.", &r); + let step_line = p.examples[0].steps[0].match_span.start_line; + let ports = ExecutePorts::silent(); + let caught = collect_examples(&p, &ports)[0].run().unwrap_err(); + assert_eq!("expected 42 but was 41", caught.error.message()); + let failure = to_failure(&caught, &p.var_doc.path, -1); + assert_eq!(step_line as i64, failure.line); +} + +#[test] +fn an_error_fence_example_where_the_step_panics_matching_the_expected_message_passes() { + let r = reg( + "the total should be {int}", + "s.ts", + 1, + Handler::sync1(|_s, _e| panic!("boom")), + Some(StepKind::Sensor), + ); + let src = "# Q\n\nthe total should be 42.\n\n```error\nboom\n```\n"; + let p = plan_of(src, &r); + let ports = ExecutePorts::silent(); + assert!(collect_examples(&p, &ports)[0].run().is_ok()); +} + +#[test] +fn observer_receives_a_fail_observation_when_a_sensor_panics() { + let r = reg( + "the total should be {int}", + "s.ts", + 1, + Handler::sync1(|_s, _e| panic!("boom")), + Some(StepKind::Sensor), + ); + let p = plan_of("# Q\n\nthe total should be 42.", &r); + let obs: Rc>> = Rc::new(RefCell::new(Vec::new())); + let obs2 = obs.clone(); + let ports = ExecutePorts { + reporter: Box::new(|_| {}), + create_context: None, + observer: Some(Box::new(move |o| obs2.borrow_mut().push(o))), + }; + assert!(collect_examples(&p, &ports)[0].run().is_err()); + assert_eq!(1, obs.borrow().len()); + assert_eq!(StepOutcome::Fail, obs.borrow()[0].outcome); + assert!(obs.borrow()[0].error.is_some()); +} + +// ----------------------------------------------------------------------------- +// Observer +// ----------------------------------------------------------------------------- + +#[test] +fn observer_receives_a_pass_observation_per_executed_step() { + let r = reg( + "I add {int}", + "s.ts", + 1, + Handler::sync1(|state, _n| Ok(Some(state))), + Some(StepKind::Stimulus), + ); + let p = plan_of("# A\n\nI add 5.", &r); + let obs: Rc>> = Rc::new(RefCell::new(Vec::new())); + let obs2 = obs.clone(); + let ports = ExecutePorts { + reporter: Box::new(|_| {}), + create_context: None, + observer: Some(Box::new(move |o| obs2.borrow_mut().push(o))), + }; + collect_examples(&p, &ports)[0].run().unwrap(); + assert_eq!( + vec![StepObservation { + example_index: 0, + ordinal: 1, + outcome: StepOutcome::Pass, + error: None + }], + *obs.borrow() + ); +} + +#[test] +fn observer_receives_a_fail_observation_when_a_step_throws() { + let r = reg( + "I blow up", + "s.ts", + 1, + Handler::sync0(|_s| Err(HandlerError::new("kaboom"))), + Some(StepKind::Stimulus), + ); + let p = plan_of("# A\n\nI blow up.", &r); + let obs: Rc>> = Rc::new(RefCell::new(Vec::new())); + let obs2 = obs.clone(); + let ports = ExecutePorts { + reporter: Box::new(|_| {}), + create_context: None, + observer: Some(Box::new(move |o| obs2.borrow_mut().push(o))), + }; + assert!(collect_examples(&p, &ports)[0].run().is_err()); + assert_eq!(1, obs.borrow().len()); + assert_eq!(0, obs.borrow()[0].example_index); + assert_eq!(1, obs.borrow()[0].ordinal); + assert_eq!(StepOutcome::Fail, obs.borrow()[0].outcome); + assert!(obs.borrow()[0].error.is_some()); +} + +// ----------------------------------------------------------------------------- +// Failure location integration +// ----------------------------------------------------------------------------- + +#[test] +fn a_thrown_step_gets_a_located_failure_that_failure_to_failure_resolves_to_the_md_line() { + let r = reg( + "I throw", + "s.ts", + 1, + Handler::sync0(|_s| Err(HandlerError::new("boom"))), + Some(StepKind::Stimulus), + ); + let p = plan_of("# A\n\nI throw", &r); + let step_line = p.examples[0].steps[0].match_span.start_line; + let ports = ExecutePorts::silent(); + let caught = collect_examples(&p, &ports)[0].run().unwrap_err(); + assert_eq!("boom", caught.error.message()); + let failure = to_failure(&caught, &p.var_doc.path, -1); + assert_eq!(step_line as i64, failure.line); + assert_eq!("boom", failure.message); +} + +// ----------------------------------------------------------------------------- +// Async: handlers may return a Future, sync or failing +// ----------------------------------------------------------------------------- + +#[test] +fn an_action_handler_returning_a_future_is_awaited_and_its_result_becomes_the_new_state() { + let seen: Rc>> = Rc::new(RefCell::new(Vec::new())); + let seen2 = seen.clone(); + let r = create_registry(); + let r = add_step( + &r, + "I greet asynchronously", + "s.ts", + 1, + Handler::async0(|_state| { + Box::pin(YieldOnce { + value: Some(Ok(Some(Value::from("hi")))), + yielded: false, + }) + }), + Some(StepKind::Stimulus), + ) + .unwrap(); + let r = add_step( + &r, + "observe", + "s.ts", + 2, + Handler::sync0(move |state| { + seen2.borrow_mut().push(state); + Ok(None) + }), + Some(StepKind::Sensor), + ) + .unwrap(); + let p = plan_of("# A\n\nI greet asynchronously\nobserve", &r); + let ports = ExecutePorts::silent(); + collect_examples(&p, &ports)[0].run().unwrap(); + assert_eq!(vec![Value::from("hi")], *seen.borrow()); +} + +#[test] +fn an_async_handler_that_completes_exceptionally_propagates_its_cause() { + let r = reg( + "I fail asynchronously", + "s.ts", + 1, + Handler::async0(|_state| Box::pin(async { Err(HandlerError::new("async boom")) })), + Some(StepKind::Stimulus), + ); + let p = plan_of("# A\n\nI fail asynchronously", &r); + let ports = ExecutePorts::silent(); + let err = collect_examples(&p, &ports)[0].run().unwrap_err(); + assert_eq!("async boom", err.error.message()); +} + +// ----------------------------------------------------------------------------- +// executePlan: eager, fail-fast run-everything driver +// ----------------------------------------------------------------------------- + +#[test] +fn execute_plan_runs_every_example_when_none_fail() { + let ran: Rc>> = Rc::new(RefCell::new(Vec::new())); + let ran2 = ran.clone(); + let r = reg( + "I run", + "s.ts", + 1, + Handler::sync0(move |state| { + ran2.borrow_mut().push("ran".to_string()); + Ok(Some(state)) + }), + Some(StepKind::Stimulus), + ); + let p = plan_of("# A\n\nI run\n\n# B\n\nI run", &r); + let ports = ExecutePorts::silent(); + assert!(execute_plan(&p, &ports).is_ok()); + assert_eq!(vec!["ran".to_string(), "ran".to_string()], *ran.borrow()); +} + +#[test] +fn execute_plan_propagates_the_first_failure_and_does_not_run_subsequent_examples() { + let second_ran = Rc::new(RefCell::new(false)); + let second_ran2 = second_ran.clone(); + let r = create_registry(); + let r = add_step( + &r, + "I fail", + "s.ts", + 1, + Handler::sync0(|_s| Err(HandlerError::new("boom"))), + Some(StepKind::Stimulus), + ) + .unwrap(); + let r = add_step( + &r, + "I succeed", + "s.ts", + 2, + Handler::sync0(move |state| { + *second_ran2.borrow_mut() = true; + Ok(Some(state)) + }), + Some(StepKind::Stimulus), + ) + .unwrap(); + let p = plan_of("# A\n\nI fail\n\n# B\n\nI succeed", &r); + let ports = ExecutePorts::silent(); + assert!(execute_plan(&p, &ports).is_err()); + assert!(!*second_ran.borrow()); +} + +// ----------------------------------------------------------------------------- +// Wiring: a null step kind is an error; invocation isn't tied to any interface +// ----------------------------------------------------------------------------- + +#[test] +fn a_null_step_kind_throws_a_return_shape() { + let r = reg( + "I do a thing", + "s.ts", + 1, + Handler::sync0(|state| Ok(Some(state))), + None, + ); + let p = plan_of("# A\n\nI do a thing", &r); + let ports = ExecutePorts::silent(); + assert!(matches!( + collect_examples(&p, &ports)[0].run().unwrap_err().error, + StepError::ReturnShape(_) + )); +} + +#[test] +fn handler_invocation_works_for_any_closure_shape() { + let r = reg( + "I use a plain closure", + "s.ts", + 1, + Handler::sync0(|_state| Ok(Some(Value::from("ok")))), + Some(StepKind::Stimulus), + ); + let p = plan_of("# A\n\nI use a plain closure", &r); + let ports = ExecutePorts::silent(); + assert!(collect_examples(&p, &ports)[0].run().is_ok()); +} + +// ----------------------------------------------------------------------------- +// Variadic handlers: sync_var / async_var (any-arity escape hatch — Java +// reflective invocation / Python *args parity) +// ----------------------------------------------------------------------------- + +#[test] +fn a_three_slot_step_runs_through_a_sync_var_handler() { + // Two inline params + a trailing table = three slots, beyond sync2. + let seen: Rc>> = Rc::new(RefCell::new(Vec::new())); + let seen2 = seen.clone(); + let r = reg( + "I map {word} to {word}:", + "s.ts", + 1, + Handler::sync_var(move |state, args| { + seen2.borrow_mut().push(args.len()); + Ok(Some(state)) + }), + Some(StepKind::Stimulus), + ); + let source = "# M\n\nI map alpha to beta:\n\n| from | to |\n|------|----|\n| a | b |"; + let p = plan_of(source, &r); + let ports = ExecutePorts::silent(); + collect_examples(&p, &ports)[0].run().unwrap(); + assert_eq!(vec![3], *seen.borrow()); // word, word, table +} + +#[test] +fn an_async_handler_with_parameters_runs_through_async_var() { + let r = create_registry(); + let r = add_step( + &r, + "I greet {string} asynchronously", + "s.ts", + 1, + Handler::async_var(|_state, args| { + Box::pin(async move { + let name = match &args[0] { + Value::String(s) => s.clone(), + _ => String::new(), + }; + Ok(Some(Value::from(format!("hi {name}")))) + }) + }), + Some(StepKind::Stimulus), + ) + .unwrap(); + let r = add_step( + &r, + "observe greeting", + "s.ts", + 2, + Handler::sync0(|state| { + assert_eq!(Value::from("hi world"), state); + Ok(None) + }), + Some(StepKind::Sensor), + ) + .unwrap(); + let p = plan_of( + "# A\n\nI greet \"world\" asynchronously\nobserve greeting", + &r, + ); + let ports = ExecutePorts::silent(); + collect_examples(&p, &ports)[0].run().unwrap(); +} diff --git a/rust/var-core/tests/failure_test.rs b/rust/var-core/tests/failure_test.rs new file mode 100644 index 00000000..8d826d8d --- /dev/null +++ b/rust/var-core/tests/failure_test.rs @@ -0,0 +1,80 @@ +//! Port of `FailureTest.java` / `failure.test.ts`. Java's synthetic +//! `StackTraceElement` frame becomes a structural [`FailureLocation`]; the +//! regex-escape case becomes an exact path-match check. The "message/stack is a +//! String" type assertions are dropped (type-level in Rust). + +use var_core::cell_diff::CellDiff; +use var_core::doc_string_diff::DocStringDiff; +use var_core::error::{FailureLocation, HandlerError, StepError, StepFailure}; +use var_core::failure::to_failure; +use var_core::result::CellFailure; +use var_core::span::Span; + +fn located(error: StepError, path: &str, line: usize) -> StepFailure { + StepFailure { + error, + location: Some(FailureLocation { + label: String::new(), + path: path.to_string(), + line, + }), + } +} + +#[test] +fn to_failure_extracts_cells_from_a_cell_mismatch() { + let source = "a | 5 |"; + let sf = StepFailure::bare(StepError::CellMismatch(vec![CellDiff::new( + "n", + Span::from_offsets(source, 4, 5), + "5", + "4", + false, + )])); + let f = to_failure(&sf, "spec.md", 3); + assert_eq!(Some(vec![CellFailure::new(4, 5, "4")]), f.cells); + assert_eq!(None, f.doc); +} + +#[test] +fn to_failure_extracts_doc_from_a_doc_string_mismatch() { + let source = "Hello!\n"; + let sf = StepFailure::bare(StepError::DocStringMismatch(DocStringDiff::new( + Span::from_offsets(source, 0, 7), + "Hello!\n", + "Goodbye!\n", + ))); + let f = to_failure(&sf, "spec.md", 3); + assert_eq!(Some(CellFailure::new(0, 7, "Goodbye!\n")), f.doc); + assert_eq!(None, f.cells); +} + +#[test] +fn to_failure_leaves_cells_doc_null_for_a_plain_exception_or_return_shape() { + let plain = StepFailure::bare(StepError::Handler(HandlerError::new("nope"))); + assert_eq!(None, to_failure(&plain, "spec.md", 3).cells); + assert_eq!(None, to_failure(&plain, "spec.md", 3).doc); + let shape = StepFailure::bare(StepError::ReturnShape("bad".to_string())); + assert_eq!(None, to_failure(&shape, "spec.md", 3).cells); +} + +#[test] +fn to_failure_reads_the_failing_line_from_an_injected_location_else_falls_back() { + let with_frame = located( + StepError::Handler(HandlerError::new("boom")), + "docs/a.md", + 12, + ); + assert_eq!(12, to_failure(&with_frame, "docs/a.md", 99).line); + + let no_frame = StepFailure::bare(StepError::Handler(HandlerError::new("boom"))); + assert_eq!(99, to_failure(&no_frame, "docs/a.md", 99).line); +} + +#[test] +fn to_failure_uses_an_exact_spec_path_match() { + // 'aXmd' must not be treated as matching spec path 'a.md' (Java escapes the + // regex dot; Rust compares paths by `==`). + let sf = located(StepError::Handler(HandlerError::new("boom")), "aXmd", 7); + assert_eq!(42, to_failure(&sf, "a.md", 42).line); +} diff --git a/rust/var-core/tests/hash_test.rs b/rust/var-core/tests/hash_test.rs new file mode 100644 index 00000000..ebdbce48 --- /dev/null +++ b/rust/var-core/tests/hash_test.rs @@ -0,0 +1,10 @@ +//! Port of the FNV-1a vectors from `DriftTest.java` / `hash.test.ts`. + +use var_core::hash::hash_source; + +#[test] +fn hash_matches_the_typescript_vectors() { + assert_eq!("fnv1a:4f9f2cab", hash_source("hello")); + assert_eq!("fnv1a:1a47e90b", hash_source("abc")); + assert_eq!("fnv1a:4eace75e", hash_source("# Title\n")); +} diff --git a/rust/var-core/tests/matcher_test.rs b/rust/var-core/tests/matcher_test.rs new file mode 100644 index 00000000..a6bc27d9 --- /dev/null +++ b/rust/var-core/tests/matcher_test.rs @@ -0,0 +1,170 @@ +//! Port of `MatcherTest.java` / `matcher.test.ts`. + +use var_core::handler::Handler; +use var_core::matcher::{ResolvedSteps, find_hits, resolve_hits}; +use var_core::offsets::{utf16_index, utf16_len, utf16_slice}; +use var_core::registry::{Registry, add_step, create_registry}; +use var_core::value::Value; + +fn reg() -> Registry { + let r = create_registry(); + let r = add_step( + &r, + "I have {int} cukes", + "steps.ts", + 1, + Handler::noop(), + None, + ) + .unwrap(); + add_step(&r, "I withdraw {int}", "steps.ts", 5, Handler::noop(), None).unwrap() +} + +#[test] +fn find_hits_returns_no_hits_when_nothing_matches() { + assert!(find_hits("hello world", ®()).is_empty()); +} + +#[test] +fn find_hits_returns_one_hit_per_step_expression_that_matches() { + let hits = find_hits("Given I have 5 cukes in my belly", ®()); + assert_eq!(1, hits.len()); + assert_eq!("I have {int} cukes", hits[0].expression); + assert_eq!(6, hits[0].match_start); + assert_eq!(20, hits[0].match_end); + assert_eq!(vec![Value::Int(5)], hits[0].args); +} + +#[test] +fn find_hits_returns_multiple_hits_when_multiple_expressions_match_non_overlapping_ranges() { + let hits = find_hits("I have 5 cukes and I withdraw 3", ®()); + let exprs: Vec = hits.iter().map(|h| h.expression.clone()).collect(); + assert_eq!( + vec![ + "I have {int} cukes".to_string(), + "I withdraw {int}".to_string() + ], + exprs + ); +} + +#[test] +fn resolve_hits_picks_longest_leftmost_when_ranges_overlap() { + let r = create_registry(); + let r = add_step(&r, "I have {int} cukes", "s.ts", 1, Handler::noop(), None).unwrap(); + let r = add_step( + &r, + "I have {int} cukes in my belly", + "s.ts", + 2, + Handler::noop(), + None, + ) + .unwrap(); + let result = resolve_hits(find_hits("I have 5 cukes in my belly", &r)); + let ResolvedSteps::Ok(steps) = result else { + panic!("expected Ok") + }; + assert_eq!(1, steps.len()); + assert_eq!("I have {int} cukes in my belly", steps[0].expression); +} + +#[test] +fn resolve_hits_returns_ambiguous_when_same_start_and_same_length_match() { + let r = create_registry(); + let r = add_step(&r, "I have {int} cukes", "s.ts", 1, Handler::noop(), None).unwrap(); + let r = add_step(&r, "I have {int} {word}", "s.ts", 2, Handler::noop(), None).unwrap(); + let result = resolve_hits(find_hits("I have 5 cukes", &r)); + let ResolvedSteps::Ambiguous(collisions) = result else { + panic!("expected Ambiguous") + }; + assert_eq!(1, collisions.len()); + assert_eq!(2, collisions[0].candidates.len()); +} + +#[test] +fn resolve_hits_returns_all_non_overlapping_hits_left_to_right() { + let r = create_registry(); + let r = add_step(&r, "I have {int} cukes", "s.ts", 1, Handler::noop(), None).unwrap(); + let r = add_step(&r, "I withdraw {int}", "s.ts", 2, Handler::noop(), None).unwrap(); + let result = resolve_hits(find_hits("Given I have 5 cukes and I withdraw 3", &r)); + let ResolvedSteps::Ok(steps) = result else { + panic!("expected Ok") + }; + let exprs: Vec = steps.iter().map(|h| h.expression.clone()).collect(); + assert_eq!( + vec![ + "I have {int} cukes".to_string(), + "I withdraw {int}".to_string() + ], + exprs + ); +} + +#[test] +fn param_spans_use_utf16_offsets_across_an_astral_character_no_manual_conversion_needed() { + let r = create_registry(); + let r = add_step(&r, "I like {string}", "s.ts", 1, Handler::noop(), None).unwrap(); + + let sentence = "😀 I like \"tea\""; + + let hits = find_hits(sentence, &r); + assert_eq!(1, hits.len()); + let hit = &hits[0]; + + let expected_match_start = utf16_index(sentence, sentence.find("I like").unwrap()); + assert_eq!(expected_match_start, hit.match_start); + assert_eq!(utf16_len(sentence), hit.match_end); + + assert_eq!(1, hit.param_spans.len()); + let span = hit.param_spans[0]; + let quote_open = utf16_index(sentence, sentence.find('"').unwrap()); + assert_eq!(quote_open, span.start); + assert_eq!(utf16_len(sentence), span.end); + assert_eq!("\"tea\"", utf16_slice(sentence, span.start, span.end)); + assert_eq!(vec![Value::from("tea")], hit.args); +} + +// ----------------------------------------------------------------------------- +// Custom parameter types with capture groups (Java CaptureGroupTransformer / +// Python parse(*groups) parity) +// ----------------------------------------------------------------------------- + +#[test] +fn a_custom_type_with_capture_groups_passes_each_group_to_parse() { + use std::rc::Rc; + use var_core::registry::define_parameter_type; + let r = define_parameter_type( + &create_registry(), + "range", + r"(\d+)-(\d+)", + Rc::new(|groups: &[&str]| { + Value::list(groups.iter().map(|g| Value::from(*g)).collect::>()) + }), + ); + let r = add_step(&r, "the range is {range}", "s.rs", 1, Handler::noop(), None).unwrap(); + let hits = find_hits("the range is 10-20", &r); + assert_eq!(1, hits.len()); + assert_eq!( + vec![Value::list(vec![Value::from("10"), Value::from("20")])], + hits[0].args + ); +} + +#[test] +fn a_custom_type_without_groups_still_receives_the_whole_match() { + use std::rc::Rc; + use var_core::registry::define_parameter_type; + let r = define_parameter_type( + &create_registry(), + "airport", + "[A-Z]{3}", + Rc::new(|groups: &[&str]| { + assert_eq!(1, groups.len()); + Value::from(groups[0].to_lowercase()) + }), + ); + let r = add_step(&r, "I fly to {airport}", "s.rs", 1, Handler::noop(), None).unwrap(); + let hits = find_hits("I fly to LHR", &r); + assert_eq!(vec![Value::from("lhr")], hits[0].args); +} diff --git a/rust/var-core/tests/offsets_test.rs b/rust/var-core/tests/offsets_test.rs new file mode 100644 index 00000000..66e1c30d --- /dev/null +++ b/rust/var-core/tests/offsets_test.rs @@ -0,0 +1,43 @@ +//! Unit tests for the UTF-16 conversion layer (`offsets.rs`) — port infrastructure +//! the Python port needed. Not a 1:1 of a Java test file; these pin the helpers +//! the astral conformance cases (bundles 11/12) depend on. + +use var_core::offsets::{byte_index, utf16_index, utf16_len, utf16_slice}; + +#[test] +fn utf16_len_counts_code_units_ascii_and_astral() { + assert_eq!(utf16_len(""), 0); + assert_eq!(utf16_len("abc"), 3); + // 😀 is a surrogate pair (2 UTF-16 code units); Java "a😀b".length() == 4. + assert_eq!(utf16_len("a😀b"), 4); + // Combining marks are single BMP code units. + assert_eq!(utf16_len("e\u{0301}"), 2); +} + +#[test] +fn utf16_index_converts_byte_to_code_unit_offset() { + let s = "a😀b"; + assert_eq!(utf16_index(s, 0), 0); + assert_eq!(utf16_index(s, 1), 1); // after 'a' + assert_eq!(utf16_index(s, 1 + 4), 3); // after 'a' + 😀 (4 bytes, 2 units) + assert_eq!(utf16_index(s, s.len()), 4); +} + +#[test] +fn byte_index_is_the_inverse_of_utf16_index() { + let s = "a😀b"; + assert_eq!(byte_index(s, 0), 0); + assert_eq!(byte_index(s, 1), 1); + assert_eq!(byte_index(s, 3), 5); // start of 'b' + assert_eq!(byte_index(s, 4), 6); // end of string + // Past-the-end clamps to the byte length (JS String.slice semantics). + assert_eq!(byte_index(s, 99), s.len()); +} + +#[test] +fn utf16_slice_matches_java_substring() { + let s = "a😀b"; + assert_eq!(utf16_slice(s, 0, 4), "a😀b"); + assert_eq!(utf16_slice(s, 1, 3), "😀"); + assert_eq!(utf16_slice(s, 3, 4), "b"); +} diff --git a/rust/var-core/tests/param_diff_test.rs b/rust/var-core/tests/param_diff_test.rs new file mode 100644 index 00000000..8f7da6b3 --- /dev/null +++ b/rust/var-core/tests/param_diff_test.rs @@ -0,0 +1,52 @@ +//! Port of `ParamDiffTest.java` / `param-diff.test.ts`. + +mod common; + +use common::vmap; +use var_core::param_diff::compare_params; +use var_core::span::Span; +use var_core::value::Value; + +const SOURCE: &str = "I should have 3 cukes in my big belly"; + +fn span(start: usize, end: usize) -> Span { + Span::from_offsets(SOURCE, start, end) +} + +#[test] +fn all_elements_equal_every_cell_ok() { + let diffs = compare_params( + &[Value::Int(3), Value::from("big")], + &[Value::Int(3), Value::from("big")], + &[span(14, 15), span(31, 34)], + &["3".to_string(), "big".to_string()], + ); + assert!(diffs.iter().all(|d| d.ok)); +} + +#[test] +fn one_mismatching_element_that_cell_is_not_ok_with_expected_actual() { + let diffs = compare_params( + &[Value::Int(4), Value::from("big")], + &[Value::Int(3), Value::from("big")], + &[span(14, 15), span(31, 34)], + &["3".to_string(), "big".to_string()], + ); + assert_eq!("arg 1", diffs[0].column); + assert_eq!("3", diffs[0].expected); + assert_eq!("4", diffs[0].actual); + assert!(!diffs[0].ok); + assert_eq!("arg 2", diffs[1].column); + assert!(diffs[1].ok); +} + +#[test] +fn object_actuals_compare_structurally_across_references() { + let diffs = compare_params( + &[vmap(vec![("iso", Value::from("NO"))])], + &[vmap(vec![("iso", Value::from("NO"))])], + &[span(0, 2)], + &["NO".to_string()], + ); + assert!(diffs[0].ok); +} diff --git a/rust/var-core/tests/parse_test.rs b/rust/var-core/tests/parse_test.rs new file mode 100644 index 00000000..99790dfb --- /dev/null +++ b/rust/var-core/tests/parse_test.rs @@ -0,0 +1,13 @@ +//! Port of `ParseTest.java` / `parse.test.ts`. + +use var_core::parse::parse; + +#[test] +fn parse_returns_a_var_doc_whose_examples_come_from_paragraphs_and_carry_the_heading_stack() { + let source = "# Hello\n\nbody"; + let var_doc = parse("hello.md", source); + assert_eq!("hello.md", var_doc.path); + assert_eq!(source, var_doc.source); + assert_eq!(1, var_doc.examples.len()); + assert_eq!(vec!["Hello".to_string()], var_doc.examples[0].scope_stack); +} diff --git a/rust/var-core/tests/plan_test.rs b/rust/var-core/tests/plan_test.rs new file mode 100644 index 00000000..27c04e01 --- /dev/null +++ b/rust/var-core/tests/plan_test.rs @@ -0,0 +1,448 @@ +//! Port of `PlanTest.java` / `plan.test.ts`. + +mod common; + +use common::vmap; +use var_core::cell_diff::RowCheck; +use var_core::diagnostics::DiagnosticCode; +use var_core::handler::Handler; +use var_core::offsets::utf16_slice; +use var_core::parse::parse; +use var_core::plan::plan; +use var_core::registry::{Registry, add_step, create_registry}; +use var_core::step_kind::StepKind; +use var_core::value::Value; + +fn reg() -> Registry { + let r = create_registry(); + let r = add_step( + &r, + "I have {int} in my account", + "steps.ts", + 1, + Handler::noop(), + Some(StepKind::Stimulus), + ) + .unwrap(); + let r = add_step( + &r, + "I withdraw {int}", + "steps.ts", + 2, + Handler::noop(), + Some(StepKind::Stimulus), + ) + .unwrap(); + add_step( + &r, + "I should have {int} left", + "steps.ts", + 3, + Handler::noop(), + Some(StepKind::Stimulus), + ) + .unwrap() +} + +fn step(r: &Registry, expr: &str, file: &str, line: usize) -> Registry { + add_step( + r, + expr, + file, + line, + Handler::noop(), + Some(StepKind::Stimulus), + ) + .unwrap() +} + +fn step_texts(ex: &var_core::plan::PlannedExample) -> Vec { + ex.steps.iter().map(|s| s.text.clone()).collect() +} + +#[test] +fn plan_produces_a_planned_example_with_steps_in_document_order() { + let source = "# Withdrawing\n\nGiven I have 100 in my account. When I withdraw 40. Then I should have 60 left."; + let var_doc = parse("w.md", source); + let result = plan(&var_doc, ®()); + assert_eq!(0, result.diagnostics.len()); + assert_eq!(1, result.examples.len()); + let ex = &result.examples[0]; + assert_eq!( + "Given I have 100 in my account. When I withdraw 40. Then I should have 60 left", + ex.name + ); + assert_eq!(vec!["Withdrawing".to_string()], ex.scope_stack); + assert_eq!( + vec![ + "I have 100 in my account".to_string(), + "I withdraw 40".to_string(), + "I should have 60 left".to_string() + ], + step_texts(ex) + ); + assert_eq!(vec![Value::Int(100)], ex.steps[0].args); +} + +#[test] +fn plan_emits_an_ambiguous_match_diagnostic_and_does_not_include_the_example_steps() { + let r = create_registry(); + let r = step(&r, "I have {int} cukes", "a.ts", 3); + let r = step(&r, "I have {int} {word}", "a.ts", 8); + let var_doc = parse("e.md", "# Ambig\n\nGiven I have 5 cukes"); + let result = plan(&var_doc, &r); + assert_eq!(1, result.diagnostics.len()); + assert_eq!(DiagnosticCode::AmbiguousMatch, result.diagnostics[0].code); + assert_eq!(0, result.examples[0].steps.len()); +} + +#[test] +fn plan_skips_an_example_heading_whose_body_has_no_matches_and_no_keyword_led_sentences() { + let source = "# Just docs\n\nSome prose with no matches and no keywords."; + let result = plan(&parse("d.md", source), ®()); + assert_eq!(0, result.examples.len()); + assert_eq!(0, result.diagnostics.len()); +} + +#[test] +fn plan_turns_each_list_item_into_its_own_example_one_matched_step_per_item() { + let r = create_registry(); + let r = step(&r, "I have {int} in my account", "s.ts", 1); + let r = step(&r, "I withdraw {int}", "s.ts", 2); + let source = "# Bullets\n\n- Given I have 100 in my account\n- When I withdraw 40"; + let result = plan(&parse("b.md", source), &r); + assert_eq!(2, result.examples.len()); + let texts: Vec> = result.examples.iter().map(step_texts).collect(); + assert_eq!( + vec![ + vec!["I have 100 in my account".to_string()], + vec!["I withdraw 40".to_string()] + ], + texts + ); +} + +#[test] +fn plan_walks_blockquote_content_as_step_bearing() { + let r = create_registry(); + let r = step(&r, "I have {int} in my account", "s.ts", 1); + let source = "# Quote\n\n> Given I have 100 in my account"; + let result = plan(&parse("q.md", source), &r); + assert_eq!(1, result.examples[0].steps.len()); +} + +#[test] +fn a_markdown_table_immediately_following_a_step_bearing_block_attaches_as_data_table() { + let r = create_registry(); + let r = step(&r, "these users exist", "s.ts", 1); + let source = "# Users\nGiven these users exist:\n\n| name | age |\n|------|-----|\n| Bob | 30 |\n| Eve | 25 |"; + let result = plan(&parse("u.md", source), &r); + let step0 = &result.examples[0].steps[0]; + let table = step0.data_table.as_ref().expect("data table"); + assert_eq!( + vec!["name".to_string(), "age".to_string()], + table.header.cells + ); + assert_eq!(2, table.rows.len()); +} + +#[test] +fn a_table_not_immediately_after_a_step_bearing_block_does_not_attach() { + let r = create_registry(); + let r = step(&r, "these users exist", "s.ts", 1); + let source = "# Mid\nGiven these users exist:\n\nSome interrupting prose.\n\n| name | age |\n|------|-----|\n| Bob | 30 |"; + let result = plan(&parse("m.md", source), &r); + assert!(result.examples[0].steps[0].data_table.is_none()); +} + +#[test] +fn a_fenced_code_block_immediately_following_a_step_bearing_block_attaches_as_doc_string() { + let r = create_registry(); + let r = step(&r, "I send the payload", "s.ts", 1); + let source = "# Payload\nWhen I send the payload:\n\n```json\n{ \"action\": \"import\" }\n```"; + let result = plan(&parse("p.md", source), &r); + let step0 = &result.examples[0].steps[0]; + let doc = step0.doc_string.as_ref().expect("doc string"); + assert_eq!("json", doc.info); + assert_eq!("{ \"action\": \"import\" }\n", doc.body); +} + +#[test] +fn a_step_with_no_following_fence_has_no_doc_string() { + let r = create_registry(); + let r = step(&r, "I send the payload", "s.ts", 1); + let result = plan(&parse("p.md", "# P\nWhen I send the payload"), &r); + assert!(result.examples[0].steps[0].doc_string.is_none()); +} + +#[test] +fn a_keyword_led_sentence_with_no_match_does_not_produce_a_diagnostic() { + let r = create_registry(); + let result = plan( + &parse("m.md", "# Empty\n\nGiven I have 5 cukes in my belly."), + &r, + ); + assert_eq!(0, result.diagnostics.len()); +} + +#[test] +fn an_unmatched_sentence_without_a_keyword_is_also_silently_treated_as_prose() { + let r = create_registry(); + let result = plan(&parse("p.md", "# Prose\n\nI have 5 cukes in my belly."), &r); + assert_eq!(0, result.diagnostics.len()); +} + +const YAHTZEE: &str = "# Yahtzee\n\neach row lists the dice, the category and the score:\n\n| dice | category | score |\n| ------------- | ---------- | ----- |\n| 3, 3, 3, 4, 4 | full house | 17 |\n| 3, 3, 3, 3, 3 | Yahtzee | 50 |"; + +#[test] +fn a_header_bound_table_expands_into_one_example_per_row() { + let r = create_registry(); + let r = step( + &r, + "each row lists the dice, the category and the score", + "s.ts", + 1, + ); + let result = plan(&parse("y.md", YAHTZEE), &r); + assert_eq!(0, result.diagnostics.len()); + assert_eq!(2, result.examples.len()); + let first = &result.examples[0]; + let second = &result.examples[1]; + assert_eq!(1, first.steps.len()); + assert_eq!( + vec![vmap(vec![ + ("dice", Value::from("3, 3, 3, 4, 4")), + ("category", Value::from("full house")), + ("score", Value::from("17")), + ])], + first.steps[0].args + ); + assert_eq!( + vec![vmap(vec![ + ("dice", Value::from("3, 3, 3, 3, 3")), + ("category", Value::from("Yahtzee")), + ("score", Value::from("50")), + ])], + second.steps[0].args + ); + assert!(first.steps[0].data_table.is_none()); +} + +#[test] +fn a_table_whose_paragraph_names_only_some_header_cells_keeps_whole_table_behaviour() { + let r = create_registry(); + let r = step(&r, "these users exist", "s.ts", 1); + let source = "# Users\nthese users exist:\n\n| name | age |\n| ---- | --- |\n| Bob | 30 |\n| Eve | 25 |"; + let result = plan(&parse("u.md", source), &r); + assert_eq!(1, result.examples.len()); + let table = result.examples[0].steps[0].data_table.as_ref().unwrap(); + assert_eq!( + vec!["name".to_string(), "age".to_string()], + table.header.cells + ); + assert_eq!(2, table.rows.len()); +} + +#[test] +fn header_bound_matching_is_case_sensitive() { + let r = create_registry(); + let r = step(&r, "each row lists the Dice and the Score", "s.ts", 1); + let source = "# Case\neach row lists the Dice and the Score:\n\n| dice | score |\n| --------- | ----- |\n| 1,1,1,1,1 | 5 |"; + let result = plan(&parse("c.md", source), &r); + assert_eq!(1, result.examples.len()); + assert_eq!( + 1, + result.examples[0].steps[0] + .data_table + .as_ref() + .unwrap() + .rows + .len() + ); +} + +#[test] +fn header_bound_rows_are_named_by_their_cells_and_nested_under_the_paragraph() { + let r = create_registry(); + let r = step( + &r, + "each row lists the dice, the category and the score", + "s.ts", + 1, + ); + let result = plan(&parse("y.md", YAHTZEE), &r); + let names: Vec = result.examples.iter().map(|e| e.name.clone()).collect(); + assert_eq!( + vec![ + "3, 3, 3, 4, 4 / full house / 17".to_string(), + "3, 3, 3, 3, 3 / Yahtzee / 50".to_string() + ], + names + ); + for ex in &result.examples { + assert_eq!( + vec![ + "Yahtzee".to_string(), + "each row lists the dice, the category and the score".to_string() + ], + ex.scope_stack + ); + } + let lines: Vec = result.examples.iter().map(|e| e.span.start_line).collect(); + assert_ne!(lines[0], lines[1]); + assert!(lines[0] < lines[1]); +} + +#[test] +fn a_table_not_attached_to_a_step_is_allowed_no_diagnostic() { + let r = create_registry(); + let r = step(&r, "I have {int} cukes", "s.ts", 1); + let source = "# Detached\n\nGiven I have 5 cukes.\n\nSome interrupting prose paragraph.\n\n| name | age |\n|------|-----|\n| Bob | 30 |"; + let result = plan(&parse("o.md", source), &r); + assert_eq!(0, result.diagnostics.len()); +} + +#[test] +fn a_header_bound_row_example_carries_row_checks() { + let r = create_registry(); + let r = step( + &r, + "each row lists the dice, the category and the score", + "s.ts", + 1, + ); + let source = "# Yahtzee\n\neach row lists the dice, the category and the score:\n\n| dice | category | score |\n| ------------- | ---------- | ----- |\n| 3, 3, 3, 4, 4 | full house | 17 |"; + let result = plan(&parse("y.md", source), &r); + let checks: &Vec = result.examples[0] + .row_checks + .as_ref() + .expect("no rowChecks"); + let cols: Vec = checks.iter().map(|c| c.column.clone()).collect(); + assert_eq!( + vec![ + "dice".to_string(), + "category".to_string(), + "score".to_string() + ], + cols + ); + let vals: Vec = checks.iter().map(|c| c.value.clone()).collect(); + assert_eq!( + vec![ + "3, 3, 3, 4, 4".to_string(), + "full house".to_string(), + "17".to_string() + ], + vals + ); + let score_check = &checks[2]; + assert_eq!( + "17", + utf16_slice( + source, + score_check.span.start_offset, + score_check.span.end_offset + ) + ); +} + +#[test] +fn an_error_fence_marks_the_example_expected_outcome_fail_with_a_message_substring() { + let r = add_step( + &create_registry(), + "I divide {int} by {int}", + "s.ts", + 1, + Handler::noop(), + Some(StepKind::Stimulus), + ) + .unwrap(); + let src = "# Division\n\nI divide 1 by 0.\n\n```error\ndivision by zero\n```\n"; + let ex = plan(&parse("e.md", src), &r).examples.remove(0); + assert_eq!(Some("fail".to_string()), ex.expected_outcome); + assert_eq!( + Some("division by zero".to_string()), + ex.expected_error_message + ); + assert!(ex.steps[0].doc_string.is_none()); +} + +#[test] +fn no_error_fence_leaves_expected_outcome_null() { + let r = add_step( + &create_registry(), + "I divide {int} by {int}", + "s.ts", + 1, + Handler::noop(), + Some(StepKind::Stimulus), + ) + .unwrap(); + let ex = plan(&parse("e.md", "# Division\n\nI divide 1 by 1."), &r) + .examples + .remove(0); + assert_eq!(None, ex.expected_outcome); +} + +#[test] +fn an_error_fence_with_no_matching_step_emits_an_error_fence_without_step_diagnostic() { + let r = add_step( + &create_registry(), + "I divide {int} by {int}", + "s.ts", + 1, + Handler::noop(), + Some(StepKind::Stimulus), + ) + .unwrap(); + let src = "# Nope\n\nThis prose matches nothing.\n\n```error\nboom\n```\n"; + let result = plan(&parse("e.md", src), &r); + assert_eq!(0, result.examples.len()); + assert_eq!(1, result.diagnostics.len()); + assert_eq!( + DiagnosticCode::ErrorFenceWithoutStep, + result.diagnostics[0].code + ); +} + +#[test] +fn an_error_fence_on_an_ambiguous_example_emits_both_diagnostics() { + let r = create_registry(); + let r = step(&r, "I divide {int} by {int}", "s.ts", 1); + let r = step(&r, "I divide 1 by 0", "s.ts", 2); + let src = "# Ambiguous\n\nI divide 1 by 0.\n\n```error\nboom\n```\n"; + let result = plan(&parse("e.md", src), &r); + let mut codes: Vec = result.diagnostics.iter().map(|d| d.code).collect(); + codes.sort(); + assert_eq!( + vec![ + DiagnosticCode::AmbiguousMatch, + DiagnosticCode::ErrorFenceWithoutStep + ], + codes + ); +} + +#[test] +fn a_doc_string_step_carries_the_fence_body_span_on_its_plan() { + let r = add_step( + &create_registry(), + "the payload is", + "s.ts", + 1, + Handler::noop(), + Some(StepKind::Stimulus), + ) + .unwrap(); + let source = "# T\n\nthe payload is:\n\n```json\n{ \"ok\": true }\n```"; + let result = plan(&parse("d.md", source), &r); + let doc = result.examples[0].steps[0] + .doc_string + .as_ref() + .expect("no docString"); + assert_eq!("{ \"ok\": true }\n", doc.body); + assert_eq!( + "{ \"ok\": true }\n", + utf16_slice(source, doc.body_span.start_offset, doc.body_span.end_offset) + ); +} diff --git a/rust/var-core/tests/registry_test.rs b/rust/var-core/tests/registry_test.rs new file mode 100644 index 00000000..4b30cf19 --- /dev/null +++ b/rust/var-core/tests/registry_test.rs @@ -0,0 +1,147 @@ +//! Port of `RegistryTest.java` / `registry.test.ts`. Java's `Pattern.compile(...)` +//! becomes a bare regexp `&str`; the `assertThrows(UnsupportedOperationException)` +//! immutability clause is dropped (Rust values are immutable). + +use std::rc::Rc; +use var_core::error::RegistryError; +use var_core::handler::Handler; +use var_core::registry::{CustomParameterType, add_step, create_registry, define_parameter_type}; +use var_core::step_kind::StepKind; +use var_core::value::Value; + +#[test] +fn create_registry_returns_an_empty_registry_with_default_parameter_types() { + let r = create_registry(); + assert_eq!(0, r.steps.len()); +} + +#[test] +fn add_step_returns_a_new_registry_original_is_unchanged() { + let r0 = create_registry(); + let r1 = add_step( + &r0, + "I have {int} cukes", + "steps.ts", + 1, + Handler::noop(), + None, + ) + .unwrap(); + assert_eq!(0, r0.steps.len()); + assert_eq!(1, r1.steps.len()); + assert_eq!("I have {int} cukes", r1.steps[0].expression); +} + +#[test] +fn define_parameter_type_makes_a_custom_type_available_to_subsequent_step_compilations() { + let r = create_registry(); + let with_type = define_parameter_type( + &r, + "airport", + "[A-Z]{3}", + Rc::new(|g: &[&str]| Value::from(g[0])), + ); + assert!( + add_step( + &with_type, + "I fly to {airport}", + "steps.ts", + 1, + Handler::noop(), + None + ) + .is_ok() + ); +} + +#[test] +fn define_parameter_type_returned_step_actually_matches_the_regex_at_runtime() { + let r = create_registry(); + let r = define_parameter_type( + &r, + "airport", + "[A-Z]{3}", + Rc::new(|g: &[&str]| Value::from(g[0].to_lowercase())), + ); + let r = add_step( + &r, + "I fly to {airport}", + "steps.ts", + 1, + Handler::noop(), + None, + ) + .unwrap(); + let matched = r.steps[0].compiled.match_whole("I fly to LHR"); + assert!(matched.is_some()); + assert_eq!(Value::from("lhr"), matched.unwrap()[0].value); +} + +#[test] +fn add_step_throws_on_duplicate_expressions_listing_both_source_positions() { + let r = create_registry(); + let with_first = add_step(&r, "I have {int} cukes", "a.ts", 3, Handler::noop(), None).unwrap(); + let err = match add_step( + &with_first, + "I have {int} cukes", + "b.ts", + 9, + Handler::noop(), + None, + ) { + Ok(_) => panic!("expected a duplicate-step error"), + Err(e) => e, + }; + let RegistryError::DuplicateStep(msg) = err else { + panic!("expected duplicate error") + }; + assert!(msg.contains("duplicate step definition")); + assert!(msg.contains("a.ts:3")); + assert!(msg.contains("b.ts:9")); +} + +#[test] +fn add_step_carries_the_step_kind_through_to_the_registration() { + let r = add_step( + &create_registry(), + "I greet {string}", + "a.steps.ts", + 1, + Handler::noop(), + Some(StepKind::Sensor), + ) + .unwrap(); + assert_eq!(Some(StepKind::Sensor), r.steps[0].kind); +} + +#[test] +fn kind_is_optional_legacy_step_path() { + let r = add_step( + &create_registry(), + "I greet {string}", + "a.steps.ts", + 1, + Handler::noop(), + None, + ) + .unwrap(); + assert_eq!(None, r.steps[0].kind); +} + +#[test] +fn define_parameter_type_records_the_custom_type_immutably() { + let r0 = create_registry(); + assert_eq!(Vec::::new(), r0.custom_parameter_types); + let r1 = define_parameter_type( + &r0, + "airport", + "[A-Z]{3}", + Rc::new(|g: &[&str]| Value::from(g[0])), + ); + assert_eq!( + vec![CustomParameterType::new("airport", "[A-Z]{3}")], + r1.custom_parameter_types + ); + // The original registry value is untouched. + assert_eq!(Vec::::new(), r0.custom_parameter_types); +} diff --git a/rust/var-core/tests/scanner_test.rs b/rust/var-core/tests/scanner_test.rs new file mode 100644 index 00000000..c76f809d --- /dev/null +++ b/rust/var-core/tests/scanner_test.rs @@ -0,0 +1,327 @@ +//! Port of `ScannerTest.java` / `scanner.test.ts`. + +use var_core::ast::Block; +use var_core::ast::SegmentOffset; +use var_core::offsets::{utf16_len, utf16_slice}; +use var_core::scanner::scan; +use var_core::span::Span; + +fn kind_of(b: &Block) -> &'static str { + match b { + Block::Heading(_) => "heading", + Block::Paragraph(_) => "paragraph", + Block::ListItem(_) => "list_item", + Block::Blockquote(_) => "blockquote", + Block::Table(_) => "table", + Block::Fence(_) => "fence", + Block::ThematicBreak(_) => "thematic_break", + } +} + +fn kinds(blocks: &[Block]) -> Vec<&'static str> { + blocks.iter().map(kind_of).collect() +} + +fn slice(source: &str, span: Span) -> &str { + utf16_slice(source, span.start_offset, span.end_offset) +} + +fn first_paragraph(blocks: &[Block]) -> &var_core::ast::Paragraph { + blocks + .iter() + .find_map(|b| { + if let Block::Paragraph(p) = b { + Some(p) + } else { + None + } + }) + .unwrap() +} + +fn first_table(blocks: &[Block]) -> &var_core::ast::Table { + blocks + .iter() + .find_map(|b| { + if let Block::Table(t) = b { + Some(t) + } else { + None + } + }) + .unwrap() +} + +fn first_fence(blocks: &[Block]) -> &var_core::ast::Fence { + blocks + .iter() + .find_map(|b| { + if let Block::Fence(f) = b { + Some(f) + } else { + None + } + }) + .unwrap() +} + +// ── Heading tests ──────────────────────────────────────────────────── + +#[test] +fn scan_finds_a_single_h1_heading() { + let blocks = scan("# Hello"); + assert_eq!(1, blocks.len()); + let Block::Heading(h) = &blocks[0] else { + panic!("expected heading") + }; + assert_eq!(1, h.level); + assert_eq!("Hello", h.text); + assert_eq!( + Span { + start_offset: 0, + end_offset: 7, + start_line: 1, + start_col: 1, + end_line: 1, + end_col: 8 + }, + h.span + ); +} + +#[test] +fn scan_finds_headings_at_levels_1_through_6() { + let source = "# a\n## b\n### c\n#### d\n##### e\n###### f"; + let blocks = scan(source); + let levels: Vec = blocks + .iter() + .filter_map(|b| { + if let Block::Heading(h) = b { + Some(h.level) + } else { + None + } + }) + .collect(); + assert_eq!(vec![1, 2, 3, 4, 5, 6], levels); +} + +#[test] +fn scan_ignores_headings_with_more_than_6_hashes() { + let blocks = scan("####### too deep"); + assert!(!blocks.iter().any(|b| matches!(b, Block::Heading(_)))); +} + +#[test] +fn scan_strips_the_optional_trailing_hash_marker() { + let blocks = scan("## Hello ##"); + let Block::Heading(h) = &blocks[0] else { + panic!("expected heading") + }; + assert_eq!("Hello", h.text); +} + +// ── Paragraph tests ────────────────────────────────────────────────── + +#[test] +fn scan_groups_consecutive_non_blank_lines_into_a_single_paragraph() { + let source = "first line\nsecond line\n\nthird line"; + let blocks = scan(source); + let paragraphs: Vec<&var_core::ast::Paragraph> = blocks + .iter() + .filter_map(|b| { + if let Block::Paragraph(p) = b { + Some(p) + } else { + None + } + }) + .collect(); + assert_eq!(2, paragraphs.len()); + assert_eq!("first line\nsecond line", paragraphs[0].text); + assert_eq!("third line", paragraphs[1].text); +} + +#[test] +fn paragraph_span_covers_the_full_multi_line_range() { + let source = "first line\nsecond line\n\nthird line"; + let blocks = scan(source); + let p1 = first_paragraph(&blocks); + assert_eq!(0, p1.span.start_offset); + assert_eq!(utf16_len("first line\nsecond line"), p1.span.end_offset); + assert_eq!(1, p1.span.start_line); + assert_eq!(2, p1.span.end_line); +} + +#[test] +fn paragraph_segment_map_maps_text_offsets_to_source_offsets() { + let source = "# Heading\n\nhello world"; + let blocks = scan(source); + let paragraph = first_paragraph(&blocks); + // 'hello world' lives at source offset 11 (after '# Heading\n\n') + assert_eq!(SegmentOffset::new(0, 11), paragraph.segment_map[0]); +} + +#[test] +fn inline_markup_is_never_stripped_block_text_is_the_raw_source() { + let source = "Maya borrowed *Emma*, see [docs](https://x.test) and `code`."; + let blocks = scan(source); + assert_eq!(source, first_paragraph(&blocks).text); +} + +#[test] +fn astral_paragraph_span_end_offset_is_utf16_code_units() { + let source = "🎉 hello"; + assert_eq!(8, utf16_len(source)); + let blocks = scan(source); + assert_eq!(1, blocks.len()); + let Block::Paragraph(p) = &blocks[0] else { + panic!("expected paragraph") + }; + assert_eq!(0, p.span.start_offset); + assert_eq!(8, p.span.end_offset); +} + +// ── Fence tests ────────────────────────────────────────────────────── + +#[test] +fn scan_recognizes_a_fenced_code_block_with_info_string() { + let source = "# Title\n\n```json\n{ \"a\": 1 }\n```\n"; + let blocks = scan(source); + let fence = first_fence(&blocks); + assert_eq!("json", fence.info); + assert_eq!("{ \"a\": 1 }\n", fence.body); +} + +#[test] +fn scan_tolerates_a_fence_with_no_info_string() { + let blocks = scan("```\nplain body\n```"); + let fence = first_fence(&blocks); + assert_eq!("", fence.info); + assert_eq!("plain body\n", fence.body); +} + +#[test] +fn scan_does_not_split_paragraphs_across_a_fence() { + let source = "paragraph above\n\n```\nbody\n```\n\nparagraph below"; + let blocks = scan(source); + assert_eq!(vec!["paragraph", "fence", "paragraph"], kinds(&blocks)); +} + +// ── Table tests ─────────────────────────────────────────────────────── + +#[test] +fn scan_recognizes_a_gfm_table_with_header_delimiter_rows() { + let source = "| name | age |\n|------|-----|\n| Bob | 30 |\n| Eve | 25 |\n"; + let blocks = scan(source); + let table = first_table(&blocks); + assert_eq!( + vec!["name".to_string(), "age".to_string()], + table.header.cells + ); + assert_eq!(2, table.rows.len()); + assert_eq!( + vec!["Bob".to_string(), "30".to_string()], + table.rows[0].cells + ); + assert_eq!( + vec!["Eve".to_string(), "25".to_string()], + table.rows[1].cells + ); +} + +#[test] +fn a_line_that_looks_like_a_row_but_has_no_following_delimiter_is_a_paragraph() { + let blocks = scan("| not | a | table |"); + assert!(matches!(&blocks[0], Block::Paragraph(_))); +} + +#[test] +fn table_rows_expose_a_source_span_per_cell_that_slices_back_to_the_trimmed_cell_text() { + let source = "# T\n\nthese rows:\n\n| a | bb |\n| - | --- |\n| 1 | 222 |"; + let blocks = scan(source); + let table = first_table(&blocks); + let row = &table.rows[0]; + assert_eq!(2, row.cell_spans.len()); + assert_eq!("1", slice(source, row.cell_spans[0])); + assert_eq!("222", slice(source, row.cell_spans[1])); + // The header row carries cell spans too. + assert_eq!("bb", slice(source, table.header.cell_spans[1])); +} + +#[test] +fn a_single_column_gfm_table_parses_as_a_table_not_paragraphs() { + let source = "# T\n\nthese:\n\n| n |\n| - |\n| 7 |\n| 8 |"; + let blocks = scan(source); + let table = first_table(&blocks); + assert_eq!(vec!["n".to_string()], table.header.cells); + let rows: Vec> = table.rows.iter().map(|r| r.cells.clone()).collect(); + assert_eq!(vec![vec!["7".to_string()], vec!["8".to_string()]], rows); + assert_eq!("7", slice(source, table.rows[0].cell_spans[0])); +} + +// ── Thematic break tests ───────────────────────────────────────────── + +#[test] +fn recognizes_thematic_break() { + for mark in ["---", "***", "___", "----", "* * *"] { + let blocks = scan(&format!("a\n\n{mark}\n\nb")); + assert_eq!( + vec!["paragraph", "thematic_break", "paragraph"], + kinds(&blocks), + "mark = {mark}" + ); + } +} + +// ── List item tests ────────────────────────────────────────────────── + +#[test] +fn scan_recognizes_unordered_list_items() { + let blocks = scan("- Given I have 100\n- When I withdraw 40\n- Then I should have 60"); + assert_eq!(vec!["list_item", "list_item", "list_item"], kinds(&blocks)); + let Block::ListItem(first) = &blocks[0] else { + panic!("expected list item") + }; + assert!(!first.ordered); + assert_eq!("Given I have 100", first.text); +} + +#[test] +fn scan_recognizes_ordered_list_items() { + let blocks = scan("1. First step\n2. Second step"); + assert_eq!(vec!["list_item", "list_item"], kinds(&blocks)); + let Block::ListItem(first) = &blocks[0] else { + panic!("expected list item") + }; + assert!(first.ordered); +} + +// ── Blockquote tests ───────────────────────────────────────────────── + +#[test] +fn scan_recognizes_blockquotes() { + let blocks = scan("> Given I have 100\n> When I withdraw 40"); + assert_eq!(1, blocks.len()); + let Block::Blockquote(bq) = &blocks[0] else { + panic!("expected blockquote") + }; + assert_eq!("Given I have 100\nWhen I withdraw 40", bq.text); +} + +#[test] +fn blockquote_text_drops_the_prefix_per_line_with_one_segment_entry_each() { + let source = "> first *line*\n> second line"; + let blocks = scan(source); + let Block::Blockquote(quote) = &blocks[0] else { + panic!("expected blockquote") + }; + assert_eq!("first *line*\nsecond line", quote.text); + assert_eq!( + vec![ + SegmentOffset::new(0, 2), + SegmentOffset::new(utf16_len("first *line*\n"), utf16_len("> first *line*\n> ")), + ], + quote.segment_map + ); +} diff --git a/rust/var-core/tests/sentences_test.rs b/rust/var-core/tests/sentences_test.rs new file mode 100644 index 00000000..cca6c5d8 --- /dev/null +++ b/rust/var-core/tests/sentences_test.rs @@ -0,0 +1,104 @@ +//! Port of `SentencesTest.java` / `sentences.test.ts`. The `resultListIsImmutable` +//! case is dropped (Rust `Vec` is owned/immutable). + +use var_core::offsets::utf16_len; +use var_core::sentences::{Sentence, split_sentences}; + +fn texts(sentences: &[Sentence]) -> Vec { + sentences.iter().map(|s| s.text.clone()).collect() +} + +#[test] +fn splits_a_paragraph_on_periods_question_marks_exclamation_marks() { + let result = split_sentences("First sentence. Second one? Third one!"); + assert_eq!( + vec!["First sentence.", "Second one?", "Third one!"], + texts(&result) + ); +} + +#[test] +fn keeps_offsets_relative_to_the_input_text() { + let result = split_sentences("Alpha. Beta."); + assert_eq!( + vec![Sentence::new("Alpha.", 0, 6), Sentence::new("Beta.", 7, 12)], + result + ); +} + +#[test] +fn does_not_split_inside_numeric_literals() { + let result = split_sentences("The price is $1.50 today."); + assert_eq!(vec!["The price is $1.50 today."], texts(&result)); +} + +#[test] +fn does_not_split_on_common_abbreviations() { + let result = split_sentences("Use e.g. coffee. It works."); + assert_eq!(vec!["Use e.g. coffee.", "It works."], texts(&result)); +} + +#[test] +fn treats_a_blank_line_as_a_sentence_boundary() { + let result = split_sentences("First.\n\nSecond."); + assert_eq!(vec!["First.", "Second."], texts(&result)); +} + +#[test] +fn treats_a_backtick_code_span_as_a_single_token() { + let result = split_sentences("Run `npm test` first. Then `git push`."); + assert_eq!( + vec!["Run `npm test` first.", "Then `git push`."], + texts(&result) + ); +} + +#[test] +fn the_final_sentence_does_not_require_a_terminator() { + let result = split_sentences("Alpha. Beta"); + assert_eq!(vec!["Alpha.", "Beta"], texts(&result)); +} + +#[test] +fn does_not_split_on_terminators_inside_a_double_quoted_string() { + let result = split_sentences("Alpha \"with . and ? inside\" beta. Gamma."); + assert_eq!( + vec!["Alpha \"with . and ? inside\" beta.", "Gamma."], + texts(&result) + ); +} + +#[test] +fn splits_on_a_single_newline_gherkin_style_line_per_step() { + let result = split_sentences("Given I greet \"world\"\nThen the greeting is \"Hello, world!\""); + assert_eq!( + vec![ + "Given I greet \"world\"", + "Then the greeting is \"Hello, world!\"" + ], + texts(&result) + ); +} + +#[test] +fn splits_between_terminators_outside_quoted_strings_ignoring_those_inside() { + let result = split_sentences("Alpha \"with ! inside\". Beta \"and ? inside\"!"); + assert_eq!( + vec!["Alpha \"with ! inside\".", "Beta \"and ? inside\"!"], + texts(&result) + ); +} + +#[test] +fn astral_character_keeps_offsets_correct() { + let text = "Party time 🎉! Next one."; + let result = split_sentences(text); + assert_eq!(vec!["Party time 🎉!", "Next one."], texts(&result)); + let first = &result[0]; + assert_eq!(0, first.start_offset); + assert_eq!(utf16_len("Party time 🎉!"), first.end_offset); + assert_eq!( + first.text, + var_core::offsets::utf16_slice(text, first.start_offset, first.end_offset) + ); +} diff --git a/rust/var-core/tests/smoke_test.rs b/rust/var-core/tests/smoke_test.rs new file mode 100644 index 00000000..eb61c7eb --- /dev/null +++ b/rust/var-core/tests/smoke_test.rs @@ -0,0 +1,6 @@ +//! Port of `SmokeTest.java` — proves the crate compiles and tests run. + +#[test] +fn module_is_importable_and_testable() { + assert_eq!(2, 1 + 1); +} diff --git a/rust/var-core/tests/span_test.rs b/rust/var-core/tests/span_test.rs new file mode 100644 index 00000000..d9054d48 --- /dev/null +++ b/rust/var-core/tests/span_test.rs @@ -0,0 +1,70 @@ +//! Port of `SpanTest.java` / `span.test.ts`. + +use var_core::offsets::utf16_len; +use var_core::span::Span; + +#[test] +fn span_from_offsets_computes_line_and_column_for_a_single_line_source() { + let source = "hello world"; + let span = Span::from_offsets(source, 6, 11); + assert_eq!( + span, + Span { + start_offset: 6, + end_offset: 11, + start_line: 1, + start_col: 7, + end_line: 1, + end_col: 12 + } + ); +} + +#[test] +fn span_from_offsets_handles_multi_line_sources() { + let source = "line one\nline two\nline three"; + // 'two' starts at offset 14, ends at 17 + let span = Span::from_offsets(source, 14, 17); + assert_eq!( + span, + Span { + start_offset: 14, + end_offset: 17, + start_line: 2, + start_col: 6, + end_line: 2, + end_col: 9 + } + ); +} + +#[test] +fn span_from_offsets_handles_a_range_crossing_a_newline() { + let source = "ab\ncd"; + // From offset 1 ('b') to 4 ('d') + let span = Span::from_offsets(source, 1, 4); + assert_eq!( + span, + Span { + start_offset: 1, + end_offset: 4, + start_line: 1, + start_col: 2, + end_line: 2, + end_col: 2 + } + ); +} + +#[test] +fn span_from_offsets_handles_astral_chars_natively() { + let s = "a😀b"; // 😀 is a surrogate pair: 2 UTF-16 code units + assert_eq!(4, utf16_len(s)); // UTF-16 code units, same as JS .length + let sp = Span::from_offsets(s, 0, 4); + assert_eq!(0, sp.start_offset); + assert_eq!(4, sp.end_offset); + assert_eq!(1, sp.start_line); + assert_eq!(1, sp.start_col); + assert_eq!(1, sp.end_line); + assert_eq!(5, sp.end_col); +} diff --git a/rust/var-core/tests/step_role_test.rs b/rust/var-core/tests/step_role_test.rs new file mode 100644 index 00000000..337d9046 --- /dev/null +++ b/rust/var-core/tests/step_role_test.rs @@ -0,0 +1,28 @@ +//! Port of `StepRoleTest.java` / `step-role.test.ts`. + +use var_core::step_kind::StepKind; +use var_core::step_role::{Neighbours, infer_step_role}; + +#[test] +fn no_step_after_the_selection_means_sensor_expectation_last() { + let neighbours = Neighbours::new(vec![StepKind::Stimulus], vec![]); + assert_eq!(StepKind::Sensor, infer_step_role(&neighbours)); +} + +#[test] +fn a_sensor_follows_and_no_action_sits_between_means_action() { + let neighbours = Neighbours::new(vec![StepKind::Stimulus], vec![StepKind::Sensor]); + assert_eq!(StepKind::Stimulus, infer_step_role(&neighbours)); +} + +#[test] +fn nothing_before_and_a_step_after_means_context() { + let neighbours = Neighbours::new(vec![], vec![StepKind::Stimulus]); + assert_eq!(StepKind::Stimulus, infer_step_role(&neighbours)); +} + +#[test] +fn otherwise_means_action() { + let neighbours = Neighbours::new(vec![StepKind::Stimulus], vec![StepKind::Stimulus]); + assert_eq!(StepKind::Stimulus, infer_step_role(&neighbours)); +} diff --git a/rust/var-core/tests/structurer_test.rs b/rust/var-core/tests/structurer_test.rs new file mode 100644 index 00000000..1e0aeb69 --- /dev/null +++ b/rust/var-core/tests/structurer_test.rs @@ -0,0 +1,105 @@ +//! Port of `StructurerTest.java` / `structurer.test.ts`. + +use var_core::ast::Block; +use var_core::scanner::scan; +use var_core::structurer::structure; + +#[test] +fn every_paragraph_becomes_a_candidate_example_scoped_by_the_headings_above_it() { + let source = "# Withdrawing cash\n\nGiven I have $100 in my account\n\n# Overdraft\n\nGiven I have $10 in my account"; + let var_doc = structure("test.md", source, scan(source)); + assert_eq!(2, var_doc.examples.len()); + assert_eq!( + vec!["Withdrawing cash".to_string()], + var_doc.examples[0].scope_stack + ); + assert_eq!( + vec!["Overdraft".to_string()], + var_doc.examples[1].scope_stack + ); +} + +#[test] +fn two_paragraphs_under_the_same_heading_each_become_a_separate_example() { + let source = "## Example\n\nFirst paragraph.\n\nSecond paragraph."; + let var_doc = structure("test.md", source, scan(source)); + assert_eq!(2, var_doc.examples.len()); + assert!(matches!(var_doc.examples[0].body[0], Block::Paragraph(_))); + assert!(matches!(var_doc.examples[1].body[0], Block::Paragraph(_))); + assert_eq!(vec!["Example".to_string()], var_doc.examples[0].scope_stack); + assert_eq!(vec!["Example".to_string()], var_doc.examples[1].scope_stack); +} + +#[test] +fn nested_headings_stack_into_an_outer_to_inner_scope_stack() { + let source = "## Outer\n\nbody one\n\n### Inner\n\nbody two"; + let var_doc = structure("test.md", source, scan(source)); + assert_eq!(2, var_doc.examples.len()); + assert_eq!(vec!["Outer".to_string()], var_doc.examples[0].scope_stack); + assert_eq!( + vec!["Outer".to_string(), "Inner".to_string()], + var_doc.examples[1].scope_stack + ); +} + +#[test] +fn a_heading_at_the_same_level_pops_the_previous_sibling_off_the_scope_stack() { + let source = "## A\n\nbody A\n\n## B\n\nbody B"; + let var_doc = structure("test.md", source, scan(source)); + assert_eq!(2, var_doc.examples.len()); + assert_eq!(vec!["A".to_string()], var_doc.examples[0].scope_stack); + assert_eq!(vec!["B".to_string()], var_doc.examples[1].scope_stack); +} + +#[test] +fn a_paragraph_with_no_enclosing_heading_has_an_empty_scope_stack() { + let source = "standalone paragraph"; + let var_doc = structure("p.md", source, scan(source)); + assert_eq!(1, var_doc.examples.len()); + assert!(var_doc.examples[0].scope_stack.is_empty()); +} + +#[test] +fn headings_on_their_own_produce_no_examples() { + let source = "# Title only\n\n## Sub-title\n\n### Another"; + let var_doc = structure("h.md", source, scan(source)); + assert_eq!(0, var_doc.examples.len()); +} + +#[test] +fn structure_preserves_the_source_string_verbatim() { + let source = "# Hi\n\nbody"; + let var_doc = structure("p.md", source, scan(source)); + assert_eq!(source, var_doc.source); + assert_eq!("p.md", var_doc.path); +} + +#[test] +fn orphan_tables_and_fences_are_recorded_on_the_var_doc() { + let source = "| name | age |\n|------|-----|\n| Bob | 30 |"; + let var_doc = structure("o.md", source, scan(source)); + assert_eq!(1, var_doc.orphan_attachments.len()); + assert!(matches!( + var_doc.orphan_attachments[0], + var_core::ast::TableOrFence::Table(_) + )); +} + +#[test] +fn a_table_right_after_a_paragraph_attaches_to_that_paragraph_not_orphan() { + let source = + "## Example\n\nGiven these users:\n\n| name | age |\n|------|-----|\n| Bob | 30 |"; + let var_doc = structure("o.md", source, scan(source)); + assert_eq!(0, var_doc.orphan_attachments.len()); + let example = &var_doc.examples[0]; + assert!(example.body.iter().any(|b| matches!(b, Block::Table(_)))); +} + +#[test] +fn a_heading_between_a_paragraph_and_a_fence_makes_the_fence_an_orphan() { + let source = "## A\n\npara\n\n## B\n\n```\nfenced body\n```\n"; + let var_doc = structure("h.md", source, scan(source)); + assert_eq!(1, var_doc.orphan_attachments.len()); + let example = &var_doc.examples[0]; + assert!(!example.body.iter().any(|b| matches!(b, Block::Fence(_)))); +} diff --git a/rust/var-core/tests/table_cells_test.rs b/rust/var-core/tests/table_cells_test.rs new file mode 100644 index 00000000..9464f061 --- /dev/null +++ b/rust/var-core/tests/table_cells_test.rs @@ -0,0 +1,105 @@ +//! Port of `TableCellsTest.java` / the table-cell span cases of `table-cells.ts`. +//! The `cellsListIsImmutable` case is dropped (Rust `Vec` is owned/immutable by +//! construction). + +use var_core::offsets::utf16_slice; +use var_core::span::Span; +use var_core::table_cells::parse_row_cells; + +fn slice(source: &str, span: Span) -> &str { + utf16_slice(source, span.start_offset, span.end_offset) +} + +#[test] +fn basic_row_returns_trimmed_cells() { + let source = "| a | b |"; + let result = parse_row_cells(source, 0, source); + assert_eq!(vec!["a".to_string(), "b".to_string()], result.cells); +} + +#[test] +fn basic_row_spans_point_to_trimmed_text() { + let source = "| a | b |"; + let result = parse_row_cells(source, 0, source); + assert_eq!(2, result.cell_spans.len()); + assert_eq!("a", slice(source, result.cell_spans[0])); + assert_eq!("b", slice(source, result.cell_spans[1])); +} + +#[test] +fn extra_padding_is_trimmed() { + let source = "| Bob | 30 |"; + let result = parse_row_cells(source, 0, source); + assert_eq!(vec!["Bob".to_string(), "30".to_string()], result.cells); + assert_eq!("Bob", slice(source, result.cell_spans[0])); + assert_eq!("30", slice(source, result.cell_spans[1])); +} + +#[test] +fn no_pipe_returns_empty() { + let source = "hello world"; + let result = parse_row_cells(source, 0, source); + assert!(result.cells.is_empty()); + assert!(result.cell_spans.is_empty()); +} + +#[test] +fn single_pipe_returns_empty() { + let source = "| only one"; + let result = parse_row_cells(source, 0, source); + assert!(result.cells.is_empty()); + assert!(result.cell_spans.is_empty()); +} + +#[test] +fn single_column_table_row() { + let source = "| n |"; + let result = parse_row_cells(source, 0, source); + assert_eq!(vec!["n".to_string()], result.cells); + assert_eq!("n", slice(source, result.cell_spans[0])); +} + +#[test] +fn line_start_offset_shifts_spans() { + let prefix = "# T\n\n"; + let row = "| a | b |"; + let source = format!("{prefix}{row}"); + let line_start = prefix.len(); // ASCII prefix: byte len == utf16 len + let result = parse_row_cells(row, line_start, &source); + assert_eq!(vec!["a".to_string(), "b".to_string()], result.cells); + assert_eq!("a", slice(&source, result.cell_spans[0])); + assert_eq!("b", slice(&source, result.cell_spans[1])); +} + +#[test] +fn astral_cell_shifts_following_span() { + let source = "| 🎉 | a |"; // U+1F389 PARTY POPPER (2 UTF-16 code units) + let result = parse_row_cells(source, 0, source); + assert_eq!(vec!["🎉".to_string(), "a".to_string()], result.cells); + assert_eq!(2, result.cell_spans[0].start_offset); + assert_eq!(4, result.cell_spans[0].end_offset); + assert_eq!(7, result.cell_spans[1].start_offset); + assert_eq!(8, result.cell_spans[1].end_offset); + assert_eq!("🎉", slice(source, result.cell_spans[0])); + assert_eq!("a", slice(source, result.cell_spans[1])); +} + +#[test] +fn three_column_row() { + let source = "| name | age | city |"; + let result = parse_row_cells(source, 0, source); + assert_eq!( + vec!["name".to_string(), "age".to_string(), "city".to_string()], + result.cells + ); + assert_eq!("name", slice(source, result.cell_spans[0])); + assert_eq!("age", slice(source, result.cell_spans[1])); + assert_eq!("city", slice(source, result.cell_spans[2])); +} + +#[test] +fn delimiter_row_gives_dashes() { + let source = "| --- | --- |"; + let result = parse_row_cells(source, 0, source); + assert_eq!(vec!["---".to_string(), "---".to_string()], result.cells); +} diff --git a/rust/var-runner/Cargo.toml b/rust/var-runner/Cargo.toml new file mode 100644 index 00000000..243961f1 --- /dev/null +++ b/rust/var-runner/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "var-runner" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +var-core = { path = "../var-core" } +var-config = { path = "../var-config" } +regex = "1" + +[lib] +name = "var_runner" +path = "src/lib.rs" diff --git a/rust/var-runner/src/baseline_store.rs b/rust/var-runner/src/baseline_store.rs new file mode 100644 index 00000000..b8723831 --- /dev/null +++ b/rust/var-runner/src/baseline_store.rs @@ -0,0 +1,28 @@ +//! The filesystem `BaselineStore`: the committed drift baseline lives at the +//! project root as `var.lock.json`. The core owns the format; this only reads +//! and writes the raw text. + +use std::path::{Path, PathBuf}; +use var_core::drift::BaselineStore; + +pub struct FileBaselineStore { + path: PathBuf, +} + +impl FileBaselineStore { + pub fn new(root: &Path) -> FileBaselineStore { + FileBaselineStore { + path: root.join("var.lock.json"), + } + } +} + +impl BaselineStore for FileBaselineStore { + fn read(&self) -> Option { + std::fs::read_to_string(&self.path).ok() + } + + fn write(&mut self, contents: &str) { + let _ = std::fs::write(&self.path, contents); + } +} diff --git a/rust/var-runner/src/discovery.rs b/rust/var-runner/src/discovery.rs new file mode 100644 index 00000000..36d34960 --- /dev/null +++ b/rust/var-runner/src/discovery.rs @@ -0,0 +1,94 @@ +//! Spec discovery: the shared glob→regex semantics (matching the Python/Ruby +//! runners byte-for-byte on `**`, `*`, `?`), recursive file walk, include/exclude. + +use regex::Regex; +use std::path::{Path, PathBuf}; +use var_config::VarConfig; + +/// Translate a glob (`/**/`, `/**`, `**/`, `**`, `*`, `?`) to an anchored regex. +/// Port of `var_runner.discovery._glob_to_regex`. +pub fn glob_to_regex(pattern: &str) -> Regex { + let chars: Vec = pattern.chars().collect(); + let n = chars.len(); + let starts = |i: usize, pat: &str| { + pat.chars() + .enumerate() + .all(|(k, pc)| chars.get(i + k) == Some(&pc)) + }; + + let mut out = String::from("^"); + let mut i = 0; + while i < n { + if chars[i] == '/' && starts(i, "/**/") { + out.push_str("/(?:.+/)?"); + i += 4; + } else if chars[i] == '/' && starts(i, "/**") && i + 3 == n { + out.push_str("(?:/.*)?"); + i += 3; + } else if chars[i] == '*' && starts(i, "**/") { + out.push_str("(?:.*/)?"); + i += 3; + } else if chars[i] == '*' && starts(i, "**") { + out.push_str(".*"); + i += 2; + } else if chars[i] == '*' { + out.push_str("[^/]*"); + i += 1; + } else if chars[i] == '?' { + out.push_str("[^/]"); + i += 1; + } else { + out.push_str(®ex::escape(&chars[i].to_string())); + i += 1; + } + } + out.push('$'); + Regex::new(&out).expect("valid glob regex") +} + +fn matches_any(rel: &str, globs: &[String]) -> bool { + globs.iter().any(|g| glob_to_regex(g).is_match(rel)) +} + +/// The path relative to `root`, forward-slashed. Falls back to the file name +/// when `path` is not under `root`. +fn rel_posix(path: &Path, root: &Path) -> String { + let rel = path.strip_prefix(root).unwrap_or(path); + rel.components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + +fn walk(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, out); + } else if path.is_file() { + out.push(path); + } + } +} + +/// True iff `path` (relative to `root`) matches an include glob and no exclude. +pub fn match_spec(path: &Path, include: &[String], exclude: &[String], root: &Path) -> bool { + let rel = rel_posix(path, root); + matches_any(&rel, include) && !matches_any(&rel, exclude) +} + +/// Files under `root` matching any `docs.include` glob and no `docs.exclude`, +/// sorted. +pub fn find_specs(config: &VarConfig, root: &Path) -> Vec { + let mut files = Vec::new(); + walk(root, &mut files); + let mut kept: Vec = files + .into_iter() + .filter(|p| match_spec(p, &config.docs_include, &config.docs_exclude, root)) + .collect(); + kept.sort(); + kept +} diff --git a/rust/var-runner/src/lib.rs b/rust/var-runner/src/lib.rs new file mode 100644 index 00000000..8cb37679 --- /dev/null +++ b/rust/var-runner/src/lib.rs @@ -0,0 +1,21 @@ +//! `var-runner` — the imperative shell shared by var test-runner adapters. +//! +//! Spec discovery (the shared glob semantics), planning/running examples, +//! failure rendering, and the filesystem `var.lock.json` baseline store for +//! drift. Contains no pipeline logic — it delegates to `var-core`. Steps are +//! supplied by the caller (Rust compiles step files in; there is no dynamic +//! `load_steps`), as a `Registry` plus a context factory. +//! +// `run_example` surfaces var-core's `StepFailure` by value, matching that +// crate's own `#![allow(clippy::result_large_err)]` public-API choice. +#![allow(clippy::result_large_err)] + +pub mod baseline_store; +pub mod discovery; +pub mod render; +pub mod run; + +pub use baseline_store::FileBaselineStore; +pub use discovery::{find_specs, match_spec}; +pub use render::render_failure; +pub use run::{example_names, plan_spec, run_example}; diff --git a/rust/var-runner/src/render.rs b/rust/var-runner/src/render.rs new file mode 100644 index 00000000..354e0a9d --- /dev/null +++ b/rust/var-runner/src/render.rs @@ -0,0 +1,29 @@ +//! Pure human-readable rendering of a step failure, anchored to the `.md`. +//! Port of `var_runner.render.render_failure`; reuses the core diff payloads. + +use var_core::error::StepFailure; + +pub fn render_failure(failure: &StepFailure, _source: &str, path: &str) -> String { + let error = &failure.error; + if let Some(cells) = error.as_cell_mismatch() { + let mut lines = vec![format!("Cell mismatch in {path}:")]; + let failing: Vec<_> = cells.iter().filter(|c| !c.ok).collect(); + if failing.is_empty() { + lines.push(" (no failing cells)".to_string()); + } + for cell in failing { + lines.push(format!( + " line {} | column '{}' — expected: {:?}, actual: {:?}", + cell.span.start_line, cell.column, cell.expected, cell.actual + )); + } + return lines.join("\n"); + } + if let Some(diff) = error.as_doc_string_mismatch() { + return format!( + "Doc string mismatch at line {}:\n expected: {:?}\n actual: {:?}", + diff.span.start_line, diff.expected, diff.actual + ); + } + error.message() +} diff --git a/rust/var-runner/src/run.rs b/rust/var-runner/src/run.rs new file mode 100644 index 00000000..a3d7f7a6 --- /dev/null +++ b/rust/var-runner/src/run.rs @@ -0,0 +1,54 @@ +//! Planning and running examples, plus the adapter display-name rule. + +use std::collections::HashMap; +use var_core::error::StepFailure; +use var_core::execute::{ExecutePorts, collect_examples}; +use var_core::parse::parse; +use var_core::plan::{ExecutionPlan, plan}; +use var_core::registry::Registry; +use var_core::value::Value; + +/// Parse + plan one spec. +pub fn plan_spec(name: &str, source: &str, registry: &Registry) -> ExecutionPlan { + plan(&parse(name, source), registry) +} + +/// The per-example display names: the innermost heading (or the body-derived +/// name when there is no heading), de-duplicated with a `[n]` suffix — the rule +/// the pytest/unittest adapters use, so header-bound rows share their binding +/// sentence's name. +pub fn example_names(plan: &ExecutionPlan) -> Vec { + let mut seen: HashMap = HashMap::new(); + plan.examples + .iter() + .map(|ex| { + let base = ex + .scope_stack + .last() + .cloned() + .unwrap_or_else(|| ex.name.clone()); + let idx = *seen.get(&base).unwrap_or(&0); + seen.insert(base.clone(), idx + 1); + if idx == 0 { + base + } else { + format!("{base}[{idx}]") + } + }) + .collect() +} + +/// Run a single example by index. `context_factory` maps a step file to its +/// fresh initial state. +pub fn run_example( + plan: &ExecutionPlan, + context_factory: &dyn Fn(&str) -> Value, + index: usize, +) -> Result<(), StepFailure> { + let ports = ExecutePorts { + reporter: Box::new(|_| {}), + create_context: Some(Box::new(|file: &str| context_factory(file))), + observer: None, + }; + collect_examples(plan, &ports)[index].run() +} diff --git a/rust/var-runner/tests/runner.rs b/rust/var-runner/tests/runner.rs new file mode 100644 index 00000000..0cac2e7b --- /dev/null +++ b/rust/var-runner/tests/runner.rs @@ -0,0 +1,97 @@ +//! Unit tests for the runner shell: glob discovery, spec finding, and the +//! filesystem baseline store driving drift reconciliation. + +use std::path::PathBuf; +use var_config::VarConfig; +use var_core::drift::{BaselineStore, reconcile_drift}; +use var_core::handler::Handler; +use var_core::parse::parse; +use var_core::plan::plan; +use var_core::registry::{add_step, create_registry}; +use var_core::step_kind::StepKind; +use var_runner::discovery::glob_to_regex; +use var_runner::{FileBaselineStore, find_specs}; + +fn tmp(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("var-runner-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn glob_star_stays_within_one_segment() { + let re = glob_to_regex("*.md"); + assert!(re.is_match("a.md")); + assert!(!re.is_match("sub/a.md")); +} + +#[test] +fn leading_doublestar_matches_zero_or_more_segments() { + let re = glob_to_regex("**/*.md"); + assert!(re.is_match("a.md")); + assert!(re.is_match("sub/a.md")); + assert!(re.is_match("x/y/a.md")); +} + +#[test] +fn nested_doublestar_and_trailing_doublestar() { + assert!(glob_to_regex("specs/**/*.md").is_match("specs/a.md")); + assert!(glob_to_regex("specs/**/*.md").is_match("specs/x/a.md")); + let wip = glob_to_regex("specs/wip/**"); + assert!(wip.is_match("specs/wip")); + assert!(wip.is_match("specs/wip/draft.md")); +} + +#[test] +fn find_specs_honours_include_and_exclude() { + let root = tmp("find"); + std::fs::write(root.join("a.md"), "x").unwrap(); + std::fs::write(root.join("README.md"), "x").unwrap(); + std::fs::create_dir_all(root.join("sub")).unwrap(); + std::fs::write(root.join("sub/b.md"), "x").unwrap(); + + let flat = VarConfig { + docs_include: vec!["*.md".to_string()], + docs_exclude: vec!["README.md".to_string()], + ..Default::default() + }; + let names: Vec = find_specs(&flat, &root) + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names, vec!["a.md"]); + + let recursive = VarConfig { + docs_include: vec!["**/*.md".to_string()], + docs_exclude: vec!["README.md".to_string()], + ..Default::default() + }; + assert_eq!(find_specs(&recursive, &root).len(), 2); // a.md + sub/b.md +} + +#[test] +fn baseline_store_round_trips_and_reconcile_writes_lock() { + let root = tmp("drift"); + let mut store = FileBaselineStore::new(&root); + assert!(store.read().is_none()); + + let registry = add_step( + &create_registry(), + "I greet {string}", + "s.rs", + 1, + Handler::sync1(|state, _n| Ok(Some(state))), + Some(StepKind::Stimulus), + ) + .unwrap(); + let source = "# Hi\n\nI greet \"world\"."; + let doc = parse("hi.md", source); + let execution = plan(&doc, ®istry); + + // Clean run: no drift, and the baseline is written. + let drifts = reconcile_drift(&mut store, "hi.md", source, &doc, &execution, false); + assert!(drifts.is_empty()); + assert!(store.read().is_some(), "var.lock.json should be written"); + assert!(root.join("var.lock.json").is_file()); +} diff --git a/rust/var/Cargo.toml b/rust/var/Cargo.toml new file mode 100644 index 00000000..07e4a665 --- /dev/null +++ b/rust/var/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "var" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +var-core = { path = "../var-core" } + +[lib] +name = "var" +path = "src/lib.rs" + +[[test]] +name = "conformance" +path = "tests/conformance.rs" diff --git a/rust/var/src/lib.rs b/rust/var/src/lib.rs new file mode 100644 index 00000000..a9d775b8 --- /dev/null +++ b/rust/var/src/lib.rs @@ -0,0 +1,23 @@ +//! `var` — the author facade over [`var_core`]. +//! +//! Rust uses the **injected-Registrar** author model (ADR 0006): a step file +//! exposes a `register(Registry) -> Registry` that adds its steps explicitly, +//! rather than the module-scope accumulator TypeScript/Python use. There is +//! therefore no `defineState`/`steps()` side-effecting global here — the +//! "author API" is `var_core::registry` plus the handler/value types, curated +//! into a single import surface. This crate is also where the +//! registry/plan/trace conformance gates live (see `tests/conformance.rs`), +//! mirroring the Java `var` module: they need both `var-core`'s pipeline and +//! the author surface every bundle fixture is written against. + +mod steps; +pub use steps::Steps; + +pub use var_core::error::HandlerError; +pub use var_core::handler::{Handler, HandlerReturn, StepReturn}; +pub use var_core::registry::{ + CustomParameterType, FormatFn, ParseFn, Registry, StepRegistration, add_step, create_registry, + define_parameter_type, define_parameter_type_with_format, +}; +pub use var_core::step_kind::StepKind; +pub use var_core::value::Value; diff --git a/rust/var/src/steps.rs b/rust/var/src/steps.rs new file mode 100644 index 00000000..63f18d99 --- /dev/null +++ b/rust/var/src/steps.rs @@ -0,0 +1,102 @@ +//! The ergonomic author API: a `Steps` builder over `var-core`'s registry, so +//! step definitions read as `s.stimulus(expr, …)` / `s.sensor(expr, …)` — the +//! call name IS the kind, matching every other port (and what the LSP/ +//! tree-sitter dialect extracts). Mirrors the JVM `StateBinder`. +//! +//! The builder owns a `Registry` and folds each definition in with `var-core`'s +//! pure `add_step` / `define_parameter_type*`; nothing global is mutated. + +use var_core::handler::Handler; +use var_core::registry::{ + FormatFn, ParseFn, Registry, add_step, create_registry, define_parameter_type, + define_parameter_type_with_format, +}; +use var_core::step_kind::StepKind; + +pub struct Steps { + registry: Registry, +} + +impl Steps { + /// A builder over a fresh registry. + pub fn new() -> Steps { + Steps { + registry: create_registry(), + } + } + + /// A builder that continues folding into an existing registry. + pub fn from_registry(registry: Registry) -> Steps { + Steps { registry } + } + + /// Register a stimulus (drives the software; returns the whole next state). + pub fn stimulus( + &mut self, + expression: &str, + file: &str, + line: usize, + handler: Handler, + ) -> &mut Steps { + self.registry = add_step( + &self.registry, + expression, + file, + line, + handler, + Some(StepKind::Stimulus), + ) + .expect("valid stimulus expression"); + self + } + + /// Register a sensor (the read-only assertion; its return is compared). + pub fn sensor( + &mut self, + expression: &str, + file: &str, + line: usize, + handler: Handler, + ) -> &mut Steps { + self.registry = add_step( + &self.registry, + expression, + file, + line, + handler, + Some(StepKind::Sensor), + ) + .expect("valid sensor expression"); + self + } + + /// Declare a custom parameter type. + pub fn param(&mut self, name: &str, regexp: &str, parse: ParseFn) -> &mut Steps { + self.registry = define_parameter_type(&self.registry, name, regexp, parse); + self + } + + /// Declare a custom parameter type that also renders values for diffs. + pub fn param_with_format( + &mut self, + name: &str, + regexp: &str, + parse: ParseFn, + format: FormatFn, + ) -> &mut Steps { + self.registry = + define_parameter_type_with_format(&self.registry, name, regexp, parse, format); + self + } + + /// Consume the builder, yielding the accumulated registry. + pub fn into_registry(self) -> Registry { + self.registry + } +} + +impl Default for Steps { + fn default() -> Steps { + Steps::new() + } +} diff --git a/rust/var/tests/conformance.rs b/rust/var/tests/conformance.rs new file mode 100644 index 00000000..4fb968c2 --- /dev/null +++ b/rust/var/tests/conformance.rs @@ -0,0 +1,152 @@ +//! Registry / plan / trace conformance gates — the three stages deferred from +//! `var-core` (which gates only var-doc). Mirrors the Java `var` module's +//! `ConformanceTest`: for every bundle in the shared corpus, load its Rust step +//! fixture, build the registry, and assert the registry/plan/trace artifacts +//! byte-for-byte against the committed goldens. +//! +//! Fixtures live alongside every other language's `*.steps.*` in +//! `conformance/bundles//.steps.rs`, reached via `#[path]`. Each +//! exposes `register(Registry) -> Registry` and `state() -> Value` (the +//! per-example initial state). The bundle→fixture map is an explicit, +//! compiler-checked `match`, like Java's `loadFixture` switch. + +use std::fs; +use std::path::{Path, PathBuf}; + +use var::{Registry, create_registry}; +use var_core::canonical_json::canonical_stringify; +use var_core::conformance::{run_conformance, to_plan_artifact, to_registry_artifact}; +use var_core::parse::parse; +use var_core::plan::plan; +use var_core::value::Value; + +// Fixtures live in the shared corpus (siblings of every `*.steps.ts`), pulled +// in by path. Declared at the test's top level so the path base is +// `rust/var/tests/`. +#[path = "../../../conformance/bundles/01-roman-numerals/numerals.steps.rs"] +mod b01; +#[path = "../../../conformance/bundles/02-context-isolation/counter.steps.rs"] +mod b02; +#[path = "../../../conformance/bundles/03-expected-failure/division.steps.rs"] +mod b03; +#[path = "../../../conformance/bundles/04-tables-and-docstrings/echo.steps.rs"] +mod b04; +#[path = "../../../conformance/bundles/05-ambiguous-match/cukes.steps.rs"] +mod b05; +#[path = "../../../conformance/bundles/06-doc-string-mismatch/echo.steps.rs"] +mod b06; +#[path = "../../../conformance/bundles/07-row-check-mismatch/report.steps.rs"] +mod b07; +#[path = "../../../conformance/bundles/08-string-capture/greet.steps.rs"] +mod b08; +#[path = "../../../conformance/bundles/09-expected-message-mismatch/boom.steps.rs"] +mod b09; +#[path = "../../../conformance/bundles/10-error-fence-without-step/cukes.steps.rs"] +mod b10; +#[path = "../../../conformance/bundles/11-emoji-offsets/greet.steps.rs"] +mod b11; +#[path = "../../../conformance/bundles/12-combining-marks/greet.steps.rs"] +mod b12; +#[path = "../../../conformance/bundles/13-custom-parameter-type/airports.steps.rs"] +mod b13; +#[path = "../../../conformance/bundles/14-stateless-steps/squares.steps.rs"] +mod b14; +#[path = "../../../conformance/bundles/15-custom-parameter-format/money.steps.rs"] +mod b15; + +type RegisterFn = fn(Registry) -> Registry; +type StateFn = fn() -> Value; + +fn fixture(bundle: &str) -> (RegisterFn, StateFn) { + match bundle { + "01-roman-numerals" => (b01::register, b01::state), + "02-context-isolation" => (b02::register, b02::state), + "03-expected-failure" => (b03::register, b03::state), + "04-tables-and-docstrings" => (b04::register, b04::state), + "05-ambiguous-match" => (b05::register, b05::state), + "06-doc-string-mismatch" => (b06::register, b06::state), + "07-row-check-mismatch" => (b07::register, b07::state), + "08-string-capture" => (b08::register, b08::state), + "09-expected-message-mismatch" => (b09::register, b09::state), + "10-error-fence-without-step" => (b10::register, b10::state), + "11-emoji-offsets" => (b11::register, b11::state), + "12-combining-marks" => (b12::register, b12::state), + "13-custom-parameter-type" => (b13::register, b13::state), + "14-stateless-steps" => (b14::register, b14::state), + "15-custom-parameter-format" => (b15::register, b15::state), + other => panic!("no Rust step fixture for bundle {other}"), + } +} + +fn bundles_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../conformance/bundles") +} + +fn bundle_dirs() -> Vec { + let mut dirs: Vec = fs::read_dir(bundles_dir()) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.is_dir()) + .collect(); + dirs.sort(); + dirs +} + +fn name_of(dir: &Path) -> String { + dir.file_name().unwrap().to_string_lossy().into_owned() +} + +fn golden(dir: &Path, artifact: &str) -> String { + fs::read_to_string(dir.join("golden").join(artifact)).unwrap() +} + +#[test] +fn registry_matches_golden() { + let mut fails = Vec::new(); + for dir in bundle_dirs() { + let name = name_of(&dir); + let (register, _) = fixture(&name); + let registry = register(create_registry()); + let actual = canonical_stringify(&to_registry_artifact(®istry)); + if actual != golden(&dir, "registry.json") { + fails.push(name); + } + } + assert!(fails.is_empty(), "registry.json mismatches: {fails:?}"); +} + +#[test] +fn plan_matches_golden() { + let mut fails = Vec::new(); + for dir in bundle_dirs() { + let name = name_of(&dir); + let (register, _) = fixture(&name); + let registry = register(create_registry()); + let source = fs::read_to_string(dir.join("example.md")).unwrap(); + let doc = parse("example.md", &source); + let execution = plan(&doc, ®istry); + let actual = canonical_stringify(&to_plan_artifact(&execution)); + if actual != golden(&dir, "plan.json") { + fails.push(name); + } + } + assert!(fails.is_empty(), "plan.json mismatches: {fails:?}"); +} + +#[test] +fn trace_matches_golden() { + let mut fails = Vec::new(); + for dir in bundle_dirs() { + let name = name_of(&dir); + let (register, state) = fixture(&name); + let registry = register(create_registry()); + let source = fs::read_to_string(dir.join("example.md")).unwrap(); + let doc = parse("example.md", &source); + let artifacts = run_conformance(&doc, ®istry, &|| state()); + let actual = canonical_stringify(&artifacts.trace); + if actual != golden(&dir, "trace.json") { + fails.push(name); + } + } + assert!(fails.is_empty(), "trace.json mismatches: {fails:?}"); +} diff --git a/typescript/knip.json b/typescript/knip.json index cec44e51..3283e0eb 100644 --- a/typescript/knip.json +++ b/typescript/knip.json @@ -30,6 +30,7 @@ "tree-sitter-java", "tree-sitter-python", "tree-sitter-ruby", + "tree-sitter-rust", "tree-sitter-typescript" ] }, @@ -40,6 +41,7 @@ "tree-sitter-java", "tree-sitter-python", "tree-sitter-ruby", + "tree-sitter-rust", "tree-sitter-typescript" ] }, diff --git a/typescript/packages/var-language/package.json b/typescript/packages/var-language/package.json index ffed08d3..c5cb806f 100644 --- a/typescript/packages/var-language/package.json +++ b/typescript/packages/var-language/package.json @@ -29,6 +29,7 @@ "tree-sitter-java": "^0.23.5", "tree-sitter-python": "^0.25.0", "tree-sitter-ruby": "^0.23.1", + "tree-sitter-rust": "^0.24.0", "tree-sitter-typescript": "^0.23.2" }, "publishConfig": { diff --git a/typescript/packages/var-language/src/tree-sitter-dialects/rust.ts b/typescript/packages/var-language/src/tree-sitter-dialects/rust.ts new file mode 100644 index 00000000..569da473 --- /dev/null +++ b/typescript/packages/var-language/src/tree-sitter-dialects/rust.ts @@ -0,0 +1,77 @@ +import type { Node } from 'web-tree-sitter' +import type { HandlerParams } from '../step-defs.ts' +import type { LanguageSpec } from './types.ts' + +// Verified against tree-sitter-rust 0.24.0 and all 15 conformance bundles +// (2026-07-12). Rust authors steps through the `var::Steps` builder, so a step +// def is a method call `s.stimulus(...)` / `s.sensor(...)` — the method name is +// the kind, matching every other port. The expression is the FIRST string +// argument (anchored with `.`), so string literals inside the handler closure +// are never mistaken for it; the `steps`/`from_registry`/`into_registry` calls +// have other names, excluded by the #match filter. +const STEP_DEFINITION_QUERY = ` +(call_expression + function: (field_expression + field: (field_identifier) @function-name) + arguments: (arguments . (string_literal) @expression) + (#match? @function-name "^(stimulus|sensor)$") +) @root +` + +// A custom parameter type is `s.param("name", "regexp", …)` or +// `s.param_with_format("name", "regexp", …)`: name the first string, regexp the +// second — which may be a raw string (`r"£\\d+\\.\\d{2}"`), whose backslashes +// are literal. +const PARAMETER_TYPE_QUERY = ` +(call_expression + function: (field_expression + field: (field_identifier) @function-name) + arguments: (arguments + . + (string_literal) @name + . + [(string_literal) (raw_string_literal)] @regexp-value + ) + (#match? @function-name "^param") +) @root +` + +const ESCAPES: Readonly> = { + '\\': '\\', + '"': '"', + "'": "'", + n: '\n', + t: '\t', + r: '\r', + '0': '\0', +} + +// Decode one `escape_sequence` node's text (leading backslash included). +function decodeEscape(text: string): string { + const body = text.slice(1) + if (body.startsWith('u{')) return String.fromCodePoint(Number.parseInt(body.slice(2, -1), 16)) + if (body.startsWith('x')) return String.fromCharCode(Number.parseInt(body.slice(1), 16)) + return ESCAPES[body] ?? body +} + +// Both string_literal and raw_string_literal carry their text in one or more +// `string_content` children; a plain string additionally has `escape_sequence` +// siblings. A raw string has none, so its content survives verbatim. +function decodeString(node: Node): string { + let out = '' + for (const child of node.children) { + if (child?.type === 'string_content') out += child.text + else if (child?.type === 'escape_sequence') out += decodeEscape(child.text) + } + return out +} + +export const rustSpec: LanguageSpec = { + stepDefQuery: STEP_DEFINITION_QUERY, + parameterTypeQuery: PARAMETER_TYPE_QUERY, + decodeString, + // Handler params (the closure arguments) aren't captured yet — Rust LSP hover + // is future work; extraction conformance only needs kind/expression/regexp. + extractHandlerParams: (): HandlerParams | undefined => undefined, + resolveRegexp: (node) => decodeString(node), +} diff --git a/typescript/packages/var-language/src/tree-sitter-dialects/types.ts b/typescript/packages/var-language/src/tree-sitter-dialects/types.ts index b0d3bc13..9e99885e 100644 --- a/typescript/packages/var-language/src/tree-sitter-dialects/types.ts +++ b/typescript/packages/var-language/src/tree-sitter-dialects/types.ts @@ -1,7 +1,14 @@ import type { Node } from 'web-tree-sitter' import type { HandlerParams, Position, Range } from '../step-defs.ts' -export type LanguageId = 'typescript' | 'typescript-tsx' | 'python' | 'java' | 'kotlin' | 'ruby' +export type LanguageId = + | 'typescript' + | 'typescript-tsx' + | 'python' + | 'java' + | 'kotlin' + | 'ruby' + | 'rust' // One entry per language: the queries plus the three language-specific // behaviors (string decoding, handler-param extraction, regexp resolution). diff --git a/typescript/packages/var-language/src/tree-sitter-scanner.ts b/typescript/packages/var-language/src/tree-sitter-scanner.ts index f70b79ac..9cfc2ee3 100644 --- a/typescript/packages/var-language/src/tree-sitter-scanner.ts +++ b/typescript/packages/var-language/src/tree-sitter-scanner.ts @@ -7,6 +7,7 @@ import { javaSpec } from './tree-sitter-dialects/java.ts' import { kotlinSpec } from './tree-sitter-dialects/kotlin.ts' import { pythonSpec } from './tree-sitter-dialects/python.ts' import { rubySpec } from './tree-sitter-dialects/ruby.ts' +import { rustSpec } from './tree-sitter-dialects/rust.ts' import type { LanguageId, LanguageSpec } from './tree-sitter-dialects/types.ts' import { toRange } from './tree-sitter-dialects/types.ts' import { typescriptSpec } from './tree-sitter-dialects/typescript.ts' @@ -26,6 +27,7 @@ const SPECS: Readonly>> = { kotlin: kotlinSpec, python: pythonSpec, ruby: rubySpec, + rust: rustSpec, typescript: typescriptSpec, 'typescript-tsx': typescriptSpec, } @@ -37,6 +39,7 @@ const EXTENSIONS: ReadonlyArray = [ ['.java', 'java'], ['.kt', 'kotlin'], ['.rb', 'ruby'], + ['.rs', 'rust'], ] export function languageIdForPath(path: string): LanguageId | undefined { diff --git a/typescript/packages/var-language/tests/test-grammar-loader.ts b/typescript/packages/var-language/tests/test-grammar-loader.ts index 476a8802..acc42f38 100644 --- a/typescript/packages/var-language/tests/test-grammar-loader.ts +++ b/typescript/packages/var-language/tests/test-grammar-loader.ts @@ -9,6 +9,7 @@ const GRAMMAR_FILES: Readonly> = { java: 'tree-sitter-java/tree-sitter-java.wasm', kotlin: '@tree-sitter-grammars/tree-sitter-kotlin/tree-sitter-kotlin.wasm', ruby: 'tree-sitter-ruby/tree-sitter-ruby.wasm', + rust: 'tree-sitter-rust/tree-sitter-rust.wasm', } export function createTestGrammarLoader(): GrammarLoader { diff --git a/typescript/packages/var-language/tests/tree-sitter-scanner-rust.test.ts b/typescript/packages/var-language/tests/tree-sitter-scanner-rust.test.ts new file mode 100644 index 00000000..b90f20f1 --- /dev/null +++ b/typescript/packages/var-language/tests/tree-sitter-scanner-rust.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'vitest' +import { createTreeSitterScanner } from '../src/tree-sitter-scanner.ts' +import { createTestGrammarLoader } from './test-grammar-loader.ts' + +// (kind, expression) and parameter-type extraction across every bundle are +// proven by extraction-conformance.test.ts. This file covers the Rust-specific +// pieces: anchoring the expression to the first argument (so strings inside the +// handler closure aren't mistaken for it) and raw-string regexp handling. +async function rustScanner() { + return createTreeSitterScanner(createTestGrammarLoader(), ['rust']) +} + +describe('rust dialect', () => { + test('extracts kind + expression from Steps builder calls', async () => { + const scanner = await rustScanner() + const src = `pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + s.stimulus("I add {int}", file!(), line!(), Handler::sync1(|state, _n| Ok(Some(state)))); + s.sensor("the total is {int}", file!(), line!(), Handler::sync1(|_s, _e| Ok(None))); + s.into_registry() +}` + const defs = scanner.discoverStepDefs('x.steps.rs', src) + expect(defs.map((d) => [d.kind, d.expression])).toEqual([ + ['stimulus', 'I add {int}'], + ['sensor', 'the total is {int}'], + ]) + }) + + test('a string inside the handler closure is not mistaken for the expression', async () => { + const scanner = await rustScanner() + const src = `fn r() { s.stimulus("real expr", FILE, 1, Handler::sync0(|_s| Err(HandlerError::new("inner")))); }` + expect(scanner.discoverStepDefs('x.steps.rs', src).map((d) => d.expression)).toEqual([ + 'real expr', + ]) + }) + + test('param + param_with_format extract name and regexp; raw strings stay verbatim', async () => { + const scanner = await rustScanner() + const src = `fn r() { + s.param("airport", "[A-Z]{3}", parse); + s.param_with_format("money", r"£\\d+\\.\\d{2}", parse, format); +}` + expect( + scanner.discoverParameterTypes('x.steps.rs', src).map((t) => [t.name, t.regexp]), + ).toEqual([ + ['airport', '[A-Z]{3}'], + ['money', '£\\d+\\.\\d{2}'], + ]) + }) +}) diff --git a/typescript/packages/var-lsp/package.json b/typescript/packages/var-lsp/package.json index 1177216f..d1991466 100644 --- a/typescript/packages/var-lsp/package.json +++ b/typescript/packages/var-lsp/package.json @@ -33,6 +33,7 @@ "tree-sitter-java": "^0.23.5", "tree-sitter-python": "^0.25.0", "tree-sitter-ruby": "^0.23.1", + "tree-sitter-rust": "^0.24.0", "tree-sitter-typescript": "^0.23.2", "vscode-languageserver": "^10.1.0", "vscode-languageserver-textdocument": "^1.0.12" diff --git a/typescript/packages/var-lsp/src/node-grammar-loader.ts b/typescript/packages/var-lsp/src/node-grammar-loader.ts index 2eb130d5..448b9b5e 100644 --- a/typescript/packages/var-lsp/src/node-grammar-loader.ts +++ b/typescript/packages/var-lsp/src/node-grammar-loader.ts @@ -10,6 +10,7 @@ const GRAMMAR_FILES: Readonly> = { java: 'tree-sitter-java/tree-sitter-java.wasm', kotlin: '@tree-sitter-grammars/tree-sitter-kotlin/tree-sitter-kotlin.wasm', ruby: 'tree-sitter-ruby/tree-sitter-ruby.wasm', + rust: 'tree-sitter-rust/tree-sitter-rust.wasm', } export function createNodeGrammarLoader(): GrammarLoader { diff --git a/typescript/packages/var-vscode/esbuild.mjs b/typescript/packages/var-vscode/esbuild.mjs index da0cfb9c..f9efa31f 100644 --- a/typescript/packages/var-vscode/esbuild.mjs +++ b/typescript/packages/var-vscode/esbuild.mjs @@ -62,6 +62,7 @@ for (const specifier of [ 'tree-sitter-java/tree-sitter-java.wasm', '@tree-sitter-grammars/tree-sitter-kotlin/tree-sitter-kotlin.wasm', 'tree-sitter-ruby/tree-sitter-ruby.wasm', + 'tree-sitter-rust/tree-sitter-rust.wasm', ]) { const src = requireFromLsp.resolve(specifier) await copyFile(src, `dist/${specifier.split('/').pop()}`) diff --git a/typescript/packages/website/src/content/docs/how-to/tables-and-doc-strings.mdx b/typescript/packages/website/src/content/docs/how-to/tables-and-doc-strings.mdx index 8777ab6a..e6b64a78 100644 --- a/typescript/packages/website/src/content/docs/how-to/tables-and-doc-strings.mdx +++ b/typescript/packages/website/src/content/docs/how-to/tables-and-doc-strings.mdx @@ -69,6 +69,24 @@ keyed by the header, and returns the computed columns: end ``` + + ```rust + s.sensor( + "a decimal and a roman number", + file!(), + line!() as usize, + Handler::sync1(|_state, row| { + let m = smap(&row); + let decimal = as_str(&m["decimal"]); + let roman = to_roman(decimal.parse().expect("decimal")); + Ok(Some(vmap(vec![ + ("decimal", Value::from(decimal)), + ("roman", Value::from(roman)), + ]))) + }), + ); + ``` + Each row runs as its own check. If `toRoman(9)` returned `"VIIII"`, only the @@ -129,6 +147,31 @@ Uppercase each one: end ``` + + ```rust + s.sensor( + "Uppercase each one:", + file!(), + line!() as usize, + Handler::sync1(|_state, table| { + let Value::List(rows) = table else { panic!("expected a table") }; + let out: Vec = rows + .iter() + .skip(1) // drop the header row + .map(|row| { + let Value::List(cells) = row else { panic!("expected a row") }; + let before = as_str(&cells[0]); + vmap(vec![ + ("before", Value::from(before.clone())), + ("after", Value::from(before.to_uppercase())), + ]) + }) + .collect(); + Ok(Some(Value::List(out))) + }), + ); + ``` + The table is this sensor's only comparable value, so it is returned bare. Vár @@ -182,6 +225,22 @@ the text the software actually produces: sensor('Greet {word}:') { |_state, name, _doc| [name, "Hello, #{name}!\n"] } ``` + + ```rust + s.sensor( + "Greet {word}:", + file!(), + line!() as usize, + Handler::sync2(|_state, name, _doc| { + let name = as_str(&name); + Ok(Some(Value::List(vec![ + Value::from(name.clone()), + Value::from(format!("Hello, {name}!\n")), + ]))) + }), + ); + ``` + The comparison is exact equality, **including the trailing newline**. This step @@ -219,6 +278,16 @@ whose doc string is its only comparable value returns the text bare: sensor('Greet Bob:') { |_state, _doc| "Hello, Bob!\n" } ``` + + ```rust + s.sensor( + "Greet Bob:", + file!(), + line!() as usize, + Handler::sync1(|_state, _doc| Ok(Some(Value::from("Hello, Bob!\n")))), + ); + ``` + See the [sensors reference](/reference/sensors/) for the full return-value diff --git a/typescript/packages/website/src/content/docs/reference/custom-parameters.mdx b/typescript/packages/website/src/content/docs/reference/custom-parameters.mdx index acf6b49c..d9e23086 100644 --- a/typescript/packages/website/src/content/docs/reference/custom-parameters.mdx +++ b/typescript/packages/website/src/content/docs/reference/custom-parameters.mdx @@ -79,6 +79,27 @@ can use it as a `{name}` placeholder. end ``` + + ```rust + let mut s = Steps::from_registry(r); + + let parse: ParseFn = Rc::new(|g: &[&str]| { + Value::Float(g[0].strip_prefix('£').unwrap_or(g[0]).parse().unwrap_or(0.0)) + }); + let format: FormatFn = Rc::new(|v: &Value| match v { + Value::Float(pounds) => Some(format!("£{pounds:.2}")), + _ => None, + }); + s.param_with_format("money", r"£\d+\.\d{2}", parse, format); + + s.sensor( + "owes a {money} late fee", + file!(), + line!() as usize, + Handler::sync1(|state, _expected| Ok(smap(&state).get("fee").cloned())), + ); + ``` + This page is the reference for the three fields of a custom parameter type and diff --git a/typescript/packages/website/src/content/docs/reference/sensors.mdx b/typescript/packages/website/src/content/docs/reference/sensors.mdx index f795562e..446da68f 100644 --- a/typescript/packages/website/src/content/docs/reference/sensors.mdx +++ b/typescript/packages/website/src/content/docs/reference/sensors.mdx @@ -70,6 +70,30 @@ sensors, rather than reaching inside it. end ``` + + ```rust + let mut s = Steps::from_registry(r); + + s.stimulus( + "I add {int}", + file!(), + line!() as usize, + Handler::sync1(|state, n| { + // FULL-REPLACEMENT state: return the whole next state. + let mut m = smap(&state); + let total = m.get("total").map(as_int).unwrap_or(0) + as_int(&n); + m.insert("total".into(), Value::Int(total)); + Ok(Some(Value::Map(m))) + }), + ); + s.sensor( + "the total is {int}", + file!(), + line!() as usize, + Handler::sync1(|state, _expected| Ok(smap(&state).get("total").cloned())), + ); + ``` + In your prose the sensor is the *outcome* you expect; see @@ -139,6 +163,21 @@ a return value against. Throw to fail, return nothing to pass: sensor('the alarm fired') { |state| raise 'no alarm' unless state[:alarm] } ``` + + ```rust + s.sensor( + "the alarm fired", + file!(), + line!() as usize, + Handler::sync0(|state| { + if !matches!(smap(&state).get("alarm"), Some(Value::Bool(true))) { + panic!("no alarm"); + } + Ok(None) + }), + ); + ``` + Returning any other value is a `ReturnShapeError`. This is deliberate: a @@ -175,6 +214,16 @@ certainly believed it was being checked. sensor('the total is {int}') { |state, _expected| state[:total] } ``` + + ```rust + s.sensor( + "the total is {int}", + file!(), + line!() as usize, + Handler::sync1(|state, _expected| Ok(smap(&state).get("total").cloned())), + ); + ``` + The return **is** the slot's value. It is never interpreted as a positional @@ -245,6 +294,22 @@ types whose `parse` produces an array: end ``` + + ```rust + let parse: ParseFn = Rc::new(|g: &[&str]| { + Value::List(g[0].split(", ").map(|n| Value::Int(n.parse().unwrap())).collect()) + }); + s.param("numbers", r"\d+(?:, \d+)*", parse); + + // "The dice show 5, 6" — {numbers} transforms to [5, 6] + s.sensor( + "The dice show {numbers}", + file!(), + line!() as usize, + Handler::sync1(|state, _dice| Ok(smap(&state).get("dice").cloned())), + ); + ``` + Because a single-slot return is always the bare value, `[5, 6]` here is @@ -289,6 +354,22 @@ never mistaken for a two-slot positional array. end ``` + + ```rust + s.sensor( + "I should have {int} cukes in my {word} belly", + file!(), + line!() as usize, + Handler::sync2(|state, _count, _belly| { + let m = smap(&state); + Ok(Some(Value::List(vec![ + m["count"].clone(), + m["belly_name"].clone(), + ]))) + }), + ); + ``` + The array must have exactly one element per slot; a different length or a @@ -326,6 +407,22 @@ last element: sensor('Greet {word}:') { |_state, name, _doc| [name, "Hello, #{name}!\n"] } ``` + + ```rust + s.sensor( + "Greet {word}:", + file!(), + line!() as usize, + Handler::sync2(|_state, name, _doc| { + let name = as_str(&name); + Ok(Some(Value::List(vec![ + Value::from(name.clone()), + Value::from(format!("Hello, {name}!\n")), + ]))) + }), + ); + ``` + ## What each slot kind compares @@ -381,6 +478,24 @@ returns a **row object** keyed by the header — not slots: end ``` + + ```rust + s.sensor( + "a decimal and a roman number", + file!(), + line!() as usize, + Handler::sync1(|_state, row| { + let m = smap(&row); + let decimal = as_str(&m["decimal"]); + let roman = to_roman(decimal.parse().expect("decimal")); + Ok(Some(vmap(vec![ + ("decimal", Value::from(decimal)), + ("roman", Value::from(roman)), + ]))) + }), + ); + ``` + Each returned column is compared cell by cell against that row. This is the one diff --git a/typescript/packages/website/src/content/docs/reference/stimuli.mdx b/typescript/packages/website/src/content/docs/reference/stimuli.mdx index 71f93206..a8408e68 100644 --- a/typescript/packages/website/src/content/docs/reference/stimuli.mdx +++ b/typescript/packages/website/src/content/docs/reference/stimuli.mdx @@ -65,6 +65,29 @@ with sensors. end ``` + + ```rust + let mut s = Steps::from_registry(r); + + s.stimulus( + "I add {int}", + file!(), + line!() as usize, + Handler::sync1(|state, n| { + let mut m = smap(&state); + let total = m.get("total").map(as_int).unwrap_or(0) + as_int(&n); + m.insert("total".into(), Value::Int(total)); + Ok(Some(Value::Map(m))) + }), + ); + s.sensor( + "the total is {int}", + file!(), + line!() as usize, + Handler::sync1(|state, _expected| Ok(smap(&state).get("total").cloned())), + ); + ``` + In your prose the stimulus covers both the *context* (arrange) and the *action* @@ -133,6 +156,32 @@ full replacement, same principle: new value out, never mutation. end ``` + + ```rust + s.stimulus( + "I greet {string}", + file!(), + line!() as usize, + Handler::sync1(|state, name| { + // Full replacement: clone state, then set just the changed key. + let mut m = smap(&state); + m.insert("greeting".into(), Value::from(format!("Hello, {}!", as_str(&name)))); + Ok(Some(Value::Map(m))) + }), + ); + s.stimulus( + "I add {int}", + file!(), + line!() as usize, + Handler::sync1(|state, n| { + let mut m = smap(&state); + let count = m.get("count").map(as_int).unwrap_or(0) + as_int(&n); + m.insert("count".into(), Value::Int(count)); + Ok(Some(Value::Map(m))) + }), + ); + ``` + - **Returning nothing** leaves state unchanged — right for a stimulus whose @@ -199,6 +248,21 @@ empty state they can ignore: end ``` + + ```rust + let mut s = Steps::from_registry(r); + + s.sensor( + "the square of {int} is {int}", + file!(), + line!() as usize, + Handler::sync2(|_state, n, _square| { + let n = as_int(&n); + Ok(Some(Value::List(vec![Value::Int(n), Value::Int(n * n)]))) + }), + ); + ``` + ## Tables and doc strings @@ -244,6 +308,32 @@ first), a doc string as its exact text: end ``` + + ```rust + s.stimulus( + "these books exist:", + file!(), + line!() as usize, + Handler::sync1(|state, table| { + let Value::List(rows) = table else { panic!("expected a table") }; + let books: Vec = rows + .iter() + .skip(1) + .map(|row| { + let Value::List(cells) = row else { panic!("expected a row") }; + vmap(vec![ + ("title", cells[0].clone()), + ("author", cells[1].clone()), + ]) + }) + .collect(); + let mut m = smap(&state); + m.insert("books".into(), Value::List(books)); + Ok(Some(Value::Map(m))) + }), + ); + ``` + A stimulus *consumes* these as input. To *check* a table or doc string against diff --git a/typescript/packages/website/src/content/docs/tutorials/get-started.mdx b/typescript/packages/website/src/content/docs/tutorials/get-started.mdx index 5f27db19..0fb4122c 100644 --- a/typescript/packages/website/src/content/docs/tutorials/get-started.mdx +++ b/typescript/packages/website/src/content/docs/tutorials/get-started.mdx @@ -130,6 +130,22 @@ Every command and code sample below follows that choice. end ``` + + ```rust + use var::{Handler, Registry, Steps, Value}; + + pub fn register(r: Registry) -> Registry { + let mut s = Steps::from_registry(r); + s.sensor( + "life, the universe and everything is {int}", + file!(), + line!() as usize, + Handler::sync1(|_state, _answer| Ok(Some(Value::Int(42)))), + ); + s.into_registry() + } + ``` +