Lines
99.57 %
Functions
100 %
Branches
use nomiscript::{Expr, Fraction, Program, Reader};
fn parse(input: &str) -> Program {
Reader::parse(input).unwrap()
}
fn parse_one(input: &str) -> Expr {
let program = parse(input);
assert_eq!(program.exprs.len(), 1, "expected single expression");
program.exprs.into_iter().next().unwrap()
fn num(n: i64) -> Expr {
Expr::Number(Fraction::from_integer(n))
fn frac(n: i64, d: i64) -> Expr {
Expr::Number(Fraction::new(n, d))
fn sym(s: &str) -> Expr {
Expr::Symbol(s.into())
fn list(items: Vec<Expr>) -> Expr {
Expr::List(items)
mod atoms {
use super::*;
#[test]
fn test_nil_variations() {
let cases = [("nil", Expr::Nil), (" nil ", Expr::Nil)];
for (input, expected) in cases {
assert_eq!(parse_one(input), expected, "input: {input:?}");
fn test_booleans() {
let cases = [
("#t", Expr::Bool(true)),
("#f", Expr::Nil),
(" #t ", Expr::Bool(true)),
(" #f ", Expr::Nil),
];
mod numbers {
fn test_integers() {
("0", num(0)),
("1", num(1)),
("42", num(42)),
("123456789", num(123456789)),
("-1", num(-1)),
("-42", num(-42)),
("+1", num(1)),
("+42", num(42)),
fn test_decimals() {
("0.1", frac(1, 10)),
("0.01", frac(1, 100)),
("0.001", frac(1, 1000)),
("1.5", frac(3, 2)),
("3.14", frac(314, 100)),
("10.25", frac(1025, 100)),
("-0.5", frac(-1, 2)),
("-1.25", frac(-5, 4)),
fn test_decimal_normalization() {
let half = parse_one("0.5");
let also_half = parse_one("0.50");
assert_eq!(half, also_half);
let quarter = parse_one("0.25");
assert_eq!(quarter, frac(1, 4));
fn test_percent_sugar() {
// `15%` reads as `15/100` (reduced). The `%` rides any numeric body —
// integer, decimal, or rational — and divides it by 100.
("15%", frac(15, 100)),
("100%", num(1)),
("0.5%", frac(1, 200)),
("1/2%", frac(1, 200)),
("-50%", frac(-1, 2)),
fn test_percent_equivalent_to_explicit_fraction() {
// `15%` and `15/100` denote the same Scalar — and the same inside a form.
assert_eq!(parse_one("15%"), parse_one("15/100"));
assert_eq!(parse_one("(* amount 15%)"), parse_one("(* amount 15/100)"));
fn test_decimal_overflow_is_parse_error() {
// Malformed decimals must be structured parse errors, never i64
// overflow panics (debug) / wraps (release).
// 19 fractional digits → 10^19 exceeds i64.
assert!(Reader::parse("1.0000000000000000000").is_err());
// 9 × 10^18 + 10^18-scale fraction overflows the scaled numerator.
assert!(Reader::parse("9.999999999999999999").is_err());
fn test_percent_denominator_overflow_is_parse_error() {
// `denom * 100` would overflow i64 — must be a structured parse error,
// never an overflow panic (debug) or silent wrap (release).
assert!(Reader::parse("1/9223372036854775807%").is_err());
fn test_bare_percent_is_still_a_symbol() {
// `%` is delimiter-only for terminating a numeric token; a lone `%`
// (no numeric body) still reads as a symbol.
assert_eq!(parse_one("%"), sym("%"));
mod strings {
fn test_simple_strings() {
(r#""""#, Expr::String(String::new())),
(r#""hello""#, Expr::String("hello".into())),
(r#""hello world""#, Expr::String("hello world".into())),
(r#""123""#, Expr::String("123".into())),
fn test_escape_sequences() {
(r#""hello\nworld""#, Expr::String("hello\nworld".into())),
(r#""tab\there""#, Expr::String("tab\there".into())),
(r#""return\rhere""#, Expr::String("return\rhere".into())),
(r#""slash\\here""#, Expr::String("slash\\here".into())),
(r#""quote\"here""#, Expr::String("quote\"here".into())),
(r#""multi\n\tline""#, Expr::String("multi\n\tline".into())),
fn test_triple_quoted_strings() {
(r#""""""""#, Expr::String(String::new())),
(r#""""hello""""#, Expr::String("hello".into())),
(
r#""""line1
line2""""#,
Expr::String("line1\nline2".into()),
),
r#""""contains "quotes" inside""""#,
Expr::String("contains \"quotes\" inside".into()),
mod symbols {
fn test_simple_symbols() {
("x", sym("X")),
("foo", sym("FOO")),
("hello-world", sym("HELLO-WORLD")),
("snake_case", sym("SNAKE_CASE")),
("CamelCase", sym("CAMELCASE")),
fn test_operator_symbols() {
("+", sym("+")),
("-", sym("-")),
("*", sym("*")),
("/", sym("/")),
("=", sym("=")),
("<", sym("<")),
(">", sym(">")),
("<=", sym("<=")),
(">=", sym(">=")),
("!=", sym("!=")),
("++", sym("++")),
("->", sym("->")),
("=>", sym("=>")),
fn test_special_symbols() {
("define", sym("DEFINE")),
("lambda", sym("LAMBDA")),
("if", sym("IF")),
("cond", sym("COND")),
("let", sym("LET")),
("let*", sym("LET*")),
("set!", sym("SET!")),
("begin", sym("BEGIN")),
("car", sym("CAR")),
("cdr", sym("CDR")),
("cons", sym("CONS")),
("list?", sym("LIST?")),
("null?", sym("NULL?")),
("pair?", sym("PAIR?")),
fn test_case_insensitive() {
assert_eq!(parse_one("foo"), parse_one("FOO"));
assert_eq!(parse_one("foo"), parse_one("Foo"));
assert_eq!(parse_one("Sum"), sym("SUM"));
fn test_nil_as_symbol_variant() {
assert_eq!(parse_one("nIl"), Expr::Nil);
assert_eq!(parse_one("nIL"), Expr::Nil);
mod lists {
fn test_empty_list() {
assert_eq!(parse_one("()"), list(vec![]));
assert_eq!(parse_one("( )"), list(vec![]));
fn test_simple_lists() {
("(1)", list(vec![num(1)])),
("(1 2)", list(vec![num(1), num(2)])),
("(1 2 3)", list(vec![num(1), num(2), num(3)])),
("(a b c)", list(vec![sym("A"), sym("B"), sym("C")])),
fn test_nested_lists() {
("(())", list(vec![list(vec![])])),
("((1))", list(vec![list(vec![num(1)])])),
"((1 2) (3 4))",
list(vec![list(vec![num(1), num(2)]), list(vec![num(3), num(4)])]),
"(a (b (c d)))",
list(vec![
sym("A"),
list(vec![sym("B"), list(vec![sym("C"), sym("D")])]),
]),
fn test_mixed_lists() {
("(+ 1 2)", list(vec![sym("+"), num(1), num(2)])),
r#"(print "hello")"#,
list(vec![sym("PRINT"), Expr::String("hello".into())]),
"(if #t 1 0)",
list(vec![sym("IF"), Expr::Bool(true), num(1), num(0)]),
mod quotes {
fn test_quoted_atoms() {
("'x", Expr::Quote(Box::new(sym("X")))),
("'42", Expr::Quote(Box::new(num(42)))),
("'nil", Expr::Quote(Box::new(Expr::Nil))),
("'#t", Expr::Quote(Box::new(Expr::Bool(true)))),
fn test_quoted_lists() {
("'()", Expr::Quote(Box::new(list(vec![])))),
"'(1 2 3)",
Expr::Quote(Box::new(list(vec![num(1), num(2), num(3)]))),
"'(a b c)",
Expr::Quote(Box::new(list(vec![sym("A"), sym("B"), sym("C")]))),
fn test_nested_quotes() {
let input = "''x";
let expected = Expr::Quote(Box::new(Expr::Quote(Box::new(sym("X")))));
assert_eq!(parse_one(input), expected);
mod programs {
fn test_empty_program() {
let program = parse("");
assert!(program.exprs.is_empty());
let program = parse(" ");
let program = parse("\n\n");
fn test_multiple_expressions() {
let program = parse("1 2 3");
assert_eq!(program.exprs.len(), 3);
assert_eq!(program.exprs[0], num(1));
assert_eq!(program.exprs[1], num(2));
assert_eq!(program.exprs[2], num(3));
fn test_scheme_like_programs() {
"(define x 10)",
vec![list(vec![sym("DEFINE"), sym("X"), num(10)])],
"(define (square x) (* x x))",
vec![list(vec![
sym("DEFINE"),
list(vec![sym("SQUARE"), sym("X")]),
list(vec![sym("*"), sym("X"), sym("X")]),
])],
"(define x 10) (+ x 5)",
vec![
list(vec![sym("DEFINE"), sym("X"), num(10)]),
list(vec![sym("+"), sym("X"), num(5)]),
],
"(if (> x 0) x (- x))",
sym("IF"),
list(vec![sym(">"), sym("X"), num(0)]),
sym("X"),
list(vec![sym("-"), sym("X")]),
assert_eq!(program.exprs, expected, "input: {input:?}");
fn test_lambda_expressions() {
"(lambda (x) x)",
list(vec![sym("LAMBDA"), list(vec![sym("X")]), sym("X")]),
"(lambda (x y) (+ x y))",
sym("LAMBDA"),
list(vec![sym("X"), sym("Y")]),
list(vec![sym("+"), sym("X"), sym("Y")]),
"((lambda (x) (* x x)) 5)",
list(vec![sym("X")]),
num(5),
fn test_let_expressions() {
"(let ((x 1)) x)",
sym("LET"),
list(vec![list(vec![sym("X"), num(1)])]),
"(let ((x 1) (y 2)) (+ x y))",
list(vec![sym("X"), num(1)]),
list(vec![sym("Y"), num(2)]),
fn test_cond_expressions() {
let input = "(cond ((< x 0) -1) ((= x 0) 0) (#t 1))";
let expected = list(vec![
sym("COND"),
list(vec![list(vec![sym("<"), sym("X"), num(0)]), num(-1)]),
list(vec![list(vec![sym("="), sym("X"), num(0)]), num(0)]),
list(vec![Expr::Bool(true), num(1)]),
]);
mod whitespace {
fn test_various_whitespace() {
let inputs = [
"(+ 1 2)",
"( + 1 2 )",
"(\n+\n1\n2\n)",
"(\t+\t1\t2\t)",
"( \n\t + \n\t 1 \n\t 2 \n\t )",
let expected = list(vec![sym("+"), num(1), num(2)]);
for input in inputs {
fn test_leading_trailing_whitespace() {
let inputs = ["42", " 42", "42 ", " 42 ", "\n42\n", "\t42\t"];
assert_eq!(parse_one(input), num(42), "input: {input:?}");
mod edge_cases {
fn test_deeply_nested() {
let input = "((((((1))))))";
let mut expr = num(1);
for _ in 0..6 {
expr = list(vec![expr]);
assert_eq!(parse_one(input), expr);
fn test_long_symbol() {
let input = "a".repeat(1000);
let expected = "A".repeat(1000);
assert_eq!(parse_one(&input), sym(&expected));
fn test_many_list_elements() {
let input = format!(
"({})",
(1..=100)
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(" ")
);
let program = parse(&input);
assert_eq!(program.exprs.len(), 1);
if let Expr::List(items) = &program.exprs[0] {
assert_eq!(items.len(), 100);
} else {
panic!("expected list");
/// ADR-0029 colon-namespace grammar, plus the cross-crate regression guard
/// that the rpc envelope + transaction-plist shapes (which parse through this
/// same `Reader`) are unaffected by the grammar tightening.
mod namespaces {
fn qualified_symbol_single_and_double_colon() {
assert_eq!(parse_one("finance:add-money"), sym("FINANCE:ADD-MONEY"));
// `::` folds to the canonical single-colon key.
assert_eq!(parse_one("finance::add-money"), sym("FINANCE:ADD-MONEY"));
fn qualified_symbol_in_call_position() {
assert_eq!(
parse_one("(split:list-for-transaction tx)"),
list(vec![sym("SPLIT:LIST-FOR-TRANSACTION"), sym("TX")])
fn leading_colon_remains_keyword() {
assert_eq!(parse_one(":foo"), Expr::Keyword("FOO".into()));
fn malformed_qualified_symbols_rejected() {
for bad in ["foo:", "foo::", "a:b:c", "a::b::c", "a:b::c", "::foo", ":"] {
assert!(
Reader::parse(bad).is_err(),
"expected parse error for {bad:?}"
// Cross-crate guard: the rpc envelope (`(:id N :form …)`) and the
// create-transaction plist payload (`(:post-date … :splits (…))`) ride
// this same reader. The grammar change touches only symbol/leading-colon
// parsing, so well-formed `:key value` plists must parse byte-unchanged.
fn rpc_envelope_frame_parses_unchanged() {
let frame = parse_one("(:id 42 :form (list-accounts))");
frame,
Expr::Keyword("ID".into()),
num(42),
Expr::Keyword("FORM".into()),
list(vec![sym("LIST-ACCOUNTS")]),
])
fn transaction_plist_payload_parses_unchanged() {
let payload = parse_one(
"(:post-date \"2026-02-01T00:00:00Z\" \
:splits ((:account-id \"a\" :value -100) (:account-id \"b\" :value 100)))",
let Expr::List(items) = payload else {
panic!("expected plist");
};
assert_eq!(items[0], Expr::Keyword("POST-DATE".into()));
assert_eq!(items[1], Expr::String("2026-02-01T00:00:00Z".into()));
assert_eq!(items[2], Expr::Keyword("SPLITS".into()));