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 {}