Skip to main content

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}