textlane 0.3.0

Rust-native text processing pipeline — a unified replacement with native and JavaScript plugins
Documentation
use textlane_core::{
    Allocator, Attach, Compiler, Error, File, HtmlWriter, Parser, Plugin, Step,
    Transform, Tree, TreeKind,
};

use crate::actions::allocator::reuse::with_allocator;
use crate::models::host::missing_host;
use crate::models::pipeline::Api;

/// A pipeline being composed: `.with()` adds a plugin, as unified's `.use()` does.
#[derive(Default)]
pub struct Processor {
    plugins: Vec<Box<dyn Plugin>>,
}

/// A processor whose plugins are attached: it runs any number of documents, in parallel.
pub struct FrozenProcessor {
    parser: Option<Box<dyn Parser>>,
    transforms: Vec<Box<dyn Transform>>,
    compiler: Option<Box<dyn Compiler>>,
    // The last stage and the compiler in one pass: that stage's index and its writer
    writer: Option<(usize, Box<dyn HtmlWriter>)>,
}

/// Where running the transforms stopped: at the end, or before stages a binding runs.
pub(crate) enum Advance<'a> {
    Done(Tree<'a>),
    /// The consecutive host stages' ids, and the index of the stage after them.
    Host {
        hosts: Vec<u32>,
        next: usize,
        tree: Tree<'a>,
    },
}

/// Where running the transforms and compiling stopped: at the text, or before stages a
/// binding runs.
pub(crate) enum Finish<'a> {
    Text(String),
    Host {
        hosts: Vec<u32>,
        next: usize,
        tree: Tree<'a>,
    },
}

/// What processing one document produced: the output and the kind of text it is.
#[derive(Debug)]
pub struct Output {
    pub value: String,
    pub extension: &'static str,
    pub file: File,
}

impl Processor {
    pub fn new() -> Self {
        Self::default()
    }

    /// Add a plugin; parsers and compilers apply wherever they are, transforms in order.
    pub fn with(mut self, plugin: impl Plugin + 'static) -> Self {
        self.plugins.push(Box::new(plugin));
        self
    }

    pub(crate) fn with_boxed(mut self, plugin: Box<dyn Plugin>) -> Self {
        self.plugins.push(plugin);
        self
    }

    /// Attach every plugin once and check that each stage takes the tree the one before
    /// returns.
    pub fn freeze(self) -> Result<FrozenProcessor, Error> {
        self.freeze_with(|_| {}, |_| {})
    }

    // `prepare` runs before the first plugin attaches and `collect` after the last, so a
    // caller can seed and read back the slots plugins share
    pub(crate) fn freeze_with(
        self,
        prepare: impl FnOnce(&mut Attach),
        collect: impl FnOnce(&mut Attach),
    ) -> Result<FrozenProcessor, Error> {
        let mut context = Attach::default();
        seed_slots(&mut context);
        prepare(&mut context);
        for plugin in &self.plugins {
            plugin.attach(&mut context)?;
        }
        collect(&mut context);
        let (parser, transforms, compiler) = context.finish()?;
        let mut kind = parser.as_ref().map(|parser| parser.output());
        for transform in transforms
            .iter()
            .filter(|transform| transform.host().is_none())
        {
            check_kind(kind, transform.input())?;
            kind = Some(transform.output());
        }
        if let Some(compiler) = &compiler {
            check_kind(kind, compiler.input())?;
        }
        let writer = find_writer(&transforms, compiler.as_deref());
        Ok(FrozenProcessor {
            parser,
            transforms,
            compiler,
            writer,
        })
    }
}

impl FrozenProcessor {
    /// Parse, run and stringify one document.
    pub fn process(&self, input: &str) -> Result<Output, Error> {
        self.process_file(input, File::new())
    }

