carta-readers 0.0.2

Input-format readers: parse a source format's text into the document model.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
//! Document front matter: a leading YAML metadata block (`yaml_metadata_block`), a percent-line
//! title block (`pandoc_title_block`), or a `key: value` title block (`mmd_title_block`). Each
//! populates the document's `meta` map from material at the very top of the input and removes it
//! from the body.
//!
//! Each is recognized only when its extension is enabled and only at the first line of the document.
//! [`extract`] returns the metadata together with the body that remains once the front matter is
//! stripped.

use std::collections::BTreeMap;

use carta_ast::{Inline, MetaValue};
use carta_core::{Error, Extension, ReaderOptions, Result};

use super::inline::parse_meta_inlines;
use super::parse_meta_blocks;
use super::yaml::{self, Scalar, Yaml};

/// Document metadata together with the body that remains once any front matter is stripped. A `None`
/// body means the input carried no front matter and should be parsed unchanged.
pub(crate) struct FrontMatter {
    pub(crate) meta: BTreeMap<String, MetaValue>,
    pub(crate) body: Option<String>,
}

/// Extract document metadata from a leading YAML or title block, if either applies. Returns the
/// metadata and, when a block is consumed, the remaining body text. Malformed YAML is an error.
pub(crate) fn extract(normalized: &str, options: &ReaderOptions) -> Result<FrontMatter> {
    if options.extensions.contains(Extension::YamlMetadataBlock)
        && let Some(front) = yaml_block(normalized, options)?
    {
        return Ok(front);
    }
    if options.extensions.contains(Extension::PandocTitleBlock)
        && let Some((meta, body)) = title_block(normalized, options)
    {
        return Ok(FrontMatter {
            meta,
            body: Some(body),
        });
    }
    if options.extensions.contains(Extension::MmdTitleBlock)
        && let Some((meta, body)) = mmd_title_block(normalized)
    {
        return Ok(FrontMatter {
            meta,
            body: Some(body),
        });
    }
    Ok(FrontMatter {
        meta: BTreeMap::new(),
        body: None,
    })
}

/// Try to consume a leading YAML metadata block. `Ok(None)` means the input does not open one (fall
/// through); `Ok(Some(..))` carries the metadata and body; `Err` marks malformed YAML.
fn yaml_block(normalized: &str, options: &ReaderOptions) -> Result<Option<FrontMatter>> {
    let lines: Vec<&str> = normalized.split('\n').collect();
    // A fence line is `---` (open or close) or `...` (close) with optional trailing whitespace;
    // leading whitespace disqualifies it, so the comparison trims only the end.
    if lines.first().is_none_or(|line| line.trim_end() != "---") {
        return Ok(None);
    }
    let close = lines
        .iter()
        .enumerate()
        .skip(1)
        .find(|&(_, &line)| {
            let line = line.trim_end();
            line == "---" || line == "..."
        })
        .map(|(i, _)| i);
    // The closing fence is mandatory; without it the opening `---` is an ordinary thematic break.
    let Some(close) = close else {
        return Ok(None);
    };
    let content = lines.get(1..close).unwrap_or(&[]).join("\n");
    match yaml::parse(&content) {
        Ok(yaml::TopLevel::Mapping(entries)) => {
            let meta = entries
                .into_iter()
                .map(|(key, value)| (key, yaml_to_meta(value, options)))
                .collect();
            let body = lines.get(close + 1..).unwrap_or(&[]).join("\n");
            Ok(Some(FrontMatter {
                meta,
                body: Some(body),
            }))
        }
        // Valid YAML that is not a mapping does not become metadata; the fences fall through.
        Ok(yaml::TopLevel::NotMapping) => Ok(None),
        Err(()) => Err(Error::InvalidMetadata(
            "could not parse YAML metadata block".to_owned(),
        )),
    }
}

