Skip to main content

chappi_syntax/
parser.rs

1use serde::{Deserialize, Serialize};
2
3use crate::Diagnostic;
4
5type ParseResult<T> = Result<T, Box<Diagnostic>>;
6
7/// Maximum number of nested lists accepted by the Chappi v1 parser.
8pub const MAX_NESTING_DEPTH: usize = 256;
9
10/// A half-open byte range with one-based line and column positions.
11#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
12pub struct Span {
13    pub start: usize,
14    pub end: usize,
15    pub start_line: usize,
16    pub start_column: usize,
17    pub end_line: usize,
18    pub end_column: usize,
19}
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22pub struct Document {
23    pub forms: Vec<Form>,
24}
25
26#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
27pub struct Form {
28    pub span: Span,
29    pub kind: FormKind,
30}
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33pub enum FormKind {
34    Atom(Atom),
35    List(Vec<Form>),
36}
37
38#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
39pub enum Atom {
40    Symbol(String),
41    Integer(String),
42    Decimal(String),
43    String(String),
44}
45
46/// Parses generic S-expressions and continues after errors at later top-level lines.
47pub fn parse_document(input: &str) -> Result<Document, Vec<Diagnostic>> {
48    let mut parser = Parser::new(input);
49    let mut forms = Vec::new();
50    let mut diagnostics = Vec::new();
51
52    loop {
53        parser.skip_trivia();
54        if parser.is_eof() {
55            break;
56        }
57
58        match parser.parse_form(0) {
59            Ok(form) => forms.push(form),
60            Err(diagnostic) => {
61                diagnostics.push(*diagnostic);
62                parser.recover_top_level();
63            }
64        }
65    }
66
67    if diagnostics.is_empty() {
68        Ok(Document { forms })
69    } else {
70        Err(diagnostics)
71    }
72}
73
74#[derive(Clone, Copy)]
75struct Mark {
76    byte: usize,
77    line: usize,
78    column: usize,
79}
80
81struct Parser<'a> {
82    input: &'a str,
83    byte: usize,
84    line: usize,
85    column: usize,
86}
87
88impl<'a> Parser<'a> {
89    fn new(input: &'a str) -> Self {
90        Self {
91            input,
92            byte: 0,
93            line: 1,
94            column: 1,
95        }
96    }
97
98    fn is_eof(&self) -> bool {
99        self.byte == self.input.len()
100    }
101
102    fn mark(&self) -> Mark {
103        Mark {
104            byte: self.byte,
105            line: self.line,
106            column: self.column,
107        }
108    }
109
110    fn span_from(&self, start: Mark) -> Span {
111        Span {
112            start: start.byte,
113            end: self.byte,
114            start_line: start.line,
115            start_column: start.column,
116            end_line: self.line,
117            end_column: self.column,
118        }
119    }
120
121    fn empty_span(&self) -> Span {
122        self.span_from(self.mark())
123    }
124
125    fn peek(&self) -> Option<char> {
126        self.input[self.byte..].chars().next()
127    }
128
129    fn bump(&mut self) -> Option<char> {
130        let character = self.peek()?;
131        self.byte += character.len_utf8();
132        if character == '\n' {
133            self.line += 1;
134            self.column = 1;
135        } else {
136            self.column += 1;
137        }
138        Some(character)
139    }
140
141    fn skip_trivia(&mut self) {
142        loop {
143            while matches!(self.peek(), Some(' ' | '\t' | '\r' | '\n')) {
144                self.bump();
145            }
146            if self.peek() != Some(';') {
147                return;
148            }
149            while let Some(character) = self.bump() {
150                if character == '\n' {
151                    break;
152                }
153            }
154        }
155    }
156
157    fn parse_form(&mut self, depth: usize) -> ParseResult<Form> {
158        self.skip_trivia();
159        let start = self.mark();
160        match self.peek() {
161            Some('(') if depth == MAX_NESTING_DEPTH => {
162                self.bump();
163                let mut diagnostic = Diagnostic::error(
164                    "nesting-depth-exceeded",
165                    self.span_from(start),
166                    format!(
167                        "S-expression nesting exceeds the Chappi v1 limit of {MAX_NESTING_DEPTH} lists"
168                    ),
169                );
170                diagnostic.hint = Some(format!(
171                    "Reduce list nesting to at most {MAX_NESTING_DEPTH} levels."
172                ));
173                Err(diagnostic.into())
174            }
175            Some('(') => self.parse_list(start, depth + 1),
176            Some(')') => {
177                self.bump();
178                Err(Diagnostic::error(
179                    "unexpected-close-paren",
180                    self.span_from(start),
181                    "unexpected `)` at top level",
182                )
183                .into())
184            }
185            Some('"') => self.parse_string(start),
186            Some(_) => self.parse_bare_atom(start),
187            None => Err(Diagnostic::error(
188                "unexpected-eof",
189                self.empty_span(),
190                "expected an S-expression",
191            )
192            .into()),
193        }
194    }
195
196    fn parse_list(&mut self, start: Mark, depth: usize) -> ParseResult<Form> {
197        self.bump();
198        let mut items = Vec::new();
199        loop {
200            self.skip_trivia();
201            match self.peek() {
202                Some(')') => {
203                    self.bump();
204                    return Ok(Form {
205                        span: self.span_from(start),
206                        kind: FormKind::List(items),
207                    });
208                }
209                Some(_) => items.push(self.parse_form(depth)?),
210                None => {
211                    return Err(Diagnostic::error(
212                        "unclosed-list",
213                        self.span_from(start),
214                        "list opened here is missing a closing `)`",
215                    )
216                    .into());
217                }
218            }
219        }
220    }
221
222    fn parse_string(&mut self, start: Mark) -> ParseResult<Form> {
223        self.bump();
224        let mut value = String::new();
225        loop {
226            let Some(character) = self.peek() else {
227                return Err(Diagnostic::error(
228                    "unclosed-string",
229                    self.span_from(start),
230                    "string literal is missing its closing quote",
231                )
232                .into());
233            };
234            if matches!(character, '\n' | '\r') {
235                return Err(Diagnostic::error(
236                    "unclosed-string",
237                    self.span_from(start),
238                    "string literals cannot contain a raw line break",
239                )
240                .into());
241            }
242            self.bump();
243            match character {
244                '"' => {
245                    return Ok(Form {
246                        span: self.span_from(start),
247                        kind: FormKind::Atom(Atom::String(value)),
248                    });
249                }
250                '\\' => {
251                    let escape_start = self.mark();
252                    let Some(escaped) = self.peek() else {
253                        return Err(Diagnostic::error(
254                            "invalid-escape",
255                            self.span_from(start),
256                            "string literal ends after an escape character",
257                        )
258                        .into());
259                    };
260                    if matches!(escaped, '\n' | '\r') {
261                        return Err(Diagnostic::error(
262                            "unclosed-string",
263                            self.span_from(start),
264                            "string literals cannot contain a raw line break",
265                        )
266                        .into());
267                    }
268                    self.bump();
269                    let decoded = match escaped {
270                        '"' => '"',
271                        '\\' => '\\',
272                        'n' => '\n',
273                        'r' => '\r',
274                        't' => '\t',
275                        'u' => self.parse_unicode_escape(escape_start)?,
276                        _ => {
277                            return Err(Diagnostic::error(
278                            "invalid-escape",
279                            self.span_from(escape_start),
280                            "supported string escapes are `\\\"`, `\\\\`, `\\n`, `\\r`, `\\t`, and `\\u{...}`",
281                        )
282                        .into());
283                        }
284                    };
285                    value.push(decoded);
286                }
287                _ => value.push(character),
288            }
289        }
290    }
291
292    fn parse_bare_atom(&mut self, start: Mark) -> ParseResult<Form> {
293        while matches!(self.peek(), Some(character) if !is_delimiter(character)) {
294            self.bump();
295        }
296        let value = &self.input[start.byte..self.byte];
297        if value.is_empty() {
298            self.bump();
299            return Err(Diagnostic::error(
300                "invalid-character",
301                self.span_from(start),
302                "expected an atom, list, or string",
303            )
304            .into());
305        }
306        let atom = if is_integer(value) {
307            Atom::Integer(value.to_owned())
308        } else if is_decimal(value) {
309            Atom::Decimal(value.to_owned())
310        } else {
311            Atom::Symbol(value.to_owned())
312        };
313        Ok(Form {
314            span: self.span_from(start),
315            kind: FormKind::Atom(atom),
316        })
317    }
318
319    fn parse_unicode_escape(&mut self, escape_start: Mark) -> ParseResult<char> {
320        if self.peek() != Some('{') {
321            return Err(Diagnostic::error(
322                "invalid-unicode-escape",
323                self.span_from(escape_start),
324                "a Unicode escape must start with `\\u{`",
325            )
326            .into());
327        }
328        self.bump();
329        let digits_start = self.byte;
330        while matches!(self.peek(), Some(character) if character.is_ascii_hexdigit()) {
331            self.bump();
332        }
333        let digits = &self.input[digits_start..self.byte];
334        if digits.is_empty() || digits.len() > 6 || self.peek() != Some('}') {
335            return Err(Diagnostic::error(
336                "invalid-unicode-escape",
337                self.span_from(escape_start),
338                "Unicode escapes must contain one to six hexadecimal digits and end in `}`",
339            )
340            .into());
341        }
342        self.bump();
343        let value = u32::from_str_radix(digits, 16).expect("ASCII hexadecimal digits parse");
344        char::from_u32(value).ok_or_else(|| {
345            Diagnostic::error(
346                "invalid-unicode-escape",
347                self.span_from(escape_start),
348                "Unicode escape is not a valid scalar value",
349            )
350            .into()
351        })
352    }
353
354    fn recover_top_level(&mut self) {
355        while let Some(character) = self.bump() {
356            if character == '\n' {
357                break;
358            }
359        }
360    }
361}
362
363fn is_delimiter(character: char) -> bool {
364    matches!(character, ' ' | '\t' | '\n' | '\r' | '(' | ')' | '"' | ';')
365}
366
367fn is_integer(value: &str) -> bool {
368    let digits = value.strip_prefix('-').unwrap_or(value);
369    !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
370}
371
372fn is_decimal(value: &str) -> bool {
373    let unsigned = value.strip_prefix('-').unwrap_or(value);
374    let Some((whole, fraction)) = unsigned.split_once('.') else {
375        return false;
376    };
377    !whole.is_empty()
378        && !fraction.is_empty()
379        && !fraction.contains('.')
380        && whole.bytes().all(|byte| byte.is_ascii_digit())
381        && fraction.bytes().all(|byte| byte.is_ascii_digit())
382}