One-pass S-expression parser with nested lists and precise error handling
BasetenSoftware Engineer · OnsiteLast updated
Implement a single-pass parser for simplified S-expressions in three stages: atoms, a parenthesized list, and arbitrarily nested lists, without splitting the input. The exercise centers on error handling: detecting invalid characters, malformed numbers, unclosed or unexpected parentheses and trailing input, and stopping with a precise error position.
Implement a parser for a simplified S-expression language. The exercise has three parts that build on one another. The parser must read the input sequentially, in a single left-to-right pass over the characters; you may not tokenize it with split() or similar whole-string splitting. The interviewer's main interest is error handling: when the parser meets an invalid character, it must stop parsing at that point and report the problem.
Constraints and Clarifications
The grammar was described only as simplified S-expressions. Unless the interviewer specifies another, use this working grammar:
- Whitespace (space, tab, carriage return, newline) separates elements and is otherwise ignored.
- Integer atom: an optional
-followed by one or more decimal digits, for example42or-7. - Symbol atom: an ASCII letter or one of
+ - * / _, followed by any number of ASCII letters, digits or+ - * / _, for examplex,add,+orset_value. A token that matches the integer rule is an integer, not a symbol. - List:
(, then zero or more expressions separated by whitespace, then). A parenthesis also ends an atom, so(a(b))is legal. - Any other character, for example
#,"or@, is invalid. A token that starts with a digit but is not an integer, such as12ab, is invalid too. - Return integers as integers, symbols as strings and lists as lists, so
(add 1 (neg x))parses to["add", 1, ["neg", "x"]]. - The input holds exactly one expression, optionally surrounded by whitespace. Anything after it is an error.
- An error report must say what went wrong and at which character index.
Clarifying Questions
- Should errors be raised as exceptions or returned as values, and should anything parsed before the error be returned?
- Are string literals, which may contain spaces, parentheses or escaped quotes, part of the language?
- Is a character index enough to locate an error, or are line and column numbers expected?
- Can the input contain more than one top-level expression?
Part 1 — Parse an atom
Write a function that parses an input consisting of a single atom, optionally surrounded by whitespace, and returns its value. For example, " -15 " gives -15 and "add" gives "add", while "12ab", "a#b" and an empty input are errors. Structure the atom reader so that the later parts can call it in the middle of a longer input.
What This Part Should Cover
- Recognizing integers, symbols and invalid tokens in one scan
- Where an atom ends, and which characters may legally follow it
- Error messages that name the problem and its index
- An interface the later parts can reuse
Part 2 — Parse a parenthesized expression
Extend the parser to accept a parenthesized list of atoms, with no nesting yet: (add 1 -2 x) gives ["add", 1, -2, "x"] and () gives an empty list. Make the error handling complete. The parser must stop at the first problem and report what went wrong and where; at least these inputs must fail with a clear message: (a # b), (ab#), (12ab), (a b, ) and (a b) c. A nested list such as (a (b)) may be rejected for now.
What This Part Should Cover
- The loop that reads a list, driven by the same cursor
- One clear detection point for each error class: invalid character, malformed atom, unclosed list, unexpected
), trailing input - Exact error indices, and stopping without returning a half-built result
- Tests for each error class
Part 3 — Recursive parsing
Now lists may contain lists to any depth: (define (square x) (* x x)) gives ["define", ["square", "x"], ["*", "x", "x"]]. Extend the parser, still in one pass, so that every error from Part 2 is still detected and reported correctly at any depth, for example a list deep inside the input that is never closed.
Clarifying Questions for this Part
- How deep can nesting go? Is it acceptable to rely on the language's recursion limit?
- When a list is never closed, which
(should the error point to?
What This Part Should Cover
- The recursive structure: one routine for any expression and one for a list, sharing the cursor
- Error reporting that stays correct inside nested lists
- Very deep nesting: recursion versus an explicit stack
- Running time linear in the input length
Hints
Hint 1
Let the reader own the position: A reader that starts at the current index and leaves the index just past what it consumed can be called from inside a list later, without slicing or re-reading the string.
Hint 2
Only a few things can come next: Inside a list, after skipping whitespace, list what the next character is allowed to be and what each case means. Everything else is an error you can report on the spot.
Hint 3
Where does an inner list end?: Consider what the code that reads a list should do when it meets `(`, and what it should hand back to whoever called it.
Follow-up questions
- An input with 100,000 nested lists crashes a recursive parser. How do you fix it without changing any result?
- How would you add string literals that may contain spaces, parentheses and escaped quotes?
- The input arrives in chunks from a network stream. How would you restructure the parser so it can resume where a chunk ended?
- How would you report line and column numbers instead of a character index?
Solution walkthrough is for members
Get the full walkthrough with reference code and complexity analysis, plus Kitty Coach at 300 messages a day. Hints, the code runner and community answers stay free.