acdc-parser 0.11.0

`AsciiDoc` parser
Documentation
use std::sync::{Arc, Mutex, PoisonError};

use super::*;
use crate::{IncludeLoader, SafeMode};

type TestResult = Result<(), Box<dyn std::error::Error>>;

struct Sources {
    files: Vec<(&'static str, &'static str)>,
    opened: Mutex<Vec<String>>,
}

impl Sources {
    fn new(files: &[(&'static str, &'static str)]) -> Arc<Self> {
        Arc::new(Self {
            files: files.to_vec(),
            opened: Mutex::default(),
        })
    }

    fn options(self: &Arc<Self>) -> Result<Options<'static>, Error> {
        let provider: Arc<Self> = Arc::clone(self);
        Options::builder()
            .with_safe_mode(SafeMode::Safe)
            .with_base_dir(std::env::temp_dir().join("acdc-include-budget"))
            .with_include_loader(IncludeLoader::Custom(provider))
            .with_attribute("allow-uri-read", true)
            .build()
    }

    fn opened(&self) -> Vec<String> {
        self.opened
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .clone()
    }
}

fn process(input: &str, options: &Options<'_>, budget: usize) -> Result<String, Error> {
    let options = options
        .clone()
        .into_builder()
        .with_max_total_include_bytes(budget)
        .build()?;
    let preprocessor = Preprocessor::new(&options, Rc::default());
    let source = SourceOrigin::entry_file(
        &options
            .base_dir
            .as_ref()
            .ok_or_else(|| std::io::Error::other("missing test base directory"))?
            .join("entry.adoc"),
        options.base_dir.as_deref(),
    )?;
    preprocessor
        .process_inner(input, Some(&source), &mut options.clone())
        .map(|result| result.text.into_owned())
}

impl IncludeSourceProvider for Sources {
    fn open(&self, target: &IncludeSourceTarget) -> Result<IncludeSource, IncludeSourceError> {
        let name = match target {
            IncludeSourceTarget::File(path) => path.file_name().and_then(|name| name.to_str()),
            IncludeSourceTarget::Uri(uri) => uri.rsplit('/').next(),
        }
        .ok_or_else(|| {
            IncludeSourceError::new(IncludeSourceErrorKind::Fatal, "invalid test target")
        })?;
        self.opened
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .push(name.to_owned());
        self.files
            .iter()
            .find(|(file, _)| *file == name)
            .map(|(_, content)| IncludeSource::from_bytes(content.as_bytes().to_vec()))
            .ok_or_else(|| IncludeSourceError::new(IncludeSourceErrorKind::NotFound, "missing"))
    }
}

fn assert_budget_error(result: Result<String, Error>, file: &str, line: u32) -> TestResult {
    let Err(error) = result else {
        return Err("expected an include budget error".into());
    };
    assert!(
        matches!(error, Error::IncludeExpansionTooLarge(..)),
        "{error}"
    );
    let source = error.source_location().ok_or("missing error location")?;
    assert_eq!(
        source
            .file
            .as_ref()
            .and_then(|path| path.file_name())
            .ok_or("missing error file")?,
        file
    );
    assert_eq!(source.location.start.line, line);
    assert_eq!(source.location.start.column, 1);
    assert!(
        error
            .advice()
            .ok_or("missing error advice")?
            .contains("Select less include text")
    );
    Ok(())
}

#[test]
fn include_budget_is_shared_by_siblings_and_counts_utf8_bytes() -> TestResult {
    let sources = Sources::new(&[("a.log", "é"), ("b.log", "bbb")]);
    let options = sources.options()?;
    let input = "include::a.log[]\ninclude::b.log[]";
    assert_eq!(process(input, &options, 7)?, "é\nbbb");
    assert_budget_error(process(input, &options, 6), "entry.adoc", 2)?;
    Ok(())
}

#[test]
fn include_budget_counts_repeated_targets_and_stops_on_failure() -> TestResult {
    let sources = Sources::new(&[("a.log", "abc"), ("later.log", "unused")]);
    let options = sources.options()?;
    let error = process(
        "include::a.log[]\ninclude::a.log[]\ninclude::later.log[]",
        &options,
        7,
    );
    assert_budget_error(error, "entry.adoc", 2)?;
    assert_eq!(sources.opened(), ["a.log", "a.log"]);
    Ok(())
}

