use std::{
borrow::Cow,
cell::{Cell, RefCell},
collections::HashSet,
ops::Range,
path::{Component, Path, PathBuf},
rc::Rc,
};
use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE};
use crate::{
DocumentAttributeValue, DocumentAttributes, IncludeSource, IncludeSourceError,
IncludeSourceErrorKind, IncludeSourceProvider, IncludeSourceTarget, Location, Options, Warning,
WarningKind,
error::{Error, SourceLocation},
model::{LeveloffsetRange, Position, SourceRange},
};
mod attribute;
mod comment;
mod conditional;
mod include;
#[cfg(test)]
mod include_budget_tests;
mod tag;
use comment::CommentScanner;
use include::{Include, IncludeResult, LocationContext};
#[derive(Clone, Copy, Debug, Default)]
struct DeniedIncludeSourceProvider;
impl IncludeSourceProvider for DeniedIncludeSourceProvider {
fn open(&self, _target: &IncludeSourceTarget) -> Result<IncludeSource, IncludeSourceError> {
Err(IncludeSourceError::new(
IncludeSourceErrorKind::Unsupported,
"no include source provider was supplied",
))
}
}
#[derive(Debug)]
pub(crate) struct PreprocessorResult<'input> {
pub(crate) text: Cow<'input, str>,
pub(crate) leveloffset_ranges: Vec<LeveloffsetRange>,
pub(crate) source_ranges: Vec<SourceRange>,
pub(crate) included_files: HashSet<String>,
pub(crate) front_matter: Option<String>,
comment_context: comment::BlockContext,
}
impl PreprocessorResult<'_> {
fn into_owned(self) -> PreprocessorResult<'static> {
PreprocessorResult {
text: Cow::Owned(self.text.into_owned()),
leveloffset_ranges: self.leveloffset_ranges,
source_ranges: self.source_ranges,
included_files: self.included_files,
front_matter: self.front_matter,
comment_context: self.comment_context,
}
}
}
struct NestedPreprocessorResult {
result: PreprocessorResult<'static>,
document_attributes: DocumentAttributes<'static>,
}
#[derive(Debug)]
struct SkimmedFrontMatter {
value: String,
lines_consumed: usize,
}
fn skim_front_matter<'a>(mut lines: impl Iterator<Item = &'a str>) -> Option<SkimmedFrontMatter> {
if lines.next()? != "---" {
return None;
}
let mut value = String::new();
for (index, line) in lines.enumerate() {
if line == "---" {
return Some(SkimmedFrontMatter {
value,
lines_consumed: index + 2,
});
}
if index > 0 {
value.push('\n');
}
value.push_str(line);
}
None
}
fn consume_front_matter<'a>(
lines: &mut impl Iterator<Item = &'a str>,
line_number: &mut usize,
capture: bool,
front_matter: Option<SkimmedFrontMatter>,
) -> Option<String> {
let front_matter = front_matter?;
for _ in 0..front_matter.lines_consumed {
lines.next();
}
*line_number += front_matter.lines_consumed;
if !capture {
return None;
}
Some(front_matter.value)
}
#[derive(Debug)]
struct DirectiveContext<'a> {
input_line: usize,
source_line: usize,
current_offset: usize,
source_origin: Option<&'a SourceOrigin>,
}
impl DirectiveContext<'_> {
fn current_file(&self) -> Option<&Path> {
self.source_origin.and_then(SourceOrigin::as_path)
}
}
const DIRECTIVE_PREFIXES: [&str; 4] = ["include::", "ifdef::", "ifndef::", "ifeval::"];
fn has_attribute_continuation(line: &str) -> bool {
line.strip_prefix(':')
.and_then(|entry| entry.split_once(':'))
.is_some_and(|(_, value)| value.trim_start_matches([' ', '\t']).ends_with(" \\"))
}
fn has_include_directive_shape(line: &str) -> bool {
let Some(target_and_attributes) = line
.strip_prefix("include::")
.and_then(|body| body.strip_suffix(']'))
else {
return false;
};
let Some((target, _attributes)) = target_and_attributes.split_once('[') else {
return false;
};
let Some((first, last)) = target.as_bytes().first().zip(target.as_bytes().last()) else {
return false;
};
!first.is_ascii_whitespace() && !last.is_ascii_whitespace()
}
fn is_escaped_directive(line: &str) -> bool {
line.strip_prefix('\\').is_some_and(|rest| {
if rest.starts_with("include::") {
has_include_directive_shape(rest)
} else {
DIRECTIVE_PREFIXES[1..]
.iter()
.any(|prefix| rest.starts_with(prefix))
}
})
}
#[derive(Debug, Clone)]
pub(super) struct IncludeContext {
allows_uri_read: bool,
depth: usize,
max_depth: usize,
remaining_include_bytes: Rc<Cell<usize>>,
block_context: comment::BlockContext,
}
impl IncludeContext {
fn root(options: &Options<'_>) -> Self {
let max_depth = options
.document_attributes
.get(crate::document_attribute::MAX_INCLUDE_DEPTH_ATTR)
.and_then(DocumentAttributeValue::as_integer)
.unwrap_or(crate::document_attribute::DEFAULT_MAX_INCLUDE_DEPTH);
let max_depth = usize::try_from(max_depth).unwrap_or(usize::MAX);
Self {
allows_uri_read: options.document_attributes.contains_key("allow-uri-read"),
depth: 0,
max_depth,
remaining_include_bytes: Rc::new(Cell::new(options.max_total_include_bytes())),
block_context: comment::BlockContext::default(),
}
}
fn nested(self) -> Self {
Self {
depth: self.depth.saturating_add(1),
..self
}
}
fn consume_include_bytes(&self, content: &str) -> bool {
let bytes = content
.len()
.saturating_add(usize::from(!content.is_empty()));
let Some(remaining) = self.remaining_include_bytes.get().checked_sub(bytes) else {
return false;
};
self.remaining_include_bytes.set(remaining);
true
}
fn blocked_limit(&self, safe_mode: crate::SafeMode) -> Option<usize> {
(safe_mode != crate::SafeMode::Secure && self.depth >= self.max_depth)
.then_some(self.max_depth)
}
}
#[derive(Debug, Clone)]
pub(super) enum SourceOrigin {
File {
path: PathBuf,
base_dir: PathBuf,
is_entry: bool,
},
Memory {
base_dir: Option<PathBuf>,
},
Uri(String),
}
impl SourceOrigin {
fn include_name(&self) -> Option<String> {
let name = match self {
Self::File { path, base_dir, .. } => {
let shared = path
.components()
.zip(base_dir.components())
.take_while(|(left, right)| left == right)
.count();
let mut relative = PathBuf::new();
for component in base_dir.components().skip(shared) {
if !matches!(component, Component::Normal(_)) {
return path.with_extension("").to_str().map(str::to_owned);
}
relative.push("..");
}
relative.extend(path.components().skip(shared));
relative.to_string_lossy().replace('\\', "/")
}
Self::Uri(uri) => uri.clone(),
Self::Memory { .. } => return None,
};
name.rsplit_once('.').map(|(stem, _)| stem.to_string())
}
fn entry_file(path: &Path, base_override: Option<&Path>) -> Result<Self, Error> {
let absolute_path = absolute_normalized(path)?;
let default_base = absolute_path.parent().unwrap_or(&absolute_path);
let base_dir = absolute_normalized(base_override.unwrap_or(default_base))?;
Ok(Self::File {
path: path.to_path_buf(),
base_dir,
is_entry: true,
})
}
fn memory(base_override: Option<&Path>) -> Self {
Self::Memory {
base_dir: base_override.map(Path::to_path_buf),
}
}
pub(super) fn as_path(&self) -> Option<&Path> {
match self {
Self::File { path, .. } => Some(path),
Self::Memory { .. } => None,
Self::Uri(uri) => Some(Path::new(uri)),
}
}
}
#[derive(Debug, Clone, Copy)]
pub(super) struct SourceLineOrigin {
pub(super) line: usize,
pub(super) offset: usize,
pub(super) column_shift: isize,
}
impl SourceLineOrigin {
fn one_to_one(line: usize, offset: usize) -> Self {
Self {
line,
offset,
column_shift: 0,
}
}
}
pub(super) fn absolute_normalized(path: &Path) -> Result<PathBuf, Error> {
let absolute = std::path::absolute(path)?;
let mut normalized = PathBuf::new();
for component in absolute.components() {
match component {
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
Component::RootDir => normalized.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
if normalized
.components()
.next_back()
.is_some_and(|last| matches!(last, Component::Normal(_)))
{
normalized.pop();
}
}
Component::Normal(segment) => normalized.push(segment),
}
}
Ok(normalized)
}
#[derive(Debug)]
struct ConditionalFrame<'input> {
conditional: conditional::Conditional<'input>,
active: bool,
opening_line: usize,
}
struct PreprocessorState<'input> {
input: &'input str,
output: Vec<Cow<'input, str>>,
borrowed_run: Option<Range<usize>>,
byte_offset: usize,
leveloffset_ranges: Vec<LeveloffsetRange>,
source_ranges: Vec<SourceRange>,
included_files: HashSet<String>,
source_file: Option<PathBuf>,
src_line_starts: Vec<usize>,
line_origins: Vec<SourceLineOrigin>,
run: Option<MainFileRun>,
run_expected_src_line: usize,
}
#[derive(Debug, Clone, Copy)]
struct MainFileRun {
out_start: usize,
origin: SourceLineOrigin,
}
impl<'input> PreprocessorState<'input> {
fn new(
input: &'input str,
source_origin: Option<&SourceOrigin>,
line_origins: Vec<SourceLineOrigin>,
) -> Self {
let mut src_line_starts = vec![0];
src_line_starts.extend(
input
.bytes()
.enumerate()
.filter_map(|(idx, byte)| (byte == b'\n').then_some(idx + 1)),
);
Self {
input,
output: Vec::new(),
borrowed_run: None,
byte_offset: 0,
leveloffset_ranges: Vec::new(),
source_ranges: Vec::new(),
included_files: HashSet::new(),
source_file: source_origin.and_then(|origin| origin.as_path().map(Path::to_path_buf)),
src_line_starts,
line_origins,
run: None,
run_expected_src_line: 1,
}
}
fn flush_borrowed_run(&mut self) {
if let Some(range) = self.borrowed_run.take() {
self.output.push(Cow::Borrowed(&self.input[range]));
}
}
fn push_line(&mut self, line: Cow<'input, str>) {
self.flush_borrowed_run();
self.byte_offset += line.len() + 1;
self.output.push(line);
}
fn push_source_line(&mut self, line: &'input str, src_line: usize) {
self.note_source_line(src_line);
let start = self.src_offset_of_line(src_line);
let end = start + line.len();
debug_assert_eq!(self.input.get(start..end), Some(line));
if let Some(range) = &mut self.borrowed_run
&& range.end.checked_add(1) == Some(start)
{
range.end = end;
} else {
self.flush_borrowed_run();
self.borrowed_run = Some(start..end);
}
self.byte_offset += line.len() + 1;
}
fn src_offset_of_line(&self, line: usize) -> usize {
self.src_line_starts
.get(line.saturating_sub(1))
.copied()
.unwrap_or(0)
}
fn origin_of_line(&self, input_line: usize) -> SourceLineOrigin {
self.line_origins
.get(input_line.saturating_sub(1))
.copied()
.unwrap_or_else(|| {
SourceLineOrigin::one_to_one(input_line, self.src_offset_of_line(input_line))
})
}
fn note_source_line(&mut self, input_line: usize) {
let origin = self.origin_of_line(input_line);
if self.run.is_some()
&& (origin.line != self.run_expected_src_line
|| self
.run
.is_some_and(|run| run.origin.column_shift != origin.column_shift))
{
self.flush_run();
}
if self.run.is_none() {
self.run = Some(MainFileRun {
out_start: self.byte_offset,
origin,
});
}
self.run_expected_src_line = origin.line + 1;
}
fn push_source_range(
&mut self,
start_offset: usize,
end_offset: usize,
file: Option<PathBuf>,
file_chain: Vec<String>,
origin: SourceLineOrigin,
) {
self.source_ranges.push(SourceRange {
start_offset,
end_offset,
file,
file_chain,
start_line: origin.line,
source_start_offset: origin.offset,
column_shift: origin.column_shift,
});
}
fn flush_run(&mut self) {
if let Some(run) = self.run.take()
&& self.byte_offset > run.out_start
{
self.push_source_range(
run.out_start,
self.byte_offset,
self.source_file.clone(),
Vec::new(),
run.origin,
);
}
}
fn push_chunk(&mut self, content: String, input_line: usize) {
self.flush_run();
let start = self.byte_offset;
let origin = self.origin_of_line(input_line);
self.push_line(Cow::Owned(content));
self.push_source_range(
start,
self.byte_offset,
self.source_file.clone(),
Vec::new(),
origin,
);
}
}
const BOM_PATTERNS: &[(&[u8], &Encoding, usize, &str)] = &[
(&[0xEF, 0xBB, 0xBF], UTF_8, 3, "UTF-8"),
(&[0xFF, 0xFE], UTF_16LE, 2, "UTF-16 LE"),
(&[0xFE, 0xFF], UTF_16BE, 2, "UTF-16 BE"),
];
pub(crate) fn read_and_decode_file(
file_path: &Path,
encoding: Option<&str>,
) -> Result<String, Error> {
let bytes = std::fs::read(file_path)?;
decode_bytes(&bytes, encoding, &file_path.display().to_string())
}
pub(super) fn decode_bytes(
bytes: &[u8],
encoding: Option<&str>,
source: &str,
) -> Result<String, Error> {
if let Some(enc_label) = encoding {
if let Some(encoding) = Encoding::for_label(enc_label.as_bytes()) {
if encoding == UTF_16LE
&& enc_label.eq_ignore_ascii_case("utf-16")
&& !bytes.starts_with(&[0xFF, 0xFE])
&& !bytes.starts_with(&[0xFE, 0xFF])
{
return Err(Error::UnrecognizedEncodingInFile(source.to_string()));
}
let (cow, _, had_errors) = encoding.decode(bytes);
if had_errors {
return Err(Error::UnrecognizedEncodingInFile(source.to_string()));
}
return Ok(cow.into_owned());
}
return decode_bytes(bytes, None, source);
}
for (bom, encoding, skip, _name) in BOM_PATTERNS {
if bytes.starts_with(bom)
&& let Some(content) = bytes.get(*skip..)
{
let (cow, _, had_errors) = encoding.decode(content);
if had_errors {
return Err(Error::UnrecognizedEncodingInFile(source.to_string()));
}
return Ok(cow.into_owned());
}
}
let (cow, _, had_errors) = UTF_8.decode(bytes);
if !had_errors {
return Ok(cow.into_owned());
}
Err(Error::UnrecognizedEncodingInFile(source.to_string()))
}
#[derive(Debug)]
pub(crate) struct Preprocessor {
warnings: Rc<RefCell<Vec<Warning>>>,
include_context: IncludeContext,
}
impl Preprocessor {
fn new(options: &Options<'_>, warnings: Rc<RefCell<Vec<Warning>>>) -> Self {
Self {
warnings,
include_context: IncludeContext::root(options),
}
}
pub(super) fn nested(warnings: &Rc<RefCell<Vec<Warning>>>, context: IncludeContext) -> Self {
Self {
warnings: Rc::clone(warnings),
include_context: context.nested(),
}
}
pub(crate) fn add_warning_at(
&self,
message: impl Into<Cow<'static, str>>,
location: SourceLocation,
) {
let warning = Warning::new(WarningKind::Other(message.into()), Some(location));
warning.emit_trace();
self.warnings.borrow_mut().push(warning);
}
fn recover_content_at(&self, message: impl Into<Cow<'static, str>>, location: SourceLocation) {
let warning = Warning::new(
WarningKind::ContentRecovery {
message: message.into(),
},
Some(location),
);
warning.emit_trace();
self.warnings.borrow_mut().push(warning);
}
}
impl Preprocessor {
fn create_source_location(line_number: usize, file_parent: Option<&Path>) -> SourceLocation {
SourceLocation {
file: file_parent.map(Path::to_path_buf),
location: Location::point(Position::from_line_col(line_number, 0)),
}
}
fn normalize(input: &str) -> Cow<'_, str> {
let trimmed = input
.strip_suffix("\r\n")
.or_else(|| input.strip_suffix('\n'))
.unwrap_or(input);
let needs_rebuild = trimmed.as_bytes().contains(&b'\r')
|| trimmed
.split('\n')
.any(|line| matches!(line.as_bytes().last(), Some(b' ' | b'\t')));
if !needs_rebuild {
return Cow::Borrowed(trimmed);
}
let mut result = String::with_capacity(input.len());
for (i, line) in input.lines().map(str::trim_end).enumerate() {
if i > 0 {
result.push('\n');
}
result.push_str(line);
}
Cow::Owned(result)
}
fn try_pass_through(
&self,
text: &str,
setext: bool,
attributes: &DocumentAttributes<'_>,
) -> Option<CommentScanner> {
let mut scanner = CommentScanner::new(setext, self.include_context.block_context.clone());
let mut style_options = (text.contains('{')
&& text
.lines()
.any(|line| line.starts_with('[') && line.contains('{')))
.then(|| Options {
document_attributes: attributes.clone(),
..Options::default()
});
for line in text.lines() {
if scanner.preserves_comment_line(line) {
scanner.record(line, attributes);
continue;
}
if is_escaped_directive(line) {
return None;
}
if has_attribute_continuation(line) {
return None;
}
if line.ends_with(']')
&& !line.starts_with('[')
&& line.contains("::")
&& (has_include_directive_shape(line)
|| DIRECTIVE_PREFIXES[1..]
.iter()
.any(|prefix| line.starts_with(prefix)))
{
return None;
}
if let Some(options) = style_options.as_mut()
&& line.starts_with(':')
{
attribute::parse_line(options, line).ok()?;
}
let attributes = style_options
.as_ref()
.map_or(attributes, |options| &options.document_attributes);
if scanner.at_table_delimiter(line) || scanner.at_verbatim_delimiter(line) {
scanner.record(line, attributes);
continue;
}
if scanner.drops(line) {
return None;
}
scanner.record(line, attributes);
}
Some(scanner)
}
#[tracing::instrument(skip_all, fields(safe_mode = ?options.safe_mode))]
pub(crate) fn process_reader<R: std::io::Read>(
mut reader: R,
options: &Options<'_>,
warnings: Rc<RefCell<Vec<Warning>>>,
) -> Result<PreprocessorResult<'static>, Error> {
let mut input = String::new();
reader.read_to_string(&mut input).inspect_err(|e| {
tracing::error!(error_kind = ?e.kind(), "failed to read from reader");
})?;
let source_origin = SourceOrigin::memory(options.base_dir.as_deref());
let preprocessor = Self::new(options, warnings);
let mut options = options.clone();
Ok(preprocessor
.process_inner(&input, Some(&source_origin), &mut options)?
.into_owned())
}
#[tracing::instrument(skip_all, fields(input_len = input.len(), safe_mode = ?options.safe_mode))]
pub(crate) fn process<'input>(
input: &'input str,
options: &Options<'_>,
warnings: Rc<RefCell<Vec<Warning>>>,
) -> Result<PreprocessorResult<'input>, Error> {
let source_origin = SourceOrigin::memory(options.base_dir.as_deref());
let preprocessor = Self::new(options, warnings);
let mut options = options.clone();
preprocessor.process_inner(input, Some(&source_origin), &mut options)
}
#[tracing::instrument(skip_all, fields(safe_mode = ?options.safe_mode))]
pub(crate) fn process_with_file<'input>(
input: &'input str,
file_path: &Path,
options: &Options<'_>,
warnings: Rc<RefCell<Vec<Warning>>>,
) -> Result<PreprocessorResult<'input>, Error> {
let source_origin = SourceOrigin::entry_file(file_path, options.base_dir.as_deref())?;
let preprocessor = Self::new(options, warnings);
let mut options = options.clone();
preprocessor.process_inner(input, Some(&source_origin), &mut options)
}
#[cfg(test)]
#[tracing::instrument(skip_all, fields(safe_mode = ?options.safe_mode))]
pub(crate) fn process_file<P: AsRef<Path>>(
file_path: P,
options: &Options<'_>,
warnings: Rc<RefCell<Vec<Warning>>>,
) -> Result<PreprocessorResult<'static>, Error> {
if file_path.as_ref().parent().is_some() {
let input = read_and_decode_file(file_path.as_ref(), None)?;
let source_origin =
SourceOrigin::entry_file(file_path.as_ref(), options.base_dir.as_deref())?;
let preprocessor = Self::new(options, warnings);
let mut options = options.clone();
Ok(preprocessor
.process_inner(&input, Some(&source_origin), &mut options)?
.into_owned())
} else {
Err(Error::InvalidIncludePath(
Box::new(Self::create_source_location(1, Some(file_path.as_ref()))),
file_path.as_ref().to_path_buf(),
))
}
}
#[tracing::instrument(skip_all, fields(line_number = line_number, current_offset = current_offset))]
fn process_include(
&self,
line: &str,
line_number: usize,
current_offset: usize,
source_origin: Option<&SourceOrigin>,
options: &Options,
include_context: IncludeContext,
) -> Result<Option<IncludeResult>, Error> {
let Some(source_origin) = source_origin else {
self.recover_content_at(
format!("source origin is missing; include directive cannot be processed: {line}"),
Self::create_source_location(line_number, None),
);
return Ok(None);
};
static DENIED: DeniedIncludeSourceProvider = DeniedIncludeSourceProvider;
let source_provider = match options.include_loader.provider() {
Some(source_provider) => source_provider,
None if options.safe_mode == crate::SafeMode::Secure => &DENIED,
None => return Ok(None),
};
let include = Include::parse(
source_origin,
line,
LocationContext::new(line_number, current_offset, source_origin.as_path()),
options,
include_context,
&self.warnings,
)?;
let attribute_list_as_written = line
.strip_suffix(']')
.and_then(|directive| directive.split_once('['))
.map_or("", |(_, attributes)| attributes);
Ok(Some(
include.process(attribute_list_as_written, source_provider)?,
))
}
#[tracing::instrument(skip_all, fields(line_number = line_number))]
fn process_continuation<'a, I: Iterator<Item = &'a str>>(
line: &str,
lines: &mut std::iter::Peekable<I>,
line_number: &mut usize,
) -> String {
let mut attribute_content = String::with_capacity(line.len() * 2);
if line.ends_with(" + \\") {
attribute_content.push_str(line);
attribute_content.push('\n');
} else if line.ends_with(" \\") {
attribute_content.push_str(line.trim_end_matches('\\'));
}
while let Some(next_line) = lines.peek() {
let next_line = next_line.trim();
if next_line.is_empty() {
break;
}
if next_line.ends_with(" + \\") {
attribute_content.push_str(next_line);
attribute_content.push('\n');
lines.next();
*line_number += 1;
} else if next_line.ends_with(" \\") {
attribute_content.push_str(next_line.trim_end_matches('\\'));
lines.next();
*line_number += 1;
} else {
attribute_content.push_str(next_line);
lines.next();
*line_number += 1;
break;
}
}
attribute_content
}
#[tracing::instrument(level = "trace", skip_all, fields(input_len = line.len()))]
pub(super) fn is_verbatim_delimiter(line: &str) -> Option<&str> {
let trimmed = line.trim();
if trimmed.starts_with("```") {
return Some("```");
}
let mut chars = trimmed.chars();
let first_char = chars.next()?;
if first_char != '-' && first_char != '.' && first_char != '+' {
return None;
}
let mut idx = 1;
for next_char in chars {
if next_char == first_char {
idx += 1;
} else {
break;
}
}
if idx >= 4 {
return trimmed.get(..idx);
}
None
}
fn handle_include_result(
include_result: IncludeResult,
state: &mut PreprocessorState<'_>,
input_line: usize,
) {
if include_result.synthetic {
state.push_chunk(include_result.content, input_line);
return;
}
Self::handle_regular_include_result(include_result, state);
}
fn handle_regular_include_result(
include_result: IncludeResult,
state: &mut PreprocessorState<'_>,
) {
state.included_files.extend(include_result.included_files);
state.flush_borrowed_run();
let start_offset = state.byte_offset;
let mut content = include_result.content;
let has_content = !content.is_empty();
if content.contains("\r\n") {
content = content.replace("\r\n", "\n");
}
if content.ends_with('\n') {
content.pop();
}
let content_len = if has_content { content.len() + 1 } else { 0 };
if let Some(leveloffset) = include_result.effective_leveloffset
&& leveloffset != 0
{
state.leveloffset_ranges.push(LeveloffsetRange::new(
start_offset,
start_offset + content_len,
leveloffset,
));
tracing::trace!(
leveloffset,
start_offset,
end_offset = start_offset + content_len,
"Recording leveloffset range for include"
);
}
for nested_range in include_result.leveloffset_ranges {
let adjusted_range = LeveloffsetRange::new(
nested_range.start_offset + start_offset,
nested_range.end_offset + start_offset,
nested_range.value,
);
tracing::trace!(
original_start = nested_range.start_offset,
original_end = nested_range.end_offset,
adjusted_start = adjusted_range.start_offset,
adjusted_end = adjusted_range.end_offset,
leveloffset = adjusted_range.value,
"Merging nested leveloffset range"
);
state.leveloffset_ranges.push(adjusted_range);
}
let target = include_result.target;
for source_range in include_result.source_ranges {
let mut file_chain = Vec::with_capacity(source_range.file_chain.len() + 1);
file_chain.push(target.clone());
file_chain.extend(source_range.file_chain);
state.push_source_range(
source_range.start_offset + start_offset,
source_range.end_offset + start_offset,
source_range.file,
file_chain,
SourceLineOrigin {
line: source_range.start_line,
offset: source_range.source_start_offset,
column_shift: source_range.column_shift,
},
);
}
state.byte_offset += content_len;
if has_content {
state.output.push(Cow::Owned(content));
}
}
fn process_conditional_line<'input>(
&self,
line: &'input str,
ctx: &DirectiveContext<'_>,
attributes: &DocumentAttributes,
stack: &mut Vec<ConditionalFrame<'input>>,
out: &mut PreprocessorState<'_>,
scanner: &mut CommentScanner,
) -> Result<bool, Error> {
if !line.ends_with(']') || line.starts_with('[') || !line.contains("::") {
return Ok(false);
}
if line.starts_with("ifdef::")
|| line.starts_with("ifndef::")
|| line.starts_with("ifeval::")
{
let mut content = String::new();
let conditional = conditional::parse_line(
line,
ctx.source_line,
ctx.current_offset,
ctx.current_file(),
)?;
let parent_active = stack.last().is_none_or(|frame| frame.active);
let is_inline = conditional.has_inline_content();
let active = parent_active
&& conditional.is_true(
attributes,
&mut content,
ctx.source_line,
ctx.current_offset,
ctx.current_file(),
)?;
if is_inline {
if active {
scanner.record_expansion(&content, attributes);
out.push_chunk(content, ctx.input_line);
}
} else {
stack.push(ConditionalFrame {
conditional,
active,
opening_line: ctx.source_line,
});
}
return Ok(true);
}
if line.starts_with("endif::")
&& let Some(frame) = stack.last()
{
let endif = conditional::parse_endif(
line,
ctx.source_line,
ctx.current_offset,
ctx.current_file(),
)?;
if !endif.closes(&frame.conditional) {
self.add_warning_at(
"attribute mismatch between if and endif directives",
Self::create_source_location(ctx.source_line, ctx.current_file()),
);
return Err(Error::InvalidConditionalDirective(Box::new(
Self::create_source_location(ctx.source_line, ctx.current_file()),
)));
}
stack.pop();
return Ok(true);
}
Ok(false)
}
fn process_directive_line<'input>(
&self,
line: &'input str,
ctx: &DirectiveContext<'_>,
options: &mut Options,
out: &mut PreprocessorState<'input>,
scanner: &mut CommentScanner,
) -> Result<(), Error> {
if is_escaped_directive(line) {
out.note_source_line(ctx.input_line);
out.push_line(Cow::Borrowed(&line[1..]));
scanner.record(&line[1..], &options.document_attributes);
} else if has_include_directive_shape(line) {
if options.include_loader.provider().is_none()
&& options.safe_mode != crate::SafeMode::Secure
{
self.recover_content_at(
"include not read because include loading is disabled",
Self::create_source_location(ctx.source_line, ctx.current_file()),
);
out.push_source_line(line, ctx.input_line);
scanner.record(line, &options.document_attributes);
return Ok(());
}
if let Some(limit) = self.include_context.blocked_limit(options.safe_mode) {
let message = if limit == 0 {
"include not read because maximum include depth is zero".to_owned()
} else {
format!("maximum include depth of {limit} exceeded")
};
self.recover_content_at(
message,
Self::create_source_location(ctx.source_line, ctx.current_file()),
);
out.push_source_line(line, ctx.input_line);
scanner.record(line, &options.document_attributes);
return Ok(());
}
out.flush_run();
if let Some(mut include_result) = self.process_include(
line,
ctx.source_line,
ctx.current_offset,
ctx.source_origin,
options,
IncludeContext {
block_context: scanner.block_context(),
..self.include_context.clone()
},
)? {
if let Some(context) = include_result.comment_context.take() {
scanner.record_preprocessed(&include_result.content, context);
} else {
scanner.record_expansion(&include_result.content, &options.document_attributes);
}
let document_attributes = include_result.document_attributes.take();
Self::handle_include_result(include_result, out, ctx.input_line);
if let Some(document_attributes) = document_attributes {
options.document_attributes = document_attributes;
}
}
} else {
out.push_source_line(line, ctx.input_line);
scanner.record(line, &options.document_attributes);
}
Ok(())
}
#[tracing::instrument(skip_all, fields(input_len = input.len(), safe_mode = ?options.safe_mode))]
fn process_inner<'input>(
&self,
input: &'input str,
source_origin: Option<&SourceOrigin>,
options: &mut Options<'_>,
) -> Result<PreprocessorResult<'input>, Error> {
let normalized = Preprocessor::normalize(input);
let setext = comment::setext_enabled(options);
let front_matter = options
.document_attributes
.contains_key("skip-front-matter")
.then(|| skim_front_matter(normalized.lines()))
.flatten();
if front_matter.is_none()
&& !self.nested_attributes_must_propagate(&normalized)
&& let Some(scanner) =
self.try_pass_through(&normalized, setext, &options.document_attributes)
{
return Ok(PreprocessorResult {
text: normalized,
leveloffset_ranges: Vec::new(),
source_ranges: Vec::new(),
included_files: HashSet::new(),
front_matter: None,
comment_context: scanner.block_context(),
});
}
self.process_slow_path(
&normalized,
source_origin,
options,
setext,
Vec::new(),
front_matter,
)
}
fn process_mapped(
&self,
mut input: String,
source_origin: &SourceOrigin,
options: &Options<'_>,
line_origins: Vec<SourceLineOrigin>,
) -> Result<NestedPreprocessorResult, Error> {
let mut options = options.clone();
let normalized = Preprocessor::normalize(&input);
let setext = comment::setext_enabled(&options);
let front_matter = options
.document_attributes
.contains_key("skip-front-matter")
.then(|| skim_front_matter(normalized.lines()))
.flatten();
if front_matter.is_none()
&& !self.nested_attributes_must_propagate(&normalized)
&& let Some(scanner) =
self.try_pass_through(&normalized, setext, &options.document_attributes)
{
let source_ranges = {
let normalized_ref = normalized.as_ref();
let mut state =
PreprocessorState::new(normalized_ref, Some(source_origin), line_origins);
for (idx, line) in normalized_ref.lines().enumerate() {
state.note_source_line(idx + 1);
state.byte_offset += line.len() + 1;
}
state.flush_run();
state.source_ranges
};
let result = PreprocessorResult {
text: Cow::Owned(match normalized {
Cow::Borrowed(text) => {
let len = text.len();
input.truncate(len);
input
}
Cow::Owned(text) => text,
}),
leveloffset_ranges: Vec::new(),
source_ranges,
included_files: HashSet::new(),
front_matter: None,
comment_context: scanner.block_context(),
};
return Ok(NestedPreprocessorResult {
result,
document_attributes: options.document_attributes.into_static(),
});
}
let result = self.process_slow_path(
&normalized,
Some(source_origin),
&mut options,
setext,
line_origins,
front_matter,
)?;
Ok(NestedPreprocessorResult {
result: result.into_owned(),
document_attributes: options.document_attributes.into_static(),
})
}
#[cold]
#[inline(never)]
fn process_slow_path<'input>(
&self,
normalized: &Cow<'input, str>,
source_origin: Option<&SourceOrigin>,
options: &mut Options<'_>,
setext: bool,
line_origins: Vec<SourceLineOrigin>,
front_matter: Option<SkimmedFrontMatter>,
) -> Result<PreprocessorResult<'input>, Error> {
let normalized_ref = normalized.as_ref();
let mut lines = normalized_ref.lines().peekable();
let mut line_number = 1;
let mut out = PreprocessorState::new(normalized_ref, source_origin, line_origins);
let mut scanner = CommentScanner::new(setext, self.include_context.block_context.clone());
let mut conditional_stack: Vec<ConditionalFrame<'_>> = Vec::new();
let captured_front_matter = consume_front_matter(
&mut lines,
&mut line_number,
self.include_context.depth == 0,
front_matter,
);
if let Some(value) = &captured_front_matter {
options
.document_attributes
.set_text("front-matter".into(), value.clone().into());
}
while let Some(line) = lines.next() {
if scanner.preserves_comment_line(line) {
if conditional_stack.last().is_none_or(|frame| frame.active) {
out.push_source_line(line, line_number);
scanner.record(line, &options.document_attributes);
}
line_number += 1;
continue;
}
let origin = out.origin_of_line(line_number);
let ctx = DirectiveContext {
input_line: line_number,
source_line: origin.line,
current_offset: origin.offset,
source_origin,
};
if self.process_conditional_line(
line,
&ctx,
&options.document_attributes,
&mut conditional_stack,
&mut out,
&mut scanner,
)? {
line_number += 1;
continue;
}
if conditional_stack.last().is_some_and(|frame| !frame.active) {
line_number += 1;
continue;
}
if has_attribute_continuation(line) {
let continuation_start_line = line_number;
let attribute_content =
Self::process_continuation(line, &mut lines, &mut line_number);
attribute::parse_line(options, attribute_content.as_str())?;
out.push_chunk(attribute_content, continuation_start_line);
scanner.record(line, &options.document_attributes);
line_number += 1;
continue;
} else if line.starts_with(':') {
attribute::parse_line(options, line.trim())?;
}
if scanner.at_table_delimiter(line) || scanner.at_verbatim_delimiter(line) {
out.push_source_line(line, line_number);
} else if line.starts_with("//") {
if scanner.drops(line) {
line_number += 1;
continue;
}
out.push_source_line(line, line_number);
} else if line.ends_with(']') && !line.starts_with('[') && line.contains("::") {
self.process_directive_line(line, &ctx, options, &mut out, &mut scanner)?;
line_number += 1;
continue;
} else {
out.push_source_line(line, line_number);
}
scanner.record(line, &options.document_attributes);
line_number += 1;
}
let file = source_origin.and_then(SourceOrigin::as_path);
for frame in conditional_stack {
self.recover_content_at(
"conditional directive has no matching endif",
Self::create_source_location(frame.opening_line, file),
);
}
out.flush_run();
out.flush_borrowed_run();
Ok(PreprocessorResult {
text: Cow::Owned(out.output.join("\n")),
leveloffset_ranges: out.leveloffset_ranges,
source_ranges: out.source_ranges,
included_files: out.included_files,
front_matter: captured_front_matter,
comment_context: scanner.block_context(),
})
}
fn nested_attributes_must_propagate(&self, text: &str) -> bool {
self.include_context.depth > 0 && text.lines().any(|line| line.starts_with(':'))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::grammar::LineMap;
use std::ffi::OsStr;
#[test]
fn include_catalog_distinguishes_full_and_partial_sources() -> Result<(), Error> {
let options = Options::builder()
.with_safe_mode(crate::SafeMode::Unsafe)
.with_base_dir("fixtures/preprocessor/xref_catalog")
.build()?;
for (attributes, full) in [
("", true),
("opts=partial", false),
("lines=1..6", false),
("tag=body", false),
("tags=**", true),
("tags=**;*", true),
("tags=**;!*", false),
("tags=**;!body", false),
] {
let input = format!("include::chapter.adoc[{attributes}]\n");
let result = Preprocessor::process(&input, &options, Rc::default())?;
assert_eq!(
result.included_files.contains("chapter"),
full,
"{attributes}"
);
}
Ok(())
}
#[test]
fn include_catalog_retains_empty_and_nested_full_includes() -> Result<(), Error> {
let options = Options::builder()
.with_safe_mode(crate::SafeMode::Unsafe)
.with_base_dir("fixtures/preprocessor/xref_catalog")
.build()?;
let result = Preprocessor::process(
"include::entry.adoc[opts=partial]\ninclude::empty.adoc[]\ninclude::missing.adoc[opts=optional]\nifdef::absent[]\ninclude::excluded.adoc[]\nendif::[]\n",
&options,
Rc::default(),
)?;
assert_eq!(
result.included_files,
["chapter".into(), "empty".into()].into()
);
for input in [
"include::chapter.adoc[opts=partial]\ninclude::chapter.adoc[]\n",
"include::chapter.adoc[]\ninclude::chapter.adoc[opts=partial]\n",
] {
let result = Preprocessor::process(input, &options, Rc::default())?;
assert!(result.included_files.contains("chapter"));
}
Ok(())
}
#[test]
fn include_catalog_uses_paths_relative_to_the_root() -> Result<(), Error> {
let options = Options::builder()
.with_safe_mode(crate::SafeMode::Unsafe)
.with_base_dir("fixtures/preprocessor")
.build()?;
let result = Preprocessor::process(
"include::./xref_catalog/entry.adoc[]\n",
&options,
Rc::default(),
)?;
assert_eq!(
result.included_files,
["xref_catalog/entry".into(), "xref_catalog/chapter".into()].into()
);
Ok(())
}
#[test]
fn include_catalog_excludes_disabled_includes() -> Result<(), Error> {
let options = Options::builder()
.with_base_dir("fixtures/preprocessor/xref_catalog")
.with_safe_mode(crate::SafeMode::Secure)
.build()?;
let result = Preprocessor::process("include::chapter.adoc[]\n", &options, Rc::default())?;
assert!(result.included_files.is_empty());
Ok(())
}
#[test]
fn include_catalog_names_preserve_parent_paths_and_uris() {
let source = SourceOrigin::File {
path: PathBuf::from("/docs/shared/chapter.adoc"),
base_dir: PathBuf::from("/docs/book"),
is_entry: false,
};
assert_eq!(source.include_name().as_deref(), Some("../shared/chapter"));
let source = SourceOrigin::Uri("https://example.org/chapter.adoc".into());
assert_eq!(
source.include_name().as_deref(),
Some("https://example.org/chapter")
);
}
#[test]
fn max_include_depth_fallback_uses_the_canonical_default() {
let options = Options {
document_attributes: DocumentAttributes::empty(),
..Options::default()
};
let actual = i128::try_from(IncludeContext::root(&options).max_depth).unwrap_or(i128::MAX);
assert_eq!(
crate::document_attribute::default_value(
crate::document_attribute::MAX_INCLUDE_DEPTH_ATTR
),
Some(&DocumentAttributeValue::integer(actual))
);
}
#[test]
fn mapped_include_fast_path_preserves_normalization() -> Result<(), Error> {
let options = Options::default();
let preprocessor = Preprocessor::new(&options, Rc::default());
let origin = SourceOrigin::Memory { base_dir: None };
for (input, expected) in [
("", ""),
("\n", ""),
("\n\n", "\n"),
("a\n\n", "a\n"),
("a \n", "a"),
("é\r\nb\r\n", "é\nb"),
] {
let result =
preprocessor.process_mapped(input.to_owned(), &origin, &options, Vec::new())?;
assert_eq!(result.result.text, expected, "{input:?}");
}
Ok(())
}
#[test]
fn include_handoff_preserves_blank_lines_and_following_offsets() {
for (content, expected, following_offset) in [
("", "after", 0),
("\n", "\nafter", 1),
("\n\n", "\n\nafter", 2),
("a", "a\nafter", 2),
("a\n", "a\nafter", 2),
("a\n\n", "a\n\nafter", 3),
("a\r\nb\n", "a\nb\nafter", 4),
("a\rb", "a\rb\nafter", 4),
("é\n", "é\nafter", 3),
] {
let mut state = PreprocessorState::new("after", None, Vec::new());
let included = IncludeResult {
content: content.to_owned(),
synthetic: false,
effective_leveloffset: Some(1),
leveloffset_ranges: Vec::new(),
target: "included.adoc".into(),
source_ranges: Vec::new(),
included_files: HashSet::new(),
document_attributes: None,
comment_context: None,
};
Preprocessor::handle_regular_include_result(included, &mut state);
assert_eq!(state.byte_offset, following_offset, "{content:?}");
assert_eq!(
state
.leveloffset_ranges
.first()
.map(|range| range.end_offset),
Some(following_offset),
"{content:?}"
);
state.push_source_line("after", 1);
state.flush_borrowed_run();
assert_eq!(state.output.join("\n"), expected, "{content:?}");
assert_eq!(state.byte_offset, expected.len() + 1, "{content:?}");
}
}
#[test]
fn unchanged_source_lines_share_one_borrowed_output_chunk() {
let input = "first\n\nsecond\nthird";
let mut state = PreprocessorState::new(input, None, Vec::new());
for (index, line) in input.lines().enumerate() {
state.push_source_line(line, index + 1);
}
state.flush_borrowed_run();
assert_eq!(state.output.len(), 1);
assert!(matches!(
state.output.as_slice(),
[Cow::Borrowed(output)] if *output == input
));
}
#[test]
fn test_process() -> Result<(), Error> {
let options = Options::default();
let input = ":attribute: value
ifdef::attribute[]
content
endif::[]
";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, ":attribute: value\n\ncontent");
Ok(())
}
#[test]
fn include_like_block_macros_bypass_include_processing() -> Result<(), Error> {
let input = "ifdef::missing[]
hidden
endif::[]
includes::x[]
include-foo::bar[]
\\includes::x[]
\\include-foo::bar[]";
let result = Preprocessor::process(input, &Options::default(), Rc::default())?;
assert_eq!(
result.text,
"includes::x[]\ninclude-foo::bar[]\n\\includes::x[]\n\\include-foo::bar[]"
);
Ok(())
}
#[test]
fn malformed_include_directives_remain_literal() -> Result<(), Error> {
let input = "include::[]
include:: target.adoc[]
include::target.adoc []
include::target.adoc
include::target.adoc[] trailing";
let result = Preprocessor::process(input, &Options::default(), Rc::default())?;
let file_result = Preprocessor::process_with_file(
input,
Path::new("main.adoc"),
&Options::default(),
Rc::default(),
)?;
assert_eq!(result.text, input);
assert_eq!(file_result.text, input);
Ok(())
}
#[test]
fn only_valid_escaped_include_directives_are_unescaped() -> Result<(), Error> {
let input = "\\include::[]
\\include:: target.adoc[]
\\include::target.adoc []
\\include::target.adoc
\\include::target.adoc[] trailing
\\include::target.adoc[]
\\include::target with spaces.adoc[]";
let result = Preprocessor::process(input, &Options::default(), Rc::default())?;
assert_eq!(
result.text,
"\\include::[]
\\include:: target.adoc[]
\\include::target.adoc []
\\include::target.adoc
\\include::target.adoc[] trailing
include::target.adoc[]
include::target with spaces.adoc[]"
);
Ok(())
}
#[test]
fn conditional_like_block_macros_bypass_conditional_processing() -> Result<(), Error> {
let input = "ifdefs::x[]
ifdef-foo::bar[]
ifndefs::x[]
ifevals::x[]
\\ifdefs::x[]";
let result = Preprocessor::process(input, &Options::default(), Rc::default())?;
assert_eq!(result.text, input);
Ok(())
}
#[test]
fn endif_like_block_macro_inside_a_conditional_is_content() -> Result<(), Error> {
let input = "ifdef::missing[]
endifs::x[]
endif::[]
after";
let result = Preprocessor::process(input, &Options::default(), Rc::default())?;
assert_eq!(result.text, "after");
Ok(())
}
#[test]
fn multi_attribute_conditional_in_header() -> Result<(), Error> {
let input = "= Title
ifdef::backend-pdf,backend-docbook5[]
:title-page:
endif::backend-pdf,backend-docbook5[]
== Visible
Body";
let result = Preprocessor::process(input, &Options::default(), Rc::default())?;
assert_eq!(result.text, "= Title\n\n== Visible\nBody");
Ok(())
}
#[test]
fn active_multi_attribute_conditional_in_header() -> Result<(), Error> {
let options = Options::builder()
.with_attribute("backend-pdf", true)
.build()?;
let input = "= Title
ifdef::backend-pdf,backend-docbook5[]
:title-page:
endif::backend-pdf,backend-docbook5[]
== Visible
Body";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, "= Title\n:title-page:\n\n== Visible\nBody");
Ok(())
}
#[test]
fn parser_accepts_multi_attribute_conditional_in_header() -> Result<(), Error> {
let input = "= Title
ifdef::backend-pdf,backend-docbook5[]
:title-page:
endif::backend-pdf,backend-docbook5[]
== Visible
Body";
let parsed = crate::parse(input, &Options::default())?;
assert!(parsed.document().header.is_some());
assert!(matches!(
parsed.document().blocks.as_slice(),
[crate::Block::Section(_)]
));
Ok(())
}
#[test]
fn inactive_conditional_body_can_contain_blank_lines() -> Result<(), Error> {
let input = "= Title
ifdef::backend-pdf,backend-docbook5[]
== Hidden
Hidden body
endif::backend-pdf,backend-docbook5[]
== Visible
Visible body";
let result = Preprocessor::process(input, &Options::default(), Rc::default())?;
assert_eq!(result.text, "= Title\n\n\n== Visible\nVisible body");
assert!(!result.text.contains("Hidden"));
assert!(!result.text.contains("endif::"));
Ok(())
}
#[test]
fn nested_conditionals_follow_enclosing_activity() -> Result<(), Error> {
let options = Options::builder().with_attribute("outer", true).build()?;
let input = "ifdef::outer[]
outer content
ifdef::inner[]
hidden inner content
endif::inner[]
after inner
endif::outer[]
tail";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, "outer content\nafter inner\ntail");
Ok(())
}
#[test]
fn inactive_enclosing_conditional_hides_active_nested_condition() -> Result<(), Error> {
let options = Options::builder().with_attribute("inner", true).build()?;
let input = "ifdef::outer[]
hidden outer content
ifdef::inner[]
hidden inner content
endif::inner[]
endif::outer[]
tail";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, "tail");
Ok(())
}
#[test]
fn multi_attribute_conditions_evaluate_every_attribute() -> Result<(), Error> {
let options = Options::builder()
.with_attribute("third", true)
.with_attribute("first", true)
.with_attribute("second", true)
.build()?;
let input = "ifdef::missing-one,missing-two,third[]
or content
endif::missing-one,missing-two,third[]
ifdef::first+second+third[]
and content
endif::first+second+third[]";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, "or content\nand content");
Ok(())
}
#[test]
fn attribute_defined_in_active_conditional_affects_later_condition() -> Result<(), Error> {
let options = Options::builder().with_attribute("outer", true).build()?;
let input = "ifdef::outer[]
:inner:
endif::outer[]
ifdef::inner[]
visible
endif::inner[]";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, ":inner:\nvisible");
Ok(())
}
#[test]
fn block_comments_bypass_directives_on_fast_and_slow_paths() -> Result<(), Error> {
let comment = "////\ninclude::missing-comment-file.adoc[]\nifeval::[invalid]\n:kept: changed \\\ncontinued\n\\include::escaped-missing-file.adoc[]\n////";
let input = format!("= T\n:kept: original\n{comment}");
let fast = Preprocessor::process(&input, &Options::default(), Rc::default())?;
assert!(matches!(&fast.text, Cow::Borrowed(_)));
assert_eq!(fast.text, input);
let input = format!("{input}\n\nifdef::kept[]\nVisible\nendif::[]");
let slow = Preprocessor::process(&input, &Options::default(), Rc::default())?;
assert!(slow.text.contains(comment));
assert!(slow.text.ends_with("Visible"));
assert!(slow.included_files.is_empty());
Ok(())
}
#[test]
fn block_comments_respect_outer_conditional_exclusion() -> Result<(), Error> {
let input = "ifdef::missing[]\n////\ninclude::missing-comment-file.adoc[]\nifdef::nested[]\nignored\nendif::[]\n////\nHidden\nendif::[]\nVisible";
let result = Preprocessor::process(input, &Options::default(), Rc::default())?;
assert_eq!(result.text, "Visible");
Ok(())
}
#[test]
fn block_comment_delimiters_stay_literal_in_verbatim_blocks() -> Result<(), Error> {
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/preprocessor");
let options = Options::builder()
.with_safe_mode(crate::SafeMode::Unsafe)
.with_base_dir(fixtures)
.build()?;
for delimiter in ["----", "....", "++++", "```"] {
let input = format!(
"{delimiter}\n////\ninclude::header_comment_visible.inc[]\n////\n{delimiter}"
);
let result = Preprocessor::process(&input, &options, Rc::default())?;
assert!(
result.text.contains("////\nIncluded content\n////"),
"{delimiter}: {}",
result.text
);
}
Ok(())
}
#[test]
fn table_comment_delimiters_keep_directives_active_on_both_paths() -> Result<(), Error> {
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/preprocessor");
let options = Options::builder()
.with_safe_mode(crate::SafeMode::Unsafe)
.with_base_dir(fixtures)
.build()?;
let comment = "////\ninclude::missing-comment-file.adoc[]\n////";
for delimiter in ["|===", "!===", ",===", ":==="] {
let input =
format!("{delimiter}\ncell\n////\nCell text\n////\n----\n{delimiter}\n\n{comment}");
let fast = Preprocessor::process(&input, &options, Rc::default())?;
assert!(matches!(&fast.text, Cow::Borrowed(_)), "{delimiter}");
assert_eq!(fast.text, input);
let input = input.replace("Cell text", "include::header_comment_visible.inc[]");
let slow = Preprocessor::process(&input, &options, Rc::default())?;
assert!(matches!(&slow.text, Cow::Owned(_)), "{delimiter}");
assert_eq!(
slow.text,
input.replace("include::header_comment_visible.inc[]", "Included content"),
"{delimiter}"
);
}
Ok(())
}
#[test]
fn table_comment_context_ignores_excluded_and_verbatim_fences() -> Result<(), Error> {
let comment = "////\ninclude::missing-comment-file.adoc[]\n////\nVisible";
for prefix in [
"ifdef::missing[]\n|===\nendif::[]",
"----\n|===\n////\n----",
"....\n|===\n////\n....",
"++++\n|===\n////\n++++",
"```\n|===\n////\n```",
"|=== literal text",
] {
let input = format!("{prefix}\n\n{comment}");
let result = Preprocessor::process(&input, &Options::default(), Rc::default())?;
assert!(result.text.ends_with(comment), "{prefix}: {}", result.text);
assert!(result.included_files.is_empty());
}
Ok(())
}
#[test]
fn table_comment_context_keeps_outer_fence_through_nested_tables() -> Result<(), Error> {
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/preprocessor");
let options = Options::builder()
.with_safe_mode(crate::SafeMode::Unsafe)
.with_base_dir(fixtures)
.build()?;
let input = "|===\na|\n!===\nl!\n////\ninclude::header_comment_visible.inc[]\n////\n!===\n|====\n////\ninclude::header_comment_visible.inc[]\n////\n|===\n\n////\ninclude::missing-comment-file.adoc[]\n////\nVisible";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text.matches("Included content").count(), 2);
assert!(result.text.ends_with("////\nVisible"));
Ok(())
}
#[test]
fn includes_inherit_table_and_verbatim_comment_context() -> Result<(), Error> {
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/tests");
let options = Options::builder()
.with_safe_mode(crate::SafeMode::Unsafe)
.with_base_dir(fixtures)
.build()?;
for delimiter in ["|===", "----", "....", "++++", "```"] {
for selection in ["", "lines=1..5"] {
let input = format!(
"{delimiter}\ninclude::table_comment_cell.asciidoc[{selection}]\n{delimiter}\n\n////\ninclude::missing-comment-file.adoc[]\n////"
);
let result = Preprocessor::process(&input, &options, Rc::default())?;
assert!(
result.text.contains("////\nVISIBLE TABLE INCLUDE\n////"),
"{delimiter} / {selection}: {}",
result.text
);
assert!(
result
.text
.ends_with("include::missing-comment-file.adoc[]\n////")
);
}
}
Ok(())
}
#[test]
fn included_table_fences_update_the_callers_comment_context() -> Result<(), Error> {
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/tests");
let options = Options::builder()
.with_safe_mode(crate::SafeMode::Unsafe)
.with_base_dir(fixtures)
.build()?;
let input = "include::table_comment_open.asciidoc[]\n////\ninclude::table_comment_visible.inc[]\n////\ninclude::table_comment_close.asciidoc[]\n\n////\ninclude::missing-comment-file.adoc[]\n////\nVisible";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert!(
result
.text
.contains("////\nVISIBLE TABLE INCLUDE\n////\n|===")
);
assert!(
result
.text
.ends_with("include::missing-comment-file.adoc[]\n////\nVisible")
);
let input = "include::table_comment_cell.asciidoc[lines=1]\ninclude::missing-comment-file.adoc[]\n////\nVisible";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(
result.text,
"////\ninclude::missing-comment-file.adoc[]\n////\nVisible"
);
Ok(())
}
#[test]
fn styled_paragraph_comments_survive_fast_and_slow_paths() -> Result<(), Error> {
for style in ["source,text", "listing", "literal", "verse"] {
let source =
format!("[{style}]\ncode\n// visible\n////\ninside\n////\n// still visible");
let fast = Preprocessor::process(&source, &Options::default(), Rc::default())?;
assert_eq!(fast.text, source);
assert!(matches!(fast.text, Cow::Borrowed(_)));
let slow = source.replace("inside", "ifndef::missing[]\ninside\nendif::[]");
let result = Preprocessor::process(&slow, &Options::default(), Rc::default())?;
assert_eq!(result.text, source);
}
for first in [".", "..", ": code", "+", " +"] {
let source = format!("[source]\n{first}\n// visible");
let result = Preprocessor::process(&source, &Options::default(), Rc::default())?;
assert_eq!(result.text, source);
}
let source = ":kind: source\n\n[{kind}]\ncode\n// visible";
let result = Preprocessor::process(source, &Options::default(), Rc::default())?;
assert_eq!(result.text, source);
assert!(matches!(result.text, Cow::Borrowed(_)));
assert_eq!(result.source_ranges, []);
Ok(())
}
#[test]
fn styled_paragraph_comments_respect_metadata_and_boundaries() -> Result<(), Error> {
for metadata in [
"[source,text]",
"[listing.role#id%nowrap]",
"[style=literal]",
"[\"verse\"]\n[#id]\n.Title",
":kind: source\n[{kind}]",
] {
for (prefix, boundary) in [
("", "\n"),
("", "+"),
("====\n", "===="),
("--\n", "--"),
("****\n", "****"),
] {
let source = format!(
"{prefix}{metadata}\n// leading\ncode\n// visible\n{boundary}\nordinary\n// hidden\nafter"
);
let result = Preprocessor::process(&source, &Options::default(), Rc::default())?;
assert_eq!(
result.text,
source
.replace("// leading\n", "")
.replace("// hidden\n", ""),
"{source}"
);
}
}
for prefix in [
"[source]\n[quote]",
"[source]\n== Section",
"[source]\n\n[normal]",
] {
let source = format!("{prefix}\nordinary\n// hidden\nafter");
let result = Preprocessor::process(&source, &Options::default(), Rc::default())?;
assert_eq!(result.text, source.replace("// hidden\n", ""));
}
Ok(())
}
#[test]
#[cfg(feature = "setext")]
fn styled_paragraph_comments_respect_setext_headings() -> Result<(), Error> {
let options = Options::builder().with_setext().build()?;
for title in ["= Title", "Title\n====="] {
let source =
format!("{title}\n\n[source]\nHeading\n-------\nordinary\n// hidden\ntail");
let result = Preprocessor::process(&source, &options, Rc::default())?;
assert_eq!(result.text, source.replace("// hidden\n", ""));
let result = Preprocessor::process(&source, &Options::default(), Rc::default())?;
assert_eq!(result.text, source);
}
for source in [
"[source]\nfirst\nHeading\n-------\n// visible",
"[source]\n+\ncode\n// visible",
] {
let result = Preprocessor::process(source, &options, Rc::default())?;
assert_eq!(result.text, source);
}
Ok(())
}
#[test]
fn styled_paragraph_comments_cross_selected_and_nested_includes() -> Result<(), Error> {
let options = Options::builder()
.with_safe_mode(crate::SafeMode::Unsafe)
.with_base_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/tests"))
.build()?;
let source = include_str!("../../fixtures/tests/styled_paragraph_comment_includes.adoc");
let result = Preprocessor::process(source, &options, Rc::default())?;
for visible in [
"// I02", "// I04", "// I05", "// I07", "// I09", "// I14", "// I16", "// I18",
] {
assert!(
result.text.contains(visible),
"missing {visible}: {}",
result.text
);
}
assert!(!result.text.contains("HIDDEN"), "{}", result.text);
assert_eq!(result.text.matches("// I02").count(), 2);
assert_eq!(result.text.matches("// I04").count(), 3);
Ok(())
}
#[test]
fn styled_paragraph_comments_follow_emitted_conditional_content() -> Result<(), Error> {
for middle in [
"ifdef::missing[]\n\n[quote]\n////\nendif::[]",
"ifndef::missing[code]",
] {
let source = format!("[source]\ncode\n{middle}\n// visible");
let result = Preprocessor::process(&source, &Options::default(), Rc::default())?;
assert!(result.text.ends_with("// visible"), "{}", result.text);
}
let source = "[source]\nifndef::missing[code]\n// visible\n\nordinary\n// hidden";
let result = Preprocessor::process(source, &Options::default(), Rc::default())?;
assert_eq!(result.text, "[source]\ncode\n// visible\n\nordinary");
Ok(())
}
#[test]
fn test_line_comment_adjacent_to_content_is_dropped() -> Result<(), Error> {
let options = Options::default();
let input = "para line one
// adjacent comment
para line two";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, "para line one\npara line two");
Ok(())
}
#[test]
fn test_trailing_line_comments_after_content_are_dropped() -> Result<(), Error> {
let options = Options::default();
let input = "Usage-controlled: *NO*. +
// Trappable: *NO*. +
// Interruptible: *NO*. +";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, "Usage-controlled: *NO*. +");
Ok(())
}
#[test]
fn test_standalone_line_comment_is_preserved() -> Result<(), Error> {
let options = Options::default();
let input = "para
// standalone comment
more";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, "para\n\n// standalone comment\n\nmore");
Ok(())
}
fn reported_source_line(input: &str, file: &str, needle: &str) -> Option<usize> {
let result = Preprocessor::process_with_file(
input,
Path::new(file),
&Options::default(),
Rc::default(),
)
.ok()?;
let text: &'static str = Box::leak(result.text.into_owned().into_boxed_str());
resolve_warning_location(text, result.source_ranges, file, needle).map(|(_, line)| line)
}
fn resolve_warning_location(
text: &'static str,
source_ranges: Vec<SourceRange>,
current_file: &str,
needle: &str,
) -> Option<(Option<PathBuf>, usize)> {
use crate::grammar::ParserState;
let offset = text.find(needle)?;
let mut state = ParserState::new_for_test(text);
state.current_file = Some(PathBuf::from(current_file).into());
state.source_ranges = source_ranges;
let loc = state.create_location(offset, offset + needle.len());
let resolved = state.create_error_source_location(loc);
Some((resolved.file, resolved.location.start.line as usize))
}
#[test]
fn dropped_comment_remaps_following_lines_to_source() {
let input = "line one\n// c\nline two\n\nsecond para\n";
assert_eq!(reported_source_line(input, "doc.adoc", "line one"), Some(1));
assert_eq!(reported_source_line(input, "doc.adoc", "line two"), Some(3));
assert_eq!(
reported_source_line(input, "doc.adoc", "second para"),
Some(5)
);
}
#[test]
fn stripped_conditional_remaps_following_lines_to_source() {
let input = "one\nifdef::cond[]\nhidden\nendif::[]\ntwo\n";
assert_eq!(reported_source_line(input, "doc.adoc", "one"), Some(1));
assert_eq!(reported_source_line(input, "doc.adoc", "two"), Some(5));
}
#[test]
fn active_conditional_remaps_body_and_following_lines_to_source() {
let input = ":cond:\nifdef::cond[]\ninside\nendif::[]\nafter\n";
assert_eq!(reported_source_line(input, "doc.adoc", "inside"), Some(3));
assert_eq!(reported_source_line(input, "doc.adoc", "after"), Some(5));
}
#[test]
fn collapsed_attribute_continuation_remaps_following_lines_to_source() {
let input = ":attr: foo \\\nbar\n\nbaz\n";
assert_eq!(reported_source_line(input, "doc.adoc", "baz"), Some(4));
}
#[test]
fn no_path_input_records_ranges_with_none_file() -> Result<(), Error> {
let input = "one\n// c\ntwo\n";
let result = Preprocessor::process(input, &Options::default(), Rc::default())?;
assert_ne!(result.source_ranges, []);
assert!(result.source_ranges.iter().all(|r| r.file.is_none()));
let text: &'static str = Box::leak(result.text.clone().into_owned().into_boxed_str());
let mapped = resolve_warning_location(text, result.source_ranges, "", "two");
assert_eq!(mapped.map(|(_, line)| line), Some(3));
Ok(())
}
#[test]
fn include_reports_line_within_the_included_file() -> Result<(), Error> {
let path = Path::new("fixtures/preprocessor/include_line_mapping_main.adoc");
let result = Preprocessor::process_file(
path,
&Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
},
Rc::default(),
)?;
let text: &'static str = Box::leak(result.text.into_owned().into_boxed_str());
let chapter = resolve_warning_location(
text,
result.source_ranges.clone(),
"fixtures/preprocessor/include_line_mapping_main.adoc",
"target reference here",
);
assert_eq!(
chapter
.as_ref()
.and_then(|(file, _)| file.as_ref())
.and_then(|p| p.file_name())
.and_then(OsStr::to_str),
Some("include_line_mapping_chapter.adoc"),
);
assert_eq!(chapter.map(|(_, line)| line), Some(5));
let main = resolve_warning_location(
text,
result.source_ranges,
"fixtures/preprocessor/include_line_mapping_main.adoc",
"after the comment",
);
assert_eq!(
main.as_ref()
.and_then(|(file, _)| file.as_ref())
.and_then(|p| p.file_name())
.and_then(OsStr::to_str),
Some("include_line_mapping_main.adoc"),
);
assert_eq!(main.map(|(_, line)| line), Some(5));
Ok(())
}
fn resolve_partial_origin(path: &str, needle: &str) -> Option<(Option<String>, usize, usize)> {
let result = Preprocessor::process_file(
Path::new(path),
&Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
},
Rc::default(),
)
.ok()?;
let text = result.text.into_owned();
let offset = text.find(needle)?;
let range = SourceRange::find_containing(&result.source_ranges, offset)?;
let file = range
.file
.as_ref()
.and_then(|p| p.file_name())
.and_then(OsStr::to_str)
.map(str::to_string);
let line_map = LineMap::new(&text);
Some((
file,
line_map.source_line(range, &text, offset) as usize,
range.source_offset(offset),
))
}
#[test]
fn partial_lines_include_maps_to_included_file_true_lines() {
let main = "fixtures/preprocessor/include_partial_lines.adoc";
assert_eq!(
resolve_partial_origin(main, "Line three."),
Some((Some("include_partial_part.adoc".to_string()), 3, 20)),
);
assert_eq!(
resolve_partial_origin(main, "Line four."),
Some((Some("include_partial_part.adoc".to_string()), 4, 32)),
);
}
#[test]
fn partial_multi_range_include_splits_into_located_runs() {
let main = "fixtures/preprocessor/include_partial_multi.adoc";
assert_eq!(
resolve_partial_origin(main, "Line one."),
Some((Some("include_partial_part.adoc".to_string()), 1, 0)),
);
assert_eq!(
resolve_partial_origin(main, "Line four."),
Some((Some("include_partial_part.adoc".to_string()), 4, 32)),
);
assert_eq!(
resolve_partial_origin(main, "Line five."),
Some((Some("include_partial_part.adoc".to_string()), 5, 43)),
);
}
#[test]
fn partial_line_ranges_sort_deduplicate_and_accept_negative_eof() -> Result<(), Error> {
let result = Preprocessor::process_file(
Path::new("fixtures/preprocessor/include_lines_negative_deduplicated.adoc"),
&Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
},
Rc::default(),
)?;
assert_eq!(
result.text,
"= Negative and Duplicate Include Lines\n\nLine three.\nLine four.\nLine five."
);
Ok(())
}
#[test]
fn tag_include_maps_to_tag_region_source_lines() {
let main = "fixtures/preprocessor/include_with_tag.adoc";
let resolved = resolve_partial_origin(main, "This is the introduction.");
assert_eq!(
resolved.as_ref().and_then(|(file, _, _)| file.as_deref()),
Some("tagged_content.adoc"),
);
assert_eq!(resolved.map(|(_, line, _)| line), Some(4));
}
#[test]
fn test_line_comment_in_list_continuation_is_preserved() -> Result<(), Error> {
let options = Options::default();
let input = "* item
+
// a comment
+
more";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, "* item\n+\n// a comment\n+\nmore");
Ok(())
}
#[test]
fn test_line_comment_after_title_is_preserved() -> Result<(), Error> {
let options = Options::default();
let input = "= The Title
// a comment
Roberto Avanzi";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, "= The Title\n// a comment\nRoberto Avanzi");
Ok(())
}
#[test]
fn test_line_comment_in_verbatim_block_is_preserved() -> Result<(), Error> {
let options = Options::default();
let input = "----
code line
// not a comment here
----";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, "----\ncode line\n// not a comment here\n----");
Ok(())
}
#[test]
fn test_line_comment_after_equals_run_dropped_without_setext() -> Result<(), Error> {
let options = Options::default();
let input = "Title
=====
// a comment
body";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, "Title\n=====\nbody");
Ok(())
}
#[cfg(feature = "setext")]
#[test]
fn test_line_comment_after_setext_underline_is_preserved() -> Result<(), Error> {
let options = Options::builder().with_setext().build()?;
let input = "Title
=====
// a comment
body";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, "Title\n=====\n// a comment\nbody");
Ok(())
}
#[test]
fn test_good_endif_directive() -> Result<(), Error> {
let options = Options::default();
let input = ":asdf:
ifdef::asdf[]
content
endif::asdf[]";
let result = Preprocessor::process(input, &options, Rc::default())?;
assert_eq!(result.text, ":asdf:\n\ncontent");
Ok(())
}
#[test]
fn test_bad_endif_directive() {
let options = Options::default();
let input = "ifdef::asdf[]
content
endif::another[]";
let output = Preprocessor::process(input, &options, Rc::default());
assert!(matches!(
output,
Err(Error::InvalidConditionalDirective(..))
));
}
#[test]
fn multi_attribute_endif_must_match_complete_condition() {
let input = "ifdef::backend-pdf,backend-docbook5[]
content
endif::backend-pdf[]";
let output = Preprocessor::process(input, &Options::default(), Rc::default());
assert!(matches!(
output,
Err(Error::InvalidConditionalDirective(..))
));
}
#[test]
fn test_utf8_bom_detection() -> Result<(), Error> {
let path = Path::new("fixtures/preprocessor/utf8_bom.adoc");
let content = read_and_decode_file(path, None)?;
assert!(content.contains("= Test Document"));
assert!(content.contains("This is a test with special chars: é, ñ, ü."));
assert!(!content.starts_with('\u{FEFF}'));
Ok(())
}
#[test]
fn test_utf16le_bom_detection() -> Result<(), Error> {
let path = Path::new("fixtures/preprocessor/utf16le_bom.adoc");
let content = read_and_decode_file(path, None)?;
assert!(content.contains("= Test Document"));
assert!(content.contains("This is a test with special chars: é, ñ, ü."));
Ok(())
}
#[test]
fn test_utf16be_bom_detection() -> Result<(), Error> {
let path = Path::new("fixtures/preprocessor/utf16be_bom.adoc");
let content = read_and_decode_file(path, None)?;
assert!(content.contains("= Test Document"));
assert!(content.contains("This is a test with special chars: é, ñ, ü."));
Ok(())
}
#[test]
fn test_utf8_no_bom() -> Result<(), Error> {
let path = Path::new("fixtures/preprocessor/utf8_no_bom.adoc");
let content = read_and_decode_file(path, None)?;
assert!(content.contains("= Test Document"));
assert!(content.contains("This is a test with special chars: é, ñ, ü."));
Ok(())
}
#[test]
fn test_explicit_encoding_override() -> Result<(), Error> {
let path = Path::new("fixtures/preprocessor/utf8_no_bom.adoc");
let content = read_and_decode_file(path, Some("utf-8"))?;
assert!(content.contains("= Test Document"));
Ok(())
}
#[test]
fn test_unknown_encoding_label_falls_back_to_utf8() -> Result<(), Error> {
let path = Path::new("fixtures/preprocessor/utf8_no_bom.adoc");
let content = read_and_decode_file(path, Some("unknown-encoding-12345"))?;
assert!(content.contains("= Test Document"));
Ok(())
}
#[test]
fn test_include_utf16_file() -> Result<(), Error> {
let warnings = Rc::<RefCell<Vec<Warning>>>::default();
let path = Path::new("fixtures/preprocessor/main_with_include.adoc");
let options = Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
};
let result = Preprocessor::process_file(path, &options, warnings)?;
assert!(result.text.contains("= Main Document"));
assert!(result.text.contains("This is included content."));
assert!(result.text.contains("With special characters: é, ñ, ü."));
assert!(result.text.contains("After include."));
Ok(())
}
#[test]
fn test_include_with_single_tag() -> Result<(), Error> {
let warnings = Rc::<RefCell<Vec<Warning>>>::default();
let path = Path::new("fixtures/preprocessor/include_with_tag.adoc");
let options = Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
};
let result = Preprocessor::process_file(path, &options, warnings)?;
assert!(result.text.contains("This is the introduction."));
assert!(result.text.contains("It has multiple lines."));
assert!(!result.text.contains("untagged content"));
assert!(!result.text.contains("main content"));
assert!(!result.text.contains("Debug information"));
assert!(!result.text.contains("tag::intro"));
assert!(!result.text.contains("end::intro"));
Ok(())
}
#[test]
fn test_include_with_multiple_tags() -> Result<(), Error> {
let warnings = Rc::<RefCell<Vec<Warning>>>::default();
let path = Path::new("fixtures/preprocessor/include_multiple_tags.adoc");
let options = Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
};
let result = Preprocessor::process_file(path, &options, warnings)?;
assert!(result.text.contains("This is the introduction."));
assert!(result.text.contains("This is the main content."));
assert!(!result.text.contains("Debug information"));
Ok(())
}
#[test]
fn test_include_with_wildcard_excluding_tag() -> Result<(), Error> {
let warnings = Rc::<RefCell<Vec<Warning>>>::default();
let path = Path::new("fixtures/preprocessor/include_wildcard_exclude.adoc");
let options = Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
};
let result = Preprocessor::process_file(path, &options, warnings)?;
assert!(result.text.contains("This is the introduction."));
assert!(result.text.contains("This is the main content."));
assert!(!result.text.contains("Debug information"));
Ok(())
}
#[test]
fn test_include_with_double_wildcard() -> Result<(), Error> {
let warnings = Rc::<RefCell<Vec<Warning>>>::default();
let path = Path::new("fixtures/preprocessor/include_double_wildcard.adoc");
let options = Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
};
let result = Preprocessor::process_file(path, &options, warnings)?;
assert!(result.text.contains("untagged content"));
assert!(result.text.contains("This is the introduction."));
assert!(result.text.contains("This is the main content."));
assert!(result.text.contains("Debug information"));
assert!(!result.text.contains("tag::intro"));
assert!(!result.text.contains("end::intro"));
Ok(())
}
#[test]
fn test_include_with_nested_tag() -> Result<(), Error> {
let warnings = Rc::<RefCell<Vec<Warning>>>::default();
let path = Path::new("fixtures/preprocessor/include_nested_tag.adoc");
let options = Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
};
let result = Preprocessor::process_file(path, &options, warnings)?;
assert!(result.text.contains("This is nested within main."));
assert!(!result.text.contains("This is the main content."));
assert!(!result.text.contains("Back to main content."));
Ok(())
}
#[test]
fn test_include_select_untagged_only() -> Result<(), Error> {
let warnings = Rc::<RefCell<Vec<Warning>>>::default();
let path = Path::new("fixtures/preprocessor/include_untagged_only.adoc");
let options = Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
};
let result = Preprocessor::process_file(path, &options, warnings)?;
assert!(result.text.contains("untagged content at the beginning"));
assert!(result.text.contains("More untagged content"));
assert!(result.text.contains("Final untagged content"));
assert!(!result.text.contains("This is the introduction"));
assert!(!result.text.contains("This is the main content"));
assert!(!result.text.contains("Debug information"));
Ok(())
}
#[test]
fn lines_selection_takes_precedence_over_tag_selection() -> Result<(), Error> {
let warnings = Rc::<RefCell<Vec<Warning>>>::default();
let path = Path::new("fixtures/preprocessor/include_tag_with_lines.adoc");
let options = Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
};
let result = Preprocessor::process_file(path, &options, warnings)?;
assert!(
result
.text
.contains("Debug information that should be excluded.")
);
assert!(!result.text.contains("This is the introduction."));
Ok(())
}
#[test]
fn test_include_with_indent() -> Result<(), Error> {
let warnings = Rc::<RefCell<Vec<Warning>>>::default();
let path = Path::new("fixtures/preprocessor/include_with_indent.adoc");
let options = Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
};
let result = Preprocessor::process_file(path, &options, warnings)?;
assert!(result.text.contains(" def hello"));
assert!(result.text.contains(" puts \"Hello\""));
assert!(result.text.contains(" end"));
Ok(())
}
#[test]
fn test_include_with_indent_zero() -> Result<(), Error> {
let warnings = Rc::<RefCell<Vec<Warning>>>::default();
let path = Path::new("fixtures/preprocessor/include_with_indent_zero.adoc");
let options = Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
};
let result = Preprocessor::process_file(path, &options, warnings)?;
assert!(result.text.contains("def hello"));
assert!(result.text.contains(" puts \"Hello\""));
assert!(result.text.contains("end"));
Ok(())
}
#[test]
fn test_include_with_indent_and_tag() -> Result<(), Error> {
let warnings = Rc::<RefCell<Vec<Warning>>>::default();
let path = Path::new("fixtures/preprocessor/include_with_indent_and_tag.adoc");
let options = Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
};
let result = Preprocessor::process_file(path, &options, warnings)?;
assert!(result.text.contains(" This is the introduction."));
assert!(result.text.contains(" It has multiple lines."));
assert!(!result.text.contains("main content"));
assert!(!result.text.contains("Debug information"));
Ok(())
}
#[test]
fn test_nested_include_relative_paths() -> Result<(), Error> {
let warnings = Rc::<RefCell<Vec<Warning>>>::default();
let path = Path::new("fixtures/preprocessor/nested_include_main.adoc");
let options = Options {
safe_mode: crate::SafeMode::Unsafe,
..Options::default()
};
let result = Preprocessor::process_file(path, &options, warnings)?;
assert!(result.text.contains("= Nested Include Test"));
assert!(result.text.contains("This is middle content."));
assert!(
result.text.contains("This is inner content from subdir."),
"Nested include failed to resolve relative path. Got: {}",
result.text
);
Ok(())
}
}