Skip to main content

dune_netlist/
deck.rs

1//! Physical deck reading: files → logical cards.
2//!
3//! Mirrors `inp_readall` in `corpus/spice3f5/src/lib/fte/inpcom.c`:
4//! the first non-blank line of the root file is the title; `.include`
5//! at column 1 splices another file in before anything else happens;
6//! then whole-line comments are dropped and `+` continuation lines are
7//! joined (with a single space) onto the previous card — comments in
8//! between do not break a continuation chain, and the title line can
9//! never be continued.
10//!
11//! Deliberate differences from the C, all documented in
12//! `docs/dune-netlist.md`:
13//! - relative `.include` paths resolve against the including file's
14//!   directory (the C uses the process working directory);
15//! - `$` and `;` preceded by whitespace start an end-of-line comment
16//!   (ngspice superset; the C only knows whole-line comments);
17//! - trailing `\r` is stripped (DOS decks);
18//! - include depth and total size are bounded so hostile decks cannot
19//!   hang or exhaust memory (principle 4 of `CLAUDE.md`).
20
21use std::path::{Path, PathBuf};
22
23use crate::diag::Diagnostic;
24use crate::span::{SourceMap, Span};
25
26/// Maximum `.include` nesting; also what bounds include cycles.
27const MAX_INCLUDE_DEPTH: usize = 16;
28/// Cap on the total bytes loaded across all files of one deck.
29const MAX_TOTAL_BYTES: usize = 256 << 20;
30
31/// Loads the text of an `.include`d file. The default implementation
32/// reads the file system; a WASM host can supply its own.
33///
34/// `limit` is the byte budget left for this deck: an implementation
35/// must not return more than `limit` bytes, and must refuse sources
36/// it cannot bound (pipes, device files) — otherwise a hostile
37/// `.include /dev/zero` would hang the read before any cap applies.
38pub trait IncludeLoader {
39    fn load(&mut self, path: &Path, limit: u64) -> Result<String, String>;
40}
41
42/// [`IncludeLoader`] over `std::fs`.
43pub struct FsLoader;
44
45impl IncludeLoader for FsLoader {
46    fn load(&mut self, path: &Path, limit: u64) -> Result<String, String> {
47        use std::io::Read;
48        let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
49        if !meta.is_file() {
50            return Err("not a regular file".into());
51        }
52        let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
53        let mut text = String::new();
54        file.take(limit.saturating_add(1))
55            .read_to_string(&mut text)
56            .map_err(|e| e.to_string())?;
57        if text.len() as u64 > limit {
58            return Err(format!(
59                "larger than the deck's remaining {limit}-byte budget"
60            ));
61        }
62        Ok(text)
63    }
64}
65
66/// One logical line: continuations joined, with enough bookkeeping to
67/// map any byte range of `text` back to a source [`Span`].
68#[derive(Debug)]
69pub struct Card {
70    pub text: String,
71    segments: Vec<Segment>,
72}
73
74#[derive(Debug)]
75struct Segment {
76    /// Start of this segment inside `Card::text`.
77    text_start: u32,
78    len: u32,
79    /// Where the segment came from.
80    src: Span,
81}
82
83impl Card {
84    fn new(text: String, src: Span) -> Self {
85        let len = text.len() as u32;
86        Card {
87            text,
88            segments: vec![Segment {
89                text_start: 0,
90                len,
91                src,
92            }],
93        }
94    }
95
96    /// Append a continuation segment (joined with a single space).
97    fn push_continuation(&mut self, more: &str, src: Span) {
98        self.text.push(' ');
99        let text_start = self.text.len() as u32;
100        self.text.push_str(more);
101        self.segments.push(Segment {
102            text_start,
103            len: more.len() as u32,
104            src,
105        });
106    }
107
108    /// Source span of `text[start..end]`. Ranges that straddle a
109    /// continuation boundary are clipped to the segment holding
110    /// `start` — good enough for diagnostics.
111    pub fn span_of(&self, start: usize, end: usize) -> Span {
112        let start = start as u32;
113        let end = end as u32;
114        let seg = self
115            .segments
116            .iter()
117            .rev()
118            .find(|s| s.text_start <= start)
119            .unwrap_or(&self.segments[0]);
120        let off = start.saturating_sub(seg.text_start).min(seg.len);
121        let len = end.saturating_sub(start).min(seg.len - off);
122        Span {
123            file: seg.src.file,
124            start: seg.src.start + off,
125            end: seg.src.start + off + len,
126        }
127    }
128
129    /// Span of the whole card (first segment's source line).
130    pub fn span(&self) -> Span {
131        self.segments[0].src
132    }
133}
134
135/// The reader's output: a title plus logical cards, with everything
136/// needed to render diagnostics.
137#[derive(Debug)]
138pub struct Deck {
139    pub title: String,
140    pub cards: Vec<Card>,
141    pub sources: SourceMap,
142    pub diags: Vec<Diagnostic>,
143}
144
145/// Read a deck from the file system (root file + includes).
146pub fn read_file(path: &Path) -> Result<Deck, crate::Error> {
147    let text = FsLoader
148        .load(path, MAX_TOTAL_BYTES as u64)
149        .map_err(|message| crate::Error::Io {
150            path: path.to_path_buf(),
151            message,
152        })?;
153    Ok(read_with_loader(path, text, &mut FsLoader))
154}
155
156/// Read a deck from a string, resolving `.include` with `loader`
157/// (relative to `name`'s directory).
158pub fn read_with_loader(name: &Path, text: String, loader: &mut dyn IncludeLoader) -> Deck {
159    let mut r = Reader {
160        sources: SourceMap::new(),
161        physical: Vec::new(),
162        diags: Vec::new(),
163        loaded_bytes: 0,
164        loader,
165    };
166    r.load(name.to_path_buf(), text, 0);
167    r.finish()
168}
169
170/// A physical line that survived file reading and include splicing.
171struct Physical {
172    text: String,
173    src: Span,
174}
175
176struct Reader<'a> {
177    sources: SourceMap,
178    physical: Vec<Physical>,
179    diags: Vec<Diagnostic>,
180    loaded_bytes: usize,
181    loader: &'a mut dyn IncludeLoader,
182}
183
184impl Reader<'_> {
185    /// Register `text` as a file and splice its lines (and includes)
186    /// into `physical`.
187    fn load(&mut self, name: PathBuf, text: String, depth: usize) {
188        self.loaded_bytes = self.loaded_bytes.saturating_add(text.len());
189        if self.loaded_bytes > MAX_TOTAL_BYTES {
190            self.diags.push(Diagnostic::global_error(format!(
191                "deck exceeds {} MiB of source text; giving up",
192                MAX_TOTAL_BYTES >> 20
193            )));
194            return;
195        }
196        let Some(file) = self.sources.add(name.clone(), text) else {
197            self.diags
198                .push(Diagnostic::global_error("source file too large"));
199            return;
200        };
201        // Walk the file's lines by byte offset so spans are exact.
202        let text = &self.sources.file(file).text;
203        let mut line_ranges = Vec::new();
204        let mut start = 0usize;
205        for (i, b) in text.bytes().enumerate() {
206            if b == b'\n' {
207                line_ranges.push((start, i));
208                start = i + 1;
209            }
210        }
211        if start < text.len() {
212            line_ranges.push((start, text.len()));
213        }
214
215        for (s, mut e) in line_ranges {
216            let text = &self.sources.file(file).text;
217            // Strip trailing '\r' (DOS decks) and trailing blanks.
218            while e > s && matches!(text.as_bytes()[e - 1], b'\r' | b' ' | b'\t') {
219                e -= 1;
220            }
221            // Blank lines never become cards (the C's readline folds
222            // them away).
223            if text[s..e].trim().is_empty() {
224                continue;
225            }
226            // `.include` is only recognized at column 1, like the C.
227            if starts_with_ignore_case(&text[s..e], ".include") {
228                let line = text[s..e].to_string();
229                let src = Span::new(file, s, e);
230                self.handle_include(&line, src, &name, depth);
231                continue;
232            }
233            // End-of-line comments: `$` or `;` at the start of a
234            // field (ngspice superset). The title line is printed
235            // verbatim, so it is never stripped.
236            let is_title = depth == 0 && self.physical.is_empty();
237            if !is_title {
238                let cut = inline_comment_start(&text[s..e]);
239                e = s + cut;
240                while e > s && matches!(text.as_bytes()[e - 1], b' ' | b'\t') {
241                    e -= 1;
242                }
243            }
244            if text[s..e].trim().is_empty() {
245                continue;
246            }
247            // Re-borrow immutably for the push below.
248            let line = self.sources.file(file).text[s..e].to_string();
249            self.physical.push(Physical {
250                text: line,
251                src: Span::new(file, s, e),
252            });
253        }
254    }
255
256    fn handle_include(&mut self, line: &str, src: Span, includer: &Path, depth: usize) {
257        if depth >= MAX_INCLUDE_DEPTH {
258            self.diags.push(Diagnostic::error(
259                format!(".include nested deeper than {MAX_INCLUDE_DEPTH} levels (cycle?)"),
260                src,
261            ));
262            return;
263        }
264        let mut fields = line.split_whitespace();
265        fields.next(); // `.include`
266        let Some(fname) = fields.next() else {
267            self.diags
268                .push(Diagnostic::error(".include filename missing", src));
269            return;
270        };
271        let path = {
272            let p = Path::new(fname);
273            if p.is_absolute() {
274                p.to_path_buf()
275            } else {
276                includer.parent().unwrap_or(Path::new(".")).join(p)
277            }
278        };
279        let budget = (MAX_TOTAL_BYTES.saturating_sub(self.loaded_bytes)) as u64;
280        match self.loader.load(&path, budget) {
281            Ok(text) => self.load(path, text, depth + 1),
282            Err(e) => self.diags.push(Diagnostic::error(
283                format!("cannot open .include file {}: {e}", path.display()),
284                src,
285            )),
286        }
287    }
288
289    /// Logical pass: title, comments, continuations.
290    fn finish(mut self) -> Deck {
291        let mut lines = self.physical.into_iter();
292        let title = match lines.next() {
293            Some(l) => l.text.trim_end().to_string(),
294            None => {
295                self.diags
296                    .push(Diagnostic::global_error("empty input deck"));
297                String::new()
298            }
299        };
300
301        let mut cards: Vec<Card> = Vec::new();
302        for line in lines {
303            let trimmed = line.text.trim_start();
304            let first = trimmed.chars().next().unwrap_or('\0');
305            match first {
306                // Whole-line comments. The C also treats `#` and `$`
307                // in column 1 this way. Comments do NOT break a
308                // continuation chain.
309                '*' | '#' | '$' => continue,
310                '+' => {
311                    let rest = &trimmed[1..];
312                    let lead = line.text.len() - trimmed.len() + 1;
313                    let src = Span {
314                        file: line.src.file,
315                        start: line.src.start + lead as u32,
316                        end: line.src.end,
317                    };
318                    match cards.last_mut() {
319                        Some(card) => card.push_continuation(rest, src),
320                        None => self.diags.push(Diagnostic::warning(
321                            "illegal continuation line: ignored",
322                            line.src,
323                        )),
324                    }
325                }
326                _ => cards.push(Card::new(line.text, line.src)),
327            }
328        }
329        Deck {
330            title,
331            cards,
332            sources: self.sources,
333            diags: self.diags,
334        }
335    }
336}
337
338fn starts_with_ignore_case(s: &str, prefix: &str) -> bool {
339    // Byte-wise: slicing `s[..prefix.len()]` would panic when the
340    // boundary lands inside a multi-byte character.
341    s.as_bytes()
342        .get(..prefix.len())
343        .is_some_and(|p| p.eq_ignore_ascii_case(prefix.as_bytes()))
344}
345
346/// Byte offset where an end-of-line comment begins (`$` or `;` at the
347/// start of the line or right after whitespace), or `s.len()`.
348fn inline_comment_start(s: &str) -> usize {
349    let bytes = s.as_bytes();
350    for (i, &b) in bytes.iter().enumerate() {
351        if (b == b'$' || b == b';') && (i == 0 || bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
352            return i;
353        }
354    }
355    s.len()
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::diag::Severity;
362
363    fn read(text: &str) -> Deck {
364        read_with_loader(Path::new("test.cir"), text.into(), &mut FailLoader)
365    }
366
367    struct FailLoader;
368    impl IncludeLoader for FailLoader {
369        fn load(&mut self, _: &Path, _: u64) -> Result<String, String> {
370            Err("no includes in this test".into())
371        }
372    }
373
374    #[test]
375    fn title_comments_and_continuations() {
376        let d = read("my title\n* comment\nr1 1 0\n+ 1k\n$ gone\nc1 2 0 1u\n");
377        assert_eq!(d.title, "my title");
378        assert_eq!(d.cards.len(), 2);
379        assert_eq!(d.cards[0].text, "r1 1 0  1k");
380        assert_eq!(d.cards[1].text, "c1 2 0 1u");
381    }
382
383    #[test]
384    fn comment_does_not_break_continuation() {
385        let d = read("t\nr1 1 0\n* note\n+1k\n");
386        assert_eq!(d.cards.len(), 1);
387        assert_eq!(d.cards[0].text, "r1 1 0 1k");
388    }
389
390    #[test]
391    fn continuation_cannot_attach_to_title() {
392        let d = read("t\n+ 1k\n");
393        assert!(d.cards.is_empty());
394        assert_eq!(d.diags.len(), 1);
395        assert_eq!(d.diags[0].severity, Severity::Warning);
396    }
397
398    #[test]
399    fn inline_dollar_and_semicolon_comments() {
400        let d = read("t\nr1 1 0 1k $ load\nc1 1 0 1u ; bypass\nv$weird 1 0\n");
401        assert_eq!(d.cards[0].text, "r1 1 0 1k");
402        assert_eq!(d.cards[1].text, "c1 1 0 1u");
403        // `$` inside a token is not a comment.
404        assert_eq!(d.cards[2].text, "v$weird 1 0");
405    }
406
407    #[test]
408    fn blank_lines_vanish_and_title_is_first_nonblank() {
409        let d = read("\n\n  \nthe title\nr1 1 0 1k\n");
410        assert_eq!(d.title, "the title");
411        assert_eq!(d.cards.len(), 1);
412    }
413
414    #[test]
415    fn missing_include_is_a_diagnostic_not_a_panic() {
416        let d = read("t\n.include nope.cir\nr1 1 0 1k\n");
417        assert_eq!(d.cards.len(), 1);
418        assert!(d.diags[0].message.contains("nope.cir"));
419    }
420
421    #[test]
422    fn include_depth_is_bounded() {
423        struct SelfLoader;
424        impl IncludeLoader for SelfLoader {
425            fn load(&mut self, _: &Path, _: u64) -> Result<String, String> {
426                Ok(".include self.cir\n".into())
427            }
428        }
429        let d = read_with_loader(
430            Path::new("test.cir"),
431            "t\n.include self.cir\n".into(),
432            &mut SelfLoader,
433        );
434        assert!(d.diags.iter().any(|g| g.message.contains("nested deeper")));
435    }
436
437    #[test]
438    fn spans_survive_continuation_joins() {
439        let d = read("t\nr1 1 0\n+ 1k\n");
440        let card = &d.cards[0];
441        // "1k" sits in the continuation segment.
442        let pos = card.text.find("1k").unwrap();
443        let span = card.span_of(pos, pos + 2);
444        assert_eq!(d.sources.snippet(span), "1k");
445        // "r1" sits in the first segment.
446        let span = card.span_of(0, 2);
447        assert_eq!(d.sources.snippet(span), "r1");
448    }
449
450    #[test]
451    fn multibyte_line_start_does_not_panic() {
452        // `starts_with_ignore_case` must not slice mid-character.
453        let d = read("t\nréseau 1 0 1k\n\u{e9}\u{e9}\u{e9} x y\n");
454        assert_eq!(d.cards.len(), 2);
455    }
456
457    #[test]
458    fn loader_budget_shrinks_with_each_include() {
459        struct Probe(Vec<u64>);
460        impl IncludeLoader for Probe {
461            fn load(&mut self, _: &Path, limit: u64) -> Result<String, String> {
462                self.0.push(limit);
463                Ok("rinc 1 0 1k\n".into())
464            }
465        }
466        let mut probe = Probe(Vec::new());
467        let d = read_with_loader(
468            Path::new("test.cir"),
469            "t\n.include a.cir\n.include b.cir\n".into(),
470            &mut probe,
471        );
472        assert_eq!(d.cards.len(), 2);
473        assert_eq!(probe.0.len(), 2);
474        // The second include's budget is smaller than the first's.
475        assert!(probe.0[1] < probe.0[0]);
476    }
477
478    #[test]
479    fn crlf_is_stripped() {
480        let d = read("t\r\nr1 1 0 1k\r\n");
481        assert_eq!(d.title, "t");
482        assert_eq!(d.cards[0].text, "r1 1 0 1k");
483    }
484}