"""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 ``__