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).
This commit is contained in:
Ryan Malloy 2026-07-11 15:21:18 -06:00
parent dc44abc7f7
commit 3bf4f6bed2
13 changed files with 1847 additions and 0 deletions

View File

@ -0,0 +1,69 @@
"""Specctra DSN parsing for freeroute.
Public API::
from freeroute.dsn import parse_dsn
board = parse_dsn(dsn_text)
``parse_dsn`` returns a typed :class:`~freeroute.dsn.model.DsnBoard`. The
lower-level :func:`~freeroute.dsn.tokenizer.tokenize` and
:func:`~freeroute.dsn.sexp.parse` are exposed for tooling and tests.
"""
from __future__ import annotations
from .model import (
ClearanceRule,
ComponentPlace,
ComponentPlacement,
DsnBoard,
Image,
Keepout,
Layer,
Net,
NetClass,
NetPin,
Padstack,
ParserInfo,
Pin,
Resolution,
StructureRules,
WidthRule,
)
from .reader import DsnParseError, parse_dsn
from .sexp import SExp, parse
from .shapes import Area, Circle, Path, Polygon, Rectangle, Shape
from .tokenizer import DsnSyntaxError, Token, TokenKind, tokenize
__all__ = [
"parse_dsn",
"DsnParseError",
"parse",
"SExp",
"tokenize",
"Token",
"TokenKind",
"DsnSyntaxError",
"DsnBoard",
"ParserInfo",
"Resolution",
"Layer",
"WidthRule",
"ClearanceRule",
"StructureRules",
"Padstack",
"Pin",
"Image",
"ComponentPlace",
"ComponentPlacement",
"NetPin",
"Net",
"NetClass",
"Keepout",
"Shape",
"Rectangle",
"Circle",
"Polygon",
"Path",
"Area",
]

218
src/freeroute/dsn/model.py Normal file
View File

@ -0,0 +1,218 @@
"""Typed board model produced by parsing a Specctra DSN file.
These dataclasses are the Python analogue of the data that FreeRouting's
``io/specctra/parser`` scope readers accumulate before constructing a
``RoutingBoard``. They stay at the DSN level (names, raw coordinates in DSN
units) turning them into board geometry is a later port stage.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .shapes import Area, Shape
__all__ = [
"ParserInfo",
"Resolution",
"Layer",
"WidthRule",
"ClearanceRule",
"StructureRules",
"Padstack",
"Pin",
"Image",
"ComponentPlace",
"ComponentPlacement",
"NetPin",
"Net",
"NetClass",
"Keepout",
"DsnBoard",
]
@dataclass
class ParserInfo:
"""The ``(parser ...)`` scope."""
string_quote: str = '"'
host_cad: str | None = None
host_version: str | None = None
constants: list[tuple[str, str]] = field(default_factory=list)
generated_by_freerouting: bool = False
@dataclass
class Resolution:
"""The ``(resolution <unit> <value>)`` scope. Defaults match FreeRouting."""
unit: str = "mil"
value: int = 100
@dataclass
class Layer:
"""A ``(layer ...)`` entry. ``index`` is the physical layer number, 0 = top."""
name: str
index: int
is_signal: bool = True
net_names: list[str] = field(default_factory=list)
@dataclass
class WidthRule:
value: float
@dataclass
class ClearanceRule:
value: float
#: class-pair names from ``(type a-b)`` / ``(type smd_smd)``; empty = default
class_pairs: list[str] = field(default_factory=list)
@dataclass
class StructureRules:
"""The default ``(rule ...)`` block plus control/snap settings."""
width_rules: list[WidthRule] = field(default_factory=list)
clearance_rules: list[ClearanceRule] = field(default_factory=list)
@dataclass
class Padstack:
"""A ``(padstack ...)`` — one or more per-layer pad shapes plus flags."""
name: str
shapes: list[Shape] = field(default_factory=list)
attach_allowed: bool = True
placed_absolute: bool = False
@dataclass
class Pin:
"""A pin within an image: ``(pin <padstack> [ (rotate d) ] <name> x y)``."""
padstack_name: str
name: str
x: float
y: float
rotation: float = 0.0
@dataclass
class Image:
"""A ``(image ...)`` — a component footprint definition."""
name: str
is_front: bool = True
pins: list[Pin] = field(default_factory=list)
outlines: list[Shape] = field(default_factory=list)
keepouts: list[Area] = field(default_factory=list)
via_keepouts: list[Area] = field(default_factory=list)
place_keepouts: list[Area] = field(default_factory=list)
@dataclass
class ComponentPlace:
"""One placed instance: ``(place <refdes> x y front|back rot ...)``.
``x`` / ``y`` are ``None`` when the component is declared but not yet placed.
"""
name: str
x: float | None = None
y: float | None = None
is_front: bool = True
rotation: float = 0.0
position_fixed: bool = False
part_number: str | None = None
@dataclass
class ComponentPlacement:
"""A ``(component <libname> (place ...) ...)`` group."""
lib_name: str
places: list[ComponentPlace] = field(default_factory=list)
@dataclass
class NetPin:
"""A ``Comp-Pin`` reference inside a net's ``(pins ...)`` list."""
component: str
pin: str
@dataclass
class Net:
"""A ``(net <name> [subnet] (pins ...))`` entry."""
name: str
subnet: int = 1
pins: list[NetPin] = field(default_factory=list)
@dataclass
class NetClass:
"""A ``(class <name> net... (circuit (use_via ...)) (rule ...))`` entry."""
name: str
net_names: list[str] = field(default_factory=list)
width_rules: list[WidthRule] = field(default_factory=list)
clearance_rules: list[ClearanceRule] = field(default_factory=list)
use_via: list[str] = field(default_factory=list)
use_layer: list[str] = field(default_factory=list)
via_rule: str | None = None
@dataclass
class Keepout:
"""A structure-level keepout area with its kind."""
area: Area
kind: str = "keepout" # keepout | via_keepout | place_keepout
@dataclass
class DsnBoard:
"""The fully parsed DSN file."""
name: str = ""
parser: ParserInfo = field(default_factory=ParserInfo)
resolution: Resolution = field(default_factory=Resolution)
unit: str = "mil"
layers: list[Layer] = field(default_factory=list)
#: bounding-box boundary shape (layer ``pcb``), if present
boundary: Shape | None = None
#: outline shapes (layer ``signal``)
outlines: list[Shape] = field(default_factory=list)
via_padstack_names: list[str] = field(default_factory=list)
structure_rules: StructureRules = field(default_factory=StructureRules)
snap_angle: str = "fortyfive_degree"
via_at_smd_allowed: bool = False
keepouts: list[Keepout] = field(default_factory=list)
padstacks: list[Padstack] = field(default_factory=list)
images: list[Image] = field(default_factory=list)
placements: list[ComponentPlacement] = field(default_factory=list)
nets: list[Net] = field(default_factory=list)
net_classes: list[NetClass] = field(default_factory=list)
# Convenience lookups -----------------------------------------------------
def layer_names(self) -> list[str]:
return [layer.name for layer in self.layers]
def net(self, name: str) -> Net | None:
for n in self.nets:
if n.name == name:
return n
return None
def padstack(self, name: str) -> Padstack | None:
for p in self.padstacks:
if p.name == name:
return p
return None

