use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::{
document::AnnotationRef,
document::SourceSpan,
errors::{Diagnostic, McdError, Result},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DirectiveParseOptions {
pub strict: bool,
}
impl Default for DirectiveParseOptions {
fn default() -> Self {
Self { strict: true }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TableDisplay {
#[default]
Table,
Chart,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TablePlacement {
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
pub ref_id: Option<String>,
pub table: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view: Option<String>,
#[serde(default)]
pub display: TableDisplay,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub numbering: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<AnnotationRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImagePlacement {
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
pub ref_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alt: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<AnnotationRef>,
}
pub fn parse_table_directive(
body: &str,
source: Option<SourceSpan>,
options: DirectiveParseOptions,
) -> Result<TablePlacement> {
let fields = parse_fields(
body,
&[
"ref",
"table",
"view",
"display",
"caption",
"numbering",
"annotation",
"annotations",
],
source,
options,
)?;
let table = required_field(&fields, "table", "directive.table.table.missing", source)?;
let display = match fields.get("display").map(String::as_str).unwrap_or("table") {
"table" => TableDisplay::Table,
"chart" => TableDisplay::Chart,
value => {
return Err(directive_error(
"directive.table.display.invalid",
format!("Table directive display must be 'table' or 'chart', got '{value}'."),
source,
));
}
};
if display == TableDisplay::Chart && empty_or_missing(fields.get("view")) {
return Err(directive_error(
"directive.table.view.required",
"Table directives with display: chart must include view.",
source,
));
}
Ok(TablePlacement {
ref_id: optional_field(&fields, "ref"),
table,
view: optional_field(&fields, "view"),
display,
caption: optional_field(&fields, "caption"),
numbering: optional_field(&fields, "numbering"),
annotations: directive_annotation_refs(&fields),
})
}
pub fn parse_image_directive(
body: &str,
source: Option<SourceSpan>,
options: DirectiveParseOptions,
) -> Result<ImagePlacement> {
let fields = parse_fields(
body,
&[
"ref",
"asset",
"image",
"caption",
"alt",
"annotation",
"annotations",
],
source,
options,
)?;
let asset = optional_field(&fields, "asset");
let image = optional_field(&fields, "image");
if asset.is_none() && image.is_none() {
return Err(directive_error(
"directive.image.asset.missing",
"Image directive must include asset or image.",
source,
));
}
Ok(ImagePlacement {
ref_id: optional_field(&fields, "ref"),
asset,
image,
caption: optional_field(&fields, "caption"),
alt: optional_field(&fields, "alt"),
annotations: directive_annotation_refs(&fields),
})
}
fn directive_annotation_refs(fields: &HashMap<String, String>) -> Vec<AnnotationRef> {
fields
.get("annotations")
.or_else(|| fields.get("annotation"))
.map(|value| {
value
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.map(|id| AnnotationRef {
id: id.to_owned(),
text_offset: None,
})
.collect()
})
.unwrap_or_default()
}
fn parse_fields(
body: &str,
known_fields: &[&str],
source: Option<SourceSpan>,
options: DirectiveParseOptions,
) -> Result<HashMap<String, String>> {
let known = known_fields.iter().copied().collect::<HashSet<_>>();
let mut fields = HashMap::new();
for (index, line) in body.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Some((key, value)) = trimmed.split_once(':') else {
return Err(directive_error(
"directive.syntax.invalid",
format!(
"Directive field on body line {} must use 'key: value' syntax.",
index + 1
),
source,
));
};
let key = key.trim();
if key.is_empty() || key.contains(char::is_whitespace) {
return Err(directive_error(
"directive.syntax.invalid",
format!(
"Directive field on body line {} has an invalid key.",
index + 1
),
source,
));
}
if options.strict && !known.contains(key) {
return Err(directive_error(
"directive.field.unknown",
format!("Unknown directive field '{key}'."),
source,
));
}
if fields
.insert(key.to_string(), value.trim().to_string())
.is_some()
{
return Err(directive_error(
"directive.field.duplicate",
format!("Duplicate directive field '{key}'."),
source,
));
}
}
Ok(fields)
}
fn required_field(
fields: &HashMap<String, String>,
name: &'static str,
code: &'static str,
source: Option<SourceSpan>,
) -> Result<String> {
optional_field(fields, name).ok_or_else(|| {
directive_error(
code,
format!("Directive field '{name}' is required."),
source,
)
})
}
fn optional_field(fields: &HashMap<String, String>, name: &str) -> Option<String> {
fields
.get(name)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn empty_or_missing(value: Option<&String>) -> bool {
value.is_none_or(|value| value.trim().is_empty())
}
fn directive_error(
code: impl Into<String>,
message: impl Into<String>,
source: Option<SourceSpan>,
) -> McdError {
let diagnostic = match source {
Some(source) => Diagnostic::error(code, message).with_source(source.to_string()),
None => Diagnostic::error(code, message),
};
McdError::from_diagnostic(diagnostic)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_display_defaults_to_table() {
let placement = parse_table_directive(
"ref: revenue-table\ntable: revenue\ncaption: Revenue",
None,
DirectiveParseOptions::default(),
)
.expect("directive parses");
assert_eq!(placement.display, TableDisplay::Table);
assert_eq!(placement.table, "revenue");
}
#[test]
fn chart_requires_view() {
let err = parse_table_directive(
"table: revenue\ndisplay: chart",
None,
DirectiveParseOptions::default(),
)
.expect_err("chart without view should fail");
assert_eq!(
err.diagnostic().map(|d| d.code.as_str()),
Some("directive.table.view.required")
);
}
#[test]
fn image_requires_asset_or_metadata_ref() {
let err = parse_image_directive(
"ref: process-diagram",
None,
DirectiveParseOptions::default(),
)
.expect_err("image without asset should fail");
assert_eq!(
err.diagnostic().map(|d| d.code.as_str()),
Some("directive.image.asset.missing")
);
}
}