/// Parse a standalone YAML metadata file (no surrounding fences) into a metadata map, reusing the
/// same scalar→[`MetaValue`] mapping as a leading metadata block. A non-mapping file contributes no
/// metadata.
///
/// # Errors
/// [`Error::InvalidMetadata`] if the content is not valid YAML.
pub(crate) fn parse_metadata_yaml(
    content: &str,
    options: &ReaderOptions,
) -> Result<BTreeMap<String, MetaValue>> {
    match yaml::parse(content) {
        Ok(yaml::TopLevel::Mapping(entries)) => Ok(entries
            .into_iter()
            .map(|(key, value)| (key, yaml_to_meta(value, options)))
            .collect()),
        Ok(yaml::TopLevel::NotMapping) => Ok(BTreeMap::new()),
        Err(()) => Err(Error::InvalidMetadata(
            "could not parse YAML metadata file".to_owned(),
        )),
    }
}

/// Parse a standalone JSON metadata file into a metadata map. String and number scalars are parsed as
/// inline Markdown (matching the YAML path), booleans become [`MetaValue::MetaBool`], and arrays and
/// objects recurse.
///
/// # Errors
/// [`Error::InvalidMetadata`] if the content is not a valid JSON object.
pub(crate) fn parse_metadata_json(
    content: &str,
    options: &ReaderOptions,
) -> Result<BTreeMap<String, MetaValue>> {
    let value: serde_json::Value = serde_json::from_str(content).map_err(|error| {
        Error::InvalidMetadata(format!("could not parse JSON metadata file: {error}"))
    })?;
    match value {
        serde_json::Value::Object(map) => Ok(map
            .into_iter()
            .map(|(key, value)| (key, json_to_meta(value, options)))
            .collect()),
        _ => Err(Error::InvalidMetadata(
            "JSON metadata file must be an object".to_owned(),
        )),
    }
}

/// Convert a parsed JSON value into a metadata value, mirroring the YAML scalar typing: strings and
/// numbers parse as inline Markdown, booleans stay boolean, and arrays/objects recurse.
fn json_to_meta(value: serde_json::Value, options: &ReaderOptions) -> MetaValue {
    match value {
        serde_json::Value::Null => MetaValue::MetaString(String::new()),
        serde_json::Value::Bool(b) => MetaValue::MetaBool(b),
        serde_json::Value::Number(n) => MetaValue::MetaInlines(parse_meta_inlines(
            &n.to_string(),
            options.extensions,
            options.greedy_paragraphs,
        )),
        serde_json::Value::String(s) => MetaValue::MetaInlines(parse_meta_inlines(
            &s,
            options.extensions,
            options.greedy_paragraphs,
        )),
        serde_json::Value::Array(items) => MetaValue::MetaList(
            items
                .into_iter()
                .map(|item| json_to_meta(item, options))
                .collect(),
        ),
        serde_json::Value::Object(map) => MetaValue::MetaMap(
            map.into_iter()
                .map(|(key, item)| (key, json_to_meta(item, options)))
                .collect(),
        ),
    }
}

/// Convert a parsed YAML value into a metadata value, recursing through mappings and sequences.
fn yaml_to_meta(value: Yaml, options: &ReaderOptions) -> MetaValue {
    match value {
        Yaml::Mapping(entries) => MetaValue::MetaMap(
            entries
                .into_iter()
                .map(|(key, value)| (key, yaml_to_meta(value, options)))
                .collect(),
        ),
        Yaml::Sequence(items) => MetaValue::MetaList(
            items
                .into_iter()
                .map(|v| yaml_to_meta(v, options))
                .collect(),
        ),
        Yaml::Scalar(scalar) => scalar_to_meta(scalar, options),
    }
}

/// Resolve a scalar to a metadata value. Plain scalars are typed (null, boolean, number, or inline
/// text); quoted scalars are always inline text; block scalars are block- or inline-level depending
/// on whether their text keeps a trailing newline.
fn scalar_to_meta(scalar: Scalar, options: &ReaderOptions) -> MetaValue {
    match scalar {
        Scalar::Plain(text) => plain_scalar_to_meta(&text, options),
        Scalar::Quoted(text) => MetaValue::MetaInlines(parse_meta_inlines(
            &text,
            options.extensions,
            options.greedy_paragraphs,
        )),
        Scalar::Block(text) => text_to_meta(&text, options),
    }
}

fn plain_scalar_to_meta(text: &str, options: &ReaderOptions) -> MetaValue {
    if text.is_empty() || is_null(text) {
        return MetaValue::MetaString(String::new());
    }
    if let Some(value) = as_bool(text) {
        return MetaValue::MetaBool(value);
    }
    if let Some(canonical) = yaml::canonicalize_number(text) {
        return MetaValue::MetaInlines(parse_meta_inlines(
            &canonical,
            options.extensions,
            options.greedy_paragraphs,
        ));
    }
    MetaValue::MetaInlines(parse_meta_inlines(
        text,
        options.extensions,
        options.greedy_paragraphs,
    ))
}

/// Text whose trailing newline survived block-scalar chomping is parsed as block-level markdown;
/// otherwise it is inline markdown.
fn text_to_meta(text: &str, options: &ReaderOptions) -> MetaValue {
    if text.ends_with('\n') {
        MetaValue::MetaBlocks(parse_meta_blocks(
            text,
            options.extensions,
            options.greedy_paragraphs,
        ))
    } else {
        MetaValue::MetaInlines(parse_meta_inlines(
            text,
            options.extensions,
            options.greedy_paragraphs,
        ))
    }
}

fn is_null(text: &str) -> bool {
    matches!(text, "null" | "Null" | "NULL" | "~")
}

/// The unquoted YAML 1.1 boolean tokens.
fn as_bool(text: &str) -> Option<bool> {
    match text {
        "y" | "Y" | "yes" | "Yes" | "YES" | "true" | "True" | "TRUE" | "on" | "On" | "ON" => {
            Some(true)
        }
        "n" | "N" | "no" | "No" | "NO" | "false" | "False" | "FALSE" | "off" | "Off" | "OFF" => {
            Some(false)
        }
        _ => None,
    }
}

/// Try to consume a leading title block: up to three percent-introduced fields (title, author(s),
/// date) at the top of the document. Returns the metadata and the remaining body.
fn title_block(
    normalized: &str,
    options: &ReaderOptions,
) -> Option<(BTreeMap<String, MetaValue>, String)> {
    let lines: Vec<&str> = normalized.split('\n').collect();
    if !lines.first().is_some_and(|line| line.starts_with('%')) {
        return None;
    }
    let labels = ["title", "author", "date"];
    let mut meta = BTreeMap::new();
    let mut idx = 0;
    for label in labels {
        let Some(&line) = lines.get(idx) else { break };
        if !line.starts_with('%') {
            break;
        }
        let mut field = vec![strip_field_marker(line).to_owned()];
        idx += 1;
        while let Some(&cont) = lines.get(idx) {
            if cont.starts_with('%') || cont.trim().is_empty() || !starts_with_space(cont) {
                break;
            }
            field.push(cont.trim().to_owned());
            idx += 1;
        }
        insert_field(&mut meta, label, &field, options);
    }
    let body = lines.get(idx..).unwrap_or(&[]).join("\n");
    Some((meta, body))
}

