feat: improve typst handling
This commit is contained in:
@@ -0,0 +1,743 @@
|
||||
//! Loading a Typst file and splicing generated data into it.
|
||||
//!
|
||||
//! The previous version of this module built a document with `format!`. That made
|
||||
//! every layout decision a code change, which is the wrong place for a decision
|
||||
//! about where the points label sits. So the tool no longer writes documents; it
|
||||
//! writes *data into* a document you own.
|
||||
//!
|
||||
//! ## Markers
|
||||
//!
|
||||
//! A template marks its injection points with Typst line comments, which means a
|
||||
//! template is a valid `.typ` file that compiles on its own and can be styled
|
||||
//! without this tool in the loop:
|
||||
//!
|
||||
//! ```typst
|
||||
//! // coursebank:begin questions
|
||||
//! #render-question((number: 1, stem: [Sample.], options: ()))
|
||||
//! // coursebank:end questions
|
||||
//! ```
|
||||
//!
|
||||
//! Everything between the `begin` and `end` lines is replaced; the marker lines
|
||||
//! themselves survive. That has two consequences worth stating plainly:
|
||||
//!
|
||||
//! * The bundled templates ship with sample data inside their regions, so
|
||||
//! `typst compile templates/exam.typ` works before any export has happened.
|
||||
//! * An exported document is itself a valid template. Re-exporting into a file you
|
||||
//! have since restyled replaces the questions and leaves your edits alone, which
|
||||
//! is the difference between a generator you can use twice and one you copy out
|
||||
//! of once.
|
||||
//!
|
||||
//! A bare `// coursebank:questions` with no region also works. It is rewritten
|
||||
//! into a region on output, so the second export behaves like every subsequent one.
|
||||
//!
|
||||
//! ## Slots
|
||||
//!
|
||||
//! | Slot | Injected |
|
||||
//! |:--|:--|
|
||||
//! | `meta` | `#let cb-meta = (...)` — course, assessment, form, totals |
|
||||
//! | `questions` | one `#render-question((...))` call per printed item |
|
||||
//! | `data` | `#let cb-data = (...)` — metadata and questions together |
|
||||
//!
|
||||
//! `questions` unrolls the loop with the record's own numbering. `data` hands you
|
||||
//! the array and gets out of the way. Templates are free to use either, both, or
|
||||
//! neither; only slots the template actually contains are rendered, so nothing
|
||||
//! costs anything until it is asked for.
|
||||
//!
|
||||
//! ## Lookup order
|
||||
//!
|
||||
//! 1. an explicit `--template` path, or `template:` in the render config
|
||||
//! 2. `templates/<assessment-id>-<variant>.typ`, for a one-off layout
|
||||
//! 3. `templates/<variant>.typ`, the course's own default
|
||||
//! 4. the template compiled into this binary
|
||||
//!
|
||||
//! `coursebank template dump` writes step 4 into step 3 so that customizing means
|
||||
//! editing a file rather than reading this documentation.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::course::Layout;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
use super::config::Variant;
|
||||
|
||||
/// The prefix every marker comment carries.
|
||||
const MARKER: &str = "coursebank:";
|
||||
|
||||
/// The bundled exam paper template.
|
||||
const EMBEDDED_EXAM: &str = include_str!("templates/exam.typ");
|
||||
|
||||
/// The bundled answer key template.
|
||||
const EMBEDDED_KEY: &str = include_str!("templates/key.typ");
|
||||
|
||||
/// The bundled answer sheet template.
|
||||
const EMBEDDED_ANSWER_SHEET: &str = include_str!("templates/answer-sheet.typ");
|
||||
|
||||
/// An injection point a template can declare.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Slot {
|
||||
/// Course, assessment, form, and totals, as a `#let` binding.
|
||||
Meta,
|
||||
/// One call per printed item.
|
||||
Questions,
|
||||
/// Metadata and questions together, as a `#let` binding.
|
||||
Data,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// Every slot.
|
||||
pub const ALL: [Slot; 3] = [Slot::Meta, Slot::Questions, Slot::Data];
|
||||
|
||||
/// The name used in a marker comment.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Slot::Meta => "meta",
|
||||
Slot::Questions => "questions",
|
||||
Slot::Data => "data",
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot for a marker name.
|
||||
fn parse(s: &str) -> Option<Slot> {
|
||||
Slot::ALL.iter().copied().find(|slot| slot.as_str() == s)
|
||||
}
|
||||
|
||||
/// A comma-separated list of every slot name, for error messages.
|
||||
fn names() -> String {
|
||||
Slot::ALL
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a template came from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Origin {
|
||||
/// Compiled into the binary.
|
||||
Embedded,
|
||||
/// Read from disk.
|
||||
File(PathBuf),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Origin {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Origin::Embedded => f.write_str("built-in"),
|
||||
Origin::File(path) => write!(f, "{}", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded template, with its markers already located.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Template {
|
||||
/// Which document this template produces.
|
||||
pub variant: Variant,
|
||||
/// Where it was loaded from.
|
||||
pub origin: Origin,
|
||||
/// The full source.
|
||||
pub source: String,
|
||||
/// The regions found, in the order they appear.
|
||||
regions: Vec<Region>,
|
||||
}
|
||||
|
||||
/// One located marker, as a half-open line range to replace.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Region {
|
||||
/// Which slot.
|
||||
slot: Slot,
|
||||
/// The marker line's leading whitespace, reapplied to every injected line so
|
||||
/// data nested inside a Typst block stays readable.
|
||||
indent: String,
|
||||
/// First line index of the marker, which is the `begin` line for a region.
|
||||
start: usize,
|
||||
/// One past the `end` line index, or one past a bare point marker.
|
||||
end: usize,
|
||||
}
|
||||
|
||||
/// The source of the template compiled in for a variant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - which document.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The bundled template source.
|
||||
pub fn embedded(variant: Variant) -> &'static str {
|
||||
match variant {
|
||||
Variant::Exam => EMBEDDED_EXAM,
|
||||
Variant::Key => EMBEDDED_KEY,
|
||||
Variant::AnswerSheet => EMBEDDED_ANSWER_SHEET,
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory holding a course's template overrides.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the course layout.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The `templates/` directory, which need not exist.
|
||||
pub fn dir(layout: &Layout) -> PathBuf {
|
||||
layout.templates()
|
||||
}
|
||||
|
||||
/// The paths that are consulted for a variant, in order.
|
||||
///
|
||||
/// Exposed so `coursebank template list` can show where a template would be found
|
||||
/// and why, rather than leaving the lookup order to be inferred.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the course layout.
|
||||
/// * `variant` - which document.
|
||||
/// * `assessment_id` - the assessment being exported, if one is in hand.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Candidate paths, most specific first.
|
||||
pub fn candidates(layout: &Layout, variant: Variant, assessment_id: Option<&str>) -> Vec<PathBuf> {
|
||||
let base = dir(layout);
|
||||
let mut paths = Vec::new();
|
||||
if let Some(id) = assessment_id {
|
||||
paths.push(base.join(format!("{id}-{}", variant.template_file())));
|
||||
}
|
||||
paths.push(base.join(variant.template_file()));
|
||||
paths
|
||||
}
|
||||
|
||||
/// Loads the template for a variant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the course layout.
|
||||
/// * `variant` - which document.
|
||||
/// * `assessment_id` - the assessment being exported, if one is in hand.
|
||||
/// * `explicit` - a path that overrides the lookup entirely.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The loaded template.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Io`] when an explicitly requested template cannot be read, and
|
||||
/// [`Error::Invalid`] when the template's markers are malformed. A missing file in
|
||||
/// the lookup chain is not an error; it just falls through to the next candidate.
|
||||
pub fn load(
|
||||
layout: &Layout,
|
||||
variant: Variant,
|
||||
assessment_id: Option<&str>,
|
||||
explicit: Option<&Path>,
|
||||
) -> Result<Template> {
|
||||
if let Some(path) = explicit {
|
||||
let source = std::fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
|
||||
return Template::parse(variant, Origin::File(path.to_path_buf()), source);
|
||||
}
|
||||
|
||||
for path in candidates(layout, variant, assessment_id) {
|
||||
if path.is_file() {
|
||||
let source = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
|
||||
return Template::parse(variant, Origin::File(path), source);
|
||||
}
|
||||
}
|
||||
|
||||
Template::parse(variant, Origin::Embedded, embedded(variant).to_string())
|
||||
}
|
||||
|
||||
impl Template {
|
||||
/// Parses a template, locating its markers.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - which document.
|
||||
/// * `origin` - where the source came from.
|
||||
/// * `source` - the template source.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The parsed template.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Invalid`] listing every marker problem at once: an unknown
|
||||
/// slot name, a `begin` with no `end`, an `end` with no `begin`, a nested
|
||||
/// region, or the same slot claimed twice.
|
||||
pub fn parse(variant: Variant, origin: Origin, source: String) -> Result<Template> {
|
||||
let lines: Vec<&str> = source.lines().collect();
|
||||
let mut regions: Vec<Region> = Vec::new();
|
||||
let mut issues: Vec<String> = Vec::new();
|
||||
let mut open: Option<(Slot, String, usize)> = None;
|
||||
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let Some(marker) = parse_marker(line) else {
|
||||
continue;
|
||||
};
|
||||
let human = index + 1;
|
||||
|
||||
match marker.kind {
|
||||
MarkerKind::Begin => {
|
||||
if let Some((slot, _, at)) = &open {
|
||||
issues.push(format!(
|
||||
"line {human}: `begin {}` opens inside the region `{}` opened on line \
|
||||
{}; regions cannot nest",
|
||||
marker.name,
|
||||
slot.as_str(),
|
||||
at + 1
|
||||
));
|
||||
continue;
|
||||
}
|
||||
match Slot::parse(&marker.name) {
|
||||
Some(slot) => open = Some((slot, marker.indent, index)),
|
||||
None => issues.push(unknown_slot(human, &marker.name)),
|
||||
}
|
||||
}
|
||||
MarkerKind::End => match open.take() {
|
||||
Some((slot, indent, start)) => {
|
||||
if slot.as_str() != marker.name {
|
||||
issues.push(format!(
|
||||
"line {human}: `end {}` closes the region `{}` opened on line \
|
||||
{}",
|
||||
marker.name,
|
||||
slot.as_str(),
|
||||
start + 1
|
||||
));
|
||||
}
|
||||
regions.push(Region {
|
||||
slot,
|
||||
indent,
|
||||
start,
|
||||
end: index + 1,
|
||||
});
|
||||
}
|
||||
None => issues.push(format!(
|
||||
"line {human}: `end {}` has no matching `begin`",
|
||||
marker.name
|
||||
)),
|
||||
},
|
||||
MarkerKind::Point => {
|
||||
if open.is_some() {
|
||||
// A point marker inside a region would be overwritten by
|
||||
// the region's own injection, so it is a mistake worth
|
||||
// naming rather than silently dropping.
|
||||
issues.push(format!(
|
||||
"line {human}: the marker `{}` sits inside an open region and would be \
|
||||
overwritten",
|
||||
marker.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
match Slot::parse(&marker.name) {
|
||||
Some(slot) => regions.push(Region {
|
||||
slot,
|
||||
indent: marker.indent,
|
||||
start: index,
|
||||
end: index + 1,
|
||||
}),
|
||||
None => issues.push(unknown_slot(human, &marker.name)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((slot, _, start)) = open {
|
||||
issues.push(format!(
|
||||
"line {}: the region `{}` is never closed; add `// coursebank:end {}`",
|
||||
start + 1,
|
||||
slot.as_str(),
|
||||
slot.as_str()
|
||||
));
|
||||
}
|
||||
|
||||
for slot in Slot::ALL {
|
||||
let count = regions.iter().filter(|r| r.slot == slot).count();
|
||||
if count > 1 {
|
||||
issues.push(format!(
|
||||
"the slot `{}` appears {count} times; each slot may be filled once",
|
||||
slot.as_str()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !issues.is_empty() {
|
||||
issues.insert(0, format!("in the Typst template {origin}:"));
|
||||
return Err(Error::Invalid(issues));
|
||||
}
|
||||
|
||||
Ok(Template {
|
||||
variant,
|
||||
origin,
|
||||
source,
|
||||
regions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the template asks for a slot.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `slot` - the slot to look for.
|
||||
pub fn wants(&self, slot: Slot) -> bool {
|
||||
self.regions.iter().any(|r| r.slot == slot)
|
||||
}
|
||||
|
||||
/// The slots this template declares, in the order they appear.
|
||||
pub fn slots(&self) -> Vec<Slot> {
|
||||
self.regions.iter().map(|r| r.slot).collect()
|
||||
}
|
||||
|
||||
/// Whether the template declares no markers at all.
|
||||
pub fn is_inert(&self) -> bool {
|
||||
self.regions.is_empty()
|
||||
}
|
||||
|
||||
/// Renders the template with the given slot bodies.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `bodies` - the Typst source to inject, by slot. A slot the template does
|
||||
/// not declare is ignored; a slot the template declares but that has no body
|
||||
/// is emitted as an empty region.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The finished document. Every injected region is delimited by `begin`/`end`
|
||||
/// markers, including regions that came from a bare point marker, so the
|
||||
/// output can be used as the template for the next export.
|
||||
pub fn render(&self, bodies: &[(Slot, String)]) -> String {
|
||||
let lines: Vec<&str> = self.source.lines().collect();
|
||||
let mut out = String::with_capacity(self.source.len() + 4096);
|
||||
let mut cursor = 0usize;
|
||||
|
||||
// `parse` produced regions in source order and rejected overlaps, so a
|
||||
// single forward pass is enough.
|
||||
for region in &self.regions {
|
||||
for line in &lines[cursor..region.start] {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
let body = bodies
|
||||
.iter()
|
||||
.find(|(slot, _)| *slot == region.slot)
|
||||
.map(|(_, body)| body.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let name = region.slot.as_str();
|
||||
let indent = region.indent.as_str();
|
||||
|
||||
out.push_str(indent);
|
||||
out.push_str("// ");
|
||||
out.push_str(MARKER);
|
||||
out.push_str("begin ");
|
||||
out.push_str(name);
|
||||
out.push('\n');
|
||||
|
||||
for line in body.lines() {
|
||||
if line.trim().is_empty() {
|
||||
out.push('\n');
|
||||
} else {
|
||||
out.push_str(indent);
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str(indent);
|
||||
out.push_str("// ");
|
||||
out.push_str(MARKER);
|
||||
out.push_str("end ");
|
||||
out.push_str(name);
|
||||
out.push('\n');
|
||||
|
||||
// Everything from the opening marker through the closing one has now
|
||||
// been rewritten, so resume after it.
|
||||
cursor = region.end;
|
||||
}
|
||||
|
||||
for line in &lines[cursor..] {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the bundled templates into a directory.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `dir` - the destination directory, created if absent.
|
||||
/// * `variants` - which templates to write.
|
||||
/// * `force` - whether to overwrite files that already exist.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The paths written, and the paths skipped because they already existed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Io`] when the directory or a file cannot be written.
|
||||
pub fn dump(dir: &Path, variants: &[Variant], force: bool) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
let mut written = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for variant in variants {
|
||||
let path = dir.join(variant.template_file());
|
||||
if path.exists() && !force {
|
||||
// These are hand-edited files. Clobbering one is the sort of thing you
|
||||
// discover after you have already lost the edit.
|
||||
skipped.push(path);
|
||||
continue;
|
||||
}
|
||||
crate::yaml::write_text(&path, embedded(*variant))?;
|
||||
written.push(path);
|
||||
}
|
||||
Ok((written, skipped))
|
||||
}
|
||||
|
||||
/// A marker comment, parsed.
|
||||
struct Marker {
|
||||
kind: MarkerKind,
|
||||
name: String,
|
||||
indent: String,
|
||||
}
|
||||
|
||||
/// What a marker comment does.
|
||||
enum MarkerKind {
|
||||
/// Opens a replaceable region.
|
||||
Begin,
|
||||
/// Closes one.
|
||||
End,
|
||||
/// A standalone insertion point.
|
||||
Point,
|
||||
}
|
||||
|
||||
/// Parses one line as a marker comment, if it is one.
|
||||
///
|
||||
/// Recognized forms, with any leading whitespace and any run of `/` accepted:
|
||||
///
|
||||
/// ```text
|
||||
/// // coursebank:questions
|
||||
/// // coursebank:begin questions
|
||||
/// // coursebank:end questions
|
||||
/// ```
|
||||
///
|
||||
/// `begin`/`end` may also be spelled with a colon (`coursebank:begin:questions`),
|
||||
/// because that is how people guess it.
|
||||
fn parse_marker(line: &str) -> Option<Marker> {
|
||||
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
|
||||
let rest = line[indent.len()..].trim_end();
|
||||
|
||||
let rest = rest
|
||||
.strip_prefix("//")?
|
||||
.trim_start_matches('/')
|
||||
.trim_start();
|
||||
let rest = rest.strip_prefix(MARKER)?.trim();
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (kind, name) = if let Some(name) = strip_word(rest, "begin") {
|
||||
(MarkerKind::Begin, name)
|
||||
} else if let Some(name) = strip_word(rest, "end") {
|
||||
(MarkerKind::End, name)
|
||||
} else {
|
||||
(MarkerKind::Point, rest)
|
||||
};
|
||||
|
||||
Some(Marker {
|
||||
kind,
|
||||
name: name.trim().to_ascii_lowercase(),
|
||||
indent,
|
||||
})
|
||||
}
|
||||
|
||||
/// Strips a leading `begin`/`end` keyword, separated by whitespace or a colon.
|
||||
fn strip_word<'a>(s: &'a str, word: &str) -> Option<&'a str> {
|
||||
let rest = s.strip_prefix(word)?;
|
||||
match rest.chars().next() {
|
||||
Some(c) if c.is_whitespace() || c == ':' => Some(rest[c.len_utf8()..].trim_start()),
|
||||
// `begin` alone, with no slot named.
|
||||
None => Some(""),
|
||||
// `beginning`, which is a slot name that happens to start with `begin`.
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The message for a marker naming a slot that does not exist.
|
||||
fn unknown_slot(line: usize, name: &str) -> String {
|
||||
format!(
|
||||
"line {line}: unknown slot `{name}`; expected one of {}",
|
||||
Slot::names()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(source: &str) -> Result<Template> {
|
||||
Template::parse(Variant::Exam, Origin::Embedded, source.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_marker_is_replaced_and_becomes_a_region() {
|
||||
// The second export has to behave like every one after it, so a point
|
||||
// marker is rewritten into a region on the way out.
|
||||
let template = parse("before\n// coursebank:questions\nafter\n").unwrap();
|
||||
let out = template.render(&[(Slot::Questions, "#q(1)".to_string())]);
|
||||
assert!(out.contains("before\n"));
|
||||
assert!(out.contains("// coursebank:begin questions\n#q(1)\n"));
|
||||
assert!(out.contains("// coursebank:end questions\n"));
|
||||
assert!(out.contains("after\n"));
|
||||
assert!(!out.contains("#q(1)\n#q(1)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_region_replaces_its_body_and_keeps_its_markers() {
|
||||
let template = parse(
|
||||
"head\n// coursebank:begin questions\nstale sample data\nmore stale\n// \
|
||||
coursebank:end questions\ntail\n",
|
||||
)
|
||||
.unwrap();
|
||||
let out = template.render(&[(Slot::Questions, "#q(1)".to_string())]);
|
||||
assert!(!out.contains("stale"), "old body survived: {out}");
|
||||
assert!(out.contains("#q(1)"));
|
||||
assert!(out.contains("head\n"));
|
||||
assert!(out.contains("tail\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_is_idempotent() {
|
||||
// Exporting into a file that was itself exported must replace the
|
||||
// questions and leave the surrounding edits alone. This is the property
|
||||
// that makes "restyle the output, then re-export" a workable habit.
|
||||
let template = parse("// coursebank:questions\n").unwrap();
|
||||
let first = template.render(&[(Slot::Questions, "#q(1)".to_string())]);
|
||||
let again = parse(&first).unwrap();
|
||||
let second = again.render(&[(Slot::Questions, "#q(2)".to_string())]);
|
||||
assert!(second.contains("#q(2)"));
|
||||
assert!(!second.contains("#q(1)"));
|
||||
assert_eq!(second.matches("coursebank:begin questions").count(), 1);
|
||||
assert_eq!(second.matches("coursebank:end questions").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indentation_is_reapplied_to_injected_lines() {
|
||||
let template = parse("#block[\n // coursebank:questions\n]\n").unwrap();
|
||||
let out = template.render(&[(Slot::Questions, "#q(\n 1,\n)".to_string())]);
|
||||
assert!(out.contains(" // coursebank:begin questions"), "{out}");
|
||||
assert!(out.contains(" #q("), "{out}");
|
||||
assert!(out.contains(" 1,"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_declared_slots_are_reported() {
|
||||
let template = parse("// coursebank:meta\n// coursebank:questions\n").unwrap();
|
||||
assert!(template.wants(Slot::Meta));
|
||||
assert!(template.wants(Slot::Questions));
|
||||
assert!(!template.wants(Slot::Data));
|
||||
assert_eq!(template.slots(), vec![Slot::Meta, Slot::Questions]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_with_no_markers_is_reported_as_inert() {
|
||||
// Not an error: someone may want a fully hand-written paper. But the CLI
|
||||
// warns, because silently writing a document with no questions in it is
|
||||
// not a good afternoon.
|
||||
let template = parse("#set page(paper: \"us-letter\")\n").unwrap();
|
||||
assert!(template.is_inert());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_slot_names_the_valid_ones() {
|
||||
let err = parse("// coursebank:qustions\n").unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("unknown slot `qustions`"), "{message}");
|
||||
assert!(message.contains("questions"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_regions_are_reported_with_line_numbers() {
|
||||
let err = parse("a\n// coursebank:begin questions\nb\n").unwrap_err();
|
||||
assert!(err.to_string().contains("never closed"));
|
||||
|
||||
let err = parse("// coursebank:end questions\n").unwrap_err();
|
||||
assert!(err.to_string().contains("no matching `begin`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicated_slot_is_an_error() {
|
||||
let err = parse("// coursebank:questions\n// coursebank:questions\n").unwrap_err();
|
||||
assert!(err.to_string().contains("appears 2 times"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_region_names_are_reported() {
|
||||
let err = parse("// coursebank:begin questions\n// coursebank:end meta\n").unwrap_err();
|
||||
assert!(err.to_string().contains("closes the region"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_problem_is_reported_in_one_pass() {
|
||||
let err = parse("// coursebank:nope\n// coursebank:end data\n").unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("unknown slot"), "{message}");
|
||||
assert!(message.contains("no matching `begin`"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_spelling_is_forgiving() {
|
||||
assert!(parse("// coursebank:begin:questions\n// coursebank:end:questions\n").is_ok());
|
||||
assert!(parse(" // coursebank: questions\n").is_ok());
|
||||
assert!(parse("/// coursebank:questions\n").is_ok());
|
||||
assert!(parse("// coursebank:QUESTIONS\n").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_comments_are_left_alone() {
|
||||
assert!(parse("// nothing to see\n// coursebank\n")
|
||||
.unwrap()
|
||||
.is_inert());
|
||||
// A word that merely starts with `begin` is a slot name, not a keyword.
|
||||
let err = parse("// coursebank:beginning\n").unwrap_err();
|
||||
assert!(err.to_string().contains("unknown slot `beginning`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_body_leaves_an_empty_region() {
|
||||
let template = parse("// coursebank:questions\n").unwrap();
|
||||
let out = template.render(&[]);
|
||||
assert!(out.contains("// coursebank:begin questions"));
|
||||
assert!(out.contains("// coursebank:end questions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_bundled_template_parses_and_declares_slots() {
|
||||
for variant in Variant::ALL {
|
||||
let template =
|
||||
Template::parse(variant, Origin::Embedded, embedded(variant).to_string())
|
||||
.unwrap_or_else(|e| panic!("bundled {} template: {e}", variant.as_str()));
|
||||
assert!(
|
||||
!template.is_inert(),
|
||||
"the bundled {} template declares no slots",
|
||||
variant.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lookup_order_is_most_specific_first() {
|
||||
let layout = Layout::new("/course");
|
||||
let paths = candidates(&layout, Variant::Key, Some("exam-2"));
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert!(paths[0].ends_with("templates/exam-2-key.typ"));
|
||||
assert!(paths[1].ends_with("templates/key.typ"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user