#[test]
fn include_budget_is_shared_by_nested_and_sibling_includes() -> TestResult {
    let nested = "include::leaf.log[]";
    let sources = Sources::new(&[("chapter.adoc", nested), ("leaf.log", "abc")]);
    let options = sources.options()?;
    let input = "include::chapter.adoc[]\ninclude::leaf.log[]";
    let budget = nested.len() + 1 + 4 + 4;
    assert_eq!(process(input, &options, budget)?, "abc\nabc");
    assert_budget_error(process(input, &options, budget - 1), "entry.adoc", 2)?;
    Ok(())
}

#[test]
fn include_budget_error_retains_the_selected_source_line() -> TestResult {
    let sources = Sources::new(&[
        ("chapter.adoc", "skip\nskip\ninclude::leaf.log[]"),
        ("leaf.log", "abc"),
    ]);
    let options = sources.options()?;
    let error = process(
        "include::chapter.adoc[lines=3]",
        &options,
        "include::leaf.log[]".len() + 1,
    );
    assert_budget_error(error, "chapter.adoc", 3)?;
    Ok(())
}

#[test]
fn include_budget_checks_parent_text_before_opening_nested_targets() -> TestResult {
    let sources = Sources::new(&[("chapter.adoc", "include::leaf.log[]"), ("leaf.log", "abc")]);
    let options = sources.options()?;
    let error = process("include::chapter.adoc[]", &options, 1);
    assert_budget_error(error, "entry.adoc", 1)?;
    assert_eq!(sources.opened(), ["chapter.adoc"]);
    Ok(())
}

#[test]
fn include_budget_counts_selection_after_indentation() -> TestResult {
    let sources = Sources::new(&[("a.log", "ignored\nx\nignored")]);
    let options = sources.options()?;
    let input = "include::a.log[lines=2,indent=3]";
    assert_eq!(process(input, &options, 5)?, "   x");
    assert_budget_error(process(input, &options, 4), "entry.adoc", 1)?;
    Ok(())
}

#[test]
fn include_budget_is_shared_by_local_and_custom_uri_sources() -> TestResult {
    let sources = Sources::new(&[("a.log", "abc")]);
    let options = sources.options()?;
    let error = process(
        "include::a.log[]\ninclude::https://example.test/a.log[]",
        &options,
        7,
    );
    assert_budget_error(error, "entry.adoc", 2)?;
    Ok(())
}

#[test]
fn include_budget_does_not_count_entry_text_or_unread_includes() -> TestResult {
    let sources = Sources::new(&[("a.log", "abc"), ("empty.log", "")]);
    let options = sources.options()?;
    let input = "Entry text\nifdef::absent[]\ninclude::a.log[]\nendif::[]\ninclude::missing.log[opts=optional]\ninclude::empty.log[]";
    assert_eq!(process(input, &options, 0)?, "Entry text");
    assert_eq!(sources.opened(), ["missing.log", "empty.log"]);
    for options in [
        Options {
            safe_mode: SafeMode::Secure,
            ..options.clone()
        },
        Options {
            include_loader: IncludeLoader::Disabled,
            ..options.clone()
        },
    ] {
        process("include::a.log[]", &options, 0)?;
    }
    assert_eq!(sources.opened(), ["missing.log", "empty.log"]);
    Ok(())
}

#[test]
fn include_budget_counts_text_before_comments_and_conditionals_are_removed() -> TestResult {
    for content in ["// comment", "ifdef::absent[]\nignored\nendif::[]"] {
        let sources = Sources::new(&[("chapter.adoc", content)]);
        let options = sources.options()?;
        let input = "include::chapter.adoc[]";
        process(input, &options, content.len() + 1)?;
        assert_budget_error(process(input, &options, content.len()), "entry.adoc", 1)?;
    }
    Ok(())
}

#[test]
fn include_budget_resets_between_parses_with_the_same_options() -> TestResult {
    let sources = Sources::new(&[("a.log", "abc")]);
    let options = sources.options()?;
    assert!(process("include::a.log[]\ninclude::a.log[]", &options, 4).is_err());
    assert_eq!(
        Preprocessor::process("include::a.log[]", &options, Rc::default())?.text,
        "abc"
    );
    assert_eq!(
        Preprocessor::process_reader("include::a.log[]".as_bytes(), &options, Rc::default())?.text,
        "abc"
    );
    Ok(())
}