506
src/freeroute/dsn/reader.py Normal file
View File

@ -0,0 +1,506 @@
"""Build a typed :class:`DsnBoard` from a Specctra DSN S-expression tree.
This is the Python analogue of FreeRouting's ``ScopeKeyword`` recursive-descent
readers (``Parser``, ``Resolution``, ``Structure``, ``Library``, ``Package``,
``Component``, ``Network`` ...). Each ``_read_*`` function here corresponds to a
``read_scope`` method there. Unknown scopes are ignored, matching FreeRouting's
tolerant ``skip_scope`` behaviour.
"""
from __future__ import annotations
from .model import (
ClearanceRule,
ComponentPlace,
ComponentPlacement,
DsnBoard,
Image,
Keepout,
Layer,
Net,
NetClass,
NetPin,
Padstack,
ParserInfo,
Pin,
Resolution,
WidthRule,
)
from .sexp import SExp, parse
from .shapes import Shape, read_area, read_shape
from .tokenizer import Token
__all__ = ["parse_dsn", "DsnParseError"]
class DsnParseError(ValueError):
"""Raised when the input is not a valid ``(pcb ...)`` DSN file."""
# --- small leaf helpers ------------------------------------------------------
def _first_value(node: SExp) -> str | None:
"""Text of the first leaf-token item of ``node`` (its name argument)."""
for it in node.items:
if isinstance(it, Token):
return it.text
return None
def _on_off(node: SExp, default: bool = False) -> bool:
"""Interpret an ``(... on|off)`` scope as a bool."""
val = _first_value(node)
if val is None:
return default
return val.lower() == "on"
def _split_pin_ref(ref: str) -> NetPin:
"""Split a ``Comp-Pin`` reference on the first hyphen (FreeRouting rule)."""
comp, sep, pin = ref.partition("-")
if not sep:
return NetPin(component=ref, pin="")
return NetPin(component=comp, pin=pin)
# --- scope readers -----------------------------------------------------------
def _read_parser(node: SExp) -> ParserInfo:
info = ParserInfo()
for it in node.items:
if not isinstance(it, SExp):
continue
if it.head == "string_quote":
val = _first_value(it)
if val:
info.string_quote = val
elif it.head == "host_cad":
info.host_cad = _first_value(it)
elif it.head == "host_version":
info.host_version = _first_value(it)
elif it.head == "generated_by_freerouting":
info.generated_by_freerouting = True
elif it.head == "constant":
vals = [v.text for v in it.values()]
if len(vals) >= 2:
info.constants.append((vals[0], vals[1]))
return info
def _read_resolution(node: SExp) -> Resolution:
vals = node.values()
res = Resolution()
if vals:
res.unit = vals[0].text
if len(vals) > 1:
as_int = vals[1].as_int()
if as_int is not None:
res.value = as_int
return res
def _read_layer(node: SExp, index: int) -> Layer:
name = _first_value(node) or f"layer_{index}"
is_signal = True
net_names: list[str] = []
for it in node.items:
if not isinstance(it, SExp):
continue
if it.head == "type":
t = _first_value(it)
if t is not None and t.lower() == "power":
is_signal = False
elif it.head == "use_net":
net_names.extend(v.text for v in it.values())
return Layer(name=name, index=index, is_signal=is_signal, net_names=net_names)
def _collect_via_names(node: SExp) -> list[str]:
"""Collect padstack names from a ``(via ...)`` scope, incl. ``(spare ...)``."""
names: list[str] = []
spare: list[str] = []
for it in node.items:
if isinstance(it, Token) and it.is_value:
names.append(it.text)
elif isinstance(it, SExp) and it.head == "spare":
spare.extend(_collect_via_names(it))
names.extend(spare)
return names
def _read_rules_into(
node: SExp, width_out: list[WidthRule], clear_out: list[ClearanceRule]
) -> None:
"""Read a ``(rule (width v) (clearance v (type ...)))`` block."""
for it in node.items:
if not isinstance(it, SExp):
continue
if it.head == "width":
vals = it.values()
if vals and vals[0].as_float() is not None:
width_out.append(WidthRule(value=vals[0].as_float()))
elif it.head in ("clearance", "clear"):
vals = it.values()
if not vals or vals[0].as_float() is None:
continue
value = vals[0].as_float()
class_pairs: list[str] = []
type_scope = it.child("type")
if type_scope is not None:
class_pairs = [v.text for v in type_scope.values()]
clear_out.append(ClearanceRule(value=value, class_pairs=class_pairs))
def _read_structure(node: SExp, board: DsnBoard) -> None:
layer_index = 0
for it in node.items:
if not isinstance(it, SExp):
continue
head = it.head
if head == "layer":
board.layers.append(_read_layer(it, layer_index))
layer_index += 1
elif head == "boundary":
_read_boundary(it, board)
elif head == "via":
board.via_padstack_names = _collect_via_names(it)
elif head == "rule":
_read_rules_into(
it, board.structure_rules.width_rules, board.structure_rules.clearance_rules
)
elif head == "keepout":
area = read_area(it)
if area is not None:
board.keepouts.append(Keepout(area=area, kind="keepout"))
elif head == "via_keepout":
area = read_area(it)
if area is not None:
board.keepouts.append(Keepout(area=area, kind="via_keepout"))
elif head == "place_keepout":
area = read_area(it)
if area is not None:
board.keepouts.append(Keepout(area=area, kind="place_keepout"))
elif head == "control":
smd = it.child("via_at_smd")
if smd is not None:
board.via_at_smd_allowed = _on_off(smd)
elif head == "snap_angle":
val = _first_value(it)
if val:
board.snap_angle = val
def _read_boundary(node: SExp, board: DsnBoard) -> None:
for it in node.items:
if not isinstance(it, SExp):
continue
shape = read_shape(it)
if shape is None:
continue
if shape.layer == "pcb":
board.boundary = shape
else: # signal (or a named layer treated as outline)
board.outlines.append(shape)
def _read_padstack(node: SExp) -> tuple[str, list[Shape], bool, bool]:
name = _first_value(node) or ""
shapes: list[Shape] = []
attach_allowed = True
placed_absolute = False
for it in node.items:
if not isinstance(it, SExp):
continue
if it.head == "shape":
for sub in it.items:
if isinstance(sub, SExp):
shape = read_shape(sub)
if shape is not None:
shapes.append(shape)
elif it.head == "attach":
attach_allowed = _on_off(it, default=True)
elif it.head == "absolute":
placed_absolute = _on_off(it, default=False)
return name, shapes, attach_allowed, placed_absolute
def _read_pin(node: SExp) -> Pin | None:
"""Read ``(pin <padstack> [ (rotate d) ] <name> x y [ (rotate d) ])``."""
padstack_name: str | None = None
pin_name: str | None = None
coords: list[float] = []
rotation = 0.0
for it in node.items:
if isinstance(it, SExp):
if it.head == "rotate":
rv = it.values()
if rv and rv[0].as_float() is not None:
rotation = rv[0].as_float()
continue
# leaf token: padstack, pin name, then two coordinates
if padstack_name is None:
padstack_name = it.text
elif it.is_number and len(coords) < 2 and pin_name is not None:
coords.append(float(it.text))
elif pin_name is None:
pin_name = it.text
elif len(coords) < 2 and it.is_number:
coords.append(float(it.text))
if padstack_name is None or pin_name is None or len(coords) < 2:
return None
return Pin(
padstack_name=padstack_name, name=pin_name, x=coords[0], y=coords[1], rotation=rotation
)
def _read_image(node: SExp) -> Image | None:
name = _first_value(node)
if name is None:
return None
image = Image(name=name)
for it in node.items:
if not isinstance(it, SExp):
continue
head = it.head
if head == "pin":
pin = _read_pin(it)
if pin is not None:
image.pins.append(pin)
elif head == "side":
side = _first_value(it)
image.is_front = side is None or side.lower() != "back"
elif head == "outline":
for sub in it.items:
if isinstance(sub, SExp):
shape = read_shape(sub)
if shape is not None:
image.outlines.append(shape)
elif head == "keepout":
area = read_area(it)
if area is not None:
image.keepouts.append(area)
elif head == "via_keepout":
area = read_area(it)
if area is not None:
image.via_keepouts.append(area)
elif head == "place_keepout":
area = read_area(it)
if area is not None:
image.place_keepouts.append(area)
return image
def _read_library(node: SExp, board: DsnBoard) -> None:
for it in node.items:
if not isinstance(it, SExp):
continue
if it.head == "padstack":
name, shapes, attach, absolute = _read_padstack(it)
board.padstacks.append(
Padstack(name=name, shapes=shapes, attach_allowed=attach, placed_absolute=absolute)
)
elif it.head == "image":
image = _read_image(it)
if image is not None:
board.images.append(image)
def _read_place(node: SExp) -> ComponentPlace | None:
"""Read ``(place <refdes> x y front|back rot [ (lock_type position) ] ...)``."""
name: str | None = None
coords: list[float] = []
is_front = True
rotation = 0.0
position_fixed = False
part_number: str | None = None
seen_side = False
for it in node.items:
if isinstance(it, SExp):
if it.head == "lock_type":
position_fixed = any(
isinstance(v, Token) and v.text == "position" for v in it.items
)
elif it.head in ("PN", "pn"):
part_number = _first_value(it)
continue
if name is None:
name = it.text
elif not seen_side and it.is_number and len(coords) < 2:
coords.append(float(it.text))
elif it.text.lower() in ("front", "back"):
is_front = it.text.lower() != "back"
seen_side = True
elif seen_side and it.is_number:
rotation = float(it.text)
if name is None:
return None
x = coords[0] if len(coords) >= 2 else None
y = coords[1] if len(coords) >= 2 else None
return ComponentPlace(
name=name,
x=x,
y=y,
is_front=is_front,
rotation=rotation,
position_fixed=position_fixed,
part_number=part_number,
)
def _read_placement(node: SExp, board: DsnBoard) -> None:
for it in node.items:
if not isinstance(it, SExp) or it.head != "component":
continue
lib_name = _first_value(it) or ""
placement = ComponentPlacement(lib_name=lib_name)
for sub in it.items:
if isinstance(sub, SExp) and sub.head == "place":
place = _read_place(sub)
if place is not None:
placement.places.append(place)
board.placements.append(placement)
def _read_pins_list(node: SExp) -> list[NetPin]:
"""Read a ``(pins Comp-Pin ...)`` list into :class:`NetPin` entries.
The common case is a single atom per reference (``U1-1``) which splits on
its first hyphen. When component or pin names are quoted (Eagle exports with
``space_in_quoted_tokens``), the reference arrives as several tokens around
a standalone ``-`` separator (``"J3" - "GND"``); those are stitched back
together here.
"""
parts = [v.text for v in node.values() if v.text]
refs: list[NetPin] = []
i = 0
while i < len(parts):
p = parts[i]
if p == "-":
i += 1
continue
if p.endswith("-") and i + 1 < len(parts): # unquoted comp + quoted pin
refs.append(NetPin(component=p[:-1], pin=parts[i + 1]))
i += 2
continue
if "-" in p: # single-token Comp-Pin (the common case)
refs.append(_split_pin_ref(p))
i += 1
continue
if i + 1 < len(parts) and parts[i + 1] == "-" and i + 2 < len(parts):
refs.append(NetPin(component=p, pin=parts[i + 2])) # both quoted
i += 3
continue
refs.append(NetPin(component=p, pin="")) # name without a pin
i += 1
return refs
def _read_net(node: SExp) -> Net | None:
name = _first_value(node)
if name is None:
return None
net = Net(name=name)
for it in node.items:
if isinstance(it, Token):
as_int = it.as_int()
if as_int is not None and it.text == name:
continue # (defensive) name repeated
if as_int is not None:
net.subnet = as_int
continue
if it.head in ("pins", "order", "fromto"):
net.pins.extend(_read_pins_list(it))
return net
def _read_net_class(node: SExp) -> NetClass | None:
name = _first_value(node)
if name is None:
return None
net_class = NetClass(name=name)
# Leading bareword/string values after the name are member net names.
first_seen = False
for it in node.items:
if isinstance(it, Token):
if not first_seen:
first_seen = True # skip the class name itself
continue
net_class.net_names.append(it.text)
continue
head = it.head
if head == "rule":
_read_rules_into(it, net_class.width_rules, net_class.clearance_rules)
elif head == "circuit":
use_via = it.child("use_via")
if use_via is not None:
net_class.use_via.extend(v.text for v in use_via.values())
use_layer = it.child("use_layer")
if use_layer is not None:
net_class.use_layer.extend(v.text for v in use_layer.values())
elif head == "use_via":
net_class.use_via.extend(v.text for v in it.values())
elif head == "use_layer":
net_class.use_layer.extend(v.text for v in it.values())
elif head == "via_rule":
net_class.via_rule = _first_value(it)
return net_class
def _read_network(node: SExp, board: DsnBoard) -> None:
for it in node.items:
if not isinstance(it, SExp):
continue
if it.head == "net":
net = _read_net(it)
if net is not None:
board.nets.append(net)
elif it.head == "class":
net_class = _read_net_class(it)
if net_class is not None:
board.net_classes.append(net_class)
# --- top level ---------------------------------------------------------------
def parse_dsn(text: str) -> DsnBoard:
"""Parse DSN ``text`` into a :class:`DsnBoard`.
Raises :class:`DsnParseError` if the top-level scope is not ``(pcb ...)``.
"""
top = parse(text)
if top.head != "pcb":
raise DsnParseError(
f"not a Specctra DSN file: expected top-level '(pcb ...)', got '({top.head} ...)'"
)
board = DsnBoard(name=_first_value(top) or "")
for it in top.items:
if not isinstance(it, SExp):
continue
head = it.head
if head == "parser":
board.parser = _read_parser(it)
elif head == "resolution":
board.resolution = _read_resolution(it)
board.unit = board.resolution.unit
elif head == "unit":
val = _first_value(it)
if val:
board.unit = val
elif head == "structure":
_read_structure(it, board)
elif head == "library":
_read_library(it, board)
elif head == "placement":
_read_placement(it, board)
elif head == "network":
_read_network(it, board)
# `wiring` and unknown scopes are intentionally ignored for now.
return board

