use crate::ast::*;
use crate::error::CompileError;
use crate::lexer::{
Token, TokenKind, comment_body, comment_is_legacy, doc_block_content, doc_fence_len,
doc_line_is_legacy, doc_line_text, has_blank_line_between, legacy_comment_error,
legacy_doc_line_error,
};
use crate::span::Span;
mod declarations;
mod expressions;
mod statements;
mod types;
#[derive(Debug, Default)]
struct TriviaTable {
leading: Vec<Vec<Comment>>,
trailing: Vec<Option<String>>,
epilogue: Vec<Comment>,
legacy: Vec<CompileError>,
}
impl TriviaTable {
fn take_leading(&mut self, index: usize) -> Vec<Comment> {
match self.leading.get_mut(index) {
Some(v) => std::mem::take(v),
None => Vec::new(),
}
}
fn take_trailing(&mut self, index: usize) -> Option<String> {
self.trailing.get_mut(index).and_then(|s| s.take())
}
fn take_epilogue(&mut self) -> Vec<Comment> {
std::mem::take(&mut self.epilogue)
}
fn is_fully_drained(&self) -> bool {
self.leading.iter().all(Vec::is_empty)
&& self.trailing.iter().all(Option::is_none)
&& self.epilogue.is_empty()
}
fn epilogue_is_empty(&self) -> bool {
self.epilogue.is_empty()
}
}
fn split_trivia(tokens: &[Token], source: &str) -> (Vec<Token>, TriviaTable) {
let mut filtered: Vec<Token> = Vec::with_capacity(tokens.len());
let mut table = TriviaTable::default();
let mut pending_leading: Vec<Comment> = Vec::new();
let mut last_content_end: Option<usize> = None;
let mut last_leading_end: Option<usize> = None;
for tok in tokens {
if !pending_leading.is_empty()
&& let Some(end) = last_leading_end
&& source[end..tok.span.start].matches('\n').count() >= 2
{
pending_leading.push(Comment::Blank);
}
if tok.kind == TokenKind::DocLine && doc_line_is_legacy(source, tok.span) {
table.legacy.push(legacy_doc_line_error(source, tok.span));
}
if tok.kind == TokenKind::Comment {
if comment_is_legacy(source, tok.span) {
table.legacy.push(legacy_comment_error(tok.span));
}
let body = comment_body(source, tok.span).into_owned();
if pending_leading.is_empty()
&& filtered.last().is_some_and(|t| {
!matches!(
t.kind,
TokenKind::DocBlock | TokenKind::DocLine | TokenKind::LBrace
)
})
&& let Some(prev_end) = last_content_end
&& !source[prev_end..tok.span.start].contains('\n')
{
let last_idx = filtered.len() - 1;
if table.trailing[last_idx].is_none() {
table.trailing[last_idx] = Some(body);
continue;
}
}
let own_line = !pending_leading.is_empty()
|| filtered
.last()
.is_none_or(|t| matches!(t.kind, TokenKind::DocBlock | TokenKind::DocLine))
|| last_content_end.is_some_and(|end| source[end..tok.span.start].contains('\n'));
pending_leading.push(Comment::Line(body));
last_leading_end = own_line.then_some(tok.span.end);
continue;
}
filtered.push(*tok);
table.leading.push(std::mem::take(&mut pending_leading));
table.trailing.push(None);
last_content_end = Some(tok.span.end);
last_leading_end = None;
}
table.epilogue = pending_leading;
(filtered, table)
}
pub fn parse(tokens: &[Token], source: &str) -> Result<Commons, Vec<CompileError>> {
parse_with_warnings(tokens, source).map(|(c, _warnings)| c)
}
pub fn parse_with_warnings(
tokens: &[Token],
source: &str,
) -> Result<(Commons, Vec<CompileError>), Vec<CompileError>> {
let (unit, warnings) = parse_unit_with_warnings(tokens, source)?;
match unit {
SourceUnit::Commons(c) => Ok((c, warnings)),
SourceUnit::Context(ctx) => Err(vec![
CompileError::new(
"bynk.parse.unexpected_context",
ctx.span,
"expected a `commons` declaration but found a `context` declaration",
)
.with_note(
"contexts must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
),
]),
SourceUnit::Suite(t) => Err(vec![
CompileError::new(
"bynk.parse.unexpected_suite",
t.span,
"expected a `commons` declaration but found a `suite` declaration",
)
.with_note(
"tests must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
),
]),
SourceUnit::Adapter(a) => Err(vec![
CompileError::new(
"bynk.parse.unexpected_adapter",
a.span,
"expected a `commons` declaration but found an `adapter` declaration",
)
.with_note(
"adapters must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
),
]),
}
}
pub fn parse_unit_with_recovery(
tokens: &[Token],
source: &str,
) -> (Option<SourceUnit>, Vec<CompileError>) {
let (units, errors) = parse_units_with_recovery(tokens, source);
(units.into_iter().next(), errors)
}
pub fn parse_units_with_recovery(
tokens: &[Token],
source: &str,
) -> (Vec<SourceUnit>, Vec<CompileError>) {
let recovered = parse_units_recovering(tokens, source);
(recovered.units, recovered.errors)
}
#[derive(Debug)]
pub struct Recovered {
pub units: Vec<SourceUnit>,
pub errors: Vec<CompileError>,
pub broken_decl_names: Vec<String>,
}
pub fn merge_syntax_errors(
strict: Vec<CompileError>,
recovered: Vec<CompileError>,
) -> Vec<CompileError> {
let mut out = strict;
for e in recovered {
if !out.iter().any(|o| o.span.start == e.span.start) {
out.push(e);
}
}
out
}
pub fn parse_units_recovering(tokens: &[Token], source: &str) -> Recovered {
parse_units_recovering_from(tokens, source, &mut 0)
}
pub fn parse_units_recovering_from(tokens: &[Token], source: &str, next_id: &mut u32) -> Recovered {
let (filtered, trivia) = split_trivia(tokens, source);
let mut warnings = Vec::new();
let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
p.recover_mode = true;
p.next_expr_id = *next_id;
let mut units = Vec::new();
loop {
match p.parse_unit() {
Ok(u) => units.push(u),
Err(e) => {
p.recovered_errors.push(e);
break;
}
}
if p.peek().is_none() {
break;
}
}
let broken_decl_names = std::mem::take(&mut p.broken_decl_names);
*next_id = p.next_expr_id;
let mut all_errors = p.recovered_errors;
all_errors.append(&mut p.doc_errors);
all_errors.append(&mut warnings);
Recovered {
units,
errors: all_errors,
broken_decl_names,
}
}
pub fn parse_unit(tokens: &[Token], source: &str) -> Result<SourceUnit, Vec<CompileError>> {
parse_unit_with_warnings(tokens, source).map(|(unit, _warnings)| unit)
}
pub fn parse_unit_with_warnings(
tokens: &[Token],
source: &str,
) -> Result<(SourceUnit, Vec<CompileError>), Vec<CompileError>> {
parse_unit_with_warnings_from(tokens, source, &mut 0)
}
pub fn parse_unit_with_warnings_from(
tokens: &[Token],
source: &str,
next_id: &mut u32,
) -> Result<(SourceUnit, Vec<CompileError>), Vec<CompileError>> {
let (filtered, trivia) = split_trivia(tokens, source);
let mut warnings = Vec::new();
let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
p.next_expr_id = *next_id;
let result = match p.parse_unit() {
Ok(u) => {
if let Some(extra) = p.peek() {
Err(vec![
CompileError::new(
"bynk.parse.extra_tokens",
extra.span,
"unexpected token after top-level declaration",
)
.with_note(
"a `.bynk` file contains exactly one `commons` or `context` declaration",
),
])
} else {
Ok(u)
}
}
Err(e) => Err(vec![e]),
};
let result = match (result, std::mem::take(&mut p.doc_errors)) {
(r, docs) if docs.is_empty() => r,
(Ok(_), docs) => Err(docs),
(Err(mut errs), mut docs) => {
errs.append(&mut docs);
Err(errs)
}
};
*next_id = p.next_expr_id;
match result {
Ok(u) => {
debug_assert!(
p.trivia.epilogue_is_empty(),
"a file-trailing comment was left undrained after a successful parse"
);
Ok((u, warnings))
}
Err(mut errs) => {
errs.append(&mut warnings);
Err(errs)
}
}
}
pub fn parse_units(tokens: &[Token], source: &str) -> Result<Vec<SourceUnit>, Vec<CompileError>> {
parse_units_with_warnings(tokens, source).map(|(units, _warnings)| units)
}
pub fn parse_units_with_warnings(
tokens: &[Token],
source: &str,
) -> Result<(Vec<SourceUnit>, Vec<CompileError>), Vec<CompileError>> {
parse_units_with_drain_check(tokens, source)
.map(|(units, warnings, _drained)| (units, warnings))
}
pub fn parse_units_with_warnings_from(
tokens: &[Token],
source: &str,
next_id: &mut u32,
) -> Result<(Vec<SourceUnit>, Vec<CompileError>), Vec<CompileError>> {
parse_units_with_drain_check_from(tokens, source, next_id)
.map(|(units, warnings, _drained)| (units, warnings))
}
pub fn parse_units_with_drain_check(
tokens: &[Token],
source: &str,
) -> Result<(Vec<SourceUnit>, Vec<CompileError>, bool), Vec<CompileError>> {
parse_units_with_drain_check_from(tokens, source, &mut 0)
}
pub fn parse_units_with_drain_check_from(
tokens: &[Token],
source: &str,
next_id: &mut u32,
) -> Result<(Vec<SourceUnit>, Vec<CompileError>, bool), Vec<CompileError>> {
parse_units_drain(tokens, source, next_id, false)
}
pub fn parse_units_migrating(
tokens: &[Token],
source: &str,
) -> Result<(Vec<SourceUnit>, Vec<CompileError>, bool), Vec<CompileError>> {
parse_units_drain(tokens, source, &mut 0, true)
}
fn parse_units_drain(
tokens: &[Token],
source: &str,
next_id: &mut u32,
migrate: bool,
) -> Result<(Vec<SourceUnit>, Vec<CompileError>, bool), Vec<CompileError>> {
let (filtered, trivia) = split_trivia(tokens, source);
let mut warnings = Vec::new();
let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
if migrate {
p.doc_errors.clear();
}
p.next_expr_id = *next_id;
let mut units = Vec::new();
let mut errors: Vec<CompileError> = Vec::new();
while p.peek().is_some() {
match p.parse_unit() {
Ok(u) => units.push(u),
Err(e) => {
errors.push(e);
break;
}
}
}
errors.append(&mut p.doc_errors);
*next_id = p.next_expr_id;
let eof = p.eof_span();
let fully_drained = p.trivia.is_fully_drained();
if !errors.is_empty() {
errors.append(&mut warnings);
return Err(errors);
}
if units.is_empty() {
return Err(vec![CompileError::new(
"bynk.parse.unexpected_eof",
eof,
"expected `commons`, `context`, or `suite` to start the file, found end of file",
)]);
}
debug_assert!(
p.trivia.epilogue_is_empty(),
"a file-trailing comment was left undrained after a successful parse"
);
Ok((units, warnings, fully_drained))
}
enum SignedNumLit {
Int(IntBound),
Float(FloatBound),
}
struct Parser<'a> {
tokens: &'a [Token],
source: &'a str,
pos: usize,
warnings: &'a mut Vec<CompileError>,
recover_mode: bool,
recovered_errors: Vec<CompileError>,
pub(crate) item_start: Option<usize>,
broken_decl_names: Vec<String>,
trivia: TriviaTable,
depth: usize,
no_record_literal: bool,
brace_depth: usize,
item_loop_baseline: Vec<usize>,
next_expr_id: u32,
doc_errors: Vec<CompileError>,
doc_probe: bool,
}
impl<'a> Parser<'a> {
fn new(
tokens: &'a [Token],
source: &'a str,
mut trivia: TriviaTable,
warnings: &'a mut Vec<CompileError>,
) -> Self {
let doc_errors = std::mem::take(&mut trivia.legacy);
Self {
tokens,
source,
pos: 0,
warnings,
recover_mode: false,
recovered_errors: Vec::new(),
item_start: None,
broken_decl_names: Vec::new(),
trivia,
depth: 0,
no_record_literal: false,
brace_depth: 0,
item_loop_baseline: Vec::new(),
next_expr_id: 0,
doc_errors,
doc_probe: false,
}
}
fn alloc_expr_id(&mut self) -> ExprId {
let id = ExprId(self.next_expr_id);
self.next_expr_id += 1;
id
}
fn enter_recursion(&mut self, what: &str) -> Result<(), CompileError> {
self.depth += 1;
if self.depth > crate::MAX_NESTING_DEPTH {
self.depth -= 1;
let span = self
.peek()
.map(|t| t.span)
.unwrap_or_else(|| self.eof_span());
return Err(self.nesting_too_deep(span, what));
}
Ok(())
}
fn nesting_too_deep(&self, span: Span, what: &str) -> CompileError {
CompileError::new(
"bynk.parse.nesting_too_deep",
span,
format!(
"{what} nests more than {} levels deep",
crate::MAX_NESTING_DEPTH
),
)
.with_note(
"deeply nested source is rejected to keep the parser from overflowing its \
stack and aborting; flatten or split the construct",
)
}
fn expression_too_long(&self, span: Span) -> CompileError {
CompileError::new(
"bynk.parse.nesting_too_deep",
span,
format!(
"this expression is more than {} levels deep",
crate::MAX_NESTING_DEPTH
),
)
.with_note(
"a long operator or member chain is rejected to keep the compiler from overflowing \
its stack; split it across `let` bindings, or reduce a sequence with \
`.sum()`/`.fold(...)`",
)
}
fn enter_chain_fold(&mut self, folds: &mut usize, span: Span) -> Result<(), CompileError> {
self.depth += 1;
*folds += 1;
if self.depth > crate::MAX_NESTING_DEPTH {
self.depth -= *folds;
*folds = 0;
return Err(self.expression_too_long(span));
}
Ok(())
}
fn deepen_spine(&mut self, span: Span) -> Result<(), CompileError> {
self.depth += 1;
if self.depth > crate::MAX_NESTING_DEPTH {
return Err(self.expression_too_long(span));
}
Ok(())
}
fn take_leading_trivia(&mut self) -> Vec<Comment> {
let mut comments = self.take_unit_leading_trivia();
comments.retain(|c| *c != Comment::Blank);
comments
}
fn take_unit_leading_trivia(&mut self) -> Vec<Comment> {
self.trivia.take_leading(self.pos)
}
fn take_epilogue_trivia(&mut self) -> Vec<Comment> {
self.trivia.take_epilogue()
}
fn take_trailing_trivia(&mut self) -> Option<String> {
if self.pos == 0 {
return None;
}
self.trivia.take_trailing(self.pos - 1)
}
fn handle_item_err(&mut self, e: CompileError) -> Result<(), CompileError> {
if self.recover_mode {
self.recovered_errors.push(e);
if let Some(start) = self.item_start.take() {
self.broken_decl_names.extend(self.decl_names_at(start));
}
let before = self.pos;
self.recover_to_top_item();
let baseline = self.item_loop_baseline.last().copied().unwrap_or(0);
let closes_body = baseline > 0
&& self.brace_depth == baseline
&& self.peek_kind() == Some(TokenKind::RBrace);
if self.pos == before && !closes_body {
self.bump();
self.recover_to_top_item();
}
Ok(())
} else {
Err(e)
}
}
fn decl_names_at(&self, start: usize) -> Vec<String> {
let kind_at = |i: usize| self.tokens.get(i).map(|t| t.kind);
let ident_at = |i: usize| {
self.tokens
.get(i)
.filter(|t| t.kind == TokenKind::Ident)
.map(|t| self.slice(t.span).to_string())
};
match kind_at(start) {
Some(
TokenKind::Type
| TokenKind::Event
| TokenKind::Capability
| TokenKind::Service
| TokenKind::Agent
| TokenKind::Actor,
) => ident_at(start + 1).into_iter().collect(),
Some(TokenKind::Fn) => match (ident_at(start + 1), kind_at(start + 2)) {
(Some(owner), Some(TokenKind::Dot)) => match ident_at(start + 3) {
Some(method) => vec![format!("{owner}.{method}")],
None => Vec::new(),
},
(Some(name), _) => vec![name],
(None, _) => Vec::new(),
},
_ => Vec::new(),
}
}
fn recover_to_top_item(&mut self) {
let baseline = self.item_loop_baseline.last().copied().unwrap_or(0);
while let Some(t) = self.peek() {
match t.kind {
TokenKind::RBrace if self.brace_depth == baseline => return,
_ if self.brace_depth == baseline && is_item_start(t.kind) => return,
_ => {
self.bump();
}
}
}
}
fn enter_item_loop(&mut self) {
self.item_loop_baseline.push(self.brace_depth);
}
fn exit_item_loop(&mut self) {
self.item_loop_baseline.pop();
}
fn peek(&self) -> Option<Token> {
self.tokens.get(self.pos).copied()
}
fn peek_kind(&self) -> Option<TokenKind> {
self.peek().map(|t| t.kind)
}
fn nth(&self, n: usize) -> Option<Token> {
self.tokens.get(self.pos + n).copied()
}
fn nth_kind(&self, n: usize) -> Option<TokenKind> {
self.nth(n).map(|t| t.kind)
}
fn nth_text(&self, n: usize) -> &'a str {
self.nth(n).map(|t| self.slice(t.span)).unwrap_or("")
}
fn prev_span(&self) -> Span {
self.tokens
.get(self.pos.wrapping_sub(1))
.or_else(|| self.peek_ref())
.map(|t| t.span)
.unwrap_or_default()
}
fn peek_ref(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn bump(&mut self) -> Option<Token> {
let t = self.peek();
if let Some(t) = t {
match t.kind {
TokenKind::LBrace => self.brace_depth += 1,
TokenKind::RBrace => self.brace_depth = self.brace_depth.saturating_sub(1),
_ => {}
}
self.pos += 1;
}
t
}
fn eat(&mut self, kind: TokenKind) -> Option<Token> {
if self.peek_kind() == Some(kind) {
self.bump()
} else {
None
}
}
fn slice(&self, span: Span) -> &'a str {
&self.source[span.range()]
}
fn next_token_on_new_line(&self, prev: Span) -> bool {
match self.peek() {
Some(t) if prev.end <= t.span.start => {
self.source[prev.end..t.span.start].contains('\n')
}
_ => false,
}
}
fn eof_span(&self) -> Span {
let end = self.source.len();
let start = (0..end)
.rev()
.find(|&i| self.source.is_char_boundary(i))
.unwrap_or(0);
Span::new(start, end)
}
fn expect(&mut self, kind: TokenKind, ctx: &str) -> Result<Token, CompileError> {
match self.peek() {
Some(t) if t.kind == kind => {
self.bump();
Ok(t)
}
Some(t) => Err(CompileError::new(
"bynk.parse.expected_token",
t.span,
format!(
"expected {} {ctx}, found {}",
kind.describe(),
t.kind.describe()
),
)),
None => Err(CompileError::new(
"bynk.parse.unexpected_eof",
self.eof_span(),
format!("expected {} {ctx}, found end of file", kind.describe()),
)),
}
}
fn expect_ident(&mut self, ctx: &str) -> Result<Ident, CompileError> {
match self.peek() {
Some(t) if t.kind == TokenKind::Ident => {
self.bump();
Ok(Ident {
name: self.slice(t.span).to_string(),
span: t.span,
})
}
Some(t) if crate::keywords::is_reserved_contextual(self.slice(t.span)) => {
self.bump();
Ok(Ident {
name: self.slice(t.span).to_string(),
span: t.span,
})
}
Some(t) if is_reserved_keyword(t.kind) => Err(CompileError::new(
"bynk.parse.reserved_keyword",
t.span,
format!(
"expected identifier {ctx}, but `{}` is a reserved keyword",
self.slice(t.span)
),
)
.with_note("rename the identifier to something that is not a keyword")),
Some(t) => Err(CompileError::new(
"bynk.parse.expected_token",
t.span,
format!("expected identifier {ctx}, found {}", t.kind.describe()),
)),
None => Err(CompileError::new(
"bynk.parse.unexpected_eof",
self.eof_span(),
format!("expected identifier {ctx}, found end of file"),
)),
}
}
fn take_doc_block(&mut self) -> Option<(Doc, Span)> {
let first = self.take_one_doc()?;
let mut docs = vec![first.clone()];
while let Some(next) = self.peek()
&& matches!(next.kind, TokenKind::DocBlock | TokenKind::DocLine)
&& !has_blank_line_between(self.source, docs[docs.len() - 1].1.end, next.span.start)
{
docs.push(self.take_one_doc().expect("a doc token was peeked"));
}
if docs.len() > 1 && !self.doc_probe {
self.doc_errors.push(multiple_docs(self.source, &docs));
}
Some(first)
}
fn take_one_doc(&mut self) -> Option<(Doc, Span)> {
match self.peek_kind()? {
TokenKind::DocBlock => {
let t = self.bump().unwrap();
let body = doc_block_content(self.source, t.span);
if !self.doc_probe && doc_content_is_code(&body) {
self.doc_errors.push(doc_block_contains_code(t.span));
}
Some((Doc::new(body, DocForm::Block), t.span))
}
TokenKind::DocLine => {
let t = self.bump().unwrap();
let text = doc_line_text(self.source, t.span);
Some((Doc::new(text, DocForm::Lines), t.span))
}
_ => None,
}
}
fn collect_item_lead(&mut self) -> (Vec<Comment>, Option<DocLead>) {
self.collect_lead(Self::take_unit_leading_trivia)
}
fn collect_body_item_lead(&mut self) -> (Vec<Comment>, Option<DocLead>) {
self.collect_lead(Self::take_leading_trivia)
}
fn collect_lead(
&mut self,
take: fn(&mut Self) -> Vec<Comment>,
) -> (Vec<Comment>, Option<DocLead>) {
let mut leading = take(self);
let doc = self.take_doc_block().map(|(content, span)| DocLead {
content,
span,
at: leading.len(),
});
if doc.is_some() {
leading.extend(take(self));
}
(leading, doc)
}
fn collect_member_lead(&mut self, close: TokenKind) -> (Vec<Comment>, Option<Doc>) {
let (mut leading, doc) = self.collect_body_item_lead();
let Some(doc) = doc else {
return (leading, None);
};
match self.peek() {
Some(t) if t.kind != close => {
let doc = self.finalize_doc(Some(doc), t.span, &mut leading);
(leading, doc)
}
_ => {
self.warnings.push(CompileError::new(
"bynk.parse.orphan_doc_block",
doc.span,
"documentation has no following member to attach to",
));
keep_orphan(&mut leading, doc);
(leading, None)
}
}
}
fn peek_past_docs(&self) -> Option<TokenKind> {
self.tokens[self.pos.min(self.tokens.len())..]
.iter()
.map(|t| t.kind)
.find(|k| !matches!(k, TokenKind::DocBlock | TokenKind::DocLine))
}
fn finalize_doc(
&mut self,
doc: Option<DocLead>,
next_span: Span,
leading: &mut Vec<Comment>,
) -> Option<Doc> {
let doc = doc?;
let doc_span = doc.span;
if has_blank_line_between(self.source, doc_span.end, next_span.start) {
self.warnings.push(
CompileError::new(
"bynk.parse.orphan_doc_block",
doc_span,
"documentation block is separated from the following declaration by a blank line; it will not be attached",
)
.with_note(
"remove the blank line to attach the doc to the next declaration, \
or remove the doc block if it is not meant to document anything",
),
);
keep_orphan(leading, doc);
return None;
}
Some(doc.content)
}
}
pub(crate) struct DocLead {
pub(crate) content: Doc,
pub(crate) span: Span,
pub(crate) at: usize,
}
fn keep_orphan(leading: &mut Vec<Comment>, doc: DocLead) {
let at = doc.at.min(leading.len());
leading.insert(at, Comment::OrphanDoc(doc.content));
}
fn multiple_docs(source: &str, docs: &[(Doc, Span)]) -> CompileError {
let (_, first) = docs[0];
let (_, last) = docs[docs.len() - 1];
let mut err = CompileError::new(
"bynk.parse.multiple_docs",
docs[1].1,
"this declaration or member already has a doc; a target has one doc",
)
.with_label(first, "the first doc is here")
.with_note(
"a doc of more than one line is a block: a `---` line, the text, and another `---` line",
);
let one_liners = docs
.iter()
.all(|(d, span)| d.form == DocForm::Lines && !doc_line_is_legacy(source, *span));
if one_liners {
let line_start = source[..first.start].rfind('\n').map_or(0, |i| i + 1);
let indent = &source[line_start..first.start];
let text = docs
.iter()
.map(|(d, _)| d.as_str())
.collect::<Vec<_>>()
.join("\n");
let fence = "-".repeat(doc_fence_len(&text));
let mut block = fence.clone();
for line in text.lines() {
block.push('\n');
if !line.is_empty() {
block.push_str(indent);
block.push_str(line);
}
}
block.push('\n');
block.push_str(indent);
block.push_str(&fence);
let end = source[..last.end].trim_end_matches(['\n', '\r']).len();
err = err.with_suggestion(
"join the docs into one `---` block",
vec![(Span::new_in(first.file, first.start, end), block)],
crate::error::Applicability::MachineApplicable,
);
}
err
}
fn doc_block_contains_code(span: Span) -> CompileError {
CompileError::new(
"bynk.parse.doc_block_contains_code",
span,
"this documentation block contains declarations, so they are not compiled",
)
.with_note(
"a line of three or more dashes opens or closes a doc block, so two dash \
dividers turn the code between them into documentation; for a divider use a \
line comment with text, such as `-- Helpers --`, and to show code in a doc \
block put it in a Markdown code fence (```)",
)
}
const DECLARATION_KEYWORDS: &[TokenKind] = &[
TokenKind::Type,
TokenKind::Fn,
TokenKind::Capability,
TokenKind::Provides,
TokenKind::Service,
TokenKind::Agent,
TokenKind::Actor,
TokenKind::Messages,
TokenKind::Event,
TokenKind::Asset,
TokenKind::Uses,
TokenKind::Consumes,
TokenKind::Exports,
TokenKind::Binding,
];
fn doc_content_is_code(content: &str) -> bool {
doc_content_is_code_at(content, 0)
}
const DOC_PROBE_MAX_DEPTH: usize = 4;
fn doc_content_is_code_at(content: &str, depth: usize) -> bool {
let stripped = strip_code_fences(content);
let tokens = match crate::lexer::tokenize(&stripped) {
Ok(tokens) => tokens,
Err(_) => {
let unmarked = blank_marker_lines(&stripped);
if unmarked == stripped {
return false;
}
return doc_content_is_code_at(&unmarked, depth);
}
};
if depth < DOC_PROBE_MAX_DEPTH
&& tokens.iter().any(|t| {
t.kind == TokenKind::DocBlock
&& doc_content_is_code_at(&doc_block_content(&stripped, t.span), depth + 1)
})
{
return true;
}
if !tokens
.iter()
.any(|t| DECLARATION_KEYWORDS.contains(&t.kind))
{
return false;
}
let (filtered, trivia) = split_trivia(&tokens, &stripped);
let mut warnings = Vec::new();
let mut p = Parser::new(&filtered, &stripped, trivia, &mut warnings);
p.doc_probe = true;
let at = Span::new(0, 0);
let name = QualifiedName {
parts: Vec::new(),
span: at,
};
match p.parse_adapter_body(at, name, None, false) {
Ok(a) => {
p.peek().is_none()
&& (!a.items.is_empty()
|| !a.uses.is_empty()
|| !a.consumes.is_empty()
|| !a.exports.is_empty()
|| a.binding.is_some())
}
Err(_) => false,
}
}
fn blank_marker_lines(content: &str) -> String {
let mut out = String::with_capacity(content.len());
for line in content.lines() {
let trimmed = line.trim();
if !(trimmed.len() >= 3 && trimmed.bytes().all(|b| b == b'-')) {
out.push_str(line);
}
out.push('\n');
}
out
}
fn strip_code_fences(content: &str) -> String {
let mut out = String::with_capacity(content.len());
let mut open: Option<(char, usize)> = None;
for line in content.lines() {
let trimmed = line.trim();
let fence = ['`', '~'].into_iter().find_map(|c| {
let n = trimmed.chars().take_while(|&x| x == c).count();
(n >= 3).then_some((c, n))
});
match (open, fence) {
(None, Some(f)) => open = Some(f),
(Some((c, n)), Some((fc, fnum)))
if fc == c && fnum >= n && trimmed.len() == fnum * c.len_utf8() =>
{
open = None;
}
(None, None) => out.push_str(line),
(Some(_), _) => {}
}
out.push('\n');
}
out
}
fn parse_string_literal(lexeme: &str, span: Span) -> Result<String, CompileError> {
let bytes = lexeme.as_bytes();
debug_assert!(bytes.first() == Some(&b'"') && bytes.last() == Some(&b'"'));
let inner = &lexeme[1..lexeme.len() - 1];
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
other => {
return Err(CompileError::new(
"bynk.lex.bad_escape",
span,
format!(
"invalid escape sequence `\\{}` in string literal",
other.map(|c| c.to_string()).unwrap_or_default()
),
)
.with_note("supported escapes: \\n \\t \\\" \\\\"));
}
}
} else {
out.push(c);
}
}
Ok(out)
}
fn is_reserved_keyword(kind: TokenKind) -> bool {
use TokenKind::*;
matches!(
kind,
Commons
| Type
| Fn
| Where
| True
| False
| Int
| String
| Bool
| Let
| If
| Else
| Ok
| Err
| Result
| ValidationError
| Enum
| Match
| Option
| Record
| Self_
| Some
| None
| Is
| Opaque
| Uses
| Context
| Consumes
| Exports
| Transparent
| Agent
| As
| Capability
| Effect
| Do
| Given
| On
| Http
| Provides
| Stub
| Service
| Actor
| By
| Expect
| Suite
| Case
| Float
| Duration
| Instant
| Bytes
| JsonError
| Property
| Adapter
| Binding
| Cron
| Queue
| From
| Protocol
| Invariant
| Implies
| Requires
| Ensures
| Transition
)
}
fn is_item_start(kind: TokenKind) -> bool {
use TokenKind::*;
matches!(
kind,
Commons | Context | Adapter | Suite
| Type | Fn | Messages | Event | Asset | Uses
| Consumes | Exports | Capability | Provides | Service | Agent | Actor
| Binding
| Stub | Case | Property
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lexer::tokenize;
fn parse_str(src: &str) -> Result<Commons, Vec<CompileError>> {
let toks = tokenize(src).map_err(|e| vec![e])?;
parse(&toks, src)
}
fn parse_recover_str(src: &str) -> (Option<SourceUnit>, Vec<CompileError>) {
let toks = match tokenize(src) {
Ok(t) => t,
Err(e) => return (None, vec![e]),
};
parse_unit_with_recovery(&toks, src)
}
#[test]
fn parse_units_with_recovery_keeps_every_top_level_unit() {
let src =
"commons m {\n fn f() -> Int { 1 }\n}\n\nsuite m\n\ncase \"c\" {\n expect true\n}\n";
let toks = tokenize(src).unwrap();
let (units, errors) = parse_units_with_recovery(&toks, src);
assert!(errors.is_empty(), "{errors:?}");
assert_eq!(units.len(), 2, "expected both units, got {units:?}");
assert!(matches!(units[0], SourceUnit::Commons(_)));
assert!(matches!(units[1], SourceUnit::Suite(_)));
let (unit, errors) = parse_unit_with_recovery(&toks, src);
assert!(errors.is_empty(), "{errors:?}");
assert!(matches!(unit, Some(SourceUnit::Commons(_))));
}
#[test]
fn empty_input_still_reports_an_error_through_the_plural_entry_point() {
let toks = tokenize("").unwrap();
let (units, errors) = parse_units_with_recovery(&toks, "");
assert!(units.is_empty());
assert!(
!errors.is_empty(),
"an empty file must still produce a diagnostic, not silently no units and no error"
);
let (unit, unit_errors) = parse_unit_with_recovery(&toks, "");
assert!(unit.is_none());
assert_eq!(
errors.len(),
unit_errors.len(),
"the singular wrapper must see the same error(s) as the plural entry point"
);
}
#[test]
fn drain_check_reports_expression_interior_comments_as_undrained() {
let ordinary = "commons x {\n// note\ntype T = Int where Positive\n}\n";
let toks = tokenize(ordinary).unwrap();
let (_, _, drained) = parse_units_with_drain_check(&toks, ordinary).unwrap();
assert!(
drained,
"a declaration-leading comment must be fully drained"
);
let lossy = "commons x {\n fn f() -> Int {\n 1 + // note\n 2\n }\n}\n";
let toks = tokenize(lossy).unwrap();
let (_, _, drained) = parse_units_with_drain_check(&toks, lossy).unwrap();
assert!(
!drained,
"a comment inside a binop expression must be reported as undrained"
);
}
#[test]
fn eof_span_never_splits_a_multibyte_codepoint() {
for src in [
"commons x {\n // ends with an arrow →",
"agent A {\n key k: String\n // note 🦀",
"commons y {\n type T = é",
] {
let (_unit, errors) = parse_recover_str(src);
for e in &errors {
assert!(
src.is_char_boundary(e.span.start) && src.is_char_boundary(e.span.end),
"span {:?} splits a codepoint in {src:?}",
e.span,
);
}
}
}
#[test]
fn reserved_contextual_keywords_readable_in_expression_position() {
for kw in ["asset", "case", "event", "messages", "on", "suite"] {
let src = format!("commons x\n\nfn f({kw}: Int) -> Int {{\n {kw}\n}}\n");
let result = parse_str(&src);
assert!(
result.is_ok(),
"a parameter named `{kw}` must be readable in expression position: {:?}",
result.err()
);
}
}
#[test]
fn recovery_skips_garbage_between_decls() {
let src = "commons x {\n\
type A = Int where NonNegative\n\
??? !!!\n\
type B = String where NonEmpty\n\
}";
let (unit, errors) = parse_recover_str(src);
let unit = unit.expect("recovery should produce a partial AST");
let SourceUnit::Commons(c) = unit else {
panic!("expected commons")
};
let names: Vec<_> = c
.items
.iter()
.map(|i| match i {
CommonsItem::Type(t) => t.name.name.clone(),
_ => panic!("expected only types"),
})
.collect();
assert!(
names.contains(&"A".to_string()) && names.contains(&"B".to_string()),
"expected both A and B; got {names:?}",
);
assert!(!errors.is_empty(), "expected at least one parse error");
}
#[test]
fn recovery_handles_bad_first_decl_then_good_second() {
let src = "commons x {\n\
type A Int where NonNegative\n\
type B = String where NonEmpty\n\
}";
let (unit, errors) = parse_recover_str(src);
let unit = unit.expect("recovery should produce a partial AST");
let SourceUnit::Commons(c) = unit else {
panic!("expected commons")
};
let names: Vec<_> = c
.items
.iter()
.filter_map(|i| match i {
CommonsItem::Type(t) => Some(t.name.name.clone()),
_ => None,
})
.collect();
assert!(
names.contains(&"B".to_string()),
"B should be parsed after A's failure; got {names:?}"
);
assert!(!errors.is_empty(), "expected at least one parse error");
}
#[test]
fn recovery_keeps_the_bodys_closing_brace_after_a_bodiless_item() {
let src = "commons m {\n fn f() -> Int\n}\n";
let (unit, errors) = parse_recover_str(src);
assert!(unit.is_some(), "recovery should produce a partial AST");
let categories: Vec<_> = errors.iter().map(|e| e.category).collect();
assert_eq!(
categories.len(),
1,
"one syntax error, no follow-on: {categories:?}"
);
assert!(
!categories.contains(&"bynk.parse.unexpected_eof"),
"the commons' closing brace must survive: {categories:?}"
);
}
#[test]
fn recovery_skips_a_rejected_item_whole() {
let src = "commons m\n\nagent Counter {\n key id: String\n}\n\nfn g() -> Int { 2 }\n";
let recovered = parse_units_recovering(&crate::lexer::tokenize(src).unwrap(), src);
assert_eq!(
recovered.errors.len(),
1,
"one syntax error, no follow-on: {:?}",
recovered
.errors
.iter()
.map(|e| e.category)
.collect::<Vec<_>>()
);
let Some(SourceUnit::Commons(c)) = recovered.units.first() else {
panic!("expected commons")
};
assert!(
c.items.iter().any(|i| matches!(i, CommonsItem::Fn(_))),
"the `fn g` after the skipped agent must still parse"
);
assert_eq!(recovered.broken_decl_names, ["Counter"]);
}
#[test]
fn recovery_skips_a_nested_blocks_own_closing_brace() {
let src = "commons m {\n \
fn f() -> Int {\n \
match 1 {\n \
is 1 -> { let z = }\n \
is _ -> 2\n \
}\n \
}\n \
fn g() -> Int { 2 }\n\
}\n";
let (unit, errors) = parse_recover_str(src);
let unit = unit.expect("recovery should produce a partial AST");
let SourceUnit::Commons(c) = unit else {
panic!("expected commons")
};
let names: Vec<_> = c
.items
.iter()
.filter_map(|i| match i {
CommonsItem::Fn(f) => match &f.name {
FnName::Free(id) => Some(id.name.clone()),
_ => None,
},
_ => None,
})
.collect();
assert_eq!(
names,
vec!["g".to_string()],
"g must still be recovered as an item; got {names:?}"
);
assert!(
!errors
.iter()
.any(|e| e.category == "bynk.parse.expected_unit_header"),
"the outer body's own closing brace must not be mistaken for \
end-of-file: {errors:?}"
);
}
#[test]
fn doc_block_attaches_to_type() {
let c =
parse_str("commons x {\n---\nA descriptive doc.\n---\ntype T = Int where Positive\n}")
.unwrap();
let CommonsItem::Type(t) = &c.items[0] else {
panic!()
};
assert!(t.documentation.is_some());
assert!(
t.documentation
.as_ref()
.unwrap()
.contains("A descriptive doc.")
);
}
#[test]
fn interpolated_string_parses_into_parts() {
let c = parse_str("commons x\n\nfn f(name: String) -> String {\n \"Hi, \\(name)!\"\n}\n")
.unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!("expected fn")
};
let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
panic!("expected InterpStr, got {:?}", f.body.tail.kind)
};
assert_eq!(parts.len(), 3);
assert!(matches!(&parts[0], InterpPart::Chunk(s) if s == "Hi, "));
assert!(
matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::Ident(id) if id.name == "name"))
);
assert!(matches!(&parts[2], InterpPart::Chunk(s) if s == "!"));
}
#[test]
fn interpolated_hole_parses_a_full_expression() {
let c =
parse_str("commons x\n\nfn f(a: Int, b: Int) -> String {\n \"sum = \\(a + b)\"\n}\n")
.unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!("expected fn")
};
let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
panic!("expected InterpStr")
};
assert!(matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::BinOp(..))));
}
#[test]
fn empty_interpolation_hole_is_rejected() {
let errs = parse_str("commons x\n\nfn f() -> String {\n \"\\()\"\n}\n").unwrap_err();
assert!(
errs.iter()
.any(|e| e.category == "bynk.parse.empty_interpolation"),
"expected empty_interpolation; got {errs:?}"
);
}
#[test]
fn interpolation_hole_lex_error_span_is_rebased() {
let cases = [
"commons x\n\nfn f() -> String {\n \"a \\($)\"\n}\n",
"commons x\n\nfn f() -> String {\n \"n = \\(99999999999999999999)\"\n}\n",
"commons x\n\nfn f() -> String {\n \"é \\($)\"\n}\n",
];
for src in cases {
let errs = parse_str(src).unwrap_err();
assert!(!errs.is_empty(), "expected a lex error for {src:?}");
for e in &errs {
assert!(
src.is_char_boundary(e.span.start) && src.is_char_boundary(e.span.end),
"span {:?} splits a codepoint in {src:?}",
e.span,
);
let hole_start = src.find("\\(").expect("case has a hole") + 2;
assert!(
e.span.start >= hole_start,
"span {:?} precedes the hole (starts at {hole_start}) in {src:?}",
e.span,
);
}
}
}
#[test]
fn fragment_form_parses() {
let c = parse_str("commons x.y\n\ntype T = Int where NonNegative\n").unwrap();
assert_eq!(c.form, CommonsForm::Fragment);
assert_eq!(c.items.len(), 1);
}
#[test]
fn uses_parses() {
let c = parse_str("commons x\n\nuses other.lib\n").unwrap();
assert_eq!(c.uses.len(), 1);
assert_eq!(c.uses[0].target.joined(), "other.lib");
}
fn parse_unit_str(src: &str) -> Result<SourceUnit, Vec<CompileError>> {
let toks = tokenize(src).map_err(|e| vec![e])?;
parse_unit(&toks, src)
}
#[test]
fn minimal_context_parses() {
let u = parse_unit_str("context commerce.orders {}").unwrap();
let SourceUnit::Context(c) = u else {
panic!("expected context");
};
assert_eq!(c.name.joined(), "commerce.orders");
assert!(c.items.is_empty());
}
#[test]
fn context_consumes_and_exports_parse() {
let src = "context commerce.orders {\n uses commerce.money\n consumes commerce.payment\n exports opaque { OrderId }\n exports transparent { OrderError }\n type OrderId = String where Matches(\"ORD-[0-9]+\")\n type OrderError = enum { CartEmpty, BadInput }\n}";
let u = parse_unit_str(src).unwrap();
let SourceUnit::Context(c) = u else { panic!() };
assert_eq!(c.uses.len(), 1);
assert_eq!(c.consumes.len(), 1);
assert_eq!(c.exports.len(), 2);
assert_eq!(c.exports[0].kind, ExportKind::Type(Visibility::Opaque));
assert_eq!(c.exports[1].kind, ExportKind::Type(Visibility::Transparent));
}
#[test]
fn context_fragment_form_parses() {
let src = "context x.y\n\nuses other.lib\nconsumes other.ctx\nexports opaque { T }\n\ntype T = Int where NonNegative\n";
let u = parse_unit_str(src).unwrap();
let SourceUnit::Context(c) = u else { panic!() };
assert_eq!(c.form, CommonsForm::Fragment);
assert_eq!(c.uses.len(), 1);
assert_eq!(c.consumes.len(), 1);
assert_eq!(c.exports.len(), 1);
}
#[test]
fn opaque_type_parses() {
let c = parse_str("commons x { type T = opaque Int where NonNegative }").unwrap();
let CommonsItem::Type(t) = &c.items[0] else {
panic!()
};
assert!(matches!(t.body, TypeBody::Opaque { .. }));
}
#[test]
fn empty_commons() {
let c = parse_str("commons fitness.units {}").unwrap();
assert_eq!(c.name.joined(), "fitness.units");
assert!(c.items.is_empty());
}
#[test]
fn one_type_decl() {
let c = parse_str("commons x { type Metres = Int where NonNegative }").unwrap();
assert_eq!(c.items.len(), 1);
let CommonsItem::Type(t) = &c.items[0] else {
panic!()
};
assert_eq!(t.name.name, "Metres");
match &t.body {
TypeBody::Refined {
base, refinement, ..
} => {
assert_eq!(*base, BaseType::Int);
assert!(refinement.is_some());
}
_ => panic!("expected refined body"),
}
}
#[test]
fn function_decl() {
let c = parse_str("commons x { fn add(a: Int, b: Int) -> Int { a + b } }").unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!()
};
assert_eq!(f.name.ident().name, "add");
assert_eq!(f.params.len(), 2);
}
#[test]
fn chained_comparison_is_error() {
let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a < b < c } }")
.unwrap_err();
assert_eq!(errs[0].category, "bynk.parse.non_associative");
}
#[test]
fn chained_equality_is_error() {
let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a == b == c } }")
.unwrap_err();
assert_eq!(errs[0].category, "bynk.parse.non_associative");
}
fn on_big_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
std::thread::Builder::new()
.stack_size(64 * 1024 * 1024)
.spawn(f)
.unwrap()
.join()
.unwrap()
}
#[test]
fn deeply_nested_parens_are_bounded_not_overflowed() {
let errs = on_big_stack(|| {
let depth = crate::MAX_NESTING_DEPTH + 8;
let src = format!(
"commons x {{ fn f() -> Int {{ {}0{} }} }}",
"(".repeat(depth),
")".repeat(depth),
);
parse_str(&src).unwrap_err()
});
assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
}
#[test]
fn deeply_nested_types_are_bounded_not_overflowed() {
let errs = on_big_stack(|| {
let depth = crate::MAX_NESTING_DEPTH + 8;
let src = format!(
"commons x {{ fn f(x: {}Int{}) -> Int {{ 0 }} }}",
"Result[Int, ".repeat(depth),
"]".repeat(depth),
);
parse_str(&src).unwrap_err()
});
assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
}
#[test]
fn deeply_nested_patterns_are_bounded_not_overflowed() {
let errs = on_big_stack(|| {
let depth = crate::MAX_NESTING_DEPTH + 8;
let src = format!(
"commons x {{ fn f(n: Int) -> Int {{ match n {{ {}n{} => 0 }} }} }}",
"Ok(".repeat(depth),
")".repeat(depth),
);
parse_str(&src).unwrap_err()
});
assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
}
#[test]
fn nesting_below_the_limit_still_parses() {
let ok = on_big_stack(|| {
let depth = crate::MAX_NESTING_DEPTH - 8;
let src = format!(
"commons x {{ fn f() -> Int {{ {}0{} }} }}",
"(".repeat(depth),
")".repeat(depth),
);
parse_str(&src).is_ok()
});
assert!(ok, "well-nested source under the limit should parse");
}
#[test]
fn let_statement_parses() {
let c = parse_str("commons x { fn f(n: Int) -> Int { let y = n + 1\n y } }").unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!()
};
assert_eq!(f.body.statements.len(), 1);
match &f.body.statements[0] {
Statement::Let(l) => {
assert_eq!(l.name.name, "y");
assert!(l.type_annot.is_none());
}
_ => panic!("expected a pure `let` statement"),
}
}
#[test]
fn let_with_annotation() {
let c = parse_str("commons x { fn f(n: Int) -> Int { let y: Int = n\n y } }").unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!()
};
match &f.body.statements[0] {
Statement::Let(l) => assert!(l.type_annot.is_some()),
_ => panic!("expected a pure `let` statement"),
}
}
#[test]
fn if_else_parses_as_expression() {
let c = parse_str("commons x { fn f(b: Bool) -> Int { if b { 1 } else { 0 } } }").unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!()
};
assert!(matches!(f.body.tail.kind, ExprKind::If { .. }));
}
#[test]
fn else_if_chain_parses() {
let c = parse_str(
"commons x { fn f(n: Int) -> Int { if n < 0 { -1 } else if n == 0 { 0 } else { 1 } } }",
)
.unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!()
};
let ExprKind::If { else_block, .. } = &f.body.tail.kind else {
panic!()
};
assert!(else_block.statements.is_empty());
assert!(matches!(else_block.tail.kind, ExprKind::If { .. }));
}
#[test]
fn ok_and_err_parse_as_expressions() {
let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!()
};
assert!(matches!(f.body.tail.kind, ExprKind::Ok(_)));
let c =
parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Err(\"x\") } }").unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!()
};
assert!(matches!(f.body.tail.kind, ExprKind::Err(_)));
}
#[test]
fn question_postfix_parses() {
let c = parse_str(
"commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { let x = T.of(n)?\n Ok(x) } }",
)
.unwrap();
let CommonsItem::Fn(f) = &c.items[1] else {
panic!()
};
let Statement::Let(l) = &f.body.statements[0] else {
panic!("expected a pure `let` statement");
};
assert!(matches!(l.value.kind, ExprKind::Question(_)));
}
#[test]
fn constructor_call_parses() {
let c = parse_str(
"commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { T.of(n) } }",
)
.unwrap();
let CommonsItem::Fn(f) = &c.items[1] else {
panic!()
};
let ExprKind::MethodCall {
receiver, method, ..
} = &f.body.tail.kind
else {
panic!("expected MethodCall, got {:?}", f.body.tail.kind)
};
let ExprKind::Ident(id) = &receiver.kind else {
panic!("expected receiver Ident");
};
assert_eq!(id.name, "T");
assert_eq!(method.name, "of");
}
#[test]
fn result_type_ref_parses() {
let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!()
};
assert!(matches!(f.return_type, TypeRef::Result(_, _, _)));
}
#[test]
fn result_missing_arg_count_errors() {
let errs = parse_str("commons x { fn f(n: Int) -> Result[Int] { Ok(n) } }").unwrap_err();
assert_eq!(errs[0].category, "bynk.parse.generic_arg_count");
}
#[test]
fn field_access_parses_in_v0_2() {
let c =
parse_str("commons x { type R = { foo: Int }\n fn f(r: R) -> Int { r.foo } }").unwrap();
let CommonsItem::Fn(f) = &c.items[1] else {
panic!()
};
assert!(matches!(f.body.tail.kind, ExprKind::FieldAccess { .. }));
}
#[test]
fn leading_line_comment_attaches_to_next_decl() {
let src = "commons x {\n// explain the type\ntype T = Int where NonNegative\n}";
let c = parse_str(src).unwrap();
let CommonsItem::Type(t) = &c.items[0] else {
panic!()
};
assert_eq!(
t.trivia.leading,
vec![Comment::Line(" explain the type".to_string())]
);
assert!(t.trivia.trailing.is_none());
}
#[test]
fn trailing_line_comment_attaches_to_prev_decl() {
let src = "commons x {\ntype T = Int where NonNegative // trailing note\n}";
let c = parse_str(src).unwrap();
let CommonsItem::Type(t) = &c.items[0] else {
panic!()
};
assert!(t.trivia.leading.is_empty());
assert_eq!(t.trivia.trailing.as_deref(), Some(" trailing note"));
}
#[test]
fn grouped_leading_comments_attach_together() {
let src = "commons x {\n// one\n// two\n// three\ntype T = Int where Positive\n}";
let c = parse_str(src).unwrap();
let CommonsItem::Type(t) = &c.items[0] else {
panic!()
};
assert_eq!(
t.trivia.leading,
vec![
Comment::Line(" one".to_string()),
Comment::Line(" two".to_string()),
Comment::Line(" three".to_string())
],
);
}
#[test]
fn comment_with_doc_block_keeps_both() {
let src = "commons x {\n// intro\n---\ndocs\n---\ntype T = Int where Positive\n}";
let c = parse_str(src).unwrap();
let CommonsItem::Type(t) = &c.items[0] else {
panic!()
};
assert_eq!(t.trivia.leading, vec![Comment::Line(" intro".to_string())]);
assert_eq!(t.documentation.as_deref(), Some("docs"));
}
#[test]
fn messages_keyword_does_not_collide_with_a_commons_name_segment() {
let src = "commons app.messages {\ntype T = Int where Positive\n}";
let c = parse_str(src).unwrap();
assert_eq!(c.name.joined(), "app.messages");
}
#[test]
fn messages_decl_parses_tag_annotation_and_entries() {
let src = "commons app.messages {\n\
// intro\n\
---\n\
docs\n\
---\n\
messages \"en\" @reference {\n\
\"greeting\" => \"Hello, {name}!\"\n\
\"farewell\" => \"Bye\"\n\
} // trailing\n\
}";
let c = parse_str(src).unwrap();
let CommonsItem::Messages(m) = &c.items[0] else {
panic!("expected a messages item, got {:?}", c.items[0]);
};
assert_eq!(m.tag, "en");
assert_eq!(m.annotations.len(), 1);
assert_eq!(m.annotations[0].name.name, "reference");
assert!(m.annotations[0].args.is_empty());
assert_eq!(m.entries.len(), 2);
assert_eq!(m.entries[0].code, "greeting");
assert_eq!(m.entries[0].template, "Hello, {name}!");
assert_eq!(m.entries[1].code, "farewell");
assert_eq!(m.entries[1].template, "Bye");
assert_eq!(m.trivia.leading, vec![Comment::Line(" intro".to_string())]);
assert_eq!(m.documentation.as_deref(), Some("docs"));
assert_eq!(m.trivia.trailing.as_deref(), Some(" trailing"));
}
#[test]
fn messages_decl_parses_with_no_annotation_and_no_entries() {
let src = "commons app.messages {\nmessages \"en\" {\n}\n}";
let c = parse_str(src).unwrap();
let CommonsItem::Messages(m) = &c.items[0] else {
panic!("expected a messages item, got {:?}", c.items[0]);
};
assert_eq!(m.tag, "en");
assert!(m.annotations.is_empty());
assert!(m.entries.is_empty());
}
#[test]
fn messages_decl_parses_syntactically_inside_a_context_too() {
let src = "context app.svc {\nmessages \"en\" @reference {\n\"a\" => \"b\"\n}\n}";
let toks = tokenize(src).unwrap();
let (unit, errors) = parse_unit_with_recovery(&toks, src);
assert!(errors.is_empty(), "unexpected parse errors: {errors:?}");
let Some(SourceUnit::Context(ctx)) = unit else {
panic!("expected a context")
};
let CommonsItem::Messages(m) = &ctx.items[0] else {
panic!("expected a messages item, got {:?}", ctx.items[0]);
};
assert_eq!(m.tag, "en");
}
#[test]
fn comment_before_let_statement_attaches() {
let src = "commons x {\nfn f(n: Int) -> Int {\n// pick a value\nlet y = n + 1\ny\n}\n}";
let c = parse_str(src).unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!()
};
let Statement::Let(l) = &f.body.statements[0] else {
panic!()
};
assert_eq!(
l.trivia.leading,
vec![Comment::Line(" pick a value".to_string())]
);
}
#[test]
fn comment_before_tail_attaches_to_block_tail() {
let src = "commons x {\nfn f(n: Int) -> Int {\nlet y = n + 1\n// result\ny\n}\n}";
let c = parse_str(src).unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!()
};
assert_eq!(
f.body.tail_leading_comments,
vec![Comment::Line(" result".to_string())],
);
}
#[test]
fn contextual_keywords_are_valid_identifiers() {
let c = parse_str("commons demo {\n type R = { on: Int, suite: String, case: Bool }\n}")
.expect("`on`/`suite`/`case` are valid field names");
let CommonsItem::Type(_) = &c.items[0] else {
panic!("expected a type decl")
};
parse_str("commons demo {\n fn f(on: Int, case: Int) -> Int { 0 }\n}")
.expect("`on`/`case` are valid parameter names");
parse_str("commons demo {\n type R = { suite: Int }\n}")
.expect("`suite` is a valid field name");
}
#[test]
fn is_reserved_keyword_covers_every_lexer_keyword() {
let lexer_src = include_str!("lexer.rs");
let mut words = Vec::new();
for line in lexer_src.lines() {
let t = line.trim();
if let Some(rest) = t.strip_prefix("#[token(\"")
&& let Some(word) = rest.split('"').next()
&& word.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
{
words.push(word.to_string());
}
}
assert!(
words.len() > 30,
"keyword extraction looks broken: only {} words",
words.len()
);
use crate::keywords::RESERVED_CONTEXTUAL;
let mut unclassified = Vec::new();
for word in &words {
let tokens = crate::lexer::tokenize(word).expect("keyword lexes");
let kind = tokens.first().expect("keyword yields a token").kind;
if !is_reserved_keyword(kind) && !RESERVED_CONTEXTUAL.contains(&word.as_str()) {
unclassified.push(word.clone());
}
}
assert!(
unclassified.is_empty(),
"keywords missing from is_reserved_keyword (add them, or document \
them as contextual): {unclassified:?}"
);
}
#[test]
fn is_item_start_matches_the_pinned_keyword_set() {
use TokenKind::*;
let expected_true = [
Commons, Context, Adapter, Suite, Type, Fn, Messages, Event, Asset, Uses, Consumes,
Exports, Capability, Provides, Service, Agent, Actor, Binding, Stub, Case, Property,
];
for kind in expected_true {
assert!(is_item_start(kind), "{kind:?} must be an item start");
}
let expected_false = [
Ident, Plus, Minus, Colon, Dot, Eq, LBrace, RBrace, LParen, RParen, If, Else, Let,
Where, True, False, Match, Is, On, Given,
];
for kind in expected_false {
assert!(!is_item_start(kind), "{kind:?} must not be an item start");
}
}
#[test]
fn recovery_makes_progress_on_context_only_keyword_in_commons() {
let src = "commons demo\n\ncapability Logger {\n fn log(m: String) -> Effect[()]\n}\n";
let tokens = crate::lexer::tokenize(src).unwrap();
let (unit, errors) = parse_unit_with_recovery(&tokens, src);
assert!(unit.is_some(), "the commons header still parses");
assert!(
errors
.iter()
.any(|e| e.category == "bynk.capability.outside_context"),
"the misplaced capability is reported: {errors:?}"
);
assert!(errors.len() < 10, "recovery repeated itself: {errors:?}");
}
#[test]
fn trailing_file_comment_becomes_unit_trailing() {
let src = "commons x\n\ntype T = Int where Positive\n// afterword\n";
let c = parse_str(src).unwrap();
assert_eq!(
c.trailing_comments,
vec![Comment::Line(" afterword".to_string())]
);
}
#[test]
fn trailing_file_comment_after_a_brace_form_commons_is_not_dropped() {
let src = "commons x {\n type T = Int where Positive\n}\n// afterword\n";
let c = parse_str(src).unwrap();
assert_eq!(
c.trailing_comments,
vec![Comment::Line(" afterword".to_string())]
);
}
#[test]
fn trailing_file_comment_after_a_brace_form_context_is_not_dropped() {
let src = "context x {\n type T = Int where Positive\n}\n// afterword\n";
let SourceUnit::Context(c) = parse_unit_str(src).unwrap() else {
panic!("expected context");
};
assert_eq!(
c.trailing_comments,
vec![Comment::Line(" afterword".to_string())]
);
}
#[test]
fn trailing_file_comment_after_a_brace_form_suite_is_not_dropped() {
let src = "suite x {\n case \"c\" {\n expect 1 == 1\n }\n}\n// afterword\n";
let SourceUnit::Suite(s) = parse_unit_str(src).unwrap() else {
panic!("expected suite");
};
assert_eq!(
s.trailing_comments,
vec![Comment::Line(" afterword".to_string())]
);
}
#[test]
fn trailing_file_comment_after_a_brace_form_adapter_is_not_dropped() {
let src = "adapter x {\n binding \"./x.ts\"\n}\n// afterword\n";
let SourceUnit::Adapter(a) = parse_unit_str(src).unwrap() else {
panic!("expected adapter");
};
assert_eq!(
a.trailing_comments,
vec![Comment::Line(" afterword".to_string())]
);
}
#[test]
fn commons_fragment_rejects_uses_after_a_decl() {
let src = "commons x\n\ntype T = Int where Positive\nuses bynk.list\n";
let errs = parse_str(src).unwrap_err();
assert!(
errs.iter()
.any(|e| e.category == "bynk.parse.uses_after_decls"),
"{errs:?}"
);
}
#[test]
fn commons_brace_allows_uses_after_a_decl() {
let src = "commons x {\n type T = Int where Positive\n uses bynk.list\n}\n";
parse_str(src).expect("brace form must not enforce fragment's uses-ordering rule");
}
#[test]
fn context_fragment_rejects_consumes_after_a_decl() {
let src = "context x\n\ntype T = Int where Positive\nconsumes bynk\n";
let errs = parse_unit_str(src).unwrap_err();
assert!(
errs.iter()
.any(|e| e.category == "bynk.parse.consumes_after_decls"),
"{errs:?}"
);
}
#[test]
fn context_fragment_rejects_exports_after_a_decl() {
let src = "context x\n\ntype T = Int where Positive\nexports opaque { T }\n";
let errs = parse_unit_str(src).unwrap_err();
assert!(
errs.iter()
.any(|e| e.category == "bynk.parse.exports_after_decls"),
"{errs:?}"
);
}
#[test]
fn context_brace_allows_consumes_and_exports_after_a_decl() {
let src = "context x {\n type T = Int where Positive\n consumes bynk\n exports opaque { T }\n}\n";
parse_unit_str(src)
.expect("brace form must not enforce fragment's consumes/exports-ordering rules");
}
#[test]
fn test_fragment_rejects_uses_after_a_decl() {
let src = "suite m\n\ncase \"c\" {\n expect true\n}\nuses bynk.list\n";
let errs = parse_unit_str(src).unwrap_err();
assert!(
errs.iter()
.any(|e| e.category == "bynk.parse.uses_after_decls"),
"{errs:?}"
);
}
#[test]
fn test_brace_allows_uses_after_a_decl() {
let src = "suite m {\n case \"c\" {\n expect true\n }\n uses bynk.list\n}\n";
parse_unit_str(src).expect("brace form must not enforce fragment's uses-ordering rule");
}
fn body_tail(body: &str) -> ExprKind {
let src = format!("commons x\n\nfn f() -> Int {{\n {body}\n}}\n");
let c = parse_str(&src).unwrap_or_else(|e| panic!("parse failed for {body:?}: {e:?}"));
let CommonsItem::Fn(f) = &c.items[0] else {
panic!("expected fn, got {:?}", c.items[0]);
};
f.body.tail.kind.clone()
}
fn body_err(body: &str) -> Vec<CompileError> {
let src = format!("commons x\n\nfn f() -> Int {{\n {body}\n}}\n");
parse_str(&src).expect_err(&format!("expected a parse error for {body:?}"))
}
#[test]
fn if_condition_ending_in_ident_does_not_swallow_a_single_ident_branch() {
for src in [
"if ready { result } else { fallback }",
"if ready { fallback } else { result }",
"if !ready { result } else { fallback }",
"if a == b { result } else { fallback }",
"if a && b { result } else { fallback }",
] {
let ExprKind::If {
then_block,
else_block,
..
} = body_tail(src)
else {
panic!("expected If for {src:?}, got {:?}", body_tail(src));
};
assert!(
matches!(&then_block.tail.kind, ExprKind::Ident(_)),
"then-branch tail not an ident for {src:?}: {:?}",
then_block.tail.kind,
);
assert!(
matches!(&else_block.tail.kind, ExprKind::Ident(_)),
"else-branch tail not an ident for {src:?}: {:?}",
else_block.tail.kind,
);
}
}
#[test]
fn else_less_if_with_single_ident_branch_parses() {
let ExprKind::If { then_block, .. } = body_tail("if ready { result }") else {
panic!("expected If");
};
assert!(matches!(&then_block.tail.kind, ExprKind::Ident(_)));
}
#[test]
fn record_construction_still_parses_in_value_position() {
assert!(matches!(
body_tail("Point { x }"),
ExprKind::RecordConstruction { .. }
));
assert!(matches!(
body_tail("Point { x: 1, y: 2 }"),
ExprKind::RecordConstruction { .. }
));
assert!(matches!(
body_tail("Empty {}"),
ExprKind::RecordConstruction { .. }
));
}
#[test]
fn parenthesised_record_is_allowed_in_condition_head() {
let ExprKind::If { cond, .. } =
body_tail("if (ready { result }) { branch } else { other }")
else {
panic!("expected If");
};
let ExprKind::Paren(inner) = &cond.kind else {
panic!("expected a parenthesised condition, got {:?}", cond.kind);
};
assert!(
matches!(&inner.kind, ExprKind::RecordConstruction { .. }),
"parenthesised record in condition head should still construct: {:?}",
inner.kind,
);
}
#[test]
fn record_in_call_arg_within_condition_still_constructs() {
let ExprKind::If { cond, .. } = body_tail("if check(Point { x: 1 }) { a } else { b }")
else {
panic!("expected If");
};
let ExprKind::Call { args, .. } = &cond.kind else {
panic!("expected Call in condition, got {:?}", cond.kind);
};
assert!(matches!(&args[0].kind, ExprKind::RecordConstruction { .. }));
}
#[test]
fn safe_condition_shapes_are_unaffected() {
assert!(matches!(
body_tail("if ready == true { result } else { fallback }"),
ExprKind::If { .. }
));
assert!(matches!(
body_tail("if (ready) { result } else { fallback }"),
ExprKind::If { .. }
));
assert!(matches!(
body_tail("if ready { \"a\" } else { \"b\" }"),
ExprKind::If { .. }
));
}
fn body_block(body: &str) -> Block {
let src = format!("commons x\n\nfn f() -> Int {{\n {body}\n}}\n");
let c = parse_str(&src).unwrap_or_else(|e| panic!("parse failed for {body:?}: {e:?}"));
let CommonsItem::Fn(f) = &c.items[0] else {
panic!("expected fn, got {:?}", c.items[0]);
};
f.body.clone()
}
fn has_error(errs: &[CompileError], code: &str) -> bool {
errs.iter().any(|e| e.category == code)
}
#[test]
fn let_else_desugars_to_a_hidden_binding_and_a_match() {
let b = body_block("let x = 1\n let Some(n) = find(x) else { 0 }\n n + x");
assert_eq!(
b.statements.len(),
2,
"the plain let and the hidden binding"
);
let Statement::Let(hidden) = &b.statements[1] else {
panic!("expected a pure hidden binding, got {:?}", b.statements[1]);
};
assert!(
is_generated_binding(&hidden.name.name),
"{}",
hidden.name.name
);
assert_eq!(
hidden.name.span.start, hidden.name.span.end,
"the binder has no extent"
);
let le = hidden
.let_else
.as_ref()
.expect("the binding records the sugar");
assert_eq!(le.form, LetElseForm::Block);
let ExprKind::Match { discriminant, arms } = &b.tail.kind else {
panic!("expected a match tail, got {:?}", b.tail.kind);
};
assert!(matches!(&discriminant.kind, ExprKind::Ident(i) if i.name == hidden.name.name));
assert_eq!(arms.len(), 2);
assert!(matches!(arms[0].pattern, Pattern::Variant { .. }));
let MatchBody::Block(rest) = &arms[0].body else {
panic!("the pattern's arm runs the rest of the block");
};
assert!(rest.statements.is_empty());
assert!(
!matches!(rest.tail.kind, ExprKind::UnitLit),
"{:?}",
rest.tail.kind
);
assert!(matches!(arms[1].pattern, Pattern::Wildcard(_)));
}
#[test]
fn effectful_let_else_binds_with_an_arrow() {
let b = body_block("let Ok(v) <- load() else { Effect.pure(0) }\n v");
assert!(matches!(&b.statements[0], Statement::EffectLet(l) if l.let_else.is_some()));
}
#[test]
fn let_else_chain_nests_each_step_in_the_previous_arm() {
let b = body_block(
"let Ok(a) = one() else { 1 }\n let y = a\n let Ok(b) = two(y) else { 2 }\n let Ok(c) = three(b) else { 3 }\n c",
);
let ExprKind::Match { arms, .. } = &b.tail.kind else {
panic!("expected a match tail");
};
let MatchBody::Block(second) = &arms[0].body else {
panic!("expected a block arm");
};
assert_eq!(
second.statements.len(),
2,
"`let y` and the second hidden binding"
);
assert!(matches!(&second.statements[0], Statement::Let(l) if l.name.name == "y"));
let ExprKind::Match { arms, .. } = &second.tail.kind else {
panic!("expected the second step's match");
};
let MatchBody::Block(third) = &arms[0].body else {
panic!("expected a block arm");
};
let ExprKind::Match { arms, .. } = &third.tail.kind else {
panic!("expected the third step's match");
};
let MatchBody::Block(last) = &arms[0].body else {
panic!("expected a block arm");
};
assert!(matches!(&last.tail.kind, ExprKind::Ident(i) if i.name == "c"));
}
#[test]
fn else_match_arms_follow_the_patterns_arm() {
let b = body_block("let Ok(v) = r else match { Err(e) => 0 }\n v");
let Statement::Let(l) = &b.statements[0] else {
panic!("expected the hidden binding");
};
assert_eq!(l.let_else.as_ref().unwrap().form, LetElseForm::Match);
let ExprKind::Match { arms, .. } = &b.tail.kind else {
panic!("expected a match tail");
};
assert_eq!(arms.len(), 2);
assert!(
matches!(&arms[1].pattern, Pattern::Variant { variant, .. } if variant.name == "Err")
);
}
#[test]
fn let_else_as_the_last_statement_leaves_a_unit_rest() {
let b = body_block("let Some(n) = find(1) else { () }");
let ExprKind::Match { arms, .. } = &b.tail.kind else {
panic!("expected a match tail");
};
let MatchBody::Block(rest) = &arms[0].body else {
panic!("expected a block arm");
};
assert!(rest.implicit_tail && rest.statements.is_empty());
}
#[test]
fn let_pattern_forms_are_recognised() {
for src in [
"let None = o else { 0 }\n 1",
"let Status.Done = s else { 0 }\n 1",
"let Shipped(at) = s else { 0 }\n at",
"let 0 = n else { 1 }\n 0",
"let -1 = n else { 1 }\n 0",
"let \"a\" = s else { 1 }\n 0",
"let (Ok(x) | Err(x)) = r else { 1 }\n x",
] {
let b = body_block(src);
assert!(
matches!(&b.statements[0], Statement::Let(l) if l.let_else.is_some()),
"{src:?} should parse as a let-else"
);
}
let b = body_block("let Total = 1\n Total");
assert!(
matches!(&b.statements[0], Statement::Let(l) if l.let_else.is_none() && l.name.name == "Total")
);
}
#[test]
fn let_pattern_without_else_is_refused() {
assert!(has_error(
&body_err("let Ok(v) = r\n v"),
"bynk.parse.let_pattern_without_else"
));
assert!(has_error(
&body_err("let Ok(v) <- r()\n v"),
"bynk.parse.let_pattern_without_else"
));
}
#[test]
fn else_on_a_plain_binding_is_refused() {
assert!(has_error(
&body_err("let v = r else { 0 }\n v"),
"bynk.parse.let_else_on_binding"
));
assert!(has_error(
&body_err("let v <- r() else { 0 }\n v"),
"bynk.parse.let_else_on_binding"
));
}
#[test]
fn annotated_let_pattern_is_refused() {
assert!(has_error(
&body_err("let Ok(v): Int = r else { 0 }\n v"),
"bynk.parse.let_pattern_annotation"
));
}
#[test]
fn empty_else_match_is_refused() {
assert!(has_error(
&body_err("let Ok(v) = r else match { }\n v"),
"bynk.parse.empty_match"
));
}
#[test]
fn let_else_steps_count_against_the_nesting_budget() {
let chain = |n: usize| -> String {
let steps: String = (0..n)
.map(|i| format!("let Ok(v{i}) = step() else {{ {i} }}\n "))
.collect();
format!("{steps}0")
};
body_block(&chain(40));
let errs = body_err(&chain(400));
assert!(
errs.iter()
.any(|e| e.category == "bynk.parse.nesting_too_deep"),
"{errs:?}"
);
}
#[test]
fn empty_match_reports_its_own_diagnostic() {
let errs = body_err("match result {}");
assert!(
errs.iter().any(|e| e.category == "bynk.parse.empty_match"),
"expected empty_match; got {errs:?}",
);
}
#[test]
fn match_discriminant_ending_in_ident_parses() {
assert!(matches!(
body_tail("match ready { x => x }"),
ExprKind::Match { .. }
));
}
#[test]
fn identifier_statement_followed_by_unit_tail_does_not_merge_into_a_call() {
let src = "commons c\n\nfn f() -> Int {\n status := Paid\n ()\n}\n";
let c = parse_str(src).unwrap_or_else(|e| panic!("parse failed: {e:?}"));
let CommonsItem::Fn(f) = &c.items[0] else {
panic!("expected fn, got {:?}", c.items[0]);
};
assert_eq!(
f.body.statements.len(),
1,
"expected exactly one Assign statement, got {:?}",
f.body.statements
);
let Statement::Assign(a) = &f.body.statements[0] else {
panic!(
"expected an Assign statement, got {:?}",
f.body.statements[0]
);
};
assert!(
matches!(a.value.kind, ExprKind::Ident(_)),
"assign value must stay the bare identifier `Paid`, got {:?}",
a.value.kind
);
assert!(
matches!(f.body.tail.kind, ExprKind::UnitLit),
"the `()` must remain the block's own tail, got {:?}",
f.body.tail.kind
);
}
#[test]
fn variant_pattern_followed_by_unit_tail_does_not_take_it_as_bindings() {
for (name, pat) in [("user variant", "Paid"), ("built-in variant", "None")] {
let src = format!("commons c\n\nfn f() -> () {{\n let b = r is {pat}\n ()\n}}\n");
let c = parse_str(&src).unwrap_or_else(|e| panic!("{name}: parse failed: {e:?}"));
let CommonsItem::Fn(f) = &c.items[0] else {
panic!("expected fn, got {:?}", c.items[0]);
};
let Statement::Let(l) = &f.body.statements[0] else {
panic!("{name}: expected a Let, got {:?}", f.body.statements[0]);
};
let ExprKind::Is { pattern, .. } = &l.value.kind else {
panic!("{name}: expected an `is` test, got {:?}", l.value.kind);
};
let Pattern::Variant { variant, span, .. } = pattern.as_ref() else {
panic!("{name}: expected a variant pattern, got {pattern:?}");
};
assert_eq!(
span.end, variant.span.end,
"{name}: the pattern must end at `{pat}`, not take the `()` below it"
);
assert!(
matches!(f.body.tail.kind, ExprKind::UnitLit),
"{name}: the `()` must remain the block's own tail, got {:?}",
f.body.tail.kind
);
}
}
#[test]
fn variant_pattern_payload_on_the_same_line_still_binds() {
for (name, pat) in [
("user variant", "Paid(\n x\n )"),
("built-in variant", "Some(x)"),
] {
let src = format!("commons c\n\nfn f() -> Bool {{\n r is {pat}\n}}\n");
let c = parse_str(&src).unwrap_or_else(|e| panic!("{name}: parse failed: {e:?}"));
let CommonsItem::Fn(f) = &c.items[0] else {
panic!("expected fn, got {:?}", c.items[0]);
};
let ExprKind::Is { pattern, .. } = &f.body.tail.kind else {
panic!("{name}: expected an `is` tail, got {:?}", f.body.tail.kind);
};
assert!(
matches!(pattern.as_ref(), Pattern::Variant { bindings, .. } if bindings.len() == 1),
"{name}: expected one binding, got {pattern:?}"
);
}
}
#[test]
fn match_arm_pattern_payload_on_a_later_line_is_rejected() {
let src = "commons c\n\nfn f() -> Int {\n match m {\n Settled\n (amount) => 1,\n _ => 0,\n }\n}\n";
assert!(
parse_str(src).is_err(),
"a next-line `(` after an arm's variant must not open its payload list"
);
let same_line = src.replace("Settled\n (amount)", "Settled(amount)");
parse_str(&same_line).unwrap_or_else(|e| panic!("same-line form must parse: {e:?}"));
}
#[test]
fn method_reference_followed_by_unit_tail_does_not_merge_into_a_call() {
let src = "commons c\n\nfn f() -> Int {\n let y = x.field\n ()\n}\n";
let c = parse_str(src).unwrap_or_else(|e| panic!("parse failed: {e:?}"));
let CommonsItem::Fn(f) = &c.items[0] else {
panic!("expected fn, got {:?}", c.items[0]);
};
let Statement::Let(l) = &f.body.statements[0] else {
panic!("expected a Let statement, got {:?}", f.body.statements[0]);
};
assert!(
matches!(l.value.kind, ExprKind::FieldAccess { .. }),
"let value must stay a field access, got {:?}",
l.value.kind
);
assert!(
matches!(f.body.tail.kind, ExprKind::UnitLit),
"the `()` must remain the block's own tail, got {:?}",
f.body.tail.kind
);
}
#[test]
fn unparenthesised_record_in_condition_head_now_errors() {
assert!(
!body_err("match Point { x: 1 } { p => p }").is_empty(),
"unparenthesised record discriminant should not parse",
);
let ExprKind::Match { discriminant, .. } = body_tail("match (Point { x: 1 }) { p => p }")
else {
panic!("expected Match for the parenthesised form");
};
let ExprKind::Paren(inner) = &discriminant.kind else {
panic!(
"expected a parenthesised discriminant, got {:?}",
discriminant.kind
);
};
assert!(matches!(&inner.kind, ExprKind::RecordConstruction { .. }));
}
fn strict_codes(src: &str) -> Vec<&'static str> {
let toks = tokenize(src).unwrap();
match parse_units_with_warnings(&toks, src) {
Ok((_, warnings)) => warnings.iter().map(|e| e.category).collect(),
Err(errs) => errs.iter().map(|e| e.category).collect(),
}
}
#[test]
fn a_divider_pair_around_code_is_doc_block_contains_code() {
let src =
"commons m\n\n--------\nfn helper() -> Int { 1 }\n--------\nfn f() -> Int { 2 }\n";
assert_eq!(strict_codes(src), ["bynk.parse.doc_block_contains_code"]);
let errs = parse_str(src).unwrap_err();
assert_eq!(errs[0].category, "bynk.parse.doc_block_contains_code");
assert_eq!(
&src[errs[0].span.range()],
"--------\nfn helper() -> Int { 1 }\n--------\n"
);
let (unit, errs) = parse_recover_str(src);
assert!(unit.is_some(), "recovery keeps the unit");
assert!(
errs.iter()
.any(|e| e.category == "bynk.parse.doc_block_contains_code"),
"{errs:?}"
);
}
#[test]
fn an_orphaned_or_trailing_divider_pair_is_doc_block_contains_code() {
let orphan = "commons m\n\n---\nfn helper() -> Int { 1 }\n---\n\nfn f() -> Int { 2 }\n";
let codes = strict_codes(orphan);
assert!(
codes.contains(&"bynk.parse.doc_block_contains_code"),
"{codes:?}"
);
let trailing = "commons m\n\nfn f() -> Int { 2 }\n\n---\ntype Id = Int\n---\n";
let codes = strict_codes(trailing);
assert!(
codes.contains(&"bynk.parse.doc_block_contains_code"),
"{codes:?}"
);
}
#[test]
fn near_miss_prose_and_fenced_code_are_not_code() {
for doc in [
"type of the thing",
"fn is used here",
"The type Id is an Int.",
"uses the clock",
"Event handler.\nfn helper() -> Int { 1 }",
"Example:\n```bynk\nfn helper() -> Int { 1 }\n```",
"~~~~\ntype Id = Int\n~~~~",
"---\nA nested doc.\n---",
"",
] {
assert!(!doc_content_is_code(doc), "{doc:?} read as code");
}
for doc in [
"fn helper() -> Int { 1 }",
"type Id = Int\nfn f() -> Int { 1 }",
"```\nan example\n```\nfn helper() -> Int { 1 }",
"---\nA nested doc.\n---\nfn helper() -> Int { 1 }",
] {
assert!(doc_content_is_code(doc), "{doc:?} not read as code");
}
}
#[test]
fn code_inside_a_nested_doc_or_beside_a_rule_is_code() {
let nested =
"commons m\n\n----\n---\nfn helper() -> Int { 1 }\n---\n----\nfn f() -> Int { 2 }\n";
assert_eq!(strict_codes(nested), ["bynk.parse.doc_block_contains_code"]);
let ruled = "commons m\n\n----\n---\nfn helper() -> Int { 1 }\n----\nfn f() -> Int { 2 }\n";
assert_eq!(strict_codes(ruled), ["bynk.parse.doc_block_contains_code"]);
assert!(doc_content_is_code(
"----\n---\nfn helper() -> Int { 1 }\n---\n----"
));
for doc in [
"---\nAn inner doc.\n---",
"Intro.\n\n---\n\nfn is used here",
"---\n```\nfn helper() -> Int { 1 }\n```\n---",
] {
assert!(!doc_content_is_code(doc), "{doc:?} read as code");
}
let mut deep = "fn helper() -> Int { 1 }".to_string();
for n in (3..3 + DOC_PROBE_MAX_DEPTH + 1).rev() {
let fence = "-".repeat(n);
deep = format!("{fence}\n{deep}\n{fence}");
}
assert!(!doc_content_is_code(&deep));
}
#[test]
fn a_fenced_nested_doc_example_parses_clean() {
let src = "commons m\n\n----\nShows a doc.\n\n```bynk\n---\nInner.\n---\nfn example() -> Int { 1 }\n```\n----\nfn f() -> Int { 2 }\n";
assert!(strict_codes(src).is_empty(), "{:?}", strict_codes(src));
let c = parse_str(src).unwrap();
let CommonsItem::Fn(f) = &c.items[0] else {
panic!("expected fn")
};
assert!(
f.documentation
.as_deref()
.is_some_and(|d| d.contains("---\nInner.\n---")),
"{:?}",
f.documentation
);
}
fn type_at(c: &Commons, i: usize) -> &TypeDecl {
let CommonsItem::Type(t) = &c.items[i] else {
panic!("expected a type at {i}")
};
t
}
fn doc_of(d: &Option<Doc>) -> Option<(&str, DocForm)> {
d.as_ref().map(|d| (d.as_str().trim_end(), d.form))
}
#[test]
fn doc_lines_document_a_declaration() {
let src = "commons m\n\n--- A commit SHA.\ntype Commit = String\n\n---- First. ----\nfn f() -> Int { 1 }\n";
let c = parse_str(src).unwrap();
assert_eq!(
doc_of(&type_at(&c, 0).documentation),
Some(("A commit SHA.", DocForm::Lines))
);
let CommonsItem::Fn(f) = &c.items[1] else {
panic!("expected fn")
};
assert_eq!(doc_of(&f.documentation), Some(("First.", DocForm::Lines)));
assert!(strict_codes(src).is_empty(), "{:?}", strict_codes(src));
}
#[test]
fn legacy_spellings_parse_and_are_each_reported() {
let src =
"-- a header\ncommons m\n\n--| A run.\ntype Run = {\n repo: String, -- trailing\n}\n";
assert_eq!(
strict_codes(src),
[
"bynk.lex.legacy_comment",
"bynk.lex.legacy_doc_line",
"bynk.lex.legacy_comment"
]
);
let tokens = tokenize(src).unwrap();
let (units, _, _) = parse_units_migrating(&tokens, src).unwrap();
let SourceUnit::Commons(c) = &units[0] else {
panic!("commons")
};
assert_eq!(
doc_of(&type_at(c, 0).documentation),
Some(("A run.", DocForm::Lines))
);
}
#[test]
fn consecutive_doc_lines_are_multiple_docs_with_a_block_fix() {
let src = "commons m\n\ntype R = {\n --- First.\n --- Second.\n a: Int,\n}\n";
let tokens = tokenize(src).unwrap();
let errs = parse_units(&tokens, src).unwrap_err();
assert_eq!(errs.len(), 1, "{errs:?}");
assert_eq!(errs[0].category, "bynk.parse.multiple_docs");
let (span, block) = &errs[0].suggestions[0].edits[0];
assert_eq!(&src[span.range()], "--- First.\n --- Second.");
assert_eq!(block, "---\n First.\n Second.\n ---");
}
#[test]
fn members_take_docs_in_either_form() {
let src = "commons m\n\ntype Run = {\n --- The repository.\n repo: String,\n ---\n The version.\n ---\n version: String,\n // a note\n sha: String, // trailing\n}\n\ntype Outcome = enum {\n --- Passed.\n Pass,\n Fail,\n}\n\ntype Shape =\n --- Round.\n | Circle(\n --- The radius.\n r: Int,\n )\n | Dot\n\n--- After the sum.\nfn f() -> Int { 1 }\n\nmessages \"en\" @reference {\n --- Greets.\n \"hi\" => \"Hello\"\n}\n";
assert!(strict_codes(src).is_empty(), "{:?}", strict_codes(src));
let c = parse_str(src).unwrap();
let TypeBody::Record(r) = &type_at(&c, 0).body else {
panic!("record")
};
assert_eq!(
doc_of(&r.fields[0].documentation),
Some(("The repository.", DocForm::Lines))
);
assert_eq!(
doc_of(&r.fields[1].documentation),
Some(("The version.", DocForm::Block))
);
assert_eq!(r.fields[2].documentation, None);
assert_eq!(
r.fields[2].trivia.leading,
vec![Comment::Line(" a note".into())]
);
assert_eq!(r.fields[2].trivia.trailing.as_deref(), Some(" trailing"));
let TypeBody::Sum(e) = &type_at(&c, 1).body else {
panic!("enum")
};
assert_eq!(
doc_of(&e.variants[0].documentation),
Some(("Passed.", DocForm::Lines))
);
assert_eq!(e.variants[1].documentation, None);
let TypeBody::Sum(p) = &type_at(&c, 2).body else {
panic!("sum")
};
assert_eq!(
doc_of(&p.variants[0].documentation),
Some(("Round.", DocForm::Lines))
);
assert_eq!(
doc_of(&p.variants[0].payload[0].documentation),
Some(("The radius.", DocForm::Lines))
);
assert_eq!(p.variants[1].documentation, None);
let CommonsItem::Fn(f) = &c.items[3] else {
panic!("expected fn")
};
assert_eq!(
doc_of(&f.documentation),
Some(("After the sum.", DocForm::Lines))
);
let CommonsItem::Messages(m) = &c.items[4] else {
panic!("expected messages")
};
assert_eq!(
doc_of(&m.entries[0].documentation),
Some(("Greets.", DocForm::Lines))
);
}
#[test]
fn two_docs_on_one_target_is_multiple_docs() {
for src in [
"commons m\n\n---\nBlock.\n---\n--- Lines.\nfn f() -> Int { 1 }\n",
"commons m\n\n--- Lines.\n---\nBlock.\n---\nfn f() -> Int { 1 }\n",
"commons m\n\n---\nOne.\n---\n---\nTwo.\n---\nfn f() -> Int { 1 }\n",
"commons m\n\n--- One.\n--- Two.\nfn f() -> Int { 1 }\n",
"commons m\n\ntype R = {\n --- Lines.\n ---\n Block.\n ---\n a: Int,\n}\n",
] {
assert_eq!(strict_codes(src), ["bynk.parse.multiple_docs"], "{src}");
let (unit, errs) = parse_recover_str(src);
assert!(unit.is_some());
assert!(
errs.iter()
.any(|e| e.category == "bynk.parse.multiple_docs")
);
}
}
#[test]
fn a_dangling_doc_before_a_payload_close_is_only_an_orphan() {
let src =
"commons m\n\ntype S =\n | Circle(\n r: Int,\n --- dangling\n )\n | Dot\n";
assert_eq!(strict_codes(src), ["bynk.parse.orphan_doc_block"]);
let c = parse_str(src).unwrap();
let TypeBody::Sum(s) = &type_at(&c, 0).body else {
panic!("sum")
};
assert_eq!(s.variants[0].payload.len(), 1);
assert_eq!(s.variants[0].payload[0].documentation, None);
}
#[test]
fn an_unattached_member_doc_is_an_orphan() {
let src = "commons m\n\ntype R = {\n --- Spaced.\n\n a: Int,\n --- Dangling.\n}\n";
assert_eq!(
strict_codes(src),
["bynk.parse.orphan_doc_block", "bynk.parse.orphan_doc_block"]
);
let c = parse_str(src).unwrap();
let TypeBody::Record(r) = &type_at(&c, 0).body else {
panic!("record")
};
assert_eq!(r.fields[0].documentation, None);
assert!(matches!(
&r.fields[0].trivia.leading[..],
[Comment::OrphanDoc(d)] if d.as_str() == "Spaced." && d.form == DocForm::Lines
));
assert!(matches!(
&r.trailing_comments[..],
[Comment::OrphanDoc(d)] if d.as_str() == "Dangling."
));
}
}