diff --git a/examples/match-on-value.ilo b/examples/match-on-value.ilo new file mode 100644 index 00000000..37f49bab --- /dev/null +++ b/examples/match-on-value.ilo @@ -0,0 +1,34 @@ +-- Match-on-value: the ilo equivalent of Rust's `match n { 1 => ..., _ => ... }` +-- or a C `switch`. This file pins the two canonical shapes the ILO-P009 +-- "match-on-value arms" hint funnels agents toward when they reach for the +-- wrong syntax (e.g. `?h x{0:1;_:2}` thinking `?h` is a `match` keyword). +-- +-- Single-token subject — drop any leading `?h` and prefix the value with `?`. +-- Arms are `:` pairs separated by `;`; `_` is the fallback. +classify x:n>t;?x{0:"zero";1:"one";_:"other"} + +-- Multi-token subject — wrap the whole match expression in parens so the +-- parser knows where the subject ends and the arms begin. Without the parens +-- the parser reads `mod x 3` as a prefix-call sequence and never reaches the +-- match form, which is exactly the failure the ILO-P009 hint is designed to +-- redirect away from. +mod3 x:n>n;?(mod x 3){0:10;1:20;_:0} + +-- Text-valued match subject works identically — arm literals just become +-- text patterns rather than numbers. +ok lvl:t>n;?lvl{"high":2;"low":1;_:0} + +-- run: classify 1 +-- out: one +-- run: classify 5 +-- out: other +-- run: mod3 6 +-- out: 10 +-- run: mod3 7 +-- out: 20 +-- run: mod3 8 +-- out: 0 +-- run: ok "high" +-- out: 2 +-- run: ok "med" +-- out: 0 diff --git a/skills/ilo/ilo-language.md b/skills/ilo/ilo-language.md index 48189600..2e67d137 100644 --- a/skills/ilo/ilo-language.md +++ b/skills/ilo/ilo-language.md @@ -29,7 +29,7 @@ Flat early returns at statement: `cls sp:n>t;>=sp 1000 "gold";>=sp 500 "silver"; ## match -`?r{~v:v;^e:^+"failed: "e;_:"unknown"}`. Arms: `"lit":body`, `42:body`, `~v:body` ok-bind, `^e:body` err-bind, `_:body` else. +`?r{~v:v;^e:^+"failed: "e;_:"unknown"}`. Arms: `"lit":body`, `42:body`, `~v:body` ok-bind, `^e:body` err-bind, `_:body` else. Multi-token subj wraps: `?(e){…}`. ## results diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 2fe17263..f2c709a2 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1412,6 +1412,20 @@ impl Parser { // expression-position branch above and the `?=cond a b` family in // `parse_prefix_ternary`. let first = self.parse_prefix_binop_operand()?; + // Before consuming the second operand, intercept a `{` to give a + // context-aware hint (match-on-value mis-parenthesisation, or the + // `?h cond{...}` vs the three canonical forms). Otherwise the + // bare ILO-P009 "expected expression, got `{`" surfaces with no + // recovery hint, costing a re-prompt or worse (cron-explainer + // burned 167k tokens partly on this shape; the date/time persona + // cluster all tripped on the `?h cond{...}` variant). + let subj_src = subject_source(subj); + let first_src = subject_source(&first); + if let Some(err) = + self.prefix_ternary_brace_hint(subj_src.as_deref(), first_src.as_deref()) + { + return Err(err); + } let second = self.parse_prefix_binop_operand()?; // `?h` general prefix-ternary: when the subject ident is literally // `h` and a third operand follows, reinterpret `?h` as a fixed @@ -1520,6 +1534,74 @@ impl Parser { false } + /// When parsing a prefix-ternary operand and the next token is `{`, + /// produce a context-aware ILO-P009 instead of the bare "expected + /// expression, got `{`". Two shapes are common: + /// + /// 1. `? {:body; ...}` — agent reached for a Rust-style + /// match on a value but used a leading prefix-ternary keyword shape + /// instead of the canonical `?{...}` (single-token subject) or + /// `?(){...}` (multi-token). Hint at the parenthesised form using + /// the parsed operand as the most-likely-intended subject. + /// 2. `?h cond{body}` (non-arm brace) — agent reached for braced- + /// conditional / brace-ternary execution but used the `?h cond a b` + /// prefix-ternary keyword. Point at the right shape for each intent + /// (drop the braces for `?h cond a b`; drop the `?h` prefix for + /// `cond{body}` braced-conditional). + /// + /// Returns `None` if peek isn't `{`, leaving the caller to fall through + /// to the normal operand parse (and its own ILO-P009 if the token is + /// invalid in a different way). + fn prefix_ternary_brace_hint( + &self, + subj_src: Option<&str>, + first_operand_src: Option<&str>, + ) -> Option { + if self.peek() != Some(&Token::LBrace) { + return None; + } + let is_match_arm_shape = self.brace_starts_with_literal_arm(); + let cond_display = first_operand_src.unwrap_or("cond"); + let (msg, hint) = if is_match_arm_shape { + // For `?h x{0:1; _:2}` the agent likely wanted to match on `x`. + // For multi-token operands (where first_operand_src is None + // because it wasn't a bare Ref), recommend the parens form on a + // placeholder so the agent fills in their own expression. + let body = match first_operand_src { + Some(name) => format!( + "match arms need a single bracketed subject. Drop `?{}` and write `?{name}{{:body; _:fallback}}`; for a multi-token subject use `?(){{...}}`", + subj_src.unwrap_or("h") + ), + None => "match arms need a single bracketed subject. Wrap a multi-token match expression in parens: `?(){:body; _:fallback}`".to_string(), + }; + ( + "expected ternary operand, got `{` (looks like match-on-value arms)".to_string(), + body, + ) + } else { + ( + "expected ternary operand, got `{`".to_string(), + format!( + "three conditional shapes: prefix-ternary `?h {cond_display} a b` (no braces), brace-ternary `{cond_display}{{a}}{{b}}` (no `?h`), braced-conditional `{cond_display}{{body}}` (no `?h`, single brace). Pick one" + ), + ) + }; + Some(self.error_hint("ILO-P009", msg, hint)) + } + + /// Peek into the `{...}` block at the cursor and decide whether the first + /// non-trivial token looks like a match-arm literal (`:` or + /// `"text":`). Pure lookahead. Assumes peek is `{`. + fn brace_starts_with_literal_arm(&self) -> bool { + debug_assert!(self.peek() == Some(&Token::LBrace)); + match self.token_at(self.pos + 1) { + Some(Token::Number(_)) | Some(Token::Text(_)) => { + self.token_at(self.pos + 2) == Some(&Token::Colon) + } + _ => false, + } + } + /// Parse `?subj{a}{b}` ternary after the subject has been consumed and /// `looks_like_brace_ternary` returned true. Consumes both brace blocks /// and produces an `Expr::Ternary`. @@ -2271,6 +2353,17 @@ impl Parser { // chokes on `sc "NONE"`. See `parse_prefix_ternary` for the // same swap on the `?=cond a b` family. let first = self.parse_prefix_binop_operand()?; + // Mirror the stmt-position hint in `parse_match_stmt`: intercept + // a `{` before the second operand so `?h cond{...}` and + // `?subj{:body;...}` get actionable hints instead of the + // bare ILO-P009. + let subj_src = subject_source(subj.as_ref()); + let first_src = subject_source(&first); + if let Some(err) = + self.prefix_ternary_brace_hint(subj_src.as_deref(), first_src.as_deref()) + { + return Err(err); + } let second = self.parse_prefix_binop_operand()?; // `?h` general prefix-ternary in expr position. See the matching // block in `parse_match_stmt` for the rationale: literal subject diff --git a/tests/regression_ternary_brace_hint.rs b/tests/regression_ternary_brace_hint.rs new file mode 100644 index 00000000..83b3368d --- /dev/null +++ b/tests/regression_ternary_brace_hint.rs @@ -0,0 +1,151 @@ +// Regression tests: context-aware ILO-P009 hints when a `{` appears mid +// prefix-ternary operand parse. Two shapes covered: +// +// 1. `? {:body; _:fallback}` — Rust-style match-on-value +// reached for the wrong shape. Hint should suggest the parenthesised +// `?(){...}` form (or single-token `?{...}`). +// 2. `?h cond{body}` — three-form conditional confusion (`?h cond a b` vs +// `cond{a}{b}` vs `cond{body}`). Hint should enumerate all three. +// +// Both shapes are pure parser diagnostics — exercised via `--parse` so all +// engines (tree / VM / Cranelift) share the same front-end behaviour. + +use std::process::Command; + +fn ilo() -> Command { + Command::new(env!("CARGO_BIN_EXE_ilo")) +} + +fn stderr_of(args: &[&str]) -> String { + let out = ilo().args(args).output().expect("failed to run ilo"); + String::from_utf8_lossy(&out.stderr).into_owned() +} + +// --------------------------------------------------------------------------- +// Hint #1: match-on-value shape mis-keyed with leading `?h` +// --------------------------------------------------------------------------- + +#[test] +fn ternary_match_arm_shape_has_hint() { + // `?h x{0:1; _:2}` — agent wanted `?x{0:1; _:2}` (or `?(x){0:1; _:2}`). + // After parsing subject `h` and first operand `x`, the parser hits `{` + // and would otherwise emit a bare ILO-P009 with no hint. + let src = "main x:n>n;?h x{0:1;_:2}"; + let stderr = stderr_of(&[src, "--vm", "main", "1"]); + assert!( + stderr.contains("ILO-P009"), + "expected ILO-P009, got: {stderr}" + ); + assert!( + stderr.contains("match-on-value arms"), + "expected match-on-value framing in message, got: {stderr}" + ); + assert!( + stderr.contains("?(") || stderr.contains("?x{"), + "expected hint pointing at parenthesised or single-token match form, got: {stderr}" + ); +} + +#[test] +fn ternary_match_arm_shape_uses_first_operand_in_hint() { + // The recommended single-token form should echo the operand the agent + // most likely wanted as the match subject (here: `mn`). + let src = "main x:n>n;mn=x;?h mn{0:1;_:2}"; + let stderr = stderr_of(&[src, "--vm", "main", "1"]); + assert!( + stderr.contains("ILO-P009"), + "expected ILO-P009, got: {stderr}" + ); + assert!( + stderr.contains("?mn{"), + "hint should suggest `?mn{{...}}` using the parsed operand, got: {stderr}" + ); +} + +// --------------------------------------------------------------------------- +// Hint #2: three-form conditional confusion +// --------------------------------------------------------------------------- + +#[test] +fn ternary_h_cond_brace_body_has_hint() { + // `?h ok{1}` — agent wanted one of the three canonical shapes. The + // brace body is a single expression (NOT a `:` arm), so the hint + // should enumerate all three forms rather than the match-on-value + // recommendation. + let src = "main x:n>n;ok=>x 0;?h ok{1}"; + let stderr = stderr_of(&[src, "--vm", "main", "1"]); + assert!( + stderr.contains("ILO-P009"), + "expected ILO-P009, got: {stderr}" + ); + // Should NOT misfire as match-arm shape (single non-literal-arm body) + assert!( + !stderr.contains("match-on-value arms"), + "should not flag as match-on-value when body is a bare expression, got: {stderr}" + ); + // Should cover all three canonical conditional shapes + assert!( + stderr.contains("?h ok a b"), + "hint should mention prefix-ternary `?h cond a b` form with the parsed condition, got: {stderr}" + ); + assert!( + stderr.contains("ok{a}{b}"), + "hint should mention brace-ternary `cond{{a}}{{b}}` form, got: {stderr}" + ); + assert!( + stderr.contains("ok{body}"), + "hint should mention braced-conditional `cond{{body}}` form, got: {stderr}" + ); +} + +#[test] +fn ternary_h_cond_brace_body_hint_in_expr_position() { + // Same shape but in expr position (RHS of binding). The hint must fire + // in `parse_prefix_ternary` (expression path) too, not only the + // statement-position `parse_match_stmt`. + let src = "main x:n>n;ok=>x 0;r=?h ok{1};r"; + let stderr = stderr_of(&[src, "--vm", "main", "1"]); + assert!( + stderr.contains("ILO-P009"), + "expected ILO-P009 in expr position, got: {stderr}" + ); + assert!( + stderr.contains("three conditional shapes") + || (stderr.contains("?h ok a b") && stderr.contains("ok{body}")), + "expected three-shape hint in expr position, got: {stderr}" + ); +} + +// --------------------------------------------------------------------------- +// Negative: shapes that already work must still parse cleanly +// --------------------------------------------------------------------------- + +#[test] +fn parenthesised_match_subject_still_parses() { + // Canonical `?(){...}` form must not be flagged by the new hint. + let src = "main x:n>n;?(mod x 3){0:10;1:20;_:0}"; + let out = ilo() + .args([src, "--vm", "main", "1"]) + .output() + .expect("run ilo"); + assert!( + out.status.success(), + "parenthesised match subject should parse, stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn prefix_ternary_three_operand_still_parses() { + // `?h cond a b` keyword form must not be flagged when no `{` follows. + let src = "main x:n>n;ok=>x 0;?h ok 1 0"; + let out = ilo() + .args([src, "--vm", "main", "1"]) + .output() + .expect("run ilo"); + assert!( + out.status.success(), + "?h cond a b should parse, stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +}