mod peg;
use peg::conditional_parser;
use std::path::Path;
use crate::{
DocumentAttributes, Location,
error::{Error, SourceLocation},
model::{HEADER, Position, substitute},
};
#[derive(Debug)]
pub(crate) struct Conditional<'input> {
condition: Condition<'input>,
content: Option<&'input str>,
}
#[derive(Debug)]
enum Condition<'input> {
Ifdef(AttributeCondition<'input>),
Ifndef(AttributeCondition<'input>),
Ifeval(EvalCondition),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Operation {
Or,
And,
}
#[derive(Debug, PartialEq)]
struct AttributeCondition<'input> {
attributes: Vec<&'input str>,
operation: Option<Operation>,
}
#[derive(Debug)]
struct EvalCondition {
left: EvalValue,
operator: Operator,
right: EvalValue,
}
#[derive(Debug, Clone, PartialEq, PartialOrd)]
pub(crate) enum EvalValue {
String(String),
Number(f64),
Boolean(bool),
}
#[derive(Debug, PartialEq)]
pub(crate) enum Operator {
Equal,
NotEqual,
LessThan,
GreaterThan,
LessThanOrEqual,
GreaterThanOrEqual,
}
#[derive(Debug)]
pub(crate) struct Endif<'input> {
condition: Option<AttributeCondition<'input>>,
}
impl<'input> AttributeCondition<'input> {
fn new(attributes: Vec<&'input str>, operation: Option<Operation>) -> Self {
Self {
attributes,
operation,
}
}
fn matches(&self, other: &Self) -> bool {
self.operation == other.operation
&& self.attributes.len() == other.attributes.len()
&& self
.attributes
.iter()
.zip(&other.attributes)
.all(|(left, right)| left.eq_ignore_ascii_case(right))
}
}
impl Conditional<'_> {
fn evaluate_attributes(
attrs: &[&str],
operation: Option<&Operation>,
doc_attrs: &DocumentAttributes,
negate: bool,
) -> bool {
if attrs.is_empty() {
tracing::warn!("no attributes in conditional directive but expecting at least one");
return !negate; }
let result = match operation {
Some(Operation::Or) => attrs.iter().any(|attr| doc_attrs.contains_key(attr)),
_ => attrs.iter().all(|attr| doc_attrs.contains_key(attr)),
};
if negate { !result } else { result }
}
pub(crate) fn is_true(
&self,
attributes: &DocumentAttributes,
content: &mut String,
line_number: usize,
current_offset: usize,
file_parent: Option<&Path>,
) -> Result<bool, Error> {
let is_true = match &self.condition {
Condition::Ifdef(condition) => Self::evaluate_attributes(
&condition.attributes,
condition.operation.as_ref(),
attributes,
false,
),
Condition::Ifndef(condition) => Self::evaluate_attributes(
&condition.attributes,
condition.operation.as_ref(),
attributes,
true,
),
Condition::Ifeval(ifeval) => {
ifeval.evaluate(attributes, line_number, current_offset, file_parent)?
}
};
if is_true && let Some(if_content) = &self.content {
content.push_str(if_content);
}
Ok(is_true)
}
pub(crate) fn has_inline_content(&self) -> bool {
self.content.is_some()
}
fn attribute_condition(&self) -> Option<&AttributeCondition<'_>> {
match &self.condition {
Condition::Ifdef(condition) | Condition::Ifndef(condition) => Some(condition),
Condition::Ifeval(_) => None,
}
}
}
impl Endif<'_> {
#[tracing::instrument(level = "trace", skip_all)]
pub(crate) fn closes(&self, conditional: &Conditional<'_>) -> bool {
match (&self.condition, conditional.attribute_condition()) {
(None, _) => true,
(Some(endif), Some(opening)) => endif.matches(opening),
(Some(_), None) => false,
}
}
}
impl EvalCondition {
#[tracing::instrument(level = "trace", skip_all, fields(line_number = line_number, current_offset = current_offset))]
fn evaluate(
&self,
attributes: &DocumentAttributes,
line_number: usize,
current_offset: usize,
file_parent: Option<&Path>,
) -> Result<bool, Error> {
let left = self.left.convert(attributes);
let right = self.right.convert(attributes);
match (&left, &right) {
(EvalValue::Number(_), EvalValue::Number(_))
| (EvalValue::Boolean(_), EvalValue::Boolean(_))
| (EvalValue::String(_), EvalValue::String(_)) => {}
_ => {
tracing::error!("cannot compare different types of values in ifeval directive");
return Err(Error::InvalidIfEvalDirectiveMismatchedTypes(Box::new(
SourceLocation {
file: file_parent.map(Path::to_path_buf),
location: Location::point(Position::from_line_col(line_number, 1)),
},
)));
}
}
Ok(match self.operator {
Operator::Equal => left == right,
Operator::NotEqual => left != right,
Operator::LessThan => left < right,
Operator::GreaterThan => left > right,
Operator::LessThanOrEqual => left <= right,
Operator::GreaterThanOrEqual => left >= right,
})
}
}
impl EvalValue {
#[tracing::instrument(level = "trace", skip_all)]
fn convert(&self, attributes: &DocumentAttributes) -> Self {
match self {
EvalValue::String(s) => {
let s = substitute(s, HEADER, attributes);
if let Some(value) = Self::strip_quotes(&s) {
return EvalValue::String(value.to_string());
}
s.parse::<bool>()
.map(EvalValue::Boolean)
.or_else(|_| s.parse::<f64>().map(EvalValue::Number))
.or_else(|_| evalexpr::eval_float(&s).map(EvalValue::Number))
.or_else(|_| {
#[allow(clippy::cast_precision_loss)]
evalexpr::eval_int(&s)
.map(|v| v as f64)
.map(EvalValue::Number)
})
.unwrap_or_else(|_| EvalValue::String(s.into_owned()))
}
value @ (EvalValue::Number(_) | EvalValue::Boolean(_)) => value.clone(),
}
}
#[tracing::instrument(level = "trace", skip_all)]
fn strip_quotes(s: &str) -> Option<&str> {
s.strip_prefix('\'')
.and_then(|value| value.strip_suffix('\''))
.or_else(|| s.strip_prefix('"')?.strip_suffix('"'))
}
}
#[tracing::instrument(level = "trace", skip_all, fields(line_number = line_number, current_offset = current_offset))]
pub(crate) fn parse_line<'input>(
line: &'input str,
line_number: usize,
current_offset: usize,
file_parent: Option<&Path>,
) -> Result<Conditional<'input>, Error> {
conditional_parser::conditional(line).map_err(|_| {
tracing::error!(
line_number,
current_offset,
"failed to parse conditional directive"
);
Error::InvalidConditionalDirective(Box::new(SourceLocation {
file: file_parent.map(Path::to_path_buf),
location: Location::point(Position::from_line_col(line_number, 1)),
}))
})
}
#[tracing::instrument(level = "trace", skip_all, fields(line_number = line_number, current_offset = current_offset))]
pub(crate) fn parse_endif<'input>(
line: &'input str,
line_number: usize,
current_offset: usize,
file_parent: Option<&Path>,
) -> Result<Endif<'input>, Error> {
conditional_parser::endif(line).map_err(|_| {
tracing::error!(
line_number,
current_offset,
"failed to parse endif directive"
);
Error::InvalidConditionalDirective(Box::new(SourceLocation {
file: file_parent.map(Path::to_path_buf),
location: Location::point(Position::from_line_col(line_number, 1)),
}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ifdef_single_attribute() -> Result<(), Error> {
let line = "ifdef::attribute[]";
let conditional = parse_line(line, 1, 0, None)?;
assert!(
matches!(conditional, Conditional { condition: Condition::Ifdef(condition), content: None } if condition.attributes == vec!["attribute"] && condition.operation.is_none())
);
Ok(())
}
#[test]
fn test_ifdef_or_attributes() -> Result<(), Error> {
let line = "ifdef::attr1,attr2[]";
let conditional = parse_line(line, 1, 0, None)?;
assert!(
matches!(conditional, Conditional { condition: Condition::Ifdef(condition), content: None } if condition.attributes == vec!["attr1", "attr2"] && condition.operation == Some(Operation::Or))
);
Ok(())
}
#[test]
fn test_ifdef_and_attributes() -> Result<(), Error> {
let line = "ifdef::attr1+attr2[]";
let conditional = parse_line(line, 1, 0, None)?;
assert!(
matches!(conditional, Conditional { condition: Condition::Ifdef(condition), content: None } if condition.attributes == vec!["attr1", "attr2"] && condition.operation == Some(Operation::And))
);
Ok(())
}
#[test]
fn test_ifdef_three_or_attributes() -> Result<(), Error> {
let conditional = parse_line("ifdef::attr1,attr2,attr3[]", 1, 0, None)?;
assert!(
matches!(conditional, Conditional { condition: Condition::Ifdef(condition), .. } if condition.attributes == vec!["attr1", "attr2", "attr3"] && condition.operation == Some(Operation::Or))
);
Ok(())
}
#[test]
fn test_ifdef_three_and_attributes() -> Result<(), Error> {
let conditional = parse_line("ifdef::attr1+attr2+attr3[]", 1, 0, None)?;
assert!(
matches!(conditional, Conditional { condition: Condition::Ifdef(condition), .. } if condition.attributes == vec!["attr1", "attr2", "attr3"] && condition.operation == Some(Operation::And))
);
Ok(())
}
#[test]
fn test_ifdef_rejects_mixed_attribute_operators() {
assert!(matches!(
parse_line("ifdef::attr1,attr2+attr3[]", 1, 0, None),
Err(Error::InvalidConditionalDirective(..))
));
}
#[test]
fn test_ifndef() -> Result<(), Error> {
let line = "ifndef::attribute[]";
let conditional = parse_line(line, 1, 0, None)?;
assert!(
matches!(conditional, Conditional { condition: Condition::Ifndef(condition), content: None } if condition.attributes == vec!["attribute"] && condition.operation.is_none())
);
Ok(())
}
#[test]
fn effective_defaults_are_defined_for_conditionals() -> Result<(), Error> {
let attributes = DocumentAttributes::default();
let defined = parse_line("ifdef::max-include-depth[]", 1, 0, None)?;
let undefined = parse_line("ifndef::max-include-depth[]", 1, 0, None)?;
assert!(defined.is_true(&attributes, &mut String::new(), 1, 0, None)?);
assert!(!undefined.is_true(&attributes, &mut String::new(), 1, 0, None)?);
Ok(())
}
#[test]
fn ifeval_uses_typed_default_text_presentation() -> Result<(), Error> {
let conditional = parse_line("ifeval::[{max-include-depth} == 64]", 1, 0, None)?;
assert!(conditional.is_true(
&DocumentAttributes::default(),
&mut String::new(),
1,
0,
None
)?);
let mut attributes = DocumentAttributes::default();
assert!(
attributes
.set("max-include-depth".into(), "03".into())
.is_ok()
);
let explicit_condition = parse_line("ifeval::[{max-include-depth} == 3]", 1, 0, None)?;
assert!(explicit_condition.is_true(&attributes, &mut String::new(), 1, 0, None)?);
Ok(())
}
#[test]
fn test_ifeval_simple_math() -> Result<(), Error> {
let line = "ifeval::[1 + 1 == 2]";
let conditional = parse_line(line, 1, 0, None)?;
assert!(
matches!(&conditional, Conditional { condition: Condition::Ifeval(ifeval), content: None } if ifeval.left == EvalValue::String("1 + 1".to_string()) && ifeval.operator == Operator::Equal && ifeval.right == EvalValue::String("2".to_string()))
);
assert!(conditional.is_true(
&DocumentAttributes::default(),
&mut String::new(),
1,
0,
None
)?);
Ok(())
}
#[test]
fn test_ifeval_str_equality() -> Result<(), Error> {
let line = "ifeval::['ASDF' == ASDF]";
let conditional = parse_line(line, 1, 0, None)?;
assert!(
matches!(&conditional, Conditional { condition: Condition::Ifeval(ifeval), content: None } if ifeval.left == EvalValue::String("'ASDF'".to_string()) && ifeval.operator == Operator::Equal && ifeval.right == EvalValue::String("ASDF".to_string()))
);
assert!(conditional.is_true(
&DocumentAttributes::default(),
&mut String::new(),
1,
0,
None
)?);
Ok(())
}
#[test]
fn test_ifeval_greater_than_string_vs_number() -> Result<(), Error> {
let line = "ifeval::['1+1' >= 2]";
let conditional = parse_line(line, 1, 0, None)?;
assert!(
matches!(&conditional, Conditional { condition: Condition::Ifeval(ifeval), content: None } if ifeval.left == EvalValue::String("'1+1'".to_string()) && ifeval.operator == Operator::GreaterThanOrEqual && ifeval.right == EvalValue::String("2".to_string()))
);
assert!(matches!(
conditional.is_true(
&DocumentAttributes::default(),
&mut String::new(),
1,
0,
None
),
Err(Error::InvalidIfEvalDirectiveMismatchedTypes(..))
));
Ok(())
}
#[test]
fn quoted_ifeval_rejects_invalid_directives_without_panicking() {
for line in [
"ifeval::[\"<tag>\" \"<tag>\"]",
"ifeval::[\"<tag>\" ==]",
"ifeval::[\"<tag>\" == \"<tag>\"] trailing",
"ifeval::[\"<tag> == \"<tag>\"]",
"ifeval::[' == ']",
] {
assert!(
matches!(
parse_line(line, 1, 0, None),
Err(Error::InvalidConditionalDirective(..))
),
"unexpected result for {line:?}"
);
}
}
#[test]
fn quoted_ifeval_retains_mismatched_type_errors() -> Result<(), Error> {
for line in ["ifeval::[\"2\" > 1]", "ifeval::['true' == true]"] {
let conditional = parse_line(line, 1, 0, None)?;
assert!(matches!(
conditional.is_true(
&DocumentAttributes::default(),
&mut String::new(),
1,
0,
None
),
Err(Error::InvalidIfEvalDirectiveMismatchedTypes(..))
));
}
Ok(())
}
#[test]
fn test_ifdef_with_content() -> Result<(), Error> {
let line = "ifdef::attribute[Some content here]";
let conditional = parse_line(line, 1, 0, None)?;
assert!(
matches!(conditional, Conditional { condition: Condition::Ifdef(condition), content: Some(content) } if condition.attributes == vec!["attribute"] && condition.operation.is_none() && content == "Some content here")
);
Ok(())
}
#[test]
fn test_endif() -> Result<(), Error> {
let line = "endif::attribute[]";
let endif = parse_endif(line, 1, 0, None)?;
assert!(matches!(
endif.condition,
Some(AttributeCondition {
attributes,
operation: None,
}) if attributes == ["attribute"]
));
Ok(())
}
#[test]
fn test_endif_no_attribute() -> Result<(), Error> {
let line = "endif::[]";
let endif = parse_endif(line, 1, 0, None)?;
assert_eq!(endif.condition, None);
Ok(())
}
#[test]
fn test_endif_matches_complete_condition_case_insensitively() -> Result<(), Error> {
let conditional = parse_line("ifdef::Backend-PDF,Backend-DocBook5[]", 1, 0, None)?;
let endif = parse_endif("endif::backend-pdf,backend-docbook5[]", 2, 0, None)?;
assert!(endif.closes(&conditional));
Ok(())
}
#[test]
fn test_endif_rejects_partial_or_reordered_condition() -> Result<(), Error> {
let conditional = parse_line("ifdef::backend-pdf,backend-docbook5[]", 1, 0, None)?;
let partial = parse_endif("endif::backend-pdf[]", 2, 0, None)?;
let reordered = parse_endif("endif::backend-docbook5,backend-pdf[]", 2, 0, None)?;
let different_operation = parse_endif("endif::backend-pdf+backend-docbook5[]", 2, 0, None)?;
assert!(!partial.closes(&conditional));
assert!(!reordered.closes(&conditional));
assert!(!different_operation.closes(&conditional));
Ok(())
}
}