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:
parent
dc44abc7f7
commit
3bf4f6bed2
69
src/freeroute/dsn/__init__.py
Normal file
69
src/freeroute/dsn/__init__.py
Normal 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
218
src/freeroute/dsn/model.py
Normal 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
506
src/freeroute/dsn/reader.py
Normal 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
101
src/freeroute/dsn/sexp.py
Normal 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
215
src/freeroute/dsn/shapes.py
Normal 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)
|
||||
191
src/freeroute/dsn/tokenizer.py
Normal file
191
src/freeroute/dsn/tokenizer.py
Normal 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
|
||||
19
tests/dsn/fixtures/README.md
Normal file
19
tests/dsn/fixtures/README.md
Normal 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>
|
||||
26
tests/dsn/fixtures/empty_board.dsn
Normal file
26
tests/dsn/fixtures/empty_board.dsn
Normal 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)
|
||||
)
|
||||
)
|
||||
)
|
||||
84
tests/dsn/fixtures/smd_demo.dsn
Normal file
84
tests/dsn/fixtures/smd_demo.dsn
Normal 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
158
tests/dsn/test_reader.py
Normal 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
60
tests/dsn/test_sexp.py
Normal 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
74
tests/dsn/test_shapes.py
Normal 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
126
tests/dsn/test_tokenizer.py
Normal 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"]
|
||||
Loading…
x
Reference in New Issue
Block a user