diff --git a/crates/loon-lang/src/check/mod.rs b/crates/loon-lang/src/check/mod.rs index fecbf8c..aa1181b 100644 --- a/crates/loon-lang/src/check/mod.rs +++ b/crates/loon-lang/src/check/mod.rs @@ -459,6 +459,24 @@ impl Checker { ); } + // signature: ∀a. a → Str (accepts any fn value, returns its signature) + { + let a = self.subst.fresh(); + let tva = if let Type::Var(v) = a { + v + } else { + unreachable!() + }; + self.env.set_global( + "signature".to_string(), + Scheme { + bounds: vec![], + vars: vec![tva], + ty: Type::Fn(vec![Type::Var(tva)], Box::new(Type::Str), EffectRow::pure()), + }, + ); + } + // println: ∀a. a → () { let a = self.subst.fresh(); diff --git a/crates/loon-lang/src/interp/builtins.rs b/crates/loon-lang/src/interp/builtins.rs index a65b0c7..dce931a 100644 --- a/crates/loon-lang/src/interp/builtins.rs +++ b/crates/loon-lang/src/interp/builtins.rs @@ -107,6 +107,46 @@ pub fn register_builtins(env: &mut Env) { }; } + builtin!(env, "signature", |_, args: &[Value]| { + if args.len() != 1 { + return Err(err("signature requires exactly 1 argument (a function)")); + } + fn param_str(p: &super::value::Param) -> String { + match p { + super::value::Param::Simple(n) => n.clone(), + super::value::Param::Rest(n) => format!("& {n}"), + super::value::Param::VecDestructure(ps) => { + let inner: Vec = ps.iter().map(param_str).collect(); + format!("#[{}]", inner.join(" ")) + } + super::value::Param::MapDestructure(entries) => { + let keys: Vec<&str> = entries.iter().map(|(k, _)| k.as_str()).collect(); + format!("{{{}}}", keys.join(" ")) + } + } + } + match &args[0] { + Value::Fn(lf) => { + let name = lf.name.as_deref().unwrap_or("fn"); + let clauses: Vec = lf + .clauses + .iter() + .map(|(params, _)| { + let ps: Vec = params.iter().map(param_str).collect(); + if ps.is_empty() { + format!("[{name}]") + } else { + format!("[{name} {}]", ps.join(" ")) + } + }) + .collect(); + Ok(Value::Str(clauses.join(" | ").into())) + } + Value::Builtin(name, _) => Ok(Value::Str(format!("[{name} ...] (builtin)").into())), + other => Err(err(format!("signature requires a function, got {other}"))), + } + }); + builtin!(env, "+", |_, args: &[Value]| { if args.len() < 2 { return Err(err("+ requires at least 2 arguments")); @@ -1792,7 +1832,7 @@ pub fn apply_value(func: &Value, args: &[Value]) -> IResult { call_fn(lf, args, &mut env, crate::syntax::Span::ZERO) } Value::Builtin(name, f) => f(name, args), - _ => Err(err(format!("not callable: {func}"))), + _ => Err(err(super::not_callable_msg(func))), } } diff --git a/crates/loon-lang/src/interp/machine.rs b/crates/loon-lang/src/interp/machine.rs index 076ba55..1e24890 100644 --- a/crates/loon-lang/src/interp/machine.rs +++ b/crates/loon-lang/src/interp/machine.rs @@ -1099,7 +1099,7 @@ impl Machine { } } - other => Err(err_at(format!("not callable: {other}"), span)), + other => Err(err_at(super::not_callable_msg(&other), span)), } } diff --git a/crates/loon-lang/src/interp/mod.rs b/crates/loon-lang/src/interp/mod.rs index 37c28ed..839d935 100644 --- a/crates/loon-lang/src/interp/mod.rs +++ b/crates/loon-lang/src/interp/mod.rs @@ -103,6 +103,20 @@ pub fn err_at(msg: impl Into, span: Span) -> InterpError { e } +/// Message for applying a non-callable value. When the callee is a number +/// the likely cause is a `[x y z]` vector-literal habit from other languages, +/// so name it and show the two correct spellings. +pub(crate) fn not_callable_msg(func: &Value) -> String { + match func { + Value::Int(_) | Value::Float(_) => format!( + "not callable: {func} — {func} is a number, not a function. \ + loon has no [x y z] vector syntax; write #[{func} ...] for a vector, \ + or pass components as separate arguments, e.g. [translate 30 20 -1 solid]" + ), + _ => format!("not callable: {func}"), + } +} + thread_local! { /// SplitMix64 state for the `Rand` builtin effect (None = unseeded). static RAND_STATE: std::cell::Cell> = const { std::cell::Cell::new(None) }; @@ -1124,7 +1138,7 @@ pub fn eval(expr: &Expr, env: &mut Env) -> IResult { CALL_STACK.with(|s| s.borrow_mut().pop()); result } - _ => Err(err_at(format!("not callable: {func}"), head.span)), + _ => Err(err_at(not_callable_msg(&func), head.span)), } } } @@ -1311,7 +1325,7 @@ fn eval_pipe(args: &[Expr], env: &mut Env) -> IResult { val = match func { Value::Fn(lf) => call_fn(&lf, &call_args, env, step.span)?, Value::Builtin(name, f) => f(&name, &call_args)?, - _ => return Err(err(format!("not callable in pipe: {func}"))), + _ => return Err(err(format!("in pipe: {}", not_callable_msg(&func)))), }; } ExprKind::Symbol(_) => { @@ -1319,7 +1333,7 @@ fn eval_pipe(args: &[Expr], env: &mut Env) -> IResult { val = match func { Value::Fn(lf) => call_fn(&lf, &[val], env, step.span)?, Value::Builtin(name, f) => f(&name, &[val])?, - _ => return Err(err(format!("not callable in pipe: {func}"))), + _ => return Err(err(format!("in pipe: {}", not_callable_msg(&func)))), }; } _ => return Err(err("pipe step must be a list or symbol")), diff --git a/crates/loon-lang/tests/interp_tests.rs b/crates/loon-lang/tests/interp_tests.rs index c74e409..f1bd065 100644 --- a/crates/loon-lang/tests/interp_tests.rs +++ b/crates/loon-lang/tests/interp_tests.rs @@ -1849,3 +1849,77 @@ fn let_destructure_non_sequence_errors_loudly() { "error should name the requirement, got: {msg}" ); } + +// ── Vector-literal trap & signature discoverability ────────────────── + +#[test] +fn calling_a_number_names_the_vector_trap() { + // `[30 20 -1]` is application, not a vector literal — the error must + // say so and point at #[...] / separate args, with a span. + let exprs = parse("[30 20 -1]").expect("parse failed"); + let e = eval_program(&exprs).unwrap_err(); + assert!( + e.message.contains("30 is a number, not a function"), + "{}", + e.message + ); + assert!(e.message.contains("#["), "{}", e.message); + assert!(e.span.is_some(), "not-callable error must carry a span"); +} + +#[test] +fn calling_a_number_names_the_vector_trap_vm() { + let exprs = parse("[30 20 -1]").expect("parse failed"); + let e = eval_program_vm(&exprs).unwrap_err(); + assert!( + e.message.contains("30 is a number, not a function"), + "{}", + e.message + ); +} + +#[test] +fn calling_a_non_number_keeps_plain_message() { + let exprs = parse("[\"nope\" 1]").expect("parse failed"); + let e = eval_program(&exprs).unwrap_err(); + assert!(e.message.starts_with("not callable:"), "{}", e.message); + assert!(!e.message.contains("vector syntax"), "{}", e.message); +} + +#[test] +fn signature_of_named_fn() { + let result = run(r#" + [let translate [fn [x y z s] s]] + [signature translate] + "#); + assert_eq!(result, Value::Str("[fn x y z s]".into())); +} + +#[test] +fn signature_of_named_fn_form() { + let result = run(r#" + [fn translate [x y z s] s] + [signature translate] + "#); + assert_eq!(result, Value::Str("[translate x y z s]".into())); +} + +#[test] +fn signature_of_builtin() { + let result = run("[signature str]"); + match result { + Value::Str(s) => assert!(s.contains("str") && s.contains("builtin"), "{s}"), + other => panic!("expected string, got {other}"), + } +} + +#[test] +fn signature_of_non_fn_errors() { + let exprs = parse("[signature 5]").expect("parse failed"); + let e = eval_program(&exprs).unwrap_err(); + assert!( + e.message.contains("signature requires a function"), + "{}", + e.message + ); +} diff --git a/docs/plans/2026-07-18-subject-last-ergonomics.md b/docs/plans/2026-07-18-subject-last-ergonomics.md new file mode 100644 index 0000000..b9e6164 --- /dev/null +++ b/docs/plans/2026-07-18-subject-last-ergonomics.md @@ -0,0 +1,86 @@ +# Subject-last ergonomics for the vcad stdlib + +Date: 2026-07-18. Status: implemented, design open for review. + +The vcad stdlib (vcad `lib/src/lib.loon`) is subject-last so pipes read +naturally: `[pipe body [difference tool]]`. But a *direct* call +`[difference A B]` computes B − A — reversed from every other CAD system — +and yields a valid-but-wrong solid with no error. Two related traps: +`[30 20 -1]` is application (→ "not callable: 30"), and there was no way to +discover a symbol's arity/arg names without reading lib.loon. + +## Problem 1 decision: lint, not new calling conventions + +Options considered: + +- **(a) keyword/record args** (`[difference {:from body :cut tool}]`) — + order-proof, but a second calling convention that breaks pipe threading + (pipe appends the subject positionally), breaks every existing `.loon` + file if made mandatory, and is optional otherwise (an optional safety + doesn't catch the user who doesn't know about the trap). Rejected. +- **(c) subject-first alias family** (`cut`, `move`, …) — doesn't make the + *existing* names any safer; doubles the vocabulary and splits the + ecosystem into two dialects. Rejected as the primary fix (an alias can + still be added later as sugar). +- **(b) lint on direct calls to subject-last builtins** — chosen, with a + precision refinement: + - `difference` at full arity (2 solid args): always warn — the two + operands are both solids, so wrong order is statically undetectable; + the warning states the semantics ("`[difference A B]` computes B − A") + and shows both correct spellings (swap, or pipe). + - `translate`/`rotate`/`scale`/`mirror` at full arity: warn **only when + the last argument is a numeric literal** — the subject slot holding a + number means the args are definitely reversed. Correct direct calls + stay silent. + - `union`/`intersection`: no warning — commutative, order can't be wrong. + +This makes the mistake loud without regressing pipes (pipe-stage calls are +under-arity and never match), without breaking any existing source +(warnings, not errors), and with zero false positives for transforms. + +Implemented as `vcad_loon::lint_vcad(source) -> Vec` (message + +1-based user line). Consumers (Typst plugin, CLI) call it alongside +`eval_vcad` and surface warnings however they render diagnostics. It lives +in vcad-loon rather than loon-lang because subject-last-ness is vcad stdlib +knowledge, not a language property. If more stdlib fns need it, the table +could move into lib.loon metadata later. + +## Problem 2: the vector trap error + spans across the FFI + +- loon-lang: all "not callable" sites (`interp/mod.rs` call + pipe paths, + `interp/machine.rs`, `interp/builtins.rs::apply_value`) now share + `not_callable_msg`, which special-cases a numeric callee: "30 is a + number, not a function. loon has no [x y z] vector syntax; write + #[30 ...] for a vector, or pass components as separate arguments, e.g. + [translate 30 20 -1 solid]". +- vcad-loon: errors no longer collapse to a bare `String`. New + `VcadError { message, line: Option }` and + `eval_vcad_diag` / (internal) span→line mapping that subtracts the + prepended lib prefix; spans inside the bundled lib map to `line: None`. + The existing `eval_vcad`/`eval_vcad_to_value` `String` APIs are kept + backward-compatible and now prefix "line N: " via `VcadError`'s Display. +- The multi-root `collect_top_level_values` rewrite now copies inter-form + text verbatim and wraps value forms in place (no inserted newlines), so + line numbers survive the rewrite. + +## Problem 3: discoverability + +- `signature` builtin in loon-lang: `[signature translate]` → + `"[translate x y z s]"` (multi-clause fns join with `|`, rest params show + as `& name`, builtins report `"[name ...] (builtin)"`). Registered in the + checker as `∀a. a → Str`. +- No new `[vec x y z]` helper: `#[x y z]` already exists and the improved + not-callable message now teaches it at exactly the moment of the mistake. + A parse-time diagnostic for `[number ...]` was skipped — the runtime error + now fires with a span and the guided message, and parse-level rejection + would outlaw macro-generated forms that are legal today. + +## Tests + +- loon-lang `tests/interp_tests.rs`: number-callee message (tree-walker and + VM), plain message preserved for non-numeric callees, `signature` on + named/anonymous fns, builtins, and non-fns. +- vcad-loon `ergonomics_tests`: error carries user line (including through + the multi-root rewrite), lint fires on direct `difference` and reversed + transforms, stays silent on piped/correct calls, `[signature translate]` + reads stdlib arg names.