1use 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#[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 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
72fn 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 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
150fn coerce_value(slot: set_schema::SlotType, value: &SetValue, em_pt: f64) -> Option<AttrValue> {
154 use set_schema::SlotType;
155 match (slot, value) {
156 (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 (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
171pub(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#[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 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}