app
Adom Desktop - KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
"""Lightweight S-expression tokenizer and tree builder for KiCad files.
KiCad uses S-expressions for all file formats (.kicad_sch, .kicad_pcb,
.kicad_sym, sym-lib-table, etc.). This module provides a generic parser
that turns the text into nested Python lists, plus helpers for querying
the resulting tree.
Example:
>>> tree = parse('(kicad_sch (version 20250114) (generator "eeschema"))')
>>> tree
['kicad_sch', ['version', '20250114'], ['generator', 'eeschema']]
>>> find_node(tree, 'version')
['version', '20250114']
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Union
# A parsed node is either a string (atom) or a list of nodes.
Node = Union[str, list]
# Regex that matches one token at a time:
# - quoted string (captures content without quotes)
# - open/close paren
# - unquoted atom (anything that isn't whitespace or parens)
_TOKEN_RE = re.compile(r'"((?:[^"\\]|\\.)*)"|(\()|(\))|([^\s()]+)')
def tokenize(text: str):
"""Yield tokens from S-expression text.
Tokens are: '(', ')', or string values (with quotes stripped).
"""
for m in _TOKEN_RE.finditer(text):
quoted, lparen, rparen, atom = m.groups()
if lparen:
yield "("
elif rparen:
yield ")"
elif quoted is not None:
# Unescape backslash-escaped characters inside quoted strings
yield quoted.replace('\\"', '"').replace("\\\\", "\\")
else:
yield atom
def parse(text: str) -> Node:
"""Parse S-expression text into a nested list structure.
Returns the first top-level expression found.
Raises ValueError if the text is malformed.
"""
tokens = list(tokenize(text))
if not tokens:
raise ValueError("Empty S-expression")
result, _ = _parse_expr(tokens, 0)
return result
def parse_all(text: str) -> list[Node]:
"""Parse all top-level S-expressions from text."""
tokens = list(tokenize(text))
results = []
pos = 0
while pos < len(tokens):
if tokens[pos] == "(":
node, pos = _parse_expr(tokens, pos)
results.append(node)
else:
pos += 1
return results
def _parse_expr(tokens: list[str], pos: int) -> tuple[Node, int]:
"""Parse one expression starting at `pos`. Returns (node, new_pos)."""
if tokens[pos] == "(":
pos += 1 # skip '('
children = []
while pos < len(tokens) and tokens[pos] != ")":
child, pos = _parse_expr(tokens, pos)
children.append(child)
if pos < len(tokens):
pos += 1 # skip ')'
return children, pos
else:
return tokens[pos], pos + 1
def parse_file(filepath: str | Path) -> Node:
"""Parse a KiCad file and return the top-level S-expression tree."""
text = Path(filepath).read_text(encoding="utf-8")
return parse(text)
# ── Tree query helpers ──────────────────────────────────────────────
def find_node(tree: list, tag: str) -> list | None:
"""Find the first direct child node with the given tag name.
A "node" is a list whose first element is a string matching `tag`.
Returns None if not found.
"""
if not isinstance(tree, list):
return None
for child in tree:
if isinstance(child, list) and child and child[0] == tag:
return child
return None
def find_nodes(tree: list, tag: str) -> list[list]:
"""Find all direct child nodes with the given tag name."""
if not isinstance(tree, list):
return []
return [c for c in tree if isinstance(c, list) and c and c[0] == tag]
def find_node_recursive(tree: list, tag: str) -> list | None:
"""Find the first node with the given tag anywhere in the tree (DFS)."""
if not isinstance(tree, list):
return None
if tree and tree[0] == tag:
return tree
for child in tree:
if isinstance(child, list):
result = find_node_recursive(child, tag)
if result is not None:
return result
return None
def find_nodes_recursive(tree: list, tag: str) -> list[list]:
"""Find all nodes with the given tag anywhere in the tree (DFS)."""
results = []
_find_nodes_recursive_impl(tree, tag, results)
return results
def _find_nodes_recursive_impl(tree, tag, results):
if not isinstance(tree, list):
return
if tree and tree[0] == tag:
results.append(tree)
for child in tree:
if isinstance(child, list):
_find_nodes_recursive_impl(child, tag, results)
def node_value(node: list | None) -> str | None:
"""Get the second element of a node as a string, or None."""
if node and len(node) >= 2 and isinstance(node[1], str):
return node[1]
return None
def node_values(node: list | None) -> list[str]:
"""Get all string elements after the tag name."""
if not node:
return []
return [v for v in node[1:] if isinstance(v, str)]