From 61d4dcb50e892cb8a9a1a83aff107f40f1b595d2 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Thu, 2 Jul 2026 14:06:29 +0800 Subject: [PATCH 1/5] Truth up crates/AGENTS.md implemented-subset gap list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of the parser against spec §16 confirmed several missing productions that the "Not yet implemented" list did not mention: `spawn`, `select`, `emit`, `if let`, `let` destructuring patterns, and `#[...]` compiler directives all lex but fail to parse, and `storage { }` blocks inside `onchain mod` are consumed but discarded. The subset contract is the source of truth for what the bootstrap parser accepts, so the list must match observed behavior. This commit seeds the integration branch for the parser correctness wave; fix sub-PRs will merge into this branch and update the lines they change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H3CinyyqWL6SfCLrKGm3ja --- crates/AGENTS.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/AGENTS.md b/crates/AGENTS.md index 877a5f9..bbe9a96 100644 --- a/crates/AGENTS.md +++ b/crates/AGENTS.md @@ -28,8 +28,15 @@ the accepted list as **not yet implemented** — add a corpus fixture when it la unary/binary (incl. `/`), `?`, `.await`, `|>`, `as`, struct literals (with the §5.1 block-head restriction), `vec!`, and `if`/`else`; `let`, `return`, `break`, `continue`, `send`, and expression/tail statements. -- **Not yet implemented:** `match`, `while`, `for`, `loop`, and closures (their - keywords lex but have no parse production); generic parameters and +- **Not yet implemented:** `match`, `while`, `for`, `loop`, `if let`, and + closures (their keywords lex but have no parse production); `spawn`, `select`, + and `emit` (reserved keywords with no expression/statement production yet, so + §8 actor spawns and §11.5 event emission do not parse); patterns — `let` binds + a single identifier only, no destructuring (`let (a, b) = ...` and + `let Some(x) = ...` are rejected); `#[...]` compiler directives are not parsed + in any position (`#[cfg(test)]`, `#[target(...)]`, `#[indexed]` all fail); + `storage { }` blocks inside `onchain mod` are consumed but discarded, not + stored in the AST; generic parameters and `trait`/`impl` bodies are skipped, not stored in the AST; `let mut` / `&mut` mutability and the `send` keyword are parsed but not preserved; a block-like expression (`if`/block) used as a non-tail statement needs a trailing `;`. From d6bd425f443349d0359b974d0a12a7b2a43c0632 Mon Sep 17 00:00:00 2001 From: RevenantPulse <68097584+StreamDemon@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:40:58 +0800 Subject: [PATCH 2/5] Fix pipe-stage ?, impl Trait for Type, trait headers, dyn type args (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix pipe-stage ?, impl Trait for Type, trait headers, and dyn type args Four parser bugs found by an empirical review against spec section 16, all in surface the subset contract claims to support: - `x |> f?` parsed as `x |> (f?)` because `|>` was an ordinary binary operator and the `?` postfix bound inside the right operand. Spec sections 5.7 and 16 (pipe_stage) mandate `(x |> f)?` -- the stage's trailing `?` wraps the accumulated pipe application. The RHS of `|>` now goes through a dedicated pipe_stage() production (callee path, optional turbofish, field chain, optional args) and the caller wraps the accumulated pipe in ErrorProp. Closure stages and non-final- segment turbofish remain not-yet-implemented with explicit errors. - `impl Trait for Type` failed with "expected item": impl_block() never handled `for`. It now records an optional TraitRef and parses `where` clauses. - `trait Convert` and `trait Loggable: Printable` failed: trait_def() skipped straight from the name to the body. Generic params, supertrait bounds (parsed structurally, not stored), and `where` clauses now parse. - `&dyn Iter` failed: the dyn branch of ty() ignored type_args. Type::Dyn now carries a TraitRef (grammar's trait_ref = type_path [type_args]). Grammar-correct behavior changes, regression-guarded by tests: `x |> a + b` is now `(x |> a) + b` (a stage is only a callee), and struct literals are no longer valid pipe stages. Adds 15 unit tests (AST-shape assertions for every pipe edge case plus impl/trait/dyn shapes), two corpus fixtures (traits_impls.sp, pipes.sp), and updates the crates/AGENTS.md subset contract for the lines these fixes change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H3CinyyqWL6SfCLrKGm3ja * Add predicate to the pipe-stage not-yet-implemented list item Cubic review on PR #70: the new list entry was the only clause in the not-yet-implemented list without a verb. It now states what the parser actually does — rejects both shapes with explicit not-yet-implemented parse errors. --- crates/AGENTS.md | 17 +- crates/sploosh-ast/src/lib.rs | 11 +- crates/sploosh-parser/src/lib.rs | 370 +++++++++++++++++++++++++- crates/sploosh-parser/tests/corpus.rs | 2 + tests/corpus/pipes.sp | 12 + tests/corpus/traits_impls.sp | 52 ++++ 6 files changed, 455 insertions(+), 9 deletions(-) create mode 100644 tests/corpus/pipes.sp create mode 100644 tests/corpus/traits_impls.sp diff --git a/crates/AGENTS.md b/crates/AGENTS.md index bbe9a96..96e5232 100644 --- a/crates/AGENTS.md +++ b/crates/AGENTS.md @@ -22,11 +22,16 @@ The parser covers a subset of §16, not the whole grammar. Treat anything outsid the accepted list as **not yet implemented** — add a corpus fixture when it lands. - **Accepted:** every item form (`fn`, `struct`, `enum` incl. tuple/struct - variants, `actor`, `mod`, `use`, `const`, `type`, `trait`, `impl`, + variants, `actor`, `mod`, `use`, `const`, `type`, `trait` incl. generics, + supertrait bounds, and `where` clauses (bounds parsed but not stored), + `impl Type` and `impl Trait for Type` (trait ref recorded in the AST), `onchain mod`, `extern`); types incl. generics, references, arrays/slices, - tuples, `fn`/`dyn`; expressions incl. calls, turbofish, field/index, - unary/binary (incl. `/`), `?`, `.await`, `|>`, `as`, struct literals (with the - §5.1 block-head restriction), `vec!`, and `if`/`else`; `let`, `return`, + tuples, `fn`, and `dyn Trait` incl. associated-type bindings; + expressions incl. calls, turbofish, field/index, + unary/binary (incl. `/`), `?`, `.await`, `as`, struct literals (with the + §5.1 block-head restriction), `vec!`, and `if`/`else`; `|>` with §16 + `pipe_stage` stages (`callee [args] [?]` — a stage's trailing `?` wraps the + accumulated pipe application per §5.7); `let`, `return`, `break`, `continue`, `send`, and expression/tail statements. - **Not yet implemented:** `match`, `while`, `for`, `loop`, `if let`, and closures (their keywords lex but have no parse production); `spawn`, `select`, @@ -36,7 +41,9 @@ the accepted list as **not yet implemented** — add a corpus fixture when it la `let Some(x) = ...` are rejected); `#[...]` compiler directives are not parsed in any position (`#[cfg(test)]`, `#[target(...)]`, `#[indexed]` all fail); `storage { }` blocks inside `onchain mod` are consumed but discarded, not - stored in the AST; generic parameters and + stored in the AST; closure pipe stages (`x |> (|v| ...)`) and turbofish on a + non-final pipe-stage segment are rejected with explicit + not-yet-implemented parse errors; generic parameters and `trait`/`impl` bodies are skipped, not stored in the AST; `let mut` / `&mut` mutability and the `send` keyword are parsed but not preserved; a block-like expression (`if`/block) used as a non-tail statement needs a trailing `;`. diff --git a/crates/sploosh-ast/src/lib.rs b/crates/sploosh-ast/src/lib.rs index f3723e4..cbbba7c 100644 --- a/crates/sploosh-ast/src/lib.rs +++ b/crates/sploosh-ast/src/lib.rs @@ -171,6 +171,8 @@ pub struct Trait { #[derive(Debug, Clone, PartialEq)] pub struct ImplBlock { + /// `Some` for `impl Trait for Type`, `None` for an inherent `impl Type`. + pub trait_ref: Option, pub target: Type, } @@ -195,7 +197,14 @@ pub enum Type { Slice(Box), Tuple(Vec), Function { params: Vec, ret: Box }, - Dyn(Path), + Dyn(TraitRef), +} + +/// `trait_ref = type_path [ type_args ]` (§16). +#[derive(Debug, Clone, PartialEq)] +pub struct TraitRef { + pub path: Path, + pub args: Vec, } #[derive(Debug, Clone, PartialEq)] diff --git a/crates/sploosh-parser/src/lib.rs b/crates/sploosh-parser/src/lib.rs index 6d83a3f..7f5dcc7 100644 --- a/crates/sploosh-parser/src/lib.rs +++ b/crates/sploosh-parser/src/lib.rs @@ -370,16 +370,52 @@ impl Parser { fn trait_def(&mut self) -> Option { self.expect_keyword(Keyword::Trait)?; let name = self.ident()?; + self.maybe_generic_params(); + if self.eat(TokenKind::Colon).is_some() { + self.bounds(); + } + self.maybe_where_clause(); self.skip_item_body(); Some(Trait { name }) } + /// `bounds = bound { "+" bound }` with `bound = trait_ref | lifetime` (§16). + /// Parsed for validation and diagnostics; not stored during bootstrap. + fn bounds(&mut self) { + loop { + if self.eat(TokenKind::Lifetime).is_none() && self.trait_ref().is_none() { + self.recover_until(&[TokenKind::LBrace, TokenKind::Semi]); + return; + } + if self.eat(TokenKind::Plus).is_none() { + break; + } + } + } + + /// `trait_ref = type_path [ type_args ]` (§16). + fn trait_ref(&mut self) -> Option { + let path = self.path()?; + let args = self.type_args(); + Some(TraitRef { path, args }) + } + fn impl_block(&mut self) -> Option { self.expect_keyword(Keyword::Impl)?; self.maybe_generic_params(); - let target = self.ty()?; + let first = self.ty()?; + let (trait_ref, target) = if self.eat_keyword(Keyword::For).is_some() { + let Type::Path { path, args } = first else { + self.error_here("expected trait path before `for`"); + return None; + }; + (Some(TraitRef { path, args }), self.ty()?) + } else { + (None, first) + }; + self.maybe_where_clause(); self.skip_item_body(); - Some(ImplBlock { target }) + Some(ImplBlock { trait_ref, target }) } fn onchain_item(&mut self) -> Option { @@ -487,7 +523,7 @@ impl Parser { }); } if self.eat_ident_text("dyn").is_some() { - return Some(Type::Dyn(self.path()?)); + return Some(Type::Dyn(self.trait_ref()?)); } let path = self.path()?; let args = self.type_args(); @@ -656,6 +692,30 @@ impl Parser { break; } let op_text = self.bump().lexeme; + if op == "|>" { + // §16: the RHS of `|>` is a `pipe_stage`, not a precedence-climbed + // expression. + let stage = self.pipe_stage()?; + let span = lhs.span.join(stage.span); + lhs = Expr { + kind: ExprKind::Binary { + op: op_text, + left: Box::new(lhs), + right: Box::new(stage), + }, + span, + }; + // §5.7: a stage's trailing `?` applies to the accumulated pipe + // application result — `x |> f?` is `(x |> f)?`. + if self.eat(TokenKind::Question).is_some() { + let span = lhs.span.join(self.prev_span()); + lhs = Expr { + kind: ExprKind::ErrorProp(Box::new(lhs)), + span, + }; + } + continue; + } let rhs = self.expr(right_bp)?; let span = lhs.span.join(rhs.span); lhs = if op == "=" { @@ -819,6 +879,73 @@ impl Parser { } } + /// `pipe_stage = stage_callee [ "(" args ")" ]` with + /// `stage_callee = path_expr [ turbofish ] { "." IDENT [ turbofish ] }` (§16). + /// The stage-trailing `?` is consumed by the caller so it can wrap the + /// accumulated pipe application (§5.7). The `"(" closure ")"` stage form is + /// not yet implemented — closures have no parse production. + fn pipe_stage(&mut self) -> Option { + if self.at(TokenKind::LParen) { + self.error_here("closure pipe stages are not yet implemented"); + return None; + } + if !matches!( + self.peek_kind(), + Some(TokenKind::Ident | TokenKind::Keyword(Keyword::SelfValue | Keyword::SelfType)) + ) { + self.error_here("expected pipe stage: a function path or method chain"); + return None; + } + let path = self.path()?; + let mut callee = Expr { + span: path.span, + kind: ExprKind::Path(path), + }; + let mut type_args = self.turbofish(); + while type_args.is_none() && self.eat(TokenKind::Dot).is_some() { + let name = self.ident()?.name; + let span = callee.span.join(self.prev_span()); + callee = Expr { + kind: ExprKind::Field { + base: Box::new(callee), + name, + }, + span, + }; + type_args = self.turbofish(); + } + if type_args.is_some() && self.at(TokenKind::Dot) { + self.error_here("turbofish on a non-final pipe-stage segment is not yet implemented"); + return None; + } + if self.eat(TokenKind::LParen).is_some() { + let args = self.args(TokenKind::RParen)?; + let end = self.expect(TokenKind::RParen)?.span.end; + let span = Span::new(callee.span.start, end); + callee = Expr { + kind: ExprKind::Call { + callee: Box::new(callee), + type_args: type_args.unwrap_or_default(), + args, + }, + span, + }; + } else if let Some(type_args) = type_args { + // `x |> parse::` has no parens; keep the type args on a zero-arg + // call — §5.6 desugaring makes this identical to `x |> parse::()`. + let span = callee.span.join(self.prev_span()); + callee = Expr { + kind: ExprKind::Call { + callee: Box::new(callee), + type_args, + args: Vec::new(), + }, + span, + }; + } + Some(callee) + } + fn if_expr(&mut self) -> Option { let start = self.expect_keyword(Keyword::If)?.span.start; let condition = self.cond_expr()?; @@ -1332,4 +1459,241 @@ mod tests { }; assert_eq!(fields.len(), 1); } + + /// Parses `fn f() { let r = ; }` and returns the bound expression. + fn let_value(expr_src: &str) -> Expr { + let source = format!("fn f() {{ let r = {expr_src}; }}"); + let program = parse_program(&source).unwrap(); + let ItemKind::Function(func) = &program.items[0].kind else { + panic!("expected function"); + }; + let Stmt::Let { value, .. } = &func.body.as_ref().unwrap().statements[0] else { + panic!("expected let statement"); + }; + value.clone() + } + + fn path_named(expr: &Expr, name: &str) -> bool { + matches!(&expr.kind, ExprKind::Path(path) if path.segments == [name]) + } + + #[test] + fn pipe_stage_question_wraps_accumulated_pipe() { + // §5.7: `input |> parse?` is `(input |> parse)?`, never `input |> (parse?)`. + let value = let_value("input |> parse?"); + let ExprKind::ErrorProp(inner) = &value.kind else { + panic!("expected ErrorProp at the top, got {value:?}"); + }; + let ExprKind::Binary { op, left, right } = &inner.kind else { + panic!("expected pipe binary inside ErrorProp"); + }; + assert_eq!(op, "|>"); + assert!(path_named(left, "input")); + assert!(path_named(right, "parse")); + } + + #[test] + fn pipe_chain_question_on_each_stage() { + // `a |> f? |> g?` nests as ErrorProp(Binary(ErrorProp(Binary(a, f)), g)). + let value = let_value("a |> f? |> g?"); + let ExprKind::ErrorProp(outer) = &value.kind else { + panic!("expected outer ErrorProp"); + }; + let ExprKind::Binary { op, left, right } = &outer.kind else { + panic!("expected outer pipe binary"); + }; + assert_eq!(op, "|>"); + assert!(path_named(right, "g")); + let ExprKind::ErrorProp(mid) = &left.kind else { + panic!("expected inner ErrorProp"); + }; + let ExprKind::Binary { op, left, right } = &mid.kind else { + panic!("expected inner pipe binary"); + }; + assert_eq!(op, "|>"); + assert!(path_named(left, "a")); + assert!(path_named(right, "f")); + } + + #[test] + fn pipe_stage_with_args_keeps_question_on_pipe() { + let value = let_value("x |> f(a)?"); + let ExprKind::ErrorProp(inner) = &value.kind else { + panic!("expected ErrorProp at the top"); + }; + let ExprKind::Binary { op, right, .. } = &inner.kind else { + panic!("expected pipe binary"); + }; + assert_eq!(op, "|>"); + let ExprKind::Call { callee, args, .. } = &right.kind else { + panic!("expected call stage"); + }; + assert!(path_named(callee, "f")); + assert_eq!(args.len(), 1); + } + + #[test] + fn pipe_stage_method_chain_is_stage_callee() { + // The field chain belongs to the stage, not to the pipe expression. + let value = let_value("x |> svc.parse?"); + let ExprKind::ErrorProp(inner) = &value.kind else { + panic!("expected ErrorProp at the top"); + }; + let ExprKind::Binary { op, right, .. } = &inner.kind else { + panic!("expected pipe binary"); + }; + assert_eq!(op, "|>"); + let ExprKind::Field { base, name } = &right.kind else { + panic!("expected field-chain stage"); + }; + assert_eq!(name, "parse"); + assert!(path_named(base, "svc")); + } + + #[test] + fn pipe_stage_turbofish_without_parens() { + // `x |> parse::` desugars like `x |> parse::()`. + let value = let_value("x |> parse::?"); + let ExprKind::ErrorProp(inner) = &value.kind else { + panic!("expected ErrorProp at the top"); + }; + let ExprKind::Binary { right, .. } = &inner.kind else { + panic!("expected pipe binary"); + }; + let ExprKind::Call { + callee, + type_args, + args, + } = &right.kind + else { + panic!("expected zero-arg call stage"); + }; + assert!(path_named(callee, "parse")); + assert_eq!(type_args.len(), 1); + assert!(args.is_empty()); + } + + #[test] + fn pipe_stage_is_callee_not_arithmetic() { + // A stage is only a callee: `x |> a + b` is `(x |> a) + b`. + let value = let_value("x |> a + b"); + let ExprKind::Binary { op, left, right } = &value.kind else { + panic!("expected `+` at the top"); + }; + assert_eq!(op, "+"); + assert!(path_named(right, "b")); + let ExprKind::Binary { op, left, right } = &left.kind else { + panic!("expected pipe binary on the left"); + }; + assert_eq!(op, "|>"); + assert!(path_named(left, "x")); + assert!(path_named(right, "a")); + } + + #[test] + fn pipe_rejects_non_callee_stage() { + let errors = parse_program("fn f() { let r = x |> 42; }").unwrap_err(); + assert!(errors.iter().any(|err| err.message.contains("pipe stage"))); + } + + #[test] + fn pipe_rejects_closure_stage_for_now() { + let errors = parse_program("fn f() { let r = x |> (v); }").unwrap_err(); + assert!( + errors + .iter() + .any(|err| err.message.contains("not yet implemented")) + ); + } + + #[test] + fn question_outside_pipe_unchanged() { + let value = let_value("fetch()?"); + let ExprKind::ErrorProp(inner) = &value.kind else { + panic!("expected ErrorProp"); + }; + assert!(matches!(&inner.kind, ExprKind::Call { .. })); + } + + #[test] + fn impl_trait_for_type_records_trait_ref() { + let program = parse_program("impl Display for User {}").unwrap(); + let ItemKind::Impl(imp) = &program.items[0].kind else { + panic!("expected impl"); + }; + let trait_ref = imp.trait_ref.as_ref().expect("trait ref recorded"); + assert_eq!(trait_ref.path.segments, ["Display"]); + assert!(trait_ref.args.is_empty()); + assert!(matches!( + &imp.target, + Type::Path { path, .. } if path.segments == ["User"] + )); + } + + #[test] + fn inherent_impl_has_no_trait_ref() { + let program = parse_program("impl User {}").unwrap(); + let ItemKind::Impl(imp) = &program.items[0].kind else { + panic!("expected impl"); + }; + assert!(imp.trait_ref.is_none()); + } + + #[test] + fn impl_generic_trait_with_where_clause_parses() { + let source = "impl Convert for Wrapper where T: Clone {}"; + let program = parse_program(source).unwrap(); + let ItemKind::Impl(imp) = &program.items[0].kind else { + panic!("expected impl"); + }; + let trait_ref = imp.trait_ref.as_ref().expect("trait ref recorded"); + assert_eq!(trait_ref.path.segments, ["Convert"]); + assert_eq!(trait_ref.args.len(), 1); + } + + #[test] + fn impl_rejects_non_path_trait_before_for() { + let errors = parse_program("impl &Foo for Bar {}").unwrap_err(); + assert!(errors.iter().any(|err| err.message.contains("trait path"))); + } + + #[test] + fn trait_with_generics_supertraits_and_where_parses() { + let source = r#" + trait Convert {} + trait Loggable: Printable {} + trait Bounded: Convert + Printable + 'static where Self: Printable {} + "#; + let program = parse_program(source).unwrap(); + let names: Vec<_> = program + .items + .iter() + .map(|item| { + let ItemKind::Trait(tr) = &item.kind else { + panic!("expected trait"); + }; + tr.name.name.as_str() + }) + .collect(); + assert_eq!(names, ["Convert", "Loggable", "Bounded"]); + } + + #[test] + fn dyn_trait_type_args_with_assoc_binding() { + let program = parse_program("fn f(it: &dyn Iter) {}").unwrap(); + let ItemKind::Function(func) = &program.items[0].kind else { + panic!("expected function"); + }; + let Param::Named { ty, .. } = &func.params[0] else { + panic!("expected named param"); + }; + let Type::Reference { inner, .. } = ty else { + panic!("expected reference type"); + }; + let Type::Dyn(trait_ref) = inner.as_ref() else { + panic!("expected dyn type"); + }; + assert_eq!(trait_ref.path.segments, ["Iter"]); + assert!(matches!(&trait_ref.args[0], TypeArg::Assoc { name, .. } if name.name == "Item")); + } } diff --git a/crates/sploosh-parser/tests/corpus.rs b/crates/sploosh-parser/tests/corpus.rs index f40dc58..3b04d3f 100644 --- a/crates/sploosh-parser/tests/corpus.rs +++ b/crates/sploosh-parser/tests/corpus.rs @@ -6,6 +6,8 @@ fn parses_corpus_files() { "tests/corpus/basic.sp", "tests/corpus/actor.sp", "tests/corpus/control_flow.sp", + "tests/corpus/traits_impls.sp", + "tests/corpus/pipes.sp", ] { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") diff --git a/tests/corpus/pipes.sp b/tests/corpus/pipes.sp new file mode 100644 index 0000000..d39ce22 --- /dev/null +++ b/tests/corpus/pipes.sp @@ -0,0 +1,12 @@ +/// Pipe stages per §16 pipe_stage: bare callee, call args, method chains, +/// turbofish, and the stage-trailing `?` that wraps the accumulated pipe +/// application (§5.7 — `x |> f?` is `(x |> f)?`). +fn pipeline(input: String, checker: Checker) -> Result { + let trimmed = input |> trim; + let n = trimmed |> parse::?; + let m = n |> add(5) |> add(20); + let v = m |> checker.validate(3)?; + let chained = v |> step_one? |> step_two?; + let arith = (chained |> double) + offset(1); + arith |> finish +} diff --git a/tests/corpus/traits_impls.sp b/tests/corpus/traits_impls.sp new file mode 100644 index 0000000..d3c8be8 --- /dev/null +++ b/tests/corpus/traits_impls.sp @@ -0,0 +1,52 @@ +/// Trait and impl shapes: generics, supertraits, where clauses, trait impls, +/// and `dyn` trait references with type arguments (§16 trait_def, impl_block, +/// trait_ref). +trait Printable { + fn print(&self); +} + +trait Convert { + fn convert(&self) -> T; +} + +trait Loggable: Printable { + fn log(&self); +} + +trait Bounded: Convert + Printable where Self: Printable { + fn cap(&self) -> i64; +} + +struct User { + id: u64, +} + +struct Wrapper { + value: T, +} + +impl User { + fn id(&self) -> u64 { + self.id + } +} + +impl Printable for User { + fn print(&self) {} +} + +impl Convert for Wrapper where T: Clone { + fn convert(&self) -> T { + self.value + } +} + +impl Loggable for User where User: Printable { + fn log(&self) {} +} + +struct Holder { + it: Box>, +} + +fn takes(c: &dyn Convert) {} From e70552c2265c6ee90d6d7984a45732984d3a623e Mon Sep 17 00:00:00 2001 From: RevenantPulse <68097584+StreamDemon@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:49:22 +0800 Subject: [PATCH 3/5] Enforce send operand, assignment target, and vec! bracket rules (#72) Send statements now require a method-call operand behind the section 2.7 statement-head rule, assignment left sides are validated against section 16 assign_target, and a missing bracket after vec! is a parse error instead of a silent fallback. Includes 7 unit tests and the send_assign.sp corpus fixture. --- crates/AGENTS.md | 10 +- crates/sploosh-parser/src/lib.rs | 181 +++++++++++++++++++++++++- crates/sploosh-parser/tests/corpus.rs | 1 + tests/corpus/send_assign.sp | 19 +++ 4 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 tests/corpus/send_assign.sp diff --git a/crates/AGENTS.md b/crates/AGENTS.md index 96e5232..85a828b 100644 --- a/crates/AGENTS.md +++ b/crates/AGENTS.md @@ -28,11 +28,15 @@ the accepted list as **not yet implemented** — add a corpus fixture when it la `onchain mod`, `extern`); types incl. generics, references, arrays/slices, tuples, `fn`, and `dyn Trait` incl. associated-type bindings; expressions incl. calls, turbofish, field/index, - unary/binary (incl. `/`), `?`, `.await`, `as`, struct literals (with the - §5.1 block-head restriction), `vec!`, and `if`/`else`; `|>` with §16 + unary/binary (incl. `/`), assignment (targets validated per §16 + `assign_target`), `?`, `.await`, `as`, struct literals (with the + §5.1 block-head restriction), `vec!` (square brackets required — + `vec!(...)` is a parse error), and `if`/`else`; `|>` with §16 `pipe_stage` stages (`callee [args] [?]` — a stage's trailing `?` wraps the accumulated pipe application per §5.7); `let`, `return`, - `break`, `continue`, `send`, and expression/tail statements. + `break`, `continue`, `send` (statement-head rule per §2.7 — opens a + send-statement only when the next token can begin an expression, and the + operand must be a method call), and expression/tail statements. - **Not yet implemented:** `match`, `while`, `for`, `loop`, `if let`, and closures (their keywords lex but have no parse production); `spawn`, `select`, and `emit` (reserved keywords with no expression/statement production yet, so diff --git a/crates/sploosh-parser/src/lib.rs b/crates/sploosh-parser/src/lib.rs index 7f5dcc7..efb1b2a 100644 --- a/crates/sploosh-parser/src/lib.rs +++ b/crates/sploosh-parser/src/lib.rs @@ -577,8 +577,23 @@ impl Parser { } else if self.eat_keyword(Keyword::Continue).is_some() { self.expect(TokenKind::Semi)?; statements.push(Stmt::Continue); - } else if self.eat_ident_text("send").is_some() { + } else if self.at_ident_text("send") + && self + .peek_kind_at(1) + .is_some_and(|kind| can_begin_expr(&kind)) + { + // §2.7: `send` at statement head followed by any token that can + // begin an expression always opens a send-statement; the operand + // must be a method call (`handle.method(args)`, §8.2). Before any + // other token, `send` stays an ordinary identifier. + self.bump(); let expr = self.expr(0)?; + if !is_method_call(&expr) { + self.error_at( + expr.span, + "`send` operand must be a method call on a handle", + ); + } self.expect(TokenKind::Semi)?; statements.push(Stmt::Expr(expr)); } else { @@ -719,6 +734,10 @@ impl Parser { let rhs = self.expr(right_bp)?; let span = lhs.span.join(rhs.span); lhs = if op == "=" { + // §16: only an `assign_target` may appear on the left side. + if !is_assign_target(&lhs) { + self.error_at(lhs.span, "invalid assignment target"); + } Expr { kind: ExprKind::Assign { target: Box::new(lhs), @@ -790,7 +809,14 @@ impl Parser { } TokenKind::Ident if token.lexeme == "vec" => { self.bump(); - if self.eat(TokenKind::Bang).is_some() && self.eat(TokenKind::LBracket).is_some() { + if self.eat(TokenKind::Bang).is_some() { + // §16 `vec_literal`: `vec` "!" only ever binds to square + // brackets — `vec!(...)` / `vec!{...}` are parse errors, not + // a silent fallback to a plain `vec` path. + if self.eat(TokenKind::LBracket).is_none() { + self.error_here("expected `[` after `vec!`"); + return None; + } if self.eat(TokenKind::RBracket).is_some() { return Some(Expr { kind: ExprKind::VecLiteral(Vec::new()), @@ -1210,11 +1236,13 @@ impl Parser { }) } - fn eat_ident_text(&mut self, text: &str) -> Option { - if self - .peek() + fn at_ident_text(&self, text: &str) -> bool { + self.peek() .is_some_and(|token| token.kind == TokenKind::Ident && token.lexeme == text) - { + } + + fn eat_ident_text(&mut self, text: &str) -> Option { + if self.at_ident_text(text) { Some(self.bump()) } else { None @@ -1289,6 +1317,10 @@ impl Parser { let span = self .peek() .map_or_else(|| self.prev_span(), |token| token.span); + self.error_at(span, message); + } + + fn error_at(&mut self, span: Span, message: impl Into) { self.errors.push(ParseError { message: message.into(), span, @@ -1296,6 +1328,54 @@ impl Parser { } } +/// The §2.7 send-statement operand shape: `handle.method(args)` — a call whose +/// callee is a field access. +fn is_method_call(expr: &Expr) -> bool { + matches!( + &expr.kind, + ExprKind::Call { callee, .. } if matches!(callee.kind, ExprKind::Field { .. }) + ) +} + +/// §16: `assign_target = IDENT | "self" "." IDENT | expr "." IDENT | "*" expr +/// | expr "[" expr "]"`. `self`/`Self` alone and multi-segment paths are not +/// assignable. +fn is_assign_target(expr: &Expr) -> bool { + match &expr.kind { + ExprKind::Path(path) => { + path.segments.len() == 1 && path.segments[0] != "self" && path.segments[0] != "Self" + } + ExprKind::Field { .. } | ExprKind::Index { .. } => true, + ExprKind::Unary { op, .. } => op == "*", + _ => false, + } +} + +/// Tokens that can begin an expression — must stay in sync with `prefix()`. +fn can_begin_expr(kind: &TokenKind) -> bool { + matches!( + kind, + TokenKind::IntLit + | TokenKind::FloatLit + | TokenKind::StringLit + | TokenKind::CharLit + | TokenKind::Ident + | TokenKind::Keyword( + Keyword::True + | Keyword::False + | Keyword::SelfValue + | Keyword::SelfType + | Keyword::If + ) + | TokenKind::Bang + | TokenKind::Minus + | TokenKind::Star + | TokenKind::Amp + | TokenKind::LParen + | TokenKind::LBrace + ) +} + fn is_primitive_type(text: &str) -> bool { matches!( text, @@ -1460,6 +1540,95 @@ mod tests { assert_eq!(fields.len(), 1); } + #[test] + fn send_operand_must_be_method_call() { + // §2.7: inside a send-statement, anything but `handle.method(args)` is + // a parse error. + for source in [ + "fn f() { send 42; }", + "fn f() { send free_fn(x); }", + "fn f() { send (x); }", + "fn f() { send vec![1]; }", + ] { + let errors = parse_program(source).unwrap_err(); + assert!( + errors.iter().any(|err| err.message.contains("method call")), + "{source}: expected a method-call error, got {errors:?}" + ); + } + } + + #[test] + fn send_method_call_operands_accepted() { + let source = r#" + fn f(worker: Handle) { + send worker.run(1); + send hub.pool.run(2); + send self.notify(3); + send worker.push::(4); + } + "#; + assert!(parse_program(source).is_ok()); + } + + #[test] + fn send_head_before_non_expression_token_is_identifier() { + // §2.7: the send-statement opens only when the next token can begin an + // expression; otherwise `send` is an ordinary identifier. + assert!(parse_program("fn f() { send = x; }").is_ok()); + assert!(parse_program("fn f() { send.reset(); }").is_ok()); + } + + #[test] + fn assignment_targets_follow_grammar() { + let source = r#" + fn f(p: Point, arr: Vec, ptr: &mut i64) { + x = 1; + p.f = 2; + arr[0] = 3; + *ptr = 4; + self.state = 5; + a = b = c; + } + "#; + assert!(parse_program(source).is_ok()); + } + + #[test] + fn assignment_rejects_invalid_targets() { + for source in [ + "fn f() { 1 + 2 = 3; }", + "fn f() { g() = 1; }", + "fn f() { a::b = 1; }", + "fn f() { x? = 1; }", + "fn f() { self = x; }", + ] { + let errors = parse_program(source).unwrap_err(); + assert!( + errors + .iter() + .any(|err| err.message.contains("assignment target")), + "{source}: expected an assignment-target error, got {errors:?}" + ); + } + } + + #[test] + fn vec_bang_requires_square_brackets() { + for source in ["fn f() { let v = vec!(1); }", "fn f() { let v = vec!{1}; }"] { + let errors = parse_program(source).unwrap_err(); + assert!( + errors.iter().any(|err| err.message.contains("vec!")), + "{source}: expected a vec! error, got {errors:?}" + ); + } + } + + #[test] + fn vec_without_bang_is_plain_identifier() { + assert!(parse_program("fn f() { let v = vec; }").is_ok()); + } + /// Parses `fn f() { let r = ; }` and returns the bound expression. fn let_value(expr_src: &str) -> Expr { let source = format!("fn f() {{ let r = {expr_src}; }}"); diff --git a/crates/sploosh-parser/tests/corpus.rs b/crates/sploosh-parser/tests/corpus.rs index 3b04d3f..29106ff 100644 --- a/crates/sploosh-parser/tests/corpus.rs +++ b/crates/sploosh-parser/tests/corpus.rs @@ -8,6 +8,7 @@ fn parses_corpus_files() { "tests/corpus/control_flow.sp", "tests/corpus/traits_impls.sp", "tests/corpus/pipes.sp", + "tests/corpus/send_assign.sp", ] { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") diff --git a/tests/corpus/send_assign.sp b/tests/corpus/send_assign.sp new file mode 100644 index 0000000..1cd419d --- /dev/null +++ b/tests/corpus/send_assign.sp @@ -0,0 +1,19 @@ +/// Send statements (§2.7 statement-head rule, §16 send_stmt) and every +/// assignment-target shape (§16 assign_target). +actor Worker { + jobs: i64, + + pub fn run(&mut self, n: i64) { + self.jobs = self.jobs + n; + } +} + +fn dispatch(worker: Handle, hub: Hub, ptr: &mut i64) { + send worker.run(1); + send hub.pool.run(2); + let mut values = vec![0; 4]; + let mut count = 0; + count = count + 1; + values[0] = count; + *ptr = count; +} From a01ca50aa56922ad5530817afafbeb05f3870e1a Mon Sep 17 00:00:00 2001 From: RevenantPulse <68097584+StreamDemon@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:51:02 +0800 Subject: [PATCH 4/5] Enforce lexical literal constraints from grammar.md (#73) Empty char literals, bare 0x/0o/0b prefixes, integer suffixes on float literals, float suffixes on based literals, and separators touching non-digits of the literal's base are now lex errors, matching the documented section 16.1 constraints. Literal-overflow checking is documented as deferred to semantic analysis. Includes 5 lexer unit tests. --- crates/AGENTS.md | 4 +- crates/sploosh-lexer/src/lib.rs | 101 +++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/crates/AGENTS.md b/crates/AGENTS.md index 85a828b..ff4375e 100644 --- a/crates/AGENTS.md +++ b/crates/AGENTS.md @@ -47,7 +47,9 @@ the accepted list as **not yet implemented** — add a corpus fixture when it la `storage { }` blocks inside `onchain mod` are consumed but discarded, not stored in the AST; closure pipe stages (`x |> (|v| ...)`) and turbofish on a non-final pipe-stage segment are rejected with explicit - not-yet-implemented parse errors; generic parameters and + not-yet-implemented parse errors; literal-overflow checking (a parse-time + error per `docs/reference/grammar.md`) is deferred to semantic analysis, + where literals gain types; generic parameters and `trait`/`impl` bodies are skipped, not stored in the AST; `let mut` / `&mut` mutability and the `send` keyword are parsed but not preserved; a block-like expression (`if`/block) used as a non-tail statement needs a trailing `;`. diff --git a/crates/sploosh-lexer/src/lib.rs b/crates/sploosh-lexer/src/lib.rs index fe78edb..e21e234 100644 --- a/crates/sploosh-lexer/src/lib.rs +++ b/crates/sploosh-lexer/src/lib.rs @@ -276,7 +276,20 @@ impl Lexer<'_> { self.pos += 2; } + let digits_start = self.pos; self.digits(base); + if base != 10 && self.pos == digits_start { + let name = match base { + 16 => "hexadecimal", + 8 => "octal", + _ => "binary", + }; + self.error( + format!("{name} literal needs at least one digit"), + start, + self.pos, + ); + } let mut kind = TokenKind::IntLit; if base == 10 && self.peek() == Some('.') && self.peek_next() != Some('.') { kind = TokenKind::FloatLit; @@ -300,13 +313,23 @@ impl Lexer<'_> { } } if let Some(suffix) = self.numeric_suffix() { - kind = if suffix.starts_with('f') { - TokenKind::FloatLit - } else { - kind - }; + if suffix.starts_with('f') { + // §16.1 FLOAT_LIT: a float suffix only combines with `dec_lit`. + if base != 10 { + self.error("float suffixes require a decimal literal", start, self.pos); + } + kind = TokenKind::FloatLit; + } else if kind == TokenKind::FloatLit { + // §16.1: float_suffix = "f32" | "f64" — integer suffixes on a + // literal with a `.` or exponent are a compile error. + self.error( + "float literals only accept `f32`/`f64` suffixes", + start, + self.pos, + ); + } } - self.validate_numeric_body(start); + self.validate_numeric_body(start, base); self.push(kind, start, self.pos); } @@ -331,7 +354,7 @@ impl Lexer<'_> { None } - fn validate_numeric_body(&mut self, start: usize) { + fn validate_numeric_body(&mut self, start: usize, base: u8) { let text = &self.source[start..self.pos]; let suffixes = [ "i128", "u128", "u256", "i64", "u64", "f64", "i32", "u32", "f32", "i16", "u16", "i8", @@ -346,10 +369,13 @@ impl Lexer<'_> { if *ch != '_' { continue; } - let valid_prev = index > 0 && chars[index - 1].is_ascii_hexdigit(); + // Neighbors must be digits *of this base* — `is_ascii_hexdigit` + // would wrongly accept `1_e5` in a decimal literal because `e` is + // a hex digit character. + let valid_prev = index > 0 && valid_digit(chars[index - 1], base); let valid_next = chars .get(index + 1) - .is_some_and(|next| next.is_ascii_hexdigit()); + .is_some_and(|next| valid_digit(*next, base)); if !valid_prev || !valid_next { self.error( "numeric separators must appear between digits", @@ -404,6 +430,7 @@ impl Lexer<'_> { self.push(TokenKind::CharLit, start, self.pos); return; } + let mut has_scalar = true; if self.peek() == Some('\\') { self.escape(start); } else if self @@ -411,9 +438,15 @@ impl Lexer<'_> { .is_some_and(|c| c != '\'' && c != '\n' && c != '\r') { self.bump(); + } else { + has_scalar = false; } if self.peek() == Some('\'') { self.bump(); + if !has_scalar { + // §16.1 CHAR_LIT: exactly one Unicode scalar value. + self.error("empty character literal", start, self.pos); + } self.push(TokenKind::CharLit, start, self.pos); } else { self.error("unterminated character literal", start, self.pos); @@ -609,4 +642,54 @@ mod tests { assert!(lex(r#""\u{D800}""#).is_err()); assert!(lex(r#""\u{110000}""#).is_err()); } + + #[test] + fn rejects_empty_char_literal() { + let err = lex("''").unwrap_err(); + assert!(err[0].message.contains("empty character literal")); + assert!(lex("' '").is_ok()); + assert!(lex(r"'\''").is_ok()); + } + + #[test] + fn rejects_bare_base_prefixes() { + for (source, name) in [("0x", "hexadecimal"), ("0o", "octal"), ("0b", "binary")] { + let err = lex(source).unwrap_err(); + assert!(err[0].message.contains(name), "{source}: {err:?}"); + } + // A prefix followed only by separators trips the separator rule instead. + assert!(lex("0x_").is_err()); + assert!(lex("0xFF").is_ok()); + } + + #[test] + fn rejects_int_suffix_on_float_literals() { + let err = lex("3.14u32").unwrap_err(); + assert!(err[0].message.contains("f32")); + assert!(lex("1e10u8").is_err()); + assert!(lex("3.14f32").is_ok()); + assert!(lex("1e10f64").is_ok()); + } + + #[test] + fn rejects_float_suffix_on_based_literals() { + let err = lex("0b1f32").unwrap_err(); + assert!(err[0].message.contains("decimal")); + assert!(lex("0o7f64").is_err()); + // Hex swallows `f` as a digit, so `0xFf32` stays a hex int literal. + let tokens = lex("0xFf32").unwrap(); + assert_eq!(tokens[0].kind, TokenKind::IntLit); + } + + #[test] + fn separator_validation_is_base_aware() { + // `e` is a hex digit character but not a decimal digit: a separator + // touching the exponent marker must be rejected. + assert!(lex("1_e5").is_err()); + assert!(lex("1e_5").is_err()); + assert!(lex("1.5_e3").is_err()); + assert!(lex("1_000.5e10").is_ok()); + assert!(lex("0xF_F").is_ok()); + assert!(lex("0xd_e").is_ok()); + } } From 42472684f9edae0b6a7972cb8f4a70b6ed5bad17 Mon Sep 17 00:00:00 2001 From: RevenantPulse <68097584+StreamDemon@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:55:26 +0800 Subject: [PATCH 5/5] Enforce range non-associativity and item-modifier placement (#74) Chained ranges (a..b..c) are now a parse error per the precedence table's None associativity, with the diagnostic anchored to the second operator. Item modifiers are validated per section 16: offchain/async only on fn items, pub rejected on impl, actor, onchain, and extern items, with errors on the modifier's own span. Includes 4 unit tests with span assertions and the ranges_modifiers.sp corpus fixture. --- crates/AGENTS.md | 11 ++- crates/sploosh-parser/src/lib.rs | 132 +++++++++++++++++++++++++- crates/sploosh-parser/tests/corpus.rs | 1 + tests/corpus/ranges_modifiers.sp | 22 +++++ 4 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 tests/corpus/ranges_modifiers.sp diff --git a/crates/AGENTS.md b/crates/AGENTS.md index ff4375e..481539e 100644 --- a/crates/AGENTS.md +++ b/crates/AGENTS.md @@ -25,10 +25,13 @@ the accepted list as **not yet implemented** — add a corpus fixture when it la variants, `actor`, `mod`, `use`, `const`, `type`, `trait` incl. generics, supertrait bounds, and `where` clauses (bounds parsed but not stored), `impl Type` and `impl Trait for Type` (trait ref recorded in the AST), - `onchain mod`, `extern`); types incl. generics, references, arrays/slices, - tuples, `fn`, and `dyn Trait` incl. associated-type bindings; - expressions incl. calls, turbofish, field/index, - unary/binary (incl. `/`), assignment (targets validated per §16 + `onchain mod`, `extern`), with §16 modifier placement enforced — + `offchain`/`async` only on `fn` items, and `pub` rejected on `impl`, + `actor`, `onchain`, and `extern` items; types incl. generics, references, + arrays/slices, tuples, `fn`, and `dyn Trait` incl. associated-type + bindings; expressions incl. calls, turbofish, field/index, + unary/binary (incl. `/`; `..`/`..=` are non-associative — chained ranges + are a parse error), assignment (targets validated per §16 `assign_target`), `?`, `.await`, `as`, struct literals (with the §5.1 block-head restriction), `vec!` (square brackets required — `vec!(...)` is a parse error), and `if`/`else`; `|>` with §16 diff --git a/crates/sploosh-parser/src/lib.rs b/crates/sploosh-parser/src/lib.rs index efb1b2a..3c3b9ca 100644 --- a/crates/sploosh-parser/src/lib.rs +++ b/crates/sploosh-parser/src/lib.rs @@ -63,14 +63,42 @@ impl Parser { self.skip_doc_comments(); let start = self.peek()?.span.start; let attrs = self.attrs(); - let visibility = if self.eat_keyword(Keyword::Pub).is_some() { + let pub_token = self.eat_keyword(Keyword::Pub); + let offchain_token = self.eat_keyword(Keyword::Offchain); + let async_token = self.eat_keyword(Keyword::Async); + let visibility = if pub_token.is_some() { Visibility::Public } else { Visibility::Private }; - let is_offchain = self.eat_keyword(Keyword::Offchain).is_some(); - let is_async = self.eat_keyword(Keyword::Async).is_some(); - let kind = match self.peek_kind()? { + let is_offchain = offchain_token.is_some(); + let is_async = async_token.is_some(); + // §16: `offchain`/`async` prefix only `fn_def`; `pub` prefixes every + // item form except `impl_block`, `actor_def`, `onchain_mod`/`event_def`, + // and `extern_block`. + let next = self.peek_kind()?; + if !matches!(next, TokenKind::Keyword(Keyword::Fn)) { + if let Some(token) = &offchain_token { + self.error_at(token.span, "`offchain` applies only to `fn` items"); + } + if let Some(token) = &async_token { + self.error_at(token.span, "`async` applies only to `fn` items"); + } + } + if let Some(token) = &pub_token + && matches!( + next, + TokenKind::Keyword( + Keyword::Impl | Keyword::Actor | Keyword::Onchain | Keyword::Extern + ) + ) + { + self.error_at( + token.span, + "`pub` is not allowed on `impl`, `actor`, `onchain`, or `extern` items", + ); + } + let kind = match next { TokenKind::Keyword(Keyword::Fn) => ItemKind::Function(self.function_after_mods( visibility, is_async, @@ -755,6 +783,16 @@ impl Parser { span, } }; + // The precedence table marks `..`/`..=` non-associative: a range + // operand may not itself be an unparenthesized range. + if matches!(op, ".." | "..=") + && matches!( + self.peek_kind(), + Some(TokenKind::DotDot | TokenKind::DotDotEq) + ) + { + self.error_here("range operators cannot be chained; parenthesize one side"); + } } Some(lhs) } @@ -1540,6 +1578,26 @@ mod tests { assert_eq!(fields.len(), 1); } + #[test] + fn range_operators_cannot_chain() { + for source in [ + "fn f() { let r = a..b..c; }", + "fn f() { let r = a..=b..c; }", + "fn f() { let r = a..b..=c; }", + ] { + let errors = parse_program(source).unwrap_err(); + // The diagnostic must anchor to the second range operator + // (`..=` starts with `..`, so the second `..` match is its start). + let second_op = source.match_indices("..").nth(1).unwrap().0; + assert!( + errors + .iter() + .any(|err| err.message.contains("chained") && err.span.start == second_op), + "{source}: expected a range-chaining error at {second_op}, got {errors:?}" + ); + } + } + #[test] fn send_operand_must_be_method_call() { // §2.7: inside a send-statement, anything but `handle.method(args)` is @@ -1613,6 +1671,72 @@ mod tests { } } + #[test] + fn single_and_parenthesized_ranges_parse() { + assert!(parse_program("fn f() { let r = lo..hi; }").is_ok()); + assert!(parse_program("fn f() { let r = lo..=hi; }").is_ok()); + assert!(parse_program("fn f() { let r = (a..b)..c; }").is_ok()); + } + + #[test] + fn offchain_and_async_apply_only_to_fn_items() { + for (source, modifier) in [ + ("offchain struct S { x: i64 }", "offchain"), + ("async struct S { x: i64 }", "async"), + ("pub async struct S { x: i64 }", "async"), + ("async trait T {}", "async"), + ("offchain mod m;", "offchain"), + ] { + let errors = parse_program(source).unwrap_err(); + // The diagnostic must anchor to the misplaced modifier's own span. + let start = source.find(modifier).unwrap(); + let span = Span::new(start, start + modifier.len()); + assert!( + errors + .iter() + .any(|err| err.message.contains("applies only to `fn` items") + && err.span == span), + "{source}: expected a modifier error at {span:?}, got {errors:?}" + ); + } + assert!(parse_program("pub offchain async fn f() {}").is_ok()); + assert!(parse_program("offchain fn g() {}").is_ok()); + } + + #[test] + fn pub_rejected_where_grammar_omits_it() { + for source in [ + "pub impl User {}", + "pub actor A { state: i64, }", + "pub onchain mod token {}", + "pub extern \"C\" { fn f(); }", + ] { + let errors = parse_program(source).unwrap_err(); + // `pub` heads each source, so the diagnostic must anchor to 0..3. + assert!( + errors + .iter() + .any(|err| err.message.contains("`pub` is not allowed") + && err.span == Span::new(0, 3)), + "{source}: expected a pub-placement error at 0..3, got {errors:?}" + ); + } + for source in [ + "pub struct S { x: i64 }", + "pub enum E { A }", + "pub trait T {}", + "pub mod m;", + "pub use crate::api;", + "pub const X: i64 = 1;", + "pub type Y = i64;", + ] { + assert!( + parse_program(source).is_ok(), + "{source}: `pub` should be accepted here" + ); + } + } + #[test] fn vec_bang_requires_square_brackets() { for source in ["fn f() { let v = vec!(1); }", "fn f() { let v = vec!{1}; }"] { diff --git a/crates/sploosh-parser/tests/corpus.rs b/crates/sploosh-parser/tests/corpus.rs index 29106ff..78ecba6 100644 --- a/crates/sploosh-parser/tests/corpus.rs +++ b/crates/sploosh-parser/tests/corpus.rs @@ -9,6 +9,7 @@ fn parses_corpus_files() { "tests/corpus/traits_impls.sp", "tests/corpus/pipes.sp", "tests/corpus/send_assign.sp", + "tests/corpus/ranges_modifiers.sp", ] { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") diff --git a/tests/corpus/ranges_modifiers.sp b/tests/corpus/ranges_modifiers.sp new file mode 100644 index 0000000..7f1d940 --- /dev/null +++ b/tests/corpus/ranges_modifiers.sp @@ -0,0 +1,22 @@ +/// Range expressions (`..`/`..=`, non-associative per the precedence table) +/// and §16 item-modifier placement: `pub` on declarations, `offchain`/`async` +/// on functions only. +pub offchain async fn window(lo: i64, hi: i64) -> i64 { + let half_open = lo..hi; + let inclusive = lo..=hi; + lo + hi +} + +pub struct Config { + pub retries: i64, +} + +pub mod api; + +pub use crate::api; + +pub const LIMIT: i64 = 8; + +pub type Pair = (i64, i64); + +pub trait Sink {}