1use std::path::{Path, PathBuf};
22
23use crate::diag::Diagnostic;
24use crate::span::{SourceMap, Span};
25
26const MAX_INCLUDE_DEPTH: usize = 16;
28const MAX_TOTAL_BYTES: usize = 256 << 20;
30
31pub trait IncludeLoader {
39 fn load(&mut self, path: &Path, limit: u64) -> Result<String, String>;
40}
41
42pub 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#[derive(Debug)]
69pub struct Card {
70 pub text: String,
71 segments: Vec<Segment>,
72}
73
74#[derive(Debug)]
75struct Segment {
76 text_start: u32,
78 len: u32,
79 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 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 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 pub fn span(&self) -> Span {
131 self.segments[0].src
132 }
133}
134
135#[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
145pub 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
156pub 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
170struct 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 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 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 while e > s && matches!(text.as_bytes()[e - 1], b'\r' | b' ' | b'\t') {
219 e -= 1;
220 }
221 if text[s..e].trim().is_empty() {
224 continue;
225 }
226 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 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 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(); 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 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 '*' | '#' | '$' => 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 s.as_bytes()
342 .get(..prefix.len())
343 .is_some_and(|p| p.eq_ignore_ascii_case(prefix.as_bytes()))
344}
345
346fn 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 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 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 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 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 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}