use std::{borrow::Cow, sync::LazyLock};
use regex::{Captures, Regex, Replacer};
use crate::{
HasSpan, Parser, SafeMode, Span,
attributes::{Attrlist, AttrlistContext},
content::AttributeMissing,
document::{Attribute, InterpretedValue},
parser::{
DeferredWarning, Fidelity, IncludeResolution, SourceLine, SourceMap, Transform,
attribute_lookup_name,
},
span::MatchedItem,
warnings::{Warning, WarningType},
};
pub(crate) fn preprocess(
source: &str,
parser: &Parser,
) -> (String, SourceMap, Vec<DeferredWarning>, Vec<(String, bool)>) {
preprocess_with_initial_file_name(source, parser, parser.primary_file_name.as_deref())
}
pub(crate) fn preprocess_with_initial_file_name(
source: &str,
parser: &Parser,
initial_file_name: Option<&str>,
) -> (String, SourceMap, Vec<DeferredWarning>, Vec<(String, bool)>) {
if !source.starts_with("include::")
&& !source.starts_with("if")
&& !source.starts_with("endif::")
&& !source.starts_with("\\if")
&& !source.starts_with("\\endif::")
&& !source.contains("\ninclude::")
&& !source.contains("\nif")
&& !source.contains("\nendif::")
&& !source.contains("\n\\if")
&& !source.contains("\n\\endif::")
&& !source.starts_with("\\include::")
&& !source.contains("\n\\include::")
&& initial_file_name.is_none()
{
return (source.to_owned(), SourceMap::default(), vec![], vec![]);
}
let mut temp_parser = parser.clone();
let mut state = PreprocessorState::new(&mut temp_parser);
state.process_adoc_include(source, initial_file_name, &Reindented::default());
state.emit_unterminated_conditional_warnings();
(
state.output,
state.source_map,
state.warnings,
state.includes,
)
}
#[derive(Debug)]
struct PreprocessorState<'p> {
parser: &'p mut Parser,
in_document_header: bool,
can_have_attribute: bool,
include_depth: usize,
output_line_number: usize,
output: String,
source_map: SourceMap,
current_fidelity: Fidelity,
warnings: Vec<DeferredWarning>,
includes: Vec<(String, bool)>,
max_include_depth: Option<MaxIncludeDepth>,
conditional_stack: Vec<Conditional>,
}
#[derive(Debug)]
struct Conditional {
target: Option<String>,
skipping: bool,
directive_text: String,
file_name: Option<String>,
source_line: usize,
}
#[derive(Clone, Copy, Debug)]
struct MaxIncludeDepth {
abs: usize,
curr: usize,
rel: usize,
}
impl<'p> PreprocessorState<'p> {
fn new(parser: &'p mut Parser) -> Self {
let max_include_depth = match parser.attribute_value("max-include-depth") {
InterpretedValue::Value(value) => ruby_to_i(&value),
InterpretedValue::Set => 0,
InterpretedValue::Unset => 64,
};
let max_include_depth = (max_include_depth > 0).then(|| {
let depth = usize::try_from(max_include_depth).unwrap_or(usize::MAX);
MaxIncludeDepth {
abs: depth,
curr: depth,
rel: depth,
}
});
Self {
parser,
in_document_header: true,
can_have_attribute: true,
include_depth: 0,
output_line_number: 1,
output: String::new(),
source_map: SourceMap::default(),
current_fidelity: Fidelity::Verbatim,
warnings: vec![],
includes: vec![],
max_include_depth,
conditional_stack: vec![],
}
}
fn skipping(&self) -> bool {
self.conditional_stack.last().is_some_and(|c| c.skipping)
}
fn process_adoc_include(
&mut self,
source: &str,
file_name: Option<&str>,
reindented: &Reindented,
) {
self.include_depth += 1;
let mut has_reported_file = file_name.is_none();
let mut source_span = Span::new(source);
let mut comment_block_delimiter: Option<String> = None;
let mut in_comment_paragraph = false;
let mut comment_style_pending = false;
while !source_span.is_empty() {
let original_source = source_span;
let MatchedItem { item: line, after } = source_span.take_line();
source_span = after;
let source_line_number = line.line();
let content_fidelity = reindented.fidelity_for(source_line_number);
if let Some(delimiter) = &comment_block_delimiter {
let closes = line.data() == delimiter;
self.emit_line(
line.data(),
file_name,
source_line_number,
content_fidelity,
&mut has_reported_file,
);
if closes {
comment_block_delimiter = None;
}
continue;
}
if in_comment_paragraph {
if line.data().is_empty() {
in_comment_paragraph = false;
}
self.emit_line(
line.data(),
file_name,
source_line_number,
content_fidelity,
&mut has_reported_file,
);
continue;
}
if has_conditional_prefix(line.data())
&& let Some(caps) = CONDITIONAL_DIRECTIVE.captures(line.data())
{
has_reported_file = false;
comment_style_pending = false;
if caps.get(1).is_some() {
if !self.skipping() {
self.emit_line(
&line.data()[1..],
file_name,
source_line_number,
Fidelity::Transformed(Transform::Rewritten),
&mut has_reported_file,
);
}
} else {
self.process_conditional_directive(
&caps[2],
caps.get(3).map_or("", |m| m.as_str()),
caps.get(4).map_or("", |m| m.as_str()),
file_name,
source_line_number,
&mut has_reported_file,
);
}
continue;
}
if self.skipping() {
has_reported_file = false;
continue;
}
if is_comment_block_delimiter(line.data()) {
comment_block_delimiter = Some(line.data().to_owned());
comment_style_pending = false;
self.emit_line(
line.data(),
file_name,
source_line_number,
content_fidelity,
&mut has_reported_file,
);
continue;
}
if comment_style_pending {
if line.data() == "--" {
comment_block_delimiter = Some("--".to_owned());
comment_style_pending = false;
self.emit_line(
line.data(),
file_name,
source_line_number,
content_fidelity,
&mut has_reported_file,
);
continue;
}
if !is_block_metadata_line(line.data()) {
if !line.data().is_empty() {
in_comment_paragraph = true;
}
comment_style_pending = false;
}
}
if let Some(is_comment) = self.attrlist_block_style_is_comment(line.data()) {
comment_style_pending = is_comment;
}
if self.can_have_attribute
&& line.starts_with(':')
&& (line.ends_with(':') || line.contains(": "))
&& let Some(attr) = Attribute::parse(original_source, self.parser)
{
self.record_origin(
file_name,
source_line_number,
content_fidelity,
&mut has_reported_file,
);
let mut warnings: Vec<Warning> = vec![];
self.parser
.set_attribute_from_body(&attr.item, &mut warnings);
self.output.push_str(attr.item.span().data());
self.output.push('\n');
self.output_line_number += attr
.item
.span()
.data()
.as_bytes()
.iter()
.filter(|&&b| b == b'\n')
.count()
+ 1;
source_span = attr.after;
} else if line.starts_with("include::")
&& let Some(caps) = INCLUDE_DIRECTIVE.captures(line.data())
{
let attribute_missing = AttributeMissing::from_parser(self.parser);
let missing_policy = match attribute_missing {
AttributeMissing::Skip => MissingAttribute::KeepLiteral,
AttributeMissing::Drop => MissingAttribute::Drop,
AttributeMissing::DropLine | AttributeMissing::Warn => {
MissingAttribute::DropLine
}
};
let (target, missing_reference) =
self.substitute_attributes_tracking(&caps[1], missing_policy);
if missing_reference
&& matches!(
attribute_missing,
AttributeMissing::DropLine | AttributeMissing::Warn
)
{
if attribute_missing == AttributeMissing::DropLine
|| parse_attrlist(&caps, self.parser).has_option("optional")
{
has_reported_file = false;
continue;
}
self.emit_unresolved_directive(
line.data(),
WarningType::IncludeDroppedDueToMissingAttribute(line.data().to_owned()),
file_name,
source_line_number,
&mut has_reported_file,
);
continue;
}
if self.parser.safe >= SafeMode::Secure {
self.record_origin(
file_name,
source_line_number,
Fidelity::Synthetic(Transform::SecureLinkRewrite),
&mut has_reported_file,
);
let replacement = if target.contains(' ') {
format!("link:pass:c[{target}][role=include]")
} else {
format!("link:{target}[role=include]")
};
self.output_line_number += 1;
self.output.push_str(&replacement);
self.output.push('\n');
continue;
}
let Some(max_depth) = self.max_include_depth else {
self.emit_line(
line.data(),
file_name,
source_line_number,
content_fidelity,
&mut has_reported_file,
);
continue;
};
if self.include_depth > max_depth.curr {
self.warnings.push(DeferredWarning {
offset: self.output.len(),
len: line.data().len(),
warning: WarningType::MaxIncludeDepthExceeded(max_depth.rel),
origin: None,
});
self.emit_line(
line.data(),
file_name,
source_line_number,
content_fidelity,
&mut has_reported_file,
);
continue;
}
let attrlist = parse_attrlist(&caps, self.parser);
if is_uri(&target) && !self.parser.is_attribute_set("allow-uri-read") {
self.record_origin(
file_name,
source_line_number,
Fidelity::Synthetic(Transform::SecureLinkRewrite),
&mut has_reported_file,
);
let replacement = if target.contains(' ') {
format!("link:pass:c[{target}][role=include]")
} else {
format!("link:{target}[role=include]")
};
self.output_line_number += 1;
self.output.push_str(&replacement);
self.output.push('\n');
continue;
}
let resolution = self
.parser
.include_file_handler
.as_ref()
.map_or(IncludeResolution::NotFound, |ifh| {
ifh.resolve_target(file_name, &target, &attrlist, self.parser)
});
let (include_content, failure_warning): (_, fn(String) -> WarningType) =
match resolution {
IncludeResolution::Found(content) => {
(Some(content), WarningType::IncludeFileNotFound)
}
IncludeResolution::NotReadable => {
(None, WarningType::IncludeFileNotReadable)
}
IncludeResolution::NotDecodable => {
(None, WarningType::IncludeFileNotDecodable)
}
IncludeResolution::NotFound => (None, WarningType::IncludeFileNotFound),
};
if let Some(include_content) = include_content {
let (selected, tag_diagnostics) =
select_included_lines(include_content.content(), &attrlist);
let (selected, nested_reindent) =
reindent_included_lines(selected, &attrlist, self.parser);
self.emit_tag_filter_warnings(&tag_diagnostics, file_name, source_line_number);
let non_utf8_encoding = (!include_content.encoding_handled())
.then(|| {
attrlist
.named_attribute("encoding")
.map(|a| a.value())
.filter(|v| !is_utf8_encoding(v))
})
.flatten();
let leveloffset = attrlist
.named_attribute("leveloffset")
.map(|a| a.value())
.filter(|v| !v.is_empty());
let restore_leveloffset = leveloffset.map(|offset| {
let restore = match self.parser.attribute_value("leveloffset") {
InterpretedValue::Value(v) if !v.is_empty() => {
format!(":leveloffset: {v}")
}
_ => ":leveloffset!:".to_string(),
};
let wrapper = Fidelity::Synthetic(Transform::LevelOffsetWrapper);
self.emit_line(
&format!(":leveloffset: {offset}"),
file_name,
source_line_number,
wrapper,
&mut has_reported_file,
);
self.emit_line(
"",
file_name,
source_line_number,
wrapper,
&mut has_reported_file,
);
restore
});
let content_start = self.output.len();
if is_asciidoc_file(&target) {
if self.include_depth == 1 {
let full = is_full_include(&attrlist);
self.includes
.push((include_catalog_key(&target).to_string(), full));
}
let saved_max_depth = self.max_include_depth;
if let Some(depth_attr) = attrlist.named_attribute("depth")
&& let Some(max_depth) = self.max_include_depth.as_mut()
{
let rel = ruby_to_i(depth_attr.value());
if rel > 0 {
let mut rel = usize::try_from(rel).unwrap_or(usize::MAX);
let mut curr = self.include_depth.saturating_add(rel);
if curr > max_depth.abs {
curr = max_depth.abs;
rel = max_depth.abs;
}
max_depth.curr = curr;
max_depth.rel = rel;
} else {
max_depth.curr = self.include_depth;
max_depth.rel = 0;
}
}
self.process_adoc_include(&selected, Some(&target), &nested_reindent);
self.max_include_depth = saved_max_depth;
} else {
self.process_nonadoc_include(&selected, Some(&target), &nested_reindent);
}
if let Some(encoding) = non_utf8_encoding {
let len = self.output[content_start..]
.find('\n')
.unwrap_or(self.output.len() - content_start);
self.warnings.push(DeferredWarning {
offset: content_start,
len,
warning: WarningType::NonUtf8IncludeEncoding(encoding.to_string()),
origin: None,
});
}
if let Some(restore) = restore_leveloffset {
let wrapper = Fidelity::Synthetic(Transform::LevelOffsetWrapper);
self.emit_line(
"",
file_name,
source_line_number,
wrapper,
&mut has_reported_file,
);
self.emit_line(
&restore,
file_name,
source_line_number,
wrapper,
&mut has_reported_file,
);
}
has_reported_file = false;
} else if attrlist.has_option("optional") {
has_reported_file = false;
} else {
let warning = failure_warning(target);
self.emit_unresolved_directive(
line.data(),
warning,
file_name,
source_line_number,
&mut has_reported_file,
);
}
} else {
let escaped_include = line.starts_with("\\include::")
&& INCLUDE_DIRECTIVE.is_match(&line.data()[1..]);
let line_text = if escaped_include {
&line.data()[1..]
} else {
line.data()
};
let fidelity = if escaped_include {
Fidelity::Transformed(Transform::Rewritten)
} else {
content_fidelity
};
self.emit_line(
line_text,
file_name,
source_line_number,
fidelity,
&mut has_reported_file,
);
}
}
self.include_depth -= 1;
}
fn process_nonadoc_include(
&mut self,
source: &str,
file_name: Option<&str>,
reindented: &Reindented,
) {
let mut source_span = Span::new(source);
let mut has_reported_file = false;
while !source_span.is_empty() {
let MatchedItem { item: line, after } = source_span.take_line();
source_span = after;
self.record_origin(
file_name,
line.line(),
reindented.fidelity_for(line.line()),
&mut has_reported_file,
);
if line.is_empty() {
self.in_document_header = false;
self.can_have_attribute = true;
} else if !self.in_document_header {
self.can_have_attribute = false;
}
self.output_line_number += 1;
self.output.push_str(line.data());
self.output.push('\n');
}
}
fn attrlist_block_style_is_comment(&self, line: &str) -> Option<bool> {
let inner = line.strip_prefix('[')?.strip_suffix(']')?;
if inner.starts_with('[') {
return None;
}
let attrlist = Attrlist::parse(Span::new(inner), self.parser, AttrlistContext::Block)
.item
.item;
attrlist.block_style().map(|style| style == "comment")
}
fn substitute_attributes(&self, input: &str, missing: MissingAttribute) -> String {
self.substitute_attributes_tracking(input, missing).0
}
fn substitute_attributes_tracking(
&self,
input: &str,
missing: MissingAttribute,
) -> (String, bool) {
if !input.contains('{') {
return (input.to_string(), false);
}
#[derive(Debug)]
struct AttributeReplacer<'p> {
parser: &'p Parser,
missing: MissingAttribute,
missing_reference: bool,
}
impl Replacer for AttributeReplacer<'_> {
fn replace_append(&mut self, caps: ®ex::Captures<'_>, dest: &mut String) {
let attr_name = &caps[2];
if caps.get(1).is_some() || caps.get(3).is_some() {
dest.push('{');
dest.push_str(attr_name);
dest.push('}');
return;
}
let lookup_name = attribute_lookup_name(attr_name);
if !self.parser.has_attribute(&lookup_name) {
self.missing_reference = true;
if matches!(self.missing, MissingAttribute::KeepLiteral) {
dest.push_str(&caps[0]);
}
return;
}
if let InterpretedValue::Value(value) = self.parser.attribute_value(&lookup_name) {
dest.push_str(value.as_ref());
}
}
}
let result: Cow<'_, str> = input.into();
let mut replacer = AttributeReplacer {
parser: self.parser,
missing,
missing_reference: false,
};
let replaced = ATTRIBUTE_REFERENCE.replace_all(&result, replacer.by_ref());
let text = match replaced {
Cow::Owned(new_result) => new_result,
Cow::Borrowed(_) => input.to_string(),
};
(text, replacer.missing_reference)
}
fn record_origin(
&mut self,
file_name: Option<&str>,
source_line_number: usize,
fidelity: Fidelity,
has_reported_file: &mut bool,
) {
if *has_reported_file && self.current_fidelity == fidelity {
return;
}
*has_reported_file = true;
self.current_fidelity = fidelity;
self.source_map.append(
self.output_line_number,
file_name,
source_line_number,
fidelity,
);
}
fn emit_line(
&mut self,
text: &str,
file_name: Option<&str>,
source_line_number: usize,
fidelity: Fidelity,
has_reported_file: &mut bool,
) {
self.record_origin(file_name, source_line_number, fidelity, has_reported_file);
if text.is_empty() {
self.in_document_header = false;
self.can_have_attribute = true;
} else if !self.in_document_header {
self.can_have_attribute = false;
}
self.output_line_number += 1;
self.output.push_str(text);
self.output.push('\n');
}
fn emit_unresolved_directive(
&mut self,
directive_line: &str,
warning: WarningType,
file_name: Option<&str>,
source_line_number: usize,
has_reported_file: &mut bool,
) {
self.record_origin(
file_name,
source_line_number,
Fidelity::Synthetic(Transform::UnresolvedDirective),
has_reported_file,
);
let replacement = format!(
"Unresolved directive in {file_name} - {directive_line}",
file_name = file_name.unwrap_or("(root file)"),
);
self.warnings.push(DeferredWarning {
offset: self.output.len(),
len: replacement.len(),
warning,
origin: None,
});
self.output_line_number += 1;
self.output.push_str(&replacement);
self.output.push('\n');
}
fn process_conditional_directive(
&mut self,
keyword: &str,
target: &str,
content: &str,
file_name: Option<&str>,
source_line_number: usize,
has_reported_file: &mut bool,
) {
let already_skipping = self.skipping();
if keyword == "endif" {
if !content.is_empty() {
if !already_skipping {
self.emit_conditional_warning(
WarningType::MalformedConditionalDirective(
"text not permitted".to_owned(),
directive_text(keyword, target, content),
),
file_name,
source_line_number,
);
}
return;
}
match self.conditional_stack.last() {
Some(top) if target.is_empty() || top.target.as_deref() == Some(target) => {
self.conditional_stack.pop();
}
Some(_) => {
if !already_skipping {
self.emit_conditional_warning(
WarningType::MismatchedConditionalDirective(directive_text(
keyword, target, content,
)),
file_name,
source_line_number,
);
}
}
None => {
self.emit_conditional_warning(
WarningType::UnmatchedConditionalDirective(directive_text(
keyword, target, content,
)),
file_name,
source_line_number,
);
}
}
return;
}
if keyword == "ifeval" {
let malformed_reason = if !target.is_empty() {
Some("target not permitted")
} else if content.trim().is_empty() {
Some("missing expression")
} else if !IFEVAL_EXPRESSION.is_match(content.trim()) {
Some("invalid expression")
} else {
None
};
if let Some(reason) = malformed_reason {
if !already_skipping {
self.emit_conditional_warning(
WarningType::MalformedConditionalDirective(
reason.to_owned(),
directive_text(keyword, target, content),
),
file_name,
source_line_number,
);
}
return;
}
let include = !already_skipping && self.eval_ifeval(content);
self.conditional_stack.push(Conditional {
target: None,
skipping: already_skipping || !include,
directive_text: directive_text(keyword, target, content),
file_name: to_owned(file_name),
source_line: source_line_number,
});
return;
}
if target.is_empty() {
if !already_skipping {
self.emit_conditional_warning(
WarningType::MalformedConditionalDirective(
"missing target".to_owned(),
directive_text(keyword, target, content),
),
file_name,
source_line_number,
);
}
return;
}
if content.is_empty() {
let skipping = already_skipping || !self.eval_ifdef(keyword, target);
self.conditional_stack.push(Conditional {
target: Some(target.to_owned()),
skipping,
directive_text: directive_text(keyword, target, content),
file_name: to_owned(file_name),
source_line: source_line_number,
});
} else if !already_skipping && self.eval_ifdef(keyword, target) {
self.process_single_line_content(
content,
file_name,
source_line_number,
has_reported_file,
);
}
}
fn emit_conditional_warning(
&mut self,
warning: WarningType,
file_name: Option<&str>,
source_line_number: usize,
) {
self.warnings.push(DeferredWarning {
offset: self.output.len(),
len: 0,
warning,
origin: Some(SourceLine(to_owned(file_name), source_line_number)),
});
}
fn emit_tag_filter_warnings(
&mut self,
diagnostics: &[TagFilterDiagnostic],
file_name: Option<&str>,
source_line_number: usize,
) {
for diagnostic in diagnostics {
let warning = match diagnostic {
TagFilterDiagnostic::NotFound(names) => {
let word = if names.len() > 1 { "tags" } else { "tag" };
WarningType::IncludeTagNotFound(format!("{word} '{}'", names.join(", ")))
}
TagFilterDiagnostic::Unclosed(name) => {
WarningType::IncludeTagUnclosed(format!("'{name}'"))
}
TagFilterDiagnostic::MismatchedEnd { expected, found } => {
WarningType::IncludeTagMismatchedEnd(
format!("'{expected}'"),
format!("'{found}'"),
)
}
TagFilterDiagnostic::UnexpectedEnd(name) => {
WarningType::IncludeTagUnexpectedEnd(format!("'{name}'"))
}
};
self.warnings.push(DeferredWarning {
offset: self.output.len(),
len: 0,
warning,
origin: Some(SourceLine(to_owned(file_name), source_line_number)),
});
}
}
fn emit_unterminated_conditional_warnings(&mut self) {
for conditional in std::mem::take(&mut self.conditional_stack) {
self.warnings.push(DeferredWarning {
offset: self.output.len(),
len: 0,
warning: WarningType::UnterminatedConditionalDirective(conditional.directive_text),
origin: Some(SourceLine(conditional.file_name, conditional.source_line)),
});
}
}
fn process_single_line_content(
&mut self,
content: &str,
file_name: Option<&str>,
source_line_number: usize,
has_reported_file: &mut bool,
) {
let can_have_attribute = self.can_have_attribute;
let mut applied_attribute = false;
if can_have_attribute
&& content.starts_with(':')
&& (content.ends_with(':') || content.contains(": "))
&& let Some(attr) = Attribute::parse(Span::new(content), self.parser)
{
let mut warnings: Vec<Warning> = vec![];
self.parser
.set_attribute_from_body(&attr.item, &mut warnings);
applied_attribute = true;
}
self.emit_line(
content,
file_name,
source_line_number,
Fidelity::Transformed(Transform::Rewritten),
has_reported_file,
);
if applied_attribute {
self.can_have_attribute = can_have_attribute;
}
}
fn eval_ifdef(&self, keyword: &str, target: &str) -> bool {
let is_set = |name: &str| self.parser.is_attribute_set(attribute_lookup_name(name));
let comma = target.find(',');
let plus = target.find('+');
let comma_first = match (comma, plus) {
(Some(c), Some(p)) => c < p,
(Some(_), None) => true,
_ => false,
};
let defined = if comma_first {
target.split(',').any(is_set)
} else if plus.is_some() {
target.split('+').all(is_set)
} else {
is_set(target)
};
if keyword == "ifndef" {
!defined
} else {
defined
}
}
fn eval_ifeval(&self, expr: &str) -> bool {
let Some(caps) = IFEVAL_EXPRESSION.captures(expr.trim()) else {
return false;
};
let lhs = self.resolve_expr_val(&caps[1]);
let rhs = self.resolve_expr_val(&caps[3]);
compare_values(&lhs, &caps[2], &rhs)
}
fn resolve_expr_val(&self, raw: &str) -> Value {
let raw = raw.trim();
let quoted_inner = raw
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| raw.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')));
match quoted_inner {
Some(inner) => Value::Str(self.substitute_attributes(inner, MissingAttribute::Drop)),
None => coerce_unquoted(&self.substitute_attributes(raw, MissingAttribute::Drop)),
}
}
}
#[derive(Clone, Copy, Debug)]
enum MissingAttribute {
KeepLiteral,
Drop,
DropLine,
}
#[derive(Debug, PartialEq)]
enum Value {
Int(i64),
Float(f64),
Str(String),
Bool(bool),
Nil,
}
fn coerce_unquoted(s: &str) -> Value {
if s.is_empty() {
return Value::Nil;
}
match s {
"true" => return Value::Bool(true),
"false" => return Value::Bool(false),
_ => {}
}
if s.chars().all(char::is_whitespace) {
return Value::Str(" ".to_owned());
}
if s.contains('.') {
Value::Float(ruby_to_f(s))
} else {
Value::Int(ruby_to_i(s))
}
}
pub(super) fn ruby_to_i(s: &str) -> i64 {
let mut digits = String::new();
for (idx, ch) in s.trim().char_indices() {
if (idx == 0 && (ch == '+' || ch == '-')) || ch.is_ascii_digit() {
digits.push(ch);
} else {
break;
}
}
digits.parse().unwrap_or_else(|_| {
if !digits.bytes().any(|b| b.is_ascii_digit()) {
0
} else if digits.starts_with('-') {
i64::MIN
} else {
i64::MAX
}
})
}
fn ruby_to_f(s: &str) -> f64 {
let s = s.trim();
if let Ok(f) = s.parse::<f64>() {
return f;
}
let mut digits = String::new();
let mut seen_dot = false;
for (idx, ch) in s.char_indices() {
if (idx == 0 && (ch == '+' || ch == '-')) || ch.is_ascii_digit() {
digits.push(ch);
} else if ch == '.' && !seen_dot {
seen_dot = true;
digits.push(ch);
} else {
break;
}
}
digits.parse().unwrap_or(0.0)
}
fn compare_values(lhs: &Value, op: &str, rhs: &Value) -> bool {
match op {
"==" => values_equal(lhs, rhs),
"!=" => !values_equal(lhs, rhs),
_ => match ordering_of(lhs, rhs) {
Some(ordering) => match op {
"<" => ordering.is_lt(),
"<=" => ordering.is_le(),
">" => ordering.is_gt(),
_ => ordering.is_ge(),
},
None => false,
},
}
}
fn values_equal(lhs: &Value, rhs: &Value) -> bool {
match (lhs, rhs) {
(Value::Int(a), Value::Int(b)) => a == b,
(Value::Float(a), Value::Float(b)) => a == b,
(Value::Int(a), Value::Float(b)) | (Value::Float(b), Value::Int(a)) => (*a as f64) == *b,
(Value::Str(a), Value::Str(b)) => a == b,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Nil, Value::Nil) => true,
_ => false,
}
}
fn ordering_of(lhs: &Value, rhs: &Value) -> Option<std::cmp::Ordering> {
match (lhs, rhs) {
(Value::Int(a), Value::Int(b)) => Some(a.cmp(b)),
(Value::Float(a), Value::Float(b)) => a.partial_cmp(b),
(Value::Int(a), Value::Float(b)) => (*a as f64).partial_cmp(b),
(Value::Float(a), Value::Int(b)) => a.partial_cmp(&(*b as f64)),
(Value::Str(a), Value::Str(b)) => Some(a.cmp(b)),
_ => None,
}
}
fn has_conditional_prefix(line: &str) -> bool {
let line = line.strip_prefix('\\').unwrap_or(line);
line.starts_with("ifdef::")
|| line.starts_with("ifndef::")
|| line.starts_with("ifeval::")
|| line.starts_with("endif::")
}
fn to_owned(maybe_file_name: Option<&str>) -> Option<String> {
maybe_file_name.map(|n| n.to_string())
}
fn is_comment_block_delimiter(line: &str) -> bool {
line.len() >= 4 && line.bytes().all(|b| b == b'/')
}
fn is_block_metadata_line(line: &str) -> bool {
if line.starts_with('[') {
return true;
}
matches!(
line.strip_prefix('.'),
Some(rest) if rest.starts_with(|c: char| !c.is_whitespace() && c != '.')
)
}
fn parse_attrlist<'src>(caps: &Captures<'src>, parser: &Parser) -> Attrlist<'src> {
caps.get(2)
.map(|attrlist| {
let span = Span::new(attrlist.as_str());
Attrlist::parse(span, parser, AttrlistContext::Inline)
.item
.item
})
.unwrap_or_default()
}
fn directive_text(keyword: &str, target: &str, content: &str) -> String {
format!("{keyword}::{target}[{content}]")
}
fn is_asciidoc_file(target: &str) -> bool {
const ASCIIDOC_EXTENSIONS: [&str; 5] = ["asciidoc", "adoc", "ad", "asc", "txt"];
let file_name = target.rsplit('/').next().unwrap_or(target);
match file_name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => ASCIIDOC_EXTENSIONS.contains(&ext),
_ => false,
}
}
fn include_catalog_key(target: &str) -> &str {
match target.rsplit_once('.') {
Some((stem, _ext)) if !stem.is_empty() => stem,
_ => target,
}
}
fn is_full_include(attrlist: &Attrlist<'_>) -> bool {
if attrlist
.named_attribute("lines")
.map(|a| a.value())
.is_some_and(|v| !v.is_empty())
{
return false;
}
match attrlist
.named_attribute("tags")
.or_else(|| attrlist.named_attribute("tag"))
.map(|a| a.value())
.filter(|v| !v.is_empty())
{
Some(tags) => tags.trim() == "**",
None => true,
}
}
fn is_uri(target: &str) -> bool {
URI_PREFIX.is_match(target)
}
fn is_utf8_encoding(value: &str) -> bool {
let normalized: String = value
.trim()
.to_ascii_lowercase()
.chars()
.filter(|&c| c != '-')
.collect();
normalized == "utf8"
}
fn split_delimited_value(value: &str) -> impl Iterator<Item = &str> {
let delimiter = if value.contains(',') { ',' } else { ';' };
value
.split(delimiter)
.map(str::trim)
.filter(|s| !s.is_empty())
}
fn select_included_lines(
text: &str,
attrlist: &Attrlist<'_>,
) -> (String, Vec<TagFilterDiagnostic>) {
if let Some(lines) = attrlist
.named_attribute("lines")
.map(|a| a.value())
.filter(|v| !v.is_empty())
{
return (select_by_line_ranges(text, lines), vec![]);
}
if let Some(tags) = attrlist
.named_attribute("tags")
.or_else(|| attrlist.named_attribute("tag"))
.map(|a| a.value())
.filter(|v| !v.is_empty())
{
return select_by_tags(text, tags);
}
(text.to_string(), vec![])
}
#[derive(Debug)]
enum TagFilterDiagnostic {
NotFound(Vec<String>),
Unclosed(String),
MismatchedEnd { expected: String, found: String },
UnexpectedEnd(String),
}
fn select_by_line_ranges(text: &str, spec: &str) -> String {
let ranges: Vec<(usize, Option<usize>)> = split_delimited_value(spec)
.map(|entry| {
if let Some((from, to)) = entry.split_once("..") {
let from = from.trim().parse().unwrap_or(0);
let to = to.trim();
let to = match to.parse::<i64>() {
Ok(to) if to >= 0 => Some(to as usize),
_ => None,
};
(from, to)
} else {
let n = entry.parse().unwrap_or(0);
(n, Some(n))
}
})
.filter(|&(from, to)| to.is_none_or(|to| from <= to))
.collect();
if ranges.is_empty() {
return text.to_string();
}
let mut output = String::new();
for (index, line) in text.lines().enumerate() {
let line_number = index + 1;
if ranges
.iter()
.any(|&(from, to)| line_number >= from && to.is_none_or(|to| line_number <= to))
{
output.push_str(line);
output.push('\n');
}
}
output
}
fn select_by_tags(text: &str, spec: &str) -> (String, Vec<TagFilterDiagnostic>) {
let mut diagnostics: Vec<TagFilterDiagnostic> = vec![];
let mut inc_tags: Vec<(String, bool)> = vec![];
for entry in split_delimited_value(spec) {
let (name, include) = match entry.strip_prefix('!') {
Some(name) => (name, false),
None => (entry, true),
};
if name.is_empty() {
continue;
}
match inc_tags.iter_mut().find(|(n, _)| n == name) {
Some(existing) => existing.1 = include,
None => inc_tags.push((name.to_string(), include)),
}
}
let requested_named: Vec<String> = inc_tags
.iter()
.filter(|(name, include)| *include && name != "*" && name != "**")
.map(|(name, _)| name.clone())
.collect();
let mut seen_tags: Vec<String> = vec![];
let take = |tags: &mut Vec<(String, bool)>, name: &str| -> Option<bool> {
tags.iter()
.position(|(n, _)| n == name)
.map(|i| tags.remove(i).1)
};
let mut wildcard: Option<bool> = None;
let base_select: bool;
if let Some(double) = take(&mut inc_tags, "**") {
base_select = double;
if let Some(single) = take(&mut inc_tags, "*") {
wildcard = Some(single);
} else if !double && inc_tags.first().map(|(_, v)| *v) == Some(false) {
wildcard = Some(true);
}
} else if inc_tags.iter().any(|(n, _)| n == "*") {
if inc_tags.first().map(|(n, _)| n.as_str()) == Some("*") {
let single = take(&mut inc_tags, "*").unwrap_or(false);
wildcard = Some(single);
base_select = !single;
} else {
wildcard = take(&mut inc_tags, "*");
base_select = false;
}
} else {
base_select = !inc_tags.iter().any(|(_, v)| *v);
}
let lookup = |name: &str| inc_tags.iter().find(|(n, _)| n == name).map(|(_, v)| *v);
let mut output = String::new();
let mut select = base_select;
let mut active_tag: Option<String> = None;
let mut tag_stack: Vec<(String, bool)> = vec![];
for line in text.lines() {
if let Some((is_end, name)) = find_tag_directive(line) {
if is_end {
if active_tag.as_deref() == Some(name) {
tag_stack.pop();
match tag_stack.last() {
Some((tag, sel)) => {
active_tag = Some(tag.clone());
select = *sel;
}
None => {
active_tag = None;
select = base_select;
}
}
} else if let Some(idx) = tag_stack.iter().rposition(|(n, _)| n == name) {
diagnostics.push(TagFilterDiagnostic::MismatchedEnd {
expected: active_tag.clone().unwrap_or_default(),
found: name.to_string(),
});
tag_stack.remove(idx);
} else {
diagnostics.push(TagFilterDiagnostic::UnexpectedEnd(name.to_string()));
}
} else {
if !seen_tags.iter().any(|n| n == name) {
seen_tags.push(name.to_string());
}
select = if let Some(named) = lookup(name) {
named
} else if let Some(wildcard) = wildcard {
if active_tag.is_some() && !select {
false
} else {
wildcard
}
} else {
select
};
tag_stack.push((name.to_string(), select));
active_tag = Some(name.to_string());
}
} else if select {
output.push_str(line);
output.push('\n');
}
}
for (name, _) in &tag_stack {
diagnostics.push(TagFilterDiagnostic::Unclosed(name.clone()));
}
let missing: Vec<String> = requested_named
.into_iter()
.filter(|name| !seen_tags.iter().any(|n| n == name))
.collect();
if !missing.is_empty() {
diagnostics.push(TagFilterDiagnostic::NotFound(missing));
}
(output, diagnostics)
}
fn find_tag_directive(line: &str) -> Option<(bool, &str)> {
if !line.contains("::") || !line.contains("[]") {
return None;
}
for caps in TAG_DIRECTIVE.captures_iter(line) {
let whole = caps.get(0)?;
let trailing_ok = match line[whole.end()..].chars().next() {
None => true,
Some(c) => c == ' ' || c == '\r',
};
if trailing_ok {
let is_end = &caps[1] == "end";
return Some((is_end, caps.get(2)?.as_str()));
}
}
None
}
#[derive(Debug, Default)]
struct Reindented {
changes: Vec<Option<Transform>>,
}
impl Reindented {
fn fidelity_for(&self, line: usize) -> Fidelity {
match self.changes.get(line.wrapping_sub(1)).copied().flatten() {
Some(transform) => Fidelity::Transformed(transform),
None => Fidelity::Verbatim,
}
}
}
fn reindent_included_lines(
text: String,
attrlist: &Attrlist<'_>,
parser: &Parser,
) -> (String, Reindented) {
let indent: Option<i64> = attrlist
.named_attribute("indent")
.map(|a| a.value().trim().parse().unwrap_or(0));
let tab_size = match parser.attribute_value("tabsize") {
InterpretedValue::Value(v) => v.trim().parse().unwrap_or(0),
_ => 0,
};
let expand = tab_size > 0 && text.contains('\t');
let apply_indent = matches!(indent, Some(i) if i >= 0);
if !expand && !apply_indent {
return (text, Reindented::default());
}
let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
let originals = lines.clone();
if expand {
for line in lines.iter_mut() {
*line = expand_tabs(line, tab_size);
}
}
if apply_indent {
adjust_indentation(&mut lines, indent.unwrap_or(0) as usize);
}
let changes = originals
.iter()
.zip(&lines)
.map(|(original, reindented)| {
if original == reindented {
None
} else if expand && original.contains('\t') {
Some(Transform::TabExpansion)
} else {
Some(Transform::Reindent)
}
})
.collect();
let mut output = lines.join("\n");
if !output.is_empty() || !text.is_empty() {
output.push('\n');
}
(output, Reindented { changes })
}
fn adjust_indentation(lines: &mut [String], indent: usize) {
if lines.is_empty() {
return;
}
let Some(offset) = lines
.iter()
.filter(|l| !l.is_empty())
.map(|l| l.len() - l.trim_start_matches(' ').len())
.min()
else {
return;
};
if offset == 0 {
return;
}
let padding = " ".repeat(indent);
for line in lines.iter_mut() {
if line.is_empty() {
continue;
}
let stripped = &line[offset..];
*line = if indent > 0 {
format!("{padding}{stripped}")
} else {
stripped.to_string()
};
}
}
fn expand_tabs(line: &str, tab_size: usize) -> String {
if !line.contains('\t') {
return line.to_string();
}
let mut output = String::new();
let mut column = 0;
for ch in line.chars() {
if ch == '\t' {
let spaces = tab_size - (column % tab_size);
output.extend(std::iter::repeat_n(' ', spaces));
column += spaces;
} else {
output.push(ch);
column += 1;
}
}
output
}
static TAG_DIRECTIVE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r#"\b(tag|end)::(\S+?)\[\]"#).unwrap()
});
static URI_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r#"^[A-Za-z][A-Za-z0-9.+-]*://"#).unwrap()
});
static INCLUDE_DIRECTIVE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?x) # Extended (verbose) mode
^ # Start of string
include:: # Literal 'include::' macro prefix
( # (1) Target path
[^\s\[] # First char: not space or '['
(?: [^\[]* [^\s\[] )? # Optional middle part ending with non-space/non-'['
) # end capture group 1
\[ # Literal '[' starting the attributes block
([^\]].+)? # (2) Optional contents inside brackets (lazy by default)
\] # Literal closing bracket
$ # End of line
"#,
)
.unwrap()
});
static ATTRIBUTE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r#"(\\)?\{(\w[\w-]*)(\\)?\}"#).unwrap()
});
static CONDITIONAL_DIRECTIVE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?x) # Extended (verbose) mode
^ # Start of line
(\\)? # (1) Optional escaping backslash
(ifdef|ifndef|ifeval|endif) # (2) Directive keyword
:: # Literal '::' separator
([^\[]*) # (3) Target (attribute expression), may be empty
\[ # Literal '[' opening the brackets
(.*) # (4) Bracketed content, may be empty
\] # Literal closing ']'
$ # End of line
"#,
)
.unwrap()
});
static IFEVAL_EXPRESSION: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r#"(?s)^(.+?)\s*(==|!=|<=|>=|<|>)\s*(.+)$"#).unwrap()
});
#[cfg(test)]
mod tests {
#![allow(clippy::indexing_slicing)]
#![allow(clippy::unwrap_used)]
use crate::{
SafeMode,
attributes::Attrlist,
parser::{
IncludeContent, IncludeFileHandler, IncludeResolution, SourceLine,
preprocessor::preprocess,
},
tests::{fixtures::inline_file_handler::InlineFileHandler, prelude::*},
};
#[test]
fn no_preprocessor_directives() {
let source =
"= Document Title\n\nThis is a simple document with no includes or conditionals.";
let parser = Parser::default().with_primary_file_name("test.adoc");
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
"= Document Title\n\nThis is a simple document with no includes or conditionals.\n"
);
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("test.adoc".to_owned()), 1))
);
}
#[test]
fn simple_include_directive() {
let source = "= Document Title\n\ninclude::shared.adoc[]\n\nMore content.";
let handler = InlineFileHandler::from_pairs([(
"shared.adoc",
"This is shared content.\n\nWith multiple lines.\n",
)]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
"= Document Title\n\nThis is shared content.\n\nWith multiple lines.\n\nMore content.\n"
);
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("main.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(2),
Some(SourceLine(Some("main.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(3),
Some(SourceLine(Some("shared.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(4),
Some(SourceLine(Some("shared.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(5),
Some(SourceLine(Some("shared.adoc".to_owned()), 3))
);
assert_eq!(
source_map.original_file_and_line(6),
Some(SourceLine(Some("main.adoc".to_owned()), 4))
);
assert_eq!(
source_map.original_file_and_line(7),
Some(SourceLine(Some("main.adoc".to_owned()), 5))
);
}
#[test]
fn include_directive_at_start() {
let source = "include::header.adoc[]\n\n= Document Title\n\nContent here.";
let handler =
InlineFileHandler::from_pairs([("header.adoc", ":author: John Doe\n:version: 1.0")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
":author: John Doe\n:version: 1.0\n\n= Document Title\n\nContent here.\n"
);
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("header.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(2),
Some(SourceLine(Some("header.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(3),
Some(SourceLine(Some("main.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(4),
Some(SourceLine(Some("main.adoc".to_owned()), 3))
);
assert_eq!(
source_map.original_file_and_line(5),
Some(SourceLine(Some("main.adoc".to_owned()), 4))
);
assert_eq!(
source_map.original_file_and_line(6),
Some(SourceLine(Some("main.adoc".to_owned()), 5))
);
}
#[test]
fn include_directive_at_start_secure_mode() {
let source = "include::header.adoc[]\n\n= Document Title\n\nContent here.";
let handler = InlineFileHandler::from_pairs([("header.adoc", "SHOULD NOT APPEAR")]);
let parser = Parser::default()
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
"link:header.adoc[role=include]\n\n= Document Title\n\nContent here.\n"
);
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("main.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(2),
Some(SourceLine(Some("main.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(3),
Some(SourceLine(Some("main.adoc".to_owned()), 3))
);
}
#[test]
fn include_directive_after_content_secure_mode() {
let source = "= Document Title\n\nSome content.\n\ninclude::header.adoc[]\n\nMore content.";
let handler = InlineFileHandler::from_pairs([("header.adoc", "SHOULD NOT APPEAR")]);
let parser = Parser::default()
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
"= Document Title\n\nSome content.\n\nlink:header.adoc[role=include]\n\nMore content.\n"
);
assert_eq!(
source_map.original_file_and_line(4),
Some(SourceLine(Some("main.adoc".to_owned()), 4))
);
assert_eq!(
source_map.original_file_and_line(5),
Some(SourceLine(Some("main.adoc".to_owned()), 5))
);
assert_eq!(
source_map.original_file_and_line(6),
Some(SourceLine(Some("main.adoc".to_owned()), 6))
);
}
#[test]
fn nested_includes() {
let source =
"= Document Title\n\ninclude::chapter1.adoc[]\n\n(a little more of root document)";
let handler = InlineFileHandler::from_pairs([
(
"chapter1.adoc",
"== Chapter 1\n\ninclude::section1.adoc[]\n\n(a little more of chapter 1)",
),
("section1.adoc", "=== Section 1\n\nContent here."),
]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
"= Document Title\n\n== Chapter 1\n\n=== Section 1\n\nContent here.\n\n(a little more of chapter 1)\n\n(a little more of root document)\n"
);
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("main.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(2),
Some(SourceLine(Some("main.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(3),
Some(SourceLine(Some("chapter1.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(4),
Some(SourceLine(Some("chapter1.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(5),
Some(SourceLine(Some("section1.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(6),
Some(SourceLine(Some("section1.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(7),
Some(SourceLine(Some("section1.adoc".to_owned()), 3))
);
assert_eq!(
source_map.original_file_and_line(8),
Some(SourceLine(Some("chapter1.adoc".to_owned()), 4))
);
assert_eq!(
source_map.original_file_and_line(9),
Some(SourceLine(Some("chapter1.adoc".to_owned()), 5))
);
assert_eq!(
source_map.original_file_and_line(10),
Some(SourceLine(Some("main.adoc".to_owned()), 4))
);
}
#[test]
fn include_with_missing_file() {
let source = "= Document Title\n\ninclude::missing.adoc[]\n\nMore content.";
let handler = InlineFileHandler::from_pairs([("other.adoc", "Other content")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
"= Document Title\n\nUnresolved directive in main.adoc - include::missing.adoc[]\n\nMore content.\n"
);
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].warning,
WarningType::IncludeFileNotFound("missing.adoc".to_owned())
);
assert_eq!(
&processed_source[warnings[0].offset..warnings[0].offset + warnings[0].len],
"Unresolved directive in main.adoc - include::missing.adoc[]"
);
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("main.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(2),
Some(SourceLine(Some("main.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(3),
Some(SourceLine(Some("main.adoc".to_owned()), 3))
);
assert_eq!(
source_map.original_file_and_line(4),
Some(SourceLine(Some("main.adoc".to_owned()), 4))
);
}
#[test]
fn empty_file_with_include() {
let source = "include::entire-doc.adoc[]";
let handler = InlineFileHandler::from_pairs([(
"entire-doc.adoc",
"= Full Document\n\n== Chapter 1\n\nContent here.",
)]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
"= Full Document\n\n== Chapter 1\n\nContent here.\n"
);
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("entire-doc.adoc".to_owned()), 1))
);
}
#[test]
fn no_include_handler() {
let source = "= Document Title\n\ninclude::missing.adoc[]\n\nMore content.";
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc");
let (processed_source, source_map, warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
"= Document Title\n\nUnresolved directive in main.adoc - include::missing.adoc[]\n\nMore content.\n"
);
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].warning,
WarningType::IncludeFileNotFound("missing.adoc".to_owned())
);
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("main.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(2),
Some(SourceLine(Some("main.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(3),
Some(SourceLine(Some("main.adoc".to_owned()), 3))
);
assert_eq!(
source_map.original_file_and_line(4),
Some(SourceLine(Some("main.adoc".to_owned()), 4))
);
}
#[test]
fn asciidoc_file_recognition() {
use super::is_asciidoc_file;
assert!(is_asciidoc_file("foo.asciidoc"));
assert!(is_asciidoc_file("foo.adoc"));
assert!(is_asciidoc_file("foo.ad"));
assert!(is_asciidoc_file("foo.asc"));
assert!(is_asciidoc_file("foo.txt"));
assert!(is_asciidoc_file("path/to/foo.adoc"));
assert!(is_asciidoc_file("a.b.adoc"));
assert!(!is_asciidoc_file("foo.csv"));
assert!(!is_asciidoc_file("foo.rb"));
assert!(!is_asciidoc_file("path/to/data.csv"));
assert!(!is_asciidoc_file("foo")); assert!(!is_asciidoc_file("foo.ADOC")); assert!(!is_asciidoc_file(".adoc")); }
#[test]
fn asciidoc_include_processes_nested_directives() {
let source = "include::outer.adoc[]";
let handler = InlineFileHandler::from_pairs([
("outer.adoc", "Top.\ninclude::inner.adoc[]"),
("inner.adoc", "Nested."),
]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, _source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(processed_source, "Top.\nNested.\n");
}
#[test]
fn non_asciidoc_include_merged_verbatim() {
let source = "include::data.csv[]";
let handler = InlineFileHandler::from_pairs([
("data.csv", "a,b\ninclude::inner.adoc[]"),
("inner.adoc", "SHOULD NOT APPEAR"),
]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, warnings, _includes) = preprocess(source, &parser);
assert_eq!(processed_source, "a,b\ninclude::inner.adoc[]\n");
assert!(warnings.is_empty());
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("data.csv".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(2),
Some(SourceLine(Some("data.csv".to_owned()), 2))
);
}
#[test]
fn non_asciidoc_include_in_body_tracks_header_state() {
let source = "Body.\n\ninclude::data.csv[]";
let handler = InlineFileHandler::from_pairs([("data.csv", "row one\n\nrow two")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, _source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(processed_source, "Body.\n\nrow one\n\nrow two\n");
}
#[test]
fn optional_include_dropped_silently() {
let source = "Before.\n\ninclude::missing.adoc[opts=optional]\n\nAfter.";
let handler = InlineFileHandler::from_pairs([("other.adoc", "Other content")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, warnings, _includes) = preprocess(source, &parser);
assert_eq!(processed_source, "Before.\n\n\nAfter.\n");
assert!(warnings.is_empty());
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("main.adoc".to_owned()), 1)) );
assert_eq!(
source_map.original_file_and_line(4),
Some(SourceLine(Some("main.adoc".to_owned()), 5)) );
}
fn parser_with_attribute_missing(mode: &str) -> Parser {
Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_intrinsic_attribute("attribute-missing", mode, ModificationContext::Anywhere)
.with_include_file_handler(InlineFileHandler::from_pairs([(
"partial.adoc",
"Included content.",
)]))
}
#[test]
fn include_target_with_missing_attribute_is_skipped_by_default() {
let source = "Before.\n\ninclude::{foodir}/partial.adoc[]\n\nAfter.";
let (processed_source, _source_map, warnings, _includes) =
preprocess(source, &parser_with_attribute_missing("skip"));
assert_eq!(
processed_source,
"Before.\n\nUnresolved directive in main.adoc - include::{foodir}/partial.adoc[]\n\nAfter.\n"
);
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].warning,
WarningType::IncludeFileNotFound("{foodir}/partial.adoc".to_owned())
);
}
#[test]
fn include_target_with_missing_attribute_is_dropped_under_drop_line() {
let source = "Before.\n\ninclude::{foodir}/partial.adoc[]\n\nAfter.";
let (processed_source, source_map, warnings, _includes) =
preprocess(source, &parser_with_attribute_missing("drop-line"));
assert_eq!(processed_source, "Before.\n\n\nAfter.\n");
assert!(warnings.is_empty());
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("main.adoc".to_owned()), 1)) );
assert_eq!(
source_map.original_file_and_line(4),
Some(SourceLine(Some("main.adoc".to_owned()), 5)) );
}
#[test]
fn include_target_with_missing_attribute_is_dropped_at_secure_safe_mode() {
let source = "Before.\n\ninclude::{foodir}/partial.adoc[]\n\nAfter.";
let parser = Parser::default()
.with_primary_file_name("main.adoc")
.with_intrinsic_attribute(
"attribute-missing",
"drop-line",
ModificationContext::Anywhere,
);
let (processed_source, _source_map, warnings, _includes) = preprocess(source, &parser);
assert_eq!(processed_source, "Before.\n\n\nAfter.\n");
assert!(warnings.is_empty());
}
#[test]
fn include_target_with_missing_attribute_warns_under_warn() {
let source = "Before.\n\ninclude::{foodir}/partial.adoc[]\n\nAfter.";
let (processed_source, _source_map, warnings, _includes) =
preprocess(source, &parser_with_attribute_missing("warn"));
assert_eq!(
processed_source,
"Before.\n\nUnresolved directive in main.adoc - include::{foodir}/partial.adoc[]\n\nAfter.\n"
);
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].warning,
WarningType::IncludeDroppedDueToMissingAttribute(
"include::{foodir}/partial.adoc[]".to_owned()
)
);
}
#[test]
fn optional_include_target_with_missing_attribute_is_dropped_silently_under_warn() {
let source = "Before.\n\ninclude::{foodir}/partial.adoc[opts=optional]\n\nAfter.";
let (processed_source, _source_map, warnings, _includes) =
preprocess(source, &parser_with_attribute_missing("warn"));
assert_eq!(processed_source, "Before.\n\n\nAfter.\n");
assert!(warnings.is_empty());
}
#[test]
fn include_target_with_missing_attribute_is_still_resolved_under_drop() {
let source = "Before.\n\ninclude::{foodir}partial.adoc[]\n\nAfter.";
let (processed_source, _source_map, warnings, _includes) =
preprocess(source, &parser_with_attribute_missing("drop"));
assert_eq!(processed_source, "Before.\n\nIncluded content.\n\nAfter.\n");
assert!(warnings.is_empty());
}
#[test]
fn escaped_include_directive() {
let source = "Before.\n\n\\include::partial.adoc[]\n\nAfter.";
let handler = InlineFileHandler::from_pairs([("partial.adoc", "SHOULD NOT APPEAR")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
"Before.\n\ninclude::partial.adoc[]\n\nAfter.\n"
);
assert_eq!(
source_map.original_file_and_line(3),
Some(SourceLine(Some("main.adoc".to_owned()), 3))
);
}
#[test]
fn escaped_include_directive_without_primary_file() {
let source = "\\include::partial.adoc[]";
let parser = Parser::default();
let (processed_source, _source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(processed_source, "include::partial.adoc[]\n");
}
#[test]
fn escaped_non_directive_is_unchanged() {
let source = "\\include::partial.adoc";
let parser = Parser::default().with_primary_file_name("main.adoc");
let (processed_source, _source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(processed_source, "\\include::partial.adoc\n");
}
#[test]
fn double_backslash_include_is_unchanged() {
let source = "\\\\include::partial.adoc[]";
let parser = Parser::default().with_primary_file_name("main.adoc");
let (processed_source, _source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(processed_source, "\\\\include::partial.adoc[]\n");
}
#[test]
fn multiple_includes_same_line() {
let source = "include::part1.adoc[] include::part2.adoc[]";
let handler = InlineFileHandler::from_pairs([
("part1.adoc", "First part"),
("part2.adoc", "Second part"),
]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
"include::part1.adoc[] include::part2.adoc[]\n"
);
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("main.adoc".to_owned()), 1))
);
}
#[test]
fn attribute_substitution_in_include_target() {
let source =
":fixturesdir: fixtures\n:ext: adoc\n\ninclude::{fixturesdir}/include-file.{ext}[]";
let handler = InlineFileHandler::from_pairs([(
"fixtures/include-file.adoc",
"This is included content.",
)]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
":fixturesdir: fixtures\n:ext: adoc\n\nThis is included content.\n"
);
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("main.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(2),
Some(SourceLine(Some("main.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(3),
Some(SourceLine(Some("main.adoc".to_owned()), 3))
);
assert_eq!(
source_map.original_file_and_line(4),
Some(SourceLine(Some("fixtures/include-file.adoc".to_owned()), 1))
);
}
#[test]
fn dropped_include_attrlist_does_not_leak_counter_state() {
let source = ":attribute-missing: warn\n\ninclude::{foodir}/partial.adoc[opts=optional,title={counter:n}]\n\nValue: {counter:n}.";
let mut parser = Parser::default();
let doc = parser.parse(source);
assert_eq!(parser.attribute_value("n"), InterpretedValue::Value("1"));
let rendered: Vec<_> = doc
.child_blocks()
.filter_map(|b| b.rendered_content())
.collect();
assert_eq!(rendered, vec!["Value: 1."]);
}
#[test]
fn include_target_with_brace_that_is_not_an_attribute_reference() {
let source = "include::{}partial.adoc[]";
let handler =
InlineFileHandler::from_pairs([("{}partial.adoc", "Brace in the file name.")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_intrinsic_attribute(
"attribute-missing",
"drop-line",
ModificationContext::Anywhere,
)
.with_include_file_handler(handler);
let (processed_source, _source_map, warnings, _includes) = preprocess(source, &parser);
assert_eq!(processed_source, "Brace in the file name.\n");
assert!(warnings.is_empty());
}
#[test]
fn multiple_attribute_substitution_in_include_target() {
let source = ":dir: chapters\n:filename: intro\n:extension: adoc\n\ninclude::{dir}/{filename}.{extension}[]";
let handler = InlineFileHandler::from_pairs([(
"chapters/intro.adoc",
"= Introduction\n\nWelcome to the guide.",
)]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
":dir: chapters\n:filename: intro\n:extension: adoc\n\n= Introduction\n\nWelcome to the guide.\n"
);
assert_eq!(
source_map.original_file_and_line(5),
Some(SourceLine(Some("chapters/intro.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(6),
Some(SourceLine(Some("chapters/intro.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(7),
Some(SourceLine(Some("chapters/intro.adoc".to_owned()), 3))
);
}
#[test]
fn missing_attribute_in_include_target() {
let source = ":fixturesdir: fixtures\n\ninclude::{fixturesdir}/include-file.{missingext}[]";
let handler = InlineFileHandler::from_pairs([
(
"fixtures/include-file.adoc",
"This content won't be included.",
),
("fixtures/include-file.", "This shouldn't match either."),
]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
":fixturesdir: fixtures\n\nUnresolved directive in main.adoc - include::{fixturesdir}/include-file.{missingext}[]\n"
);
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("main.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(2),
Some(SourceLine(Some("main.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(3),
Some(SourceLine(Some("main.adoc".to_owned()), 3))
);
}
#[test]
fn escaped_attribute_reference_in_include_target_drops_backslash() {
let source = "include::pre\\{missing}post.adoc[]";
let handler = InlineFileHandler::from_pairs([(
"pre{missing}post.adoc",
"Included via escaped literal target.",
)]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, _source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(processed_source, "Included via escaped literal target.\n");
}
#[test]
fn escaped_closing_brace_in_include_target_drops_backslash() {
let source = "include::pre{missing\\}mid\\{missing\\}post.adoc[]";
let handler = InlineFileHandler::from_pairs([(
"pre{missing}mid{missing}post.adoc",
"Included via escaped literal target.",
)]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, _source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(processed_source, "Included via escaped literal target.\n");
}
#[test]
fn attribute_substitution_with_nested_includes() {
let source = ":basedir: content\n:format: adoc\n\ninclude::{basedir}/main.{format}[]";
let handler = InlineFileHandler::from_pairs([
(
"content/main.adoc",
":partdir: parts\n\n== Main Chapter\n\ninclude::{partdir}/section1.{format}[]",
),
(
"parts/section1.adoc",
"=== Section 1\n\nSection content here.",
),
]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
":basedir: content\n:format: adoc\n\n:partdir: parts\n\n== Main Chapter\n\n=== Section 1\n\nSection content here.\n"
);
assert_eq!(
source_map.original_file_and_line(8),
Some(SourceLine(Some("parts/section1.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(9),
Some(SourceLine(Some("parts/section1.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(10),
Some(SourceLine(Some("parts/section1.adoc".to_owned()), 3))
);
}
#[test]
fn attribute_substitution_in_target_with_attrlist() {
let source = ":srcdir: examples\n:lang: java\n\ninclude::{srcdir}/hello.{lang}[tag=main]";
let handler = InlineFileHandler::from_pairs([(
"examples/hello.java",
"// tag::main[]\npublic class Hello {}\n// end::main[]",
)]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
":srcdir: examples\n:lang: java\n\npublic class Hello {}\n"
);
assert_eq!(
source_map.original_file_and_line(4),
Some(SourceLine(Some("examples/hello.java".to_owned()), 1))
);
}
#[test]
fn attribute_substitution_with_multiline_attribute() {
let source = ":longpath: very/long/path/to/some/ \\\nsubdirectory\n:ext: adoc\n\ninclude::{longpath}/file.{ext}[]";
let handler = InlineFileHandler::from_pairs([(
"very/long/path/to/some/ subdirectory/file.adoc",
"Multi-line attribute worked!",
)]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (processed_source, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
processed_source,
":longpath: very/long/path/to/some/ \\\nsubdirectory\n:ext: adoc\n\nMulti-line attribute worked!\n"
);
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(Some("main.adoc".to_owned()), 1))
);
assert_eq!(
source_map.original_file_and_line(2),
Some(SourceLine(Some("main.adoc".to_owned()), 2))
);
assert_eq!(
source_map.original_file_and_line(3),
Some(SourceLine(Some("main.adoc".to_owned()), 3))
);
assert_eq!(
source_map.original_file_and_line(4),
Some(SourceLine(Some("main.adoc".to_owned()), 4))
);
assert_eq!(
source_map.original_file_and_line(5),
Some(SourceLine(
Some("very/long/path/to/some/ subdirectory/file.adoc".to_owned()),
1
))
);
}
fn conditional_output(source: &str) -> String {
let parser = Parser::default();
let (output, _source_map, _warnings, _includes) = preprocess(source, &parser);
output
}
#[test]
fn ifdef_set_includes_content() {
assert_eq!(
conditional_output(":foo:\n\nifdef::foo[]\nkept\nendif::[]\n\ntail"),
":foo:\n\nkept\n\ntail\n"
);
}
#[test]
fn ifdef_unset_excludes_content() {
assert_eq!(
conditional_output("head\n\nifdef::foo[]\ndropped\nendif::[]\n\ntail"),
"head\n\n\ntail\n"
);
}
#[test]
fn ifndef_unset_includes_content() {
assert_eq!(
conditional_output("head\n\nifndef::foo[]\nkept\nendif::[]"),
"head\n\nkept\n"
);
}
#[test]
fn ifndef_set_excludes_content() {
assert_eq!(
conditional_output(":foo:\n\nifndef::foo[]\ndropped\nendif::[]\n\ntail"),
":foo:\n\n\ntail\n"
);
}
#[test]
fn ifdef_single_line_included() {
assert_eq!(
conditional_output(":foo:\n\nifdef::foo[kept on one line]"),
":foo:\n\nkept on one line\n"
);
}
#[test]
fn ifdef_single_line_excluded() {
assert_eq!(
conditional_output("head\n\nifdef::foo[dropped]\n\ntail"),
"head\n\n\ntail\n"
);
}
#[test]
fn comment_block_suppresses_conditional_directive() {
assert_eq!(
conditional_output("////\nifdef::foo[]\nhidden\nendif::[]\n////\n\ntail"),
"////\nifdef::foo[]\nhidden\nendif::[]\n////\n\ntail\n"
);
}
#[test]
fn longer_comment_delimiter_closes_only_on_exact_match() {
assert_eq!(
conditional_output("/////\nifdef::foo[x]\n////\nstill in comment\n/////\ntail"),
"/////\nifdef::foo[x]\n////\nstill in comment\n/////\ntail\n"
);
}
#[test]
fn comment_block_suppresses_include_expansion() {
let source = "////\ninclude::sub.adoc[]\n////\n\ntail";
let handler = InlineFileHandler::from_pairs([("sub.adoc", "Included.")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (output, _source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(output, "////\ninclude::sub.adoc[]\n////\n\ntail\n");
}
#[test]
fn comment_open_block_suppresses_conditional_directive() {
assert_eq!(
conditional_output("[comment]\n--\nfirst\nifdef::foo[dropped]\nlast\n--\n\ntail"),
"[comment]\n--\nfirst\nifdef::foo[dropped]\nlast\n--\n\ntail\n"
);
}
#[test]
fn comment_paragraph_suppresses_directive_after_first_line() {
assert_eq!(
conditional_output("[comment]\nfirst line\nifdef::foo[dropped]\n\ntail"),
"[comment]\nfirst line\nifdef::foo[dropped]\n\ntail\n"
);
}
#[test]
fn comment_style_carried_across_block_metadata() {
assert_eq!(
conditional_output(
"[comment]\n.title\n[[id]]\nfirst line\nifdef::foo[dropped]\n\ntail"
),
"[comment]\n.title\n[[id]]\nfirst line\nifdef::foo[dropped]\n\ntail\n"
);
}
#[test]
fn comment_style_cleared_after_comment_block_delimiter() {
assert_eq!(
conditional_output(":foo:\n\n[comment]\n////\nc\n////\n\nnext\nifdef::foo[VISIBLE]"),
":foo:\n\n[comment]\n////\nc\n////\n\nnext\nVISIBLE\n"
);
}
#[test]
fn later_style_overrides_comment_style() {
let source = "[comment]\n[source]\n----\ninclude::sub.adoc[]\n----\n";
let handler = InlineFileHandler::from_pairs([("sub.adoc", "Included.")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (output, _source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(output, "[comment]\n[source]\n----\nIncluded.\n----\n");
}
#[test]
fn non_style_attribute_list_keeps_comment_style() {
assert_eq!(
conditional_output("[comment]\n[.rolename]\nfirst line\nifdef::foo[dropped]\n\ntail"),
"[comment]\n[.rolename]\nfirst line\nifdef::foo[dropped]\n\ntail\n"
);
}
#[test]
fn comment_style_carried_across_metadata_before_open_block() {
assert_eq!(
conditional_output("[comment]\n.title\n--\nifdef::foo[dropped]\n--\n\ntail"),
"[comment]\n.title\n--\nifdef::foo[dropped]\n--\n\ntail\n"
);
}
#[test]
fn ifdef_single_line_attribute_entry_is_applied() {
assert_eq!(
conditional_output(":foo:\n\nifdef::foo[:bar: yes]\nifdef::bar[bar is set]"),
":foo:\n\n:bar: yes\nbar is set\n"
);
}
#[test]
fn single_line_attribute_entry_preserves_attribute_context() {
let source = ":flag:\n\nifdef::flag[:dir: sub]\n:file: {dir}/f\ninclude::{file}.adoc[]";
let handler = InlineFileHandler::from_pairs([("sub/f.adoc", "Included.")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (output, _source_map, warnings, _includes) = preprocess(source, &parser);
assert!(output.contains("Included."), "output was: {output:?}");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn ifdef_or_combinator() {
assert_eq!(
conditional_output(":b:\n\nifdef::a,b[]\nkept\nendif::[]"),
":b:\n\nkept\n"
);
assert_eq!(
conditional_output("head\n\nifdef::a,b[]\ndropped\nendif::[]"),
"head\n\n"
);
}
#[test]
fn ifdef_and_combinator() {
assert_eq!(
conditional_output(":a:\n:b:\n\nifdef::a+b[]\nkept\nendif::[]"),
":a:\n:b:\n\nkept\n"
);
assert_eq!(
conditional_output(":a:\n\nifdef::a+b[]\ndropped\nendif::[]"),
":a:\n\n"
);
}
#[test]
fn nested_conditionals() {
assert_eq!(
conditional_output(
":outer:\n:inner:\n\nifdef::outer[]\nA\nifdef::inner[]\nB\nendif::[]\nC\nendif::[]"
),
":outer:\n:inner:\n\nA\nB\nC\n"
);
}
#[test]
fn nested_conditional_inside_skipped_region_stays_skipped() {
assert_eq!(
conditional_output(
":inner:\n\nifdef::outer[]\nA\nifdef::inner[]\nB\nendif::[]\nC\nendif::[]\n\ntail"
),
":inner:\n\n\ntail\n"
);
}
#[test]
fn named_endif_matches_target() {
assert_eq!(
conditional_output(":foo:\n\nifdef::foo[]\nkept\nendif::foo[]"),
":foo:\n\nkept\n"
);
}
#[test]
fn ifeval_numeric_true() {
assert_eq!(
conditional_output("head\n\nifeval::[2 > 1]\nkept\nendif::[]"),
"head\n\nkept\n"
);
}
#[test]
fn ifeval_numeric_false() {
assert_eq!(
conditional_output("head\n\nifeval::[1 > 2]\ndropped\nendif::[]\n\ntail"),
"head\n\n\ntail\n"
);
}
#[test]
fn ifeval_attribute_reference() {
assert_eq!(
conditional_output("head\n\nifeval::[{sectnumlevels} == 3]\nkept\nendif::[]"),
"head\n\nkept\n"
);
}
#[test]
fn ifeval_string_comparison() {
assert_eq!(
conditional_output(
":backend: html5\n\nifeval::[\"{backend}\" == \"html5\"]\nkept\nendif::[]"
),
":backend: html5\n\nkept\n"
);
assert_eq!(
conditional_output(
":backend: docbook5\n\nifeval::[\"{backend}\" == \"html5\"]\ndropped\nendif::[]"
),
":backend: docbook5\n\n"
);
}
#[test]
fn ifeval_type_mismatch_is_false() {
assert_eq!(
conditional_output("head\n\nifeval::[1 < \"a\"]\ndropped\nendif::[]\n\ntail"),
"head\n\n\ntail\n"
);
}
#[test]
fn escaped_conditional_directive_emitted_literally() {
assert_eq!(
conditional_output("head\n\n\\ifdef::foo[]\n\ntail"),
"head\n\nifdef::foo[]\n\ntail\n"
);
}
#[test]
fn source_map_realigns_after_skipped_region() {
let source = "l1\n\nifdef::foo[]\ndropped\ndropped\nendif::[]\n\nl8";
let parser = Parser::default();
let (output, source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(output, "l1\n\n\nl8\n");
assert_eq!(
source_map.original_file_and_line(1),
Some(SourceLine(None, 1))
);
assert_eq!(
source_map.original_file_and_line(4),
Some(SourceLine(None, 8))
);
}
#[test]
fn ifeval_with_nonempty_target_is_malformed() {
assert_eq!(
conditional_output("ifeval::foo[1 == 1]\nkept\nendif::[]"),
"kept\n"
);
}
#[test]
fn ifdef_with_empty_target_is_malformed() {
assert_eq!(conditional_output("ifdef::[]\nkept\nendif::[]"), "kept\n");
}
#[test]
fn ifeval_malformed_expression_is_dropped() {
assert_eq!(
conditional_output("ifeval::[nonsense]\nkept\nendif::[]\n\ntail"),
"kept\n\ntail\n"
);
}
#[test]
fn ifeval_coerces_trailing_text_to_integer() {
assert_eq!(
conditional_output("ifeval::[3x == 3]\nkept\nendif::[]"),
"kept\n"
);
}
#[test]
fn ifeval_coerces_trailing_text_to_float() {
assert_eq!(
conditional_output("ifeval::[1.5x < 2]\nkept\nendif::[]"),
"kept\n"
);
}
#[test]
fn ifeval_float_and_mixed_equality() {
assert_eq!(
conditional_output("ifeval::[1.5 == 1.5]\nkept\nendif::[]"),
"kept\n"
);
assert_eq!(
conditional_output("ifeval::[2 == 2.0]\nkept\nendif::[]"),
"kept\n"
);
assert_eq!(
conditional_output("ifeval::[1 == \"a\"]\ndropped\nendif::[]\n\ntail"),
"\ntail\n"
);
}
#[test]
fn ifeval_float_and_string_ordering() {
assert_eq!(
conditional_output("ifeval::[1.5 < 2.5]\nkept\nendif::[]"),
"kept\n"
);
assert_eq!(
conditional_output("ifeval::[1 < 2.5]\nkept\nendif::[]"),
"kept\n"
);
assert_eq!(
conditional_output("ifeval::[\"a\" < \"b\"]\nkept\nendif::[]"),
"kept\n"
);
assert_eq!(
conditional_output("ifeval::[3 >= 3]\nkept\nendif::[]"),
"kept\n"
);
}
fn include_output(attrs: &str, content: &'static str) -> String {
let source = format!("include::sample.adoc[{attrs}]");
let handler = InlineFileHandler::from_pairs([("sample.adoc", content)]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
preprocess(&source, &parser).0
}
const NUMBERED: &str = "one\ntwo\nthree\nfour\nfive";
#[test]
fn lines_single_range() {
assert_eq!(include_output("lines=2..4", NUMBERED), "two\nthree\nfour\n");
}
#[test]
fn lines_single_line() {
assert_eq!(include_output("lines=3", NUMBERED), "three\n");
}
#[test]
fn lines_multiple_ranges_semicolon() {
assert_eq!(
include_output("lines=1..2;4..5", NUMBERED),
"one\ntwo\nfour\nfive\n"
);
}
#[test]
fn lines_multiple_ranges_comma() {
assert_eq!(
include_output("lines=\"1,3,5\"", NUMBERED),
"one\nthree\nfive\n"
);
}
#[test]
fn lines_open_ended_range() {
assert_eq!(
include_output("lines=3..-1", NUMBERED),
"three\nfour\nfive\n"
);
assert_eq!(include_output("lines=3..", NUMBERED), "three\nfour\nfive\n");
}
const TAGGED: &str =
"// tag::a[]\nalpha\n// tag::b[]\nbeta\n// end::b[]\ngamma\n// end::a[]\ndelta";
#[test]
fn tag_selects_region_and_drops_directives() {
assert_eq!(include_output("tag=a", TAGGED), "alpha\nbeta\ngamma\n");
}
#[test]
fn tag_selects_nested_region_only() {
assert_eq!(include_output("tag=b", TAGGED), "beta\n");
}
#[test]
fn tags_exclude_nested_region() {
assert_eq!(include_output("tags=a;!b", TAGGED), "alpha\ngamma\n");
}
#[test]
fn tags_double_wildcard_drops_directive_lines() {
assert_eq!(
include_output("tags=**", TAGGED),
"alpha\nbeta\ngamma\ndelta\n"
);
}
#[test]
fn tags_negated_wildcard_selects_untagged_only() {
assert_eq!(include_output("tags=!*", TAGGED), "delta\n");
}
#[test]
fn tags_single_wildcard_selects_all_regions() {
assert_eq!(include_output("tags=*", TAGGED), "alpha\nbeta\ngamma\n");
}
#[test]
fn indent_zero_strips_block_indent() {
let content = " def names\n @name.split ' '\n end";
assert_eq!(
include_output("indent=0", content),
"def names\n @name.split ' '\nend\n"
);
}
#[test]
fn indent_positive_reindents_block() {
let content = " def names\n @name.split ' '\n end";
assert_eq!(
include_output("indent=2", content),
" def names\n @name.split ' '\n end\n"
);
}
#[test]
fn indent_ignored_when_a_line_is_flush_left() {
let content = "def names\n @name.split ' '\nend";
assert_eq!(
include_output("indent=4", content),
"def names\n @name.split ' '\nend\n"
);
}
#[test]
fn leveloffset_wraps_included_content() {
assert_eq!(
include_output("leveloffset=+1", "== Chapter\n\nBody."),
":leveloffset: +1\n\n== Chapter\n\nBody.\n\n:leveloffset!:\n"
);
}
#[test]
fn uri_include_falls_back_to_link_without_allow_uri_read() {
let source = "include::https://example.org/frag.adoc[]";
let handler =
InlineFileHandler::from_pairs([("https://example.org/frag.adoc", "SHOULD NOT APPEAR")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (output, _source_map, warnings, _includes) = preprocess(source, &parser);
assert_eq!(output, "link:https://example.org/frag.adoc[role=include]\n");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn uri_include_with_space_falls_back_to_passthrough_link() {
let source = "include::https://example.org/no such file.adoc[]";
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc");
let (output, _source_map, warnings, _includes) = preprocess(source, &parser);
assert_eq!(
output,
"link:pass:c[https://example.org/no such file.adoc][role=include]\n"
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn uri_include_resolved_with_allow_uri_read() {
let source = "include::https://example.org/frag.adoc[]";
let handler =
InlineFileHandler::from_pairs([("https://example.org/frag.adoc", "Remote content.")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_intrinsic_attribute("allow-uri-read", "", ModificationContext::Anywhere)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (output, _source_map, warnings, _includes) = preprocess(source, &parser);
assert_eq!(output, "Remote content.\n");
assert!(warnings.is_empty());
}
#[test]
fn encoding_utf8_produces_no_warning() {
for encoding in ["utf-8", "UTF-8", "utf8", "UTF8"] {
let output = include_output(&format!("encoding={encoding}"), "Content.");
assert_eq!(output, "Content.\n");
}
let source = "include::sample.adoc[encoding=utf-8]";
let handler = InlineFileHandler::from_pairs([("sample.adoc", "Content.")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (_output, _source_map, warnings, _includes) = preprocess(source, &parser);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn non_utf8_encoding_warns_but_still_includes() {
let source = "include::sample.adoc[encoding=iso-8859-1]";
let handler = InlineFileHandler::from_pairs([("sample.adoc", "Résumé.")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (output, _source_map, warnings, _includes) = preprocess(source, &parser);
assert_eq!(output, "Résumé.\n");
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].warning,
WarningType::NonUtf8IncludeEncoding("iso-8859-1".to_owned())
);
assert_eq!(
&output[warnings[0].offset..warnings[0].offset + warnings[0].len],
"Résumé."
);
}
#[test]
fn transcoded_include_suppresses_encoding_warning() {
#[derive(Debug)]
struct TranscodingFileHandler;
impl IncludeFileHandler for TranscodingFileHandler {
fn resolve_target<'src>(
&self,
_source: Option<&str>,
_target: &str,
_attrlist: &Attrlist<'src>,
_parser: &Parser,
) -> IncludeResolution {
IncludeResolution::Found(IncludeContent::transcoded("Résumé."))
}
}
let source = "include::sample.adoc[encoding=iso-8859-1]";
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(TranscodingFileHandler);
let (output, _source_map, warnings, _includes) = preprocess(source, &parser);
assert_eq!(output, "Résumé.\n");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn leveloffset_restores_previous_offset() {
let source = ":leveloffset: 1\n\ninclude::sample.adoc[leveloffset=+1]";
let handler = InlineFileHandler::from_pairs([("sample.adoc", "== Chapter")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (output, _source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
output,
":leveloffset: 1\n\n:leveloffset: +1\n\n== Chapter\n\n:leveloffset: 1\n"
);
}
#[test]
fn leveloffset_restore_ignores_offset_set_within_include() {
let source = "include::sample.adoc[leveloffset=+1]";
let handler =
InlineFileHandler::from_pairs([("sample.adoc", ":leveloffset: 2\n\n== Chapter")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (output, _source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(
output,
":leveloffset: +1\n\n:leveloffset: 2\n\n== Chapter\n\n:leveloffset!:\n"
);
}
#[test]
fn tag_filtering_edge_cases() {
assert_eq!(
include_output("tags=foo;!", "// tag::foo[]\nx\n// end::foo[]"),
"x\n"
);
assert_eq!(
include_output("tags=!foo;foo", "// tag::foo[]\nx\n// end::foo[]"),
"x\n"
);
assert_eq!(
include_output("tags=**;*", "// tag::a[]\nx\n// end::a[]\ny"),
"x\ny\n"
);
assert_eq!(
include_output(
"tags=!**;!foo",
"before\n// tag::foo[]\nf\n// end::foo[]\nafter"
),
""
);
assert_eq!(
include_output("tag=x", "<!-- tag::x[] -->\nc\n<!-- end::x[] -->"),
"c\n"
);
assert_eq!(
include_output("tag=x", "// tag::x[]\ntag::x[]y\n// end::x[]"),
"tag::x[]y\n"
);
}
#[test]
fn indent_edge_cases() {
assert_eq!(
include_output("indent=-1", " a\n b"),
" a\n b\n"
);
assert_eq!(include_output("indent=0", ""), "");
assert_eq!(include_output("indent=0", "\n\n"), "\n\n");
assert_eq!(include_output("indent=2", " a\n\n b"), " a\n\n b\n");
}
#[test]
fn indent_with_tabsize_and_untabbed_line() {
let source = "----\ninclude::code.rb[indent=0]\n----";
let handler = InlineFileHandler::from_pairs([("code.rb", "\ta\nno-tab\n\tb")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_intrinsic_attribute("tabsize", "4", ModificationContext::Anywhere)
.with_primary_file_name("main.adoc")
.with_include_file_handler(handler);
let (output, _source_map, _warnings, _includes) = preprocess(source, &parser);
assert_eq!(output, "----\n a\nno-tab\n b\n----\n");
}
#[test]
fn cyclic_include_is_bounded_by_max_include_depth() {
let handler = InlineFileHandler::from_pairs([("loop.adoc", "include::loop.adoc[]")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_include_file_handler(handler);
let (output, _source_map, warnings, _includes) =
preprocess("include::loop.adoc[]", &parser);
assert_eq!(output, "include::loop.adoc[]\n");
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].warning,
WarningType::MaxIncludeDepthExceeded(64)
);
}
#[test]
fn max_include_depth_set_with_no_value_disables_includes() {
let handler = InlineFileHandler::from_pairs([("shared.adoc", "shared content")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_intrinsic_attribute_bool("max-include-depth", true, ModificationContext::ApiOnly)
.with_include_file_handler(handler);
let (output, _source_map, warnings, _includes) =
preprocess("include::shared.adoc[]", &parser);
assert_eq!(output, "include::shared.adoc[]\n");
assert!(warnings.is_empty());
}
#[test]
fn max_include_depth_unset_falls_back_to_default() {
let handler = InlineFileHandler::from_pairs([("shared.adoc", "shared content")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_intrinsic_attribute_bool("max-include-depth", false, ModificationContext::ApiOnly)
.with_include_file_handler(handler);
let (output, _source_map, warnings, _includes) =
preprocess("include::shared.adoc[]", &parser);
assert_eq!(output, "shared content\n");
assert!(warnings.is_empty());
}
#[test]
fn depth_request_exceeding_max_include_depth_is_clamped() {
let handler = InlineFileHandler::from_pairs([
("a.adoc", "include::b.adoc[]"),
("b.adoc", "include::c.adoc[]"),
("c.adoc", "content of c"),
]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_intrinsic_attribute("max-include-depth", "2", ModificationContext::ApiOnly)
.with_include_file_handler(handler);
let (output, _source_map, warnings, _includes) =
preprocess("include::a.adoc[depth=10]", &parser);
assert_eq!(output, "include::c.adoc[]\n");
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].warning, WarningType::MaxIncludeDepthExceeded(2));
}
#[test]
fn huge_max_include_depth_acts_as_large_limit() {
for value in ["9223372036854775807", "9223372036854775808"] {
let handler = InlineFileHandler::from_pairs([("shared.adoc", "shared content")]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_intrinsic_attribute("max-include-depth", value, ModificationContext::ApiOnly)
.with_include_file_handler(handler);
let (output, _source_map, warnings, _includes) =
preprocess("include::shared.adoc[]", &parser);
assert_eq!(output, "shared content\n");
assert!(warnings.is_empty());
}
}
#[test]
fn huge_depth_request_is_clamped_not_wrapped() {
for value in ["9223372036854775807", "9223372036854775808"] {
let handler = InlineFileHandler::from_pairs([
("a.adoc", "include::b.adoc[]"),
("b.adoc", "content of b"),
]);
let parser = Parser::default()
.with_safe_mode(SafeMode::Server)
.with_intrinsic_attribute("max-include-depth", "1", ModificationContext::ApiOnly)
.with_include_file_handler(handler);
let (output, _source_map, warnings, _includes) =
preprocess(&format!("include::a.adoc[depth={value}]"), &parser);
assert_eq!(output, "include::b.adoc[]\n");
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].warning, WarningType::MaxIncludeDepthExceeded(1));
}
}
#[test]
fn ruby_to_i_saturates_on_overflow() {
use super::ruby_to_i;
assert_eq!(ruby_to_i("42"), 42);
assert_eq!(ruby_to_i("42abc"), 42);
assert_eq!(ruby_to_i("9223372036854775808"), i64::MAX);
assert_eq!(ruby_to_i("-9223372036854775809"), i64::MIN);
assert_eq!(ruby_to_i("abc"), 0);
assert_eq!(ruby_to_i("-"), 0);
assert_eq!(ruby_to_i(""), 0);
}
mod include_registry {
use super::super::{include_catalog_key, is_full_include};
use crate::{
Span,
attributes::{Attrlist, AttrlistContext},
tests::prelude::*,
};
#[test]
fn catalog_key_strips_the_asciidoc_extension() {
assert_eq!(include_catalog_key("other-chapters.adoc"), "other-chapters");
assert_eq!(include_catalog_key("part1/tigers.adoc"), "part1/tigers");
assert_eq!(include_catalog_key("../section-a.adoc"), "../section-a");
assert_eq!(include_catalog_key("notes.txt"), "notes");
assert_eq!(
include_catalog_key("using-.net-web-services.adoc"),
"using-.net-web-services"
);
assert_eq!(include_catalog_key("no-extension"), "no-extension");
assert_eq!(include_catalog_key(".adoc"), ".adoc");
}
fn is_full(attrlist_text: &str) -> bool {
let parser = Parser::default();
let span = Span::new(attrlist_text);
let attrlist = Attrlist::parse(span, &parser, AttrlistContext::Inline)
.item
.item;
is_full_include(&attrlist)
}
#[test]
fn an_unfiltered_include_is_full() {
assert!(is_full(""));
}
#[test]
fn a_lines_selection_is_partial() {
assert!(!is_full("lines=1..5"));
assert!(is_full("lines="));
}
#[test]
fn a_tag_selection_is_partial_unless_it_selects_everything() {
assert!(!is_full("tags=ch2"));
assert!(!is_full("tag=ch2"));
assert!(!is_full("tags=ch2;ch3"));
assert!(is_full("tags=**"));
assert!(is_full("tag=**"));
}
#[test]
fn lines_takes_precedence_over_a_whole_file_tag_selection() {
assert!(!is_full("lines=1..2,tags=**"));
}
}
}