use std::collections::HashMap;
use std::path::Path;
use crate::config::Config;
use crate::error::Result;
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::Node;
use crate::store::{NodeKind, Store, SYSTEM_TAG_SNIPPETS};
use super::SnippetsCommand;
pub fn run(project: &Path, cmd: SnippetsCommand) -> Result<()> {
match cmd {
SnippetsCommand::List { json } => list(project, json),
SnippetsCommand::Check { book, json } => check(project, book.as_deref(), json),
}
}
fn open(project: &Path) -> Result<(Config, Store, Hierarchy)> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout, &cfg)?;
let hierarchy = Hierarchy::load(&store)?;
Ok((cfg, store, hierarchy))
}
fn read_body(store: &Store, node: &Node) -> Option<String> {
let rel = node.file.as_ref()?;
let raw = std::fs::read_to_string(store.project_root().join(rel)).ok()?;
Some(raw)
}
fn defined_slugs(h: &Hierarchy) -> Vec<String> {
let Some(book) = h
.iter()
.find(|n| n.kind == NodeKind::Book && n.system_tag.as_deref() == Some(SYSTEM_TAG_SNIPPETS))
else {
return Vec::new();
};
h.collect_subtree(book.id)
.into_iter()
.filter_map(|id| h.get(id))
.filter(|n| n.kind == NodeKind::Paragraph)
.map(|n| n.slug.clone())
.collect()
}
struct Reference {
path: String,
line: usize,
slug: String,
}
fn collect_references(store: &Store, h: &Hierarchy, book_filter: Option<&str>) -> Vec<Reference> {
let mut refs = Vec::new();
for book in h
.children_of(None)
.into_iter()
.filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
{
if let Some(f) = book_filter {
if book.slug != f && book.title != f {
continue;
}
}
for id in h.collect_subtree(book.id) {
let Some(node) = h.get(id) else { continue };
if node.kind != NodeKind::Paragraph {
continue;
}
let Some(body) = read_body(store, node) else { continue };
let path = h.slug_path(node);
for (i, line) in body.lines().enumerate() {
for slug in crate::typst_check::snippet_references(line) {
refs.push(Reference { path: path.clone(), line: i + 1, slug });
}
}
}
}
refs
}
fn json_str(s: &str) -> String {
let mut out = String::from("\"");
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' | '\r' | '\t' => out.push(' '),
other => out.push(other),
}
}
out.push('"');
out
}
fn list(project: &Path, json: bool) -> Result<()> {
let (_cfg, store, h) = open(project)?;
let slugs = defined_slugs(&h);
let mut counts: HashMap<String, usize> = slugs.iter().map(|s| (s.clone(), 0)).collect();
for r in collect_references(&store, &h, None) {
*counts.entry(r.slug).or_insert(0) += 1;
}
if json {
let mut s = String::from("[");
for (i, slug) in slugs.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&format!(
"\n {{ \"slug\": {}, \"references\": {} }}",
json_str(slug),
counts.get(slug).copied().unwrap_or(0)
));
}
if !slugs.is_empty() {
s.push('\n');
}
s.push(']');
println!("{s}");
return Ok(());
}
if slugs.is_empty() {
println!("No snippets defined in the Snippets book.");
return Ok(());
}
println!("{} snippet(s):", slugs.len());
for slug in &slugs {
let n = counts.get(slug).copied().unwrap_or(0);
println!(" {slug:<28} {n} reference(s)");
}
Ok(())
}
fn check(project: &Path, book: Option<&str>, json: bool) -> Result<()> {
let (_cfg, store, h) = open(project)?;
let defined: std::collections::HashSet<String> = defined_slugs(&h).into_iter().collect();
let references = collect_references(&store, &h, book);
let missing: Vec<&Reference> = references
.iter()
.filter(|r| !defined.contains(&r.slug))
.collect();
let referenced: std::collections::HashSet<&str> =
references.iter().map(|r| r.slug.as_str()).collect();
let orphaned: Vec<&String> = if book.is_none() {
defined.iter().filter(|s| !referenced.contains(s.as_str())).collect()
} else {
Vec::new()
};
if json {
let mut s = String::from("{\n \"missing\": [");
for (i, r) in missing.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&format!(
"\n {{ \"slug\": {}, \"path\": {}, \"line\": {} }}",
json_str(&r.slug),
json_str(&r.path),
r.line
));
}
if !missing.is_empty() {
s.push_str("\n ");
}
s.push_str("],\n \"orphaned\": [");
for (i, o) in orphaned.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&format!("\n {}", json_str(o)));
}
if !orphaned.is_empty() {
s.push_str("\n ");
}
s.push_str("]\n}");
println!("{s}");
} else if missing.is_empty() && orphaned.is_empty() {
println!("snippets check: OK — every #include resolves; no orphans.");
} else {
if !missing.is_empty() {
println!("snippets check: {} missing reference(s):", missing.len());
for r in &missing {
println!(" {} line {}: no snippet `{}`", r.path, r.line, r.slug);
}
}
if !orphaned.is_empty() {
println!("snippets check: {} orphaned snippet(s) (defined, unreferenced):", orphaned.len());
let mut o: Vec<&String> = orphaned.clone();
o.sort();
for slug in o {
println!(" {slug}");
}
}
}
if missing.is_empty() {
Ok(())
} else {
std::process::exit(1);
}
}