Skip to main content

mcd_core/
directives.rs

1//! MCD block directive parsing.
2
3use std::collections::{HashMap, HashSet};
4
5use serde::{Deserialize, Serialize};
6
7use crate::{
8    document::AnnotationRef,
9    document::SourceSpan,
10    errors::{Diagnostic, McdError, Result},
11};
12
13/// Directive parsing options.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct DirectiveParseOptions {
16    /// Reject unknown fields.
17    pub strict: bool,
18}
19
20impl Default for DirectiveParseOptions {
21    fn default() -> Self {
22        Self { strict: true }
23    }
24}
25
26/// Table directive display mode.
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum TableDisplay {
30    /// Render as a table.
31    #[default]
32    Table,
33    /// Render as a chart backed by table data.
34    Chart,
35}
36
37/// Parsed `:::table` placement.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct TablePlacement {
41    /// Optional placement reference.
42    #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
43    pub ref_id: Option<String>,
44    /// Referenced table id.
45    pub table: String,
46    /// Optional referenced view id.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub view: Option<String>,
49    /// Placement display mode.
50    #[serde(default)]
51    pub display: TableDisplay,
52    /// Optional caption.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub caption: Option<String>,
55    /// Optional numbering mode.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub numbering: Option<String>,
58    /// Annotation markers attached to this table or chart placement.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub annotations: Vec<AnnotationRef>,
61}
62
63/// Parsed `:::image` placement.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct ImagePlacement {
67    /// Optional placement reference.
68    #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
69    pub ref_id: Option<String>,
70    /// Optional direct asset reference.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub asset: Option<String>,
73    /// Optional image metadata reference.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub image: Option<String>,
76    /// Optional caption.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub caption: Option<String>,
79    /// Optional alt text.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub alt: Option<String>,
82    /// Annotation markers attached to this image placement.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub annotations: Vec<AnnotationRef>,
85}
86
87/// Parse a `:::table` directive body.
88pub fn parse_table_directive(
89    body: &str,
90    source: Option<SourceSpan>,
91    options: DirectiveParseOptions,
92) -> Result<TablePlacement> {
93    let fields = parse_fields(
94        body,
95        &[
96            "ref",
97            "table",
98            "view",
99            "display",
100            "caption",
101            "numbering",
102            "annotation",
103            "annotations",
104        ],
105        source,
106        options,
107    )?;
108
109    let table = required_field(&fields, "table", "directive.table.table.missing", source)?;
110    let display = match fields.get("display").map(String::as_str).unwrap_or("table") {
111        "table" => TableDisplay::Table,
112        "chart" => TableDisplay::Chart,
113        value => {
114            return Err(directive_error(
115                "directive.table.display.invalid",
116                format!("Table directive display must be 'table' or 'chart', got '{value}'."),
117                source,
118            ));
119        }
120    };
121
122    if display == TableDisplay::Chart && empty_or_missing(fields.get("view")) {
123        return Err(directive_error(
124            "directive.table.view.required",
125            "Table directives with display: chart must include view.",
126            source,
127        ));
128    }
129
130    Ok(TablePlacement {
131        ref_id: optional_field(&fields, "ref"),
132        table,
133        view: optional_field(&fields, "view"),
134        display,
135        caption: optional_field(&fields, "caption"),
136        numbering: optional_field(&fields, "numbering"),
137        annotations: directive_annotation_refs(&fields),
138    })
139}
140
141/// Parse an `:::image` directive body.
142pub fn parse_image_directive(
143    body: &str,
144    source: Option<SourceSpan>,
145    options: DirectiveParseOptions,
146) -> Result<ImagePlacement> {
147    let fields = parse_fields(
148        body,
149        &[
150            "ref",
151            "asset",
152            "image",
153            "caption",
154            "alt",
155            "annotation",
156            "annotations",
157        ],
158        source,
159        options,
160    )?;
161
162    let asset = optional_field(&fields, "asset");
163    let image = optional_field(&fields, "image");
164    if asset.is_none() && image.is_none() {
165        return Err(directive_error(
166            "directive.image.asset.missing",
167            "Image directive must include asset or image.",
168            source,
169        ));
170    }
171
172    Ok(ImagePlacement {
173        ref_id: optional_field(&fields, "ref"),
174        asset,
175        image,
176        caption: optional_field(&fields, "caption"),
177        alt: optional_field(&fields, "alt"),
178        annotations: directive_annotation_refs(&fields),
179    })
180}
181
182fn directive_annotation_refs(fields: &HashMap<String, String>) -> Vec<AnnotationRef> {
183    fields
184        .get("annotations")
185        .or_else(|| fields.get("annotation"))
186        .map(|value| {
187            value
188                .split(',')
189                .map(str::trim)
190                .filter(|id| !id.is_empty())
191                .map(|id| AnnotationRef {
192                    id: id.to_owned(),
193                    text_offset: None,
194                })
195                .collect()
196        })
197        .unwrap_or_default()
198}
199
200fn parse_fields(
201    body: &str,
202    known_fields: &[&str],
203    source: Option<SourceSpan>,
204    options: DirectiveParseOptions,
205) -> Result<HashMap<String, String>> {
206    let known = known_fields.iter().copied().collect::<HashSet<_>>();
207    let mut fields = HashMap::new();
208
209    for (index, line) in body.lines().enumerate() {
210        let trimmed = line.trim();
211        if trimmed.is_empty() {
212            continue;
213        }
214
215        let Some((key, value)) = trimmed.split_once(':') else {
216            return Err(directive_error(
217                "directive.syntax.invalid",
218                format!(
219                    "Directive field on body line {} must use 'key: value' syntax.",
220                    index + 1
221                ),
222                source,
223            ));
224        };
225        let key = key.trim();
226        if key.is_empty() || key.contains(char::is_whitespace) {
227            return Err(directive_error(
228                "directive.syntax.invalid",
229                format!(
230                    "Directive field on body line {} has an invalid key.",
231                    index + 1
232                ),
233                source,
234            ));
235        }
236        if options.strict && !known.contains(key) {
237            return Err(directive_error(
238                "directive.field.unknown",
239                format!("Unknown directive field '{key}'."),
240                source,
241            ));
242        }
243        if fields
244            .insert(key.to_string(), value.trim().to_string())
245            .is_some()
246        {
247            return Err(directive_error(
248                "directive.field.duplicate",
249                format!("Duplicate directive field '{key}'."),
250                source,
251            ));
252        }
253    }
254
255    Ok(fields)
256}
257
258fn required_field(
259    fields: &HashMap<String, String>,
260    name: &'static str,
261    code: &'static str,
262    source: Option<SourceSpan>,
263) -> Result<String> {
264    optional_field(fields, name).ok_or_else(|| {
265        directive_error(
266            code,
267            format!("Directive field '{name}' is required."),
268            source,
269        )
270    })
271}
272
273fn optional_field(fields: &HashMap<String, String>, name: &str) -> Option<String> {
274    fields
275        .get(name)
276        .map(|value| value.trim())
277        .filter(|value| !value.is_empty())
278        .map(ToOwned::to_owned)
279}
280
281fn empty_or_missing(value: Option<&String>) -> bool {
282    value.is_none_or(|value| value.trim().is_empty())
283}
284
285fn directive_error(
286    code: impl Into<String>,
287    message: impl Into<String>,
288    source: Option<SourceSpan>,
289) -> McdError {
290    let diagnostic = match source {
291        Some(source) => Diagnostic::error(code, message).with_source(source.to_string()),
292        None => Diagnostic::error(code, message),
293    };
294    McdError::from_diagnostic(diagnostic)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn table_display_defaults_to_table() {
303        let placement = parse_table_directive(
304            "ref: revenue-table\ntable: revenue\ncaption: Revenue",
305            None,
306            DirectiveParseOptions::default(),
307        )
308        .expect("directive parses");
309
310        assert_eq!(placement.display, TableDisplay::Table);
311        assert_eq!(placement.table, "revenue");
312    }
313
314    #[test]
315    fn chart_requires_view() {
316        let err = parse_table_directive(
317            "table: revenue\ndisplay: chart",
318            None,
319            DirectiveParseOptions::default(),
320        )
321        .expect_err("chart without view should fail");
322
323        assert_eq!(
324            err.diagnostic().map(|d| d.code.as_str()),
325            Some("directive.table.view.required")
326        );
327    }
328
329    #[test]
330    fn image_requires_asset_or_metadata_ref() {
331        let err = parse_image_directive(
332            "ref: process-diagram",
333            None,
334            DirectiveParseOptions::default(),
335        )
336        .expect_err("image without asset should fail");
337
338        assert_eq!(
339            err.diagnostic().map(|d| d.code.as_str()),
340            Some("directive.image.asset.missing")
341        );
342    }
343}