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:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user