compmd 0.4.0

The CompMD processor: parses, validates and expands CompMD (Component Markdown) documents. In development.
Documentation
//! Stage 1, parsing (spec §11): text in, the document tree (spec §12) and the parse diagnostics out.
//!
//! The fork of markdown-rs parses CommonMark, GFM and CompMD's syntax into mdast, with each component line, inline
//! component, interpolation and wiki link as a raw node. [`Converter`] turns that into the tree: it builds
//! containers, slots and leaves from the component lines, reads params, fallbacks, definitions and signatures, and
//! counts positions in code points.

mod blocks;
pub(crate) mod definitions;
mod edit;
pub(crate) mod frontmatter;
mod inlines;
pub(crate) mod line;
pub(crate) mod params;
mod source;
pub(crate) mod yaml;

pub(crate) use definitions::CODE;
pub use edit::TextEdit;
pub(crate) use edit::{Change, Spliced, reparse};

use crate::diagnostic::{Code, Diagnostic, Location};
use crate::settings::Settings;
use crate::tree::{Point, Root};
use blocks::Marks;
use edit::Unit;
use markdown::mdast;
use serde_json::Value;
use source::{START, Source};
use std::rc::Rc;

/// What a parse needs besides the text.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParseOptions {
    /// Metadata a host keeps apart from the text, equivalent to frontmatter (spec §2.1). A document with its own
    /// frontmatter uses only that, and W115 reports the metadata as ignored.
    pub metadata: Option<Value>,
}

/// The result of parsing a document.
#[derive(Clone, Debug)]
pub(crate) struct Parsed {
    /// The document tree, which resolving shares rather than copies.
    pub(crate) root: Rc<Root>,
    /// The settings its frontmatter gives: none without one, or when CompMD processing doesn't apply to it.
    pub(crate) settings: Settings,
    /// The parse diagnostics, in the order they were found.
    pub(crate) diagnostics: Vec<Diagnostic>,
    /// The units of the tree's blocks, which an edit parses again on their own.
    units: Vec<Unit>,
    syntax: Syntax,
}

impl Parsed {
    /// The tree, shared, and the settings, for resolving: the caller keeps the diagnostics and the units.
    pub(crate) fn for_resolving(&self) -> Self {
        Self {
            root: Rc::clone(&self.root),
            settings: self.settings.clone(),
            diagnostics: Vec::new(),
            units: Vec::new(),
            syntax: self.syntax,
        }
    }
}

/// Parses a CompMD document: stage 1 of spec §11.
///
/// Parsing never fails: a mistake is a diagnostic, and the recovery rules of spec §10.3 decide the tree. A document
/// whose frontmatter makes CompMD processing not apply (F001, F033) is parsed as plain CommonMark and GFM.
pub(crate) fn parse(text: &str, options: &ParseOptions) -> Parsed {
    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
    let mut diagnostics = Vec::new();
    let mut metadata = options.metadata.clone();
    let mut parsed = None;
    let mut settings = Settings::default();
    let mut compmd = true;
    if let Some(yaml) = frontmatter::find(text) {
        if options.metadata.is_some() {
            diagnostics.push(Diagnostic::new(
                Code::W115,
                Location::new(1, 1),
                "the document has its own frontmatter, so the host's metadata is ignored",
            ));
        }
        let document = frontmatter::read(yaml, &mut diagnostics);
        compmd = document.is_some();
        parsed = document.as_ref().map(frontmatter::tree_value);
        metadata.clone_from(&parsed);
        if let Some(document) = document {
            settings = Settings::read(document, yaml);
        }
    } else if let Some(given) = &options.metadata {
        // A document without frontmatter takes the host's metadata as its frontmatter (spec §2.1).
        let document = frontmatter::metadata(given, &mut diagnostics);
        compmd = document.is_some();
        if let Some(document) = document {
            settings = Settings::read(document, "");
        }
    }
    let syntax = Syntax {
        compmd,
        wiki_links: metadata.as_ref().is_some_and(frontmatter::wiki_links),
    };
    let (root, units) = Converter {
        source: Source::new(text, START),
        syntax,
        labels: &Labels::NONE,
        frontmatter: parsed,
        diagnostics: &mut diagnostics,
    }
    .units(true);
    Parsed {
        root: Rc::new(root),
        settings,
        diagnostics,
        units,
        syntax,
    }
}

/// The syntax a document is parsed with.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Syntax {
    /// CompMD's syntax; without it, plain CommonMark and GFM (F001, F033).
    compmd: bool,
    /// Wiki links (spec §2.3), opted in by `md.syntax`.
    wiki_links: bool,
}

/// The labels a text defines or sees: those of its link reference definitions and footnote definitions, as the
/// fork's mdast `identifier`s.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Labels {
    pub(crate) links: Vec<String>,
    pub(crate) footnotes: Vec<String>,
}

impl Labels {
    const NONE: Self = Self {
        links: Vec::new(),
        footnotes: Vec::new(),
    };

    /// These labels and those `root` defines, at any depth.
    fn with(&self, root: &mdast::Root) -> Self {
        let mut labels = self.clone();
        labels.collect(&root.children);
        labels
    }

    fn collect(&mut self, nodes: &[mdast::Node]) {
        for node in nodes {
            match node {
                mdast::Node::Definition(definition) => {
                    self.links.push(definition.identifier.clone());
                }
                mdast::Node::FootnoteDefinition(definition) => {
                    self.footnotes.push(definition.identifier.clone());
                }
                _ => {}
            }
            if let Some(children) = node.children() {
                self.collect(children);
            }
        }
    }
}

