"""Tests for the KiCad s-expression tokenizer and tree builder.""" import os import pytest from mckicad.utils.sexp_tree import ( SexpNode, TokenType, deep_copy, find, find_named, find_one, find_recursive, get_values, make_atom, make_node, make_string, parse, parse_file, serialize, serialize_file, text_at, tokenize, ) # --------------------------------------------------------------------------- # Tokenizer # --------------------------------------------------------------------------- class TestTokenizeAtoms: def test_bare_words(self): tokens = tokenize("symbol pin at") assert len(tokens) == 3 assert all(t.type == TokenType.ATOM for t in tokens) assert [t.value for t in tokens] == ["symbol", "pin", "at"] def test_numbers(self): tokens = tokenize("1.27 2.54 0") assert [t.value for t in tokens] == ["1.27", "2.54", "0"] def test_negative_numbers(self): tokens = tokenize("-3.81 -0.5e-3") assert [t.value for t in tokens] == ["-3.81", "-0.5e-3"] def test_offsets_sequential(self): tokens = tokenize("abc def") assert tokens[0].offset == 0 assert tokens[1].offset == 4 class TestTokenizeStrings: def test_quoted_string(self): tokens = tokenize('"hello world"') assert len(tokens) == 1 assert tokens[0].type == TokenType.STRING assert tokens[0].value == "hello world" def test_escaped_quotes(self): tokens = tokenize(r'"say \"hello\""') assert tokens[0].value == 'say "hello"' def test_escaped_backslash(self): tokens = tokenize(r'"path\\to\\file"') assert tokens[0].value == "path\\to\\file" def test_empty_string(self): tokens = tokenize('""') assert len(tokens) == 1 assert tokens[0].type == TokenType.STRING assert tokens[0].value == "" class TestTokenizeNested: def test_nested_parens(self): tokens = tokenize("(at 1.27 2.54)") types = [t.type for t in tokens] assert types == [ TokenType.LPAREN, TokenType.ATOM, TokenType.ATOM, TokenType.ATOM, TokenType.RPAREN, ] def test_deeply_nested(self): tokens = tokenize('(pin passive line (at 0 0) (name "A"))') lparen_count = sum(1 for t in tokens if t.type == TokenType.LPAREN) rparen_count = sum(1 for t in tokens if t.type == TokenType.RPAREN) assert lparen_count == 3 assert rparen_count == 3 # --------------------------------------------------------------------------- # Parser # --------------------------------------------------------------------------- class TestParseSimple: def test_simple_node(self): node = parse("(at 1.27 2.54)") assert node.tag == "at" assert node.values == ["1.27", "2.54"] assert node.children == [] def test_string_values(self): node = parse('(name "VCC")') assert node.tag == "name" assert node.values == ["VCC"] def test_empty_input(self): node = parse("") assert node.tag == "" class TestParseNested: def test_pin_with_children(self): sexp = '(pin passive line (at 0 0) (name "A"))' node = parse(sexp) assert node.tag == "pin" assert node.values == ["passive", "line"] assert len(node.children) == 2 assert node.children[0].tag == "at" assert node.children[0].values == ["0", "0"] assert node.children[1].tag == "name" assert node.children[1].values == ["A"] def test_mixed_values_and_children(self): sexp = '(symbol "R" (pin_names (offset 1.016)))' node = parse(sexp) assert node.tag == "symbol" assert node.values == ["R"] assert len(node.children) == 1 assert node.children[0].tag == "pin_names" class TestParseKicadLabel: LABEL_SEXP = ( '(label "VCC"\n' "\t(at 100.0 50.0 0)\n" "\t(effects\n" "\t\t(font\n" "\t\t\t(size 1.27 1.27)\n" "\t\t)\n" "\t\t(justify left bottom)\n" "\t)\n" '\t(uuid "abc-123")\n' ")" ) def test_label_structure(self): node = parse(self.LABEL_SEXP) assert node.tag == "label" assert node.values == ["VCC"] assert len(node.children) == 3 # at, effects, uuid at_node = find_one(node, "at") assert at_node is not None assert at_node.values == ["100.0", "50.0", "0"] uuid_node = find_one(node, "uuid") assert uuid_node is not None assert uuid_node.values == ["abc-123"] class TestParseKicadSymbol: SYMBOL_SEXP = """\ (symbol "R" (pin_names (offset 0)) (symbol "R_0_1" (polyline (pts (xy 0 -1.27) (xy 0 1.27))) ) (symbol "R_1_1" (pin passive line (at 0 2.54 270) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))) ) (pin passive line (at 0 -2.54 90) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))) ) ) )""" def test_symbol_structure(self): node = parse(self.SYMBOL_SEXP) assert node.tag == "symbol" assert node.values == ["R"] # Direct children with tag "symbol" are sub-symbols sub_syms = find(node, "symbol") assert len(sub_syms) == 2 assert sub_syms[0].values == ["R_0_1"] assert sub_syms[1].values == ["R_1_1"] def test_pins_in_subsymbols(self): node = parse(self.SYMBOL_SEXP) pins = find_recursive(node, "pin") assert len(pins) == 2 # First pin assert pins[0].values[0] == "passive" # type at_vals = get_values(pins[0], "at") assert at_vals == ["0", "2.54", "270"] # --------------------------------------------------------------------------- # Query functions # --------------------------------------------------------------------------- class TestFindDirectChildren: def test_find_symbols(self): sexp = '(lib (symbol "A" (pin)) (symbol "B" (pin)) (version 1))' node = parse(sexp) syms = find(node, "symbol") assert len(syms) == 2 assert syms[0].values == ["A"] assert syms[1].values == ["B"] def test_find_no_match(self): node = parse("(root (child))") assert find(node, "nonexistent") == [] class TestFindRecursive: def test_find_pins_in_subsymbols(self): sexp = "(root (symbol (sub (pin 1)) (sub (pin 2))))" node = parse(sexp) pins = find_recursive(node, "pin") assert len(pins) == 2 assert pins[0].values == ["1"] assert pins[1].values == ["2"] class TestFindNamed: def test_exact_match(self): sexp = '(lib (symbol "Device:R") (symbol "Device:R_0_1"))' node = parse(sexp) result = find_named(node, "symbol", "Device:R") assert result is not None assert result.values == ["Device:R"] def test_no_partial_match(self): sexp = '(lib (symbol "Device:R_0_1") (symbol "Device:R_1_1"))' node = parse(sexp) result = find_named(node, "symbol", "Device:R") assert result is None def test_no_match(self): node = parse("(lib (symbol))") assert find_named(node, "symbol", "anything") is None class TestGetValues: def test_get_at_values(self): sexp = "(pin passive line (at 5.08 0 180) (length 2.54))" node = parse(sexp) at_vals = get_values(node, "at") assert at_vals == ["5.08", "0", "180"] def test_get_missing_tag(self): node = parse("(pin passive)") assert get_values(node, "at") is None # --------------------------------------------------------------------------- # Span tracking # --------------------------------------------------------------------------- class TestSpanTracking: def test_simple_span(self): source = "(at 1.27 2.54)" node = parse(source) assert node.span == (0, len(source)) def test_nested_span(self): source = '(pin passive (at 0 0) (name "A"))' node = parse(source) # Root span covers everything assert node.span == (0, len(source)) # Children have their own spans at_node = node.children[0] assert source[at_node.span[0] : at_node.span[1]] == "(at 0 0)" def test_span_with_whitespace(self): source = " (at 1 2) " node = parse(source) # Span should start at the opening paren, not at whitespace assert node.span[0] == 2 assert source[node.span[0] : node.span[1]] == "(at 1 2)" class TestTextAt: def test_extract_source(self): source = '(root (child "value") (other))' node = parse(source) child = node.children[0] extracted = text_at(source, child.span) assert extracted == '(child "value")' def test_extract_nested(self): source = "(root (parent (child 1) (child 2)))" node = parse(source) parent = node.children[0] extracted = text_at(source, parent.span) assert extracted == "(parent (child 1) (child 2))" # --------------------------------------------------------------------------- # Real file parsing (requires KiCad installation) # --------------------------------------------------------------------------- DEVICE_LIB = "/usr/share/kicad/symbols/Device.kicad_sym" @pytest.mark.skipif( not os.path.isfile(DEVICE_LIB), reason="KiCad symbol library not installed", ) class TestParseRealFile: def test_parse_device_lib(self): tree = parse_file(DEVICE_LIB) assert tree.tag == "kicad_symbol_lib" # Should contain many symbols symbols = find(tree, "symbol") assert len(symbols) > 50 def test_find_resistor(self): tree = parse_file(DEVICE_LIB) resistor = find_named(tree, "symbol", "R") assert resistor is not None assert resistor.values[0] == "R" # R has two pins in its sub-symbols pins = find_recursive(resistor, "pin") assert len(pins) == 2 def test_span_matches_source(self): with open(DEVICE_LIB, encoding="utf-8") as f: source = f.read() tree = parse(source) resistor = find_named(tree, "symbol", "R") assert resistor is not None extracted = text_at(source, resistor.span) assert extracted.startswith('(symbol "R"') assert extracted.endswith(")") def test_pin_details(self): tree = parse_file(DEVICE_LIB) resistor = find_named(tree, "symbol", "R") assert resistor is not None pins = find_recursive(resistor, "pin") for pin in pins: # Each pin should have at, length, name, number children assert find_one(pin, "at") is not None assert find_one(pin, "length") is not None assert find_one(pin, "name") is not None assert find_one(pin, "number") is not None # --------------------------------------------------------------------------- # Quoting tracking (_quoted field) # --------------------------------------------------------------------------- class TestQuotedTracking: def test_atom_not_quoted(self): node = parse("(at 1.27 2.54)") assert node._quoted == [False, False] def test_string_quoted(self): node = parse('(name "VCC")') assert node._quoted == [True] def test_mixed_values(self): node = parse('(pin passive line)') assert node._quoted == [False, False] def test_nested_quoted(self): sexp = '(property "Reference" "R1" (at 0 0 0))' node = parse(sexp) assert node._quoted == [True, True] at_node = find_one(node, "at") assert at_node is not None assert at_node._quoted == [False, False, False] def test_empty_string_quoted(self): node = parse('(value "")') assert node._quoted == [True] assert node.values == [""] # --------------------------------------------------------------------------- # Serializer # --------------------------------------------------------------------------- class TestSerializeSimple: def test_values_only(self): node = make_atom("at", "1.27", "2.54") assert serialize(node) == "(at 1.27 2.54)" def test_string_values(self): node = make_string("name", "VCC") assert serialize(node) == '(name "VCC")' def test_no_values_no_children(self): node = make_node("empty") assert serialize(node) == "(empty)" def test_escape_quotes_in_values(self): node = make_string("desc", 'say "hello"') result = serialize(node) assert result == '(desc "say \\"hello\\"")' def test_escape_backslash(self): node = make_string("path", "C:\\Users\\file") result = serialize(node) assert result == '(path "C:\\\\Users\\\\file")' class TestSerializeNested: def test_single_child_inline(self): child = make_atom("offset", "0") node = make_node("pin_names", children=[child]) assert serialize(node) == "(pin_names (offset 0))" def test_multiple_children_multiline(self): c1 = make_atom("at", "0", "0") c2 = make_string("name", "A") node = make_node("pin", ["passive", "line"], [c1, c2]) result = serialize(node) assert "(pin passive line\n" in result assert " (at 0 0)\n" in result assert ' (name "A")\n' in result assert result.endswith(")") def test_indentation_nesting(self): inner = make_atom("size", "1.27", "1.27") font = make_node("font", children=[inner]) effects = make_node("effects", children=[font]) # Single-child chain: effects -> font -> size → all inline result = serialize(effects) assert result == "(effects (font (size 1.27 1.27)))" class TestSerializeRoundTrip: def test_simple_roundtrip(self): original = "(at 1.27 2.54)" tree = parse(original) assert serialize(tree) == original def test_string_roundtrip(self): original = '(name "VCC")' tree = parse(original) assert serialize(tree) == original def test_nested_roundtrip(self): original = "(pin_names (offset 0))" tree = parse(original) assert serialize(tree) == original def test_property_roundtrip(self): original = '(property "Reference" "R1" (at 0 0 0))' tree = parse(original) result = serialize(tree) assert result == original def test_kicad_label_roundtrip(self): sexp = ( '(label "VCC"\n' ' (at 100.0 50.0 0)\n' ' (effects\n' ' (font (size 1.27 1.27))\n' ' (justify left bottom)\n' ' )\n' ' (uuid "abc-123")\n' ')' ) tree = parse(sexp) result = serialize(tree) # Re-parse both to verify structural equivalence re_parsed = parse(result) assert re_parsed.tag == "label" assert re_parsed.values == ["VCC"] at_node = find_one(re_parsed, "at") assert at_node is not None assert at_node.values == ["100.0", "50.0", "0"] def test_multi_child_structure_preserved(self): sexp = '(symbol "R" (pin_names (offset 0)) (in_bom yes) (on_board yes))' tree = parse(sexp) result = serialize(tree) re_tree = parse(result) assert re_tree.tag == "symbol" assert re_tree.values == ["R"] assert len(re_tree.children) == 3 class TestSerializeFile: def test_writes_valid_file(self, tmp_path): node = make_node("kicad_sch", children=[ make_atom("version", "20231120"), make_string("generator", "test"), make_string("uuid", "abc-123"), ]) filepath = str(tmp_path / "test.kicad_sch") serialize_file(node, filepath) assert os.path.isfile(filepath) with open(filepath, encoding="utf-8") as f: content = f.read() assert content.startswith("(kicad_sch") assert content.endswith("\n") def test_roundtrip_through_file(self, tmp_path): node = make_node("kicad_sch", children=[ make_atom("version", "20231120"), make_string("uuid", "file-test"), ]) filepath = str(tmp_path / "rt.kicad_sch") serialize_file(node, filepath) reloaded = parse_file(filepath) assert reloaded.tag == "kicad_sch" assert find_one(reloaded, "version") is not None uuid_node = find_one(reloaded, "uuid") assert uuid_node is not None assert uuid_node.values == ["file-test"] @pytest.mark.skipif( not os.path.isfile(DEVICE_LIB), reason="KiCad symbol library not installed", ) class TestSerializeRealFile: def test_device_lib_roundtrip_structural(self): """Parse real Device.kicad_sym, serialize, re-parse — same structure.""" tree = parse_file(DEVICE_LIB) text = serialize(tree) re_tree = parse(text) # Same number of top-level symbols orig_syms = find(tree, "symbol") re_syms = find(re_tree, "symbol") assert len(re_syms) == len(orig_syms) # Spot-check: resistor still has 2 pins r_orig = find_named(tree, "symbol", "R") r_new = find_named(re_tree, "symbol", "R") assert r_orig is not None assert r_new is not None assert len(find_recursive(r_orig, "pin")) == len(find_recursive(r_new, "pin")) # --------------------------------------------------------------------------- # Node builders # --------------------------------------------------------------------------- class TestMakeNode: def test_auto_quote_numeric(self): node = make_node("at", ["1.27", "2.54"]) assert node._quoted == [False, False] assert serialize(node) == "(at 1.27 2.54)" def test_auto_quote_negative(self): node = make_node("at", ["-3.81", "0"]) assert node._quoted == [False, False] def test_auto_quote_keyword(self): node = make_node("in_bom", ["yes"]) assert node._quoted == [False] assert serialize(node) == "(in_bom yes)" def test_auto_quote_string(self): node = make_node("lib_id", ["Device:R"]) assert node._quoted == [True] assert serialize(node) == '(lib_id "Device:R")' def test_auto_quote_mixed(self): node = make_node("pin", ["passive", "line"]) assert node._quoted == [False, False] def test_with_children(self): child = make_atom("offset", "0") node = make_node("pin_names", children=[child]) assert serialize(node) == "(pin_names (offset 0))" def test_empty(self): node = make_node("empty") assert node.values == [] assert node.children == [] class TestMakeAtom: def test_basic(self): node = make_atom("at", "1.27", "2.54", "90") assert node.values == ["1.27", "2.54", "90"] assert all(q is False for q in node._quoted) assert serialize(node) == "(at 1.27 2.54 90)" def test_no_values(self): node = make_atom("empty") assert node.values == [] class TestMakeString: def test_basic(self): node = make_string("property", "Reference", "R1") assert node.values == ["Reference", "R1"] assert all(q is True for q in node._quoted) assert serialize(node) == '(property "Reference" "R1")' def test_single(self): node = make_string("uuid", "abc-123") assert serialize(node) == '(uuid "abc-123")' # --------------------------------------------------------------------------- # Deep copy # --------------------------------------------------------------------------- class TestDeepCopy: def test_independent_copy(self): original = make_node("symbol", ["Device:R"], [ make_atom("at", "1.27", "2.54"), make_string("uuid", "orig-uuid"), ]) copied = deep_copy(original) # Structural equality assert copied.tag == original.tag assert copied.values == original.values assert copied._quoted == original._quoted assert len(copied.children) == len(original.children) # Independence: modifying copy doesn't affect original copied.values[0] = "Modified:R" assert original.values[0] == "Device:R" copied.children[0].values[0] = "99.99" assert original.children[0].values[0] == "1.27" def test_preserves_quoted(self): original = parse('(property "Reference" "R1")') copied = deep_copy(original) assert copied._quoted == [True, True] assert serialize(copied) == '(property "Reference" "R1")' def test_deep_nesting(self): sexp = '(effects (font (size 1.27 1.27)) (justify left))' original = parse(sexp) copied = deep_copy(original) result = serialize(copied) re_parsed = parse(result) assert re_parsed.tag == "effects" font = find_one(re_parsed, "font") assert font is not None