use praxis_source::Span;
use praxis_syntax::ident::{ident_run_len, is_ident_continue, is_ident_start};
use crate::ast::{AtomicKind, Constructor, ParserAst, shift_part_spans};
use crate::call::{CallArg, build_call, build_repeated_tail};
use crate::scan::{Scan, ScanError, skip_string};
pub(crate) fn parse_capture_body(
text: &str,
at: usize,
depth: usize,
) -> Result<ParserAst, ScanError> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Err(ScanError::EmptyCapture { byte_offset: at });
}
let base = at + (trimmed.as_ptr() as usize - text.as_ptr() as usize);
let mut cur = Scan::new(trimmed);
let ast = parse_expr(&mut cur, base, depth)?;
skip_ws(&mut cur);
if let Some((tail, _)) = cur.peek() {
return Err(ScanError::MalformedCaptureBody {
byte_offset: base + tail,
message: format!("unexpected `{}` after the parser", &trimmed[tail..]),
});
}
Ok(ast)
}
fn parse_expr(cur: &mut Scan<'_>, base: usize, depth: usize) -> Result<ParserAst, ScanError> {
skip_ws(cur);
let Some((start, c)) = cur.peek() else {
return Err(ScanError::MalformedCaptureBody {
byte_offset: base,
message: "expected a parser".to_string(),
});
};
if c == '`' {
let inner_base = base + start + 1;
let interior = crate::scan::take_template(cur)?;
let mut parts = crate::scan::scan_template_at(interior, depth + 1)
.map_err(|e| e.shifted(inner_base))?;
shift_part_spans(&mut parts, inner_base as u32);
return Ok(ParserAst::Template {
parts,
span: Span::new((base + start) as u32, (base + cur.pos()) as u32),
});
}
if !is_ident_start(c) {
return Err(ScanError::MalformedCaptureBody {
byte_offset: base + start,
message: format!("`{c}` cannot begin a parser"),
});
}
let name = take_ident(cur);
skip_ws(cur);
if cur.peek_char() != Some('(') {
if let Some(kind) = AtomicKind::from_keyword(name) {
return Ok(ParserAst::Atomic {
kind,
span: Span::new((base + start) as u32, (base + cur.pos()) as u32),
});
}
if Constructor::from_keyword(name).is_some() {
return Err(ScanError::MalformedCaptureBody {
byte_offset: base + start,
message: format!("`{name}` is a constructor and needs arguments"),
});
}
return Err(ScanError::UnknownCaptureKind {
byte_offset: base + start,
name: name.to_string(),
});
}
let Some(ctor) = Constructor::from_keyword(name) else {
return Err(ScanError::UnknownConstructor {
byte_offset: base + start,
name: name.to_string(),
});
};
let args = parse_args(cur, base, depth, ctor)?;
let span = Span::new((base + start) as u32, (base + cur.pos()) as u32);
build_call(ctor, args, span).map_err(|mut errs| {
ScanError::CallShape(errs.remove(0))
})
}
fn parse_args(
cur: &mut Scan<'_>,
base: usize,
depth: usize,
ctor: Constructor,
) -> Result<Vec<CallArg>, ScanError> {
let open = cur.pos();
cur.bump(); let mut args = Vec::new();
loop {
skip_ws(cur);
match cur.peek_char() {
None => {
return Err(ScanError::MalformedCaptureBody {
byte_offset: base + open,
message: "unbalanced `(`".to_string(),
});
}
Some(')') => {
cur.bump();
return Ok(args);
}
Some(',') => {
cur.bump();
}
Some(_) => {
let at = args.len();
args.push(parse_arg(cur, base, depth, ctor, at)?);
}
}
}
}
fn parse_arg(
cur: &mut Scan<'_>,
base: usize,
depth: usize,
ctor: Constructor,
at: usize,
) -> Result<CallArg, ScanError> {
skip_ws(cur);
if cur.peek_char() == Some('"') {
return Ok(CallArg::String(take_string(cur, base)?));
}
if starts_a_number(cur) {
let at = cur.pos();
let text = take_number(cur);
let Some(n) = praxis_syntax::numeric::parse_int_literal(text) else {
return Err(ScanError::MalformedCaptureBody {
byte_offset: base + at,
message: format!("`{text}` is not a whole number"),
});
};
return Ok(CallArg::Int(n));
}
if let Some(name) = peek_named_prefix(cur) {
for _ in 0..name.chars().count() {
cur.bump();
}
skip_ws(cur);
cur.bump(); skip_ws(cur);
let name = name.to_string();
if Some(name.as_str()) == ctor.keyword_arg() {
let value = take_keyword_value(cur);
return Ok(CallArg::Keyword { name, value });
}
if peek_ident(cur) == Some(Constructor::Repeated.keyword()) {
let at = cur.pos();
take_ident(cur);
skip_ws(cur);
if cur.peek_char() != Some('(') {
return Err(ScanError::MalformedCaptureBody {
byte_offset: base + at,
message: "`repeated` needs a parser argument".to_string(),
});
}
let args = parse_args(cur, base, depth, Constructor::Repeated)?;
return build_repeated_tail(name, args, Span::at((base + at) as u32))
.map_err(|mut errs| ScanError::CallShape(errs.remove(0)));
}
let parser = parse_expr(cur, base, depth)?;
return Ok(CallArg::Named { name, parser });
}
if ctor.flag_arg().is_some_and(|f| peek_ident(cur) == Some(f)) {
return Ok(CallArg::Flag(take_ident(cur).to_string()));
}
if ctor == Constructor::Repeated
&& at >= 1
&& let Some(name) = peek_ident(cur)
&& !crate::parser_names().any(|known| known == name)
{
return Err(ScanError::CallShape(crate::validate::ValidationError {
span: Span::at((base + cur.pos()) as u32),
code: praxis_source::DiagCode::InvalidConstructorArgument,
message: "`repeated`'s count must be a whole-number literal — the parser \
plan is built when the program is compiled, so the count cannot \
be a parser or a variable"
.to_string(),
}));
}
Ok(CallArg::Parser(parse_expr(cur, base, depth)?))
}
fn peek_named_prefix<'a>(cur: &mut Scan<'a>) -> Option<&'a str> {
let name = peek_ident(cur)?;
let src = cur.src();
let start = cur.pos();
let after = start + name.len();
let rest = src.get(after..)?;
let rest_trimmed = rest.trim_start();
if rest_trimmed.starts_with(':') {
Some(&src[start..after])
} else {
None
}
}
fn peek_ident<'a>(cur: &mut Scan<'a>) -> Option<&'a str> {
let rest = cur.src().get(cur.pos()..)?;
match ident_run_len(rest) {
0 => None,
n => Some(&rest[..n]),
}
}
fn take_ident<'a>(cur: &mut Scan<'a>) -> &'a str {
let start = cur.pos();
while cur.peek_char().is_some_and(is_ident_continue) {
cur.bump();
}
&cur.src()[start..cur.pos()]
}
fn starts_a_number(cur: &mut Scan<'_>) -> bool {
let rest = &cur.src()[cur.pos()..];
let mut chars = rest.chars();
match chars.next() {
Some(c) if c.is_ascii_digit() => true,
Some('-') => chars.next().is_some_and(|c| c.is_ascii_digit()),
_ => false,
}
}
fn take_number<'a>(cur: &mut Scan<'a>) -> &'a str {
let start = cur.pos();
if cur.peek_char() == Some('-') {
cur.bump();
}
while cur
.peek_char()
.is_some_and(|c| c.is_ascii_digit() || c == '_')
{
cur.bump();
}
&cur.src()[start..cur.pos()]
}
fn take_string(cur: &mut Scan<'_>, base: usize) -> Result<String, ScanError> {
let start = cur.pos();
skip_string(cur).map_err(|err| err.shifted(base))?;
Ok(praxis_syntax::literal::unquote_text(
&cur.src()[start..cur.pos()],
))
}
fn take_keyword_value(cur: &mut Scan<'_>) -> String {
let start = cur.pos();
while let Some(c) = cur.peek_char() {
match c {
',' | ')' => break,
'"' => {
if skip_string(cur).is_err() {
cur.advance_to(cur.src().len());
}
}
_ => {
cur.bump();
}
}
}
cur.src()[start..cur.pos()].trim().to_string()
}
fn skip_ws(cur: &mut Scan<'_>) {
while cur.peek_char().is_some_and(char::is_whitespace) {
cur.bump();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::{AtomicKind, SectionItem, SkipPolicy};
fn parse(text: &str) -> Result<ParserAst, ScanError> {
parse_capture_body(text, 0, 0)
}
#[test]
fn an_atomic_body_is_the_atomic_it_names() {
for name in ["int", "word", "char", "text", "rest", "digit"] {
match parse(name) {
Ok(ParserAst::Atomic { kind, .. }) => {
assert_eq!(kind, AtomicKind::from_keyword(name).unwrap());
}
other => panic!("{name} must be an atomic, got {other:?}"),
}
}
}
#[test]
fn an_unknown_parser_name_has_no_default() {
match parse("intr") {
Err(err @ ScanError::UnknownCaptureKind { .. }) => {
assert_eq!(err.code(), praxis_source::DiagCode::UnknownCaptureKind);
}
other => panic!("expected UnknownCaptureKind, got {other:?}"),
}
}
#[test]
fn a_constructor_call_is_built_through_the_shared_table() {
assert!(matches!(parse("csv(int)"), Ok(ParserAst::Csv { .. })));
assert!(matches!(
parse("optional(int)"),
Ok(ParserAst::Optional { .. })
));
match parse(r#"sep(",", int)"#) {
Ok(ParserAst::Sep { separator, .. }) => assert_eq!(separator.as_str(), ","),
other => panic!("expected Sep, got {other:?}"),
}
match parse(r#"chars(one_of("ab"), skip: newlines)"#) {
Ok(ParserAst::Characters { skip, .. }) => assert_eq!(skip, SkipPolicy::Newlines),
other => panic!("expected Characters, got {other:?}"),
}
assert!(matches!(
parse("csv(int, int)"),
Err(ScanError::CallShape(_))
));
assert!(matches!(parse("choice(int)"), Err(ScanError::CallShape(_))));
}
#[test]
fn an_unknown_constructor_is_reported_as_one() {
match parse("frobnicate(int)") {
Err(err @ ScanError::UnknownConstructor { .. }) => {
assert_eq!(err.code(), praxis_source::DiagCode::UnknownConstructor);
}
other => panic!("expected UnknownConstructor, got {other:?}"),
}
}
#[test]
fn a_nested_template_is_a_parser_expression() {
match parse("choice(A: `{n:int}`, B: word)") {
Ok(ParserAst::Choice { cases, .. }) => {
assert_eq!(cases.len(), 2);
assert!(matches!(cases[0].1, ParserAst::Template { .. }));
}
other => panic!("expected Choice, got {other:?}"),
}
}
#[test]
fn a_sections_tail_is_last_and_singular_here_too() {
match parse("sections(draws: csv(int), boards: repeated(matrix(int)))") {
Ok(ParserAst::SectionsNamed {
fields,
repeated_tail,
..
}) => {
assert_eq!(fields.len(), 1);
let (name, tail) = repeated_tail.expect("a tail");
assert_eq!(name, "boards");
assert!(matches!(*tail, ParserAst::Matrix { .. }));
}
other => panic!("expected SectionsNamed, got {other:?}"),
}
assert!(matches!(
parse("sections(boards: repeated(int), draws: csv(int))"),
Err(ScanError::CallShape(_))
));
assert!(matches!(
parse("sections(a: repeated(int), b: repeated(int))"),
Err(ScanError::CallShape(_))
));
assert!(matches!(
parse("repeated(int)"),
Err(ScanError::CallShape(_))
));
}
#[test]
fn a_counted_group_is_bounded_here_too() {
match parse("sections(shapes: repeated(lines(int), 2), regions: lines(int))") {
Ok(ParserAst::SectionsNamed {
fields,
repeated_tail,
..
}) => {
assert!(repeated_tail.is_none(), "a counted group is not the tail");
assert_eq!(fields.len(), 2, "and something may follow it");
match &fields[0] {
SectionItem::Counted {
name,
count,
parser,
} => {
assert_eq!(name, "shapes");
assert_eq!(count.get(), 2);
assert!(matches!(parser, ParserAst::Lines { .. }));
}
other => panic!("expected a counted group, got {other:?}"),
}
assert_eq!(fields[1].name(), "regions");
}
other => panic!("expected SectionsNamed, got {other:?}"),
}
assert!(matches!(
parse("sections(regions: lines(int), shapes: repeated(lines(int), 2))"),
Ok(ParserAst::SectionsNamed { .. })
));
for refused in [
"sections(a: repeated(int, 0))",
"sections(a: repeated(int, -1))",
"sections(a: repeated(int, word))",
"sections(a: repeated(int, n))",
"sections(a: repeated(int, 2, 3))",
] {
assert!(
matches!(parse(refused), Err(ScanError::CallShape(_))),
"`{refused}` must be refused by the shared shape check"
);
}
}
#[test]
fn an_unterminated_string_argument_is_reported_at_its_own_quote() {
match parse_capture_body(r#"sep("-, int)"#, 10, 0) {
Err(ScanError::MalformedCaptureBody {
byte_offset,
message,
}) => {
assert_eq!(byte_offset, 10 + 4, "the caret must be rebased by `at`");
assert!(message.contains("unterminated string literal"), "{message}");
}
other => panic!("expected an unterminated literal, got {other:?}"),
}
}
#[test]
fn an_unterminated_keyword_value_ends_the_body_rather_than_looping() {
assert!(matches!(
parse(r#"chars(one_of("ab"), skip: "newlines)"#),
Err(ScanError::MalformedCaptureBody { .. })
));
}
#[test]
fn trailing_text_after_the_parser_is_an_error() {
assert!(matches!(
parse("int int"),
Err(ScanError::MalformedCaptureBody { .. })
));
assert!(matches!(
parse("csv(int) x"),
Err(ScanError::MalformedCaptureBody { .. })
));
}
}