use typst_syntax::Source;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypstDiagnostic {
pub line: usize,
pub col: usize,
pub byte_start: usize,
pub byte_end: usize,
pub message: String,
pub hints: Vec<String>,
}
impl TypstDiagnostic {
pub fn summary(&self) -> String {
format!("typst: line {}:{} — {}", self.line, self.col, self.message)
}
}
pub fn check_includes(
source: &str,
known_slugs: &std::collections::HashSet<String>,
base_dir: Option<&std::path::Path>,
) -> Vec<TypstDiagnostic> {
let mut out = Vec::new();
let mut line_start = 0usize; for (i, line) in source.lines().enumerate() {
let mut search = 0usize;
while let Some(rel) = line[search..].find("#include") {
let after = search + rel + "#include".len();
let Some(q1_rel) = line[after..].find('"') else { break };
let path_start = after + q1_rel + 1;
let Some(q2_rel) = line[path_start..].find('"') else { break };
let path_end = path_start + q2_rel;
let path = &line[path_start..path_end];
let message = match snippet_slug_of(path) {
Some(slug) if !known_slugs.contains(&slug) => Some(format!(
"#include: no snippet `{slug}` in the Snippets book"
)),
Some(_) => None, None => {
if let Some(base) = basename_slug(path).filter(|b| known_slugs.contains(b)) {
Some(format!(
"#include: path does not point at the snippets directory — \
did you mean a `snippets/{base}.typ` include?"
))
} else if let Some(dir) = base_dir {
let resolved = normalize_join(dir, path);
(!resolved.exists())
.then(|| format!("#include \"{path}\": file not found"))
} else {
None
}
}
};
if let Some(message) = message {
let col = line[..path_start].chars().count() + 1;
out.push(TypstDiagnostic {
line: i + 1,
col,
byte_start: line_start + path_start,
byte_end: line_start + path_end,
message,
hints: vec!["snippet includes resolve as `…/snippets/<slug>.typ`".into()],
});
}
search = path_end + 1;
}
line_start += line.len() + 1; }
out
}
pub fn snippet_references(source: &str) -> Vec<String> {
let mut out = Vec::new();
for line in source.lines() {
let mut search = 0usize;
while let Some(rel) = line[search..].find("#include") {
let after = search + rel + "#include".len();
let Some(q1_rel) = line[after..].find('"') else { break };
let path_start = after + q1_rel + 1;
let Some(q2_rel) = line[path_start..].find('"') else { break };
let path_end = path_start + q2_rel;
if let Some(slug) = snippet_slug_of(&line[path_start..path_end]) {
out.push(slug);
}
search = path_end + 1;
}
}
out
}
pub fn snippet_slug_of(path: &str) -> Option<String> {
let segs: Vec<&str> = path.trim().split('/').collect();
if segs.len() < 2 || segs[segs.len() - 2] != "snippets" {
return None;
}
let slug = segs.last()?.strip_suffix(".typ")?;
(!slug.is_empty()).then(|| slug.to_string())
}
fn basename_slug(path: &str) -> Option<String> {
let file = path.trim().rsplit('/').next()?;
let slug = file.strip_suffix(".typ")?;
(!slug.is_empty()).then(|| slug.to_string())
}
fn normalize_join(base: &std::path::Path, path: &str) -> std::path::PathBuf {
let mut out = base.to_path_buf();
for comp in path.trim().split('/') {
match comp {
"" | "." => {}
".." => {
out.pop();
}
seg => out.push(seg),
}
}
out
}
pub fn check(source: &str) -> Vec<TypstDiagnostic> {
let source = Source::detached(source.to_owned());
let root = source.root();
let errors = root.errors();
if errors.is_empty() {
return Vec::new();
}
let lines = source.lines();
let mut out = Vec::with_capacity(errors.len());
for err in errors {
let range = match source.range(err.span) {
Some(r) => r,
None => continue, };
let (line0, col0) = lines
.byte_to_line_column(range.start)
.unwrap_or((0, 0));
out.push(TypstDiagnostic {
line: line0 + 1,
col: col0 + 1,
byte_start: range.start,
byte_end: range.end,
message: err.message.to_string(),
hints: err.hints.iter().map(|h| h.to_string()).collect(),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_buffer_is_clean() {
assert!(check("").is_empty());
}
#[test]
fn plain_prose_is_clean() {
let src = "The storm came up at three.\n\nThe sea kept rising.\n";
assert!(check(src).is_empty(), "got: {:?}", check(src));
}
#[test]
fn well_formed_heading_is_clean() {
let src = "= Chapter one\n\nThe storm came up at three.\n";
assert!(check(src).is_empty(), "got: {:?}", check(src));
}
#[test]
fn unterminated_string_is_an_error() {
let src = r#"#let x = "hello
broken
"#;
let diags = check(src);
assert!(!diags.is_empty(), "expected at least one diagnostic");
let first = &diags[0];
assert!(first.line >= 1);
assert!(first.col >= 1);
assert!(!first.message.is_empty());
}
#[test]
fn unbalanced_brace_reports_a_position() {
let src = "#let f() = {\n 1 + 1\n";
let diags = check(src);
assert!(!diags.is_empty());
for d in &diags {
assert!(d.line >= 1, "line was {}", d.line);
assert!(d.col >= 1, "col was {}", d.col);
assert!(
d.byte_end >= d.byte_start,
"byte range must be non-negative",
);
}
}
#[test]
fn summary_contains_line_and_message() {
let d = TypstDiagnostic {
line: 12,
col: 5,
byte_start: 100,
byte_end: 110,
message: "unexpected token".to_owned(),
hints: vec![],
};
let s = d.summary();
assert!(s.contains("line 12:5"));
assert!(s.contains("unexpected token"));
}
fn slugs(s: &[&str]) -> std::collections::HashSet<String> {
s.iter().map(|x| x.to_string()).collect()
}
#[test]
fn check_includes_flags_unknown_snippet_slug() {
let known = slugs(&["warning-box"]);
assert!(check_includes(
"text\n#include \"../../snippets/warning-box.typ\"\n",
&known,
None
)
.is_empty());
let d = check_includes("line one\n#include \"../snippets/missing.typ\"\n", &known, None);
assert_eq!(d.len(), 1);
assert_eq!(d[0].line, 2);
assert!(d[0].message.contains("missing"), "{}", d[0].message);
}
#[test]
fn check_includes_flags_typoed_snippets_directory() {
let known = slugs(&["this-is-the-snippet-1"]);
let d = check_includes("#include \"../nippets/this-is-the-snippet-1.typ\"", &known, None);
assert_eq!(d.len(), 1);
assert!(d[0].message.contains("snippets directory"), "{}", d[0].message);
assert!(check_includes("#include \"../lib/helpers.typ\"", &known, None).is_empty());
assert!(check_includes("#include \"../snippets/this-is-the-snippet-1.typ\"", &known, None).is_empty());
}
#[test]
fn check_includes_ignores_non_snippet_includes() {
let known = slugs(&[]);
assert!(check_includes("#include \"globals.typ\"", &known, None).is_empty());
assert!(check_includes("#include \"../other/foo.typ\"", &known, None).is_empty());
assert!(check_includes("#include \"mysnippets/x.typ\"", &known, None).is_empty());
}
#[test]
fn check_includes_resolves_generic_includes_against_assembled_dir() {
let known = slugs(&["warning-box"]);
let root = std::env::temp_dir().join(format!("inkhaven-incl-{}", std::process::id()));
let chapter = root.join("book").join("01-intro");
std::fs::create_dir_all(chapter.join("..").join("lib")).unwrap();
std::fs::write(root.join("book").join("globals.typ"), "// globals").unwrap();
std::fs::write(root.join("book").join("lib").join("helpers.typ"), "// h").unwrap();
let base = Some(chapter.as_path());
assert!(check_includes("#include \"../globals.typ\"", &known, base).is_empty());
assert!(check_includes("#include \"../lib/helpers.typ\"", &known, base).is_empty());
let d = check_includes("#include \"../lib/missing.typ\"", &known, base);
assert_eq!(d.len(), 1, "{d:?}");
assert!(d[0].message.contains("file not found"), "{}", d[0].message);
let d = check_includes("#include \"../snippets/nope.typ\"", &known, base);
assert_eq!(d.len(), 1);
assert!(d[0].message.contains("Snippets book"), "{}", d[0].message);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn snippet_references_extracts_slugs() {
let src = "#include \"../snippets/a.typ\"\ntext\n#include \"../../snippets/b.typ\" #include \"globals.typ\"\n";
assert_eq!(snippet_references(src), vec!["a", "b"]);
assert_eq!(
snippet_references("#include \"../snippets/x.typ\"\n#include \"../snippets/x.typ\""),
vec!["x", "x"]
);
assert!(snippet_references("no includes here").is_empty());
}
#[test]
fn check_includes_two_on_one_line_flags_only_the_unknown() {
let known = slugs(&["a"]);
let src = "#include \"../snippets/a.typ\" then #include \"../snippets/b.typ\"";
let d = check_includes(src, &known, None);
assert_eq!(d.len(), 1);
assert_eq!(d[0].line, 1);
assert!(d[0].message.contains("`b`"), "{}", d[0].message);
}
use proptest::prelude::*;
proptest! {
#[test]
fn check_includes_never_panics(src in "\\PC{0,400}") {
let known: std::collections::HashSet<String> =
["a", "b"].iter().map(|s| s.to_string()).collect();
let _ = check_includes(&src, &known, None);
}
#[test]
fn check_never_panics(src in "\\PC{0,400}") {
let _ = check(&src);
}
#[test]
fn check_never_panics_on_markup_salad(
toks in proptest::collection::vec(
proptest::sample::select(vec![
"$", "#", "[", "]", "{", "}", "(", ")", "*", "_", "=",
"\\", "", "let", "x", " ", "\n", "café", "—",
]),
0..200,
),
) {
let _ = check(&toks.concat());
}
}
}