/// Try to consume a leading `key: value` metadata block (`mmd_title_block`). The first line must be
/// a key line carrying a value; the block then runs to the first blank line (or the end of input).
/// Each key line starts a field; an indented line without a key continues the current field's value.
/// Any other non-blank line before the terminator abandons the block, so ordinary prose is left in
/// the body. Values are taken verbatim — words become `Str`, line breaks become soft breaks — with no
/// inline markup, and keys are lowercased with all whitespace removed.
fn mmd_title_block(normalized: &str) -> Option<(BTreeMap<String, MetaValue>, String)> {
    let lines: Vec<&str> = normalized.split('\n').collect();
    let (mut key, value) = lines.first().and_then(|line| meta_key_line(line))?;
    if value.trim().is_empty() {
        return None;
    }
    let mut meta = BTreeMap::new();
    let mut field = vec![value.trim().to_owned()];
    let mut idx = 1;
    let mut terminator = None;
    while let Some(&line) = lines.get(idx) {
        if line.trim().is_empty() {
            terminator = Some(idx);
            break;
        }
        if let Some((next_key, next_value)) = meta_key_line(line) {
            insert_meta_field(&mut meta, &key, &field);
            key = next_key;
            field = vec![next_value.trim().to_owned()];
        } else if starts_with_space(line) {
            field.push(line.trim().to_owned());
        } else {
            return None;
        }
        idx += 1;
    }
    insert_meta_field(&mut meta, &key, &field);
    let body = match terminator {
        Some(blank) => lines.get(blank + 1..).unwrap_or(&[]).join("\n"),
        None => String::new(),
    };
    Some((meta, body))
}

/// Parse one `key: value` metadata line, returning the normalized key and the raw value. The key is
/// the text before the first colon; it may hold only letters, digits, spaces, hyphens, and
/// underscores, and normalizes to its lowercase with whitespace removed. Any other content (or an
/// empty key, or no colon) means the line is not a key line.
fn meta_key_line(line: &str) -> Option<(String, &str)> {
    let colon = line.find(':')?;
    let raw = line.get(..colon)?;
    if !raw
        .chars()
        .all(|c| c.is_alphanumeric() || matches!(c, ' ' | '\t' | '-' | '_'))
    {
        return None;
    }
    let key: String = raw
        .chars()
        .filter(|c| !c.is_whitespace())
        .flat_map(char::to_lowercase)
        .collect();
    if key.is_empty() {
        return None;
    }
    Some((key, line.get(colon + 1..).unwrap_or("")))
}

/// Store one `mmd_title_block` field as verbatim inlines: each line's words become `Str` separated by
/// `Space`, and successive lines are joined with a soft break.
fn insert_meta_field(meta: &mut BTreeMap<String, MetaValue>, key: &str, field: &[String]) {
    let mut inlines = Vec::new();
    for line in field {
        if !inlines.is_empty() {
            inlines.push(Inline::SoftBreak);
        }
        for (word_index, word) in line.split_whitespace().enumerate() {
            if word_index > 0 {
                inlines.push(Inline::Space);
            }
            inlines.push(Inline::Str(word.to_owned()));
        }
    }
    meta.insert(key.to_owned(), MetaValue::MetaInlines(inlines));
}

/// Add one title-block field to the metadata. Title and date are inline markdown (continuation lines
/// join as soft breaks) and are omitted when empty; the author field is always a list, split on `;`
/// and on continuation lines.
fn insert_field(
    meta: &mut BTreeMap<String, MetaValue>,
    label: &str,
    field: &[String],
    options: &ReaderOptions,
) {
    if label == "author" {
        let mut authors = Vec::new();
        for line in field {
            for chunk in line.split(';') {
                authors.push(MetaValue::MetaInlines(parse_meta_inlines(
                    chunk.trim(),
                    options.extensions,
                    options.greedy_paragraphs,
                )));
            }
        }
        meta.insert("author".to_owned(), MetaValue::MetaList(authors));
        return;
    }
    let text = field.join("\n");
    if !text.trim().is_empty() {
        meta.insert(
            label.to_owned(),
            MetaValue::MetaInlines(parse_meta_inlines(
                &text,
                options.extensions,
                options.greedy_paragraphs,
            )),
        );
    }
}

/// Strip a field's leading `%` and the single optional space that follows it.
fn strip_field_marker(line: &str) -> &str {
    let rest = line.strip_prefix('%').unwrap_or(line);
    rest.strip_prefix(' ').unwrap_or(rest)
}

fn starts_with_space(line: &str) -> bool {
    line.starts_with([' ', '\t'])
}

#[cfg(test)]
mod tests {
    use crate::commonmark::CommonmarkReader;
    use carta_ast::{Block, MetaValue};
    use carta_core::{Extension, Extensions, Reader, ReaderOptions};

