0
mirror of https://github.com/bbenchoff/OrthoRoute.git synced 2026-09-12 03:09:17 +00:00
Files
Pratheek Balakrishna 45ae66707c feat: parse .kicad_pcb on a real s-expression parser (KiCad 5-10)
The regex loader returned 0 nets/0 pads on KiCad 8/9/10 files (no net
table; references in (property "Reference" ...)), so loading a board
from a file was broken. Replace it with a dependency-free s-expression
parser (sexpr.py) and a structural extractor (file_parser.py) handling
every dialect through KiCad 10 (format 20260206): name-only net
synthesis, footprint rotation and back-side pad transform (verified
pad-exact vs pcbnew 10.0.4), Edge.Cuts excluded from the copper count,
design rules merged from the sibling .kicad_pro. Tests use small
self-contained synthetic boards (both dialects). Also: pad_escape_planner
drill/drill_size fallback and rich_kicad_interface kipy import fix.

Re-include tests/*.py (the .gitignore blocked the suite).

Signed-off-by: Pratheek Balakrishna <pratheekb96@gmail.com>
2026-07-02 20:56:57 +02:00

262 lines
7.9 KiB
Python

"""Minimal s-expression tokenizer/parser for KiCad board files.
KiCad `.kicad_pcb` files are s-expressions: nested parenthesized lists of
atoms and double-quoted strings (with backslash escapes). The legacy regex
extractors in ``file_parser.py`` broke on every format revision; this module
replaces them with a single balanced-paren parser that works for every
dialect from KiCad 5 through KiCad 10.
Two independent entry points:
- :func:`parse` / :func:`parse_file` — full structural parse into nested
Python lists (used by the KiCad-10-capable file parser).
- :func:`strip_top_level_nodes` — byte-exact removal of selected top-level
nodes from the raw text without reserialization (used to produce stripped
test fixtures whose untouched content is byte-identical to the original).
This module must stay Python 3.9 compatible: it is imported by plugin
runtime code and macOS KiCad bundles Python 3.9.
"""
from typing import List, Optional, Tuple, Union
# A parsed node is a list whose first element is usually the node name atom;
# leaves are strings (atoms and unescaped quoted strings are not
# distinguished — KiCad consumers never need the distinction).
SExpr = Union[str, List["SExpr"]]
_ESCAPES = {
'"': '"',
"\\": "\\",
"n": "\n",
"t": "\t",
"r": "\r",
}
class SExprError(ValueError):
"""Raised on malformed s-expression input (unbalanced parens, EOF in string)."""
def parse(text: str) -> List[SExpr]:
"""Parse s-expression text into a list of top-level nodes.
Args:
text: Full file contents.
Returns:
List of top-level expressions (a .kicad_pcb has exactly one:
the ``kicad_pcb`` node).
Raises:
SExprError: On unbalanced parentheses or an unterminated string.
"""
top: List[SExpr] = []
stack: List[List[SExpr]] = []
i = 0
n = len(text)
while i < n:
c = text[i]
if c in " \t\r\n":
i += 1
elif c == "(":
node: List[SExpr] = []
if stack:
stack[-1].append(node)
else:
top.append(node)
stack.append(node)
i += 1
elif c == ")":
if not stack:
raise SExprError(f"Unbalanced ')' at offset {i}")
stack.pop()
i += 1
elif c == '"':
value, i = _read_string(text, i)
if stack:
stack[-1].append(value)
else:
top.append(value)
else:
j = i
while j < n and text[j] not in ' \t\r\n()"':
j += 1
atom = text[i:j]
if stack:
stack[-1].append(atom)
else:
top.append(atom)
i = j
if stack:
raise SExprError(f"Unbalanced '(': {len(stack)} unclosed at EOF")
return top
def parse_file(path: str) -> List[SExpr]:
"""Parse a file; see :func:`parse`."""
with open(path, "r", encoding="utf-8") as f:
return parse(f.read())
def _read_string(text: str, i: int) -> Tuple[str, int]:
"""Read a quoted string starting at ``text[i] == '"'``.
Returns (unescaped value, index just past the closing quote).
"""
n = len(text)
i += 1 # opening quote
out: List[str] = []
while i < n:
c = text[i]
if c == "\\" and i + 1 < n:
nxt = text[i + 1]
out.append(_ESCAPES.get(nxt, nxt))
i += 2
elif c == '"':
return "".join(out), i + 1
else:
out.append(c)
i += 1
raise SExprError("Unterminated string at EOF")
# ---------------------------------------------------------------------------
# Structural helpers for extractors
# ---------------------------------------------------------------------------
def node_name(node: SExpr) -> Optional[str]:
"""Return the name atom of a node, or None for leaves/empty nodes."""
if isinstance(node, list) and node and isinstance(node[0], str):
return node[0]
return None
def children(node: SExpr, name: str) -> List[List[SExpr]]:
"""All direct child nodes of ``node`` named ``name``."""
if not isinstance(node, list):
return []
return [c for c in node[1:] if isinstance(c, list) and c and c[0] == name]
def child(node: SExpr, name: str) -> Optional[List[SExpr]]:
"""First direct child node named ``name``, or None."""
found = children(node, name)
return found[0] if found else None
def atoms(node: SExpr) -> List[str]:
"""Leaf values (atoms/strings) of a node, excluding the name atom."""
if not isinstance(node, list):
return []
return [c for c in node[1:] if isinstance(c, str)]
def first_atom(node: Optional[SExpr]) -> Optional[str]:
"""First leaf value of a node, or None."""
if node is None:
return None
vals = atoms(node)
return vals[0] if vals else None
# ---------------------------------------------------------------------------
# Byte-exact top-level node stripping (fixture generation)
# ---------------------------------------------------------------------------
def find_top_level_spans(text: str, names: Tuple[str, ...]) -> List[Tuple[int, int]]:
"""Find byte spans of depth-1 nodes named in ``names``.
Depth 1 means direct children of the single root ``(kicad_pcb ...)``
node — where KiCad keeps segments, vias, zones, and footprints.
Each span covers the node's opening ``(`` through its closing ``)``,
widened to include the preceding same-line indentation and the trailing
newline, so removal deletes whole lines without leaving blanks.
"""
spans: List[Tuple[int, int]] = []
depth = 0
i = 0
n = len(text)
while i < n:
c = text[i]
if c == '"':
_, i = _read_string(text, i)
elif c == "(":
if depth == 1:
j = i + 1
while j < n and text[j] not in ' \t\r\n()"':
j += 1
if text[i + 1:j] in names:
end = _scan_node_end(text, i)
start = i
while start > 0 and text[start - 1] in " \t":
start -= 1
if end < n and text[end] == "\r":
end += 1
if end < n and text[end] == "\n":
end += 1
spans.append((start, end))
i = end
continue
depth += 1
i += 1
elif c == ")":
depth -= 1
if depth < 0:
raise SExprError(f"Unbalanced ')' at offset {i}")
i += 1
else:
i += 1
if depth != 0:
raise SExprError(f"Unbalanced '(': depth {depth} at EOF")
return spans
def _scan_node_end(text: str, start: int) -> int:
"""Given ``text[start] == '('``, return index just past the matching ')'."""
depth = 0
i = start
n = len(text)
while i < n:
c = text[i]
if c == '"':
_, i = _read_string(text, i)
elif c == "(":
depth += 1
i += 1
elif c == ")":
depth -= 1
i += 1
if depth == 0:
return i
else:
i += 1
raise SExprError("Unbalanced '(' while scanning node")
def strip_top_level_nodes(text: str, names: Tuple[str, ...]) -> Tuple[str, int]:
"""Remove all depth-1 nodes named in ``names`` from raw file text.
Untouched content is preserved byte-for-byte (no reserialization), so
stripping is deterministic and re-runnable: stripping an already
stripped file is a no-op.
Returns:
(stripped text, number of nodes removed)
"""
spans = find_top_level_spans(text, names)
if not spans:
return text, 0
out: List[str] = []
prev = 0
for start, end in spans:
out.append(text[prev:start])
prev = end
out.append(text[prev:])
return "".join(out), len(spans)