use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
use chumsky::span::Spanned;
use serde::de::{Deserialize, Deserializer, IgnoredAny, MapAccess, Visitor};
use serde_saphyr::{DefaultMessageFormatter, Location, MessageFormatter, Spanned as YamlSpanned};
use strum::{EnumString, VariantNames};
use svugy_model::model::ViewAngle;
use super::ast::Span;
use super::diagnostic::Diagnostics;
use super::skeleton::SourceText;
use crate::prose::ProseList;
const QUOTES: [char; 2] = ['"', '\''];
const MOVED_KEYS: [&str; 2] = ["title", "subtitle"];
#[derive(Debug, Default)]
pub(crate) struct Frontmatter {
pub(crate) view: Option<ViewAngle>,
pub(crate) styles: Vec<StyleSource>,
}
#[derive(Debug)]
pub(crate) struct StyleSource {
pub(crate) name: Spanned<String>,
pub(crate) properties: String,
pub(crate) properties_span: Span,
pub(crate) verbatim_offset: Option<usize>,
}
#[derive(Debug, Clone, Copy, EnumString, VariantNames)]
#[strum(serialize_all = "lowercase")]
enum FrontmatterKey {
View,
Styles,
}
#[derive(Default)]
struct FrontmatterFields {
view: Option<ViewAngle>,
styles: HashMap<YamlSpanned<String>, YamlSpanned<String>>,
unknown_keys: Vec<YamlSpanned<String>>,
}
impl Frontmatter {
pub(crate) fn read(body: SourceText<'_>, diagnostics: &mut Diagnostics) -> Self {
let is_blank = body.text.trim().is_empty();
if is_blank {
return Self::default();
}
let options = serde_saphyr::options! { with_snippet: false };
let parsed = serde_saphyr::from_str_with_options::<FrontmatterFields>(body.text, options);
let fields = match parsed {
Ok(fields) => fields,
Err(malformed) => {
let location = malformed.location();
let error_span = location
.map_or_else(|| body.span(), |location| Self::source_span(body, location));
let malformed_message = DefaultMessageFormatter.format_message(&malformed);
let message = format!("malformed frontmatter: {malformed_message}");
diagnostics.record_error(message, error_span);
return Self::default();
}
};
let FrontmatterFields {
view,
styles,
unknown_keys,
} = fields;
for unknown_key in unknown_keys {
let key_span = Self::source_span(body, unknown_key.referenced);
let key = unknown_key.value.as_str();
let is_moved_key = MOVED_KEYS.contains(&key);
if is_moved_key {
let message = format!(
"frontmatter `{key}` is v3: write it in the first workflow's heading, `### main: TITLE (subtitle=SUBTITLE)`"
);
diagnostics.record_error(message, key_span);
continue;
}
let known_spellings = FrontmatterKey::VARIANTS;
let prefix = "unknown frontmatter key";
diagnostics.record_unknown(prefix, &unknown_key.value, known_spellings, key_span);
}
let mut styles: Vec<StyleSource> = styles
.into_iter()
.map(|(name, properties)| Self::style_source(body, name, properties))
.collect();
styles.sort_by_key(|style| style.name.span.start);
Self { view, styles }
}
fn style_source(
body: SourceText<'_>,
name: YamlSpanned<String>,
properties: YamlSpanned<String>,
) -> StyleSource {
let name_span = Self::source_span(body, name.referenced);
let name = Spanned {
inner: name.value,
span: name_span,
};
let scalar_span = Self::source_span(body, properties.referenced);
let scalar = body.slice(scalar_span).trim_end();
let scalar_end = scalar_span.start + scalar.len();
let properties_span = Span::from(scalar_span.start..scalar_end);
let unquoted_scalar = scalar
.strip_prefix(QUOTES)
.and_then(|after_opening_quote| after_opening_quote.strip_suffix(QUOTES));
let is_quoted = unquoted_scalar.is_some();
let quote_length = usize::from(is_quoted);
let scalar_text = unquoted_scalar.unwrap_or(scalar);
let is_verbatim = scalar_text == properties.value;
let verbatim_start = properties_span.start + quote_length;
let verbatim_offset = is_verbatim.then_some(verbatim_start);
StyleSource {
name,
properties: properties.value,
properties_span,
verbatim_offset,
}
}
fn source_span(body: SourceText<'_>, location: Location) -> Span {
let yaml_span = location.span();
let yaml_start = yaml_span.byte_offset().unwrap_or_default();
let yaml_length = yaml_span.byte_len().unwrap_or_default();
let body_start = usize::try_from(yaml_start).unwrap_or_default();
let length = usize::try_from(yaml_length).unwrap_or_default();
let body_length = body.text.len();
let clamped_start = body_start.min(body_length);
let clamped_end = (body_start + length).min(body_length);
let start = body.offset + clamped_start;
let end = body.offset + clamped_end;
Span::from(start..end)
}
}
impl<'de> Deserialize<'de> for FrontmatterFields {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_map(FrontmatterFieldsVisitor)
}
}
struct FrontmatterFieldsVisitor;
impl<'de> Visitor<'de> for FrontmatterFieldsVisitor {
type Value = FrontmatterFields;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let key_list = ProseList::join_quoted(FrontmatterKey::VARIANTS, "and");
write!(formatter, "a mapping of {key_list}")
}
fn visit_map<A: MapAccess<'de>>(self, mut entries: A) -> Result<Self::Value, A::Error> {
let mut fields = FrontmatterFields::default();
while let Some(key) = entries.next_key::<YamlSpanned<String>>()? {
let frontmatter_key = FrontmatterKey::from_str(&key.value);
match frontmatter_key {
Ok(FrontmatterKey::View) => fields.view = Some(entries.next_value()?),
Ok(FrontmatterKey::Styles) => fields.styles = entries.next_value()?,
Err(_unknown_key) => {
let _ignored_value: IgnoredAny = entries.next_value()?;
fields.unknown_keys.push(key);
}
}
}
Ok(fields)
}
}