Skip to main content

dune_pic/
lib.rs

1//! SPICE netlist extraction from rpic figures (PLAN §2.5; D-0003,
2//! D-0004, D-0019, D-0020).
3//!
4//! A figure is ordinary pic, compiled by [`rpic_core`] as a library,
5//! with the `spice.pic` prelude (embedded; `src/spice.pic`)
6//! loaded after rpic's std circuit library. The prelude wraps every
7//! D-0019 element in a labeled block:
8//!
9//! ```text
10//! .PS
11//! spice = 1
12//! In: (0,1); Out: (2,1)
13//! V1: vsource(In, (0,0), DC 5)
14//! R1: resistor(In, Out, 1k)
15//! C1: capacitor(Out, (2,0), 1u)
16//! line from C1.N to V1.N; ground(V1.N)
17//! .tran 10u 5m
18//! .PE
19//! ```
20//!
21//! extracts to `V1 In 0 DC 5`, `R1 In Out 1k`, `C1 Out 0 1u`,
22//! `.tran 10u 5m`, `.end`.
23//!
24//! Conventions (the contract between the prelude and this crate):
25//!
26//! * **refdes = block label**; its first letter picks the card type
27//!   (D-0004 §1), restricted to the D-0019 set `R C L V I D Q M J S W X`
28//!   plus the transmission lines `T` (ideal) and `O` (LTRA), the
29//!   controlled sources `E F G H`, the mutual inductance `K`
30//!   (D-0026) and the MESFET `Z`;
31//! * **pins** are `circle invis rad 0` shapes labeled `P`/`N`,
32//!   `C`/`B`/`E` (+ the optional substrate `S`), `D`/`G`/`S`/`B`,
33//!   `Inp`/`Inm`/`Out` (the op-amp `X`), `P1` … `PN` (a generic `X`
34//!   of N nodes), `Cp`/`Cn`, `P1`/`N1`/`P2`/`N2` that
35//!   are direct children of the element block (D-0020 §c); a `K`
36//!   block has no pins, only a `Mutual` marker;
37//! * **value/model = the macro argument after the pin positions**
38//!   (D-0004 §2: the 3rd of a two-terminal, the 5th of the four-point
39//!   `E`/`G`, the (N+1)th of a generic `X` of N pins, the 2nd of an
40//!   element centered on an `At` anchor; all
41//!   three arguments of a `K`, `L1 L2 k`), read verbatim from the call
42//!   in the user's source (`1e-9` stays `1e-9`). The prelude also draws it
43//!   (the first text shape in the block); that drawing is presentation,
44//!   read only as a fallback when the block has no usable call (an
45//!   inline block, or a wrapper macro whose arguments are not the
46//!   element's) and, normalized by pic, to check that the call's
47//!   argument really is the element's value;
48//! * **wires** are the visible `line`/`arrow` paths outside element
49//!   and ground blocks; **dots** are explicit junctions; connectivity
50//!   rules are in [`connect`];
51//! * **nets** (D-0004 §3): a `ground(P)` block makes node `0`; a named
52//!   place (`Vout: (2,1)`, `Vin: R1.P`) that coincides with a net
53//!   names it — places that share a label are one SPICE node; the rest
54//!   are `n1, n2, …` in order of first appearance (lowest shape index);
55//! * **directives** (D-0004 §4): column-1 `.` lines (rpic's
56//!   `Drawing.passthrough`, the troff rule), with their column-1 `+`
57//!   continuations, pass through to the netlist in source order; a user
58//!   `.end` is dropped and one is appended.
59//!
60//! The figure must set `spice = 1` (D-0004 §5) or extraction refuses.
61
62#![forbid(unsafe_code)]
63
64mod connect;
65mod spans;
66
67use std::collections::{BTreeMap, HashMap, HashSet};
68use std::fmt;
69use std::ops::Range;
70
71use rpic_core::ir::{Drawing, MacroCall, PlaceInfo, Shape};
72use rpic_core::{CompileOptions, IncludePolicy};
73
74use crate::connect::{Pt, UnionFind, join_geometry};
75
76/// The embedded dune prelude (`src/spice.pic`, inside the crate so
77/// the packaged crate — the Python sdist, the R vendor tarball —
78/// carries it).
79pub const PRELUDE: &str = include_str!("spice.pic");
80
81/// Default snap tolerance, in pic inches (PLAN §2.5).
82pub const DEFAULT_SNAP: f64 = 1e-3;
83
84/// Marker label of the prelude's `ground(P)` block.
85const GROUND_MARKER: &str = "Gnd";
86/// Marker label of the anchor of centered elements, whose value is the
87/// 2nd macro argument instead of the 3rd.
88const ANCHOR_MARKER: &str = "At";
89/// Marker of the prelude's `coupling(L1, L2, k)` block (a `K` card,
90/// which has no pins).
91const COUPLING_MARKER: &str = "Mutual";
92/// Every pin-marker label the prelude uses on elements, plus the
93/// `At` anchor of centered elements and the coupling marker.
94const ELEMENT_MARKERS: &[&str] = &[
95    "P", "N", "Cp", "Cn", "C", "B", "E", "D", "G", "S", "Inp", "Inm", "Out", "P1", "N1", "P2",
96    "N2", "P3", "P4", "P5", "P6", "P7", "At", "Mutual",
97];
98/// Pins of a `Q` drawn with its substrate (`npn4`/`pnp4`).
99const BJT4_PINS: &[&str] = &["C", "B", "E", "S"];
100/// Pins of a generic subcircuit instance (`subckt2` … `subckt7`, an
101/// `X` card with N nodes): the first N of these, in card order.
102const SUBCKT_PINS: &[&str] = &["P1", "P2", "P3", "P4", "P5", "P6", "P7"];
103/// Marker of a block whose drawn text is a display, not the value.
104const DISPLAY_MARKER: &str = "Disp";
105/// Prelude macros that take an optional display text after the
106/// value/model, drawn instead of it (two-terminals, `vcvs`/`vccs` and
107/// the elements centered on an `At` anchor).
108const DISPLAY_MACROS: &[&str] = &[
109    "resistor",
110    "capacitor",
111    "inductor",
112    "diode",
113    "vsource",
114    "acsource",
115    "battery",
116    "isource",
117    "cccs",
118    "ccvs",
119    "vcvs",
120    "vccs",
121    "npn",
122    "pnp",
123    "npn4",
124    "pnp4",
125    "nmos",
126    "pmos",
127    "nmos4",
128    "pmos4",
129    "njfet",
130    "pjfet",
131    "nmes",
132    "pmes",
133    "opamp",
134    "subckt2",
135    "subckt3",
136    "subckt4",
137    "subckt5",
138    "subckt6",
139    "subckt7",
140];
141/// The supported card letters, for messages.
142const LETTERS: &str = "R C L V I D Q M J Z S W X T O E F G H K";
143
144/// Options for [`extract`].
145#[derive(Debug, Clone, Copy)]
146pub struct ExtractOptions<'a> {
147    /// Distance (pic inches) under which two terminals are the same
148    /// point. Default [`DEFAULT_SNAP`].
149    pub snap: f64,
150    /// Prelude loaded after rpic's std circuit library; `None` uses the
151    /// embedded [`PRELUDE`].
152    pub prelude: Option<&'a str>,
153}
154
155impl Default for ExtractOptions<'_> {
156    fn default() -> Self {
157        Self {
158            snap: DEFAULT_SNAP,
159            prelude: None,
160        }
161    }
162}
163
164/// A position in the figure source (1-based line/column, end
165/// exclusive).
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct PicSpan {
168    /// `None` = the user's figure; `Some("spice.pic")` = the prelude;
169    /// `Some("circuits")` = rpic's std library.
170    pub file: Option<String>,
171    pub line: u32,
172    pub col: u32,
173    pub end_col: u32,
174}
175
176impl fmt::Display for PicSpan {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        if let Some(file) = &self.file {
179            write!(f, "{file}:")?;
180        }
181        write!(f, "{}:{}", self.line, self.col)
182    }
183}
184
185/// Diagnostic severity.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum PicSeverity {
188    /// The netlist is emitted but is wrong or incomplete (an element
189    /// was skipped or lacks its value).
190    Error,
191    /// The netlist is emitted and probably what was meant.
192    Warning,
193}
194
195/// What a [`PicDiagnostic`] is about.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum PicDiagKind {
198    /// An element pin no other element pin (or ground) connects to.
199    DanglingPin,
200    /// Wires (or a named node) that reach no element pin.
201    FloatingNet,
202    /// An element block with no (or an empty) value text.
203    MissingValue,
204    /// Two elements with the same refdes (SPICE names are
205    /// case-insensitive).
206    DuplicateRefdes,
207    /// A refdes whose first letter is outside the supported set.
208    UnknownElement,
209    /// An element block without one of the pins its letter needs.
210    MissingPin,
211    /// An element block without a label.
212    UnlabeledElement,
213    /// One net carrying two different names (or a name and ground).
214    NodeNameConflict,
215    /// Something in the source that defeats the prelude.
216    Prelude,
217}
218
219/// A structured extraction diagnostic.
220#[derive(Debug, Clone, PartialEq)]
221pub struct PicDiagnostic {
222    pub severity: PicSeverity,
223    pub kind: PicDiagKind,
224    pub message: String,
225    /// Where in the figure, when known.
226    pub span: Option<PicSpan>,
227}
228
229impl fmt::Display for PicDiagnostic {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        let sev = match self.severity {
232            PicSeverity::Error => "error",
233            PicSeverity::Warning => "warning",
234        };
235        match &self.span {
236            Some(s) => write!(f, "{s}: {sev}: {}", self.message),
237            None => write!(f, "{sev}: {}", self.message),
238        }
239    }
240}
241
242/// Why no netlist could be produced at all.
243#[derive(Debug, Clone, PartialEq, thiserror::Error)]
244pub enum PicError {
245    /// rpic rejected the figure.
246    #[error("pic: {message}")]
247    Compile {
248        message: String,
249        span: Option<PicSpan>,
250    },
251    /// The figure does not opt in with `spice = 1` (D-0004 §5).
252    #[error("figure not marked `spice = 1`; dune-pic only extracts figures that opt in")]
253    NotMarked,
254}
255
256/// One element's shapes in the compiled drawing (ids `sN` of the SVG).
257#[derive(Debug, Clone, PartialEq)]
258pub struct ElementOverlay {
259    pub refdes: String,
260    /// Half-open range of shape indices of the element's block.
261    pub shapes: Range<usize>,
262    /// The element's terminals, in card order (the nodes of its card);
263    /// empty for a `K`, which has none.
264    pub pins: Vec<PinOverlay>,
265    /// Where the element's value/model argument is in the user's
266    /// source — the text an editor replaces to write a new value back
267    /// (issue #17).
268    ///
269    /// `Some` only when the card's value is that argument of a call to
270    /// one of the prelude's own element macros (`R1: resistor(A, B,
271    /// 1k)`), written in the user's figure. `None` when there is none
272    /// to point at: an element without a value argument (`vsource(A,
273    /// B)`), a `K` (`coupling(L1, L2, k)` spreads its card over three
274    /// arguments), an inline block, and every element whose outermost
275    /// call is a user wrapper macro — the value then comes from the
276    /// drawn fallback or from a wrapper argument, and neither is a span
277    /// an edit can be trusted to land in. A display text (the argument
278    /// after the value) never changes this: the span stays the value's.
279    pub value: Option<ValueSpanOverlay>,
280    /// The alterable parameters spelled inside [`Self::value`], in
281    /// source order, named as `alter(refdes, param, …)` names them
282    /// (`dune_core::Circuit::alter`): for `V`/`I` sources `dc`,
283    /// `acmag`, `acphase` (each the span of its number only: `AC 1 90`
284    /// gives `acmag` = `1` and `acphase` = `90`) and the transient
285    /// functions `pulse`, `sin`, `exp`, `pwl`, `sffm`, `trnoise`,
286    /// `trrandom` (each the whole spelling, keyword and parentheses
287    /// included: `PULSE(0 5 0 1u 1u 10m 20m)`); for `R`/`C`/`L`/`E`/`G`
288    /// the principal value (the leading number) and `ic` (`IC=…`,
289    /// `C`/`L`); `gain` of an `F`/`H` (`Vname gain`); `area` (the
290    /// number after the model, or `AREA=`) and `temp` (`TEMP=`) of a
291    /// `D`/`Q`/`J`; `area` of a `Z`; `w`, `l`, `temp` (`W=`…) of an `M`. Only what is
292    /// spelled is listed (a source with no `DC` field has no `dc`
293    /// entry), and a parameter spelled twice is listed once, at its
294    /// last spelling (the one SPICE keeps). Empty when
295    /// [`Self::value`] is `None`.
296    pub params: Vec<ParamSpanOverlay>,
297    /// The parameter `alter(refdes, value)` (empty parameter name)
298    /// changes for this element's letter: `resistance`, `capacitance`,
299    /// `inductance`, `dc` (`V`/`I`), `gain` (`E`/`F`/`G`/`H`), `area`
300    /// (`D`/`Q`/`J`/`Z`); `None` for letters `alter` has no principal for
301    /// (`M`, `S`, `W`, `X`, `T`, `O`, `K`). Independent of
302    /// [`Self::value`].
303    pub principal: Option<String>,
304}
305
306/// A range of the user's figure source (never the prelude): 1-based
307/// lines and columns, as the diagnostics' [`PicSpan`], from
308/// `(line, col)` to `(end_line, end_col)`, end exclusive. Columns
309/// count **chars** (Unicode scalar values) from the start of the line —
310/// not bytes, not UTF-16 code units: a JavaScript consumer indexes a
311/// line by code point (`Array.from(line)`), not by `line[i]`, when the
312/// line holds characters outside the BMP. Lines are split at `\n`.
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct PicRange {
315    pub line: u32,
316    pub col: u32,
317    pub end_line: u32,
318    pub end_col: u32,
319}
320
321/// [`ElementOverlay::value`]: the value argument's range and its text.
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct ValueSpanOverlay {
324    /// The range of `text` in the user's source. It spans lines when
325    /// the argument is split by `\` continuations.
326    pub span: PicRange,
327    /// The source text in `span`, verbatim (`4.7k`, `AC 1`, `SIN(0 1
328    /// 1k)`; `\` continuations kept). A quoted argument (`"DX"`) gives
329    /// the range and text inside the quotes — the card's value.
330    pub text: String,
331}
332
333/// One entry of [`ElementOverlay::params`].
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct ParamSpanOverlay {
336    /// The `alter` parameter name (`dc`, `acmag`, `pulse`, …).
337    pub name: String,
338    /// Its range in the user's source, inside the value's range.
339    pub span: PicRange,
340    /// The source text in `span`.
341    pub text: String,
342}
343
344/// The page frame of the SVG [`render_svg`] draws (issue #19): what
345/// maps a point of the compiled drawing — [`PinOverlay::x`]/`y`, in
346/// pic inches with y up — to SVG user units (the root `viewBox`
347/// space, 96 per inch, y down). Exactly:
348///
349/// ```text
350/// sx = (x + origin[0]) · 96
351/// sy = (h − (y + origin[1])) · 96
352/// ```
353///
354/// ([`Self::to_svg`]). This is rpic's own frame — the `canvas` of its
355/// scene export (`origin` is where the pic origin sits, measured from
356/// the page's lower-left corner; `w`/`h` the page size in inches,
357/// margins and stroke padding included, so the `viewBox` is `0 0 w·96
358/// h·96`), computed here at full precision instead of the scene's six
359/// decimals. Note the signs (`+ origin`) and that `rescale` does not
360/// enter: the drawing's coordinates, pins included, are already past
361/// the whole-picture rescale.
362#[derive(Debug, Clone, Copy, PartialEq)]
363pub struct CanvasOverlay {
364    /// Page width and height in inches (`viewBox` = `0 0 w·96 h·96`).
365    pub w: f64,
366    pub h: f64,
367    /// The pic origin, in inches from the page's lower-left corner.
368    pub origin: [f64; 2],
369    /// The whole-picture rescale factor (`.PS <width>`, `maxpswid`,
370    /// `maxpsht`; 1 when none) — informational: the shapes and pins
371    /// already carry it.
372    pub rescale: f64,
373}
374
375impl Default for CanvasOverlay {
376    fn default() -> Self {
377        Self {
378            w: 0.0,
379            h: 0.0,
380            origin: [0.0, 0.0],
381            rescale: 1.0,
382        }
383    }
384}
385
386impl CanvasOverlay {
387    /// SVG user units of the drawing point `(x, y)` (pic inches).
388    pub fn to_svg(&self, x: f64, y: f64) -> (f64, f64) {
389        (
390            (x + self.origin[0]) * SVG_PPI,
391            (self.h - (y + self.origin[1])) * SVG_PPI,
392        )
393    }
394}
395
396/// rpic's SVG scale, user units per inch (dpic's `dpPPI`).
397const SVG_PPI: f64 = 96.0;
398
399/// One terminal of an element: where it is drawn and the net it is on.
400///
401/// Element pins are invisible markers inside the element's own shapes,
402/// so a net whose elements touch pin to pin has no wire of its own in
403/// [`NetOverlay::shapes`]; the pins still give a probe target for it.
404#[derive(Debug, Clone, PartialEq)]
405pub struct PinOverlay {
406    /// The pin's prelude name (`P`, `N`, `C`, `B`, `E`, `D`, `G`, `S`,
407    /// `Inp`, `Cp`, `P1`, …; D-0020; `P1` … `PN` on a generic `X`).
408    pub name: String,
409    /// The node name on the card, as in [`NetOverlay::name`].
410    pub net: String,
411    /// The terminal point, in pic inches, in the frame of the compiled
412    /// drawing's shapes (y up, after the whole-picture rescale) — the
413    /// frame of rpic's scene; [`OverlayMap::canvas`] maps it to the
414    /// SVG's user units.
415    pub x: f64,
416    pub y: f64,
417}
418
419impl ElementOverlay {
420    /// The range as rpic object ids, `"sN..sM"` (inclusive).
421    pub fn ids(&self) -> String {
422        format!(
423            "s{}..s{}",
424            self.shapes.start,
425            self.shapes.end.saturating_sub(1)
426        )
427    }
428}
429
430/// One net's drawn shapes: its wires and dots, and the ground blocks
431/// on it (named places draw nothing).
432#[derive(Debug, Clone, PartialEq)]
433pub struct NetOverlay {
434    pub name: String,
435    pub shapes: Vec<usize>,
436}
437
438/// Map from netlist names back to rpic objects, for result overlays.
439#[derive(Debug, Clone, PartialEq, Default)]
440pub struct OverlayMap {
441    /// Emitted elements, in card order.
442    pub elements: Vec<ElementOverlay>,
443    /// Named nets, in order of first appearance (`0` first if present).
444    pub nets: Vec<NetOverlay>,
445    /// The frame that maps pins to the SVG of [`render_svg`].
446    pub canvas: CanvasOverlay,
447}
448
449/// The result of [`extract`].
450#[derive(Debug, Clone, PartialEq)]
451pub struct Extraction {
452    /// SPICE3 netlist text, ending in `.end`.
453    pub netlist: String,
454    pub map: OverlayMap,
455    pub diagnostics: Vec<PicDiagnostic>,
456}
457
458impl Extraction {
459    /// True when no diagnostic is an error.
460    pub fn is_clean(&self) -> bool {
461        self.diagnostics
462            .iter()
463            .all(|d| d.severity != PicSeverity::Error)
464    }
465}
466
467/// Compile a figure with the prelude and render it to SVG — the
468/// drawing whose shape ids [`OverlayMap`] refers to. Does not require
469/// `spice = 1`.
470pub fn render_svg(src: &str, opts: &ExtractOptions) -> Result<String, PicError> {
471    let c = compile(src, opts)?;
472    Ok(rpic_core::to_svg(&c.drawing))
473}
474
475/// Extract a SPICE3 netlist from a pic figure.
476pub fn extract(src: &str, opts: &ExtractOptions) -> Result<Extraction, PicError> {
477    let mut c = compile(src, opts)?;
478    let marked = c
479        .drawing
480        .vars
481        .iter()
482        .any(|(k, v)| k == "spice" && *v == 1.0);
483    if !marked {
484        return Err(PicError::NotMarked);
485    }
486    let snap = if opts.snap.is_finite() && opts.snap > 0.0 {
487        opts.snap
488    } else {
489        DEFAULT_SNAP
490    };
491    let canvas = canvas_of(&mut c.drawing);
492    let mut ex = Extractor::new(src, &c, snap).run();
493    ex.map.canvas = canvas;
494    Ok(ex)
495}
496
497/// The SVG page frame of `d` ([`CanvasOverlay`]), from rpic's SVG
498/// transform itself: `svg::place_points` maps places through the very
499/// function the emitter draws with, so a probe place at the pic origin
500/// gives the translation exactly; the page size follows rpic's
501/// `canvas_frame` (content bounds — the union of the shapes' SVG bounds
502/// rpic reports per object — or a fixed `canvas`, plus stroke padding
503/// and margins).
504fn canvas_of(d: &mut Drawing) -> CanvasOverlay {
505    let saved = std::mem::take(&mut d.places);
506    d.places.push(PlaceInfo {
507        label: String::new(),
508        at: rpic_core::geom::Point::new(0.0, 0.0),
509        block: None,
510        span: None,
511    });
512    let o = rpic_core::svg::place_points(d)[0];
513    d.places = saved;
514    let (raw_w, raw_h) = match d.canvas {
515        Some(b) if !b.is_empty() => (b.width(), b.height()),
516        Some(_) => (0.0, 0.0),
517        None => {
518            // Object bboxes are in SVG units: (x, y, w, h), y down.
519            let mut lo = (f64::INFINITY, f64::INFINITY);
520            let mut hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
521            for g in rpic_core::svg::object_geometries(d) {
522                if let Some((x, y, w, h)) = g.bbox {
523                    lo = (lo.0.min(x), lo.1.min(y));
524                    hi = (hi.0.max(x + w), hi.1.max(y + h));
525                }
526            }
527            if lo.0.is_finite() {
528                ((hi.0 - lo.0) / SVG_PPI, (hi.1 - lo.1) / SVG_PPI)
529            } else {
530                (0.0, 0.0)
531            }
532        }
533    };
534    let pad = d.prelude_thick.max(0.0) / 144.0;
535    let m = d.canvas_margin;
536    let extent = |v: f64| if v.is_finite() && v > 0.0 { v } else { 0.0 };
537    let w = extent(raw_w + 6.0 * pad + m.horizontal());
538    let h = extent(raw_h + 6.0 * pad + m.vertical());
539    // place_points(0, 0) = (ox · 96, (h − oy) · 96).
540    CanvasOverlay {
541        w,
542        h,
543        origin: [o.x / SVG_PPI, h - o.y / SVG_PPI],
544        rescale: d.rescale,
545    }
546}
547
548/// A compiled figure.
549struct Compiled {
550    drawing: Drawing,
551    /// The user's directives in source order: each passthrough entry
552    /// with its `+` continuations, one per line.
553    directives: Vec<String>,
554    /// Lines the prelude occupies ahead of the user source.
555    prelude_lines: u32,
556    /// The prelude's own element macros (its `define`s not named
557    /// `__…`): a call to one of these is an element's own call.
558    element_macros: HashSet<String>,
559}
560
561fn compile(src: &str, opts: &ExtractOptions) -> Result<Compiled, PicError> {
562    let prelude = opts.prelude.unwrap_or(PRELUDE);
563    let mut full = String::with_capacity(prelude.len() + src.len() + 1);
564    full.push_str(prelude);
565    if !prelude.ends_with('\n') {
566        full.push('\n');
567    }
568    let prelude_lines = full.matches('\n').count() as u32;
569    full.push_str(src);
570    let copts = CompileOptions {
571        circuits: true,
572        includes: IncludePolicy::Deny,
573        ..Default::default()
574    };
575    let drawing = rpic_core::compile_with_diagnostics(&full, &copts).map_err(|e| {
576        let info = &e.info;
577        let span = info.line.map(|line| {
578            map_span(
579                prelude_lines,
580                info.file.as_deref(),
581                line,
582                info.col.unwrap_or(1),
583                info.end_col.unwrap_or(info.col.unwrap_or(1)),
584            )
585        });
586        PicError::Compile {
587            message: info.message.clone(),
588            span,
589        }
590    })?;
591    // Directives: the user's passthrough entries (a prelude has none of
592    // its own to contribute). rpic folds the column-1 `+` continuations
593    // of a request into its entry, one line per `\n`.
594    let directives = drawing
595        .passthrough
596        .iter()
597        .filter(|l| l.span.file.is_none() && l.span.line > prelude_lines)
598        .map(|l| directive_text(&l.text))
599        .collect();
600    let element_macros = prelude
601        .lines()
602        .filter_map(|l| l.trim_start().strip_prefix("define"))
603        .filter(|r| r.starts_with(char::is_whitespace))
604        .filter_map(|r| {
605            r.split(|c: char| c.is_whitespace() || c == '{')
606                .find(|w| !w.is_empty())
607        })
608        .filter(|n| !n.starts_with("__"))
609        .map(str::to_string)
610        .collect();
611    Ok(Compiled {
612        drawing,
613        directives,
614        prelude_lines,
615        element_macros,
616    })
617}
618
619/// A passthrough entry as a netlist line: the request trimmed at the
620/// end, each `+` continuation line trimmed.
621fn directive_text(text: &str) -> String {
622    let mut lines = text.split('\n');
623    let mut out = lines.next().unwrap_or("").trim_end().to_string();
624    for cont in lines {
625        out.push('\n');
626        out.push_str(cont.trim());
627    }
628    out
629}
630
631/// Map a position in the prelude+user stream back to its source.
632fn map_span(prelude_lines: u32, file: Option<&str>, line: u32, col: u32, end_col: u32) -> PicSpan {
633    match file {
634        Some(f) => PicSpan {
635            file: Some(f.to_string()),
636            line,
637            col,
638            end_col,
639        },
640        None if line > prelude_lines => PicSpan {
641            file: None,
642            line: line - prelude_lines,
643            col,
644            end_col,
645        },
646        None => PicSpan {
647            file: Some("spice.pic".into()),
648            line,
649            col,
650            end_col,
651        },
652    }
653}
654
655/// Pins of each supported card letter (D-0019 + `T`/`O` + D-0026), in
656/// card order. A `Q` may also carry the substrate ([`BJT4_PINS`]); a
657/// `K` has none.
658fn pins_for(letter: char) -> Option<&'static [&'static str]> {
659    Some(match letter {
660        'R' | 'C' | 'L' | 'V' | 'I' | 'D' | 'W' | 'F' | 'H' => &["P", "N"],
661        'S' | 'E' | 'G' => &["P", "N", "Cp", "Cn"],
662        'K' => &[],
663        'Q' => &["C", "B", "E"],
664        'M' => &["D", "G", "S", "B"],
665        'J' | 'Z' => &["D", "G", "S"],
666        'X' => &["Inp", "Inm", "Out"],
667        'T' | 'O' => &["P1", "N1", "P2", "N2"],
668        _ => return None,
669    })
670}
671
672#[derive(Clone, Copy, PartialEq)]
673enum BlockKind {
674    Plain,
675    Element,
676    Ground,
677}
678
679/// What a terminal is.
680#[derive(Clone, Copy)]
681enum Term {
682    /// Pin `pin` (index into the element's pin list) of element `elem`.
683    Pin {
684        elem: usize,
685        pin: usize,
686    },
687    Ground,
688    /// A named place (index into the extractor's place list).
689    Place(usize),
690    /// A vertex of a wire.
691    Wire,
692    Dot,
693}
694
695struct Terminal {
696    at: Pt,
697    /// The shape the terminal belongs to; [`NO_SHAPE`] for places,
698    /// which draw nothing (so they never decide net order).
699    shape: usize,
700    term: Term,
701}
702
703struct Element {
704    refdes: String,
705    pins: &'static [&'static str],
706    /// Terminal index of each pin, in card order.
707    terms: Vec<usize>,
708    value: String,
709    /// The call argument `value` was read from, when it was.
710    value_arg: Option<usize>,
711    block: usize,
712    /// The element's call site in the user source.
713    span: Option<PicSpan>,
714}
715
716/// [`Terminal::shape`] of a named place.
717const NO_SHAPE: usize = usize::MAX;
718
719/// A named place that takes part in net naming.
720struct Place {
721    label: String,
722    span: Option<PicSpan>,
723}
724
725struct Extractor<'a> {
726    src: &'a str,
727    d: &'a Drawing,
728    directives: &'a [String],
729    prelude_lines: u32,
730    element_macros: &'a HashSet<String>,
731    snap: f64,
732    diags: Vec<PicDiagnostic>,
733    places: Vec<Place>,
734}
735
736impl<'a> Extractor<'a> {
737    fn new(src: &'a str, c: &'a Compiled, snap: f64) -> Self {
738        Self {
739            src,
740            d: &c.drawing,
741            directives: &c.directives,
742            prelude_lines: c.prelude_lines,
743            element_macros: &c.element_macros,
744            snap,
745            diags: Vec::new(),
746            places: Vec::new(),
747        }
748    }
749
750    fn diag(
751        &mut self,
752        severity: PicSeverity,
753        kind: PicDiagKind,
754        message: String,
755        span: Option<PicSpan>,
756    ) {
757        self.diags.push(PicDiagnostic {
758            severity,
759            kind,
760            message,
761            span,
762        });
763    }
764
765    fn span(&self, s: &rpic_core::Span) -> PicSpan {
766        map_span(
767            self.prelude_lines,
768            s.file.as_deref(),
769            s.line,
770            s.col,
771            s.end_col,
772        )
773    }
774
775    fn shape_span(&self, i: usize) -> Option<PicSpan> {
776        let s = self.d.shape_spans.get(i)?.as_ref()?;
777        Some(self.span(s))
778    }
779
780    /// The label and center of shape `i` if it is a labeled marker
781    /// (`circle invis rad 0`).
782    fn marker(&self, i: usize) -> Option<(&'a str, Pt)> {
783        let d = self.d;
784        let label = d.shape_labels.get(i)?.as_deref()?;
785        match &d.shapes[i] {
786            Shape::Circle {
787                c, r, dot, style, ..
788            } if *r == 0.0 && !*dot && style.invis => Some((label, (c.x, c.y))),
789            _ => None,
790        }
791    }
792
793    /// Direct-child markers of block `b`.
794    fn markers_of(&self, b: usize) -> Vec<(usize, &'a str, Pt)> {
795        let (lo, hi) = self.d.blocks[b].shapes;
796        (lo..hi.min(self.d.shapes.len()))
797            .filter(|&i| self.d.shape_blocks.get(i).copied().flatten() == Some(b))
798            .filter_map(|i| self.marker(i).map(|(l, p)| (i, l, p)))
799            .collect()
800    }
801
802    fn run(mut self) -> Extraction {
803        let d = self.d;
804        if self.src.lines().any(|l| {
805            let t = l.trim();
806            t.starts_with("copy") && t.contains("\"circuits\"")
807        }) {
808            self.diag(
809                PicSeverity::Warning,
810                PicDiagKind::Prelude,
811                "`copy \"circuits\"` reloads rpic's std library over the dune prelude, \
812                 turning elements back into plain drawings; dune-pic loads the library itself"
813                    .into(),
814                None,
815            );
816        }
817
818        // 1. Classify blocks.
819        let kinds: Vec<BlockKind> = (0..d.blocks.len())
820            .map(|b| {
821                let ms = self.markers_of(b);
822                if ms.iter().any(|m| m.1 == GROUND_MARKER) {
823                    BlockKind::Ground
824                } else if ms.iter().any(|m| ELEMENT_MARKERS.contains(&m.1)) {
825                    BlockKind::Element
826                } else {
827                    BlockKind::Plain
828                }
829            })
830            .collect();
831        // Inside a special block (at any depth)?
832        let in_special = |mut b: Option<usize>| {
833            while let Some(bi) = b {
834                if kinds[bi] != BlockKind::Plain {
835                    return true;
836                }
837                b = d.blocks[bi].parent;
838            }
839            false
840        };
841        // A shape is internal when any enclosing block is special.
842        let internal = |i: usize| in_special(d.shape_blocks.get(i).copied().flatten());
843
844        let mut terms: Vec<Terminal> = Vec::new();
845        let mut elements: Vec<Element> = Vec::new();
846
847        // 2. Elements and grounds, in source order.
848        let mut order: Vec<usize> = (0..d.blocks.len()).collect();
849        order.sort_by_key(|&b| d.blocks[b].shapes.0);
850        let mut seen: HashMap<String, String> = HashMap::new();
851        for &b in &order {
852            let ms = self.markers_of(b);
853            match kinds[b] {
854                BlockKind::Plain => {}
855                BlockKind::Ground => {
856                    for &(i, l, p) in &ms {
857                        if l == GROUND_MARKER {
858                            terms.push(Terminal {
859                                at: p,
860                                shape: i,
861                                term: Term::Ground,
862                            });
863                        }
864                    }
865                }
866                BlockKind::Element => {
867                    if let Some(e) = self.element(b, &ms, &mut terms, &mut seen, elements.len()) {
868                        elements.push(e);
869                    }
870                }
871            }
872        }
873
874        self.check_references(&elements);
875
876        // Named places (D-0004 §3): the user's `Label: position`
877        // statements, not those of the prelude, the std library or the
878        // inside of an element.
879        for p in &d.places {
880            let span = p.span.as_ref().map(|s| self.span(s));
881            if span.as_ref().is_some_and(|s| s.file.is_some()) || in_special(p.block) {
882                continue;
883            }
884            if let Some(refdes) = seen.get(&p.label.to_ascii_lowercase()) {
885                self.diag(
886                    PicSeverity::Warning,
887                    PicDiagKind::NodeNameConflict,
888                    format!(
889                        "place `{}` has the name of element `{refdes}`; it names no net",
890                        p.label
891                    ),
892                    span,
893                );
894                continue;
895            }
896            terms.push(Terminal {
897                at: (p.at.x, p.at.y),
898                shape: NO_SHAPE,
899                term: Term::Place(self.places.len()),
900            });
901            self.places.push(Place {
902                label: p.label.clone(),
903                span,
904            });
905        }
906
907        // 3. Wires and dots.
908        let mut segments: Vec<(Pt, Pt, usize)> = Vec::new();
909        for (i, s) in d.shapes.iter().enumerate() {
910            if internal(i) {
911                continue;
912            }
913            match s {
914                Shape::Path { pts, style, .. } if !style.invis && !pts.is_empty() => {
915                    let first = terms.len();
916                    for p in pts {
917                        terms.push(Terminal {
918                            at: (p.x, p.y),
919                            shape: i,
920                            term: Term::Wire,
921                        });
922                    }
923                    for w in pts.windows(2) {
924                        segments.push(((w[0].x, w[0].y), (w[1].x, w[1].y), first));
925                    }
926                }
927                Shape::Circle { c, dot: true, .. } => terms.push(Terminal {
928                    at: (c.x, c.y),
929                    shape: i,
930                    term: Term::Dot,
931                }),
932                _ => {}
933            }
934        }
935
936        // 4. Union-find.
937        let mut uf = UnionFind::new(terms.len());
938        for w in terms.windows(2).enumerate() {
939            let (k, pair) = w;
940            if let (Term::Wire, Term::Wire) = (pair[0].term, pair[1].term)
941                && pair[0].shape == pair[1].shape
942            {
943                uf.union(k, k + 1);
944            }
945        }
946        let points: Vec<Pt> = terms.iter().map(|t| t.at).collect();
947        join_geometry(&mut uf, &points, &segments, self.snap);
948        // Nets that share a place name are one node (net labels
949        // connect).
950        let mut by_name: HashMap<String, usize> = HashMap::new();
951        for (k, t) in terms.iter().enumerate() {
952            if let Term::Place(n) = t.term {
953                let key = self.places[n].label.to_ascii_lowercase();
954                match by_name.get(&key) {
955                    Some(&other) => uf.union(k, other),
956                    None => {
957                        by_name.insert(key, k);
958                    }
959                }
960            }
961        }
962
963        // 5. Gather nets.
964        let roots: Vec<usize> = (0..terms.len()).map(|k| uf.find(k)).collect();
965        let mut nets: BTreeMap<usize, Vec<usize>> = BTreeMap::new(); // root -> terms
966        for (k, &r) in roots.iter().enumerate() {
967            nets.entry(r).or_default().push(k);
968        }
969
970        // 6. Name nets.
971        let user_names: Vec<String> = self
972            .places
973            .iter()
974            .map(|p| p.label.to_ascii_lowercase())
975            .collect();
976        let mut name_of: HashMap<usize, String> = HashMap::new();
977        // Nets in order of first appearance.
978        let mut net_order: Vec<(usize, usize)> = nets
979            .iter()
980            .map(|(&r, ks)| (ks.iter().map(|&k| terms[k].shape).min().unwrap_or(0), r))
981            .collect();
982        net_order.sort();
983        for &(_, r) in &net_order {
984            let ks = &nets[&r];
985            let grounded = ks.iter().any(|&k| matches!(terms[k].term, Term::Ground));
986            // Place names in definition order.
987            let mut names: Vec<usize> = ks
988                .iter()
989                .filter_map(|&k| match terms[k].term {
990                    Term::Place(n) => Some(n),
991                    _ => None,
992                })
993                .collect();
994            names.sort_unstable();
995            let mut distinct: Vec<String> = Vec::new();
996            for n in names {
997                let l = &self.places[n].label;
998                if !distinct.iter().any(|m| m.eq_ignore_ascii_case(l)) {
999                    distinct.push(l.clone());
1000                }
1001            }
1002            if grounded {
1003                if let Some(n) = distinct.first() {
1004                    let span = self.label_span(n);
1005                    self.diag(
1006                        PicSeverity::Warning,
1007                        PicDiagKind::NodeNameConflict,
1008                        format!("place `{n}` is on the ground net; it is named `0`"),
1009                        span,
1010                    );
1011                }
1012                name_of.insert(r, "0".into());
1013            } else if let Some(first) = distinct.first() {
1014                if distinct.len() > 1 {
1015                    let span = self.label_span(&distinct[1]);
1016                    self.diag(
1017                        PicSeverity::Warning,
1018                        PicDiagKind::NodeNameConflict,
1019                        format!(
1020                            "one net named {}; using `{first}`",
1021                            distinct
1022                                .iter()
1023                                .map(|n| format!("`{n}`"))
1024                                .collect::<Vec<_>>()
1025                                .join(", ")
1026                        ),
1027                        span,
1028                    );
1029                }
1030                name_of.insert(r, first.clone());
1031            }
1032        }
1033        let mut auto = 0usize;
1034        for &(_, r) in &net_order {
1035            if name_of.contains_key(&r) {
1036                continue;
1037            }
1038            let has_pin = nets[&r]
1039                .iter()
1040                .any(|&k| matches!(terms[k].term, Term::Pin { .. }));
1041            if !has_pin {
1042                continue;
1043            }
1044            let name = loop {
1045                auto += 1;
1046                let n = format!("n{auto}");
1047                if !user_names.contains(&n) {
1048                    break n;
1049                }
1050            };
1051            name_of.insert(r, name);
1052        }
1053
1054        // 7. Electrical checks.
1055        for &(_, r) in &net_order {
1056            let ks = &nets[&r];
1057            let pins: Vec<usize> = ks
1058                .iter()
1059                .copied()
1060                .filter(|&k| matches!(terms[k].term, Term::Pin { .. }))
1061                .collect();
1062            let grounded = ks.iter().any(|&k| matches!(terms[k].term, Term::Ground));
1063            if pins.is_empty() {
1064                let drawn = ks
1065                    .iter()
1066                    .copied()
1067                    .find(|&k| matches!(terms[k].term, Term::Wire | Term::Dot));
1068                if let Some(k) = drawn {
1069                    let what = match name_of.get(&r) {
1070                        Some(n) => format!("net `{n}`"),
1071                        None => "wire".into(),
1072                    };
1073                    let span = self.shape_span(terms[k].shape).filter(|s| s.file.is_none());
1074                    self.diag(
1075                        PicSeverity::Warning,
1076                        PicDiagKind::FloatingNet,
1077                        format!("{what} connects to no element pin"),
1078                        span,
1079                    );
1080                } else if let Some(n) = ks.iter().find_map(|&k| match terms[k].term {
1081                    Term::Place(n) => Some(n),
1082                    _ => None,
1083                }) {
1084                    // Only places: a label on empty space.
1085                    let p = &self.places[n];
1086                    let msg = format!("place `{}` names no net: no wire or pin is there", p.label);
1087                    let span = p.span.clone();
1088                    self.diag(PicSeverity::Warning, PicDiagKind::FloatingNet, msg, span);
1089                }
1090                continue;
1091            }
1092            if pins.len() == 1 && !grounded {
1093                let Term::Pin { elem, pin } = terms[pins[0]].term else {
1094                    unreachable!()
1095                };
1096                let e = &elements[elem];
1097                let span = e.span.clone();
1098                let net = name_of.get(&r).cloned().unwrap_or_default();
1099                let msg = format!(
1100                    "pin {}.{} is dangling: net `{net}` reaches no other element pin",
1101                    e.refdes, e.pins[pin]
1102                );
1103                self.diag(PicSeverity::Warning, PicDiagKind::DanglingPin, msg, span);
1104            }
1105        }
1106
1107        // A Q card's 4th name is the substrate only when it is not a
1108        // model name (INP2Q looks it up in the model table).
1109        for e in &elements {
1110            if e.pins.len() == 4 && e.refdes.to_ascii_uppercase().starts_with('Q') {
1111                let sub = &name_of[&roots[e.terms[3]]];
1112                let model = e.value.split_whitespace().next().unwrap_or("");
1113                if sub.eq_ignore_ascii_case(model) {
1114                    let msg = format!(
1115                        "`{}`: substrate net `{sub}` has the name of the model; \
1116                         SPICE would read it as the model and the next word as the model name",
1117                        e.refdes
1118                    );
1119                    let span = e.span.clone();
1120                    self.diag(
1121                        PicSeverity::Warning,
1122                        PicDiagKind::NodeNameConflict,
1123                        msg,
1124                        span,
1125                    );
1126                }
1127            }
1128        }
1129
1130        // 8. Emit.
1131        let mut netlist = String::from("* netlist extracted by dune-pic\n");
1132        let mut map = OverlayMap::default();
1133        for e in &elements {
1134            netlist.push_str(&e.refdes);
1135            for &k in &e.terms {
1136                netlist.push(' ');
1137                netlist.push_str(&name_of[&roots[k]]);
1138            }
1139            if !e.value.is_empty() {
1140                netlist.push(' ');
1141                netlist.push_str(&e.value);
1142            }
1143            netlist.push('\n');
1144            let (lo, hi) = d.blocks[e.block].shapes;
1145            let pins = e
1146                .pins
1147                .iter()
1148                .zip(&e.terms)
1149                .map(|(&name, &k)| PinOverlay {
1150                    name: name.to_string(),
1151                    net: name_of[&roots[k]].clone(),
1152                    x: terms[k].at.0,
1153                    y: terms[k].at.1,
1154                })
1155                .collect();
1156            let letter = e.refdes.chars().next().unwrap_or(' ').to_ascii_uppercase();
1157            let (value, params) = match e
1158                .value_arg
1159                .and_then(|k| self.value_span(e.block, k, letter))
1160            {
1161                Some((v, p)) => (Some(v), p),
1162                None => (None, Vec::new()),
1163            };
1164            map.elements.push(ElementOverlay {
1165                refdes: e.refdes.clone(),
1166                shapes: lo..hi,
1167                pins,
1168                value,
1169                params,
1170                principal: spans::principal(letter).map(str::to_string),
1171            });
1172        }
1173        for dir in self.directives {
1174            if dir.eq_ignore_ascii_case(".end") {
1175                continue;
1176            }
1177            netlist.push_str(dir);
1178            netlist.push('\n');
1179        }
1180        netlist.push_str(".end\n");
1181
1182        // Overlay of nets: wires, dots and ground blocks.
1183        let mut net_shapes: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
1184        for (k, t) in terms.iter().enumerate() {
1185            match t.term {
1186                Term::Wire | Term::Dot => net_shapes.entry(roots[k]).or_default().push(t.shape),
1187                Term::Ground => {
1188                    let b = d.shape_blocks[t.shape].expect("marker inside its block");
1189                    let (lo, hi) = d.blocks[b].shapes;
1190                    net_shapes.entry(roots[k]).or_default().extend(lo..hi);
1191                }
1192                Term::Pin { .. } | Term::Place(_) => {}
1193            }
1194        }
1195        let mut emitted: Vec<String> = Vec::new();
1196        let mut ordered: Vec<(usize, usize)> = net_order.clone();
1197        ordered.sort_by_key(|&(first, r)| (name_of.get(&r).map(|n| n != "0"), first));
1198        for (_, r) in ordered {
1199            let Some(name) = name_of.get(&r) else {
1200                continue;
1201            };
1202            let mut shapes = net_shapes.remove(&r).unwrap_or_default();
1203            shapes.sort_unstable();
1204            shapes.dedup();
1205            // Nets merged by name share one entry.
1206            if let Some(pos) = emitted.iter().position(|n| n == name) {
1207                map.nets[pos].shapes.extend(shapes);
1208                map.nets[pos].shapes.sort_unstable();
1209                map.nets[pos].shapes.dedup();
1210                continue;
1211            }
1212            emitted.push(name.clone());
1213            map.nets.push(NetOverlay {
1214                name: name.clone(),
1215                shapes,
1216            });
1217        }
1218
1219        Extraction {
1220            netlist,
1221            map,
1222            diagnostics: self.diags,
1223        }
1224    }
1225
1226    /// Validate an element block and register its pin terminals.
1227    fn element(
1228        &mut self,
1229        b: usize,
1230        ms: &[(usize, &'a str, Pt)],
1231        terms: &mut Vec<Terminal>,
1232        seen: &mut HashMap<String, String>,
1233        index: usize,
1234    ) -> Option<Element> {
1235        let d = self.d;
1236        let Some(refdes) = d.blocks[b].label.clone() else {
1237            let value = self.value(b, ms, ' ').map(|v| v.0).unwrap_or_default();
1238            let at = ms.first().map(|m| m.2).unwrap_or((0.0, 0.0));
1239            let msg = format!(
1240                "element without a label (refdes) at ({:.3}, {:.3}){}; skipped — write `R1: resistor(…)`",
1241                at.0,
1242                at.1,
1243                if value.is_empty() {
1244                    String::new()
1245                } else {
1246                    format!(", value `{value}`")
1247                }
1248            );
1249            let span = self.block_span(b);
1250            self.diag(PicSeverity::Error, PicDiagKind::UnlabeledElement, msg, span);
1251            return None;
1252        };
1253        let span = self.block_span(b);
1254        let letter = refdes.chars().next().unwrap_or(' ').to_ascii_uppercase();
1255        let Some(mut pins) = pins_for(letter) else {
1256            self.diag(
1257                PicSeverity::Error,
1258                PicDiagKind::UnknownElement,
1259                format!(
1260                    "`{refdes}`: element letter `{letter}` is not supported ({LETTERS}); skipped"
1261                ),
1262                span,
1263            );
1264            return None;
1265        };
1266        if letter == 'Q' && ms.iter().any(|m| m.1 == "S") {
1267            pins = BJT4_PINS;
1268        }
1269        if letter == 'X' {
1270            let n = subckt_arity(ms);
1271            if n > 0 {
1272                pins = &SUBCKT_PINS[..n];
1273            }
1274        }
1275        if letter == 'K' && !ms.iter().any(|m| m.1 == COUPLING_MARKER) {
1276            self.diag(
1277                PicSeverity::Error,
1278                PicDiagKind::MissingPin,
1279                format!(
1280                    "`{refdes}`: a `K` element is drawn with `coupling(L1, L2, k)`, \
1281                     not this macro; skipped"
1282                ),
1283                span,
1284            );
1285            return None;
1286        }
1287        let value = self.value(b, ms, letter);
1288        let key = refdes.to_ascii_lowercase();
1289        if let Some(prev) = seen.get(&key) {
1290            self.diag(
1291                PicSeverity::Error,
1292                PicDiagKind::DuplicateRefdes,
1293                format!("refdes `{refdes}` already used by `{prev}`; skipped"),
1294                span,
1295            );
1296            return None;
1297        }
1298        let mut pin_terms = Vec::with_capacity(pins.len());
1299        let mut missing = Vec::new();
1300        for (pi, &pin) in pins.iter().enumerate() {
1301            match ms.iter().find(|m| m.1 == pin) {
1302                Some(&(i, _, p)) => {
1303                    pin_terms.push(terms.len());
1304                    terms.push(Terminal {
1305                        at: p,
1306                        shape: i,
1307                        term: Term::Pin {
1308                            elem: index,
1309                            pin: pi,
1310                        },
1311                    });
1312                }
1313                None => missing.push(pin),
1314            }
1315        }
1316        if !missing.is_empty() {
1317            // Undo the pins registered for this element.
1318            terms.truncate(terms.len() - pin_terms.len());
1319            self.diag(
1320                PicSeverity::Error,
1321                PicDiagKind::MissingPin,
1322                format!(
1323                    "`{refdes}`: a `{letter}` element needs pins {}, but the drawn element \
1324                     lacks {} (wrong refdes letter for this macro?); skipped",
1325                    pins.join(", "),
1326                    missing.join(", ")
1327                ),
1328                span,
1329            );
1330            return None;
1331        }
1332        seen.insert(key, refdes.clone());
1333        let Some((value, value_arg)) = value else {
1334            self.diag(
1335                PicSeverity::Error,
1336                PicDiagKind::MissingValue,
1337                format!(
1338                    "`{refdes}` draws a display text instead of its value, but its call \
1339                     is not the element's own macro (a wrapper?), so the value cannot \
1340                     be read; the card is incomplete"
1341                ),
1342                span.clone(),
1343            );
1344            return Some(Element {
1345                refdes,
1346                pins,
1347                terms: pin_terms,
1348                value: String::new(),
1349                value_arg: None,
1350                block: b,
1351                span,
1352            });
1353        };
1354        if value.is_empty() {
1355            // A source without a value is 0 V / 0 A in SPICE; anything
1356            // else is incomplete.
1357            let (sev, what) = if matches!(letter, 'V' | 'I') {
1358                (PicSeverity::Warning, "it is a 0 V/0 A source")
1359            } else {
1360                (PicSeverity::Error, "the card is incomplete")
1361            };
1362            self.diag(
1363                sev,
1364                PicDiagKind::MissingValue,
1365                format!("`{refdes}` has no value/model text; {what}"),
1366                span.clone(),
1367            );
1368        }
1369        Some(Element {
1370            refdes,
1371            pins,
1372            terms: pin_terms,
1373            value,
1374            value_arg,
1375            block: b,
1376            span,
1377        })
1378    }
1379
1380    /// Elements whose value names other elements: an `F`/`H` must
1381    /// start with the refdes of a `V` source (the controlling branch), a
1382    /// `K` with the refdes of two `L` elements. A card without them is
1383    /// invalid, so these are errors (reported as [`PicDiagKind::MissingValue`]:
1384    /// the value lacks what the card needs). An empty value was already
1385    /// reported.
1386    fn check_references(&mut self, elements: &[Element]) {
1387        let letter_of = |name: &str| {
1388            elements
1389                .iter()
1390                .find(|e| e.refdes.eq_ignore_ascii_case(name))
1391                .and_then(|e| e.refdes.chars().next())
1392                .map(|c| c.to_ascii_uppercase())
1393        };
1394        for e in elements {
1395            let letter = e.refdes.chars().next().unwrap_or(' ').to_ascii_uppercase();
1396            let toks: Vec<&str> = e.value.split_whitespace().collect();
1397            if toks.is_empty() {
1398                continue;
1399            }
1400            let (want, refs, what): (char, &[&str], &str) = match letter {
1401                'F' | 'H' => (
1402                    'V',
1403                    &toks[..1],
1404                    "a controlling voltage source (`Vname gain`)",
1405                ),
1406                'K' => (
1407                    'L',
1408                    &toks[..toks.len().min(2)],
1409                    "two inductors (`L1, L2, k`)",
1410                ),
1411                _ => continue,
1412            };
1413            for r in refs {
1414                let problem = match letter_of(r) {
1415                    Some(l) if l == want => continue,
1416                    Some(_) => format!("`{r}` is not a `{want}` element"),
1417                    None if r
1418                        .chars()
1419                        .next()
1420                        .is_some_and(|c| c.eq_ignore_ascii_case(&want)) =>
1421                    {
1422                        format!("no element `{r}` in the figure")
1423                    }
1424                    None => format!("`{r}` is not a `{want}` refdes"),
1425                };
1426                self.diag(
1427                    PicSeverity::Error,
1428                    PicDiagKind::MissingValue,
1429                    format!(
1430                        "`{}` needs {what}: {problem}; the card is invalid",
1431                        e.refdes
1432                    ),
1433                    e.span.clone(),
1434                );
1435            }
1436            if letter == 'K' && toks.len() < 3 {
1437                self.diag(
1438                    PicSeverity::Error,
1439                    PicDiagKind::MissingValue,
1440                    format!("`{}` has no coupling coefficient", e.refdes),
1441                    e.span.clone(),
1442                );
1443            }
1444        }
1445    }
1446
1447    /// Where block `b` was asked for: its outermost macro call (or its
1448    /// inline `[`) — in the user's source for every element the figure
1449    /// writes, even though its shapes point into the prelude.
1450    fn block_span(&self, b: usize) -> Option<PicSpan> {
1451        self.d.blocks[b].span.as_ref().map(|s| self.span(s))
1452    }
1453
1454    /// The value/model of element block `b` (D-0004 §2): the macro
1455    /// argument after the pin positions — index 1 for elements centered
1456    /// on an `At` anchor, 4 for the four-point `E`/`G`, N for a generic
1457    /// `X` of N pins, 2 otherwise —
1458    /// verbatim from the user's source. A `K` (`coupling(L1, L2, k)`)
1459    /// takes all three arguments, `L1 L2 k`; its drawing shows only
1460    /// `k=…`, so it has no drawn fallback.
1461    ///
1462    /// A call to one of the [`DISPLAY_MACROS`] with one more argument,
1463    /// non-empty, carries a display text that the prelude draws instead
1464    /// of the value: the value still comes from its own argument, and
1465    /// the check below compares the drawing with the display. A
1466    /// mismatch then falls back on the drawn text, which is the display
1467    /// — wrong, but it only happens when the call is not the element's
1468    /// (and the extraction would be wrong without the display too).
1469    /// A drawing that shows a display (`Disp` marker) without such a
1470    /// call — a wrapper macro passing one — has no trustworthy value:
1471    /// `None`.
1472    ///
1473    /// The call is trusted only when its argument is what the prelude
1474    /// drew (both normalized by pic), which rules out a user wrapper
1475    /// macro whose arguments are not the element's (`BlockInfo.call` is
1476    /// the *outermost* call). Fallbacks, in order: rpic's normalized
1477    /// argument (an argument itself split over lines, see
1478    /// [`Self::raw_arg`]); the drawn text (no call — an inline block —
1479    /// or a mismatch).
1480    ///
1481    /// With the value, the index of the call argument it was read from
1482    /// (`None` for a fallback and for a `K`), for
1483    /// [`ElementOverlay::value`].
1484    fn value(
1485        &self,
1486        b: usize,
1487        ms: &[(usize, &'a str, Pt)],
1488        letter: char,
1489    ) -> Option<(String, Option<usize>)> {
1490        let coupling = letter == 'K';
1491        let drawn = self.value_text(b);
1492        // A drawn display is not the value: nothing to fall back on.
1493        let shows_display = ms.iter().any(|m| m.1 == DISPLAY_MARKER);
1494        let fallback = if shows_display {
1495            None
1496        } else if coupling {
1497            Some((String::new(), None))
1498        } else {
1499            Some((drawn.clone(), None))
1500        };
1501        let arity = if letter == 'X' { subckt_arity(ms) } else { 0 };
1502        let k = match letter {
1503            'E' | 'G' => 4,
1504            _ if ms.iter().any(|m| m.1 == ANCHOR_MARKER) => 1,
1505            // A generic subcircuit: the name follows its N points.
1506            'X' if arity > 0 => arity,
1507            _ => 2,
1508        };
1509        let Some(call) = &self.d.blocks[b].call else {
1510            return fallback;
1511        };
1512        let Some(arg) = call.args.get(k) else {
1513            return fallback;
1514        };
1515        if coupling && call.args.len() != 3 {
1516            return fallback;
1517        }
1518        let words = |s: &str| s.split_whitespace().collect::<Vec<_>>().join(" ");
1519        let spliced = unquote(arg);
1520        // A display text (one argument past the value, non-empty) is
1521        // what the prelude drew, so it is what the call must match.
1522        // Only a call to the prelude macro itself has one: a wrapper's
1523        // extra argument says nothing about the element.
1524        let display = (DISPLAY_MACROS.contains(&call.name.as_str()) && call.args.len() == k + 2)
1525            .then(|| words(unquote(&call.args[k + 1])))
1526            .filter(|d| !d.is_empty());
1527        if shows_display && display.is_none() {
1528            // The display came through a wrapper: its arguments are
1529            // not the element's.
1530            return None;
1531        }
1532        let expect = if coupling {
1533            format!("k={}", words(spliced))
1534        } else if let Some(d) = display {
1535            d
1536        } else {
1537            words(spliced)
1538        };
1539        if !drawn.is_empty() && expect != words(&drawn) {
1540            return fallback;
1541        }
1542        let pick = |i: usize| unquote(&self.raw_arg(call, i)).trim().to_string();
1543        Some(if coupling {
1544            ((0..3).map(pick).collect::<Vec<_>>().join(" "), None)
1545        } else {
1546            (pick(k), Some(k))
1547        })
1548    }
1549
1550    /// Argument `i` of `call` as the user wrote it (`MacroCall.raw_args`:
1551    /// `1e-9` stays `1e-9`) when it is one line of the user's source;
1552    /// otherwise — an argument split by `\` continuations, or one that
1553    /// is not the user's text — rpic's normalized argument.
1554    fn raw_arg(&self, call: &MacroCall, i: usize) -> String {
1555        match call.raw_args.get(i) {
1556            Some(raw)
1557                if call.raw_args.len() == call.args.len()
1558                    && raw.span.file.is_none()
1559                    && raw.span.line > self.prelude_lines
1560                    && raw.end_line == raw.span.line =>
1561            {
1562                raw.text.clone()
1563            }
1564            _ => call.args[i].clone(),
1565        }
1566    }
1567
1568    /// [`ElementOverlay::value`] and [`ElementOverlay::params`] of the
1569    /// element of block `b` whose value is argument `k` of its call:
1570    /// only for a call to one of the prelude's element macros whose
1571    /// raw argument is in the user's source.
1572    fn value_span(
1573        &self,
1574        b: usize,
1575        k: usize,
1576        letter: char,
1577    ) -> Option<(ValueSpanOverlay, Vec<ParamSpanOverlay>)> {
1578        let call = self.d.blocks[b].call.as_ref()?;
1579        if !self.element_macros.contains(&call.name) || call.raw_args.len() != call.args.len() {
1580            return None;
1581        }
1582        let raw = call.raw_args.get(k)?;
1583        if raw.span.file.is_some() || raw.span.line <= self.prelude_lines {
1584            return None;
1585        }
1586        let chars: Vec<char> = raw.text.chars().collect();
1587        let pos = spans::positions(&chars, raw.span.line - self.prelude_lines, raw.span.col);
1588        // The layout must land where rpic says the argument ends;
1589        // anything else (a source convention this walk does not
1590        // share) gives no span rather than a wrong one.
1591        if pos[chars.len()] != (raw.end_line - self.prelude_lines, raw.span.end_col) {
1592            return None;
1593        }
1594        let (a, z) = spans::value_range(&chars);
1595        let value = ValueSpanOverlay {
1596            span: spans::range(&pos, a, z),
1597            text: chars[a..z].iter().collect(),
1598        };
1599        Some((value, spans::param_overlays(letter, &chars, a, z, &pos)))
1600    }
1601
1602    /// The first text shape inside block `b` (any depth), lines joined
1603    /// by spaces, trimmed.
1604    fn value_text(&self, b: usize) -> String {
1605        let (lo, hi) = self.d.blocks[b].shapes;
1606        for shape in self.d.shapes.iter().take(hi).skip(lo) {
1607            if let Shape::Text { text, .. } = shape {
1608                let joined: Vec<&str> = text.iter().map(|t| t.s.trim()).collect();
1609                return joined.join(" ").trim().to_string();
1610            }
1611        }
1612        String::new()
1613    }
1614
1615    /// Where the first place named `label` is defined.
1616    fn label_span(&self, label: &str) -> Option<PicSpan> {
1617        self.places
1618            .iter()
1619            .find(|p| p.label.eq_ignore_ascii_case(label))
1620            .and_then(|p| p.span.clone())
1621    }
1622}
1623
1624/// The number of pins of a generic subcircuit block (`P1` … `PN`, the
1625/// markers of `subckt2` … `subckt7`): how many of [`SUBCKT_PINS`] it
1626/// carries in a row from `P1`; 0 for any other `X` (the op-amp's
1627/// `Inp`/`Inm`/`Out`).
1628fn subckt_arity(ms: &[(usize, &str, Pt)]) -> usize {
1629    SUBCKT_PINS
1630        .iter()
1631        .take_while(|p| ms.iter().any(|m| m.1 == **p))
1632        .count()
1633}
1634
1635/// A lone string argument splices as its content (pic's
1636/// quote-at-use-site idiom), so `"DX"` is the value `DX`.
1637fn unquote(arg: &str) -> &str {
1638    let t = arg.trim();
1639    match t.strip_prefix('"').and_then(|r| r.strip_suffix('"')) {
1640        Some(inner) if !inner.contains('"') => inner,
1641        _ => arg,
1642    }
1643}