freeroute/tests/dsn/test_tokenizer.py
Ryan Malloy 3bf4f6bed2 Implement Specctra DSN parser with typed board model
Ports the read path of FreeRouting's io/specctra/parser package to a
Java-free Python implementation:

- tokenizer: S-expression lexer mirroring SpecctraFileDescription.flex
  (comments, quoted strings, the string_quote IGNORE_QUOTE directive,
  case-insensitive keywords, and hash-prefixed names)
- sexp: nested S-expression tree builder
- shapes: rect/circle/polygon/path plus area-with-holes scopes
- model: typed dataclasses for layers, padstacks, images, placements,
  nets, net classes, rules, keepouts
- reader: recursive-descent scope readers producing a DsnBoard, one
  _read_* function per FreeRouting read_scope method

46 pytest cases cover the tokenizer, tree, shapes, and end-to-end
parsing against hand-crafted fixtures modeled on FreeRouting's own
test DSN files. Parses 90 of 91 upstream fixtures (the one failure is
a binary OLE file, not text DSN).
2026-07-11 15:21:18 -06:00

127 lines
3.4 KiB
Python

"""Tests for the DSN tokenizer."""
from __future__ import annotations
import pytest
from freeroute.dsn.tokenizer import DsnSyntaxError, TokenKind, tokenize
def kinds(text: str) -> list[TokenKind]:
return [t.kind for t in tokenize(text)]
def texts(text: str) -> list[str]:
return [t.text for t in tokenize(text)]
def test_brackets_and_atoms():
toks = tokenize("(pcb board)")
assert [t.kind for t in toks] == [
TokenKind.LPAREN,
TokenKind.ATOM,
TokenKind.ATOM,
TokenKind.RPAREN,
]
assert texts("(pcb board)") == ["(", "pcb", "board", ")"]
def test_numbers_stay_atoms_but_expose_values():
toks = tokenize("12 -3 4.5 -0.25 1e3")
assert all(t.kind is TokenKind.ATOM for t in toks)
assert toks[0].as_int() == 12
assert toks[1].as_int() == -3
assert toks[2].as_int() is None
assert toks[2].as_float() == 4.5
assert toks[3].as_float() == -0.25
assert toks[4].as_float() == 1000.0
def test_double_quoted_string():
toks = tokenize('(host_cad "KiCad EDA")')
assert toks[2].kind is TokenKind.STRING
assert toks[2].text == "KiCad EDA"
def test_single_quoted_string():
toks = tokenize("(x 'a b c')")
assert toks[2].kind is TokenKind.STRING
assert toks[2].text == "a b c"
def test_string_quote_directive_reads_quote_as_atom():
# `(string_quote ")` — FreeRouting's IGNORE_QUOTE state reads the lone quote
# char after `string_quote` as a literal atom, not as an (unterminated)
# string opener.
toks = tokenize('(string_quote ")')
assert [t.kind for t in toks] == [
TokenKind.LPAREN,
TokenKind.ATOM,
TokenKind.ATOM,
TokenKind.RPAREN,
]
assert toks[2].text == '"'
def test_quotes_still_open_strings_normally():
toks = tokenize('(host_cad "KiCad")')
assert toks[2].kind is TokenKind.STRING
assert toks[2].text == "KiCad"
def test_atom_may_contain_special_chars():
toks = tokenize("Via[0-1]_600:300_um")
assert len(toks) == 1
assert toks[0].text == "Via[0-1]_600:300_um"
def test_quote_stops_a_bareword_and_opens_a_string():
# `space_in_quoted_tokens` pin ref: `"J3"-"GND"` is three tokens.
toks = tokenize('"J3"-"GND"')
assert [t.kind for t in toks] == [
TokenKind.STRING,
TokenKind.ATOM,
TokenKind.STRING,
]
assert [t.text for t in toks] == ["J3", "-", "GND"]
def test_hash_inside_quoted_string_is_not_a_comment():
toks = tokenize('"_VBUS #22"')
assert toks[0].kind is TokenKind.STRING
assert toks[0].text == "_VBUS #22"
def test_line_comment_ignored():
assert texts("# a comment\n(a)") == ["(", "a", ")"]
def test_block_comment_ignored():
assert texts("(a /* skip me */ b)") == ["(", "a", "b", ")"]
def test_hash_glued_to_name_is_an_atom_not_a_comment():
# `#WLTXD` is a net name, not a comment (FreeRouting reads it in NAME state).
assert texts("(net #WLTXD)") == ["(", "net", "#WLTXD", ")"]
assert texts("#22") == ["#22"]
def test_line_tracking():
toks = tokenize("(a\n b\n c)")
lines = {t.text: t.line for t in toks if t.kind is TokenKind.ATOM}
assert lines == {"a": 1, "b": 2, "c": 3}
def test_unterminated_string_raises():
with pytest.raises(DsnSyntaxError):
tokenize('(a "no end')
def test_unterminated_block_comment_raises():
with pytest.raises(DsnSyntaxError):
tokenize("(a /* no end")
def test_hyphenated_pin_ref_is_single_atom():
assert texts("U1-1 R2-2") == ["U1-1", "R2-2"]