use crate::error::ParseError;
use crate::{Diagnostic, Severity};
use super::assemble::MetadataBlock;
const CARD_YAML_INFO: &str = "card-yaml";
pub(super) struct Lines<'a> {
pub(super) source: &'a str,
pub(super) starts: Vec<usize>, }
impl<'a> Lines<'a> {
pub(super) fn new(source: &'a str) -> Self {
let mut starts = Vec::new();
starts.push(0);
for (i, b) in source.bytes().enumerate() {
if b == b'\n' {
starts.push(i + 1);
}
}
Self { source, starts }
}
pub(super) fn len(&self) -> usize {
self.starts.len()
}
pub(super) fn line_start(&self, k: usize) -> usize {
self.starts[k]
}
pub(super) fn line_end_inclusive(&self, k: usize) -> usize {
if k + 1 < self.starts.len() {
self.starts[k + 1]
} else {
self.source.len()
}
}
pub(super) fn line_text(&self, k: usize) -> &'a str {
let start = self.starts[k];
let mut end = self.line_end_inclusive(k);
if end > start && self.source.as_bytes()[end - 1] == b'\n' {
end -= 1;
}
if end > start && self.source.as_bytes()[end - 1] == b'\r' {
end -= 1;
}
&self.source[start..end]
}
pub(super) fn is_blank(&self, k: usize) -> bool {
self.line_text(k).chars().all(char::is_whitespace)
}
}
pub(super) fn code_fence_on_line(
line: &str,
open_fence: Option<(u8, usize)>,
) -> Option<(u8, usize, bool)> {
let indent = line.as_bytes().iter().take_while(|&&b| b == b' ').count();
if indent > 3 {
return None;
}
let trimmed = &line[indent..];
let bytes = trimmed.as_bytes();
let &first = bytes.first()?;
if first != b'`' && first != b'~' {
return None;
}
let run_len = bytes.iter().take_while(|&&b| b == first).count();
if run_len < 3 {
return None;
}
let rest = &trimmed[run_len..];
match open_fence {
Some((open_char, open_len)) => {
if first == open_char
&& run_len >= open_len
&& rest.chars().all(|c| c == ' ' || c == '\t')
{
Some((first, run_len, true))
} else {
None
}
}
None => Some((first, run_len, false)),
}
}
pub(super) fn code_fence_info(line: &str, run_len: usize) -> &str {
let indent = line.as_bytes().iter().take_while(|&&b| b == b' ').count();
line[indent + run_len..].trim()
}
fn card_yaml_opener_run(line: &str) -> Option<usize> {
if line.starts_with(' ') {
return None;
}
match code_fence_on_line(line, None) {
Some((b'~', run, false)) => {
let info = code_fence_info(line, run);
(info.is_empty() || info == CARD_YAML_INFO).then_some(run)
}
_ => None,
}
}
pub(crate) fn is_card_yaml_opener_line(line: &str) -> bool {
card_yaml_opener_run(line).is_some()
}
fn is_dash_fence_line(line: &str) -> bool {
let bytes = line.as_bytes();
if bytes.len() < 3 || bytes[0] != b'-' {
return false;
}
let run_len = bytes.iter().take_while(|&&b| b == b'-').count();
if run_len != 3 {
return false;
}
line[run_len..].chars().all(|c| c == ' ' || c == '\t')
}
fn looks_like_yaml_key_line(line: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
return false;
}
let bytes = trimmed.as_bytes();
let mut i;
if bytes[0] == b'$' {
if bytes.len() < 2 || !(bytes[1].is_ascii_alphabetic() || bytes[1] == b'_') {
return false;
}
i = 2;
} else if bytes[0].is_ascii_alphabetic() || bytes[0] == b'_' {
i = 1;
} else {
return false;
}
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
i < bytes.len() && bytes[i] == b':'
}
fn has_paired_dash_with_yaml_keys(lines: &Lines<'_>, opener_k: usize) -> bool {
let mut saw_yaml_key = false;
let mut j = opener_k + 1;
while j < lines.len() {
let text = lines.line_text(j);
if is_dash_fence_line(text) {
return saw_yaml_key;
}
if looks_like_yaml_key_line(text) {
saw_yaml_key = true;
}
j += 1;
}
false
}
pub(super) type FenceScan = (Vec<MetadataBlock>, Vec<Diagnostic>);
pub(super) fn find_metadata_blocks(markdown: &str) -> Result<FenceScan, ParseError> {
let lines = Lines::new(markdown);
let mut blocks: Vec<MetadataBlock> = Vec::new();
let mut warnings: Vec<Diagnostic> = Vec::new();
let mut open_code_fence: Option<(u8, usize, usize)> = None;
let mut k: usize = 0;
while k < lines.len() {
let text = lines.line_text(k);
if let Some((ch, min, _opener)) = open_code_fence {
if let Some((_, _, true)) = code_fence_on_line(text, Some((ch, min))) {
open_code_fence = None;
}
k += 1;
continue;
}
if let Some(open_run) = card_yaml_opener_run(text) {
let blank_above = k == 0 || lines.is_blank(k - 1);
if !blank_above {
warnings.push(
Diagnostic::new(
Severity::Warning,
format!(
"`~~~` card-yaml block at line {} has no blank line above it — it is treated as an ordinary code block, not a card-yaml block. Insert a blank line before it to register it.",
k + 1
),
)
.with_code("parse::card_fence_missing_blank".to_string()),
);
open_code_fence = Some((b'~', open_run, k));
k += 1;
continue;
}
let mut closer_k: Option<usize> = None;
let mut j = k + 1;
while j < lines.len() {
let candidate = lines.line_text(j);
if !candidate.starts_with(' ') {
if let Some((_, _, true)) =
code_fence_on_line(candidate, Some((b'~', open_run)))
{
closer_k = Some(j);
break;
}
}
j += 1;
}
let Some(cj) = closer_k else {
open_code_fence = Some((b'~', open_run, k));
k += 1;
continue;
};
let block = super::assemble::build_block(
markdown,
lines.line_start(k),
lines.line_end_inclusive(k),
lines.line_start(cj),
lines.line_end_inclusive(cj),
blocks.len(),
)?;
blocks.push(block);
k = cj + 1;
continue;
}
if is_dash_fence_line(text) {
let blank_above = k == 0 || lines.is_blank(k - 1);
if blocks.is_empty() && blank_above {
let above_all_blank = (0..k).all(|i| lines.is_blank(i));
if above_all_blank {
let mut closer_k: Option<usize> = None;
let mut j = k + 1;
while j < lines.len() {
if is_dash_fence_line(lines.line_text(j)) {
closer_k = Some(j);
break;
}
j += 1;
}
let Some(cj) = closer_k else {
k += 1;
continue;
};
let block = super::assemble::build_block(
markdown,
lines.line_start(k),
lines.line_end_inclusive(k),
lines.line_start(cj),
lines.line_end_inclusive(cj),
blocks.len(),
)?;
blocks.push(block);
k = cj + 1;
continue;
}
}
if !blocks.is_empty() && blank_above && has_paired_dash_with_yaml_keys(&lines, k) {
return Err(ParseError::InvalidStructure(
"Composable card block opened with `---` but composable cards \
must use `~~~` fences. Replace the opening `---` and the \
closing `---` with `~~~` (three tildes, no info string). The \
`---` style is accepted only for the document's root block."
.to_string(),
));
}
k += 1;
continue;
}
if let Some((ch, run_len, _)) = code_fence_on_line(text, None) {
open_code_fence = Some((ch, run_len, k));
}
k += 1;
}
let card_count = blocks.len().saturating_sub(1);
if card_count > crate::error::MAX_CARD_COUNT {
return Err(ParseError::InputTooLarge {
size: card_count,
max: crate::error::MAX_CARD_COUNT,
});
}
if let Some((_, _, opener_line)) = open_code_fence {
warnings.push(
Diagnostic::new(
Severity::Warning,
format!(
"Unclosed fenced code block opened at line {} — end-of-document reached without a matching closing fence. Any `~~~` card-yaml blocks after this line were treated as code and not parsed.",
opener_line + 1
),
)
.with_code("parse::unclosed_code_block".to_string()),
);
}
Ok((blocks, warnings))
}