use std::collections::HashMap;
use crate::error::ParseError;
use crate::value::QuillValue;
use crate::Diagnostic;
use super::fences::find_metadata_blocks;
use super::meta::{extract_meta_items, meta_key};
use super::payload::{Payload, PayloadItem};
use super::prescan::{prescan_fence_content, NestedComment, PreItem};
use super::{Card, Document};
fn missing_block_message(markdown: &str) -> String {
let trimmed = markdown.trim_start();
if trimmed.starts_with("$quill:") || trimmed.starts_with("quill:") {
return "Missing required root card-yaml block. Your document starts with \
YAML metadata but is missing the `~~~` fence. Wrap the \
metadata: add a line `~~~` above the `$quill:` line and a \
line containing exactly `~~~` (three tildes, no info string) below \
the last metadata field, before the prose body."
.to_string();
}
"Missing required root card-yaml block. The document must open with a \
`~~~` block declaring `$quill: <name>` (and `$kind: main`) as \
the first two lines, closed by a line containing exactly `~~~`."
.to_string()
}
fn strip_blank_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) meta_items: Vec<PayloadItem>,
pub(super) pre_items: Vec<PreItem>,
pub(super) pre_nested_comments: Vec<NestedComment>,
pub(super) pre_warnings: Vec<Diagnostic>,
}
pub(super) fn build_block(
markdown: &str,
block_start: usize,
content_start: usize,
content_end: usize,
block_end: usize,
block_index: usize,
) -> Result<MetadataBlock, ParseError> {
let raw_content = &markdown[content_start..content_end];
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()));
}
for item in &pre.items {
if let PreItem::Field { key, fill: true } = item {
if key.starts_with('$') {
return Err(ParseError::InvalidStructure(format!(
"`!fill` on `{}` is not permitted — system-metadata keys \
cannot be placeholders",
key
)));
}
}
}
let content = pre.cleaned_yaml.trim().to_string();
let (meta_items, yaml_value) = if content.is_empty() {
(Vec::new(), None)
} else {
let mut parsed = match serde_saphyr::from_str_with_options::<serde_json::Value>(
&content,
super::limits::yaml_parse_options(),
) {
Ok(parsed) => parsed,
Err(e) => {
let line = markdown[..block_start].lines().count() + 1;
let enriched =
super::yaml_hints::enrich_yaml_error(&e.to_string(), &content);
return Err(ParseError::YamlErrorWithLocation {
message: enriched.message,
line,
block_index,
hint: enriched.hint,
});
}
};
let meta = extract_meta_items(&mut parsed)?;
(meta, Some(parsed))
};
if let Some(serde_json::Value::Object(ref map)) = yaml_value {
if map.len() > crate::error::MAX_FIELD_COUNT {
return Err(ParseError::InputTooLarge {
size: map.len(),
max: crate::error::MAX_FIELD_COUNT,
});
}
}
Ok(MetadataBlock {
start: block_start,
end: block_end,
yaml_value,
meta_items,
pre_items: pre.items,
pre_nested_comments: pre.nested_comments,
pre_warnings: pre.warnings,
})
}
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 root card-yaml block declaring `$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) = find_metadata_blocks(markdown)?;
if blocks.is_empty() {
return Err(crate::error::ParseError::MissingQuill(missing_block_message(
markdown,
)));
}
let root_block = &blocks[0];
let has_root_quill = root_block
.meta_items
.iter()
.any(|m| matches!(m, PayloadItem::Quill { .. }));
if !has_root_quill {
return Err(ParseError::MissingQuill(
"The document's root card-yaml block must declare `$quill: <name>` as \
its first line (e.g. `$quill: usaf_memo@0.2.0`)."
.to_string(),
));
}
let root_kind = root_block.meta_items.iter().find_map(|m| match m {
PayloadItem::Kind { value } => Some(value.as_str()),
_ => None,
});
if let Some(other) = root_kind {
if other != "main" {
return Err(ParseError::InvalidStructure(format!(
"The document's root card-yaml block has `$kind: {}`, but \
`main` is reserved for the document root. Remove the line \
(the root's kind is `main` by position) or change it to \
`$kind: main`.",
other
)));
}
}
let mut main_payload = build_payload(
&root_block.meta_items,
&root_block.pre_items,
&root_block.pre_nested_comments,
&root_block.yaml_value,
)?;
if main_payload.kind().is_none() {
main_payload.set_kind("main");
}
let mut warnings = warnings;
for w in &root_block.pre_warnings {
warnings.push(w.clone());
}
let body_start = blocks[0].end;
let (body_end, body_is_followed_by_fence) = match blocks.get(1) {
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_blank_separator(global_body_raw).to_string()
} else {
global_body_raw.to_string()
};
let main = Card::from_parts(main_payload, global_body);
let mut cards: Vec<Card> = Vec::new();
for idx in 1..blocks.len() {
let block = &blocks[idx];
if block
.meta_items
.iter()
.any(|m| matches!(m, PayloadItem::Quill { .. }))
{
return Err(ParseError::InvalidStructure(
"A composable card-yaml block must not declare `$quill` — only \
the document's root block binds the document to a quill."
.to_string(),
));
}
let kind_is_main = block.meta_items.iter().any(|m| match m {
PayloadItem::Kind { value } => value == "main",
_ => false,
});
if kind_is_main {
return Err(ParseError::InvalidStructure(
"A composable card-yaml block must not declare `$kind: main` — \
`main` is reserved for the document root."
.to_string(),
));
}
let card_payload = build_payload(
&block.meta_items,
&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: {}", 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_blank_separator(card_body_raw).to_string()
} else {
card_body_raw.to_string()
};
cards.push(Card::from_parts(card_payload, card_body));
}
let doc = Document::from_main_and_cards(main, cards, warnings.clone());
Ok((doc, warnings))
}
fn build_payload(
meta_items: &[PayloadItem],
pre_items: &[PreItem],
pre_nested_comments: &[NestedComment],
yaml_value: &Option<serde_json::Value>,
) -> Result<Payload, 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 typed_by_key: HashMap<&'static str, PayloadItem> =
HashMap::with_capacity(meta_items.len());
for m in meta_items {
let k = meta_key(m).expect(
"build_payload: meta_items must contain only system variants \
($quill/$kind/$id/$ext); got a Field or Comment",
);
typed_by_key.insert(k, m.clone());
}
let mut items: Vec<PayloadItem> = Vec::new();
let mut consumed_user_keys: std::collections::HashSet<String> =
std::collections::HashSet::new();
for item in pre_items {
match item {
PreItem::Comment { text, inline } => {
items.push(PayloadItem::Comment {
text: text.clone(),
inline: *inline,
});
}
PreItem::Field { key, fill } => {
if key.starts_with('$') {
if let Some(meta) = typed_by_key.remove(key.as_str()) {
items.push(meta);
}
continue;
}
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(PayloadItem::Field {
key: key.clone(),
value: QuillValue::from_json(value),
fill: *fill,
nested_comments: Vec::new(),
});
consumed_user_keys.insert(key.clone());
}
}
}
}
for meta in meta_items {
let k = meta_key(meta).expect("see invariant above");
if typed_by_key.remove(k).is_some() {
items.push(meta.clone());
}
}
for (key, value) in &mapping {
if consumed_user_keys.contains(key) {
continue;
}
items.push(PayloadItem::Field {
key: key.clone(),
value: QuillValue::from_json(value.clone()),
fill: false,
nested_comments: Vec::new(),
});
}
Ok(Payload::from_items_with_flat_nested(
items,
pre_nested_comments.to_vec(),
))
}