    /// Parse, run and stringify one document into `file`, whose `path` says where it lives.
    pub fn process_file(&self, input: &str, mut file: File) -> Result<Output, Error> {
        let value = with_allocator(|alloc| {
            let tree = self.parse(input, &mut file, alloc)?;
            match self.finish(tree, &mut file, alloc, 0, Api::Async)? {
                Finish::Text(value) => Ok(value),
                Finish::Host { hosts, .. } => {
                    Err(missing_host(hosts.first().copied().unwrap_or_default()))
                }
            }
        })?;
        Ok(Output {
            value,
            extension: self.extension().unwrap_or_default(),
            file,
        })
    }

    pub fn parse<'a>(
        &self,
        input: &'a str,
        file: &mut File,
        alloc: &'a Allocator,
    ) -> Result<Tree<'a>, Error> {
        match &self.parser {
            Some(parser) => {
                let source = parser.source(input);
                file.value = Some(source.to_string());
                parser.parse(source, file, alloc)
            }
            None => Err(Error::new("missing-parser", "the processor has no parser")),
        }
    }

    /// Run every transform over a tree, in order, finishing the jobs asynchronous ones
    /// hand back.
    pub fn run<'a>(
        &self,
        tree: Tree<'a>,
        file: &mut File,
        alloc: &'a Allocator,
    ) -> Result<Tree<'a>, Error> {
        self.run_all(tree, file, alloc, Api::Async)
    }

    /// Run every transform over a tree, failing with `async-in-sync` when one hands back a
    /// job.
    pub fn run_sync<'a>(
        &self,
        tree: Tree<'a>,
        file: &mut File,
        alloc: &'a Allocator,
    ) -> Result<Tree<'a>, Error> {
        self.run_all(tree, file, alloc, Api::Sync)
    }

    /// Run the transforms from the one at `from` until the end or the next host stage.
    pub(crate) fn advance<'a>(
        &self,
        tree: Tree<'a>,
        file: &mut File,
        alloc: &'a Allocator,
        from: usize,
        api: Api,
    ) -> Result<Advance<'a>, Error> {
        self.advance_until(tree, file, alloc, from, self.transforms.len(), api)
    }

    /// Run the transforms from the one at `from` and compile, writing HTML straight from
    /// the tree before the last stage when that stage and the compiler allow it.
    pub(crate) fn finish<'a>(
        &self,
        tree: Tree<'a>,
        file: &mut File,
        alloc: &'a Allocator,
        from: usize,
        api: Api,
    ) -> Result<Finish<'a>, Error> {
        let (tree, from) = match &self.writer {
            Some((stage, writer)) if from <= *stage => {
                match self.advance_until(tree, file, alloc, from, *stage, api)? {
                    Advance::Done(tree) => match writer.write(&tree) {
                        Some(text) => return Ok(Finish::Text(text)),
                        None => (tree, *stage),
                    },
                    Advance::Host { hosts, next, tree } => {
                        return Ok(Finish::Host { hosts, next, tree });
                    }
                }
            }
            _ => (tree, from),
        };
        match self.advance(tree, file, alloc, from, api)? {
            Advance::Done(tree) => self.stringify(&tree, file).map(Finish::Text),
            Advance::Host { hosts, next, tree } => Ok(Finish::Host { hosts, next, tree }),
        }
    }

    fn advance_until<'a>(
        &self,
        mut tree: Tree<'a>,
        file: &mut File,
        alloc: &'a Allocator,
        from: usize,
        until: usize,
        api: Api,
    ) -> Result<Advance<'a>, Error> {
        let mut index = from;
        while index < until
            && let Some(transform) = self.transforms.get(index)
        {
            if transform.host().is_some() {
                let hosts: Vec<u32> = self.transforms[index..]
                    .iter()
                    .map_while(|transform| transform.host())
                    .collect();
                let next = index + hosts.len();
                return Ok(Advance::Host { hosts, next, tree });
            }
            tree = match (transform.start(tree, file, alloc)?, api) {
                (Step::Done(tree), _) => tree,
                (Step::Job(job), Api::Async) => job(file)?,
                (Step::Job(_), Api::Sync) => {
                    return Err(Error::new("async-in-sync", "a stage finished async"));
                }
            };
            index += 1;
        }
        Ok(Advance::Done(tree))
    }

    fn run_all<'a>(
        &self,
        tree: Tree<'a>,
        file: &mut File,
        alloc: &'a Allocator,
        api: Api,
    ) -> Result<Tree<'a>, Error> {
        match self.advance(tree, file, alloc, 0, api)? {
            Advance::Done(tree) => Ok(tree),
            Advance::Host { hosts, .. } => {
                Err(missing_host(hosts.first().copied().unwrap_or_default()))
            }
        }
    }

    pub fn stringify(&self, tree: &Tree<'_>, file: &mut File) -> Result<String, Error> {
        match &self.compiler {
            Some(compiler) => compiler.compile(tree, file),
            None => Err(Error::new(
                "missing-compiler",
                "the processor has no compiler",
            )),
        }
    }

    /// The part of `input` the parser reads, which positions count from.
    pub fn source<'a>(&self, input: &'a str) -> &'a str {
        match &self.parser {
            Some(parser) => parser.source(input),
            None => input,
        }
    }

    pub fn has_parser(&self) -> bool {
        self.parser.is_some()
    }

    pub fn has_compiler(&self) -> bool {
        self.compiler.is_some()
    }

    /// The kind of text the compiler writes (`html`, `md`), when there is one.
    pub fn extension(&self) -> Option<&'static str> {
        self.compiler.as_ref().map(|compiler| compiler.extension())
    }

    /// The kind of tree the compiler takes, when there is one.
    pub fn compiler_kind(&self) -> Option<TreeKind> {
        self.compiler.as_ref().map(|compiler| compiler.input())
    }

    /// The kind of tree the first stage after parsing takes.
    pub fn input_kind(&self) -> Option<TreeKind> {
        self.transforms
            .iter()
            .find(|transform| transform.host().is_none())
            .map(|transform| transform.input())
            .or_else(|| self.compiler.as_ref().map(|compiler| compiler.input()))
    }
}

