chappi-syntax 0.1.0

Canonical S-expression parser and serializer for the Chappi cognitive-state language
Documentation
use serde::{Deserialize, Serialize};

use crate::Diagnostic;

type ParseResult<T> = Result<T, Box<Diagnostic>>;

/// Maximum number of nested lists accepted by the Chappi v1 parser.
pub const MAX_NESTING_DEPTH: usize = 256;

/// A half-open byte range with one-based line and column positions.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Span {
    pub start: usize,
    pub end: usize,
    pub start_line: usize,
    pub start_column: usize,
    pub end_line: usize,
    pub end_column: usize,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Document {
    pub forms: Vec<Form>,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Form {
    pub span: Span,
    pub kind: FormKind,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum FormKind {
    Atom(Atom),
    List(Vec<Form>),
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum Atom {
    Symbol(String),
    Integer(String),
    Decimal(String),
    String(String),
}

/// Parses generic S-expressions and continues after errors at later top-level lines.
pub fn parse_document(input: &str) -> Result<Document, Vec<Diagnostic>> {
    let mut parser = Parser::new(input);
    let mut forms = Vec::new();
    let mut diagnostics = Vec::new();

    loop {
        parser.skip_trivia();
        if parser.is_eof() {
            break;
        }

        match parser.parse_form(0) {
            Ok(form) => forms.push(form),
            Err(diagnostic) => {
                diagnostics.push(*diagnostic);
                parser.recover_top_level();
            }
        }
    }

    if diagnostics.is_empty() {
        Ok(Document { forms })
    } else {
        Err(diagnostics)
    }
}

#[derive(Clone, Copy)]
struct Mark {
    byte: usize,
    line: usize,
    column: usize,
}

struct Parser<'a> {
    input: &'a str,
    byte: usize,
    line: usize,
    column: usize,
}

impl<'a> Parser<'a> {
    fn new(input: &'a str) -> Self {
        Self {
            input,
            byte: 0,
            line: 1,
            column: 1,
        }
    }

    fn is_eof(&self) -> bool {
        self.byte == self.input.len()
    }

    fn mark(&self) -> Mark {
        Mark {
            byte: self.byte,
            line: self.line,
            column: self.column,
        }
    }

    fn span_from(&self, start: Mark) -> Span {
        Span {
            start: start.byte,
            end: self.byte,
            start_line: start.line,
            start_column: start.column,
            end_line: self.line,
            end_column: self.column,
        }
    }

    fn empty_span(&self) -> Span {
        self.span_from(self.mark())
    }

    fn peek(&self) -> Option<char> {
        self.input[self.byte..].chars().next()
    }

    fn bump(&mut self) -> Option<char> {
        let character = self.peek()?;
        self.byte += character.len_utf8();
        if character == '\n' {
            self.line += 1;
            self.column = 1;
        } else {
            self.column += 1;
        }
        Some(character)
    }

    fn skip_trivia(&mut self) {
        loop {
            while matches!(self.peek(), Some(' ' | '\t' | '\r' | '\n')) {
                self.bump();
            }
            if self.peek() != Some(';') {
                return;
            }
            while let Some(character) = self.bump() {
                if character == '\n' {
                    break;
                }
            }
        }
    }

    fn parse_form(&mut self, depth: usize) -> ParseResult<Form> {
        self.skip_trivia();
        let start = self.mark();
        match self.peek() {
            Some('(') if depth == MAX_NESTING_DEPTH => {
                self.bump();
                let mut diagnostic = Diagnostic::error(
                    "nesting-depth-exceeded",
                    self.span_from(start),
                    format!(
                        "S-expression nesting exceeds the Chappi v1 limit of {MAX_NESTING_DEPTH} lists"
                    ),
                );
                diagnostic.hint = Some(format!(
                    "Reduce list nesting to at most {MAX_NESTING_DEPTH} levels."
                ));
                Err(diagnostic.into())
            }
            Some('(') => self.parse_list(start, depth + 1),
            Some(')') => {
                self.bump();
                Err(Diagnostic::error(
                    "unexpected-close-paren",
                    self.span_from(start),
                    "unexpected `)` at top level",
                )
                .into())
            }
            Some('"') => self.parse_string(start),
            Some(_) => self.parse_bare_atom(start),
            None => Err(Diagnostic::error(
                "unexpected-eof",
                self.empty_span(),
                "expected an S-expression",
            )
            .into()),
        }
    }

    fn parse_list(&mut self, start: Mark, depth: usize) -> ParseResult<Form> {
        self.bump();
        let mut items = Vec::new();
        loop {
            self.skip_trivia();
            match self.peek() {
                Some(')') => {
                    self.bump();
                    return Ok(Form {
                        span: self.span_from(start),
                        kind: FormKind::List(items),
                    });
                }
                Some(_) => items.push(self.parse_form(depth)?),
                None => {
                    return Err(Diagnostic::error(
                        "unclosed-list",
                        self.span_from(start),
                        "list opened here is missing a closing `)`",
                    )
                    .into());
                }
            }
        }
    }

    fn parse_string(&mut self, start: Mark) -> ParseResult<Form> {
        self.bump();
        let mut value = String::new();
        loop {
            let Some(character) = self.peek() else {
                return Err(Diagnostic::error(
                    "unclosed-string",
                    self.span_from(start),
                    "string literal is missing its closing quote",
                )
                .into());
            };
            if matches!(character, '\n' | '\r') {
                return Err(Diagnostic::error(
                    "unclosed-string",
                    self.span_from(start),
                    "string literals cannot contain a raw line break",
                )
                .into());
            }
            self.bump();
            match character {
                '"' => {
                    return Ok(Form {
                        span: self.span_from(start),
                        kind: FormKind::Atom(Atom::String(value)),
                    });
                }
                '\\' => {
                    let escape_start = self.mark();
                    let Some(escaped) = self.peek() else {
                        return Err(Diagnostic::error(
                            "invalid-escape",
                            self.span_from(start),
                            "string literal ends after an escape character",
                        )
                        .into());
                    };
                    if matches!(escaped, '\n' | '\r') {
                        return Err(Diagnostic::error(
                            "unclosed-string",
                            self.span_from(start),
                            "string literals cannot contain a raw line break",
                        )
                        .into());
                    }
                    self.bump();
                    let decoded = match escaped {
                        '"' => '"',
                        '\\' => '\\',
                        'n' => '\n',
                        'r' => '\r',
                        't' => '\t',
                        'u' => self.parse_unicode_escape(escape_start)?,
                        _ => {
                            return Err(Diagnostic::error(
                            "invalid-escape",
                            self.span_from(escape_start),
                            "supported string escapes are `\\\"`, `\\\\`, `\\n`, `\\r`, `\\t`, and `\\u{...}`",
                        )
                        .into());
                        }
                    };
                    value.push(decoded);
                }
                _ => value.push(character),
            }
        }
    }

    fn parse_bare_atom(&mut self, start: Mark) -> ParseResult<Form> {
        while matches!(self.peek(), Some(character) if !is_delimiter(character)) {
            self.bump();
        }
        let value = &self.input[start.byte..self.byte];
        if value.is_empty() {
            self.bump();
            return Err(Diagnostic::error(
                "invalid-character",
                self.span_from(start),
                "expected an atom, list, or string",
            )
            .into());
        }
        let atom = if is_integer(value) {
            Atom::Integer(value.to_owned())
        } else if is_decimal(value) {
            Atom::Decimal(value.to_owned())
        } else {
            Atom::Symbol(value.to_owned())
        };
        Ok(Form {
            span: self.span_from(start),
            kind: FormKind::Atom(atom),
        })
    }

    fn parse_unicode_escape(&mut self, escape_start: Mark) -> ParseResult<char> {
        if self.peek() != Some('{') {
            return Err(Diagnostic::error(
                "invalid-unicode-escape",
                self.span_from(escape_start),
                "a Unicode escape must start with `\\u{`",
            )
            .into());
        }
        self.bump();
        let digits_start = self.byte;
        while matches!(self.peek(), Some(character) if character.is_ascii_hexdigit()) {
            self.bump();
        }
        let digits = &self.input[digits_start..self.byte];
        if digits.is_empty() || digits.len() > 6 || self.peek() != Some('}') {
            return Err(Diagnostic::error(
                "invalid-unicode-escape",
                self.span_from(escape_start),
                "Unicode escapes must contain one to six hexadecimal digits and end in `}`",
            )
            .into());
        }
        self.bump();
        let value = u32::from_str_radix(digits, 16).expect("ASCII hexadecimal digits parse");
        char::from_u32(value).ok_or_else(|| {
            Diagnostic::error(
                "invalid-unicode-escape",
                self.span_from(escape_start),
                "Unicode escape is not a valid scalar value",
            )
            .into()
        })
    }

    fn recover_top_level(&mut self) {
        while let Some(character) = self.bump() {
            if character == '\n' {
                break;
            }
        }
    }
}

fn is_delimiter(character: char) -> bool {
    matches!(character, ' ' | '\t' | '\n' | '\r' | '(' | ')' | '"' | ';')
}

fn is_integer(value: &str) -> bool {
    let digits = value.strip_prefix('-').unwrap_or(value);
    !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
}

fn is_decimal(value: &str) -> bool {
    let unsigned = value.strip_prefix('-').unwrap_or(value);
    let Some((whole, fraction)) = unsigned.split_once('.') else {
        return false;
    };
    !whole.is_empty()
        && !fraction.is_empty()
        && !fraction.contains('.')
        && whole.bytes().all(|byte| byte.is_ascii_digit())
        && fraction.bytes().all(|byte| byte.is_ascii_digit())
}