impl Syntax {
    /// The fork's mdast for `text`, with `labels` defined outside it. Without MDX the fork has no syntax errors, so it
    /// always gives a root.
    fn mdast(self, text: &str, frontmatter: bool, labels: &Labels) -> mdast::Root {
        let options = markdown::ParseOptions {
            definitions: labels.links.clone(),
            gfm_footnote_definitions: labels.footnotes.clone(),
            ..self.fork_options(frontmatter)
        };
        match markdown::to_mdast(text, &options) {
            Ok(mdast::Node::Root(root)) => root,
            _ => mdast::Root {
                children: Vec::new(),
                position: None,
            },
        }
    }

    fn fork_options(self, frontmatter: bool) -> markdown::ParseOptions {
        let constructs = if self.compmd {
            markdown::Constructs {
                compmd_wiki_link: self.wiki_links,
                frontmatter,
                ..markdown::Constructs::compmd()
            }
        } else {
            markdown::Constructs {
                frontmatter,
                frontmatter_toml: false,
                ..markdown::Constructs::gfm()
            }
        };
        markdown::ParseOptions {
            constructs,
            ..markdown::ParseOptions::gfm()
        }
    }
}

/// Turns the fork's mdast for one text into the tree.
struct Converter<'s, 'd> {
    source: Source<'s>,
    syntax: Syntax,
    /// The labels the text sees: its document's, for a fallback, or those of its own lines.
    labels: &'d Labels,
    /// The parsed frontmatter, for the `yaml` node.
    frontmatter: Option<Value>,
    diagnostics: &'d mut Vec<Diagnostic>,
}

impl Converter<'_, '_> {
    /// The tree of the whole text, with frontmatter when it may have some.
    fn document(self, frontmatter: bool) -> Root {
        self.convert(frontmatter, None).0
    }

    /// [`Self::document`], and the units of its blocks.
    fn units(self, frontmatter: bool) -> (Root, Vec<Unit>) {
        let mut marks = Marks::default();
        let (root, diagnostics) = self.convert(frontmatter, Some(&mut marks));
        let units = Unit::from_marks(&marks, &root.children, diagnostics);
        (root, units)
    }

    /// The tree, and how many diagnostics there are after it.
    fn convert(self, frontmatter: bool, marks: Option<&mut Marks>) -> (Root, usize) {
        let root = self
            .syntax
            .mdast(self.source.text(), frontmatter, self.labels);
        let labels = self.labels.with(&root);
        let mut converter = Converter {
            labels: &labels,
            ..self
        };
        let position = converter.source.position(root.position.as_ref());
        let end = position.map_or(START, |position| position.end);
        let children = converter.blocks(root.children, end, &[], marks).0;
        (Root { children, position }, converter.diagnostics.len())
    }

    /// A converter for `text`, parsed on its own and placed at `origin`, with the same syntax and the labels it sees:
    /// for a fallback, those of its document; for a definition's body, none, as it resolves its own (spec §6.3).
    fn placed<'t, 'c>(
        &'c mut self,
        text: &'t str,
        origin: Point,
        labels: &'c Labels,
    ) -> Converter<'t, 'c> {
        Converter {
            source: Source::new(text, origin),
            syntax: self.syntax,
            labels,
            frontmatter: None,
            diagnostics: self.diagnostics,
        }
    }

    fn diagnose(&mut self, code: Code, at: impl Into<Location>, message: impl Into<String>) {
        self.diagnostics
            .push(Diagnostic::new(code, at.into(), message));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tree::Node;

    #[test]
    fn a_byte_order_mark_is_ignored() {
        let parsed = parse("\u{feff}---\nmd: {}\n---\n", &ParseOptions::default());
        assert!(matches!(parsed.root.children[..], [Node::Yaml(_)]));
    }

    #[test]
    fn host_metadata_opts_in_to_syntax_when_there_is_no_frontmatter() {
        let options = ParseOptions {
            metadata: Some(serde_json::json!({ "md": { "syntax": ["wiki-links"] } })),
        };
        let tree = serde_json::to_value(&*parse("[[Home]]", &options).root).unwrap();
        assert_eq!(tree["children"][0]["children"][0]["type"], "wikiLink");
    }

    #[test]
    fn host_metadata_is_the_frontmatter_of_a_document_without_one_and_is_reported_at_line_1() {
        let options = ParseOptions {
            metadata: Some(serde_json::json!({
                "title": "Notes",
                "md": { "import": [{ "from": "@acme/kit@1", "names": "*" }], "imports": [] }
            })),
        };
        let parsed = parse("Text.\n", &options);
        assert_eq!(parsed.settings.doc["title"], "Notes");
        assert_eq!(parsed.settings.imports[0].from.value, "@acme/kit@1");
        assert_eq!(parsed.settings.imports[0].from.at, Location::new(1, 1));
        let [unknown] = &parsed.diagnostics[..] else {
            panic!("one diagnostic");
        };
        assert_eq!(
            (unknown.code, unknown.range.start),
            (Code::W111, Location::new(1, 1))
        );
    }

    #[test]
    fn host_metadata_of_another_major_version_makes_the_document_plain() {
        let options = ParseOptions {
            metadata: Some(serde_json::json!({ "md": { "version": 2 } })),
        };
        let parsed = parse("::: box\nText.\n::: /box\n", &options);
        assert_eq!(parsed.diagnostics[0].code, Code::F001);
        assert!(!matches!(
            parsed.root.children[0],
            Node::ComponentContainer(_)
        ));
    }
}