746 lines
25 KiB
Rust
746 lines
25 KiB
Rust
//! 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::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"));
|
|
}
|
|
}
|