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; +}