From bb75dc50670615430d6e03a7c96edd85a8a8b031 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 28 Sep 2026 09:41:27 -0600 Subject: [PATCH] DSN reader: only the declared string_quote character opens a string KiCad declares " and writes a keycap part number as a bare ', which the tokenizer took for an unterminated string (PCBench f.60_keyboard). --- src/freeroute/dsn/reader.py | 18 +++++++++++++++++- tests/dsn/test_reader.py | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/freeroute/dsn/reader.py b/src/freeroute/dsn/reader.py index c9ad30f..356ac10 100644 --- a/src/freeroute/dsn/reader.py +++ b/src/freeroute/dsn/reader.py @@ -9,6 +9,8 @@ tolerant ``skip_scope`` behaviour. from __future__ import annotations +import re + from .model import ( ClearanceRule, ComponentPlace, @@ -481,12 +483,26 @@ def _read_network(node: SExp, board: DsnBoard) -> None: # --- top level --------------------------------------------------------------- +_STRING_QUOTE = re.compile(r"\(\s*string_quote\s+(\S)\s*\)") + + +def _declared_quote(text: str) -> str: + """The quote character the file declares in ``(string_quote X)``. + + Only that character opens a string. Without a declaration both kinds are + accepted, as before: KiCad declares ``"`` and then writes a bare ``'`` + where a value is a single quote mark, e.g. a keycap part number. + """ + found = _STRING_QUOTE.search(text[:4096]) + return found.group(1) if found else "\"'" + + 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) + top = parse(text, _declared_quote(text)) if top.head != "pcb": raise DsnParseError( f"not a Specctra DSN file: expected top-level '(pcb ...)', got '({top.head} ...)'" diff --git a/tests/dsn/test_reader.py b/tests/dsn/test_reader.py index e2f2b31..3574f96 100644 --- a/tests/dsn/test_reader.py +++ b/tests/dsn/test_reader.py @@ -156,3 +156,26 @@ def test_unplaced_component_has_no_coords(): place = board.placements[0].places[0] assert place.name == "U9" assert place.x is None and place.y is None + + +# --- string_quote ---------------------------------------------------------- + +_QUOTED = """(pcb q.dsn + (parser (string_quote {q}) (space_in_quoted_tokens on)) + (resolution um 10) (unit um) + (structure (layer F.Cu (type signal)) (boundary (path pcb 0 0 0 1000 0 1000 1000 0 1000 0 0))) + (placement (component "SW1" (place SW1 100 100 front 0 (PN {pn})))) + (library) + (network) +)""" + + +def test_a_bare_apostrophe_is_an_atom_when_the_file_quotes_with_double_quotes(): + # KiCad declares " and writes a keycap's part number as a bare ' + board = parse_dsn(_QUOTED.format(q='"', pn="'")) + assert board.parser.string_quote == '"' + + +def test_the_declared_quote_character_is_the_one_that_opens_strings(): + board = parse_dsn(_QUOTED.format(q="'", pn="'10k 1%'").replace('"SW1"', "'SW1'")) + assert board.parser.string_quote == "'"