Skip to main content

mos_eval/
set.rs

1//! Lower `#set` directives and coerce directive values.
2
3use std::collections::BTreeMap;
4
5use mos_core::{
6    AttrMap, AttrValue, Diagnostic, Document, NodeId, NodeKind, NodeSpec, SourceSpan, codes,
7};
8use mos_parse::{SetArg, SetValue};
9
10use crate::{DocumentMetadata, set_schema};
11
12/// Lower a `#set name(...)` directive into a `Raw` node carrying the
13/// resolved attribute payload. The split exists so the dispatch in
14/// `Evaluator::evaluate` only has to thread state through three
15/// directive-shaped helpers instead of one large match arm.
16#[allow(clippy::too_many_arguments)]
17pub(super) fn lower_set_directive(
18    document: &mut Document,
19    root: NodeId,
20    name: &str,
21    args: &[SetArg],
22    span: &SourceSpan,
23    metadata: &mut DocumentMetadata,
24    current_text_size_pt: &mut f64,
25    diagnostics: &mut Vec<Diagnostic>,
26) {
27    let mut attributes: AttrMap = BTreeMap::new();
28    attributes.insert("set".to_owned(), AttrValue::Str(name.to_owned()));
29    let Some(target) = set_schema::lookup_target(name) else {
30        diagnostics.push(
31            Diagnostic::simple(&codes::MOS0011, None,
32                format!(
33                    "unknown `#set` target `{name}` (expected `page`, `text`, `document`, or `image`)"
34                ),
35            )
36            .with_span(span.clone()),
37        );
38        document.alloc_child(root, set_node(span, attributes));
39        return;
40    };
41    for arg in args {
42        // The parser refuses positional args for `#set` already, so
43        // reaching the Positional arm here would mean a future caller
44        // forgot the `allow_positional=false` flag. Diagnose loudly.
45        if matches!(arg, SetArg::Positional { .. }) {
46            diagnostics.push(
47                Diagnostic::simple(
48                    &codes::MOS0024,
49                    None,
50                    format!("`#set {name}` does not accept positional arguments"),
51                )
52                .with_span(arg.value_span().clone()),
53            );
54            continue;
55        }
56        lower_set_arg(
57            target,
58            arg,
59            &mut attributes,
60            metadata,
61            current_text_size_pt,
62            diagnostics,
63        );
64    }
65    document.alloc_child(root, set_node(span, attributes));
66}
67
68fn set_node(span: &SourceSpan, attributes: AttrMap) -> NodeSpec {
69    NodeSpec::new(NodeKind::Raw, span.clone()).with_attributes(attributes)
70}
71
72/// Convert one parser-level `SetArg` into an attribute on the Raw node
73/// representing this `#set` directive. Emits semantic diagnostics
74/// (`MOS0015` unknown key, `MOS0020` type mismatch, `MOS0027` sanity floor) and
75/// updates `metadata` / `current_text_size_pt` as a side effect.
76fn lower_set_arg(
77    target: set_schema::Target,
78    arg: &SetArg,
79    attributes: &mut AttrMap,
80    metadata: &mut DocumentMetadata,
81    current_text_size_pt: &mut f64,
82    diagnostics: &mut Vec<Diagnostic>,
83) {
84    // `#set` only carries `key: value` args. Caller filters positionals;
85    // debug assert flags future callers that miss that gate.
86    let SetArg::Named {
87        key,
88        value: raw_value,
89        key_span,
90        value_span,
91    } = arg
92    else {
93        debug_assert!(false, "lower_set_arg received Positional arg");
94        return;
95    };
96    let Some(slot) = target.slot(key) else {
97        diagnostics.push(
98            Diagnostic::simple(
99                &codes::MOS0015,
100                None,
101                format!(
102                    "unknown argument `{key}` for `#set {}` (valid: {})",
103                    target.name(),
104                    target.keys().join(", ")
105                ),
106            )
107            .with_span(key_span.clone()),
108        );
109        return;
110    };
111    let Some(value) = coerce_value(slot, raw_value, *current_text_size_pt) else {
112        diagnostics.push(
113            Diagnostic::simple(
114                &codes::MOS0020,
115                None,
116                format!(
117                    "`#set {} ({key}: …)` expects {}, got {}",
118                    target.name(),
119                    slot.expected(),
120                    describe_value(raw_value),
121                ),
122            )
123            .with_span(value_span.clone()),
124        );
125        return;
126    };
127    if let Some(msg) = sanity_floor_warning(target, key, &value) {
128        diagnostics
129            .push(Diagnostic::simple(&codes::MOS0027, None, msg).with_span(value_span.clone()));
130    }
131    if matches!(target, set_schema::Target::Text)
132        && key == "size"
133        && let AttrValue::Length(pt) = &value
134    {
135        *current_text_size_pt = *pt;
136    }
137    if matches!(target, set_schema::Target::Document)
138        && let AttrValue::Str(s) = &value
139    {
140        match key.as_str() {
141            "title" => metadata.title = Some(s.clone()),
142            "author" => metadata.author = Some(s.clone()),
143            "language" => metadata.language = Some(s.clone()),
144            _ => {}
145        }
146    }
147    attributes.insert(format!("set.arg.{key}"), value);
148}
149
150/// Coerce a parser literal to the type required by the target slot.
151/// Length values are resolved to PDF points using `em_pt` for `em`
152/// literals.
153fn coerce_value(slot: set_schema::SlotType, value: &SetValue, em_pt: f64) -> Option<AttrValue> {
154    use set_schema::SlotType;
155    match (slot, value) {
156        // Bare identifiers are accepted in string slots so authors can
157        // write `numbering: bottom-center` without quotes.
158        (SlotType::Str, SetValue::Str(s) | SetValue::Ident(s)) => Some(AttrValue::Str(s.clone())),
159        (SlotType::Length, SetValue::Length(v, unit)) => {
160            Some(AttrValue::Length(length_to_pt(*v, *unit, em_pt)))
161        }
162        // Bare numbers in length slots default to pt for ergonomic input.
163        (SlotType::Length, SetValue::Float(v)) => Some(AttrValue::Length(*v)),
164        (SlotType::Length, SetValue::Int(v)) => Some(AttrValue::Length(int_to_f64(*v))),
165        (SlotType::Float, SetValue::Float(v)) => Some(AttrValue::Float(*v)),
166        (SlotType::Float, SetValue::Int(v)) => Some(AttrValue::Float(int_to_f64(*v))),
167        _ => None,
168    }
169}
170
171/// Coerce a `#image(width|height: ...)` argument to a strictly positive
172/// length in points. Bare numerics resolve as pt for ergonomics.
173pub(super) fn coerce_positive_length(
174    value: &SetValue,
175    em_pt: f64,
176    key: &str,
177    value_span: &SourceSpan,
178    diagnostics: &mut Vec<Diagnostic>,
179) -> Option<f64> {
180    let pt = match value {
181        SetValue::Length(v, unit) => length_to_pt(*v, *unit, em_pt),
182        SetValue::Float(v) => *v,
183        SetValue::Int(v) => int_to_f64(*v),
184        _ => {
185            diagnostics.push(
186                Diagnostic::simple(
187                    &codes::MOS0020,
188                    None,
189                    format!("`#image({key}: ...)` expects a length"),
190                )
191                .with_span(value_span.clone()),
192            );
193            return None;
194        }
195    };
196    if pt <= 0.0 {
197        diagnostics.push(
198            Diagnostic::simple(
199                &codes::MOS0020,
200                None,
201                format!("`#image({key}: ...)` expects a positive length"),
202            )
203            .with_span(value_span.clone()),
204        );
205        return None;
206    }
207    Some(pt)
208}
209
210/// `#set` literals only accept i64 values that fit comfortably in
211/// f64's mantissa; cap at ±2^53 so the cast is exact.
212#[allow(
213    clippy::cast_precision_loss,
214    reason = "values clamped to f64-exact range above"
215)]
216fn int_to_f64(v: i64) -> f64 {
217    v.clamp(-(1_i64 << 53), 1_i64 << 53) as f64
218}
219
220fn length_to_pt(value: f64, unit: mos_parse::LengthUnit, em_pt: f64) -> f64 {
221    match unit {
222        mos_parse::LengthUnit::Pt => value,
223        mos_parse::LengthUnit::Mm => value * 72.0 / 25.4,
224        mos_parse::LengthUnit::Em => value * em_pt,
225    }
226}
227
228fn describe_value(v: &SetValue) -> &'static str {
229    match v {
230        SetValue::Str(_) => "a string",
231        SetValue::Int(_) => "an integer",
232        SetValue::Float(_) => "a number",
233        SetValue::Length(_, _) => "a length",
234        SetValue::Ident(_) => "an identifier",
235    }
236}
237
238fn sanity_floor_warning(
239    target: set_schema::Target,
240    key: &str,
241    value: &AttrValue,
242) -> Option<String> {
243    use set_schema::Target;
244    match (target, key) {
245        (Target::Page, "margin") => {
246            // 3mm ~= 8.5pt, below most printer hardware margins.
247            if let AttrValue::Length(pt) = value
248                && *pt < 8.5
249            {
250                return Some(format!(
251                    "page margin {pt:.2}pt is below the 3mm sanity floor"
252                ));
253            }
254        }
255        (Target::Text, "size") => {
256            if let AttrValue::Length(pt) = value
257                && *pt < 4.0
258            {
259                return Some(format!("text size {pt:.2}pt is below the 4pt sanity floor"));
260            }
261        }
262        (Target::Text, "leading") => {
263            if let AttrValue::Float(v) = value
264                && *v < 0.8
265            {
266                return Some(format!("text leading {v:.2} is below the 0.8 sanity floor"));
267            }
268        }
269        _ => {}
270    }
271    None
272}
273
274#[cfg(test)]
275mod tests {
276    #![allow(
277        clippy::unwrap_used,
278        clippy::expect_used,
279        clippy::panic,
280        reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
281    )]
282
283    use std::path::PathBuf;
284
285    use mos_core::{AttrValue, Node, NodeKind, codes};
286
287    use crate::lower;
288
289    #[test]
290    fn lowers_set_block_as_raw() {
291        let r = lower(
292            "#set page(paper: \"A4\", margin: 24mm)\n\n= After\n",
293            &PathBuf::from("test.mos"),
294        );
295        assert!(!r.has_errors(), "{:?}", r.diagnostics);
296        let kinds: Vec<NodeKind> = r.document.nodes().map(|n| n.kind).collect();
297        assert!(kinds.contains(&NodeKind::Raw));
298        assert!(kinds.contains(&NodeKind::Section));
299        let raw = r
300            .document
301            .nodes()
302            .find(|n| n.kind == NodeKind::Raw && n.attributes.contains_key("set"))
303            .expect("set Raw node");
304        assert_eq!(
305            raw.attributes.get("set.arg.paper"),
306            Some(&AttrValue::Str("A4".to_owned()))
307        );
308        match raw.attributes.get("set.arg.margin") {
309            Some(AttrValue::Length(pt)) => {
310                assert!((pt - 68.031).abs() < 0.01, "margin pt = {pt}");
311            }
312            other => panic!("expected Length for margin, got {other:?}"),
313        }
314    }
315
316    #[test]
317    fn unknown_set_target_emits_mos0011() {
318        let r = lower("#set widget(x: 1)\n", &PathBuf::from("test.mos"));
319        assert!(
320            r.diagnostics
321                .iter()
322                .any(|d| d.def().code() == codes::MOS0011.code())
323        );
324    }
325
326    #[test]
327    fn unknown_set_arg_emits_mos0015() {
328        let r = lower("#set page(weirdkey: 1)\n", &PathBuf::from("test.mos"));
329        assert!(
330            r.diagnostics
331                .iter()
332                .any(|d| d.def().code() == codes::MOS0015.code()),
333            "got {:?}",
334            r.diagnostics
335        );
336    }
337
338    #[test]
339    fn type_mismatch_emits_mos0020() {
340        let r = lower("#set page(margin: \"wide\")\n", &PathBuf::from("test.mos"));
341        assert!(
342            r.diagnostics
343                .iter()
344                .any(|d| d.def().code() == codes::MOS0020.code()),
345            "got {:?}",
346            r.diagnostics
347        );
348    }
349
350    #[test]
351    fn sanity_floor_emits_mos0027() {
352        let r = lower("#set page(margin: 0.5mm)\n", &PathBuf::from("test.mos"));
353        assert!(
354            r.diagnostics
355                .iter()
356                .any(|d| d.def().code() == codes::MOS0027.code()),
357            "got {:?}",
358            r.diagnostics
359        );
360        assert!(!r.has_errors());
361    }
362
363    #[test]
364    fn em_resolves_against_current_text_size() {
365        let r = lower(
366            "#set text(size: 12pt)\n#set text(leading: 1.0)\n#set page(margin: 2em)\n",
367            &PathBuf::from("test.mos"),
368        );
369        assert!(!r.has_errors(), "{:?}", r.diagnostics);
370        let raws: Vec<&Node> = r
371            .document
372            .nodes()
373            .filter(|n| n.kind == NodeKind::Raw && n.attributes.contains_key("set"))
374            .collect();
375        let page = raws
376            .iter()
377            .find(|n| n.attributes.get("set") == Some(&AttrValue::Str("page".to_owned())))
378            .unwrap();
379        match page.attributes.get("set.arg.margin") {
380            Some(AttrValue::Length(pt)) => assert!((pt - 24.0).abs() < 0.01, "got {pt}"),
381            other => panic!("expected Length, got {other:?}"),
382        }
383    }
384
385    #[test]
386    fn document_metadata_captured() {
387        let r = lower(
388            "#set document(title: \"T\", author: \"A\", language: \"en\")\n",
389            &PathBuf::from("test.mos"),
390        );
391        assert!(!r.has_errors(), "{:?}", r.diagnostics);
392        assert_eq!(r.metadata.title.as_deref(), Some("T"));
393        assert_eq!(r.metadata.author.as_deref(), Some("A"));
394        assert_eq!(r.metadata.language.as_deref(), Some("en"));
395    }
396
397    #[test]
398    fn set_image_width_is_accepted_without_error() {
399        let r = lower("#set image(width: 200pt)\n", &PathBuf::from("test.mos"));
400        assert!(
401            r.diagnostics.is_empty(),
402            "unexpected diagnostics: {:?}",
403            r.diagnostics
404        );
405        assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Image));
406        let raw = r
407            .document
408            .nodes()
409            .find(|n| n.kind == NodeKind::Raw && n.attributes.contains_key("set"))
410            .expect("set Raw node");
411        assert_eq!(
412            raw.attributes.get("set"),
413            Some(&AttrValue::Str("image".to_owned()))
414        );
415        assert_eq!(
416            raw.attributes.get("set.arg.width"),
417            Some(&AttrValue::Length(200.0))
418        );
419    }
420}