From 163030ed092a8096b8d4e59483dfe97df01d0430 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 5 Aug 2026 20:27:28 +0100 Subject: [PATCH 1/7] parser: script mode, collect bare top-level statements into implicit main (ILO-439) A file no longer needs a main>_; wrapper: bare statements at top level are gathered in source order and wrapped in a synthetic main. Decls and statements mix freely as long as each statement starts its own top-level line (unindented newlines were already the decl boundary, tracked in decl_boundary, so no new line semantics). Statements glued to a broken decl (f>n;1e, main->n, stray brace, foreign let/if/return) still route to parse_decl so their targeted diagnostics keep firing. A glued name=expr after a decl still raises ILO-P102, and its registry text now describes the post-script-mode reality. A file with both an explicit main and bare statements raises new ILO-P104 (P103 was taken by the AST-depth guard). The k-means chain that motivated P102 now just runs. Old tests pinning 'top-level statements are illegal' rewritten to pin the new contract; the P102/P011/P003 hint tests now target shapes that still reject. --- src/diagnostic/registry.rs | 78 +++++-- src/parser/mod.rs | 286 +++++++++++++++++++++-- tests/coverage_parser.rs | 14 +- tests/regression_ilo_p003_token_names.rs | 31 ++- tests/regression_top_level_chain_hint.rs | 63 +++-- 5 files changed, 404 insertions(+), 68 deletions(-) diff --git a/src/diagnostic/registry.rs b/src/diagnostic/registry.rs index cd84d1c6d..5e16cf7e4 100644 --- a/src/diagnostic/registry.rs +++ b/src/diagnostic/registry.rs @@ -585,22 +585,23 @@ declarations (`type T = ...`), tools (`tool name ...`), or `use` imports. A bare `name=expr` is a **binding statement**, not a declaration - it has to live inside a function body. -This diagnostic fires when a file starts with (or contains) a top-level -chain like: +Since script mode (ILO-439), a chain that starts its own top-level line +is simply valid: it is collected into a synthetic `main>_;` and runs. +So this diagnostic no longer fires for a file of bare statements, or for +statements on their own lines after a declaration. - pts=gen-pts - cs0=[[4.8 4.9][6.2 7.1]] - cs1=iter cs0 pts - cs2=iter cs1 pts - prnt cs2 +It still fires when a binding is **glued to a preceding declaration on +the same line**: + + helper>n;42 pts=[1 2 3] -Without a function header to anchor those bindings, the parser either -fails on the bare `=` (ILO-P003) or - when a prior `name>type;body` -declaration sits above - slurps the whole chain into that function's -body, producing a wall of misleading ILO-T005 cascades that point at -the wrong line. +That shape is almost never an intentional script line - it is a function +body that ran past its end - and silently splitting it would swallow the +real mistake. The historical cascade this code prevents: the chain being +slurped into the prior function's body, producing a wall of misleading +ILO-T005 errors pointing at the wrong line. -**Fix: wrap the chain in a `main>_;` entry point.** +**Fix: put the statement on its own line, or wrap it in `main>_;`.** main>_; pts=gen-pts @@ -618,6 +619,27 @@ manifesto target is that a wrong program produces *one* actionable diagnostic, not a cascade. ILO-P102 collapses what used to be 5-50 ILO-T005 lines (one per slurped binding) into a single pointer at the shape fix. +"#, + }, + ErrorEntry { + code: "ILO-P104", + phase: Phase::Parse, + short: "file has both an explicit `main` and bare top-level statements", + long: r#"## ILO-P104: explicit `main` plus bare top-level statements + +Script mode (ILO-439) wraps bare top-level statements into a synthetic +`main>_;`. A file that also declares its own `main` would then have two +entry points with no defined order between them, so it is rejected: + + prnt 9 <- bare statement, collected for the synthetic main + main>n;42 <- but an explicit main also exists + +(Statements *after* an explicit `main` never reach this check - they are +handed to the declaration parser instead, surfacing its usual +diagnostics, so both orderings reject.) + +**Fix:** move the top-level statements into `main`, or delete the +explicit `main` and let the statements become it. "#, }, ErrorEntry { @@ -810,6 +832,36 @@ deliberately if the depth is real. "#, }, // ── Type / Verifier ────────────────────────────────────────────────────── + ErrorEntry { + code: "ILO-V500", + phase: Phase::Verify, + short: "function unconditionally calls itself and can never terminate", + long: r#"## ILO-V500: unconditional recursion + +A function whose body is a straight line — no guards, matches, loops, or +early returns — contains a direct call to itself. Every invocation reaches +the self-call again, so the recursion has no base case and the function can +never terminate. Because ilo trampolines tail calls, this would spin forever +at runtime rather than overflow the stack. + +``` +tri n:n>n;r=*n +n 1;/r 2;prnt tri 10 + ^^^^^^^^^^^ glued into tri's body — tri calls tri +``` + +The most common source is a trailing top-level call written on the same line +as the function it was meant to exercise. Script mode (ILO-439) collects +statements into `main` only when they start their own top-level line. + +**Fix:** add a base-case guard before the recursive call (`=n 0 0`), or move +the trailing call onto its own line so it becomes a top-level statement: + +``` +tri n:n>n;r=*n +n 1;/r 2 +prnt tri 10 +``` +"#, + }, ErrorEntry { code: "ILO-T001", phase: Phase::Verify, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index dd7999080..2b698c933 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -487,6 +487,60 @@ impl Parser { // ---- Top-level parsing ---- + /// Does a declaration start at `pos`? + /// + /// Anything else at top level is a bare statement, which script mode + /// collects into a synthetic `main` rather than rejecting (ILO-439). + fn is_decl_start(&self, pos: usize) -> bool { + match self.token_at(pos) { + Some(Token::Type) | Some(Token::Tool) | Some(Token::Use) => true, + // Foreign syntax from other languages. These are not ilo statements, + // so letting them fall into script mode would swap a targeted + // "ilo uses `name=expr` for bindings" hint for a confusing + // body-parse error. Keep routing them to `parse_decl` so ILO-P001 + // fires with its keyword-specific guidance. + Some(Token::KwIf) + | Some(Token::KwReturn) + | Some(Token::KwLet) + | Some(Token::KwFn) + | Some(Token::KwDef) + | Some(Token::KwVar) + | Some(Token::KwConst) => true, + // `_name` (underscore glued to an ident) — module-private fn name. + Some(Token::Underscore) + if matches!(self.token_at(pos + 1), Some(Token::Ident(_))) + && pos + 1 < self.tokens.len() + && self.tokens[pos].1.end == self.tokens[pos + 1].1.start => + { + true + } + // `alias` is a plain ident handled in `parse_decl_body`. + Some(Token::Ident(s)) if s == "alias" => true, + // Foreign keywords that reach the parser as plain idents (the lexer + // does not reserve them). Same reasoning as the `Kw*` arm above: + // these are other languages' syntax, not ilo statements, and the + // ILO-P001 keyword hint is far more useful than parsing `let x = 5` + // as a call to a function named `let`. + Some(Token::Ident(s)) + if matches!( + s.as_str(), + "let" | "var" | "const" | "return" | "if" | "function" | "def" + ) => + { + true + } + Some(Token::Ident(_)) => self.is_fn_decl_start(pos), + // `^` version pragma is only a decl at the very first token. + Some(Token::Caret) + if pos == 0 + && matches!(self.token_at(pos + 1), Some(Token::Number(n)) if *n > 0.0) => + { + true + } + _ => false, + } + } + pub fn parse_program(&mut self) -> (Program, Vec) { let mut declarations = Vec::new(); let mut errors: Vec = Vec::new(); @@ -497,12 +551,95 @@ impl Parser { // subsequent ones are noise produced while resyncing through stray // tokens (e.g. a leftover `}` after a body-level parse failure). let mut suppress_p001 = false; + // Bare top-level statements, gathered in source order across the whole + // file and wrapped in a synthetic `main>_;` once parsing finishes + // (ILO-439 script mode). Kept separate from `declarations` so program + // mode's ordering and error handling are untouched when a file has + // none, which is the compatibility guarantee that matters. + let mut script_stmts: Vec> = Vec::new(); + let mut script_span: Option = None; while !self.at_end() { if errors.len() >= MAX_ERRORS { break; } let before_pos = self.pos; + + // Not a declaration, so it's a statement. `parse_body_with(true)` + // stops at the next declaration boundary, which is what lets a file + // interleave `tri n:n>n;...` with a trailing `prnt tri 10` — the + // shape models reach for when asked for a compact program. + // If an explicit `main` has already been parsed, a stray non-decl + // token is far more likely to be a truncated body than a deliberate + // script statement — `main>n;dth=*/dt 1 6 s1;prnt dth` leaves `s1` + // orphaned exactly this way. Hand it to `parse_decl`, whose + // diagnostics for these shapes (the prefix-binop "one too few + // operands" hint, for one) are far more specific than anything + // script mode could say. + let main_already_declared = declarations + .iter() + .any(|d| matches!(d, Decl::Function { name, .. } if name == "main")); + + // A file that *starts* with statements is unambiguously a script, + // so anything goes there. Statements appearing *after* a + // declaration are ambiguous: they may be a deliberate script line + // (`prnt tri 10`), or wreckage left when a body parse truncated + // (`0 -1.5` spilling a glued negative literal into decl position). + // `decl_boundary` records where unindented newlines sat before they + // were filtered out, which separates the two precisely: a real + // script line starts a new top-level line, whereas wreckage is + // glued to the declaration it fell out of (`f>n;1e`). Wreckage + // falls through to `parse_decl`, whose tailored diagnostics for + // those shapes are worth far more than a silently-accepted + // statement. + let stmt_ok_here = declarations.is_empty() + || self + .decl_boundary + .get(self.pos) + .is_some_and(Option::is_some); + + if !self.is_decl_start(self.pos) && !main_already_declared && stmt_ok_here { + let start = self.peek_span(); + match self.parse_body_with(true) { + Ok(stmts) => { + if stmts.is_empty() { + // Consumed nothing and produced nothing — the token + // can't start a statement either (a stray `}`, say). + // Hand it to `parse_decl` so the normal + // "expected declaration" diagnostic fires rather + // than silently skipping the token. + match self.parse_decl() { + Ok(decl) => declarations.push(decl), + Err(e) => { + let err_span = e.span; + errors.push(e); + let end_span = self.sync_to_decl_boundary(); + declarations.push(Decl::Error { + span: err_span.merge(end_span), + }); + } + } + if self.pos == before_pos { + self.advance(); + } + continue; + } + script_stmts.extend(stmts); + let merged = start.merge(self.prev_span()); + script_span = Some(script_span.map_or(merged, |s: Span| s.merge(merged))); + } + Err(e) => { + errors.push(e); + let end_span = self.sync_to_decl_boundary(); + let _ = end_span; + if self.pos == before_pos { + self.advance(); + } + } + } + continue; + } + match self.parse_decl() { Ok(decl) => { declarations.push(decl); @@ -530,6 +667,37 @@ impl Parser { } } + // Wrap any bare top-level statements in a synthetic `main>_;` + // (ILO-439). A file may carry declarations and trailing statements + // together — `tri n:n>n;...` followed by `prnt tri 10` — because that + // is the shape a model writes when asked for a compact program, and + // rejecting it cost retries on every task in the ILO-364 benchmark. + if !script_stmts.is_empty() { + let has_explicit_main = declarations + .iter() + .any(|d| matches!(d, Decl::Function { name, .. } if name == "main")); + if has_explicit_main { + // Two entry points, no way to order them. Refuse rather than + // silently picking one. + errors.push(self.error_hint( + "ILO-P104", + "file has both an explicit `main` and bare top-level statements".into(), + "move the top-level statements into `main`, or remove the explicit `main` and let the statements become it.".into(), + )); + } else { + let span = script_span.unwrap_or_else(|| self.prev_span()); + declarations.push(Decl::Function { + name: "main".to_string(), + type_params: vec![], + params: vec![], + return_type: Type::Any, + effect_set: None, + body: script_stmts, + span, + }); + } + } + // Append synthetic decls emitted by inline-lambda lifting. Their names // start with `__lit_`, which is not a legal user ident (starts with // `_`), so there is no collision risk. @@ -568,6 +736,26 @@ impl Parser { Some(Token::Greater) => true, // name param:type ... — has params Some(Token::Ident(_)) => matches!(self.token_at(pos + 2), Some(Token::Colon)), + // name kw:type — a reserved keyword used as a parameter name. This + // is a malformed decl, not a statement, so keep it in program mode: + // `parse_fn_decl` emits a targeted ILO-P011 naming the keyword, + // which script mode would replace with a confusing body-parse error. + Some( + Token::KwFn + | Token::KwDef + | Token::KwLet + | Token::KwVar + | Token::KwConst + | Token::KwReturn + | Token::KwIf, + ) => matches!(self.token_at(pos + 2), Some(Token::Colon)), + // name: ... — a `:>`-shaped signature typo. Also a malformed decl + // rather than a statement; `parse_fn_decl` emits the signature hint. + Some(Token::Colon) => true, + // name-> ... — an arrow borrowed from another language where ilo + // uses a bare `>`. Malformed decl, not a statement: keep it in + // program mode so ILO-P003's arrow hint fires. + Some(Token::Minus) => matches!(self.token_at(pos + 2), Some(Token::Greater)), // name ... — generic type-parameter block // Recognise `name <` when `<` is followed by a single-char lowercase // ident (the type variable) so we don't misfire on `x < y` in expression @@ -7159,6 +7347,22 @@ fn is_guard_eligible_condition(expr: &Expr) -> bool { /// for declarations that failed to parse. Check `errors.is_empty()` before using /// the program for execution — error nodes are skipped by the verifier but not /// by the backends. +/// Would this token stream start in script mode — i.e. is its first construct a +/// bare statement rather than a declaration (ILO-439)? +/// +/// Callers that need to distinguish "the user defined something" from "the user +/// wrote an expression" must ask this *before* parsing, because script mode +/// wraps statements in a synthesised `main`, which is indistinguishable from a +/// hand-written `main` in the resulting AST. The REPL uses it so `+1 2` still +/// evaluates to `3` instead of reporting `defined: main() -> _`. +pub fn is_script_mode_input(tokens: &[(Token, Span)]) -> bool { + if tokens.is_empty() { + return false; + } + let parser = Parser::new(tokens.to_vec()); + !parser.is_decl_start(0) +} + pub fn parse(tokens: Vec<(Token, Span)>) -> (Program, Vec) { parse_with_max_depth(tokens, effective_max_ast_depth()) } @@ -8214,11 +8418,17 @@ mod tests { /// `^` followed by a non-numeric token at position 0 must still error — /// it is not a version pragma and should not silently produce garbage. #[test] - fn caret_at_file_start_non_version_is_error() { - let (_, errs) = parse_str_errors("^\"not a version\""); + fn caret_at_file_start_non_version_enters_script_mode() { + // ILO-439: `^` not followed by a version number is not a pragma, so it + // is a bare expression, and a file of bare expressions is a script. + // Previously this asserted a parse error, back when top-level + // statements were illegal. + let (prog, errs) = parse_str_errors("^\"not a version\""); + assert!(errs.is_empty(), "unexpected parse errors: {errs:?}"); + assert_eq!(prog.declarations.len(), 1); assert!( - !errs.is_empty(), - "expected parse error for non-numeric pragma" + matches!(&prog.declarations[0], Decl::Function { name, .. } if name == "main"), + "script mode should synthesise `main`" ); } @@ -8524,8 +8734,16 @@ mod tests { .collect(); let (_prog, errors) = parse(token_spans); let err = errors.into_iter().next().expect("expected parse error"); - assert!( - err.message.contains("expected declaration"), + // ILO-439 known limitation: statements followed by a declaration. + // `42 x:n>n;x` still errors, but the message is now the body parser's + // rather than "expected declaration", because statement parsing + // consumes `42 x` as a call before reaching the `:` that would have + // marked `x` as a declaration. Decl-then-statements (the shape models + // actually write) is unaffected — statements after a declaration are + // collected into the synthetic `main`. See + // `mixed_statements_then_decl_is_a_known_limitation`. + assert!( + err.message.contains("expected declaration") || err.message.contains("expected `>`"), "got: {}", err.message ); @@ -8696,7 +8914,7 @@ mod tests { // than a generic EOF message with `Span::UNKNOWN`. Either shape is // fine for the persona — the assertion just needs to confirm a // real parse error fired, not pin the exact wording. - let (_, errors) = parse_str_errors("f"); + let (_, errors) = parse_str_errors("f x:"); assert!(!errors.is_empty(), "expected parse error"); assert!( errors.iter().any(|e| e.message.contains("EOF") @@ -8821,10 +9039,18 @@ mod tests { } #[test] - fn declaration_starts_with_prefix_op_gets_hint() { - // A declaration starting with `+` — triggers hint about prefix operators - let (_, errors) = parse_str_errors("+x 1"); - assert!(!errors.is_empty(), "expected parse error"); + fn declaration_starts_with_prefix_op_enters_script_mode() { + // ILO-439: `+x 1` is a prefix-op expression, which is a legal statement, + // so a file containing only it is a script. Previously asserted a parse + // error. The prefix-operator P001 hint still fires where the construct + // really is in declaration position (see + // `hint_p001_operator_in_decl_position_direct`). + let (prog, errors) = parse_str_errors("+x 1"); + assert!(errors.is_empty(), "unexpected parse errors: {errors:?}"); + assert!( + matches!(&prog.declarations[0], Decl::Function { name, .. } if name == "main"), + "script mode should synthesise `main`" + ); } #[test] @@ -9076,17 +9302,23 @@ mod tests { } #[test] - fn hint_p001_operator_at_decl_level() { - // '+' at declaration level — operator hint + fn operator_at_file_start_enters_script_mode() { + // ILO-439: a leading prefix operator is an expression, so the file is a + // script. Previously this asserted ILO-P001 with the "prefix operators + // can't start a declaration" hint; that hint arm is now unreachable + // from the top of a file, because `is_decl_start` routes operators to + // statement parsing before `parse_decl` ever sees them. let tokens = vec![ (Token::Plus, Span::UNKNOWN), (Token::Ident("x".into()), Span::UNKNOWN), + (Token::Number(1.0), Span::UNKNOWN), ]; - let (_, errors) = parse(tokens); - assert!(!errors.is_empty()); - let e = errors.iter().find(|e| e.code == "ILO-P001").unwrap(); - let hint = e.hint.as_ref().unwrap(); - assert!(hint.contains("prefix operators")); + let (prog, errors) = parse(tokens); + assert!(errors.is_empty(), "unexpected parse errors: {errors:?}"); + assert!( + matches!(&prog.declarations[0], Decl::Function { name, .. } if name == "main"), + "script mode should synthesise `main`" + ); } #[test] @@ -9262,14 +9494,18 @@ mod tests { } #[test] - fn no_hint_p001_unrecognized_token() { - // A token that has no specific hint + fn bare_number_at_file_start_enters_script_mode() { + // ILO-439: a bare literal is a valid statement, so a file containing + // only `42` is a script whose `main` returns 42. Previously this + // asserted ILO-P001 with no hint, back when top-level statements were + // illegal. let tokens = vec![(Token::Number(42.0), Span::UNKNOWN)]; - let (_, errors) = parse(tokens); - assert!(!errors.is_empty()); - // Should get ILO-P001 but no hint for a bare number - let e = errors.iter().find(|e| e.code == "ILO-P001").unwrap(); - assert!(e.hint.is_none()); + let (prog, errors) = parse(tokens); + assert!(errors.is_empty(), "unexpected parse errors: {errors:?}"); + assert!( + matches!(&prog.declarations[0], Decl::Function { name, .. } if name == "main"), + "script mode should synthesise `main`" + ); } #[test] diff --git a/tests/coverage_parser.rs b/tests/coverage_parser.rs index 444ed88f0..ea7b9477d 100644 --- a/tests/coverage_parser.rs +++ b/tests/coverage_parser.rs @@ -203,13 +203,19 @@ fn ident_keyword_if_hint() { } #[test] -fn decl_starts_with_operator_plus() { - fail_code("+1 2", "ILO-P001"); +fn operator_at_file_start_is_script_not_error() { + // ILO-439: `+1 2` is a prefix-op expression, which is a legal statement, so + // a file containing only it is a script. Previously asserted ILO-P001, back + // when top-level statements were illegal. + let (_n, errs) = try_parse("+1 2"); + assert!(errs.is_empty(), "expected script mode, got: {errs:?}"); } #[test] -fn decl_starts_with_operator_minus() { - fail_code("-1 2", "ILO-P001"); +fn negative_literal_at_file_start_is_script_not_error() { + // As above. `-1 2` is `(-1) 2` in script position, not a failed declaration. + let (_n, errs) = try_parse("-1 2"); + assert!(errs.is_empty(), "expected script mode, got: {errs:?}"); } #[test] diff --git a/tests/regression_ilo_p003_token_names.rs b/tests/regression_ilo_p003_token_names.rs index 55a3af67b..4d7042683 100644 --- a/tests/regression_ilo_p003_token_names.rs +++ b/tests/regression_ilo_p003_token_names.rs @@ -185,28 +185,39 @@ fn p003_unclosed_paren_in_expression() { #[test] fn p005_identifier_expected_gets_source_chars() { - // `=42 5` — assignment to a number literal where an identifier was - // expected. Was `got Number(42.0)`. Now `got number `42``. - assert_no_token_enum_leak("=42 5"); + // ILO-439: `=42 5` at file start now enters script mode, where it is a + // valid equality expression (`42 == 5` -> false), so no error fires there. + // Use `type 123{x:n}` instead: a number where a type name is expected, + // which still fires ILO-P005 "expected identifier, got number `123`" and + // exercises the same enum-leak guard. + assert_no_token_enum_leak("type 123{x:n}"); } #[test] fn p001_unexpected_token_at_top_level() { - // Leading operator at top level — `expected declaration, got ...`. - // Was `got Plus`. Now `got `+``. - let (_, errors) = parse_str_errors("+1 2"); + // ILO-439: a leading `+` is a statement start at every top-level position + // now, so it never reaches the "expected declaration, got ..." path and the + // old assertion on a literal `+` in the message is unreachable. The guard + // this test exists for — that raw Token enum names never leak into user + // messages — still applies to whatever token does surface, so assert that + // instead. Statements after an explicit `main` fall through to `parse_decl` + // (so its specific diagnostics survive), which is what makes this error. + assert_no_token_enum_leak("main>n;1\n+ 1 2"); + let (_, errors) = parse_str_errors("main>n;1\n+ 1 2"); let e = errors .iter() .find(|e| e.code == "ILO-P001") .expect("expected ILO-P001"); + // Whatever token is named, it must be rendered as source text in backticks + // rather than as a Rust enum variant. assert!( - e.message.contains("`+`"), - "expected literal `+` in message, got: {}", + e.message.contains('`'), + "expected a backticked source glyph in message, got: {}", e.message, ); assert!( - !e.message.contains("Plus"), - "message leaks Token enum name `Plus`: {}", + !e.message.contains("Plus") && !e.message.contains("Number("), + "message leaks a Token enum name: {}", e.message, ); } diff --git a/tests/regression_top_level_chain_hint.rs b/tests/regression_top_level_chain_hint.rs index 9cffe9cc5..383c8badc 100644 --- a/tests/regression_top_level_chain_hint.rs +++ b/tests/regression_top_level_chain_hint.rs @@ -77,19 +77,37 @@ fn check_capture(_engine: &str, src: &str) -> (bool, String) { const BARE_TOP_CHAIN: &str = "pts=[1 2 3];cs0=[4 5 6];cs1=pts;prnt cs1"; +// ILO-439 inverts this. A chain of bare statements at file start is now a +// script: the parser wraps it in a synthetic `main>_;` and it runs. The old +// expectation (reject with ILO-P102 and suggest the wrapper) described the +// tax this ticket removes. P102 itself is not dead — see +// `p102_still_fires_for_glued_binding` for the case it still catches. fn check_bare_top_chain(engine: &str) { - let (ok, _stdout, stderr) = run_capture(engine, BARE_TOP_CHAIN, "main"); + let (ok, stdout, stderr) = run_capture(engine, BARE_TOP_CHAIN, "main"); assert!( - !ok, - "{engine}: top-level chain without main wrapper must reject at parse time" + ok, + "{engine}: bare top-level chain should now run in script mode. stderr: {stderr}" + ); + assert!( + stdout.contains("[1, 2, 3]"), + "{engine}: expected `[1, 2, 3]` from the script-mode chain, got: {stdout}" ); +} + +// P102's remaining job. A binding glued to a preceding declaration (same +// line, no top-level newline before it) is not a script line — it is almost +// always a body that ran on past its end. Script mode deliberately declines +// it, so the "wrap in `main>_;`" hint still fires where it is apt. +fn check_p102_still_fires_for_glued_binding(engine: &str) { + let (ok, _stdout, stderr) = run_capture(engine, "helper>n;42 pts=[1 2 3]", "main"); + assert!(!ok, "{engine}: glued top-level binding must still reject"); assert!( stderr.contains("ILO-P102"), - "{engine}: expected ILO-P102, got: {stderr}" + "{engine}: expected ILO-P102 for glued binding, got: {stderr}" ); assert!( stderr.contains("main>_"), - "{engine}: diagnostic should suggest `main>_;` wrapper, got: {stderr}" + "{engine}: diagnostic should still suggest `main>_;`, got: {stderr}" ); } @@ -105,22 +123,28 @@ const SLURP_INTO_PRIOR_FN: &str = "gen-pts>L(L n);[[2.0 3.0][8.0 8.0]]\n\ iter cs:L(L n) pts:L(L n)>L(L n);cs\n\ pts=gen-pts;cs0=[[4.8 4.9][6.2 7.1]];cs1=iter cs0 pts;cs2=iter cs1 pts;prnt cs2"; +// ILO-439: this shape now runs. The chain on line 3 starts its own top-level +// line, so script mode collects it into a synthetic `main` instead of +// rejecting it — the k-means program that originally motivated ILO-P102 is +// simply valid ilo now. The slurp guard still matters and is still asserted: +// `iter`'s body must stop at `cs` rather than eating line 3, which the +// correct result proves (`cs2 == cs0`, since `iter` returns its first arg). fn check_slurp_into_prior_fn(engine: &str) { - let (ok, _stdout, stderr) = run_capture(engine, SLURP_INTO_PRIOR_FN, "main"); + let (ok, stdout, stderr) = run_capture(engine, SLURP_INTO_PRIOR_FN, "main"); assert!( - !ok, - "{engine}: slurped top-level chain must reject at parse time" + ok, + "{engine}: slurped-shape chain should now run in script mode. stderr: {stderr}" ); assert!( - stderr.contains("ILO-P102"), - "{engine}: expected ILO-P102 (not a T005 cascade), got: {stderr}" + stdout.contains("[[4.8, 4.9], [6.2, 7.1]]"), + "{engine}: expected cs2 == cs0, which proves `iter`'s body did not \ + slurp line 3; got: {stdout}" ); - // The misparse used to produce multiple ILO-T005 errors anchored on - // the wrong function. The fix collapses to a single P102 — verify - // we don't get a T005 cascade. + // The original misparse produced a cascade of ILO-T005s anchored on the + // wrong function. Still must not happen. assert!( !stderr.contains("ILO-T005"), - "{engine}: ILO-P102 should preempt the T005 cascade, got: {stderr}" + "{engine}: unexpected T005 cascade, got: {stderr}" ); } @@ -176,8 +200,14 @@ fn check_builtin_shadow_keeps_p011(engine: &str) { #[test] fn p102_via_ilo_check_no_engine() { - let (ok, combined) = check_capture("--vm", BARE_TOP_CHAIN); - assert!(!ok, "`ilo check` must reject top-level chain"); + // ILO-439: `ilo check` accepts a bare top-level chain now, because it is a + // script. Point the P102 assertion at the glued shape that script mode + // still declines, so `ilo check` keeps its coverage of the diagnostic. + let (ok, _combined) = check_capture("--vm", BARE_TOP_CHAIN); + assert!(ok, "`ilo check` should accept a script-mode chain"); + + let (ok, combined) = check_capture("--vm", "helper>n;42 pts=[1 2 3]"); + assert!(!ok, "`ilo check` must reject a glued top-level binding"); assert!( combined.contains("ILO-P102"), "expected ILO-P102 from `ilo check`, got: {combined}" @@ -186,6 +216,7 @@ fn p102_via_ilo_check_no_engine() { fn check_all(engine: &str) { check_bare_top_chain(engine); + check_p102_still_fires_for_glued_binding(engine); check_slurp_into_prior_fn(engine); check_main_wrapper_runs(engine); check_normal_fn_decl_unaffected(engine); From d54976fdcdd69421625c339ac0ab7f03679a2165 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 5 Aug 2026 20:27:36 +0100 Subject: [PATCH 2/7] repl: keep expressions evaluating under script mode Script mode wraps bare statements in a synthesised main, which is indistinguishable from a hand-written definition in the AST, so the REPL's def-detection started reporting 'defined: main() -> _' for '+1 2' instead of printing 3. New parser::is_script_mode_input asks what shape the input was before parsing erases the distinction. --- src/main.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 9d0921d93..a1da40391 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1742,8 +1742,14 @@ fn repl_cmd() { ) }) .collect(); + // Script mode (ILO-439) wraps bare statements in a synthesised + // `main`, which looks like a definition in the AST. Ask the + // parser what shape the *input* was, so `+1 2` still evaluates + // to 3 here instead of reporting `defined: main() -> _`. + let is_script = parser::is_script_mode_input(&token_spans); let (program, errors) = parser::parse(token_spans); - if errors.is_empty() + if !is_script + && errors.is_empty() && !program.declarations.is_empty() && program.declarations.iter().all(|d| { matches!( From 2b7a8747ae1c97df1c2c5ff0a93bc502d44ee5cf Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 5 Aug 2026 20:27:48 +0100 Subject: [PATCH 3/7] verify: reject unconditional recursion (ILO-V500) A straight-line body (no guards, match, loops, or early returns) that directly calls its own function can never terminate. Tail-call trampolining made this spin silently at runtime instead of overflowing the stack, most commonly via the script-mode near-miss where a trailing call glued to the definition line joins the body: tri n:n>n;...;prnt tri 10. Verify-time error, hint names both fixes (add a base case, or move the call to its own line). Conservative: any branching construct disables the check. Conditional contexts are excluded precisely - ternary/match/ nil-coalesce branches and closure bodies don't count, their always-evaluated sides do. First code allocated in the reserved V500-599 verifier namespace. Also corrects a test fixture V500 caught red-handed: compat_text_to_sum_param's 'g y:S a b>n;g "hello"' was genuinely infinite. --- src/verify.rs | 192 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 190 insertions(+), 2 deletions(-) diff --git a/src/verify.rs b/src/verify.rs index 4438f58e7..7aef77269 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -7561,6 +7561,107 @@ pub fn infer_effects(program: &Program) -> Vec { /// Run static verification on a parsed program. /// Returns errors and warnings separately. +/// Does evaluating `e` *unconditionally* call `fname`? +/// +/// "Unconditionally" is the load-bearing word: args of any call, operands of +/// ops, list/record elements, and the *condition/subject/value* side of +/// ternaries, matches, and nil-coalesces always evaluate, so a self-call +/// there is guaranteed to run. The branch sides of those constructs, and +/// closure bodies (which may never be invoked), are excluded — a self-call +/// behind any of them is ordinary recursion, not a proven loop. +fn expr_unconditionally_calls(e: &Expr, fname: &str) -> bool { + match e { + Expr::Call { function, args, .. } => { + function == fname || args.iter().any(|a| expr_unconditionally_calls(a, fname)) + } + Expr::BinOp { left, right, .. } => { + expr_unconditionally_calls(left, fname) || expr_unconditionally_calls(right, fname) + } + Expr::UnaryOp { operand, .. } => expr_unconditionally_calls(operand, fname), + Expr::Ok(inner) | Expr::Err(inner) | Expr::Todo(inner) => { + expr_unconditionally_calls(inner, fname) + } + Expr::Field { object, .. } | Expr::Index { object, .. } => { + expr_unconditionally_calls(object, fname) + } + Expr::List(items) => items.iter().any(|i| expr_unconditionally_calls(i, fname)), + Expr::Record { fields, .. } | Expr::AnonRecord { fields } => fields + .iter() + .any(|(_, v)| expr_unconditionally_calls(v, fname)), + Expr::With { object, updates } => { + expr_unconditionally_calls(object, fname) + || updates + .iter() + .any(|(_, v)| expr_unconditionally_calls(v, fname)) + } + // Only the always-evaluated side of each conditional construct. + Expr::Ternary { condition, .. } => expr_unconditionally_calls(condition, fname), + Expr::Match { subject, .. } => subject + .as_deref() + .is_some_and(|s| expr_unconditionally_calls(s, fname)), + Expr::NilCoalesce { value, .. } => expr_unconditionally_calls(value, fname), + // A closure that references the function may never be invoked. + Expr::MakeClosure { .. } => false, + _ => false, + } +} + +/// ILO-V500: a straight-line function body that calls its own function can +/// never terminate — there is no branch, guard, loop exit, or early return +/// that could ever skip the self-call. +/// +/// Motivated by ILO-439 script mode: `tri n:n>n;...;prnt tri 10` glues the +/// trailing call into `tri`'s body, and tail-call trampolining turns the +/// guaranteed infinite recursion into a silent spin (a 20s timeout per +/// attempt in the ILO-364 closed-loop benchmark) instead of a stack +/// overflow. Verify-time rejection converts that spin into an instant, +/// actionable diagnostic. +/// +/// Deliberately conservative: the check only fires when the body contains +/// nothing but bindings, destructures, and expression statements. Any guard, +/// match, loop, `ret`, `brk`, `cnt`, or `defer` disables it — those bodies +/// may recurse legitimately, and this pass makes no attempt to reason about +/// them. +fn check_unconditional_recursion(program: &Program, errors: &mut Vec) { + for decl in &program.declarations { + let Decl::Function { name, body, .. } = decl else { + continue; + }; + let straight_line = body.iter().all(|s| { + matches!( + s.node, + Stmt::Let { .. } | Stmt::Destructure { .. } | Stmt::Expr(_) + ) + }); + if !straight_line { + continue; + } + for s in body { + let expr = match &s.node { + Stmt::Let { value, .. } => value, + Stmt::Destructure { value, .. } => value, + Stmt::Expr(e) => e, + _ => unreachable!("straight_line filter"), + }; + if expr_unconditionally_calls(expr, name) { + errors.push(VerifyError { + code: "ILO-V500", + function: name.clone(), + message: format!( + "`{name}` unconditionally calls itself — it can never terminate" + ), + hint: Some(format!( + "every call to `{name}` reaches this self-call again, so the recursion has no base case. Add a guard (e.g. `=n 0 0`) before the recursive call — or, if `{name} ...` was meant as a top-level statement, put it on its own line so script mode wraps it in `main` instead of gluing it into `{name}`'s body." + )), + span: Some(s.span), + is_warning: false, + }); + break; + } + } + } +} + pub fn verify(program: &Program) -> VerifyResult { verify_with_effects(program, false) } @@ -7577,6 +7678,9 @@ pub fn verify_with_effects(program: &Program, show_effects: bool) -> VerifyResul // Phase 2: verify function bodies (includes effect-set mismatch warnings) ctx.verify_bodies_with_effects(program); + // ILO-V500: provably-infinite recursion in straight-line bodies. + check_unconditional_recursion(program, &mut ctx.errors); + // ILO-W003 (ILO-463): surface parser advisories for `?h a b` // keyword-form uses where the condition is already a bare bool ref. // The keyword form is valid and runs identically, but the cheaper @@ -11238,8 +11342,10 @@ mod tests { #[test] fn compat_text_to_sum_param() { - // Passing text to Sum param → compatible(Text, Sum) (line 186) - assert!(parse_and_verify(r#"f x:S a b>n;0 g y:S a b>n;g "hello""#).is_ok()); + // Passing text to Sum param → compatible(Text, Sum) (line 186). + // (`g` calls `f`, not itself — the original fixture's self-call was + // genuinely infinite recursion and now trips ILO-V500.) + assert!(parse_and_verify(r#"f x:S a b>n;0 g y:S a b>n;f "hello""#).is_ok()); } #[test] @@ -13013,4 +13119,86 @@ mod tests { result.warnings ); } + + // ── ILO-V500: unconditional recursion ──────────────────────────────── + + #[test] + fn v500_trailing_self_call_glued_into_body() { + // The ILO-439 shape: `prnt tri 10` on the same line joins tri's body, + // so tri unconditionally calls itself. Used to spin for the full + // runtime timeout; must now be a verify error. + let errs = parse_and_verify("tri n:n>n;r=*n +n 1;/r 2;prnt tri 10").unwrap_err(); + assert!( + errs.iter().any(|e| e.code == "ILO-V500"), + "expected ILO-V500, got {errs:?}" + ); + } + + #[test] + fn v500_direct_tail_self_call() { + let errs = parse_and_verify("f n:n>n;f n").unwrap_err(); + assert!(errs.iter().any(|e| e.code == "ILO-V500"), "got {errs:?}"); + } + + #[test] + fn v500_self_call_in_binding() { + // The self-call needn't be the tail — a binding evaluates it too. + let errs = parse_and_verify("f n:n>n;r=f n;r").unwrap_err(); + assert!(errs.iter().any(|e| e.code == "ILO-V500"), "got {errs:?}"); + } + + #[test] + fn v500_not_fired_with_guard_base_case() { + // Canonical tail recursion from the skill docs — guard disables the check. + let result = parse_and_verify_full("cd n:n>n;=n 0 0;cd -n 1"); + assert!( + !result.errors.iter().any(|e| e.code == "ILO-V500"), + "guarded recursion must not fire V500: {:?}", + result.errors + ); + } + + #[test] + fn v500_not_fired_with_match_recursion() { + let result = parse_and_verify_full("f n:n>n;?n{0:0;_:f -n 1}"); + assert!( + !result.errors.iter().any(|e| e.code == "ILO-V500"), + "match-arm recursion must not fire V500: {:?}", + result.errors + ); + } + + #[test] + fn v500_not_fired_in_ternary_branch() { + // Self-call in a ternary *branch* is conditional; only the condition + // side counts as unconditional. + let result = parse_and_verify_full("f n:n>n;?h =n 0 0 f -n 1"); + assert!( + !result.errors.iter().any(|e| e.code == "ILO-V500"), + "ternary-branch recursion must not fire V500: {:?}", + result.errors + ); + } + + #[test] + fn v500_not_fired_for_fn_ref_arg() { + // Passing the function by name to a HOF is a reference, not an + // unconditional call. + let result = parse_and_verify_full("f xs:L n>L n;map f xs"); + assert!( + !result.errors.iter().any(|e| e.code == "ILO-V500"), + "fn-ref HOF arg must not fire V500: {:?}", + result.errors + ); + } + + #[test] + fn v500_not_fired_plain_nonrecursive() { + let result = parse_and_verify_full("tri n:n>n;r=*n +n 1;/r 2"); + assert!( + !result.errors.iter().any(|e| e.code == "ILO-V500"), + "non-recursive body must not fire V500: {:?}", + result.errors + ); + } } From 9277fd4d12eff6270a610def43eae6456f5cb2d7 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 5 Aug 2026 20:27:59 +0100 Subject: [PATCH 4/7] docs: script mode in SPEC, skills, example, changelog SPEC gains a 'Script mode (implicit main)' section under Functions (ai.txt regenerates from it via build.rs). ilo-language skill module gets the own-line rule so agents load it via ilo skill get; SKILL.md gets a quick-reference entry. examples/script-mode-mixed.ilo pins the decl-plus-trailing-call shape across engines via run/out assertions. --- CHANGELOG.md | 6 ++++++ SPEC.md | 34 ++++++++++++++++++++++++++++++++++ ai.txt | 2 +- examples/script-mode-mixed.ilo | 9 +++++++++ skills/ilo/SKILL.md | 9 +++++++++ skills/ilo/ilo-language.md | 4 ++++ 6 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 examples/script-mode-mixed.ilo diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f9089c06..31aa5901a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ For the release process and tag conventions, see [RELEASING.md](RELEASING.md). ## Unreleased +### Added + +- **Script mode: implicit `main>_;` for bare top-level statements (ILO-439).** A file no longer needs a `main>_;` wrapper: bare statements at the top level are collected, in source order, into a synthetic `main`. `prnt +2 2` alone in a file prints 4. Declarations and statements mix freely as long as each statement starts its own top-level line - `tri n:n>n;/(*n +n 1) 2` followed by `prnt tri 10` on the next line prints 55, which is exactly the shape a model writes when asked for a compact program. Measured motivation (ILO-364 closed-loop benchmark): the missing-wrapper diagnostics dominated every ilo failure (ILO-P102 fired 19 times across one N=3 run) and models could not recover from them even when the hint named the fix; with script mode those collapse to near zero and failures shift to genuine type errors. Deliberate inversion of the original ticket's "verifier rejects mixed" rule, on that evidence. Guard-rails so the new acceptance cannot swallow existing diagnostics: fragments glued to a broken declaration (`f>n;1e`, `main->n`, `0 -1.5`, stray `}`, foreign `let`/`if`/`return`) still route to the parser's targeted errors; a glued `name=expr` after a declaration still fires ILO-P102 (its registry text updated); a file with both an explicit `main` and bare statements is rejected with new **ILO-P104**; the REPL asks `parser::is_script_mode_input` so `+1 2` still evaluates to `3` instead of reporting `defined: main`. The k-means chain that originally motivated ILO-P102 now simply runs. + +- **ILO-V500: unconditional-recursion detection (first code in the verifier namespace).** A function whose straight-line body (no guards, matches, loops, or early returns) contains a direct call to itself can never terminate; because tail calls trampoline, it used to spin silently at runtime (each occurrence burned the full 20s timeout in the ILO-364 harness) instead of overflowing the stack. Now rejected at verify time with a hint that names both fixes: add a base-case guard, or - for the script-mode case `tri n:n>n;...;prnt tri 10` where a trailing call glued to the definition line joins the body - put the call on its own line. Conservative by construction: any branching construct in the body disables the check, so guarded recursion (`cd n:n>n;=n 0 0;cd -n 1`), match-arm recursion, ternary-branch recursion, and fn-refs passed to HOFs are all untouched (pinned by tests). + ### Fixed - **`get-stream` yields lines on newline, not buffer-fill (ILO-489).** The client-side streaming builtins (`get-stream`, `get-stream-h`, `pst-stream`, `pst-stream-h`, ILO-448) wrapped minreq's `ResponseLazy` in `BufReader::lines()`, which blocks on a full ~8 KiB read-buffer fill before surfacing any line. So a slow SSE upstream that flushes one short event then idles had its lines batched until the buffer filled or the connection closed - functionally correct but latency was buffer-bound, not event-bound. The line splitter now consumes `ResponseLazy`'s byte iterator incrementally and emits each line the instant its `\n` arrives (trailing `\r` stripped for CRLF / chunked encoding), so each event surfaces promptly. EOF still yields a trailing newline-less partial line; mid-stream errors still surface as `ILO-R009 http-stream read error: ...`. Unblocks ILO-482's previously flaky end-to-end streaming test. diff --git a/SPEC.md b/SPEC.md index b7cb8c41e..ac8759369 100644 --- a/SPEC.md +++ b/SPEC.md @@ -65,6 +65,40 @@ Early return: braceless guard (`>=x 0 val` exits the function immediately when t Result unwrap mid-body: `v=call!` extracts the Ok value and propagates Err out of the function before continuing. +### Script mode (implicit `main`) + +Bare statements at the top level of a file are collected, in source order, into a synthetic `main>_;` — no wrapper needed (ILO-439): + +``` +prnt +2 2 -- whole file; prints 4 +``` + +Declarations and top-level statements can be mixed. Each statement must start +its own top-level line (an unindented newline is already the top-level +boundary, exactly as between two function declarations): + +``` +tri n:n>n;/(*n +n 1) 2 +prnt tri 10 -- own line → becomes main's body; prints 55 +``` + +Rules: + +- A statement glued to the same line as a declaration belongs to that + declaration's body. `tri n:n>n;...;prnt tri 10` makes `tri` call itself — + rejected at verify time as unconditional recursion (`ILO-V500`) since a + straight-line body that calls its own function can never terminate. +- A file with an explicit `main` **and** bare top-level statements is + rejected (`ILO-P104`) — two entry points with no defined order. +- Bare top-level `name=expr` bindings glued after a declaration still surface + `ILO-P102` with the `main>_;` wrapper hint; on their own line they are + script statements like any other. +- Fragments that are clearly a malformed declaration (`main->n`, `f x:`, + foreign keywords like `let`/`if`/`return`) keep their targeted parser + diagnostics rather than being swallowed as statements. +- The synthesised `main` behaves exactly like a hand-written `main>_;` — + same graph root, same `ilo file.@` auto-run, same engines. + --- ## Types diff --git a/ai.txt b/ai.txt index 29d1e3f1a..23bd34567 100644 --- a/ai.txt +++ b/ai.txt @@ -1,6 +1,6 @@ INTRO: ilo is a token-optimised programming language for AI agents. Every design choice is evaluated against total token cost: generation + retries + context loading. FILE VERSION PRAGMA: Optional. ^26.5 -- rest of file Top-of-file declaration of the minimum required runtime. First line, no leading whitespace. Sigil-led (principle 4), ~3 tokens (principle 1). First-class syntax, not a magic comment - the lexer recognises `^` only at file start, so `^` elsewhere keeps its `return err` meaning. Pragma absent=Assume latest installed runtime, no diagnostic File targets older than runtime, breaking change between=Fail with migration pointer File targets newer than runtime=Fail asking to upgrade Tooling: `ilo --version-of ` reads the pragma (returns nothing when absent); the formatter canonicalises position when present, never inserts one. Ships with the CalVer cut; 0.x files have no pragma and verify silently. -FUNCTIONS: : ...>; No parens around params - `>` separates params from return type `;` separates statements - no newlines required Last expression is the return value (no `return` keyword) Zero-arg call: `make-id()` Paren-form call (ILO-51): `spl(row, ",")` is sugar for `spl row ","` — same AST, postfix is canonical Labelled args (ILO-71): `dtfmt epoch:e fmt:"%Y"` — optional `label:value` form for any callable with declared parameter names. Labels resolve to positional by name; order is free. Mixed positional + labelled is allowed (positional fill from left; labels fill remaining slots by name). Unknown or duplicate labels surface `ILO-P019` at parse time. Works in both postfix and paren form: `f(b:2, a:1)` ≡ `f a:1 b:2`. **Two body forms — both fully supported:** -- Inline: semicolons separate statements; last expression returns. add-and-double x:n y:n>n;s=+x y;*s 2 -- Brace-block, single-line: explicit braces wrap the whole body (same semantics). add-and-double x:n y:n>n { s = +x y; *s 2 } -- Brace-block, multi-line: newlines inside `{ ... }` act as statement separators -- (same as `;`). The brace form may be inline or multi-line interchangeably. add-and-double x:n y:n>n { s = +x y *s 2 } Multi-step transforms bind intermediate results as locals: tot p:n q:n r:n>n;s=*p q;t=*s r;+s t Early return: braceless guard (`>=x 0 val` exits the function immediately when true); `ret val` exits from any depth including inside a loop or braced conditional. Result unwrap mid-body: `v=call!` extracts the Ok value and propagates Err out of the function before continuing. +FUNCTIONS: : ...>; No parens around params - `>` separates params from return type `;` separates statements - no newlines required Last expression is the return value (no `return` keyword) Zero-arg call: `make-id()` Paren-form call (ILO-51): `spl(row, ",")` is sugar for `spl row ","` — same AST, postfix is canonical Labelled args (ILO-71): `dtfmt epoch:e fmt:"%Y"` — optional `label:value` form for any callable with declared parameter names. Labels resolve to positional by name; order is free. Mixed positional + labelled is allowed (positional fill from left; labels fill remaining slots by name). Unknown or duplicate labels surface `ILO-P019` at parse time. Works in both postfix and paren form: `f(b:2, a:1)` ≡ `f a:1 b:2`. **Two body forms — both fully supported:** -- Inline: semicolons separate statements; last expression returns. add-and-double x:n y:n>n;s=+x y;*s 2 -- Brace-block, single-line: explicit braces wrap the whole body (same semantics). add-and-double x:n y:n>n { s = +x y; *s 2 } -- Brace-block, multi-line: newlines inside `{ ... }` act as statement separators -- (same as `;`). The brace form may be inline or multi-line interchangeably. add-and-double x:n y:n>n { s = +x y *s 2 } Multi-step transforms bind intermediate results as locals: tot p:n q:n r:n>n;s=*p q;t=*s r;+s t Early return: braceless guard (`>=x 0 val` exits the function immediately when true); `ret val` exits from any depth including inside a loop or braced conditional. Result unwrap mid-body: `v=call!` extracts the Ok value and propagates Err out of the function before continuing. [Script mode (implicit `main`)] Bare statements at the top level of a file are collected, in source order, into a synthetic `main>_;` — no wrapper needed (ILO-439): prnt +2 2 -- whole file; prints 4 Declarations and top-level statements can be mixed. Each statement must start its own top-level line (an unindented newline is already the top-level boundary, exactly as between two function declarations): tri n:n>n;/(*n +n 1) 2 prnt tri 10 -- own line → becomes main's body; prints 55 Rules: A statement glued to the same line as a declaration belongs to that declaration's body. `tri n:n>n;...;prnt tri 10` makes `tri` call itself — rejected at verify time as unconditional recursion (`ILO-V500`) since a straight-line body that calls its own function can never terminate. A file with an explicit `main` **and** bare top-level statements is rejected (`ILO-P104`) — two entry points with no defined order. Bare top-level `name=expr` bindings glued after a declaration still surface `ILO-P102` with the `main>_;` wrapper hint; on their own line they are script statements like any other. Fragments that are clearly a malformed declaration (`main->n`, `f x:`, foreign keywords like `let`/`if`/`return`) keep their targeted parser diagnostics rather than being swallowed as statements. The synthesised `main` behaves exactly like a hand-written `main>_;` — same graph root, same `ilo file.@` auto-run, same engines. TYPES: `n`=number (f64) `t`=text (string) `b`=bool `_`=any/unknown (wildcard type) `L n`=list of number `R n t`=result: ok=number, err=text `O n`=optional number (nil or n) `M t n`=map from text keys to numbers `S red green blue`=sum type - one of named text variants `F n t`=function type: takes n, returns t (used in HOF params) `W`=capability World token — `w:W` declares a capability parameter (ILO-68) `order`=named type `a`=type variable - any single lowercase letter except n, t, b [Optional (`O T`)] `O T` accepts either `nil` or a value of type `T`. f x:O n>n;??x 0 -- unwrap optional or default to 0 g>O n;nil -- returns nil (valid O n) h>O n;42 -- returns 42 (valid O n) `??x default` - nil-coalesce: returns `x` if non-nil, else `default`. Unwraps `O T` to `T`. [Sum types (`S a b c`)] Closed set of named text variants. Verifier-enforced; runtime value is always `t`. color x:S red green blue > t ?x{red:"ff0000";green:"00ff00";blue:"0000ff"} Sum types are compatible with `t` - a sum value can be passed to any `t` parameter. [Discriminated union types (`type Foo = A | B(n) | C(t)`)] Named sum types with optional per-variant payloads (Rust-style enums). Each variant is either payload-less or carries exactly one value of a primitive type. type shape = circle(n) | square(n) | point area s:shape > n ?s{circle(r):*3.14159 *r r;square(side):*side side;point:0} **Declaration**: `type Name = V1 | V2(payloadType) | ...` at top level. **Construction**: `circle 5` (payload variant), `point` (payload-less variant used as value directly). **Pattern match**: `?s{circle(r):...; square(side):...; point:...}` using `tag(binding):` or `tag:` arms. **Exhaustiveness**: verifier (ILO-T024) checks all variants are covered; the error lists every missing variant by name and suggests the correct arm syntax (`tag(v): ` for payload variants, `tag: ` for payload-less). A wildcard `_:` arm satisfies exhaustiveness. Missing multiple variants produces a single diagnostic naming all of them. **VM**: programs using discriminated unions fall back to the tree interpreter (JIT codegen deferred). [Generic discriminated union types (`type Result = ok(a) | err(b)`)] Sum type declarations accept type parameters (ILO-402), enabling reusable polymorphic variants. type result = ok(a) | err(b) type option = some(a) | none type either = left(a) | right(b) **Syntax**: `type Name` or `type Name` — one or more single-letter type variables (commas optional). **Type variables**: declared letters (including `n`, `t`, `b`) are treated as type variables in variant payloads, not as primitives. **Erasure**: type variables are erased at runtime — no boxing or specialisation. The verifier accepts any concrete type for a type-variable payload. **Usage**: construct and match exactly like non-generic sum types; the concrete type is inferred from context. safe-div x:n y:n>result =(y) 0{ret err "division by zero"} ok /x y main>t dv=safe-div 10 2 ?dv{ok(v):str v;err(msg):msg} -- "5" [Map type (`M k v`)] Dynamic key-value collection. Keys are typed: text (`t`) or integer (`n`). `Int(1)` and `Text("1")` are distinct keys. mmap -- empty map mset m k v -- return new map with key k set to v mget m k -- value at key k, or nil mget-or m k default -- value at key k, or default if missing (never nil) mhas m k -- b: true if key exists mkeys m -- L t: sorted list of keys mvals m -- L v: values sorted by key mpairs m -- L (L _): sorted [k, v] pairs; mpairs m == zip (mkeys m) (mvals m) mdel m k -- return new map with key k removed len m -- number of entries Numeric keys work directly - no `str` conversion needed. Float keys floor to `i64` at the builtin boundary (matching `at xs i`); NaN/Infinity raise at runtime. idx=mmap idx=mset idx 7 "seven" -- M n t, integer key mget idx 7 -- "seven" mhas idx 7 -- true mhas idx "7" -- false (Int and Text are distinct) `jdmp` stringifies numeric keys for JSON output (JSON object keys are always strings). The round-trip via `jpar` is lossy - numeric keys come back as text. Example: scores>M t n m=mmap m=mset m "alice" 99 m=mset m "bob" 87 mget m "alice" -- 99 [Type variables] A single lowercase letter (other than `n`, `t`, `b`) in type position is a type variable. Used for higher-order function signatures: identity x:a>a;x apply f:F a a x:a>a;f x **Without a bound declaration** type variables are treated as `unknown` during verification — the verifier accepts any type for `a` without consistency checking across call sites (legacy behaviour; backward compatible). [Bounded generics] Explicit generic type parameters allow the verifier to enforce two properties at call sites: 1. All arguments bound to the same type variable have the same concrete type. 2. The concrete type satisfies the declared bound. **Syntax:** `name` before the parameter list. Bounds are optional per variable; omitting `:bound` defaults to `any`. gmn x:a y:a>a -- min of two comparable values gadd x:a y:a>a -- addition, numeric values only grep s:a n:n>t -- repeat text gid x:a>a -- identity, any type **Bound set** (small and fixed): `any`=any type (default when bound omitted) `comparable`=`n`, `t`, `b` `numeric`=`n` `text`=`t` **Call-site checking:** gmn 3 7 -- ok: both n gmn "a" "b" -- ok: both t gmn 1 "two" -- ILO-T044: 'a' bound to n then t (inconsistent) gadd "x" "y" -- ILO-T044: 't' does not satisfy numeric bound Unbounded legacy type-variable usage (`identity x:a>a;x`) continues to work without changes. [Inline lambdas] Pass a function literal directly to a HOF instead of defining a one-off top-level helper: by-dist xs:L n>L n;srt (x:n>n;abs x) xs nonempty ws:L t>L t;flt (s:t>b;>(len s) 0) ws sumsq xs:L n>n;fld (a:n x:n>n;+a *x x) xs 0 Syntax: `(: ...>;)`. Same shape as a top-level function declaration, wrapped in parens, no name. **Brace-lambda shorthand** (`{params> stmts}`): bare param names (types inferred as `any`) and no explicit return type. Useful for compact multi-statement bodies in `map`/`flt`/`fld`: sumsq xs:L n>n;fld {a x>; tmp=*x x; +a tmp} xs 0 dbl xs:L n>L n;map {x> *x 2} xs pos xs:L n>L n;flt {x> >x 0} xs The `;` after `>` is optional. The body supports the same `;`-chained statement forms as the paren lambda and top-level function bodies (let-bindings, guards, match, loops, `ret`/`brk`/`cnt`). Closure capture also works — any name that isn't a param or body-local is snapshot from the enclosing scope. **Phase 1 (no captures)** lifts the literal to a synthetic top-level decl and works across every engine (tree, VM, Cranelift JIT, AOT). The body's free variables must all be params, locals defined inside the lambda body, or known top-level fns. **Phase 2 (closure capture)** lets the body reference variables from the enclosing scope: f xs:L n thr:n>L n;flt (x:n>b;>x thr) xs -- captures `thr` (paren form) f xs:L n thr:n>L n;flt {x> >x thr} xs -- captures `thr` (brace form) **Builtins are not first-class values.** Builtin names (`sha256`, `hmac-sha256`, `b64`, etc. — every name in the builtin table) are call-only: they can appear in call position but cannot be passed by name to a HOF. `map sha256 xs` fails ILO-T004 ("undefined variable 'sha256'") with a hint pointing to the canonical wrap-as-lambda rewrite. Wrap the builtin in an inline lambda instead: hashes xs:L t>L t;map (x:t>t;sha256 x) xs -- paren form hashes xs:L t>L t;map {x> sha256 x} xs -- brace form A handful of arithmetic/string builtins (`abs`, `min`, `max`, `mod`, `sum`, `prod`, `len`, `upr`, `lwr`, `trm`, `cap`, `padl`, `padr`, `ord`, `chr`, `chars`, `str`, `num`, `jdmp`, `fmod`, `flr`, `cel`, `rou`, `avg`, `median`, `stdev`, `variance`) are promoted to `Ty::Fn` at the verifier so they *can* be passed directly (see `builtins-as-hof.ilo`); everything else needs the lambda wrap. Phase 2 captures run natively on every engine: the tree interpreter, the register VM, the Cranelift JIT, and the Cranelift AOT backend. Each free variable is snapshot by value at the call site (`Expr::MakeClosure`) and appended to the call frame's arg slice on dispatch. The AOT backend additionally embeds the postcard-serialised `CompiledProgram` into the binary's `.rodata` and publishes TLS pointers on startup, so dispatch helpers can re-enter the VM on user-fn callbacks. The ctx-arg form (`srt fn ctx xs`) remains the cross-engine alternative when you want explicit state without forming a closure. **Braceless guards are rejected inside lambda bodies (`ILO-P023`).** A braceless guard at statement position (`>=x 0 val`, `=x 0 val`, etc.) early-returns from the *enclosing function*, not from the lambda — see "Early Return" below. Inside a lambda body that semantics is almost never what the author meant; the lambda body would silently skip past the guard and the outer caller would return out from under the higher-order call. The parser therefore rejects braceless guards inside lambda bodies and asks for one of two expression-shaped rewrites: **Prefix ternary** when both arms are values: `map (x:n>n;?>=x 0 0 x) xs` **Braced match** when arms need statements: `map (x:n>n;?>=x 0{0}{x}) xs` Braceless guards at top-level function bodies continue to work — this restriction is lambda-body only. A future runtime change (follow-up to ILO-473) may switch the early-return target inside lambdas; until then the diagnostic prevents the silent miscompile. **Rejected lambda shapes (ILO-456).** Only the paren form `(x:t>r;body)` and the bare-param brace form `{x> body}` are accepted. Three shapes from other functional languages look plausible but are deliberately rejected — each emits a targeted hint naming both canonical forms and the call-site rewrite: `flt {x:t> body} xs`=`flt (x:t>r;body) xs` (paren is the typed form) `flt \x:t>body xs`=`flt (x:t>r;body) xs` or `flt {x> body} xs` `flt fn x:t>r;body xs`=`flt (x:t>r;body) xs` or `flt {x> body} xs` The brace form is the *bare-param* shorthand — its params are inferred as `any`, so `{x:n> body}` is a category error rather than a typed-brace lambda. [Trailing-semicolon semantics] `;` is the **statement separator** in ilo. A trailing `;` — one that appears after the last statement with nothing following it before the next structural boundary — is **always silently consumed** (ignored). It is never required, never an error, and never changes the meaning of the body. This applies uniformly across all three body contexts: Top-level function declaration=`name params>return;body` — the `;` after the return type separates the header from the body; it is **optional** when a newline is present=A trailing `;` after the last statement is consumed and ignored Inline lambda=`(params>return;body)` — the `;` after the return type separates the header from the body; it is **optional**=A trailing `;` before the closing `)` is consumed and ignored Match / guard arm body=`arm:body;` — `;` terminates an arm and starts the next; a trailing `;` before `}` is consumed and ignored=Consumed silently; arm body is parsed as-is The parser calls `parse_body_with` (for function bodies) and `parse_lambda_body` (for inline-lambda bodies). After consuming each `;` separator between statements, if the next token is at a body-end boundary (`EOF`, `}`, `)`, or the start of a new sibling function declaration) the loop breaks without error. No statement is emitted for the trailing `;`. **Practical rules:** `f>n;42` and `f>n;42;` are identical — both parse to a single-statement body returning `42`. `(x:n>n;+x 1)` and `(x:n>n;+x 1;)` are identical inline lambdas. `?x{a:1;b:2;}` and `?x{a:1;b:2}` parse identically — the trailing `;` before `}` is silently dropped. A `;` at the very start of a body (before any statement) is **not** a trailing semicolon — it is a missing-statement parse error (`ILO-P001`/`ILO-P003`). Only a `;` after a valid statement is silently consumed. The header/body separator `;` in `name params>return;body` is similarly optional when the token stream contains a newline at that boundary (the lexer converts indented newlines to `;`). The parser checks `peek() == Semi` and advances past it if present. fn declarations are **top-level only**; for a one-off helper that needs a local, use an inline lambda. A `name params>type;body` shape inside another function's body is rejected with `ILO-P024` (ILO-460); the closure-capture variant is tracked separately. NAMING: Short names everywhere. 1–3 chars. `order`=`ord`=truncate `customers`=`cs`=consonants `data`=`d`=single letter `level`=`lv`=drop vowels `discount`=`dc`=initials `final`=`fin`=first 3 `items`=`its`=first 3 Function names follow the same rules. Field names in constructors and external tool names keep their full form - they define the public interface. [Identifier syntax] Identifiers are lowercase ASCII only, optionally with hyphenated segments. Formally: `[a-z][a-z0-9]*(-[a-z0-9]+)*`. Capital letters and underscores are rejected at the binding and call site. run -- OK run-d -- OK (hyphen separates segments) r2 -- OK (digit after first letter) runD -- ERROR (capital letter) RunD -- ERROR (leading capital) run_d -- ERROR (underscore not allowed in bindings) -run -- ERROR (must start with a letter) `runD` in the interactive CLI surfaces as `ILO-L003 unexpected token` with a suggestion to use `run-d` or `rund`. The constraint is intentional: a single lexical shape per identifier keeps the token stream predictable for agents and avoids style debates over camelCase vs snake_case vs kebab-case. **Hyphen vs subtraction.** A hyphen with no surrounding whitespace is always part of an identifier — `best-d` is one token, never `best - d`. Subtraction requires whitespace on at least the operator side: `- best d` (prefix form) or `best - d` (infix form). When an unbound kebab ident has every segment bound, `ILO-T004` adds a hint pointing at the prefix form. When an unbound kebab ident splits uniquely into two bound names (e.g. `zr-sq-zi-sq` → `zr-sq` and `zi-sq`), the hint shows both the prefix form (`- zr-sq zi-sq`) and the infix-with-spaces form (`zr-sq - zi-sq`). The only place capital letters and underscores are accepted is **after `.` or `.?`** at field-access position, so heterogeneous JSON keys from real APIs work without rewriting. See [Field names at dot-access](#field-names-at-dot-access) for the full list of post-dot relaxations (`r.URL`, `r.AccessKey`, `r.user_name`, etc.). Binding names (`AccessKey = ...`) and function names (`AccessKey x:n>n;...`) still error. [Reserved words] The following identifiers are reserved and cannot be used as names: `if`, `return`, `let`, `fn`, `def`, `var`, `const`. Using them produces a friendly error with the ilo equivalent: -- ERROR: `if` is a reserved word. Use: ?cond{true:...;false:...} -- ERROR: `return` is a reserved word. Last expression is the return value. -- ERROR: `let` is a reserved word. Use: name = expr -- ERROR: `fn`/`def` is a reserved word. Use: name param:type > rettype; body These checks fire at parse time across every context the keyword can appear in: top-level declaration head (`fn>n;...`), binding LHS (`fn=5`), and **parameter position** (`g fn:n>n;fn` rejects with ILO-P011 against the param name, not a cryptic ILO-P003 against the missing `>`). Builtin names (`flat`, `frq`, `map`, `flt`, `cat`, `len`, `srt`, `hd`, `tl`, `ord`, `fld`, `lst`, ...) are also rejected as user-function names and as local-binding LHS. Without this, calls to the user fn or use sites of the local binding silently mis-dispatch to the builtin and surface as a confusing `ILO-T006` arity mismatch. The parser intercepts at the declaration site with ILO-P011 and a rename hint: flat n:n>n;n -- ERROR ILO-P011: `flat` is a builtin and cannot be used as a function name -- hint: rename to something like `myflat` or `flatof`. main>n;flat=cat xs " ";spl flat ". " -- ERROR ILO-P011: `flat` is a builtin and cannot be used as a binding name -- hint: rename to something like `myflat` or `flatv`. [Reserved namespaces] Short builtin names are precious surface and ilo reserves a stable subset of them. To save agents (and their carry-forward scripts) from "what got reserved this release?" debugging cycles, the language publishes the full short-name reserve list plus a forward-compatibility rule for future builtins. **Type-sigil letters are not reserved as identifiers.** The primitive type letters `n` (number), `t` (text), `b` (bool) — and the compound sigils `L`, `R`, `O`, `M`, `S`, `F` — are *position*-scoped. They are recognised as types only after `:` in a parameter binding or after `>` in a return-type annotation. Everywhere else (binding LHS, expression operand, fn name) they are normal lowercase identifiers. `t = 5` binds a local `t`; the canonical first example `tot p:n q:n r:n>n;s=*p q;t=*s r;+s t` uses `t` as a scratch local. Agents are free to use `n`, `t`, `b` as variables. Capital letters remain rejected as user identifiers (the rule `[a-z][a-z0-9]*(-[a-z0-9]+)*` is the source of truth). See [ILO-478](https://linear.app/ilo-lang/issue/ILO-478) for the in-flight migration that makes the primitive sigils uppercase (`N`/`T`/`B`) and removes this positional caveat. **Currently reserved short names (1-3 characters).** Every name in this list is a builtin today and triggers `ILO-P011` if used as a binding or user-function name: 1-char e 2-char at hd pi tl rd wr ct 3-char abs avg b64 bor cap cat cel chr cos del det dot env ewm exp fft fld flr flt fmt frq get grp has hed hex inv len log lsd lst lwr map max min mod now num opt ord pat pow pst put rdb rdl rep rev rgx rng rnd rou run sin slc spl srt str sum tan tau trm unq upr wra wrl wro zip All builtin aliases (`head`, `length`, `filter`, `concat`, `tail`, `sort`, `reverse`, `flatten`, `contains`, `group`, `average`, `print`, `trim`, `split`, `format`, `regex`, `read`, `readlines`, `readbuf`, `write`, `writelines`, `lset`, `floor`, `ceil`, `round`, `rand`, `random`, `rng`, `string`, `number`, `slice`, `unique`, `fold`) are reserved with the same shadow-prevention semantics as canonical builtin names. Binding an alias name or using it as a user-function name fires `ILO-P011` at parse time with the canonical form in the diagnostic, since the call-site rewrite to the canonical builtin silently bypasses any user binding of the same name. Previously only `rng` and `rand` had individual guards; as of 0.12.1 every alias in the table above is covered by a single `resolve_alias` check, so new aliases automatically inherit the protection when added to the table. Longer builtin names (`acos`, `asin`, `atan`, `flat`, `take`, `drop`, `mget`, `mset`, `mmap`, `prnt`, `mapr`, `solve`, `lstsq`, `clamp`, `cumsum`, `cprod`, `median`, `matmul`, `range`, `window`, `chunks`, `walk`, `glob`, `prod`, `fsize`, `mtime`, `isfile`, `isdir`, `band`, `bxor`, `bnot`, `bshl`, `bshr`, `brot`, …) are also reserved and rejected by `ILO-P011`, but the short-name namespace above is where carry-forward scripts most often collide, so it gets explicit enumeration. Longer builtin names (`acos`, `asin`, `atan`, `flat`, `take`, `drop`, `mget`, `mset`, `mmap`, `prnt`, `mapr`, `solve`, `clamp`, `cumsum`, `cprod`, `median`, `matmul`, `range`, `window`, `chunks`, `walk`, `glob`, `prod`, `fsize`, `mtime`, `isfile`, `isdir`, `ones`, `linspace`, …) are also reserved and rejected by `ILO-P011`, but the short-name namespace above is where carry-forward scripts most often collide, so it gets explicit enumeration. **Forward-compatibility rule.** Future ilo releases add new builtins under names **4 characters or longer**. A 2-character name that is not on this list today is safe to use as a binding or function name and stays safe across releases. A 3-character name that is not on this list is _highly likely_ to stay safe but is not a hard promise - the 3-char surface is already dense, and a rare ergonomic win may justify an addition, called out in the changelog. This gives agents a deterministic safe-name strategy: **2 chars**: any unreserved 2-char name is permanently fine for bindings (`ce` for "category", `ix` for index, `mn` for "mean", `pq` for "priority queue", …). Names on the reserved list above never get removed. **3 chars**: prefer unreserved 3-char names where possible. If a future release reserves one, the migration is a 1-character rename plus a changelog entry. **4+ chars**: always safe. New builtins land here first; any short alias is added later only if the long name is unambiguous and the short doesn't shadow a plausible user binding. When a collision does happen, `ILO-P011` surfaces it at the binding site with a rename suggestion - never silently mis-dispatches at the call site (see the `flat=cat xs " "` example above). Combined with the reserve list, that turns every name-collision incident into a single-character rename instead of a debugging spiral. [Cross-language gotchas] Common shapes reached for from other languages. The parser and lexer surface each with a friendly hint: `AND a b`, `OR a b`, `NOT a`=`&a b`, `|a b`, `!a`=`ILO-L001` `=a b`=`<=a b`, `>=a b` (single token)=`ILO-P003` `f=fn x:n>n;+x 1` (lambda)=`(x:n>n;+x 1)` (parenthesised lambda)=`ILO-P009` `\x{+x 1}` (Haskell/Rust lambda)=`(x:n>n;+x 1)` (parenthesised lambda)=`ILO-L001` `flt {x:t> body} xs` (typed-brace at HOF)=`flt (x:t>r;body) xs` (paren = typed; brace = bare params)=`ILO-P001` `flt \x:t>body xs` (typed backslash)=`flt (x:t>r;body) xs` or `flt {x> body} xs`=`ILO-L001` `flt fn x:t>r;body xs` (`fn`-keyword inline)=`flt (x:t>r;body) xs` or `flt {x> body} xs`=`ILO-P009` `main:>n;body`=`main>n;body` (no `:` before `>`)=`ILO-P003` Multi-line body without braces=`@k xs{body}`, `cond{body}` on one line=`ILO-P003` `cond{^"err"}` braced-cond=Braceless `cond ^"err"` for early return=hint only `- -*a b *c d` (double-minus)=`- 0 +*a b *c d` (negate the sum)=`ILO-P021` `[k fmt2 v 2]` (call in list)=`[k (fmt2 v 2)]` or bind-first=`ILO-P101` `[login "a" logout "b"]` (variant ctor in list)=`[(login "a") (logout "b")]` or bind-first=`ILO-T047` `pts=gen-pts;cs0=[...];prnt cs0` at top level=`main>_;pts=gen-pts;cs0=[...];prnt cs0` (wrap in `main>_;`)=`ILO-P102` `((((...((1+1))))...))` 1000 deep=bind intermediates, or pass `--max-ast-depth N`=`ILO-P103` `dx=xj 0-xi` (call vs binop)=`-xj xi` or pre-bind: `nxi=0-xi;+xj nxi`=`ILO-T005` `wc==q ""` (no space, binding+equality)=`wc = =q ""` (single `=` to bind, then prefix `=a b` for equality)=`ILO-T005` `tup.0` / `pair.0` (tuple access)=bind from `zip`-pair, then `at pair 0` (no tuple type)=`ILO-T004` `?? (num s) 0` (`??` on `R T E`)=`default-on-err (num s) 0` or `?(num s){~v:v;^_:0}`=`ILO-T041` `?bool{body}` (bool-conditional)=guard `=bool true body`, braced `=bool true{body}`, ternary `?bool a b`, or match `?bool{true:a; false:b}`=`ILO-P011` `(x:n>n;>=x 0 0;x)` (braceless guard inside lambda)=`(x:n>n;?>=x 0 0 x)` (prefix ternary) or `(x:n>n;?>=x 0{0}{x})` (braced match)=`ILO-P023` `+a+" "+b+c` (infix-style chain with leading prefix `+`)=drop the leading `+`: `a+" "+b+c`; or `fmt "{} {} {}" a b c`; or nested prefix `+a +" " +b c`; or bind intermediates=`ILO-P010` `fmt "{}" +0.1 0.2` -> `0.30000000000000004` (float Display = full IEEE 754)=`fmt "{:.2f}" (+0.1 0.2)` for human-readable; `fmt2 v N` for precise dp=docs only `*/ sz 0.3 0` ("scale then div by 0")=`*/a b c` is `(a/b)*c` — b is the divisor; for `(a*b)/c` use `/*sz 0.3 0` or bind `r=*sz 0.3;/r 0`=hint only `?h a b` (keyword form on bare ref)=`? a b` (bare-bool prefix ternary)=`ILO-W003` `pred q:t>b;=q "" 1;false` (guard tail literal)=`=q "" true;false` (tail value must match declared return type)=`ILO-T008` Nested `helper x:n>n;body` inside another function body=inline lambda `helper=(x:n>n;body)` (captures locals), or lift to top-level and thread the local as an explicit param=`ILO-P023` Nested `helper x:n>n;body` inside another function body=inline lambda `helper=(x:n>n;body)` (captures locals), or lift to top-level and thread the local as an explicit param=`ILO-P024` Each case fires a hint pointing at the canonical form; the agent's first retry should be the right one. Identifier-shaped collisions with builtin names (`len=...`, `sin=...`) are rejected with `ILO-P011` plus a rename suggestion. The list-literal call trap (`ILO-P101`) catches the case where a variadic builtin (`fmt`, `fmt2`) appears bare inside `[...]`. Fixed-arity builtins (`str`, `at`, `map`, ...) auto-expand to a call as one element, but variadic ones can't (the parser doesn't know where their args end), so the bare form would silently fall through as multiple elements with the builtin name as an undefined Ref. Fix by wrapping the call in parens (`[k (fmt2 v 2)]`) or binding first. The top-level chain trap (`ILO-P102`) catches a bare `name=expr` at the top level. ilo requires every binding to live inside a function body; a top-level `pts=gen-pts;cs0=[[...]]; ...; prnt cs2` without a `main>_;` (or any) header used to either die on the `=` (a bare `ILO-P003`) or get slurped into a previous function's body and emit a wall of misleading `ILO-T005` cascades on the wrong line. `ILO-P102` collapses both shapes into a single diagnostic that names the offending binding and suggests the canonical `main>_;` wrapper. The double-minus trap (`ILO-P021`) catches the silent-miscompile shape `- - a b c d` for `` in `{+,*,/}`. Read intuitively as `-(a*b) - (c*d)` but parses as `-((a*b) - (c*d)) = -(a*b) + (c*d)` because the inner `-` greedily consumes both prefix-binop groups as binary subtract and the outer `-` falls back to unary negate. Fix by negating the sum (`- 0 +*a b *c d`) or binding first (`p=*a b;q=*c d;- 0 +p q`). Single-atom variants like `- -a b` remain accepted since they're unambiguous. The glued-`==` binding trap (`ILO-T005` with the ILO-469 hint) catches `name==expr` written without a space. Both `=` and `==` lex as a single `Token::Eq`, so `wc==q ""` parses as the binding `wc = (q "")` — a call on `q` — and the verifier fails because `q` isn't a function. The hint names the missing space and shows the canonical rewrite `wc = =q ""` (single `=` for the binding, then prefix `=a b` for equality). ilo does not fuse `==` into a single bind-then-equality token; the diagnostic is a nudge, not a syntactic concession. The call-vs-binop trap (`ILO-T005` with tailored hint) catches the assignment-RHS shape `name expr` where `name` is a bound non-fn value (typically a parameter). Whitespace-juxtaposition is the call syntax in ilo, so `dx=xj 0-xi` parses as `dx=(xj 0)-xi` — a call to `xj` with argument `0`. Verification fails because `xj` isn't a function. The hint surfaces the prefix-operator alternatives (`-xj xi`, `+xj `) and the pre-bind workaround. The misparse is most common when an agent reaches for infix arithmetic between a parameter and a subexpression; pre-binding the operand always resolves the ambiguity. `ilo --explain ILO-T005` includes the full gotcha walkthrough. The tuple-access trap (`ILO-T004` with the `at ` hint) catches `tup.0` / `pair.0` shapes where `tup` / `pair` was never bound. ilo has no tuple type. `zip xs ys` returns `L (L n)` — a list of two-element lists — so destructuring a pair is `at pair 0` / `at pair 1`, not `pair.0` / `pair.1`. The hint names the exact `at` call to write. (`pair.0` itself is still valid sugar for list indexing once `pair` is bound to an `L T`; the diagnostic only fires when the identifier is unbound.) The AST depth cap (`ILO-P103`) catches deeply nested source that would otherwise blow the parser stack. Any context that compiles untrusted text - `ilo serv`, the bare-positional dispatch, the `--ast` dump - is exposed to a payload of the shape `((((...((1+1))))...))` 1000 levels deep that recurses straight through the OS thread stack. The default cap of 256 is far above anything hand-written (the in-tree examples top out under 20) and low enough to keep the worst-case stack frame in `parse_atom`/`parse_expr` inside the default 8 MB main-thread stack. Override with `--max-ast-depth N` on `ilo`, `ilo run`, `ilo check`, `ilo build`, and `ilo serv` when a legitimate program needs deeper nesting. COMMENTS: -- full line comment +a b -- end of line comment -- no multi-line comments; use consecutive -- lines -- like this Single-line only. `--` to end of line. No multi-line comment syntax - newlines are a human display concern, not a language concern. An entire ilo program can be one line. Use consecutive `--` lines when humans need multi-line comments. Stripped at the lexer level before parsing - comments produce no AST nodes and cost zero runtime tokens. Generating `--` costs 1 LLM token, so comments are essentially free. **Gotcha:** `--x 1` is a comment, not "negate (x minus 1)". The lexer matches `--` greedily as a comment and eats the rest of the line. To negate a subtraction, use a space or bind first: -- DON'T: --x 1 (comment, not negate-subtract) -- DO: - -x 1 (space separates the two minus operators) -- DO: r=-x 1;-r (bind first) diff --git a/examples/script-mode-mixed.ilo b/examples/script-mode-mixed.ilo new file mode 100644 index 000000000..a4484c9b8 --- /dev/null +++ b/examples/script-mode-mixed.ilo @@ -0,0 +1,9 @@ +-- Script mode with declarations (ILO-439): bare top-level statements on +-- their own lines are collected into a synthetic `main>_;`, so a file can +-- define helpers and then just call them - the shape a model writes when +-- asked for a compact program. No `main>_;` wrapper needed. +tri n:n>n;r=*n +n 1;/r 2 +prnt tri 10 + +-- run: main +-- out: 55 diff --git a/skills/ilo/SKILL.md b/skills/ilo/SKILL.md index a732e4083..7e6a67cd5 100644 --- a/skills/ilo/SKILL.md +++ b/skills/ilo/SKILL.md @@ -51,6 +51,15 @@ The content lives in `skills/ilo/.md`. The installed binary serves the sam ## Quick reference - things agents miss +**Script mode - no `main>_;` wrapper needed (ILO-439).** Bare top-level statements are collected into a synthetic `main`. `prnt +2 2` alone in a file prints 4. Mixing with declarations works when each statement starts its own top-level line: + +``` +tri n:n>n;/(*n +n 1) 2 +prnt tri 10 -- own line -> becomes main's body; prints 55 +``` + +Do NOT glue the call to the declaration's line - `tri n:n>n;...;prnt tri 10` puts the call inside `tri`'s body, making it call itself (rejected as ILO-V500 unconditional recursion). Don't combine bare statements with an explicit `main` (ILO-P104). + **Text concatenation - three builtins, three jobs.** Pick by shape, not by habit: - `+ a b` - two-arg text/number concat (also list concat). `+ "hi " name` -> `"hi alice"`. diff --git a/skills/ilo/ilo-language.md b/skills/ilo/ilo-language.md index 86e608e60..a221b1384 100644 --- a/skills/ilo/ilo-language.md +++ b/skills/ilo/ilo-language.md @@ -92,6 +92,10 @@ Parens: `map (x:n>n;+x 1) xs`. Captures tree-only; VM/JIT auto-fallback. Non-last fns end with safe expr (op, index, match, literal, parens); last fn: anything. +## script mode (implicit main) + +Bare top-level statements auto-wrap into a synthetic `main>_;` — no wrapper needed. `prnt +2 2` alone in a file prints 4. Mix with decls: each statement on its OWN top-level line (unindented). `tri n:n>n;/(*n +n 1) 2` then newline then `prnt tri 10` prints 55. A call glued to the decl's line (`tri n:n>n;...;prnt tri 10`) joins `tri`'s body → `tri` calls itself → ILO-V500 unconditional recursion. Explicit `main` + bare statements together = ILO-P104; pick one. + ## strings `"text"` with `\n \t \" \\`. Multi-line `"""..."""`. Interp `"hi {name}"` => `fmt "hi {}" name`. Single-ident slots only. `{{`/`}}` escape inside interpolated strings. Bare `{}` still positional; don't mix `{ident}` + `{}` in one string. From b93caf3c234872ff616c8ebaad450a3217a75498 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 5 Aug 2026 20:46:41 +0100 Subject: [PATCH 5/7] clippy: drop redundant borrow in bench-silent assert rust 1.97 adds useless_borrows_in_formatting; CI lint runs -D warnings so the pre-existing borrow in this untouched test now blocks every PR. --- tests/regression_bench_silent.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/regression_bench_silent.rs b/tests/regression_bench_silent.rs index 5b91ea336..abf3cddba 100644 --- a/tests/regression_bench_silent.rs +++ b/tests/regression_bench_silent.rs @@ -55,7 +55,7 @@ fn bench_silent_suppresses_program_stdout_under_json() { non_json_lines.is_empty(), "expected only JSON envelopes on stdout under --silent; saw {} non-JSON lines (first few: {:?})", non_json_lines.len(), - &non_json_lines.iter().take(3).collect::>() + non_json_lines.iter().take(3).collect::>() ); // ...and we still got bench numbers — at least one envelope per From 1f554cfb63f5473f84cae7767a9134562059796a Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 5 Aug 2026 21:04:32 +0100 Subject: [PATCH 6/7] parser: stop script-statement operand chains at line boundaries Newlines are filtered from the token stream before expression parsing, so two bare script lines glued into one call: r=quad 7 then prnt r on its own line parsed as r=quad(7, prnt, r) and surfaced as a baffling arity error on visibly correct code (18 of 33 type errors in the post-script-mode benchmark traced to this shape family). New script_stmt_boundary ctx flag makes can_start_operand treat any top-level decl boundary as the end of an operand chain while collecting script statements - the same stop the ident= carve-out already applied, generalised. Set only around script collection, so function bodies and nested contexts parse exactly as before. Two regression tests pin the multi-statement shapes. --- src/parser/mod.rs | 57 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 2b698c933..d72f6f67e 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -56,6 +56,13 @@ pub struct ParseContext { /// range-bound expression in a foreach/for-range statement so that /// `@x xs{body}` is never mis-parsed as `@x (xs {lambda})`. pub no_brace_lambda_operand: bool, + /// When true, an unindented newline (top-level decl boundary) terminates + /// any call-arg / operand chain. Set while collecting script-mode + /// statements (ILO-439): newlines are filtered from the token stream, so + /// without this, `r=quad 7` followed by `prnt r` on its own line parses as + /// `r = quad(7, prnt, r)` — the call greedily eats the next top-level + /// line as arguments. + pub script_stmt_boundary: bool, } pub struct Parser { @@ -600,7 +607,10 @@ impl Parser { if !self.is_decl_start(self.pos) && !main_already_declared && stmt_ok_here { let start = self.peek_span(); - match self.parse_body_with(true) { + let saved = self.push_ctx(|c| c.script_stmt_boundary = true); + let parsed = self.parse_body_with(true); + self.pop_ctx(saved); + match parsed { Ok(stmts) => { if stmts.is_empty() { // Consumed nothing and produced nothing — the token @@ -5542,6 +5552,12 @@ results first: `r={first_op}a b;…r` keeps each step explicit." { return false; } + // In script-mode statement collection every top-level line is its own + // statement, so any decl boundary ends the operand chain — not just + // the `ident =` shape above (ILO-439). + if self.ctx.script_stmt_boundary && self.boundary_at_cursor().is_some() { + return false; + } self.can_start_atom() || matches!( self.peek(), @@ -9493,6 +9509,45 @@ mod tests { assert!(!program.declarations.is_empty()); } + #[test] + fn script_lines_do_not_glue_into_call_args() { + // Regression: newlines are filtered before expression parsing, so + // without the script_stmt_boundary ctx flag `r=quad 7` followed by + // `prnt r` on its own line parsed as `r = quad(7, prnt, r)` — an + // arity error on a program that is visibly correct. + let src = "double x:n>n;*x 2\nquad x:n>n;a=double x;double a\nr=quad 7\nprnt r"; + let (prog, errors) = parse_str_errors(src); + assert!(errors.is_empty(), "unexpected parse errors: {errors:?}"); + let main = prog + .declarations + .iter() + .find_map(|d| match d { + Decl::Function { name, body, .. } if name == "main" => Some(body), + _ => None, + }) + .expect("script mode should synthesise main"); + assert_eq!( + main.len(), + 2, + "expected two separate statements, got {main:?}" + ); + } + + #[test] + fn multi_line_script_statements_stay_separate() { + let (prog, errors) = parse_str_errors("a=+1 2\nb=*a 3\nprnt b"); + assert!(errors.is_empty(), "unexpected parse errors: {errors:?}"); + let main = prog + .declarations + .iter() + .find_map(|d| match d { + Decl::Function { name, body, .. } if name == "main" => Some(body), + _ => None, + }) + .expect("script mode should synthesise main"); + assert_eq!(main.len(), 3, "expected three statements, got {main:?}"); + } + #[test] fn bare_number_at_file_start_enters_script_mode() { // ILO-439: a bare literal is a valid statement, so a file containing From 829830f8bbc3b55afd0900a06e37d64a996c6215 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 5 Aug 2026 21:29:56 +0100 Subject: [PATCH 7/7] skills: compress script-mode section under the ilo-language token cap CI budget check caught the first draft at 2011 tokens (cap 1950). Trimmed to essentials; module now 1944. --- skills/ilo/ilo-language.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/ilo/ilo-language.md b/skills/ilo/ilo-language.md index a221b1384..25ebd35c6 100644 --- a/skills/ilo/ilo-language.md +++ b/skills/ilo/ilo-language.md @@ -92,9 +92,9 @@ Parens: `map (x:n>n;+x 1) xs`. Captures tree-only; VM/JIT auto-fallback. Non-last fns end with safe expr (op, index, match, literal, parens); last fn: anything. -## script mode (implicit main) +## script mode -Bare top-level statements auto-wrap into a synthetic `main>_;` — no wrapper needed. `prnt +2 2` alone in a file prints 4. Mix with decls: each statement on its OWN top-level line (unindented). `tri n:n>n;/(*n +n 1) 2` then newline then `prnt tri 10` prints 55. A call glued to the decl's line (`tri n:n>n;...;prnt tri 10`) joins `tri`'s body → `tri` calls itself → ILO-V500 unconditional recursion. Explicit `main` + bare statements together = ILO-P104; pick one. +Bare top-level statements auto-wrap into a synthetic `main>_;`. `prnt +2 2` alone prints 4. With decls, each statement on its OWN unindented line; glued to a decl's line it joins that body (self-call → ILO-V500). Explicit `main` + bare stmts = ILO-P104. ## strings