cooklang_format/lib.rs
1//! Render Cooklang recipes into text formats.
2//!
3//! Each module turns a parsed [`cooklang::Recipe`] into one target format.
4//! The `print_*` functions write into a [`std::io::Write`]; the `*_to_string`
5//! wrappers are for callers that want a `String`.
6//!
7//! # Errors
8//!
9//! These functions return a bare [`std::io::Error`]. The only thing that can
10//! fail is the caller's own writer: the recipe is already parsed, and every
11//! field is optional, so there is nothing left to reject.
12
13#![warn(missing_docs)]
14
15// Declared bare, without `///` docs: a doc attribute on a `mod` declaration is
16// merged with the module's own `//!` header and the whole thing then resolves
17// its intra-doc links in *this* scope rather than the module's, which breaks
18// every link a module writes to its own items. Each module documents itself.
19pub mod cooklang_source;
20pub mod human;
21pub mod latex;
22pub mod markdown;
23pub mod number;
24pub mod quantity;
25pub mod schema;
26pub mod typst;
27
28/// The `cooklang` crate this library was built against.
29///
30/// Every public function takes `cooklang` types, so they are part of this
31/// crate's public surface. Re-exporting lets consumers name them without
32/// adding their own `cooklang` dependency, which could otherwise resolve to a
33/// different version and fail to unify.
34pub use cooklang;
35
36/// The separator a recipe reference's path is built and reported with.
37///
38/// **Always `/`, never [`std::path::MAIN_SEPARATOR`].** A reference is written
39/// `@./sauce{}` in Cooklang, so `/` is the separator the user typed and the one
40/// they should be shown back. Joining with the platform separator instead made
41/// Windows disagree with itself: `doctor` reports a broken reference as
42/// `./absent` because it joins with `/`, while the shopping list reported the
43/// same one as `.\absent`, and the `./`-stripping in `cookcli-core`'s recipe
44/// lookup only matches the forward-slash form, so the prefix survived into the reported
45/// name (<https://github.com/cooklang/cookcli/issues/442>).
46///
47/// Resolution is unaffected either way — `Utf8Path::join` takes `.\sauce` and
48/// `./sauce` alike on Windows — but two of these paths are not diagnostics at
49/// all: the Cooklang writer re-emits the reference as source, where a backslash
50/// is not valid syntax, and the Markdown writer puts it in a link target.
51///
52/// On Unix this is what [`std::path::MAIN_SEPARATOR`] already was, so nothing
53/// about the output changes there.
54pub const REFERENCE_SEPARATOR: &str = "/";
55
56use ::cooklang::{convert::Converter, Recipe};
57
58/// Whether formatters emit ANSI escape codes.
59///
60/// A library must not emit escape sequences by default, and `yansi`'s global
61/// enable/disable is unacceptable shared mutable state in a published crate,
62/// so colour is passed explicitly. The CLI passes `Ansi`; consumers get `Plain`.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64#[non_exhaustive]
65pub enum Style {
66 /// No escape codes: safe for files, pipes and editor buffers.
67 #[default]
68 Plain,
69 /// Escape codes for a terminal that understands them.
70 Ansi,
71}
72
73impl Style {
74 /// True when ANSI escape codes should be emitted.
75 pub fn is_ansi(self) -> bool {
76 matches!(self, Style::Ansi)
77 }
78}
79
80/// Page size for the [`latex`] and [`typst`] formatters.
81///
82/// Those formatters take the paper name as a string because each typesetter
83/// spells it differently; this enum maps one choice onto both spellings.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85#[non_exhaustive]
86pub enum PaperSize {
87 /// ISO A4, 210 x 297 mm.
88 #[default]
89 A4,
90 /// US Letter, 8.5 x 11 in.
91 Letter,
92 /// ISO A5, 148 x 210 mm.
93 A5,
94 /// US Legal, 8.5 x 14 in.
95 Legal,
96}
97
98impl PaperSize {
99 /// The name LaTeX's `geometry`/`article` class expects.
100 pub fn latex_name(self) -> &'static str {
101 match self {
102 PaperSize::A4 => "a4paper",
103 PaperSize::Letter => "letterpaper",
104 PaperSize::A5 => "a5paper",
105 PaperSize::Legal => "legalpaper",
106 }
107 }
108
109 /// The name Typst's `page(paper: ..)` expects.
110 pub fn typst_name(self) -> &'static str {
111 match self {
112 PaperSize::A4 => "a4",
113 PaperSize::Letter => "us-letter",
114 PaperSize::A5 => "a5",
115 PaperSize::Legal => "us-legal",
116 }
117 }
118}
119
120/// Render a recipe the way `cook recipe` prints it, into a `String`.
121///
122/// Convenience over [`human::print_human`], which stays the primitive.
123pub fn human_to_string(
124 recipe: &Recipe,
125 name: &str,
126 scale: f64,
127 converter: &Converter,
128 style: Style,
129) -> std::io::Result<String> {
130 let mut buf = Vec::new();
131 human::print_human(recipe, name, scale, converter, style, &mut buf)?;
132 into_string(buf)
133}
134
135/// Render a recipe as Markdown, into a `String`.
136///
137/// Convenience over [`markdown::print_md`], which stays the primitive.
138pub fn markdown_to_string(
139 recipe: &Recipe,
140 name: &str,
141 scale: f64,
142 converter: &Converter,
143) -> std::io::Result<String> {
144 let mut buf = Vec::new();
145 markdown::print_md(recipe, name, scale, converter, &mut buf)?;
146 into_string(buf)
147}
148
149/// The formatters only ever write UTF-8, so this cannot fail in practice.
150/// Returning rather than panicking keeps a NAPI consumer from taking down its
151/// JavaScript host if that ever stops being true.
152fn into_string(buf: Vec<u8>) -> std::io::Result<String> {
153 String::from_utf8(buf).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
154}
155
156/// Compiles `README.md`'s example as a doctest, so the crate's front page
157/// cannot rot into something that no longer builds.
158///
159/// Exists only under `cfg(doctest)`, so it is not part of the public API and
160/// does not appear in the rendered documentation.
161#[doc = include_str!("../README.md")]
162#[cfg(doctest)]
163pub struct ReadmeDoctests;
164
165/// A test-only stand-in for `cookcli-core`'s parser.
166///
167/// The formatters take an already-parsed recipe, so the parser is not part of
168/// this crate's public surface — but its own tests still need to build a
169/// `Recipe` from source. This reproduces `cookcli_core::parser`'s
170/// configuration exactly (no extensions, default converter) and its call
171/// shape, so the tests read the same on both sides of the split.
172#[cfg(test)]
173pub(crate) mod test_support {
174 use cooklang::{Converter, CooklangParser, Extensions, Recipe};
175 use std::sync::LazyLock;
176
177 pub(crate) static PARSER: LazyLock<CooklangParser> =
178 LazyLock::new(|| CooklangParser::new(Extensions::empty(), Converter::default()));
179
180 /// Stands in for `cookcli_core::Outcome`, of which the formatter tests use
181 /// `.value` and `.diagnostics`.
182 pub(crate) struct Parsed {
183 pub(crate) value: Recipe,
184 /// The parse warnings, rendered.
185 ///
186 /// `cookcli_core::Outcome` carries structured `Diagnostic`s, built from
187 /// the same `report.iter()` this reads. The tests here only ask whether
188 /// the collection is empty and print it when it is not, so strings carry
189 /// exactly the meaning they need without this crate depending on core —
190 /// which it cannot do anyway, since core depends on this crate.
191 pub(crate) diagnostics: Vec<String>,
192 }
193
194 /// Parse and scale, mirroring `cookcli_core::parse_recipe`.
195 ///
196 /// `scale` is applied unconditionally, including at `1.0`, because that is
197 /// what core does — see the note on `parse_unscaled` there.
198 pub(crate) fn parse_recipe(text: &str, name: &str, scale: f64) -> Result<Parsed, String> {
199 let parsed = PARSER.parse(text);
200 if parsed.report().has_errors() {
201 return Err(format!("{name} failed to parse"));
202 }
203 // Collected before `into_result` consumes the parse result. Errors are
204 // already ruled out above, so what remains is warnings.
205 let diagnostics = parsed
206 .report()
207 .iter()
208 .map(|diag| diag.message.to_string())
209 .collect();
210 match parsed.into_result() {
211 Ok((mut recipe, _)) => {
212 recipe.scale(scale, PARSER.converter());
213 Ok(Parsed {
214 value: recipe,
215 diagnostics,
216 })
217 }
218 Err(_) => Err(format!("{name} produced no output")),
219 }
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::test_support::{parse_recipe, PARSER};
227
228 /// Exercises tags, a timer, an ingredient and a step: every place the
229 /// human formatter reaches for a colour.
230 const RECIPE: &str = "---\ntitle: Tea\ntags: [hot, quick]\n---\n\nBoil @water{2%cups} in a #pot for ~{5%minutes}.\n";
231
232 fn human(style: Style) -> String {
233 let recipe = parse_recipe(RECIPE, "Tea", 1.0).expect("parses").value;
234 human_to_string(&recipe, "Tea", 1.0, PARSER.converter(), style).expect("formats")
235 }
236
237 /// The whole point of [`Style`]: `Plain` must not leak escape codes, and
238 /// it must not lose anything else either. Both halves hold on every
239 /// platform — they say nothing about whether yansi chose to paint.
240 #[test]
241 fn plain_is_ansi_with_the_escapes_removed() {
242 let plain = human(Style::Plain);
243 let coloured = human(Style::Ansi);
244
245 assert!(
246 !plain.contains('\u{1b}'),
247 "Style::Plain must emit no escape codes: {plain:?}"
248 );
249 assert_eq!(
250 plain,
251 anstream::adapter::strip_str(&coloured).to_string(),
252 "Plain must be exactly Ansi with the escapes removed, not different text"
253 );
254 }
255
256 /// The other direction: `Ansi` must not strip. yansi decides whether to
257 /// paint at all by probing the console, and off Windows that probe always
258 /// says yes; on Windows it can say no, which would make this assertion
259 /// about the host rather than about the code.
260 #[cfg(not(windows))]
261 #[test]
262 fn ansi_keeps_the_escape_codes() {
263 let coloured = human(Style::Ansi);
264 assert!(
265 coloured.contains('\u{1b}'),
266 "Style::Ansi must emit escape codes: {coloured:?}"
267 );
268 }
269
270 /// Pins the spellings the two typesetters expect. Guessing these wrong
271 /// produces a document that fails to compile, which no unit test of the
272 /// formatters themselves would catch.
273 #[test]
274 fn paper_sizes_map_to_both_typesetter_spellings() {
275 let all = [
276 (PaperSize::A4, "a4paper", "a4"),
277 (PaperSize::Letter, "letterpaper", "us-letter"),
278 (PaperSize::A5, "a5paper", "a5"),
279 (PaperSize::Legal, "legalpaper", "us-legal"),
280 ];
281 for (size, latex, typst) in all {
282 assert_eq!(size.latex_name(), latex, "latex name for {size:?}");
283 assert_eq!(size.typst_name(), typst, "typst name for {size:?}");
284 }
285 assert_eq!(PaperSize::default(), PaperSize::A4);
286 }
287
288 #[test]
289 fn style_default_is_plain_and_only_ansi_is_ansi() {
290 assert_eq!(Style::default(), Style::Plain);
291 assert!(Style::Ansi.is_ansi());
292 assert!(!Style::Plain.is_ansi());
293 }
294
295 /// The `*_to_string` wrappers must return exactly what the `Write`-based
296 /// primitives produce, not a re-rendering.
297 #[test]
298 fn to_string_wrappers_match_the_writer_primitives() {
299 let recipe = parse_recipe(RECIPE, "Tea", 1.0).expect("parses").value;
300
301 let mut buf = Vec::new();
302 human::print_human(
303 &recipe,
304 "Tea",
305 1.0,
306 PARSER.converter(),
307 Style::Plain,
308 &mut buf,
309 )
310 .expect("formats");
311 assert_eq!(
312 human_to_string(&recipe, "Tea", 1.0, PARSER.converter(), Style::Plain).unwrap(),
313 String::from_utf8(buf).unwrap()
314 );
315
316 let mut buf = Vec::new();
317 markdown::print_md(&recipe, "Tea", 1.0, PARSER.converter(), &mut buf).expect("formats");
318 assert_eq!(
319 markdown_to_string(&recipe, "Tea", 1.0, PARSER.converter()).unwrap(),
320 String::from_utf8(buf).unwrap()
321 );
322 }
323}