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
2 changes: 2 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,8 @@ f v:n>n;- 0 v -- OK: binary subtract: 0 - v = -v

The lexer splits a glued negative literal back into `Minus + Number` when the previous token is one of `;`, `\n`, `=`, `{`, `(`, or `-`. The `-` context covers the operand slot of an outer prefix-minus, so `- -0 a b` lexes as `-, -, 0, a, b` and parses as `Subtract(Subtract(0, a), b)` = `-a - b` rather than tripping `ILO-P020`. Negative literals after an Ident, `[`, or another prefix binop (`+`, `*`, `/`) stay glued so call args (`at xs -1`), list literals (`[-2 1 3]`), and binary operands (`+a -3`) read naturally.

**Subtraction spacing convention**: for general subtraction at statement position, write `a - b` with spaces on **both** sides. `a -b` (glued, no space before the `-`) is not a binary subtract: the lexer packs `-b` into a negative-literal token because the previous token (`a`, an Ident) is one of the keep-glued contexts above. That's deliberate so call args and list elements read naturally, but it means `0 -1.5` is a parse error (`ILO-P001: expected declaration, got number `-1.5`` with a tailored hint pointing at this rule). For a bare negative value as an expression, wrap in parens: `(-1.5)`.

---

## String Literals
Expand Down
2 changes: 1 addition & 1 deletion ai.txt

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions examples/glued-negative-literal-spacing.ilo
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- Glued-negative-literal spacing rule. The lexer packs a leading `-` with no
-- preceding space into the number token, so `a -1.5` is `Number(a),
-- Number(-1.5)` not `a, Minus, 1.5`. This is load-bearing for call-arg forms
-- (`mod n -2`, `sub 5 -3`) and list literals (`[1 -2 3]`), but it means a
-- naked `0 -1.5` at statement position is a parse error.
--
-- This example pins the three idiomatic forms:
-- - `a - b` with spaces both sides for subtraction (canonical).
-- - glued `-N` for call arguments and list elements (load-bearing).
-- - `(-N)` parens for a bare negative value as an expression.

-- Canonical subtraction: spaces both sides.
sub > n
0 - 1.5

-- Glued negative literal as a call argument.
modn n:n > n
mod n -2

-- Glued negative literal as a list element.
midlist > n
l=[1 -2 3]
at l 1

-- Parenthesised negation as an expression.
neg > n
(-1.5)

-- run: sub
-- out: -1.5
-- run: modn 7
-- out: 1
-- run: midlist
-- out: -2
-- run: neg
-- out: -1.5
2 changes: 1 addition & 1 deletion skills/ilo/ilo-language.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Prefix-notation, strongly-typed, verified pre-run. Bodies single-line, `;`-separ

## operators

Binary `+ - * / % < > <= >= = !=`, bool `& | !`, append `+=`. Nest: `+*a b c` = `(a*b)+c`; outer binds inner LEFT. Take atoms/nested-ops, NOT calls; bind first: `r=fac -n 1;*n r`. No compound: `<=a b`, not `=<a b`.
Binary `+ - * / % < > <= >= = !=`, bool `& | !`, append `+=`. Nest `+*a b c`=`(a*b)+c`; outer binds inner LEFT. Atoms/nested-ops not calls; bind first: `r=fac -n 1;*n r`. No compound `<=a b`. Glued `-n` = neg literal; bare `0 -1` errs ILO-P001.

## idents

Expand Down
13 changes: 13 additions & 0 deletions src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,19 @@ impl Parser {
Some("the last expression in a function body is the return value — no 'return' keyword".to_string()),
Token::KwIf =>
Some("ilo uses match for conditionals: ?expr{true:...;false:...}".to_string()),
// Glued-negative-literal misparse: `a -1.5` lexes as `Number(a), Number(-1.5)`
// because the lexer packs a leading `-` with no preceding space into the
// number token (this is load-bearing for call-args like `mod n -2` and
// list literals `[1 -2 3]`). When that negative number lands at decl
// position, the user almost certainly meant subtraction with a missing
// space, so spell out the spacing rule and the parenthesised-negation
// workaround instead of the generic "got number `-1.5`" message.
Token::Number(n) if *n < 0.0 =>
Some(format!(
"for subtraction, write `a - b` with spaces both sides (e.g. `0 - {}`). for a negative value as an expression, wrap in parens: `({})`. a glued `-N` (no space before) is only parsed as a negative literal when it's a call argument or inside a list.",
n.abs(),
n,
)),
_ => None,
};
let mut err = self.error("ILO-P001", msg);
Expand Down
163 changes: 163 additions & 0 deletions tests/regression_negative_literal_diag.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Regression test for the glued-negative-literal misparse diagnostic.
//
// Background: `a -1.5` lexes as `Number(a), Number(-1.5)` because the lexer
// packs a leading `-` (no space before) into the number token. This is
// load-bearing for call-arg forms (`mod n -2`, `sub 5 -3`) and list literals
// (`[1 -2 3]`), so we deliberately do NOT split in those contexts (see
// `regression_neg_literal_papercut.rs` for the contexts that DO split).
//
// The downside: when a user writes `0 -1.5` meaning "zero minus one point
// five" with an accidental missing space, the parser previously emitted a
// generic "expected declaration, got number `-1.5`" with no hint, leaving
// the user with no clue that the issue was the missing space.
//
// This test pins the tailored ILO-P001 hint that spells out the spacing rule
// and the parenthesised-negation workaround. It also pins the non-regressing
// shapes (spaces-both-sides subtraction, glued call-args, list literals,
// parenthesised negation) so a future lexer tweak that breaks any of those
// shows up here too. The hint emission is engine-independent (parser-level),
// and the positive shapes are exercised on every backend reachable from the
// public CLI: VM and Cranelift JIT (cranelift feature). The tree-walker was
// removed from the public CLI in 0.12.x but still runs in-process as the
// HOF-callback fallback, so the VM arm transitively covers it.

