"""Unified symbol library resolution for KiCad.
Given a lib_id like ``"Device:R"``, finds the ``.kicad_sym`` file and
extracts the full symbol sexp tree. Reads both global and project
``sym-lib-table`` files, resolving KiCad path variables.
This module is the eventual replacement for the ad-hoc library search
in sexp_parser.py, providing proper sym-lib-table parsing with variable
substitution and mtime-based cache invalidation.
"""
from __future__ import annotations
import logging
import os
from typing import Any
from mckicad.config import get_kicad_user_dir
from mckicad.utils.sexp_tree import (
SexpNode,
find,
find_named,
find_one,
find_recursive,
get_values,
parse,
parse_file,
text_at,
)
logger = logging.getLogger(__name__)
# Cache: path -> (mtime, parsed table dict)
_lib_table_cache: dict[str, tuple[float, dict[str, str]]] = {}
def resolve_symbol(
lib_id: str, *, project_dir: str | None = None,
) -> SexpNode | None:
"""Resolve a lib_id to its parsed symbol node.
Args:
lib_id: Full library identifier (e.g. ``"Device:R"``).
project_dir: Project directory for ``${KIPRJMOD}`` substitution.
Returns:
The parsed SexpNode for the symbol, or None if not found.
"""
if ":" not in lib_id:
return None
library_name, symbol_name = lib_id.split(":", 1)
if not library_name or not symbol_name:
return None
lib_path = resolve_library_path(library_name, project_dir=project_dir)
if lib_path is None:
return None
try:
tree = parse_file(lib_path)
except (OSError, UnicodeDecodeError):
logger.debug("Failed to parse library file: %s", lib_path)
return None
return find_named(tree, "symbol", symbol_name)
def resolve_symbol_with_tree(
lib_id: str, *, project_dir: str | None = None,
) -> tuple[SexpNode | None, SexpNode | None]:
"""Resolve a lib_id, returning both the symbol node and its library tree.
The library tree is needed for ``extract_symbol_pins`` and
``extract_pin_unit_map`` when the symbol uses ``(extends ...)``
to inherit pin geometry from a parent symbol.
Returns:
``(symbol_node, lib_tree)`` tuple. Both are None if resolution fails.
"""
if ":" not in lib_id:
return None, None
library_name, symbol_name = lib_id.split(":", 1)
if not library_name or not symbol_name:
return None, None
lib_path = resolve_library_path(library_name, project_dir=project_dir)
if lib_path is None:
return None, None
try:
tree = parse_file(lib_path)
except (OSError, UnicodeDecodeError):
logger.debug("Failed to parse library file: %s", lib_path)
return None, None
sym = find_named(tree, "symbol", symbol_name)
return sym, tree
def resolve_library_path(
library_name: str, *, project_dir: str | None = None,
) -> str | None:
"""Resolve a library name to its ``.kicad_sym`` file path.
Search order:
1. Project sym-lib-table (if project_dir given)
2. Global sym-lib-table (KiCad 10, 9, 8, 7)
3. Direct file search (project dir, libs/, ../libs/)
4. System symbol directory (built-in libraries like Device)
"""
filename = f"{library_name}.kicad_sym"
# 1. Project sym-lib-table
if project_dir:
table_path = os.path.join(project_dir, "sym-lib-table")
if os.path.isfile(table_path):
table = load_sym_lib_table(table_path)
if library_name in table:
uri = _substitute_variables(table[library_name], project_dir)
if os.path.isfile(uri):
return uri
# 2. Global sym-lib-tables (newest KiCad first)
for version_dir in ("10.0", "9.0", "8.0", "7.0"):
config_dir = os.path.expanduser(f"~/.config/kicad/{version_dir}")
table_path = os.path.join(config_dir, "sym-lib-table")
if os.path.isfile(table_path):
table = load_sym_lib_table(table_path)
if library_name in table:
uri = _substitute_variables(table[library_name], project_dir)
if os.path.isfile(uri):
return uri
# 3. Direct file search (fallback for projects without sym-lib-table)
if project_dir:
candidates = [
os.path.join(project_dir, filename),
os.path.join(project_dir, "libs", filename),
os.path.join(project_dir, os.pardir, "libs", filename),
]
for candidate in candidates:
resolved = os.path.normpath(candidate)
if os.path.isfile(resolved):
return resolved
# 4. System symbol directory (built-in libraries, e.g. Device, Connector).
# This is the authoritative source when no sym-lib-table is configured.
symbol_dir = get_kicad_symbol_dir()
if symbol_dir:
resolved = os.path.join(symbol_dir, filename)
if os.path.isfile(resolved):
return resolved
return None
def extract_symbol_pins(
symbol_node: SexpNode,
*,
lib_tree: SexpNode | None = None,
) -> list[dict[str, Any]]:
"""Extract pin dicts from a parsed symbol node.
Output format matches ``sexp_parser.parse_lib_symbol_pins()``:
``{number, name, type, shape, x, y, rotation, length}``
Searches through sub-symbols (children with tag "symbol") to find
all pin definitions. If the symbol uses ``(extends "Parent")``,
follows the chain to find pin definitions in the parent symbol
(requires ``lib_tree`` — the parsed library file root).
"""
# Check for extends chain
effective = _resolve_extends(symbol_node, lib_tree)
pins: list[dict[str, Any]] = []
all_pin_nodes = find_recursive(effective, "pin")
for pin_node in all_pin_nodes:
pin_dict = _parse_pin_node(pin_node)
if pin_dict is not None:
pins.append(pin_dict)
return pins
def extract_pin_unit_map(
symbol_node: SexpNode,
*,
lib_tree: SexpNode | None = None,
) -> dict[str, int]:
"""Build pin_number -> unit_number map from sub-symbols.
KiCad sub-symbols are named ``__