101
src/freeroute/dsn/sexp.py Normal file
View File

@ -0,0 +1,101 @@
"""Parse a flat token stream into a nested S-expression tree.
A DSN file is one top-level list ``(pcb ...)``. Each list's first element is
normally a keyword atom (its *head*). This module builds a generic tree; the
semantic interpretation (layers, nets, shapes ...) happens in :mod:`reader`,
which walks the tree scope by scope the way FreeRouting's ``ScopeKeyword``
recursive-descent readers walk the token stream.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .tokenizer import DsnSyntaxError, Token, TokenKind, tokenize
__all__ = ["SExp", "parse", "parse_tokens"]
@dataclass
class SExp:
"""A parenthesised list.
``head`` is the text of the first token when it is an atom (e.g. ``pcb``,
``layer``, ``net``), else ``None``. ``items`` holds the remaining elements,
each either a :class:`~freeroute.dsn.tokenizer.Token` (a leaf atom/string)
or a nested :class:`SExp`.
"""
head: str | None
items: list[Token | SExp] = field(default_factory=list)
line: int = 0
def children(self, head: str) -> list[SExp]:
"""Return all direct sub-lists whose head equals ``head``."""
return [it for it in self.items if isinstance(it, SExp) and it.head == head]
def child(self, head: str) -> SExp | None:
"""Return the first direct sub-list with the given head, or ``None``."""
for it in self.items:
if isinstance(it, SExp) and it.head == head:
return it
return None
def values(self) -> list[Token]:
"""Return the leaf-token items (skipping nested lists)."""
return [it for it in self.items if isinstance(it, Token)]
def parse(text: str, quote_chars: str = "\"'") -> SExp:
"""Tokenize and parse ``text``, returning the single top-level S-expression."""
return parse_tokens(tokenize(text, quote_chars))
def parse_tokens(tokens: list[Token]) -> SExp:
"""Parse a token list into one top-level :class:`SExp`.
Raises :class:`DsnSyntaxError` on unbalanced brackets or trailing junk.
"""
pos = 0
n = len(tokens)
def parse_list() -> SExp:
nonlocal pos
open_tok = tokens[pos] # the '('
pos += 1
head: str | None = None
items: list[Token | SExp] = []
# A leading atom is the scope head. Heads are keywords, which the DSN
# grammar matches case-insensitively (JFlex `%ignorecase`), so `(PCB`
# and `(pcb` are the same scope — normalize to lowercase for dispatch.
if pos < n and tokens[pos].kind is TokenKind.ATOM:
head = tokens[pos].text.lower()
pos += 1
while pos < n and tokens[pos].kind is not TokenKind.RPAREN:
tok = tokens[pos]
if tok.kind is TokenKind.LPAREN:
items.append(parse_list())
else:
items.append(tok)
pos += 1
if pos >= n:
raise DsnSyntaxError(f"unbalanced '(' opened at line {open_tok.line}: missing ')'")
pos += 1 # consume ')'
return SExp(head=head, items=items, line=open_tok.line)
# Skip to the first '('.
while pos < n and tokens[pos].kind is not TokenKind.LPAREN:
pos += 1
if pos >= n:
raise DsnSyntaxError("no S-expression found (expected a top-level '(')")
top = parse_list()
# Allow trailing whitespace-only tokens (there are none — tokenizer drops
# whitespace) but reject any further real tokens.
if pos != n:
extra = tokens[pos]
raise DsnSyntaxError(
f"unexpected token '{extra.text}' after top-level list at line {extra.line}"
)
return top

215
src/freeroute/dsn/shapes.py Normal file
View File

@ -0,0 +1,215 @@
"""Specctra DSN shape primitives and their parsing.
Mirrors FreeRouting's ``io/specctra/parser/Shape.java`` grammar:
* ``(rect <layer> x1 y1 x2 y2)``
* ``(circle <layer> diameter [center_x center_y])``
* ``(polygon <layer> aperture_width x1 y1 x2 y2 ...)`` a closed polygon
* ``(path <layer> width x1 y1 x2 y2 ...)`` an open path / trace
An *area* (``Shape.read_area_scope``) is an optional name, a border shape, zero
or more ``(window <shape>)`` holes, and an optional ``(clearance_class name)``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .sexp import SExp
from .tokenizer import Token, TokenKind
__all__ = [
"Shape",
"Rectangle",
"Circle",
"Polygon",
"Path",
"Area",
"read_shape",
"read_area",
]
@dataclass
class Shape:
"""Base class for a DSN shape. ``layer`` is the layer name (or ``pcb`` /
``signal`` for the special layer-spanning pseudo-layers)."""
layer: str
@dataclass
class Rectangle(Shape):
"""Axis-parallel rectangle. ``coords`` is ``[x1, y1, x2, y2]``."""
coords: list[float] = field(default_factory=list)
@dataclass
class Circle(Shape):
"""Circle of the given ``diameter`` centred at ``(center_x, center_y)``."""
diameter: float = 0.0
center_x: float = 0.0
center_y: float = 0.0
@dataclass
class Polygon(Shape):
"""Closed polygon. ``aperture_width`` is the boundary aperture (usually 0).
``coords`` is a flat ``[x1, y1, x2, y2, ...]`` list."""
aperture_width: float = 0.0
coords: list[float] = field(default_factory=list)
@dataclass
class Path(Shape):
"""Open poly-path of the given ``width``. ``coords`` is flat
``[x1, y1, x2, y2, ...]`` this is how traces (wires) are described."""
width: float = 0.0
coords: list[float] = field(default_factory=list)
@dataclass
class Area:
"""A border shape plus optional holes — the result of an area scope.
``name`` and ``clearance_class`` are optional (``None`` when absent), exactly
like FreeRouting's ``ReadAreaScopeResult``.
"""
border: Shape | None
holes: list[Shape] = field(default_factory=list)
name: str | None = None
clearance_class: str | None = None
@property
def layer(self) -> str | None:
return self.border.layer if self.border is not None else None
def _numbers(items: list[Token | SExp]) -> list[float]:
"""Collect the numeric leaf tokens from a list, skipping nested scopes.
FreeRouting's polygon/path readers skip unknown ``( ... )`` sub-scopes that
appear amid the coordinate list, so we drop nested ``SExp`` items here too.
"""
out: list[float] = []
for it in items:
if isinstance(it, Token) and it.is_number:
out.append(float(it.text))
return out
def _layer_name(tok: Token | SExp | None) -> str:
if isinstance(tok, Token):
return tok.text
return "signal"
def read_shape(node: SExp) -> Shape | None:
"""Build a :class:`Shape` from a shape scope such as ``(rect ...)``.
Returns ``None`` for an unrecognised head, matching FreeRouting's tolerant
``Shape.read_scope`` (which skips non-shape scopes).
"""
head = node.head
items = node.items
if not items:
return None
layer = _layer_name(items[0])
rest = items[1:]
if head in ("rect", "rectangle"):
nums = _numbers(rest)
if len(nums) < 4:
return None
return Rectangle(layer=layer, coords=nums[:4])
if head in ("circle", "circ"):
nums = _numbers(rest)
if not nums:
return None
diameter = nums[0]
cx = nums[1] if len(nums) > 1 else 0.0
cy = nums[2] if len(nums) > 2 else 0.0
return Circle(layer=layer, diameter=diameter, center_x=cx, center_y=cy)
if head in ("polygon", "poly"):
nums = _numbers(rest)
if not nums:
return None
return Polygon(layer=layer, aperture_width=nums[0], coords=nums[1:])
if head in ("path", "polygon_path", "polyline_path"):
nums = _numbers(rest)
if len(nums) < 5: # width + at least two points
return None
return Path(layer=layer, width=nums[0], coords=nums[1:])
return None
def read_area(node: SExp) -> Area | None:
"""Build an :class:`Area` from a keepout/plane-style scope.
``node`` is e.g. ``(keepout NAME (rect ...) (window (circle ...))
(clearance_class cls))``. The head has already been consumed by the caller;
``node.items`` is the body. An optional leading string is the area name.
"""
items = node.items
idx = 0
name: str | None = None
if items and isinstance(items[0], Token) and items[0].kind is TokenKind.STRING:
name = items[0].text or None
idx = 1
elif (
items
and isinstance(items[0], Token)
and items[0].kind is TokenKind.ATOM
and not items[0].is_number
):
# An unquoted bareword name (FreeRouting accepts a String here too).
name = items[0].text or None
idx = 1
border: Shape | None = None
holes: list[Shape] = []
clearance_class: str | None = None
for it in items[idx:]:
if not isinstance(it, SExp):
continue
if it.head in (
"rect",
"rectangle",
"circle",
"circ",
"polygon",
"poly",
"path",
"polygon_path",
"polyline_path",
):
shape = read_shape(it)
if border is None:
border = shape
else: # a bare extra shape without a window wrapper
if shape is not None:
holes.append(shape)
elif it.head == "window":
for sub in it.items:
if isinstance(sub, SExp):
hole = read_shape(sub)
if hole is not None:
holes.append(hole)
elif it.head == "clearance_class":
vals = it.values()
if vals:
clearance_class = vals[0].text
if border is None:
return None
return Area(border=border, holes=holes, name=name, clearance_class=clearance_class)

