diff --git a/crates/AGENTS.md b/crates/AGENTS.md index 877a5f9..481539e 100644 --- a/crates/AGENTS.md +++ b/crates/AGENTS.md @@ -22,14 +22,37 @@ 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`, - `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`, - `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 + 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`), 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 + `pipe_stage` stages (`callee [args] [?]` — a stage's trailing `?` wraps the + accumulated pipe application per §5.7); `let`, `return`, + `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 + §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; closure pipe stages (`x |> (|v| ...)`) and turbofish on a + non-final pipe-stage segment are rejected with explicit + 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-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-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()); + } } diff --git a/crates/sploosh-parser/src/lib.rs b/crates/sploosh-parser/src/lib.rs index 6d83a3f..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, @@ -370,16 +398,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 +551,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(); @@ -541,8 +605,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 { @@ -656,9 +735,37 @@ 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 == "=" { + // §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), @@ -676,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) } @@ -730,7 +847,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()), @@ -819,6 +943,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()?; @@ -1083,11 +1274,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 @@ -1162,6 +1355,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, @@ -1169,6 +1366,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, @@ -1332,4 +1577,416 @@ 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 + // 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 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}; }"] { + 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}; }}"); + 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..78ecba6 100644 --- a/crates/sploosh-parser/tests/corpus.rs +++ b/crates/sploosh-parser/tests/corpus.rs @@ -6,6 +6,10 @@ 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", + "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/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/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 {} 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; +} 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) {}