All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 11m13s
Reader macros in parser.sx spec, Python parser.py, and hand-written sx.js: - #; datum comment: read and discard next expression - #|...| raw string: no escape processing - #' quote shorthand: (quote expr) - #name extensible dispatch: registered handler transforms next expression #z3 reader macro demo (reader_z3.py): translates define-primitive declarations from primitives.sx into SMT-LIB verification conditions. Same source, two interpretations — bootstrappers compile to executable code, #z3 extracts proof obligations. 48 parser tests (SX spec + Python), all passing. Rebootstrapped JS+Python. Demo page at /plans/reader-macro-demo with side-by-side examples. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
292 lines
8.5 KiB
Python
292 lines
8.5 KiB
Python
"""Tests for the s-expression parser."""
|
|
|
|
import pytest
|
|
from shared.sx.parser import parse, parse_all, serialize, ParseError
|
|
from shared.sx.types import Symbol, Keyword, NIL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Atoms
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAtoms:
|
|
def test_integer(self):
|
|
assert parse("42") == 42
|
|
|
|
def test_negative_integer(self):
|
|
assert parse("-7") == -7
|
|
|
|
def test_float(self):
|
|
assert parse("3.14") == 3.14
|
|
|
|
def test_scientific(self):
|
|
assert parse("1e-3") == 0.001
|
|
|
|
def test_string(self):
|
|
assert parse('"hello world"') == "hello world"
|
|
|
|
def test_string_escapes(self):
|
|
assert parse(r'"line1\nline2"') == "line1\nline2"
|
|
assert parse(r'"tab\there"') == "tab\there"
|
|
assert parse(r'"say \"hi\""') == 'say "hi"'
|
|
|
|
def test_symbol(self):
|
|
assert parse("foo") == Symbol("foo")
|
|
|
|
def test_component_symbol(self):
|
|
s = parse("~card")
|
|
assert s == Symbol("~card")
|
|
assert s.is_component
|
|
|
|
def test_keyword(self):
|
|
assert parse(":class") == Keyword("class")
|
|
|
|
def test_true(self):
|
|
assert parse("true") is True
|
|
|
|
def test_false(self):
|
|
assert parse("false") is False
|
|
|
|
def test_nil(self):
|
|
assert parse("nil") is NIL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lists
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLists:
|
|
def test_empty_list(self):
|
|
assert parse("()") == []
|
|
|
|
def test_simple_list(self):
|
|
assert parse("(1 2 3)") == [1, 2, 3]
|
|
|
|
def test_mixed_list(self):
|
|
result = parse('(div :class "main")')
|
|
assert result == [Symbol("div"), Keyword("class"), "main"]
|
|
|
|
def test_nested_list(self):
|
|
result = parse("(a (b c) d)")
|
|
assert result == [Symbol("a"), [Symbol("b"), Symbol("c")], Symbol("d")]
|
|
|
|
def test_vector_sugar(self):
|
|
assert parse("[1 2 3]") == [1, 2, 3]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Maps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMaps:
|
|
def test_simple_map(self):
|
|
result = parse('{:a 1 :b 2}')
|
|
assert result == {"a": 1, "b": 2}
|
|
|
|
def test_nested_map(self):
|
|
result = parse('{:x {:y 3}}')
|
|
assert result == {"x": {"y": 3}}
|
|
|
|
def test_string_keys(self):
|
|
result = parse('{"name" "alice"}')
|
|
assert result == {"name": "alice"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Comments
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestComments:
|
|
def test_line_comment(self):
|
|
assert parse("; comment\n42") == 42
|
|
|
|
def test_inline_comment(self):
|
|
result = parse("(a ; stuff\nb)")
|
|
assert result == [Symbol("a"), Symbol("b")]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_all
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestParseAll:
|
|
def test_multiple(self):
|
|
results = parse_all("1 2 3")
|
|
assert results == [1, 2, 3]
|
|
|
|
def test_multiple_lists(self):
|
|
results = parse_all("(a) (b)")
|
|
assert results == [[Symbol("a")], [Symbol("b")]]
|
|
|
|
def test_empty(self):
|
|
assert parse_all("") == []
|
|
assert parse_all(" ; only comments\n") == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Quasiquote
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestQuasiquote:
|
|
def test_quasiquote_symbol(self):
|
|
result = parse("`x")
|
|
assert result == [Symbol("quasiquote"), Symbol("x")]
|
|
|
|
def test_quasiquote_list(self):
|
|
result = parse("`(a b c)")
|
|
assert result == [Symbol("quasiquote"), [Symbol("a"), Symbol("b"), Symbol("c")]]
|
|
|
|
def test_unquote(self):
|
|
result = parse(",x")
|
|
assert result == [Symbol("unquote"), Symbol("x")]
|
|
|
|
def test_splice_unquote(self):
|
|
result = parse(",@xs")
|
|
assert result == [Symbol("splice-unquote"), Symbol("xs")]
|
|
|
|
def test_quasiquote_with_unquote(self):
|
|
result = parse("`(a ,x b)")
|
|
assert result == [Symbol("quasiquote"), [
|
|
Symbol("a"),
|
|
[Symbol("unquote"), Symbol("x")],
|
|
Symbol("b"),
|
|
]]
|
|
|
|
def test_quasiquote_with_splice(self):
|
|
result = parse("`(a ,@rest)")
|
|
assert result == [Symbol("quasiquote"), [
|
|
Symbol("a"),
|
|
[Symbol("splice-unquote"), Symbol("rest")],
|
|
]]
|
|
|
|
def test_roundtrip_quasiquote(self):
|
|
assert serialize(parse("`(a ,x ,@rest)")) == "`(a ,x ,@rest)"
|
|
|
|
def test_roundtrip_unquote(self):
|
|
assert serialize(parse(",x")) == ",x"
|
|
|
|
def test_roundtrip_splice_unquote(self):
|
|
assert serialize(parse(",@xs")) == ",@xs"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Errors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestErrors:
|
|
def test_unterminated_list(self):
|
|
with pytest.raises(ParseError):
|
|
parse("(a b")
|
|
|
|
def test_unterminated_string(self):
|
|
with pytest.raises(ParseError):
|
|
parse('"hello')
|
|
|
|
def test_unexpected_closer(self):
|
|
with pytest.raises(ParseError):
|
|
parse(")")
|
|
|
|
def test_trailing_content(self):
|
|
with pytest.raises(ParseError):
|
|
parse("1 2")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Serialization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSerialize:
|
|
def test_int(self):
|
|
assert serialize(42) == "42"
|
|
|
|
def test_float(self):
|
|
assert serialize(3.14) == "3.14"
|
|
|
|
def test_string(self):
|
|
assert serialize("hello") == '"hello"'
|
|
|
|
def test_string_escapes(self):
|
|
assert serialize('say "hi"') == '"say \\"hi\\""'
|
|
|
|
def test_symbol(self):
|
|
assert serialize(Symbol("foo")) == "foo"
|
|
|
|
def test_keyword(self):
|
|
assert serialize(Keyword("class")) == ":class"
|
|
|
|
def test_bool(self):
|
|
assert serialize(True) == "true"
|
|
assert serialize(False) == "false"
|
|
|
|
def test_nil(self):
|
|
assert serialize(None) == "nil"
|
|
assert serialize(NIL) == "nil"
|
|
|
|
def test_list(self):
|
|
assert serialize([Symbol("a"), 1, 2]) == "(a 1 2)"
|
|
|
|
def test_empty_list(self):
|
|
assert serialize([]) == "()"
|
|
|
|
def test_dict(self):
|
|
result = serialize({"a": 1, "b": 2})
|
|
assert result == "{:a 1 :b 2}"
|
|
|
|
def test_roundtrip(self):
|
|
original = '(div :class "main" (p "hello") (span 42))'
|
|
assert serialize(parse(original)) == original
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reader macros
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestReaderMacros:
|
|
"""Test #; datum comment, #|...| raw string, and #' quote shorthand."""
|
|
|
|
def test_datum_comment_discards(self):
|
|
assert parse_all("#;(ignored) 42") == [42]
|
|
|
|
def test_datum_comment_in_list(self):
|
|
assert parse("(1 #;2 3)") == [1, 3]
|
|
|
|
def test_datum_comment_nested(self):
|
|
assert parse_all("#;(a (b c) d) 99") == [99]
|
|
|
|
def test_raw_string_basic(self):
|
|
assert parse('#|hello|') == "hello"
|
|
|
|
def test_raw_string_with_quotes(self):
|
|
assert parse('#|say "hi"|') == 'say "hi"'
|
|
|
|
def test_raw_string_with_backslashes(self):
|
|
assert parse('#|a\\nb|') == 'a\\nb'
|
|
|
|
def test_raw_string_empty(self):
|
|
assert parse('#||') == ""
|
|
|
|
def test_quote_shorthand_symbol(self):
|
|
assert parse("#'foo") == [Symbol("quote"), Symbol("foo")]
|
|
|
|
def test_quote_shorthand_list(self):
|
|
assert parse("#'(1 2 3)") == [Symbol("quote"), [1, 2, 3]]
|
|
|
|
def test_hash_at_eof_errors(self):
|
|
with pytest.raises(ParseError):
|
|
parse("#")
|
|
|
|
def test_unknown_reader_macro_errors(self):
|
|
with pytest.raises(ParseError, match="Unknown reader macro"):
|
|
parse("#x foo")
|
|
|
|
def test_extensible_reader_macro(self):
|
|
"""Registered reader macros transform the next expression."""
|
|
from shared.sx.parser import register_reader_macro
|
|
register_reader_macro("upper", lambda expr: str(expr).upper())
|
|
try:
|
|
result = parse('#upper "hello"')
|
|
assert result == "HELLO"
|
|
finally:
|
|
from shared.sx.parser import _READER_MACROS
|
|
del _READER_MACROS["upper"]
|