    fn read(input: &str) -> carta_ast::Document {
        let mut options = ReaderOptions::default();
        let mut extensions = Extensions::empty();
        extensions.insert(Extension::YamlMetadataBlock);
        options.extensions = extensions;
        CommonmarkReader
            .read(input, &options)
            .expect("reader should not fail")
    }

    #[test]
    fn fence_lines_tolerate_trailing_whitespace() {
        // Trailing spaces or tabs on either fence still delimit the block, and the body that
        // follows is not part of the metadata.
        let document = read("---   \ntitle: T\n---\t\n\nBody\n");
        assert_eq!(
            document.meta.get("title"),
            Some(&MetaValue::MetaInlines(vec![carta_ast::Inline::Str(
                "T".to_owned()
            )]))
        );
        assert!(matches!(document.blocks.as_slice(), [Block::Para(_)]));
    }

    #[test]
    fn closing_ellipsis_fence_tolerates_trailing_whitespace() {
        let document = read("---\ntitle: T\n...  \n\nBody\n");
        assert!(document.meta.contains_key("title"));
    }

    #[test]
    fn an_indented_opening_fence_is_not_front_matter() {
        // A fence must start at the line's first column; leading whitespace disqualifies it.
        let document = read("   ---\ntitle: T\n---\n\nBody\n");
        assert!(document.meta.is_empty());
    }

    fn read_mmd(input: &str) -> carta_ast::Document {
        let mut options = ReaderOptions::default();
        let mut extensions = Extensions::empty();
        extensions.insert(Extension::MmdTitleBlock);
        options.extensions = extensions;
        CommonmarkReader
            .read(input, &options)
            .expect("reader should not fail")
    }

    fn inlines(words: &[&str]) -> MetaValue {
        use carta_ast::Inline;
        let mut out = Vec::new();
        for (index, word) in words.iter().enumerate() {
            if index > 0 {
                out.push(Inline::Space);
            }
            out.push(Inline::Str((*word).to_owned()));
        }
        MetaValue::MetaInlines(out)
    }

    #[test]
    fn mmd_title_block_populates_meta_and_strips_the_body() {
        // Keys normalize to lowercase with whitespace removed; values are taken verbatim.
        let document = read_mmd("Title: My Doc\nBase Header Level: 2\n\nBody.\n");
        assert_eq!(document.meta.get("title"), Some(&inlines(&["My", "Doc"])));
        assert_eq!(document.meta.get("baseheaderlevel"), Some(&inlines(&["2"])));
        assert!(matches!(document.blocks.as_slice(), [Block::Para(_)]));
    }

    #[test]
    fn mmd_title_block_values_are_not_markdown() {
        // Inline markup in a value stays literal text.
        let document = read_mmd("Title: *Emph* `code`\n\nBody.\n");
        assert_eq!(
            document.meta.get("title"),
            Some(&inlines(&["*Emph*", "`code`"]))
        );
    }

    #[test]
    fn mmd_title_block_continuation_lines_join_with_soft_breaks() {
        use carta_ast::Inline;
        let document = read_mmd("Author: Jane\n    John\n\nBody.\n");
        assert_eq!(
            document.meta.get("author"),
            Some(&MetaValue::MetaInlines(vec![
                Inline::Str("Jane".to_owned()),
                Inline::SoftBreak,
                Inline::Str("John".to_owned()),
            ]))
        );
    }

    #[test]
    fn mmd_title_block_requires_a_first_value_and_well_formed_lines() {
        // A first line with no value is not a title block.
        assert!(read_mmd("Foo:\nTitle: X\n\nBody.\n").meta.is_empty());
        // A non-key, non-continuation line before the terminator abandons the block.
        assert!(read_mmd("Title: X\nNot a key\n\nBody.\n").meta.is_empty());
        // A key with an unsupported character is not a key line.
        assert!(read_mmd("a/b: v\n\nBody.\n").meta.is_empty());
    }
}