Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions crates/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Args>` 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<Args>` 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
Expand Down
132 changes: 128 additions & 4 deletions crates/sploosh-parser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}; }"] {
Expand Down
1 change: 1 addition & 0 deletions crates/sploosh-parser/tests/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("../..")
Expand Down
22 changes: 22 additions & 0 deletions tests/corpus/ranges_modifiers.sp
Original file line number Diff line number Diff line change
@@ -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 {}