Lines
100 %
Functions
53.33 %
Branches
//! Bare-symbol and `:keyword` atoms, plus the shared `is_delimiter`
//! predicate every number / atom parser consults to know where a
//! token ends.
//!
//! Colon handling (ADR-0029): a *leading* `:` is a keyword (`parse_keyword`,
//! tried first in `reader::parse_expr`); an *infix* `:` (or `::`) qualifies a
//! symbol with a namespace (`parse_symbol`). The two never collide — they are
//! distinguished by whether the first char is a colon.
use winnow::error::{AddContext, ContextError, ErrMode, ModalResult, StrContext, StrContextValue};
use winnow::prelude::*;
use winnow::token::{none_of, take_while};
use crate::ast::Expr;
pub(super) fn is_delimiter(c: char) -> bool {
// `%` terminates a numeric token (`15%` reader sugar); `parse_number`
// consumes the trailing `%` itself, so a number always ends at one.
c.is_whitespace() || "()\"'`,;%".contains(c)
}
fn token_char(c: char) -> bool {
!c.is_whitespace() && !"()\"'`,".contains(c)
/// Hard parse error (no backtrack) anchored at the current input position.
fn cut(input: &&str, label: &'static str, expected: &'static str) -> ErrMode<ContextError> {
ErrMode::Cut(
ContextError::new()
.add_context(input, &input.checkpoint(), StrContext::Label(label))
.add_context(
input,
&input.checkpoint(),
StrContext::Expected(StrContextValue::Description(expected)),
),
)
pub(super) fn parse_keyword(input: &mut &str) -> ModalResult<Expr> {
let _ = ':'.parse_next(input)?;
// A second leading colon (`::foo`) or a bare `:` is not a keyword — and
// must NOT fall through to `parse_symbol` (which would reclaim the token).
// Cut so the alternation stops here with a structured error.
if input.starts_with(':') {
return Err(cut(input, "keyword", "no leading '::'"));
let first: char = none_of(|c: char| !token_char(c))
.parse_next(input)
.map_err(|_: ErrMode<ContextError>| cut(input, "keyword", "non-empty keyword name"))?;
let rest: &str = take_while(0.., token_char).parse_next(input)?;
let mut name = String::with_capacity(1 + rest.len());
name.push(first);
name.push_str(rest);
name.make_ascii_uppercase();
Ok(Expr::Keyword(name))
pub(super) fn parse_symbol(input: &mut &str) -> ModalResult<Expr> {
let first: char = none_of(|c: char| !token_char(c)).parse_next(input)?;
if name == "NIL" {
return Ok(Expr::Nil);
if name == "T" {
return Ok(Expr::Bool(true));
canonicalize_symbol(input, name)
/// Validates and canonicalizes infix-colon namespace qualification (ADR-0029).
/// `NS:NAME` and `NS::NAME` both canonicalize to `NS:NAME` (the `::` separator
/// is accepted as input syntax but discarded — reserved for future visibility
/// semantics). Trailing colon, multiple separators, or empty sides are hard
/// errors. A token with no colon passes through unchanged.
fn canonicalize_symbol(input: &&str, name: String) -> Result<Expr, ErrMode<ContextError>> {
if !name.contains(':') {
return Ok(Expr::Symbol(name));
// Split into colon-separated segments; collapse a single `::` (one empty
// middle segment between two non-empty sides) to a single separator.
let segments: Vec<&str> = name.split(':').collect();
let qualified = match segments.as_slice() {
// NS:NAME
[ns, base] if !ns.is_empty() && !base.is_empty() => {
crate::ast::canonical_symbol(Some(ns), base)
// NS::NAME — fold the double colon to the canonical single-colon key.
[ns, mid, base] if !ns.is_empty() && mid.is_empty() && !base.is_empty() => {
_ => {
return Err(cut(
"qualified symbol",
"one ':' or '::' separator with non-empty namespace and name",
));
};
Ok(Expr::Symbol(qualified))