use std::path::{Path, PathBuf};
use crate::diag::Diagnostic;
use crate::span::{SourceMap, Span};
const MAX_INCLUDE_DEPTH: usize = 16;
const MAX_TOTAL_BYTES: usize = 256 << 20;
pub trait IncludeLoader {
fn load(&mut self, path: &Path, limit: u64) -> Result<String, String>;
}
pub struct FsLoader;
impl IncludeLoader for FsLoader {
fn load(&mut self, path: &Path, limit: u64) -> Result<String, String> {
use std::io::Read;
let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
if !meta.is_file() {
return Err("not a regular file".into());
}
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut text = String::new();
file.take(limit.saturating_add(1))
.read_to_string(&mut text)
.map_err(|e| e.to_string())?;
if text.len() as u64 > limit {
return Err(format!(
"larger than the deck's remaining {limit}-byte budget"
));
}
Ok(text)
}
}
#[derive(Debug)]
pub struct Card {
pub text: String,
segments: Vec<Segment>,
}
#[derive(Debug)]
struct Segment {
text_start: u32,
len: u32,
src: Span,
}
impl Card {
fn new(text: String, src: Span) -> Self {
let len = text.len() as u32;
Card {
text,
segments: vec![Segment {
text_start: 0,
len,
src,
}],
}
}
fn push_continuation(&mut self, more: &str, src: Span) {
self.text.push(' ');
let text_start = self.text.len() as u32;
self.text.push_str(more);
self.segments.push(Segment {
text_start,
len: more.len() as u32,
src,
});
}
pub fn span_of(&self, start: usize, end: usize) -> Span {
let start = start as u32;
let end = end as u32;
let seg = self
.segments
.iter()
.rev()
.find(|s| s.text_start <= start)
.unwrap_or(&self.segments[0]);
let off = start.saturating_sub(seg.text_start).min(seg.len);
let len = end.saturating_sub(start).min(seg.len - off);
Span {
file: seg.src.file,
start: seg.src.start + off,
end: seg.src.start + off + len,
}
}
pub fn span(&self) -> Span {
self.segments[0].src
}
}
#[derive(Debug)]
pub struct Deck {
pub title: String,
pub cards: Vec<Card>,
pub sources: SourceMap,
pub diags: Vec<Diagnostic>,
}
pub fn read_file(path: &Path) -> Result<Deck, crate::Error> {
let text = FsLoader
.load(path, MAX_TOTAL_BYTES as u64)
.map_err(|message| crate::Error::Io {
path: path.to_path_buf(),
message,
})?;
Ok(read_with_loader(path, text, &mut FsLoader))
}
pub fn read_with_loader(name: &Path, text: String, loader: &mut dyn IncludeLoader) -> Deck {
let mut r = Reader {
sources: SourceMap::new(),
physical: Vec::new(),
diags: Vec::new(),
loaded_bytes: 0,
loader,
};
r.load(name.to_path_buf(), text, 0);
r.finish()
}
struct Physical {
text: String,
src: Span,
}
struct Reader<'a> {
sources: SourceMap,
physical: Vec<Physical>,
diags: Vec<Diagnostic>,
loaded_bytes: usize,
loader: &'a mut dyn IncludeLoader,
}
impl Reader<'_> {
fn load(&mut self, name: PathBuf, text: String, depth: usize) {
self.loaded_bytes = self.loaded_bytes.saturating_add(text.len());
if self.loaded_bytes > MAX_TOTAL_BYTES {
self.diags.push(Diagnostic::global_error(format!(
"deck exceeds {} MiB of source text; giving up",
MAX_TOTAL_BYTES >> 20
)));
return;
}
let Some(file) = self.sources.add(name.clone(), text) else {
self.diags
.push(Diagnostic::global_error("source file too large"));
return;
};
let text = &self.sources.file(file).text;
let mut line_ranges = Vec::new();
let mut start = 0usize;
for (i, b) in text.bytes().enumerate() {
if b == b'\n' {
line_ranges.push((start, i));
start = i + 1;
}
}
if start < text.len() {
line_ranges.push((start, text.len()));
}
for (s, mut e) in line_ranges {
let text = &self.sources.file(file).text;
while e > s && matches!(text.as_bytes()[e - 1], b'\r' | b' ' | b'\t') {
e -= 1;
}
if text[s..e].trim().is_empty() {
continue;
}
if starts_with_ignore_case(&text[s..e], ".include") {
let line = text[s..e].to_string();
let src = Span::new(file, s, e);
self.handle_include(&line, src, &name, depth);
continue;
}
let is_title = depth == 0 && self.physical.is_empty();
if !is_title {
let cut = inline_comment_start(&text[s..e]);
e = s + cut;
while e > s && matches!(text.as_bytes()[e - 1], b' ' | b'\t') {
e -= 1;
}
}
if text[s..e].trim().is_empty() {
continue;
}
let line = self.sources.file(file).text[s..e].to_string();
self.physical.push(Physical {
text: line,
src: Span::new(file, s, e),
});
}
}
fn handle_include(&mut self, line: &str, src: Span, includer: &Path, depth: usize) {
if depth >= MAX_INCLUDE_DEPTH {
self.diags.push(Diagnostic::error(
format!(".include nested deeper than {MAX_INCLUDE_DEPTH} levels (cycle?)"),
src,
));
return;
}
let mut fields = line.split_whitespace();
fields.next(); let Some(fname) = fields.next() else {
self.diags
.push(Diagnostic::error(".include filename missing", src));
return;
};
let path = {
let p = Path::new(fname);
if p.is_absolute() {
p.to_path_buf()
} else {
includer.parent().unwrap_or(Path::new(".")).join(p)
}
};
let budget = (MAX_TOTAL_BYTES.saturating_sub(self.loaded_bytes)) as u64;
match self.loader.load(&path, budget) {
Ok(text) => self.load(path, text, depth + 1),
Err(e) => self.diags.push(Diagnostic::error(
format!("cannot open .include file {}: {e}", path.display()),
src,
)),
}
}
fn finish(mut self) -> Deck {
let mut lines = self.physical.into_iter();
let title = match lines.next() {
Some(l) => l.text.trim_end().to_string(),
None => {
self.diags
.push(Diagnostic::global_error("empty input deck"));
String::new()
}
};
let mut cards: Vec<Card> = Vec::new();
for line in lines {
let trimmed = line.text.trim_start();
let first = trimmed.chars().next().unwrap_or('\0');
match first {
'*' | '#' | '$' => continue,
'+' => {
let rest = &trimmed[1..];
let lead = line.text.len() - trimmed.len() + 1;
let src = Span {
file: line.src.file,
start: line.src.start + lead as u32,
end: line.src.end,
};
match cards.last_mut() {
Some(card) => card.push_continuation(rest, src),
None => self.diags.push(Diagnostic::warning(
"illegal continuation line: ignored",
line.src,
)),
}
}
_ => cards.push(Card::new(line.text, line.src)),
}
}
Deck {
title,
cards,
sources: self.sources,
diags: self.diags,
}
}
}
fn starts_with_ignore_case(s: &str, prefix: &str) -> bool {
s.as_bytes()
.get(..prefix.len())
.is_some_and(|p| p.eq_ignore_ascii_case(prefix.as_bytes()))
}
fn inline_comment_start(s: &str) -> usize {
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if (b == b'$' || b == b';') && (i == 0 || bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
return i;
}
}
s.len()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diag::Severity;
fn read(text: &str) -> Deck {
read_with_loader(Path::new("test.cir"), text.into(), &mut FailLoader)
}
struct FailLoader;
impl IncludeLoader for FailLoader {
fn load(&mut self, _: &Path, _: u64) -> Result<String, String> {
Err("no includes in this test".into())
}
}
#[test]
fn title_comments_and_continuations() {
let d = read("my title\n* comment\nr1 1 0\n+ 1k\n$ gone\nc1 2 0 1u\n");
assert_eq!(d.title, "my title");
assert_eq!(d.cards.len(), 2);
assert_eq!(d.cards[0].text, "r1 1 0 1k");
assert_eq!(d.cards[1].text, "c1 2 0 1u");
}
#[test]
fn comment_does_not_break_continuation() {
let d = read("t\nr1 1 0\n* note\n+1k\n");
assert_eq!(d.cards.len(), 1);
assert_eq!(d.cards[0].text, "r1 1 0 1k");
}
#[test]
fn continuation_cannot_attach_to_title() {
let d = read("t\n+ 1k\n");
assert!(d.cards.is_empty());
assert_eq!(d.diags.len(), 1);
assert_eq!(d.diags[0].severity, Severity::Warning);
}
#[test]
fn inline_dollar_and_semicolon_comments() {
let d = read("t\nr1 1 0 1k $ load\nc1 1 0 1u ; bypass\nv$weird 1 0\n");
assert_eq!(d.cards[0].text, "r1 1 0 1k");
assert_eq!(d.cards[1].text, "c1 1 0 1u");
assert_eq!(d.cards[2].text, "v$weird 1 0");
}
#[test]
fn blank_lines_vanish_and_title_is_first_nonblank() {
let d = read("\n\n \nthe title\nr1 1 0 1k\n");
assert_eq!(d.title, "the title");
assert_eq!(d.cards.len(), 1);
}
#[test]
fn missing_include_is_a_diagnostic_not_a_panic() {
let d = read("t\n.include nope.cir\nr1 1 0 1k\n");
assert_eq!(d.cards.len(), 1);
assert!(d.diags[0].message.contains("nope.cir"));
}
#[test]
fn include_depth_is_bounded() {
struct SelfLoader;
impl IncludeLoader for SelfLoader {
fn load(&mut self, _: &Path, _: u64) -> Result<String, String> {
Ok(".include self.cir\n".into())
}
}
let d = read_with_loader(
Path::new("test.cir"),
"t\n.include self.cir\n".into(),
&mut SelfLoader,
);
assert!(d.diags.iter().any(|g| g.message.contains("nested deeper")));
}
#[test]
fn spans_survive_continuation_joins() {
let d = read("t\nr1 1 0\n+ 1k\n");
let card = &d.cards[0];
let pos = card.text.find("1k").unwrap();
let span = card.span_of(pos, pos + 2);
assert_eq!(d.sources.snippet(span), "1k");
let span = card.span_of(0, 2);
assert_eq!(d.sources.snippet(span), "r1");
}
#[test]
fn multibyte_line_start_does_not_panic() {
let d = read("t\nréseau 1 0 1k\n\u{e9}\u{e9}\u{e9} x y\n");
assert_eq!(d.cards.len(), 2);
}
#[test]
fn loader_budget_shrinks_with_each_include() {
struct Probe(Vec<u64>);
impl IncludeLoader for Probe {
fn load(&mut self, _: &Path, limit: u64) -> Result<String, String> {
self.0.push(limit);
Ok("rinc 1 0 1k\n".into())
}
}
let mut probe = Probe(Vec::new());
let d = read_with_loader(
Path::new("test.cir"),
"t\n.include a.cir\n.include b.cir\n".into(),
&mut probe,
);
assert_eq!(d.cards.len(), 2);
assert_eq!(probe.0.len(), 2);
assert!(probe.0[1] < probe.0[0]);
}
#[test]
fn crlf_is_stripped() {
let d = read("t\r\nr1 1 0 1k\r\n");
assert_eq!(d.title, "t");
assert_eq!(d.cards[0].text, "r1 1 0 1k");
}
}