use std::ops::Range;
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use serde_json::{Map, Value};
use crate::error::{Error, ErrorKind};
use crate::format::{self, Format};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Sniff {
Yaml,
Json,
Toml,
}
impl Sniff {
pub(crate) fn format(self) -> Format {
match self {
Self::Yaml => Format::Yaml,
Self::Json => Format::Json,
Self::Toml => Format::Toml,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Fields {
Fenced {
labeled: Option<Sniff>,
sniff: Sniff,
inner: String,
},
Bare {
sniff: Sniff,
inner: String,
map: Map<String, Value>,
},
None,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Document {
pub fields: Fields,
pub body: Vec<String>,
}
pub(crate) fn parse(input: &str, body_fields: usize) -> Result<Document, Error> {
let mut scan = Scan::new(input);
let content_start = scan.skip_prefix();
let (fields, body_start, separated) = match scan.leading_fields_fence(content_start) {
Some(fence) => {
let fields = Fields::Fenced {
labeled: fence.labeled,
sniff: fence.sniff,
inner: fence.inner,
};
(fields, fence.body_start, false)
}
None => {
let (end, body_start, separated) =
match scan.rules_from(content_start, 1).first().cloned() {
Some(range) => (range.start, range.end, true),
None => (input.len(), input.len(), false),
};
let inner = &input[content_start..end];
let sniff = sniff(inner);
let mapping = if sniff == Sniff::Json && starts_with_bracket(inner) {
None
} else {
load_bare_mapping(inner, sniff)?
};
match mapping {
Some(map) => {
let inner = inner.to_owned();
(Fields::Bare { sniff, inner, map }, body_start, separated)
}
None => (Fields::None, 0, false),
}
}
};
scan.rules_from(body_start, body_fields.saturating_sub(1));
let split = split_body(input, body_start, &scan.rules, body_fields, separated);
if let Some(offset) = scan.unclosed_fence_offset(split.verbatim_from) {
return Err(Error::syntax(offset, "unclosed fence"));
}
Ok(Document {
fields,
body: split.sections,
})
}
fn load_bare_mapping(inner: &str, sniff: Sniff) -> Result<Option<Map<String, Value>>, Error> {
if sniff == Sniff::Yaml && !may_be_yaml_mapping(inner) {
return Ok(None);
}
let format = sniff.format();
let value = match format::load(inner, format) {
Ok(value) => value,
Err(err)
if err.kind() == ErrorKind::FormatDisabled
&& sniff == Sniff::Yaml
&& !looks_like_yaml_mapping(inner) =>
{
return Ok(None);
}
Err(err) if err.kind() == ErrorKind::FormatDisabled => return Err(err),
Err(err) if matches!(format, Format::Json | Format::Toml) => return Err(err),
Err(_) => return Ok(None),
};
Ok(match value {
Value::Object(map) => Some(map),
Value::Null => Some(Map::new()),
_ => None,
})
}
fn may_be_yaml_mapping(text: &str) -> bool {
match text
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))
{
None => true,
Some(line) => line.contains(':') || line.starts_with(['?', '{', '!', '&', '*', '%']),
}
}
fn looks_like_yaml_mapping(text: &str) -> bool {
let Some(line) = text
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))
else {
return false;
};
if line.starts_with("? ") {
return true;
}
match line.split_once(':') {
Some((key, rest)) => !key.is_empty() && (rest.is_empty() || rest.starts_with([' ', '\t'])),
None => false,
}
}
pub(crate) fn would_read_fields(candidate: &str, body_fields: usize) -> Result<bool, Error> {
match parse(candidate, body_fields) {
Ok(doc) => Ok(!matches!(doc.fields, Fields::None)),
Err(err)
if matches!(
err.kind(),
ErrorKind::FrontMatter | ErrorKind::FormatDisabled
) =>
{
Ok(true)
}
Err(err) => Err(err),
}
}
pub(crate) const SEPARATOR: &str = "\n---\n";
pub(crate) const EMPTY_STRING_SECTION: &str = "\"\"";
pub(crate) fn check_middle_section(text: &str) -> Result<(), Error> {
if text.ends_with('\r') {
return Err(Error::body(
"a body value that is not the last must not end with a carriage return",
));
}
let mut doc = String::with_capacity(text.len() + SEPARATOR.len());
doc.push_str(text);
doc.push_str(SEPARATOR);
let mut scan = Scan::new(&doc);
while scan.pull() {}
let closed = scan.unclosed_fence_offset(doc.len()).is_none();
let expected = text.len() + 1;
match scan.rules.as_slice() {
[rule] if closed && rule.start == expected => Ok(()),
_ => Err(Error::body(
"a body value that is not the last contains a top-level `---` line \
(or an unclosed code fence) and would split on read; \
put it in the last body field",
)),
}
}
pub(crate) fn may_be_list(text: &str) -> bool {
let mut rest = text;
loop {
rest = rest.trim_start();
match rest.strip_prefix('#') {
Some(comment) => match comment.split_once('\n') {
Some((_, after)) => rest = after,
None => return false,
},
None => return rest.starts_with(['[', '-']),
}
}
}
pub(crate) fn starts_with_dash_rule(text: &str) -> bool {
first_line(text).is_some_and(|(line, _)| is_dash_thematic_break(line))
}
fn first_line(text: &str) -> Option<(&str, &str)> {
if text.is_empty() {
return None;
}
match text.find('\n') {
Some(i) => Some((text[..i].trim_end_matches('\r'), &text[i + 1..])),
None => Some((text.trim_end_matches('\r'), "")),
}
}
fn fenced_block_has_closer(src: &str, block_col: usize) -> bool {
let trimmed = src.trim_end_matches(['\n', '\r']);
let Some(first_nl) = trimmed.find('\n') else {
return false;
};
let opener_line = trimmed[..first_nl].trim_end_matches('\r');
let Some(from_block) = opener_line.get(block_col..) else {
return false;
};
let open_col = block_col + (from_block.len() - from_block.trim_start_matches(' ').len());
let Some((ch, n)) = opener_line.get(open_col..).and_then(fence_opener) else {
return false;
};
let rest = trimmed[first_nl + 1..].trim_start_matches('\r');
let last = match rest.rsplit_once('\n') {
Some((_, last)) => last.trim_end_matches('\r'),
None => rest.trim_end_matches('\r'),
};
let fence = last.trim_start_matches([' ', '>']);
let close_col = last.len() - fence.len();
close_col <= open_col + 3 && is_fence_closer(fence, ch, n)
}
fn fence_opener(line: &str) -> Option<(u8, usize)> {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && i < 3 && bytes[i] == b' ' {
i += 1;
}
let ch = *bytes.get(i)?;
if ch != b'`' && ch != b'~' {
return None;
}
let start = i;
while i < bytes.len() && bytes[i] == ch {
i += 1;
}
let n = i - start;
if n < 3 {
return None;
}
Some((ch, n))
}
fn is_fence_closer(line: &str, ch: u8, min: usize) -> bool {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && i < 3 && bytes[i] == b' ' {
i += 1;
}
let start = i;
while i < bytes.len() && bytes[i] == ch {
i += 1;
}
let n = i - start;
if n < min {
return false;
}
bytes[i..].iter().all(|b| *b == b' ' || *b == b'\t')
}
fn parser_options() -> Options {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_DEFINITION_LIST);
options
}
fn nest_delta(event: &Event<'_>) -> i32 {
match event {
Event::Start(
Tag::BlockQuote(_)
| Tag::List(_)
| Tag::Item
| Tag::Table(_)
| Tag::FootnoteDefinition(_)
| Tag::DefinitionList
| Tag::HtmlBlock
| Tag::CodeBlock(_),
) => 1,
Event::End(
TagEnd::BlockQuote(_)
| TagEnd::List(_)
| TagEnd::Item
| TagEnd::Table
| TagEnd::FootnoteDefinition
| TagEnd::DefinitionList
| TagEnd::HtmlBlock
| TagEnd::CodeBlock,
) => -1,
_ => 0,
}
}
fn dash_rule(input: &str, event: &Event<'_>, range: &Range<usize>) -> Option<Range<usize>> {
if matches!(event, Event::Rule) && is_dash_thematic_break(&input[range.clone()]) {
return Some(range.clone());
}
if let Event::Start(Tag::Heading { .. }) = event {
return setext_dash_underline(input, range.clone());
}
None
}
fn setext_dash_underline(input: &str, heading: Range<usize>) -> Option<Range<usize>> {
let src = &input[heading.clone()];
let first_line_end = src.find('\n').unwrap_or(src.len());
if src[..first_line_end].trim_start().starts_with('#') {
return None;
}
let without_final_nl = src.trim_end_matches(['\n', '\r']);
let last_nl = without_final_nl.rfind('\n')?;
let last_line = &without_final_nl[last_nl + 1..];
if !is_dash_thematic_break(last_line) {
return None;
}
Some(heading.start + last_nl + 1..heading.end)
}
fn is_dash_thematic_break(src: &str) -> bool {
let line = src.trim_end_matches(['\r', '\n']);
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && i < 3 && bytes[i] == b' ' {
i += 1;
}
let rest = &line[i..];
let mut dashes = 0usize;
for c in rest.chars() {
match c {
'-' => dashes += 1,
' ' | '\t' => {}
_ => return false,
}
}
dashes >= 3
}
fn skip_one_line_ending(input: &str, pos: usize) -> usize {
match input.as_bytes().get(pos..) {
Some([b'\r', b'\n', ..]) => pos + 2,
Some([b'\n', ..] | [b'\r', ..]) => pos + 1,
_ => pos,
}
}
fn skip_ws(input: &str, mut pos: usize) -> usize {
while pos < input.len() {
let ch = input[pos..].chars().next();
let Some(ch) = ch else {
break;
};
if !ch.is_whitespace() {
break;
}
pos += ch.len_utf8();
}
pos
}
struct LeadingFence {
labeled: Option<Sniff>,
sniff: Sniff,
inner: String,
body_start: usize,
}
struct Scan<'a> {
input: &'a str,
source: Box<dyn Iterator<Item = (Event<'a>, Range<usize>)> + 'a>,
events: Vec<(Event<'a>, Range<usize>)>,
rules: Vec<Range<usize>>,
nest: i32,
done: bool,
}
impl<'a> Scan<'a> {
fn new(input: &'a str) -> Self {
Self {
input,
source: Box::new(Parser::new_ext(input, parser_options()).into_offset_iter()),
events: Vec::new(),
rules: Vec::new(),
nest: 0,
done: false,
}
}
fn pull(&mut self) -> bool {
let Some((event, range)) = self.source.next() else {
self.done = true;
return false;
};
self.nest += nest_delta(&event);
if self.nest == 0
&& let Some(rule) = dash_rule(self.input, &event, &range)
{
self.rules.push(rule);
}
self.events.push((event, range));
true
}
fn event(&mut self, index: usize) -> Option<&(Event<'a>, Range<usize>)> {
while self.events.len() <= index && self.pull() {}
self.events.get(index)
}
fn reach(&mut self, pos: usize) {
while self
.events
.last()
.is_none_or(|(_, range)| range.start <= pos)
&& self.pull()
{}
}
fn rules_from(&mut self, from: usize, count: usize) -> &[Range<usize>] {
while self.rules.len() - self.rules.partition_point(|rule| rule.start < from) < count
&& self.pull()
{}
&self.rules[self.rules.partition_point(|rule| rule.start < from)..]
}
fn skip_prefix(&mut self) -> usize {
let mut pos = 0;
let mut next = 0;
loop {
pos = skip_ws(self.input, pos);
self.reach(pos);
while self
.rules
.get(next)
.is_some_and(|rule| rule.end <= pos && rule.start != pos)
{
next += 1;
}
match self.rules.get(next) {
Some(rule) if rule.start <= pos && (pos < rule.end || rule.start == pos) => {
pos = rule.end;
next += 1;
}
_ => return pos,
}
}
}
fn leading_fields_fence(&mut self, content_start: usize) -> Option<LeadingFence> {
self.reach(content_start);
let input = self.input;
let mut nest = 0i32;
let mut i = 0;
while let Some((event, range)) = self.event(i) {
let range = range.clone();
if range.end <= content_start {
nest += nest_delta(event);
i += 1;
continue;
}
if nest == 0 {
if let Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) = event {
let between = &input[content_start..range.start];
if !between.chars().all(char::is_whitespace) {
return None;
}
let labeled = fields_lang(info)?;
let end_idx = self.matching_code_block_end(i);
let inner = fenced_inner(input, &self.events, i, end_idx);
let sniff = labeled.unwrap_or_else(|| sniff(&inner));
return Some(LeadingFence {
labeled,
sniff,
inner,
body_start: skip_one_line_ending(input, range.end),
});
}
if range.start >= content_start && !matches!(event, Event::End(_)) {
return None;
}
}
nest += nest_delta(event);
i += 1;
}
None
}
fn matching_code_block_end(&mut self, start_idx: usize) -> usize {
let mut depth = 0i32;
let mut j = start_idx;
while let Some((event, _)) = self.event(j) {
match event {
Event::Start(Tag::CodeBlock(_)) => depth += 1,
Event::End(TagEnd::CodeBlock) => {
depth -= 1;
if depth == 0 {
return j;
}
}
_ => {}
}
j += 1;
}
self.events.len().saturating_sub(1)
}
fn unclosed_fence_offset(&mut self, limit: usize) -> Option<usize> {
let input = self.input;
let mut i = 0;
while let Some((event, range)) = self.event(i) {
let range = range.clone();
if range.start >= limit && !matches!(event, Event::End(_)) {
break;
}
if !matches!(
event,
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_)))
) {
i += 1;
continue;
}
let end_idx = self.matching_code_block_end(i);
let end = self
.events
.get(end_idx)
.map_or(input.len(), |(_, r)| r.end)
.max(range.end)
.min(input.len());
let line_start = input[..range.start].rfind('\n').map_or(0, |nl| nl + 1);
if !fenced_block_has_closer(&input[line_start..end], range.start - line_start) {
return Some(range.start);
}
i += 1;
}
None
}
}
fn fenced_inner(
input: &str,
events: &[(Event<'_>, Range<usize>)],
start_idx: usize,
end_idx: usize,
) -> String {
let mut inner = String::new();
for (event, range) in &events[start_idx + 1..end_idx] {
if matches!(event, Event::Text(_)) {
inner.push_str(&input[range.clone()]);
}
}
inner
}
fn fields_lang(info: &str) -> Option<Option<Sniff>> {
let first = info.split_whitespace().next().unwrap_or("");
if first.is_empty() {
return Some(None);
}
match first.to_ascii_lowercase().as_str() {
"yaml" | "yml" => Some(Some(Sniff::Yaml)),
"json" => Some(Some(Sniff::Json)),
"toml" => Some(Some(Sniff::Toml)),
_ => None,
}
}
fn sniff(text: &str) -> Sniff {
let trimmed = text.trim();
if trimmed.is_empty() {
return Sniff::Yaml;
}
if trimmed.starts_with('{') {
return Sniff::Json;
}
if looks_like_toml(trimmed) {
return Sniff::Toml;
}
if trimmed.starts_with('[') {
return Sniff::Json;
}
Sniff::Yaml
}
fn starts_with_bracket(text: &str) -> bool {
text.trim_start().starts_with('[')
}
fn looks_like_toml(text: &str) -> bool {
let Some(line) = text
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))
else {
return false;
};
is_toml_table_header(line) || is_toml_key_value(line)
}
fn is_toml_table_header(line: &str) -> bool {
let (inner, closer) = match line.strip_prefix("[[") {
Some(inner) => (inner, "]]"),
None => match line.strip_prefix('[') {
Some(inner) => (inner, "]"),
None => return false,
},
};
let Some(rest) = toml_key(inner) else {
return false;
};
let Some(rest) = rest.trim_start().strip_prefix(closer) else {
return false;
};
let rest = rest.trim_start();
rest.is_empty() || rest.starts_with('#')
}
fn is_toml_key_value(line: &str) -> bool {
let Some(rest) = toml_key(line) else {
return false;
};
let Some(value) = rest.trim_start().strip_prefix('=') else {
return false;
};
let value = value.trim_start();
let Some(first) = value.chars().next() else {
return false;
};
if matches!(first, '"' | '\'' | '+' | '-' | '[' | '{') || first.is_ascii_digit() {
return true;
}
["true", "false", "inf", "nan"].iter().any(|word| {
value.strip_prefix(word).is_some_and(|after| {
!after.starts_with(|c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'))
})
})
}
fn toml_key(text: &str) -> Option<&str> {
let mut rest = toml_key_part(text.trim_start())?;
while let Some(after_dot) = rest.trim_start().strip_prefix('.') {
rest = toml_key_part(after_dot.trim_start())?;
}
Some(rest)
}
fn toml_key_part(text: &str) -> Option<&str> {
let first = text.chars().next()?;
match first {
'"' => {
let mut escaped = false;
for (i, c) in text.char_indices().skip(1) {
match c {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => return Some(&text[i + 1..]),
'\n' => return None,
_ => {}
}
}
None
}
'\'' => text[1..].find('\'').map(|end| &text[end + 2..]),
_ => {
let end = text
.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-')))
.unwrap_or(text.len());
(end > 0).then(|| &text[end..])
}
}
}
struct Split {
sections: Vec<String>,
verbatim_from: usize,
}
fn split_body(
input: &str,
from: usize,
dash_rules: &[Range<usize>],
body_fields: usize,
separated: bool,
) -> Split {
let rest = input.get(from..).unwrap_or("");
match body_fields {
0 => {
let sections = if rest.chars().all(char::is_whitespace) {
Vec::new()
} else {
vec![rest.to_owned()]
};
Split {
sections,
verbatim_from: from,
}
}
1 => {
let sections = if separated {
vec![rest.to_owned()]
} else if let Some((line, after)) = first_line(rest)
&& is_dash_thematic_break(line)
{
vec![after.to_owned()]
} else if rest.is_empty() {
Vec::new()
} else {
vec![rest.to_owned()]
};
Split {
sections,
verbatim_from: from,
}
}
_ => split_many(input, from, dash_rules, body_fields),
}
}
fn split_many(input: &str, from: usize, dash_rules: &[Range<usize>], body_fields: usize) -> Split {
if from >= input.len() {
return Split {
sections: Vec::new(),
verbatim_from: input.len(),
};
}
let mut sections = Vec::with_capacity(body_fields.min(8));
let mut cur = from;
let mut taken = 0;
for range in dash_rules.iter().filter(|range| range.start >= from) {
if taken == body_fields - 1 {
break;
}
let line_start = input[..range.start].rfind('\n').map_or(0, |nl| nl + 1);
let end = line_start.max(cur);
sections.push(drop_line_end(&input[cur..end]).to_owned());
cur = range.end;
taken += 1;
}
let last = &input[cur..];
if taken == body_fields - 1 {
sections.push(last.to_owned());
Split {
sections,
verbatim_from: cur,
}
} else {
if !last.is_empty() {
sections.push(last.to_owned());
}
Split {
sections,
verbatim_from: input.len(),
}
}
}
fn drop_line_end(section: &str) -> &str {
section
.strip_suffix("\r\n")
.or_else(|| section.strip_suffix('\n'))
.unwrap_or(section)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testdata::goldens;
fn fenced(doc: &Document) -> (&Option<Sniff>, Sniff, &str) {
match &doc.fields {
Fields::Fenced {
labeled,
sniff,
inner,
} => (labeled, *sniff, inner.as_str()),
Fields::Bare { .. } | Fields::None => panic!("expected fenced fields, got {doc:?}"),
}
}
#[cfg(any(feature = "yaml", feature = "json", feature = "toml"))]
fn bare(doc: &Document) -> (Sniff, &str) {
match &doc.fields {
Fields::Bare { sniff, inner, .. } => (*sniff, inner.as_str()),
Fields::Fenced { .. } | Fields::None => panic!("expected bare fields, got {doc:?}"),
}
}
fn parse_ok(input: &str) -> Document {
parse_n(input, 2)
}
fn parse_n(input: &str, body_fields: usize) -> Document {
parse(input, body_fields).unwrap_or_else(|err| panic!("{err}"))
}
#[test]
fn may_be_list_looks_at_the_first_significant_character() {
for text in [
"[1]",
" \n[]",
"- a",
"\n\n - a",
"# note\n- a",
"#a\n # b\n[1]",
] {
assert!(may_be_list(text), "{text:?}");
}
for text in [
"",
" \n",
"plain",
"a\n- b",
"{\"k\": []}",
"# only a comment",
"# c\nplain\n- a",
"k: [1]",
] {
assert!(!may_be_list(text), "{text:?}");
}
}
#[test]
fn may_be_yaml_mapping_is_permissive_about_the_first_key_line() {
for text in [
"",
"# only a comment\n\n",
"key: value",
"# c\n key:\n",
"\"quoted\": 1",
"? key\n: value",
"{a: 1}",
"!!map {}",
"&a k: 1",
"*a : 1",
"%YAML 1.2\n---\na: 1",
"see http://example.com for more",
] {
assert!(may_be_yaml_mapping(text), "{text:?}");
}
for text in [
"Just prose",
"# c\nJust prose\nlater: x",
"- a\n- b",
"plain text\nk: v",
] {
assert!(!may_be_yaml_mapping(text), "{text:?}");
}
}
#[test]
fn a_yaml_slice_without_a_key_line_is_not_decoded_as_fields() {
let doc = parse_n("Prose first\nk: v\n", 1);
assert_eq!(doc.fields, Fields::None);
assert_eq!(doc.body, ["Prose first\nk: v\n"]);
}
#[test]
fn a_large_prose_document_has_no_fields() {
let text = "Prose with no key on its first line, nothing to decode\n".repeat(20_000);
let doc = parse_n(&text, 1);
assert_eq!(doc.fields, Fields::None);
assert_eq!(doc.body, [text]);
}
#[test]
fn sniff_empty_is_yaml() {
assert_eq!(sniff(""), Sniff::Yaml);
assert_eq!(sniff(" \n"), Sniff::Yaml);
}
#[test]
fn sniff_json_object_and_array() {
assert_eq!(sniff("{\n \"a\": 1\n}"), Sniff::Json);
assert_eq!(sniff("[1, 2]"), Sniff::Json);
}
#[test]
fn sniff_toml_key_equals() {
assert_eq!(sniff("field1 = \"foo\"\nfield3 = 1\n"), Sniff::Toml);
assert_eq!(sniff("# comment\nfield1 = 1\n"), Sniff::Toml);
}
#[test]
fn sniff_yaml_mapping() {
assert_eq!(sniff("field1: foo\nfield3: 1\n"), Sniff::Yaml);
}
#[test]
fn page_fenced_yaml() {
let doc = parse_ok(goldens::PAGE_FENCED_YAML);
let (labeled, sniff, inner) = fenced(&doc);
assert_eq!(*labeled, Some(Sniff::Yaml));
assert_eq!(sniff, Sniff::Yaml);
assert!(inner.contains("field1: foo"));
assert_eq!(doc.body.len(), 2);
assert!(
!doc.body[0].starts_with('\n'),
"newline after the closing fence is not body: {:?}",
doc.body[0]
);
assert_eq!(doc.body[0].trim(), "Text1 bla bla bla");
assert_eq!(doc.body[1].trim(), "Text2 bal bla bla");
}
#[test]
fn page_fenced_yml_tag() {
let doc = parse_ok(goldens::PAGE_FENCED_YML);
let (labeled, sniff, _) = fenced(&doc);
assert_eq!(*labeled, Some(Sniff::Yaml));
assert_eq!(sniff, Sniff::Yaml);
assert_eq!(doc.body.len(), 2);
}
#[test]
fn page_fenced_json_and_toml_tags() {
let json = parse_ok(goldens::PAGE_FENCED_JSON);
let (labeled, sniff, inner) = fenced(&json);
assert_eq!(*labeled, Some(Sniff::Json));
assert_eq!(sniff, Sniff::Json);
assert!(inner.contains("\"field1\""));
assert_eq!(json.body.len(), 2);
let toml = parse_ok(goldens::PAGE_FENCED_TOML);
let (labeled, sniff, inner) = fenced(&toml);
assert_eq!(*labeled, Some(Sniff::Toml));
assert_eq!(sniff, Sniff::Toml);
assert!(inner.contains("field1 ="));
assert_eq!(toml.body.len(), 2);
}
#[test]
fn unlabeled_fence_sniffs() {
let yaml = parse_ok(goldens::PAGE_UNLABELED_YAML);
let (labeled, sniff, _) = fenced(&yaml);
assert_eq!(*labeled, None);
assert_eq!(sniff, Sniff::Yaml);
assert_eq!(yaml.body.len(), 2);
let json = parse_ok(goldens::PAGE_UNLABELED_JSON);
let (labeled, sniff, _) = fenced(&json);
assert_eq!(*labeled, None);
assert_eq!(sniff, Sniff::Json);
let toml = parse_ok(goldens::PAGE_UNLABELED_TOML);
let (labeled, sniff, _) = fenced(&toml);
assert_eq!(*labeled, None);
assert_eq!(sniff, Sniff::Toml);
}
#[test]
fn leading_prefix_dash_is_discarded() {
let doc = parse_ok(goldens::PAGE_LEADING_PREFIX);
let (labeled, _, _) = fenced(&doc);
assert_eq!(*labeled, Some(Sniff::Yaml));
assert_eq!(doc.body.len(), 2);
assert_eq!(doc.body[0].trim(), "Text1 bla bla bla");
assert_eq!(doc.body[1].trim(), "Text2 bal bla bla");
}
#[cfg(feature = "yaml")]
#[test]
fn fence_not_first_is_body() {
let doc = parse_ok(goldens::PAGE_FENCE_NOT_FIRST);
assert_eq!(doc.fields, Fields::None);
assert_eq!(doc.body.len(), 1);
assert!(doc.body[0].starts_with("Intro paragraph"));
assert!(doc.body[0].contains("```yaml"));
}
#[test]
fn bare_slices_sniff() {
#[cfg(feature = "yaml")]
{
let yaml = parse_ok(goldens::PAGE_BARE_YAML);
let (sniff, inner) = bare(&yaml);
assert_eq!(sniff, Sniff::Yaml);
assert!(inner.contains("field1: foo"));
assert_eq!(yaml.body.len(), 2);
}
#[cfg(feature = "json")]
{
let json = parse_ok(goldens::PAGE_BARE_JSON);
let (sniff, _) = bare(&json);
assert_eq!(sniff, Sniff::Json);
assert_eq!(json.body.len(), 2);
}
#[cfg(feature = "toml")]
{
let toml = parse_ok(goldens::PAGE_BARE_TOML);
let (sniff, _) = bare(&toml);
assert_eq!(sniff, Sniff::Toml);
assert_eq!(toml.body.len(), 2);
}
}
#[test]
fn dash_inside_body_fence_does_not_split() {
let doc = parse_ok(goldens::PAGE_SPLIT_FENCE);
assert_eq!(doc.body.len(), 2);
assert!(doc.body[0].contains("---"));
assert!(doc.body[0].contains("not a split"));
assert_eq!(doc.body[1].trim(), "Text2 bal bla bla");
}
#[test]
fn stars_and_underscores_do_not_split() {
let doc = parse_ok(goldens::PAGE_SPLIT_STARS);
assert_eq!(doc.body.len(), 2);
assert!(doc.body[0].contains("***"));
assert!(doc.body[0].contains("___"));
assert!(doc.body[0].contains("still text1"));
assert_eq!(doc.body[1].trim(), "Text2 bal bla bla");
}
#[test]
fn dash_in_list_item_does_not_split() {
let doc = parse_ok(goldens::PAGE_SPLIT_LIST);
assert_eq!(doc.body.len(), 2);
assert!(doc.body[0].contains("keep going"));
assert!(doc.body[0].contains("---"));
assert_eq!(doc.body[1].trim(), "Text2 bal bla bla");
}
#[test]
fn fields_only_fenced_has_no_body() {
let doc = parse_ok(goldens::FIELDS_ONLY_FENCED_YAML);
let (labeled, _, inner) = fenced(&doc);
assert_eq!(*labeled, Some(Sniff::Yaml));
assert!(inner.contains("name: only"));
assert!(doc.body.is_empty());
}
#[cfg(feature = "yaml")]
#[test]
fn fields_only_bare_is_whole_file() {
let doc = parse_ok(goldens::FIELDS_ONLY_BARE_YAML);
let (sniff, inner) = bare(&doc);
assert_eq!(sniff, Sniff::Yaml);
assert!(inner.contains("name: only"));
assert!(doc.body.is_empty());
}
#[cfg(feature = "yaml")]
#[test]
fn body_only_is_two_sections() {
let doc = parse_ok(goldens::BODY_ONLY_TWO_SECTIONS);
assert_eq!(doc.fields, Fields::None);
assert_eq!(doc.body, ["Text1 bla bla bla", "Text2 bal bla bla"]);
}
#[test]
fn optional_middle_none_keeps_empty_slot() {
let doc = parse_n(goldens::OPTIONAL_MIDDLE_NONE, 3);
assert_eq!(doc.body.len(), 3);
assert_eq!(doc.body[0].trim(), "a");
assert!(doc.body[1].trim().is_empty());
assert_eq!(doc.body[2].trim(), "c");
}
#[test]
fn optional_leading_none_empty_first_section() {
let doc = parse_n(goldens::OPTIONAL_LEADING_NONE, 3);
assert_eq!(doc.body.len(), 2);
assert!(doc.body[0].trim().is_empty());
assert_eq!(doc.body[1].trim(), "a");
}
#[cfg(feature = "yaml")]
#[test]
fn rust_info_string_is_not_fields_fence() {
let input = "```rust\nlet x = 1;\n```\n";
let doc = parse_ok(input);
assert_eq!(doc.fields, Fields::None);
assert_eq!(doc.body, [input]);
}
#[test]
fn tilde_yaml_fence_is_fields() {
let input = "~~~yaml\nfield1: foo\n~~~\nbody\n";
let doc = parse_ok(input);
let (labeled, _, inner) = fenced(&doc);
assert_eq!(*labeled, Some(Sniff::Yaml));
assert!(inner.contains("field1: foo"));
assert_eq!(doc.body.len(), 1);
assert_eq!(doc.body[0].trim(), "body");
}
#[test]
fn atx_heading_is_not_a_split() {
let input = "```yaml\nk: 1\n```\n## still section one\ntext\n---\nsection two\n";
let doc = parse_ok(input);
assert_eq!(doc.body.len(), 2);
assert!(doc.body[0].contains("## still section one"));
assert_eq!(doc.body[1].trim(), "section two");
}
#[test]
fn blank_line_dash_is_rule_split() {
let input = "```yaml\nk: 1\n```\npara\n\n---\n\nnext\n";
let doc = parse_ok(input);
assert_eq!(doc.body.len(), 2);
assert_eq!(doc.body[0].trim(), "para");
assert_eq!(doc.body[1].trim(), "next");
}
#[test]
fn is_dash_rule_rejects_stars() {
assert!(is_dash_thematic_break("---\n"));
assert!(is_dash_thematic_break(" - - -\n"));
assert!(!is_dash_thematic_break("***\n"));
assert!(!is_dash_thematic_break("___\n"));
}
#[test]
fn unclosed_fields_fence_is_syntax() {
let err = parse("```yaml\nfield1: foo\n", 2).expect_err("unclosed");
assert_eq!(err.kind(), crate::ErrorKind::Syntax);
assert_eq!(err.offset(), Some(0));
assert!(err.to_string().contains("unclosed fence"), "{err}");
}
#[test]
fn unclosed_body_fence_is_syntax() {
let input = "```yaml\nk: 1\n```\nsee\n```text\n---\n";
let err = parse(input, 2).expect_err("unclosed body fence");
assert_eq!(err.kind(), crate::ErrorKind::Syntax);
assert!(err.offset().is_some());
}
#[cfg(feature = "yaml")]
const BARE_YAML_WITH_NESTED_FENCE: &str = "artifacts:\n\
- path: a.md\n kind: doc\n\
claim: Keep one pass.\n\
reason:\n text: |-\n Behaviour ships first.\n\n ```text\n # BAD\n ```\n\
status: candidate\n\
---\n\
Body text.\n";
#[cfg(feature = "yaml")]
#[test]
fn closed_fence_inside_a_container_is_not_unclosed() {
let doc = parse_ok(BARE_YAML_WITH_NESTED_FENCE);
let Fields::Bare { sniff, inner, .. } = &doc.fields else {
panic!("expected bare fields, got {doc:?}");
};
assert_eq!(*sniff, Sniff::Yaml);
assert!(
inner.contains(" ```text\n # BAD\n ```\n"),
"{inner}"
);
assert!(inner.ends_with("status: candidate\n"), "{inner}");
assert_eq!(doc.body, vec!["Body text.\n".to_owned()]);
}
#[test]
fn closed_fences_in_a_list_and_a_quote_are_not_unclosed() {
let input =
"```yaml\nk: 1\n```\n- item\n\n ```text\n code\n ```\n\n> ```\n> quoted\n> ```\n";
let doc = parse_ok(input);
assert_eq!(doc.body.len(), 1, "{doc:?}");
}
#[test]
fn a_fence_in_a_list_left_open_is_still_unclosed() {
let input = "```yaml\nk: 1\n```\n- item\n\n ```text\n code\n";
let err = parse(input, 2).expect_err("unclosed fence in a list item");
assert_eq!(err.kind(), crate::ErrorKind::Syntax);
}
#[test]
fn dash_in_blockquote_does_not_split() {
let input = "```yaml\nk: 1\n```\n> ---\nstill section one\n---\nsection two\n";
let doc = parse_ok(input);
assert_eq!(doc.body.len(), 2);
assert!(doc.body[0].contains("---"), "{:?}", doc.body[0]);
assert!(
doc.body[0].contains("still section one"),
"{:?}",
doc.body[0]
);
assert_eq!(doc.body[1].trim(), "section two");
}
#[test]
fn many_leading_rules_are_skipped_in_linear_time() {
let input = "---\n".repeat(50_000);
let start = std::time::Instant::now();
let _ = parse(&input, 2);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(10),
"50 000 leading rules took {elapsed:?}"
);
let doc = parse_ok("---\n---\n\n```yaml\nk: 1\n```\na\n---\nb");
let (labeled, _, _) = fenced(&doc);
assert_eq!(*labeled, Some(Sniff::Yaml));
assert_eq!(doc.body, ["a", "b"]);
}
#[test]
fn sniff_toml_needs_a_strict_header_or_key_value() {
for toml in [
"[meta]\nk = 1",
"[[items]]\nk = 1",
"[a.b]",
"[ a . \"b c\" ] # note",
"# comment\n\n[t]",
"k = 1",
"a.b.\"c d\" = 'x'",
"'k' = \"v\" # c",
"ok = true",
"x = -inf",
"x = nan",
"x = [1]",
"x = {a = 1}",
"x=+1",
] {
assert_eq!(sniff(toml), Sniff::Toml, "{toml:?}");
}
for not_toml in [
"[text](url) more",
"[ref]: http://example.com",
"E = mc² is famous",
"x = oops",
"x = trueish",
"= 1",
"a b = 1",
"key: value",
"- item",
] {
assert_ne!(sniff(not_toml), Sniff::Toml, "{not_toml:?}");
}
assert_eq!(sniff("{\"a\": 1}"), Sniff::Json);
assert_eq!(sniff("[text](url) more"), Sniff::Json);
}
#[cfg(any(feature = "yaml", feature = "json", feature = "toml"))]
#[test]
fn a_bare_first_slice_starting_with_a_bracket_is_body_unless_a_toml_header() {
for body in [
"[text](url) more",
"[1, 2] is a pair",
"[ref]: http://x\nmore",
] {
let doc = parse_n(body, 1);
assert_eq!(doc.fields, Fields::None, "{body:?}");
assert_eq!(doc.body, [body], "{body:?}");
}
let doc = parse_n("```\n[1, 2]\n```\nb", 1);
let (labeled, sniff, _) = fenced(&doc);
assert_eq!(*labeled, None);
assert_eq!(sniff, Sniff::Json);
}
#[cfg(feature = "toml")]
#[test]
fn a_bare_toml_table_header_is_fields() {
let doc = parse_n("[meta]\nk = 1\n---\nb", 1);
let (sniff, inner) = bare(&doc);
assert_eq!(sniff, Sniff::Toml);
assert_eq!(inner, "[meta]\nk = 1\n");
assert_eq!(doc.body, ["b"]);
}
#[test]
fn a_fence_longer_than_three_backticks_holds_a_shorter_run() {
let doc = parse_n("````yaml\na: |-\n x\n```\n y\n````\nb", 1);
let (labeled, sniff, inner) = fenced(&doc);
assert_eq!(*labeled, Some(Sniff::Yaml));
assert_eq!(sniff, Sniff::Yaml);
assert_eq!(inner, "a: |-\n x\n```\n y\n");
assert_eq!(doc.body, ["b"]);
}
}