"""Tiny S-expression reader/writer for KiCad files. Lists are Python lists; atoms are str. Quoted strings are kept as QStr so they round-trip with quotes. Numbers stay as plain str atoms (KiCad cares about text). """ class QStr(str): """A string that was quoted in the source (and must be written quoted).""" def parse(text): pos = 0 n = len(text) stack = [[]] while pos < n: c = text[pos] if c in ' \t\r\n': pos += 1 elif c == '(': stack.append([]) pos += 1 elif c == ')': done = stack.pop() stack[-1].append(done) pos += 1 elif c == '"': pos += 1 buf = [] while True: ch = text[pos] if ch == '\\': buf.append(text[pos:pos + 2]) pos += 2 continue if ch == '"': pos += 1 break buf.append(ch) pos += 1 raw = ''.join(buf) # keep escapes as-is (we re-emit them unchanged) stack[-1].append(QStr(raw)) else: start = pos while pos < n and text[pos] not in ' \t\r\n()"': pos += 1 stack[-1].append(text[start:pos]) assert len(stack) == 1 return stack[0][0] if len(stack[0]) == 1 else stack[0] def dumps(node, indent=0): """Serialize back to KiCad-style text (one sub-list per line when nested).""" if isinstance(node, QStr): return '"' + str(node) + '"' if isinstance(node, str): return node if isinstance(node, (int, float)): return fmt(node) # list simple = all(not isinstance(x, list) for x in node) if simple: return '(' + ' '.join(dumps(x) for x in node) + ')' pad = ' ' * (indent + 1) parts = [] head = [] i = 0 while i < len(node) and not isinstance(node[i], list): head.append(dumps(node[i])) i += 1 out = '(' + ' '.join(head) for x in node[i:]: if isinstance(x, list): out += '\n' + pad + dumps(x, indent + 1) else: out += ' ' + dumps(x) out += '\n' + ' ' * indent + ')' return out def fmt(v): if isinstance(v, str): return v s = f'{v:.4f}'.rstrip('0').rstrip('.') if s in ('-0', ''): s = '0' return s def find(node, key): """First child list whose head is key.""" for x in node: if isinstance(x, list) and x and x[0] == key: return x return None def findall(node, key): return [x for x in node if isinstance(x, list) and x and x[0] == key]