use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};

fn ilo() -> Command {
Command::new(env!("CARGO_BIN_EXE_ilo"))
}

fn write_src(src: &str, tag: &str) -> std::path::PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!(
"ilo_neg_lit_diag_{}_{}_{}.ilo",
std::process::id(),
seq,
tag,
));
std::fs::write(&path, src).unwrap();
path
}

fn run_ok(engine: &str, src: &str, args: &[&str]) -> String {
let path = write_src(src, engine.trim_start_matches("--"));
let mut cmd = ilo();
cmd.arg(path.to_str().unwrap()).arg(engine);
for a in args {
cmd.arg(a);
}
let out = cmd.output().expect("failed to run ilo");
assert!(
out.status.success(),
"ilo {engine} failed for src=`{src}` args={args:?}: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}

#[test]
fn glued_neg_literal_at_stmt_position_emits_tailored_hint() {
// `0 -1.5` inside a function body: lexer packs `-1.5`; parser sees
// Number(0), Number(-1.5) and fails out of the body, re-enters
// parse_decl which sees the Number(-1.5) at decl position.
let src = "m > n\n 0 -1.5\nm\n";
let path = write_src(src, "tailored_hint");
let out = ilo()
.arg(path.to_str().unwrap())
.arg("--json")
.output()
.expect("failed to run ilo");
assert!(!out.status.success(), "expected ilo to fail");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("ILO-P001"),
"expected ILO-P001, got: {stderr}"
);
assert!(
stderr.contains("got number `-1.5`"),
"expected message to mention the offending number, got: {stderr}"
);
// The tailored hint should mention the spaces-both-sides rule AND the
// parenthesised-negation workaround.
assert!(
stderr.contains("spaces both sides"),
"expected hint to mention spaces both sides, got: {stderr}"
);
assert!(
stderr.contains("(-1.5)"),
"expected hint to mention `(-1.5)` parens workaround, got: {stderr}"
);
// And it should reference the canonical `0 - 1.5` form.
assert!(
stderr.contains("0 - 1.5"),
"expected hint to suggest the canonical `0 - 1.5` form, got: {stderr}"
);
}

#[test]
fn glued_neg_int_literal_at_stmt_position_emits_tailored_hint() {
// Same shape but with an integer to confirm the suggestion uses an
// int-style example (no spurious `.0`).
let src = "m > n\n 4 -1\nm\n";
let path = write_src(src, "tailored_int_hint");
let out = ilo()
.arg(path.to_str().unwrap())
.arg("--json")
.output()
.expect("failed to run ilo");
assert!(!out.status.success(), "expected ilo to fail");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("ILO-P001"),
"expected ILO-P001, got: {stderr}"
);
assert!(
stderr.contains("spaces both sides"),
"expected hint to mention spaces both sides, got: {stderr}"
);
}

fn check_positive_shapes(engine: &str) {
// Spaces-both-sides subtraction: the canonical form returns -1.5.
assert_eq!(
run_ok(engine, "m > n\n 0 - 1.5\n", &["m"]),
"-1.5",
"0 - 1.5 (spaces both sides) engine={engine}"
);

// Glued negative literal as call argument (load-bearing form). `mod 7
// -3` must still parse as `mod(7, -3)`.
assert_eq!(
run_ok(engine, "x > n\n mod 7 -3\n", &["x"]),
"1",
"mod 7 -3 (glued call-arg) engine={engine}"
);

// Glued negative literal in middle of list literal.
assert_eq!(
run_ok(engine, "x > n\n l=[1 -2 3]\n at l 1\n", &["x"]),
"-2",
"[1 -2 3] (glued list element) engine={engine}"
);

// Parenthesised negation: the documented workaround for "I want a
// negative value as an expression".
assert_eq!(
run_ok(engine, "x > n\n (-1.5)\n", &["x"]),
"-1.5",
"(-1.5) (paren negation) engine={engine}"
);
}

#[test]
fn neg_literal_positive_shapes_vm() {
check_positive_shapes("--vm");
}

#[test]
#[cfg(feature = "cranelift")]
fn neg_literal_positive_shapes_cranelift() {
check_positive_shapes("--jit");
}
Loading