Coverage for klayout_pex/netlist/spice_syntax.py: 100%
56 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 13:14 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 13:14 +0000
1#
2# --------------------------------------------------------------------------------
3# SPDX-FileCopyrightText: 2024-2026 Martin Jan Köhler and Harald Pretl
4# Johannes Kepler University, Institute for Integrated Circuits.
5#
6# This file is part of KPEX
7# (see https://github.com/iic-jku/klayout-pex).
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
21# SPDX-License-Identifier: GPL-3.0-or-later
22# --------------------------------------------------------------------------------
23#
25"""
26The lines of a SPICE netlist as ngspice reads them, for the netlist tools:
27 - a statement continues on the lines starting with '+', also across comment and blank lines
28 - comments: lines starting with '*', and end-of-line comments from '$' or '//' at the start of a word,
29 or from ';' (KLayout writes the net $1 as \\$1)
30 - the first line of a deck (a netlist with .end) is its title
31 - ground: the nodes '0' and 'gnd'
33NOTE: imports neither KLayout nor generated protobuf code, so that format-only paths can use it
34"""
36from __future__ import annotations
38from dataclasses import dataclass
39import re
40from typing import *
43GROUND_NODE_NAMES = ('0', 'gnd')
44"""Ground in SPICE: '0', and 'gnd', which ngspice reads as '0'"""
46_END = re.compile(r'\s*\.end\s*$', re.IGNORECASE)
47_END_OF_LINE_COMMENT = re.compile(r'(^|\s)(\$|//)|;')
50class SpiceSyntaxError(Exception):
51 """A netlist whose lines ngspice can't read, e.g. a continuation line without a line to continue"""
53 def __init__(self, line_number: int, message: str):
54 super().__init__(f"line {line_number}: {message}")
55 self.line_number = line_number
56 self.message = message
59@dataclass
60class SpiceLine:
61 """
62 A statement with its continuation lines, or a comment line, a blank line or the title
63 """
65 number: int
66 """the number of its first physical line, counted from 1"""
67 physical_lines: List[str]
68 """as written: the statement, its continuation lines and the comment and blank lines between them"""
69 text: Optional[str]
70 """the statement in one line, without comments and the '+' of its continuation lines,
71 None for a comment line, a blank line or the title"""
73 @property
74 def verbatim(self) -> str:
75 return '\n'.join(self.physical_lines)
78def strip_comment(line: str) -> str:
79 """
80 :return: the physical line without its comment, '' for a comment line
81 """
82 if line.lstrip().startswith('*'):
83 return ''
84 match = _END_OF_LINE_COMMENT.search(line)
85 return line if match is None else line[:match.start()]
88def spice_lines(text: str) -> List[SpiceLine]:
89 """
90 The lines of the netlist, in their order (see the module docs)
92 :raises SpiceSyntaxError: for a continuation line without a statement to continue
93 """
94 physical_lines = text.splitlines()
95 has_title = any(_END.match(line) for line in physical_lines)
97 lines: List[SpiceLine] = []
98 statement: Optional[SpiceLine] = None
99 between: List[SpiceLine] = [] # NOTE: comment and blank lines after the statement, a continuation may follow
101 for number, line in enumerate(physical_lines, start=1):
102 if number == 1 and has_title:
103 lines.append(SpiceLine(number=number, physical_lines=[line], text=None))
104 continue
105 stripped = strip_comment(line).strip()
106 if not stripped:
107 between.append(SpiceLine(number=number, physical_lines=[line], text=None))
108 elif stripped.startswith('+'):
109 if statement is None:
110 raise SpiceSyntaxError(number, "continuation line without a line to continue")
111 for other in between:
112 statement.physical_lines.extend(other.physical_lines)
113 between = []
114 statement.physical_lines.append(line)
115 statement.text += ' ' + stripped[1:].strip()
116 else:
117 lines.extend(between)
118 between = []
119 statement = SpiceLine(number=number, physical_lines=[line], text=stripped)
120 lines.append(statement)
121 lines.extend(between)
122 return lines