Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions crates/loon-lang/src/check/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
42 changes: 41 additions & 1 deletion crates/loon-lang/src/interp/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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<String> = lf
.clauses
.iter()
.map(|(params, _)| {
let ps: Vec<String> = 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"));
Expand Down Expand Up @@ -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))),
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/loon-lang/src/interp/machine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}
}

Expand Down
20 changes: 17 additions & 3 deletions crates/loon-lang/src/interp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,20 @@ pub fn err_at(msg: impl Into<String>, 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<Option<u64>> = const { std::cell::Cell::new(None) };
Expand Down Expand Up @@ -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)),
}
}
}
Expand Down Expand Up @@ -1311,15 +1325,15 @@ 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(_) => {
let func = eval(step, env)?;
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")),
Expand Down
74 changes: 74 additions & 0 deletions crates/loon-lang/tests/interp_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
86 changes: 86 additions & 0 deletions docs/plans/2026-07-18-subject-last-ergonomics.md
Original file line number Diff line number Diff line change
@@ -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<VcadWarning>` (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<usize> }` 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.
Loading