use std::str::FromStr;
use crate::error::ParseError;
use crate::value::QuillValue;
use crate::version::QuillReference;
use crate::Diagnostic;
use super::fences::{fence_opener_len, find_metadata_blocks};
use super::frontmatter::{Frontmatter, FrontmatterItem};
use super::prescan::{prescan_fence_content, NestedComment, PreItem};
use super::sentinel::extract_sentinels;
use super::{Card, Document, Sentinel};
fn strip_f2_separator(body: &str) -> &str {
if let Some(rest) = body.strip_suffix("\r\n") {
rest
} else if let Some(rest) = body.strip_suffix('\n') {
rest
} else {
body
}
}
#[derive(Debug)]
pub(super) struct MetadataBlock {
pub(super) start: usize, pub(super) end: usize, pub(super) yaml_value: Option<serde_json::Value>, pub(super) tag: Option<String>, pub(super) quill_ref: Option<String>, pub(super) pre_items: Vec<PreItem>,
pub(super) pre_nested_comments: Vec<NestedComment>,
pub(super) pre_warnings: Vec<Diagnostic>,
}
fn yaml_parse_options() -> serde_saphyr::Options {
let budget = serde_saphyr::Budget {
max_depth: super::limits::MAX_YAML_DEPTH,
..Default::default()
};
serde_saphyr::Options {
budget: Some(budget),
..Default::default()
}
}
pub(super) fn build_block(
markdown: &str,
abs_pos: usize,
abs_closing_pos: usize,
block_end: usize,
block_index: usize,
) -> Result<MetadataBlock, ParseError> {
let raw_content = &markdown[abs_pos + fence_opener_len(markdown, abs_pos)..abs_closing_pos];
if raw_content.len() > crate::error::MAX_YAML_SIZE {
return Err(ParseError::InputTooLarge {
size: raw_content.len(),
max: crate::error::MAX_YAML_SIZE,
});
}
let pre = prescan_fence_content(raw_content);
if let Some(err) = pre.fill_target_errors.first() {
return Err(ParseError::InvalidStructure(err.clone()));
}
let content = pre.cleaned_yaml.trim().to_string();
let (tag, quill_ref, yaml_value) = if content.is_empty() {
(None, None, None)
} else {
match serde_saphyr::from_str_with_options::<serde_json::Value>(
&content,
yaml_parse_options(),
) {
Ok(parsed) => extract_sentinels(parsed, markdown, abs_pos, block_index)?,
Err(e) => {
let line = markdown[..abs_pos].lines().count() + 1;
return Err(ParseError::YamlErrorWithLocation {
message: e.to_string(),
line,
block_index,
});
}
}
};
if let Some(serde_json::Value::Object(ref map)) = yaml_value {
let sentinel_extra = if quill_ref.is_some() || tag.is_some() {
1
} else {
0
};
if map.len() + sentinel_extra > crate::error::MAX_FIELD_COUNT {
return Err(ParseError::InputTooLarge {
size: map.len() + sentinel_extra,
max: crate::error::MAX_FIELD_COUNT,
});
}
}
Ok(MetadataBlock {
start: abs_pos,
end: block_end,
yaml_value,
tag,
quill_ref,
pre_items: pre.items,
pre_nested_comments: pre.nested_comments,
pre_warnings: pre.warnings,
})
}
fn missing_quill_message(first_fence_issue: Option<(String, usize)>) -> String {
match first_fence_issue {
Some((actual, line)) if actual.eq_ignore_ascii_case("QUILL") => format!(
"Missing required QUILL field. Found `{}:` at line {} — expected `QUILL:` (uppercase). Change the key to `QUILL` to register this fence as the document frontmatter.",
actual, line
),
Some((actual, line)) => format!(
"Missing required QUILL field. The first YAML key in the frontmatter must be `QUILL:` (found `{}:` at line {}). Reorder the frontmatter so `QUILL: <name>` is the first key.",
actual, line
),
None => "Missing required QUILL field. Add `QUILL: <name>` to the frontmatter.".to_string(),
}
}
pub(super) fn decompose(markdown: &str) -> Result<Document, crate::error::ParseError> {
decompose_with_warnings(markdown).map(|(doc, _)| doc)
}
pub(super) fn decompose_with_warnings(
markdown: &str,
) -> Result<(Document, Vec<Diagnostic>), crate::error::ParseError> {
let markdown = markdown.strip_prefix('\u{FEFF}').unwrap_or(markdown);
if markdown.trim().is_empty() {
return Err(crate::error::ParseError::EmptyInput(
"Empty markdown input cannot be parsed as a Quillmark Document. \
Provide at least a QUILL frontmatter field: `QUILL: <name>`."
.to_string(),
));
}
if markdown.len() > crate::error::MAX_INPUT_SIZE {
return Err(crate::error::ParseError::InputTooLarge {
size: markdown.len(),
max: crate::error::MAX_INPUT_SIZE,
});
}
let (blocks, warnings, first_fence_issue) = find_metadata_blocks(markdown)?;
if blocks.is_empty() {
return Err(crate::error::ParseError::MissingQuillField(
missing_quill_message(first_fence_issue),
));
}
let frontmatter_block = &blocks[0];
let quill_tag = frontmatter_block.quill_ref.clone().ok_or_else(|| {
ParseError::MissingQuillField(
"Missing required QUILL field. Add `QUILL: <name>` to the frontmatter.".to_string(),
)
})?;
let frontmatter = build_frontmatter_from_pre_and_parsed(
&frontmatter_block.pre_items,
&frontmatter_block.pre_nested_comments,
&frontmatter_block.yaml_value,
)?;
let mut warnings = warnings;
for w in &frontmatter_block.pre_warnings {
warnings.push(w.clone());
}
let body_start = blocks[0].end;
let first_card_block = blocks.iter().skip(1).find(|b| b.tag.is_some());
let (body_end, body_is_followed_by_fence) = match first_card_block {
Some(b) => (b.start, true),
None => (markdown.len(), false),
};
let global_body_raw = &markdown[body_start..body_end];
let global_body = if body_is_followed_by_fence {
strip_f2_separator(global_body_raw).to_string()
} else {
global_body_raw.to_string()
};
let mut cards: Vec<Card> = Vec::new();
for (idx, block) in blocks.iter().enumerate() {
if let Some(ref tag_name) = block.tag {
let card_frontmatter = build_frontmatter_from_pre_and_parsed(
&block.pre_items,
&block.pre_nested_comments,
&block.yaml_value,
)
.map_err(|e| match e {
ParseError::InvalidStructure(msg) => ParseError::InvalidStructure(format!(
"Invalid YAML in card block '{}': {}",
tag_name, msg
)),
other => other,
})?;
for w in &block.pre_warnings {
warnings.push(w.clone());
}
let card_body_start = block.end;
let has_next_block = idx + 1 < blocks.len();
let card_body_end = if has_next_block {
blocks[idx + 1].start
} else {
markdown.len()
};
let card_body_raw = &markdown[card_body_start..card_body_end];
let card_body = if has_next_block {
strip_f2_separator(card_body_raw).to_string()
} else {
card_body_raw.to_string()
};
cards.push(Card::new_with_sentinel(
Sentinel::Card(tag_name.clone()),
card_frontmatter,
card_body,
));
}
}
let quill_ref = QuillReference::from_str(&quill_tag).map_err(|e| {
ParseError::InvalidStructure(format!("Invalid QUILL tag '{}': {}", quill_tag, e))
})?;
let main = Card::new_with_sentinel(Sentinel::Main(quill_ref), frontmatter, global_body);
let doc = Document::from_main_and_cards(main, cards, warnings.clone());
Ok((doc, warnings))
}
fn build_frontmatter_from_pre_and_parsed(
pre_items: &[PreItem],
pre_nested_comments: &[NestedComment],
yaml_value: &Option<serde_json::Value>,
) -> Result<Frontmatter, ParseError> {
let mapping = match yaml_value {
Some(serde_json::Value::Object(map)) => map.clone(),
Some(serde_json::Value::Null) | None => serde_json::Map::new(),
Some(_) => {
return Err(ParseError::InvalidStructure(
"expected a mapping".to_string(),
));
}
};
let mut items: Vec<FrontmatterItem> = Vec::new();
let mut consumed: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut after_stripped_sentinel = false;
for pre in pre_items {
match pre {
PreItem::Comment { text, inline } => {
let demote = after_stripped_sentinel && *inline && !items.is_empty();
after_stripped_sentinel = false;
items.push(FrontmatterItem::Comment {
text: text.clone(),
inline: *inline && !demote,
});
}
PreItem::Field { key, fill } => {
if key == "QUILL" || key == "CARD" {
after_stripped_sentinel = true;
continue;
}
after_stripped_sentinel = false;
if let Some(value) = mapping.get(key).cloned() {
if *fill && value.is_object() {
return Err(ParseError::InvalidStructure(format!(
"`!fill` on key `{}` targets a mapping; `!fill` is supported on scalars and sequences only",
key
)));
}
items.push(FrontmatterItem::Field {
key: key.clone(),
value: QuillValue::from_json(value),
fill: *fill,
});
consumed.insert(key.clone());
}
}
}
}
for (key, value) in &mapping {
if consumed.contains(key) {
continue;
}
items.push(FrontmatterItem::Field {
key: key.clone(),
value: QuillValue::from_json(value.clone()),
fill: false,
});
}
Ok(Frontmatter::from_items_with_nested(
items,
pre_nested_comments.to_vec(),
))
}