// Bridges into nlcst live in md and html, which cannot depend on nl, so the parser they
// share reaches them through a slot
fn seed_slots(context: &mut Attach) {
    #[cfg(feature = "nl")]
    {
        let parser = textlane_nl::LanguageParser::new(textlane_nl::Language::Latin);
        context
            .slots_mut()
            .get_or_default::<textlane_core::NaturalLanguageParserSlot>()
            .parser = Some(std::sync::Arc::new(parser));
    }
    #[cfg(feature = "mdx")]
    {
        context
            .slots_mut()
            .get_or_default::<textlane_core::ScriptParserSlot>()
            .parser = Some(std::sync::Arc::new(textlane_mdx::OxcScriptParser));
    }
    #[cfg(not(any(feature = "nl", feature = "mdx")))]
    let _ = context;
}

// A writer from the last stage for what the compiler writes, unless a binding runs that
// stage: its tree must reach the binding
fn find_writer(
    transforms: &[Box<dyn Transform>],
    compiler: Option<&dyn Compiler>,
) -> Option<(usize, Box<dyn HtmlWriter>)> {
    let target = compiler?.html_target()?;
    let last = transforms.last()?;
    if last.host().is_some() {
        return None;
    }
    let writer = last.html_writer(target)?;
    Some((transforms.len() - 1, writer))
}

fn check_kind(actual: Option<TreeKind>, expected: TreeKind) -> Result<(), Error> {
    match actual {
        Some(actual) if actual != expected => Err(Error::new(
            "invalid-stage",
            format!("a stage takes {expected:?} but gets {actual:?}"),
        )),
        _ => Ok(()),
    }
}

#[cfg(all(test, feature = "md", feature = "html"))]
#[path = "processor_unit.rs"]
mod unit;