View File

@ -0,0 +1,191 @@
"""Tokenizer for the Specctra DSN/SES S-expression format.
Mirrors the lexical rules in FreeRouting's ``SpecctraFileDescription.flex``
(the human-readable source of the JFlex-generated
``SpecctraDsnStreamReader``). We deliberately do not reproduce the generated
DFA the flex rules are what define the language:
* whitespace is ``\\r \\n \\f \\t`` and space,
* ``#`` starts an end-of-line comment, ``/* ... */`` a block comment,
* ``"`` and ``'`` quote strings; there is no escaping — a backslash inside a
quoted string is a literal backslash and the matching quote ends it,
* ``(`` and ``)`` are their own tokens,
* every other maximal run of characters is an atom.
FreeRouting's lexer flips between lexical states (``NAME``, ``LAYER_NAME`` ...)
so that the *next* atom is read as a string even when it looks like a number
(a net literally named ``0``). We reconcile that stateful behaviour with a
stateless tokenizer by keeping both the raw text and a best-effort numeric
value on every unquoted atom (see :meth:`Token.as_int` / :meth:`Token.as_float`)
and letting the higher-level reader choose which to use.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum, auto
__all__ = ["Token", "TokenKind", "tokenize", "DsnSyntaxError"]
_WHITESPACE = " \t\r\n\f"
_QUOTES = "\"'"
_BRACKETS = "()"
class DsnSyntaxError(ValueError):
"""Raised when the input cannot be tokenized (e.g. an unterminated string)."""
class TokenKind(Enum):
LPAREN = auto()
RPAREN = auto()
ATOM = auto() # unquoted; may be a number or a bareword
STRING = auto() # quoted; always a string
@dataclass(frozen=True)
class Token:
"""A single lexical token.
``text`` is the literal characters (without surrounding quotes for a
:attr:`TokenKind.STRING`). ``line`` is 1-based for error messages.
"""
kind: TokenKind
text: str
line: int
@property
def is_value(self) -> bool:
"""True for atoms and strings — anything that is not a bracket."""
return self.kind in (TokenKind.ATOM, TokenKind.STRING)
def as_int(self) -> int | None:
"""Return the token as an int, or ``None`` if it is not an integer atom."""
if self.kind is not TokenKind.ATOM:
return None
try:
return int(self.text)
except ValueError:
return None
def as_float(self) -> float | None:
"""Return the token as a float, or ``None`` if it is not a numeric atom.
Accepts integer atoms too, matching FreeRouting's ``read_float_scope``
which takes either an ``Integer`` or a ``Double``.
"""
if self.kind is not TokenKind.ATOM:
return None
try:
return float(self.text)
except ValueError:
return None
@property
def is_number(self) -> bool:
return self.as_float() is not None
def tokenize(text: str, quote_chars: str = _QUOTES) -> list[Token]:
"""Tokenize DSN/SES ``text`` into a flat list of :class:`Token`.
``quote_chars`` is the set of characters that open/close a quoted string.
It defaults to both ``"`` and ``'``; a reader that has seen a
``(string_quote ...)`` directive can retokenize with just that character,
but in practice KiCad always uses ``"`` and both are safe defaults.
"""
tokens: list[Token] = []
i = 0
n = len(text)
line = 1
# Mirrors FreeRouting's IGNORE_QUOTE lexical state: after the
# `string_quote` keyword the following value is read with quote characters
# treated as ordinary atom characters, so `(string_quote ")` yields the
# atom `"` rather than opening an unterminated string.
ignore_quote_next = False
while i < n:
ch = text[i]
if ch == "\n":
line += 1
i += 1
continue
if ch in _WHITESPACE:
i += 1
continue
# Comments -----------------------------------------------------------
# `#` starts an end-of-line comment only when followed by whitespace
# (the conventional `# text` form). A `#` glued to a name — e.g. the
# net `#WLTXD` or pin `#22` — is a name character: FreeRouting reads
# those in its NAME lexical state where `#` is not a comment, and its
# `#` is also a valid identifier character (SpecCharASCII).
if ch == "#" and (i + 1 >= n or text[i + 1] in _WHITESPACE):
eol = text.find("\n", i)
i = n if eol == -1 else eol
continue
if ch == "/" and i + 1 < n and text[i + 1] == "*": # block comment
end = text.find("*/", i + 2)
if end == -1:
raise DsnSyntaxError(f"unterminated /* */ comment at line {line}")
line += text.count("\n", i, end)
i = end + 2
continue
# Brackets -----------------------------------------------------------
if ch == "(":
tokens.append(Token(TokenKind.LPAREN, "(", line))
i += 1
continue
if ch == ")":
tokens.append(Token(TokenKind.RPAREN, ")", line))
i += 1
continue
# Quoted string ------------------------------------------------------
if ch in quote_chars and not ignore_quote_next:
start_line = line
j = i + 1
buf: list[str] = []
while j < n and text[j] != ch:
if text[j] == "\n":
line += 1
buf.append(text[j])
j += 1
if j >= n:
raise DsnSyntaxError(f"unterminated string starting at line {start_line}")
tokens.append(Token(TokenKind.STRING, "".join(buf), start_line))
i = j + 1
continue
# Bareword atom ------------------------------------------------------
# A bareword runs to the next whitespace, bracket, or quote character.
# Stopping at quotes matters for `space_in_quoted_tokens` pin refs such
# as `"comp"-"pin with spaces"`: the second quoted segment must be read
# as its own string, otherwise a `#` inside it would start a spurious
# end-of-line comment and swallow the closing brackets. In
# ignore-quote mode (right after `string_quote`) quotes are ordinary
# characters, so `(string_quote ")` yields the atom `"`.
j = i
if ignore_quote_next:
while j < n and text[j] not in _WHITESPACE and text[j] not in _BRACKETS:
j += 1
else:
while (
j < n
and text[j] not in _WHITESPACE
and text[j] not in _BRACKETS
and text[j] not in quote_chars
):
j += 1
atom = text[i:j]
tokens.append(Token(TokenKind.ATOM, atom, line))
i = j
if ignore_quote_next:
ignore_quote_next = False
elif atom.lower() == "string_quote":
ignore_quote_next = True
return tokens

View File

@ -0,0 +1,19 @@
# DSN test fixtures
Hand-crafted minimal Specctra DSN files, written to exercise the parser. They
are modeled on the real fixtures shipped in the upstream FreeRouting repo
(`fixtures/empty_board.dsn` and `fixtures/SMD-routing-issue-demo.dsn`) so the
grammar and formatting match what `kicad-cli pcb export specctra-dsn` and
FreeRouting itself produce, but they were typed fresh here (no upstream file is
copied into this tree).
- `empty_board.dsn` — the smallest valid board: parser, resolution, unit, and a
structure with two signal layers and a `pcb` boundary path. Modeled on
FreeRouting `fixtures/empty_board.dsn`.
- `smd_demo.dsn` — a fuller board exercising every scope the parser handles:
quoted board/padstack/image names, `via`, default `rule` (width + clearance
with a `(type smd_smd)` pair), a `keepout`, padstacks (rect and circle pads),
images with pins, placement with `front`/rotation, nets with `Comp-Pin`
references, and a `class` with `circuit`/`use_via` and a `rule`. Modeled on
FreeRouting `fixtures/SMD-routing-issue-demo.dsn`.
</content>

View File

@ -0,0 +1,26 @@
(pcb freeroute-empty.dsn
(parser
(string_quote ")
(space_in_quoted_tokens on)
)
(resolution um 10)
(unit um)
(structure
(layer F.Cu
(type signal)
(property
(index 0)
)
)
(layer B.Cu
(type signal)
(property
(index 1)
)
)
(boundary
(path pcb 0 186690 -107950 129540 -107950 129540 -57150 186690 -57150
186690 -107950)
)
)
)

View File

@ -0,0 +1,84 @@
(pcb "smd-demo.dsn"
(parser
(string_quote ")
(space_in_quoted_tokens on)
(host_cad "freeroute-test")
(host_version "1.0")
)
(resolution um 10)
(unit um)
(structure
(layer Top
(type signal)
(property
(index 0)
)
)
(layer Bottom
(type signal)
(property
(index 1)
)
)
(boundary
(path pcb 0 0 0 150000 0 150000 -90000 0 -90000 0 0)
)
(via "Via[0-1]_600:300_um")
(rule
(width 200)
(clearance 200)
(clearance 50 (type smd_smd))
)
(keepout "no_route_zone"
(rect Top 10000 -10000 20000 -20000)
)
)
(library
(padstack Rect[T]Pad_800x200_um
(shape (rect Top -400 -100 400 100))
(attach off)
)
(padstack "Via[0-1]_600:300_um"
(shape (circle Top 600))
(shape (circle Bottom 600))
(attach off)
)
(image "MiniQFN-6"
(pin Rect[T]Pad_800x200_um 1 -3000 400)
(pin Rect[T]Pad_800x200_um 2 -3000 0)
(pin Rect[T]Pad_800x200_um 3 -3000 -400)
)
(image "0603"
(pin Rect[T]Pad_800x200_um 1 -850 0)
(pin Rect[T]Pad_800x200_um 2 850 0)
)
)
(placement
(component "MiniQFN-6"
(place U1 75000 -45000 front 90.000000)
)
(component "0603"
(place R1 20000 -35000 front 0.000000)
(place R2 130000 -35000 back 180.000000)
)
)
(network
(net NET_A
(pins U1-1 R2-2)
)
(net NET_B
(pins U1-2 R1-1)
)
(class default
(circuit
(use_via "Via[0-1]_600:300_um")
)
(rule
(width 200)
(clearance 200)
)
)
)
(wiring
)
)

158
tests/dsn/test_reader.py Normal file
View File

@ -0,0 +1,158 @@
"""End-to-end tests for parse_dsn against fixture files and inline snippets."""
from __future__ import annotations
from pathlib import Path
import pytest
from freeroute.dsn import parse_dsn
from freeroute.dsn.reader import DsnParseError
from freeroute.dsn.shapes import Circle, Rectangle
from freeroute.dsn.shapes import Path as DsnPath
FIXTURES = Path(__file__).parent / "fixtures"
def load(name: str) -> str:
return (FIXTURES / name).read_text()
# --- empty_board.dsn ---------------------------------------------------------
def test_empty_board_header_and_resolution():
board = parse_dsn(load("empty_board.dsn"))
assert board.name == "freeroute-empty.dsn"
assert board.parser.string_quote == '"'
assert board.resolution.unit == "um"
assert board.resolution.value == 10
assert board.unit == "um"
def test_empty_board_layers():
board = parse_dsn(load("empty_board.dsn"))
assert board.layer_names() == ["F.Cu", "B.Cu"]
assert all(layer.is_signal for layer in board.layers)
assert [layer.index for layer in board.layers] == [0, 1]
def test_empty_board_boundary_is_pcb_path():
board = parse_dsn(load("empty_board.dsn"))
assert isinstance(board.boundary, DsnPath)
assert board.boundary.layer == "pcb"
# closed rectangle: 5 points (10 coords), width prefix stripped
assert len(board.boundary.coords) == 10
assert board.outlines == []
# --- smd_demo.dsn ------------------------------------------------------------
@pytest.fixture
def smd():
return parse_dsn(load("smd_demo.dsn"))
def test_smd_parser_scope(smd):
assert smd.name == "smd-demo.dsn"
assert smd.parser.host_cad == "freeroute-test"
assert smd.parser.host_version == "1.0"
def test_smd_structure_via_and_rules(smd):
assert smd.via_padstack_names == ["Via[0-1]_600:300_um"]
widths = [r.value for r in smd.structure_rules.width_rules]
assert widths == [200.0]
clearances = smd.structure_rules.clearance_rules
assert clearances[0].value == 200.0
assert clearances[0].class_pairs == []
assert clearances[1].value == 50.0
assert clearances[1].class_pairs == ["smd_smd"]
def test_smd_keepout(smd):
assert len(smd.keepouts) == 1
ko = smd.keepouts[0]
assert ko.kind == "keepout"
assert ko.area.name == "no_route_zone"
assert isinstance(ko.area.border, Rectangle)
def test_smd_padstacks(smd):
assert [p.name for p in smd.padstacks] == [
"Rect[T]Pad_800x200_um",
"Via[0-1]_600:300_um",
]
rect_pad = smd.padstack("Rect[T]Pad_800x200_um")
assert isinstance(rect_pad.shapes[0], Rectangle)
assert rect_pad.attach_allowed is False
via_pad = smd.padstack("Via[0-1]_600:300_um")
assert len(via_pad.shapes) == 2
assert all(isinstance(s, Circle) for s in via_pad.shapes)
assert [s.layer for s in via_pad.shapes] == ["Top", "Bottom"]
def test_smd_images_and_pins(smd):
assert [img.name for img in smd.images] == ["MiniQFN-6", "0603"]
qfn = smd.images[0]
assert qfn.is_front is True
assert len(qfn.pins) == 3
pin1 = qfn.pins[0]
assert pin1.name == "1"
assert pin1.padstack_name == "Rect[T]Pad_800x200_um"
assert (pin1.x, pin1.y) == (-3000.0, 400.0)
def test_smd_placement(smd):
comps = {p.lib_name: p for p in smd.placements}
assert set(comps) == {"MiniQFN-6", "0603"}
u1 = comps["MiniQFN-6"].places[0]
assert u1.name == "U1"
assert (u1.x, u1.y) == (75000.0, -45000.0)
assert u1.is_front is True
assert u1.rotation == 90.0
r2 = comps["0603"].places[1]
assert r2.name == "R2"
assert r2.is_front is False
assert r2.rotation == 180.0
def test_smd_nets_and_pin_refs(smd):
assert [n.name for n in smd.nets] == ["NET_A", "NET_B"]
net_a = smd.net("NET_A")
assert [(p.component, p.pin) for p in net_a.pins] == [("U1", "1"), ("R2", "2")]
def test_smd_net_class(smd):
assert len(smd.net_classes) == 1
cls = smd.net_classes[0]
assert cls.name == "default"
assert cls.use_via == ["Via[0-1]_600:300_um"]
assert [r.value for r in cls.width_rules] == [200.0]
# --- error handling ----------------------------------------------------------
def test_non_pcb_top_scope_raises():
with pytest.raises(DsnParseError):
parse_dsn("(session foo)")
def test_pin_ref_splits_on_first_hyphen():
dsn = "(pcb b (network (net N (pins A-B-C X-1))))"
board = parse_dsn(dsn)
pins = board.net("N").pins
assert (pins[0].component, pins[0].pin) == ("A", "B-C")
assert (pins[1].component, pins[1].pin) == ("X", "1")
def test_unplaced_component_has_no_coords():
dsn = '(pcb b (placement (component "LIB" (place U9))))'
board = parse_dsn(dsn)
place = board.placements[0].places[0]
assert place.name == "U9"
assert place.x is None and place.y is None

60
tests/dsn/test_sexp.py Normal file
View File

@ -0,0 +1,60 @@
"""Tests for the S-expression tree parser."""
from __future__ import annotations
import pytest
from freeroute.dsn.sexp import SExp, parse
from freeroute.dsn.tokenizer import DsnSyntaxError, Token
def test_parse_simple_head_and_values():
tree = parse("(resolution um 10)")
assert tree.head == "resolution"
vals = tree.values()
assert [v.text for v in vals] == ["um", "10"]
def test_nested_children():
tree = parse("(structure (layer Top (type signal)) (layer Bot (type signal)))")
assert tree.head == "structure"
layers = tree.children("layer")
assert len(layers) == 2
assert isinstance(layers[0], SExp)
assert layers[0].values()[0].text == "Top"
type_scope = layers[0].child("type")
assert type_scope is not None
assert type_scope.values()[0].text == "signal"
def test_child_missing_returns_none():
tree = parse("(a (b 1))")
assert tree.child("nope") is None
def test_values_skips_nested_lists():
tree = parse("(net NET_A (pins U1-1 R2-2))")
assert [v.text for v in tree.values()] == ["NET_A"]
pins = tree.child("pins")
assert pins is not None
assert [v.text for v in pins.values()] == ["U1-1", "R2-2"]
def test_leaf_items_are_tokens():
tree = parse("(a 1 2)")
assert all(isinstance(it, Token) for it in tree.items)
def test_unbalanced_raises():
with pytest.raises(DsnSyntaxError):
parse("(a (b )")
def test_trailing_token_raises():
with pytest.raises(DsnSyntaxError):
parse("(a) (b)")
def test_no_toplevel_list_raises():
with pytest.raises(DsnSyntaxError):
parse("just atoms no parens")

74
tests/dsn/test_shapes.py Normal file
View File

@ -0,0 +1,74 @@
"""Tests for DSN shape parsing."""
from __future__ import annotations
from freeroute.dsn.sexp import parse
from freeroute.dsn.shapes import (
Circle,
Path,
Polygon,
Rectangle,
read_area,
read_shape,
)
def shape_of(text: str):
return read_shape(parse(text))
def test_rectangle():
rect = shape_of("(rect Top -400 -100 400 100)")
assert isinstance(rect, Rectangle)
assert rect.layer == "Top"
assert rect.coords == [-400.0, -100.0, 400.0, 100.0]
def test_circle_with_center():
circ = shape_of("(circle Bottom 600 10 20)")
assert isinstance(circ, Circle)
assert circ.diameter == 600.0
assert (circ.center_x, circ.center_y) == (10.0, 20.0)
def test_circle_without_center_defaults_to_origin():
circ = shape_of("(circle Top 600)")
assert isinstance(circ, Circle)
assert (circ.center_x, circ.center_y) == (0.0, 0.0)
def test_polygon():
poly = shape_of("(polygon signal 0 0 0 100 0 100 100 0 100)")
assert isinstance(poly, Polygon)
assert poly.aperture_width == 0.0
assert poly.coords == [0.0, 0.0, 100.0, 0.0, 100.0, 100.0, 0.0, 100.0]
def test_path_is_width_plus_points():
path = shape_of("(path pcb 0 0 0 150000 0 150000 -90000)")
assert isinstance(path, Path)
assert path.width == 0.0
assert path.coords == [0.0, 0.0, 150000.0, 0.0, 150000.0, -90000.0]
def test_unknown_shape_returns_none():
assert shape_of("(mystery Top 1 2)") is None
def test_area_border_name_and_clearance():
area = read_area(parse('(keepout "kz" (rect Top 0 0 10 10) (clearance_class special))'))
assert area is not None
assert area.name == "kz"
assert isinstance(area.border, Rectangle)
assert area.clearance_class == "special"
assert area.holes == []
def test_area_with_window_hole():
area = read_area(
parse("(keepout (polygon Top 0 0 0 0 100 100 100 100 0) (window (rect Top 10 10 20 20)))")
)
assert area is not None
assert isinstance(area.border, Polygon)
assert len(area.holes) == 1
assert isinstance(area.holes[0], Rectangle)

126
tests/dsn/test_tokenizer.py Normal file
View File

@ -0,0 +1,126 @@
"""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"]