use bumpalo::Bump;
use std::{
borrow::Cow,
cell::{Cell, RefCell},
mem::take,
ops::Range,
rc::Rc,
};
use peg::parser;
use regex_syntax::is_word_character;
use crate::{
DocumentAttributes, Error, Location, Pass, PassthroughKind, Position, SourceLocation,
Substitution, Warning, WarningKind,
grammar::{LineMap, inlines::check_constrained_opening_boundary},
model::substitution::parse_substitution,
};
#[cfg(test)]
use crate::AttributeValue;
#[derive(Debug)]
pub(crate) struct InlinePreprocessorParserState<'a> {
pub(crate) pass_found_count: Cell<usize>,
pub(crate) passthroughs: RefCell<Vec<Pass<'a>>>,
pub(crate) current_offset: Cell<usize>,
pub(crate) line_map: Rc<LineMap>,
pub(crate) full_input: &'a str,
pub(crate) arena: &'a Bump,
pub(crate) source_map: RefCell<SourceMap>,
pub(crate) input: RefCell<&'a str>,
pub(crate) substring_start_offset: Cell<usize>,
pub(crate) warnings: RefCell<Vec<Warning>>,
pub(crate) macros_enabled: bool,
pub(crate) attributes_enabled: bool,
pub(crate) defer_monospace: bool,
pub(crate) attribute_value_ranges: Vec<Range<usize>>,
}
impl<'a> InlinePreprocessorParserState<'a> {
pub(crate) fn new(
input: &'a str,
line_map: Rc<LineMap>,
full_input: &'a str,
arena: &'a Bump,
macros_enabled: bool,
attributes_enabled: bool,
) -> Self {
Self {
pass_found_count: Cell::new(0),
passthroughs: RefCell::new(Vec::new()),
current_offset: Cell::new(0),
line_map,
full_input,
arena,
source_map: RefCell::new(SourceMap::default()),
input: RefCell::new(input),
substring_start_offset: Cell::new(0),
warnings: RefCell::new(Vec::new()),
macros_enabled,
attributes_enabled,
defer_monospace: true,
attribute_value_ranges: Vec::new(),
}
}
pub(crate) fn new_all_enabled(
input: &'a str,
line_map: Rc<LineMap>,
full_input: &'a str,
arena: &'a Bump,
) -> Self {
Self::new(input, line_map, full_input, arena, true, true)
}
pub(crate) fn set_initial_position(&mut self, _location: &Location, absolute_offset: usize) {
self.substring_start_offset.set(absolute_offset);
self.current_offset.set(absolute_offset);
}
fn get_position(&self) -> Position {
self.line_map
.offset_to_position(self.current_offset.get(), self.full_input)
}
fn get_offset(&self) -> usize {
self.current_offset.get()
}
fn advance(&self, s: &str) {
self.current_offset.set(self.current_offset.get() + s.len());
}
fn advance_by(&self, n: usize) {
self.current_offset.set(self.current_offset.get() + n);
}
pub(crate) fn add_warning(&self, warning: Warning) {
let mut warnings = self.warnings.borrow_mut();
if !warnings.contains(&warning) {
warnings.push(warning);
}
}
pub(crate) fn source_location_for(
&self,
start_offset: usize,
end_offset: usize,
) -> SourceLocation {
let start = self
.line_map
.offset_to_position(start_offset, self.full_input);
let end = self
.line_map
.offset_to_position(end_offset, self.full_input);
SourceLocation {
file: None,
location: Location {
absolute_start: start_offset,
absolute_end: end_offset,
start,
end,
},
}
}
pub(crate) fn drain_warnings(&self) -> Vec<Warning> {
take(&mut *self.warnings.borrow_mut())
}
fn parse_pass_macro_parts(full: &str) -> (&str, &str, Vec<Substitution>) {
let subs_end = full[5..].find('[').unwrap_or(0);
let subs_str = &full[5..5 + subs_end];
let content = &full[5 + subs_end + 1..full.len() - 1];
let substitutions = if subs_str.is_empty() {
Vec::new()
} else {
subs_str
.split(',')
.filter_map(|s| parse_substitution(s.trim()))
.collect()
};
(subs_str, content, substitutions)
}
fn expand_escaped_passthrough(
&self,
source: &'a str,
document_attributes: &DocumentAttributes<'a>,
extract_single_passthroughs: bool,
) -> String {
let escape_start = self.get_offset();
let source_start = escape_start + 1;
let expanded = if extract_single_passthroughs {
self.extract_single_passthroughs(source, source_start, document_attributes)
} else {
let mut output = String::with_capacity(source.len());
self.append_unprotected_text(&mut output, source, source_start, document_attributes);
output
};
self.source_map.borrow_mut().add_replacement(
escape_start,
source_start,
0,
ProcessedKind::Escape,
);
self.advance_by(source.len() + 1);
expanded
}
fn extract_single_passthroughs(
&self,
source: &'a str,
source_start: usize,
document_attributes: &DocumentAttributes<'a>,
) -> String {
let mut output = String::with_capacity(source.len());
let mut copied_until = 0;
let mut search_from = 0;
while let Some(relative_start) = source[search_from..].find('+') {
let start = search_from + relative_start;
let previous = if start == 0 {
self.full_input[..source_start.saturating_sub(1)]
.chars()
.next_back()
} else {
source[..start].chars().next_back()
};
if !constrained_passthrough_start(previous) {
search_from = start + 1;
continue;
}
let Some(end) = constrained_passthrough_end(source, start) else {
search_from = start + 1;
continue;
};
self.append_unprotected_text(
&mut output,
&source[copied_until..start],
source_start + copied_until,
document_attributes,
);
let content = &source[start + 1..end];
let location = self.location_from_offsets(source_start + start, source_start + end + 1);
self.passthroughs.borrow_mut().push(Pass {
attribute_fragments: Box::default(),
text: Some(content),
substitutions: vec![Substitution::SpecialChars],
location: location.clone(),
kind: PassthroughKind::Single,
});
let placeholder = format!(
"\u{FFFD}\u{FFFD}\u{FFFD}{}\u{FFFD}\u{FFFD}\u{FFFD}",
self.pass_found_count.get()
);
self.source_map.borrow_mut().add_replacement(
location.absolute_start,
location.absolute_end,
placeholder.len(),
ProcessedKind::Passthrough,
);
self.pass_found_count.set(self.pass_found_count.get() + 1);
output.push_str(&placeholder);
copied_until = end + 1;
search_from = copied_until;
}
self.append_unprotected_text(
&mut output,
&source[copied_until..],
source_start + copied_until,
document_attributes,
);
output
}
fn append_unprotected_text(
&self,
output: &mut String,
source: &'a str,
source_start: usize,
document_attributes: &DocumentAttributes<'a>,
) {
if !self.attributes_enabled {
output.push_str(source);
return;
}
let mut copied_until = 0;
let mut search_from = 0;
while let Some(relative_start) = source[search_from..].find('{') {
let start = search_from + relative_start;
let Some(relative_end) = source[start + 1..].find('}') else {
break;
};
let end = start + relative_end + 2;
let name = &source[start + 1..end - 1];
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
search_from = end;
continue;
}
output.push_str(&source[copied_until..start]);
let location = self.location_from_offsets(source_start + start, source_start + end);
output.push_str(&self.expand_attribute_reference(name, location, document_attributes));
copied_until = end;
search_from = end;
}
output.push_str(&source[copied_until..]);
}
fn expand_attribute_reference(
&self,
attribute_name: &str,
location: Location,
document_attributes: &DocumentAttributes<'a>,
) -> String {
if !self.attributes_enabled {
return format!("{{{attribute_name}}}");
}
let is_character_reference = matches!(
attribute_name,
"lt" | "gt"
| "amp"
| "plus"
| "pp"
| "cpp"
| "cxx"
| "asterisk"
| "backtick"
| "caret"
| "tilde"
| "vbar"
| "startsb"
| "endsb"
| "backslash"
| "two-colons"
| "two-semicolons"
| "apos"
| "quot"
);
let Some(resolved) = document_attributes.get(attribute_name) else {
return format!("{{{attribute_name}}}");
};
if (is_character_reference || !resolved.inline_fragments().is_empty())
&& let Some(value) = resolved.as_str()
{
let absolute_start = location.absolute_start;
let absolute_end = location.absolute_end;
self.passthroughs.borrow_mut().push(Pass {
attribute_fragments: resolved.inline_fragments().into(),
text: Some(self.arena.alloc_str(value.as_ref())),
substitutions: Vec::new(),
location,
kind: PassthroughKind::AttributeRef,
});
let placeholder = format!(
"\u{FFFD}\u{FFFD}\u{FFFD}{}\u{FFFD}\u{FFFD}\u{FFFD}",
self.pass_found_count.get()
);
self.source_map.borrow_mut().add_replacement(
absolute_start,
absolute_end,
placeholder.len(),
ProcessedKind::Passthrough,
);
self.pass_found_count.set(self.pass_found_count.get() + 1);
return placeholder;
}
let mut value = String::new();
let _ = resolved.write_text(&mut value);
self.source_map.borrow_mut().add_replacement(
location.absolute_start,
location.absolute_end,
value.len(),
if resolved.passthrough_ranges().is_empty() {
ProcessedKind::Attribute
} else {
ProcessedKind::RawAttribute(resolved.passthrough_ranges().into())
},
);
value
}
fn location_from_offsets(&self, absolute_start: usize, absolute_end: usize) -> Location {
Location {
absolute_start,
absolute_end,
start: self
.line_map
.offset_to_position(absolute_start, self.full_input),
end: self
.line_map
.offset_to_position(absolute_end, self.full_input),
}
}
fn calculate_location(&self, start: Position, content: &str, padding: usize) -> Location {
let absolute_start = self.get_offset();
self.advance(content);
self.advance_by(padding);
Location {
absolute_start,
absolute_end: self.get_offset(),
start,
end: self.get_position(),
}
}
}
#[derive(Debug)]
pub(crate) struct ProcessedContent<'a> {
pub text: Cow<'a, str>,
pub passthroughs: Vec<Pass<'a>>,
pub(crate) source_map: SourceMap,
pub(crate) attribute_substitutions: crate::model::substitution::SubstitutionPlan,
}
#[derive(Debug, Clone)]
pub(crate) struct Replacement {
pub absolute_start: usize,
pub absolute_end: usize,
pub byte_len: usize,
pub kind: ProcessedKind,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct SourceMap {
pub replacements: Vec<Replacement>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ProcessedKind {
Attribute,
RawAttribute(Box<[Range<usize>]>),
Escape,
Passthrough,
}
impl ProcessedKind {
pub(crate) const fn is_attribute(&self) -> bool {
matches!(self, Self::Attribute | Self::RawAttribute(_))
}
}
fn constrained_passthrough_start(previous: Option<char>) -> bool {
previous.is_none_or(|character| {
!is_word_character(character) && !matches!(character, ';' | ':' | '\\')
})
}
fn constrained_passthrough_end(source: &str, start: usize) -> Option<usize> {
let content_start = start + 1;
let first = source[content_start..].chars().next()?;
if matches!(first, ' ' | '\t'..='\r') {
return None;
}
for (relative_end, character) in source[content_start..].char_indices() {
if character != '+' || relative_end == 0 {
continue;
}
let end = content_start + relative_end;
let last = source[content_start..end].chars().next_back()?;
let next = source[end + 1..].chars().next();
if !matches!(last, ' ' | '\t'..='\r') && next.is_none_or(|value| !is_word_character(value))
{
return Some(end);
}
}
None
}
fn to_signed(value: usize, context: &str) -> Result<i32, Error> {
i32::try_from(value).map_err(|e| {
tracing::error!(value, context, error = %e, "position overflow");
e.into()
})
}
fn to_unsigned(value: i32, context: &str) -> Result<usize, Error> {
usize::try_from(value).map_err(|e| {
tracing::error!(value, context, error = %e, "negative position");
e.into()
})
}
impl SourceMap {
pub(crate) fn add_replacement(
&mut self,
absolute_start: usize,
absolute_end: usize,
replacement_length: usize,
kind: ProcessedKind,
) {
self.replacements.push(Replacement {
absolute_start,
absolute_end,
byte_len: replacement_length,
kind,
});
}
pub(crate) fn map_position(&self, pos: usize) -> Result<usize, Error> {
self.map_position_with_bias(pos, false)
}
pub(crate) fn map_end_position(&self, pos: usize) -> Result<usize, Error> {
self.map_position_with_bias(pos, true)
}
pub(crate) fn empty_attribute_offsets(&self, original_start: usize) -> Vec<usize> {
let mut original_cursor = original_start;
let mut processed_cursor = 0;
let mut offsets = Vec::new();
for replacement in &self.replacements {
if replacement.absolute_start < original_cursor {
continue;
}
processed_cursor += replacement.absolute_start - original_cursor;
if replacement.kind.is_attribute() && replacement.byte_len == 0 {
offsets.push(processed_cursor);
}
processed_cursor += replacement.byte_len;
original_cursor = replacement.absolute_end;
}
offsets
}
pub(crate) fn attribute_value_ranges(&self, original_start: usize) -> Vec<Range<usize>> {
let mut original_cursor = original_start;
let mut processed_cursor = 0;
let mut ranges = Vec::new();
for replacement in &self.replacements {
if replacement.absolute_start < original_cursor {
continue;
}
processed_cursor += replacement.absolute_start - original_cursor;
if replacement.kind.is_attribute() && replacement.byte_len != 0 {
ranges.push(processed_cursor..processed_cursor + replacement.byte_len);
}
processed_cursor += replacement.byte_len;
original_cursor = replacement.absolute_end;
}
ranges
}
pub(crate) fn raw_attribute_ranges(&self, original_start: usize) -> Vec<Range<usize>> {
let mut original_cursor = original_start;
let mut processed_cursor = 0;
let mut ranges = Vec::new();
for replacement in &self.replacements {
if replacement.absolute_start < original_cursor {
continue;
}
processed_cursor += replacement.absolute_start - original_cursor;
if let ProcessedKind::RawAttribute(raw) = &replacement.kind {
ranges.extend(
raw.iter()
.map(|range| range.start + processed_cursor..range.end + processed_cursor),
);
}
processed_cursor += replacement.byte_len;
original_cursor = replacement.absolute_end;
}
ranges
}
fn map_position_with_bias(&self, pos: usize, end_bias: bool) -> Result<usize, Error> {
let signed_pos = to_signed(pos, "pos")?;
let mut adjustment: i32 = 0;
for rep in &self.replacements {
let rep_start = to_signed(rep.absolute_start, "rep.absolute_start")?;
let rep_end = to_signed(rep.absolute_end, "rep.absolute_end")?;
let replacement_length = to_signed(rep.byte_len, "rep.byte_len")?;
let rep_processed_start = rep_start + adjustment;
let rep_processed_end = rep_processed_start + replacement_length;
if signed_pos < rep_processed_start || (signed_pos == rep_processed_start && !end_bias)
{
break;
}
if signed_pos < rep_processed_end {
if end_bias {
return Ok(rep.absolute_end.saturating_sub(1));
}
return match rep.kind {
ProcessedKind::Attribute | ProcessedKind::RawAttribute(_) => {
Ok(rep.absolute_start)
}
ProcessedKind::Escape => Ok(rep.absolute_start),
ProcessedKind::Passthrough => {
let mapped_pos = signed_pos - adjustment;
if mapped_pos >= rep_end {
Ok(rep.absolute_end - 1)
} else {
to_unsigned(mapped_pos, "within_passthrough")
}
}
};
}
adjustment += replacement_length - (rep_end - rep_start);
}
to_unsigned(signed_pos - adjustment, "final_position")
}
}
parser!(
pub(crate) grammar inline_preprocessing(document_attributes: &DocumentAttributes<'input>, state: &InlinePreprocessorParserState<'input>) for str {
pub rule run() -> ProcessedContent<'input>
= content:content() {
let mut source_map = take(&mut *state.source_map.borrow_mut());
source_map
.replacements
.sort_by_key(|replacement| replacement.absolute_start);
ProcessedContent {
text: Cow::Owned(content),
passthroughs: take(&mut *state.passthroughs.borrow_mut()),
source_map,
attribute_substitutions: crate::model::substitution::SubstitutionPlan::default(),
}
}
pub rule content() -> String
= parts:inlines()+ { parts.join("") }
rule inlines() -> String = quiet!{
inherited_attribute()
/
kbd_macro()
/ monospace()
/ escaped_passthrough()
/ passthrough()
/ counter_reference()
/ escaped_attribute_reference()
/ attribute_reference()
/ unprocessed_text()
} / expected!("inlines parser failed")
rule inherited_attribute() -> String
= text:#{|input, pos| {
let absolute = pos + state.substring_start_offset.get();
match state.attribute_value_ranges.iter().find(|range| pos < input.len() && range.contains(&absolute)) {
Some(range) => {
let end = (range.end - state.substring_start_offset.get()).min(input.len());
peg::RuleResult::Matched(end, &input[pos..end])
}
None => peg::RuleResult::Failed,
}
}} {
state.advance(text);
text.into()
}
rule monospace() -> String
= text:$monospace_pattern() {?
tracing::debug!(text, "monospace matched");
if state.defer_monospace {
state.advance(text);
return Ok(text.into());
}
let width = if text.starts_with("``") { 2 } else { 1 };
let inner = &text[width..text.len() - width];
let marker = &text[..width];
let end = state.get_offset() + text.len();
state.advance_by(width);
let saved_input = state.input.replace(inner);
let saved_start = state.substring_start_offset.replace(state.get_offset());
let result = inline_preprocessing::content(inner, document_attributes, state);
state.input.replace(saved_input);
state.substring_start_offset.set(saved_start);
state.current_offset.set(end);
result.map(|content| format!("{marker}{content}{marker}"))
.map_err(|_| "could not preprocess code content")
}
rule kbd_macro() -> String
= text:$("kbd:[" (!"]" [_])* "]") {
state.advance(text);
text.into()
}
rule counter_reference() -> String
= &counter_reference_pattern() start:position!() start_offset:byte_offset() "{"
counter_type:$("counter2" / "counter") ":"
name:$(['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-']+)
(":" ['a'..='z' | 'A'..='Z' | '0'..='9']+)?
"}" end:position!()
{
let end_offset = start_offset + end - start;
let source_location = state.source_location_for(start_offset, end_offset);
state.add_warning(Warning::new(
WarningKind::Other(Cow::Owned(format!(
"Counters ({{{counter_type}:{name}}}) are not supported and will be removed from output"
))),
Some(source_location),
));
state.source_map.borrow_mut().add_replacement(
start_offset, end_offset, 0, ProcessedKind::Attribute,
);
state.advance_by(end - start);
String::new()
}
rule escaped_attribute_reference() -> String
= start:position() source:$(escaped_attribute_reference_pattern()) {
let location = state.calculate_location(start, source, 0);
let text = if state.attributes_enabled {
let text = source.strip_prefix('\\').unwrap_or(source);
if let Some(prefix) = text.strip_suffix("\\}") {
state.arena.alloc_str(&format!("{prefix}}}"))
} else {
text
}
} else {
source
};
let index = state.pass_found_count.get();
let placeholder = format!("���{index}���");
state.passthroughs.borrow_mut().push(Pass {
attribute_fragments: Box::default(),
text: Some(text),
substitutions: vec![Substitution::SpecialChars],
location: location.clone(),
kind: PassthroughKind::AttributeRef,
});
state.source_map.borrow_mut().add_replacement(
location.absolute_start, location.absolute_end, placeholder.len(), ProcessedKind::Escape,
);
state.pass_found_count.set(index + 1);
placeholder
}
rule escaped_attribute_reference_pattern()
= "\\{" attribute_name_pattern() "}"
/ "\\"? "{" attribute_name_pattern() "\\}"
{? state.attributes_enabled.then_some(()).ok_or("attribute substitutions disabled") }
rule attribute_reference() -> String
= start:position() "{" attribute_name:attribute_name() "}" {
let location = state.calculate_location(start, attribute_name, 2);
state.expand_attribute_reference(attribute_name, location, document_attributes)
}
rule attribute_name() -> &'input str
= start:position() attribute_name:$(attribute_name_pattern()) {
attribute_name
}
rule check_macros() = {? state.macros_enabled.then_some(()).ok_or("macros disabled") }
rule escaped_passthrough() -> String
= check_macros() expanded:("\\" source:$(pass_macro_pattern()) {
let start = state.get_offset();
let expanded = state.extract_single_passthroughs(source, start + 1, document_attributes);
state.current_offset.set(start + source.len() + 1);
format!("\\{expanded}")
}
/ "\\" source:$("+++" (!"+++" [_])+ "+++") {
state.expand_escaped_passthrough(source, document_attributes, true)
}
/ "\\" source:$("++" (!"++" [_])+ "++") {
state.expand_escaped_passthrough(source, document_attributes, true)
}
/ "\\" source:single_plus_pattern(true) {
state.expand_escaped_passthrough(source, document_attributes, false)
}) { expanded }
rule passthrough() -> String = quiet!{
check_macros() text:(triple_plus_passthrough() / double_plus_passthrough() / single_plus_passthrough() / pass_macro()) { text }
} / expected!("passthrough parser failed")
rule single_plus_passthrough() -> String
= start:position() source:single_plus_pattern(false)
{
let content = &source[1..source.len() - 1];
let location = state.calculate_location(start, content, 2);
state.passthroughs.borrow_mut().push(Pass {
attribute_fragments: Box::default(),
text: Some(content),
substitutions: vec![Substitution::SpecialChars],
location: location.clone(),
kind: PassthroughKind::Single,
});
let new_content = format!("\u{FFFD}\u{FFFD}\u{FFFD}{}\u{FFFD}\u{FFFD}\u{FFFD}", state.pass_found_count.get());
state.source_map.borrow_mut().add_replacement(
location.absolute_start,
location.absolute_end,
new_content.len(),
ProcessedKind::Passthrough,
);
state.pass_found_count.set(state.pass_found_count.get() + 1);
new_content
}
rule double_plus_passthrough() -> String
= start:position() "++" content:$((!"++" [_])+) "++" {
let location = state.calculate_location(start, content, 4);
state.passthroughs.borrow_mut().push(Pass {
attribute_fragments: Box::default(),
text: Some(content),
substitutions: vec![Substitution::SpecialChars].into_iter().collect(),
location: location.clone(),
kind: PassthroughKind::Double,
});
let new_content = format!("\u{FFFD}\u{FFFD}\u{FFFD}{}\u{FFFD}\u{FFFD}\u{FFFD}", state.pass_found_count.get());
let original_span = location.absolute_end - location.absolute_start;
state.source_map.borrow_mut().add_replacement(
location.absolute_start,
location.absolute_end,
new_content.len(),
ProcessedKind::Passthrough,
);
state.pass_found_count.set(state.pass_found_count.get() + 1);
new_content
}
rule triple_plus_passthrough() -> String
= start:position() "+++" content:$((!"+++" [_])+) "+++" {
let location = state.calculate_location(start, content, 6);
state.passthroughs.borrow_mut().push(Pass {
attribute_fragments: Box::default(),
text: Some(content),
substitutions: Vec::new(),
location: location.clone(),
kind: PassthroughKind::Triple,
});
let new_content = format!("\u{FFFD}\u{FFFD}\u{FFFD}{}\u{FFFD}\u{FFFD}\u{FFFD}", state.pass_found_count.get());
let original_span = location.absolute_end - location.absolute_start;
state.source_map.borrow_mut().add_replacement(
location.absolute_start,
location.absolute_end,
new_content.len(),
ProcessedKind::Passthrough,
);
state.pass_found_count.set(state.pass_found_count.get() + 1);
new_content
}
rule pass_macro() -> String
= start:position() full:$(pass_macro_pattern()) {
let (subs_str, content, substitutions) =
InlinePreprocessorParserState::parse_pass_macro_parts(full);
let padding = 5 + subs_str.len() + 1 + 1; let location = state.calculate_location(start, content, padding);
state.passthroughs.borrow_mut().push(Pass {
attribute_fragments: Box::default(),
text: Some(content),
substitutions: substitutions.clone(),
location: location.clone(),
kind: PassthroughKind::Macro,
});
let new_content = format!("\u{FFFD}\u{FFFD}\u{FFFD}{}\u{FFFD}\u{FFFD}\u{FFFD}", state.pass_found_count.get());
state.source_map.borrow_mut().add_replacement(
location.absolute_start,
location.absolute_end,
new_content.len(),
ProcessedKind::Passthrough,
);
state.pass_found_count.set(state.pass_found_count.get() + 1);
new_content
}
rule substitutions() -> Vec<Substitution>
= subs:$(substitution_value() ** ",") {
if subs.is_empty() {
Vec::new()
} else {
subs.split(',')
.filter_map(|s| parse_substitution(s.trim()))
.collect()
}
}
rule pass_macro_pattern()
= "pass:" substitutions() "[" ("\\]" / [^']'])* "]"
rule substitution_value() -> &'input str
= $(['a'..='z' | 'A'..='Z' | '0'..='9']+)
rule unprocessed_text() -> String
= text:$((
[^'{' | '+' | '`' | 'k' | 'p' | '\\']+
/
!(escaped_passthrough_pattern() / escaped_attribute_reference_pattern() / passthrough_pattern(false) / counter_reference_pattern() / attribute_reference_pattern() / kbd_macro_pattern() / monospace_pattern()) [_]
)+) {
state.advance(text);
text.to_string()
}
rule counter_reference_pattern()
= "{" ("counter2" / "counter") ":" ['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-']+ (":" ['a'..='z' | 'A'..='Z' | '0'..='9']+)? "}"
{? state.attributes_enabled.then_some(()).ok_or("attribute substitutions disabled") }
rule attribute_reference_pattern() = "{" attribute_name_pattern() "}"
rule attribute_name_pattern() = ['a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_']+
rule kbd_macro_pattern() = "kbd:[" (!"]" [_])* "]"
rule monospace_pattern()
= ({? (!state.defer_monospace).then_some(()).ok_or("attributes precede quotes") })
("``" (!"``" [_])+ "``" / "`" [^('`' | ' ' | '\t' | '\n')] [^'`']* "`")
/ (start:position!()
{? (!state.input.borrow()[..start].trim_end_matches('`').ends_with('\\'))
.then_some(()).ok_or("escaped code delimiter") })
("``" (!"``" [_])+ "``"
/ (start:position!() {? check_constrained_opening_boundary(start, state.input.borrow().as_bytes(), None, b'`')
.then_some(()).ok_or("code opening boundary") })
"`" ![' ' | '\t'..='\r'] [_]
(!monospace_close() [_])* monospace_close())
rule monospace_close()
= close:position!() "`" after:position!() {?
let input = state.input.borrow();
let trailing_space = input.as_bytes().get(close.saturating_sub(1))
.is_some_and(|byte| matches!(byte, b' ' | b'\t'..=b'\r'));
let valid_follow = input[after..].chars().next()
.is_none_or(|ch| !ch.is_alphanumeric() && !matches!(ch, '_' | '`' | '\'' | '"'));
(!trailing_space && valid_follow).then_some(()).ok_or("code closing boundary")
}
rule single_plus_pattern(escaped: bool) -> &'input str
= source:#{|input, position| {
let previous = input[..position].chars().next_back();
if input.as_bytes().get(position) != Some(&b'+')
|| (!escaped && !constrained_passthrough_start(previous))
{
return peg::RuleResult::Failed;
}
match constrained_passthrough_end(input, position) {
Some(end) => peg::RuleResult::Matched(end + 1, &input[position..=end]),
None => peg::RuleResult::Failed,
}
}} { source }
rule passthrough_pattern(escaped: bool) = check_macros() (
"+++" (!("+++") [_])+ "+++" /
"++" (!("++") [_])+ "++" /
single_plus_pattern(escaped) /
pass_macro_pattern())
rule escaped_passthrough_pattern() = "\\" passthrough_pattern(true)
rule ANY() = [_]
rule position() -> Position = { state.get_position() }
rule byte_offset() -> usize = { state.get_offset() }
}
);
#[cfg(test)]
#[allow(
clippy::panic,
clippy::indexing_slicing,
clippy::expect_used,
clippy::unwrap_used
)]
mod tests {
use super::*;
use crate::DocumentAttributes;
fn setup_attributes() -> DocumentAttributes<'static> {
let mut attributes = DocumentAttributes::default();
assert!(
attributes
.insert("s".into(), AttributeValue::String("link:/nonono".into()))
.is_ok()
);
assert!(
attributes
.insert("version".into(), AttributeValue::String("1.0".into()))
.is_ok()
);
assert!(
attributes
.insert("title".into(), AttributeValue::String("My Title".into()))
.is_ok()
);
attributes
}
fn setup_state(content: &str) -> InlinePreprocessorParserState<'_> {
let arena: &'static Bump = Box::leak(Box::new(Bump::new()));
InlinePreprocessorParserState {
pass_found_count: Cell::new(0),
passthroughs: RefCell::new(Vec::new()),
current_offset: Cell::new(0),
line_map: Rc::new(LineMap::new(content)),
full_input: content,
arena,
source_map: RefCell::new(SourceMap::default()),
input: RefCell::new(content),
substring_start_offset: Cell::new(0),
warnings: RefCell::new(Vec::new()),
macros_enabled: true,
attributes_enabled: true,
defer_monospace: true,
attribute_value_ranges: Vec::new(),
}
}
#[test]
fn test_preprocess_inline_passthrough_single() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "+hello+";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(
result.text,
"\u{FFFD}\u{FFFD}\u{FFFD}0\u{FFFD}\u{FFFD}\u{FFFD}"
);
assert_eq!(state.pass_found_count.get(), 1);
let passthroughs = result.passthroughs;
assert_eq!(passthroughs.len(), 1);
let Some(first) = passthroughs.first() else {
panic!("expected first passthrough");
};
assert_eq!(first.text, Some("hello"));
assert_eq!(first.kind, PassthroughKind::Single);
Ok(())
}
#[test]
fn test_preprocess_inline_passthrough_double() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "++hello++";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(
result.text,
"\u{FFFD}\u{FFFD}\u{FFFD}0\u{FFFD}\u{FFFD}\u{FFFD}"
);
assert_eq!(result.passthroughs.len(), 1);
let Some(first) = result.passthroughs.first() else {
panic!("expected first passthrough");
};
assert_eq!(first.text, Some("hello"));
assert_eq!(first.kind, PassthroughKind::Double);
Ok(())
}
#[test]
fn test_preprocess_inline_passthrough_triple() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "+++hello+++";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(
result.text,
"\u{FFFD}\u{FFFD}\u{FFFD}0\u{FFFD}\u{FFFD}\u{FFFD}"
);
assert_eq!(result.passthroughs.len(), 1);
let Some(first) = result.passthroughs.first() else {
panic!("expected first passthrough");
};
assert_eq!(first.text, Some("hello"));
assert_eq!(first.kind, PassthroughKind::Triple);
Ok(())
}
#[test]
fn test_preprocess_inline_passthrough_single_plus() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "+hello+ world+";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(
result.text,
"\u{FFFD}\u{FFFD}\u{FFFD}0\u{FFFD}\u{FFFD}\u{FFFD} world+"
);
assert_eq!(result.passthroughs.len(), 1);
let Some(first) = result.passthroughs.first() else {
panic!("expected first passthrough");
};
assert_eq!(first.text, Some("hello"));
assert_eq!(first.kind, PassthroughKind::Single);
Ok(())
}
#[test]
fn test_preprocess_inline_passthrough_multiple() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "Something\n\nHere is some +*bold*+ text and ++**more bold**++ text.";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(
result.text,
"Something\n\nHere is some \u{FFFD}\u{FFFD}\u{FFFD}0\u{FFFD}\u{FFFD}\u{FFFD} text and \u{FFFD}\u{FFFD}\u{FFFD}1\u{FFFD}\u{FFFD}\u{FFFD} text."
);
assert_eq!(result.passthroughs.len(), 2);
let Some(first) = result.passthroughs.first() else {
panic!("expected first passthrough");
};
assert!(matches!(&first.text, Some(s) if *s == "*bold*"));
assert_eq!(first.location.absolute_start, 24);
assert_eq!(first.location.absolute_end, 32);
assert_eq!(first.location.start.line, 3);
assert_eq!(first.location.start.column, 14);
assert_eq!(first.location.end.line, 3);
assert_eq!(first.location.end.column, 22);
let Some(second) = result.passthroughs.get(1) else {
panic!("expected second passthrough");
};
assert!(matches!(&second.text, Some(s) if *s == "**more bold**"));
assert_eq!(second.location.absolute_start, 42);
assert_eq!(second.location.absolute_end, 59);
assert_eq!(second.location.start.line, 3);
assert_eq!(second.location.start.column, 32);
assert_eq!(second.location.end.line, 3);
assert_eq!(second.location.end.column, 49);
Ok(())
}
#[test]
fn dense_escaped_passthroughs_finalize_source_map_in_order() -> Result<(), Error> {
let attributes = setup_attributes();
let input = r"\++plain++ ".repeat(256);
let state = setup_state(&input);
let result = inline_preprocessing::run(&input, &attributes, &state)?;
assert_eq!(result.source_map.replacements.len(), 512);
assert!(
result
.source_map
.replacements
.windows(2)
.all(|replacements| {
replacements[0].absolute_start <= replacements[1].absolute_start
})
);
Ok(())
}
#[test]
fn test_preprocess_attribute_in_link() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "The {s}[syntax page] provides complete stuff.";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(
result.text,
"The link:/nonono[syntax page] provides complete stuff."
);
assert_eq!(result.source_map.map_position(15)?, 4); assert_eq!(result.source_map.map_position(16)?, 7); assert_eq!(result.source_map.map_position(30)?, 21); Ok(())
}
#[test]
fn test_preprocess_inline_in_attributes() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "Version {version} of {title}";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(result.text, "Version 1.0 of My Title");
assert_eq!(result.source_map.map_position(8)?, 8);
assert_eq!(result.source_map.map_position(15)?, 21);
Ok(())
}
#[test]
fn test_preprocess_complex_example() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "Check the {s}[syntax page] and +this {s} won't expand+ for details.";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(
result.text,
"Check the link:/nonono[syntax page] and \u{FFFD}\u{FFFD}\u{FFFD}0\u{FFFD}\u{FFFD}\u{FFFD} for details."
);
assert_eq!(result.passthroughs.len(), 1);
let Some(first) = result.passthroughs.first() else {
panic!("expected first passthrough");
};
assert!(matches!(
&first.text,
Some(s) if *s == "this {s} won't expand"
));
let pos = result.source_map.map_position(10)?; assert_eq!(pos, 10); Ok(())
}
#[test]
fn test_nested_passthrough_with_nested_attributes() -> Result<(), Error> {
let mut attributes = setup_attributes();
assert!(
attributes
.insert("nested1".into(), AttributeValue::String("{version}".into()))
.is_ok()
);
assert!(
attributes
.insert("nested2".into(), AttributeValue::String("{nested1}".into()))
.is_ok()
);
let input = "Here is a +special {nested2} value+ to test.";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(
result.text,
"Here is a \u{FFFD}\u{FFFD}\u{FFFD}0\u{FFFD}\u{FFFD}\u{FFFD} to test."
);
assert_eq!(result.passthroughs.len(), 1);
let Some(first) = result.passthroughs.first() else {
panic!("expected first passthrough");
};
assert!(matches!(
&first.text,
Some(s) if *s == "special {nested2} value"
));
let start_pos = first.location.absolute_start;
let end_pos = first.location.absolute_end;
assert_eq!(start_pos, 10); assert_eq!(end_pos, 35); Ok(())
}
#[test]
fn test_line_breaks() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "This is a test +\nwith a line break.";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(result.text, "This is a test +\nwith a line break.");
assert_eq!(result.passthroughs, []);
Ok(())
}
#[test]
fn test_section_with_passthrough() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "= Document Title\nHello +<h1>+World+</h1>+ of +<u>+Gemini+</u>+";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(
result.text,
"= Document Title\nHello \u{FFFD}\u{FFFD}\u{FFFD}0\u{FFFD}\u{FFFD}\u{FFFD}</h1>+ of \u{FFFD}\u{FFFD}\u{FFFD}1\u{FFFD}\u{FFFD}\u{FFFD}</u>+"
);
assert_eq!(result.passthroughs.len(), 2);
let Some(first_pass) = result.passthroughs.first() else {
panic!("expected first passthrough");
};
let Some(second_pass) = result.passthroughs.get(1) else {
panic!("expected second passthrough");
};
assert!(matches!(&first_pass.text, Some(s) if *s == "<h1>+World"));
assert!(matches!(&second_pass.text, Some(s) if *s == "<u>+Gemini"));
assert!(
first_pass
.substitutions
.contains(&Substitution::SpecialChars)
);
assert!(
second_pass
.substitutions
.contains(&Substitution::SpecialChars)
);
Ok(())
}
#[test]
fn test_pass_macro_with_mixed_content() -> Result<(), Error> {
let mut attributes = setup_attributes();
assert!(
attributes
.insert("docname".into(), AttributeValue::String("test-doc".into()))
.is_ok()
);
let input = "The text pass:q,a[<u>underline _{docname}_</u>] is underlined.";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(
result.text,
"The text \u{FFFD}\u{FFFD}\u{FFFD}0\u{FFFD}\u{FFFD}\u{FFFD} is underlined."
);
assert_eq!(result.passthroughs.len(), 1);
let Some(pass) = result.passthroughs.first() else {
panic!("expected first passthrough");
};
assert!(matches!(
&pass.text,
Some(s) if *s == "<u>underline _{docname}_</u>"
));
assert!(pass.substitutions.contains(&Substitution::Quotes)); assert!(pass.substitutions.contains(&Substitution::Attributes));
assert_eq!(pass.location.absolute_start, 9); assert_eq!(pass.location.absolute_end, 47);
assert_eq!(result.source_map.map_position(9)?, 9); assert_eq!(result.source_map.map_position(28)?, 47); Ok(())
}
#[test]
fn test_all_passthroughs_with_attribute() -> Result<(), Error> {
let mut attributes = setup_attributes();
assert!(
attributes
.insert("meh".into(), AttributeValue::String("1.0".into()))
.is_ok()
);
let input = "1 +2+, ++3++ {meh} and +++4+++ are all numbers.";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(
result.text,
"1 \u{FFFD}\u{FFFD}\u{FFFD}0\u{FFFD}\u{FFFD}\u{FFFD}, \u{FFFD}\u{FFFD}\u{FFFD}1\u{FFFD}\u{FFFD}\u{FFFD} 1.0 and \u{FFFD}\u{FFFD}\u{FFFD}2\u{FFFD}\u{FFFD}\u{FFFD} are all numbers."
);
assert_eq!(result.passthroughs.len(), 3);
let Some(first) = result.passthroughs.first() else {
panic!("expected first passthrough");
};
let Some(second) = result.passthroughs.get(1) else {
panic!("expected second passthrough");
};
let Some(third) = result.passthroughs.get(2) else {
panic!("expected third passthrough");
};
assert!(matches!(first.kind, PassthroughKind::Single));
assert!(matches!(second.kind, PassthroughKind::Double));
assert!(matches!(third.kind, PassthroughKind::Triple));
assert!(matches!(&first.text, Some(s) if *s == "2"));
assert!(matches!(&second.text, Some(s) if *s == "3"));
assert!(matches!(&third.text, Some(s) if *s == "4"));
assert_eq!(result.source_map.map_position(2)?, 2);
assert_eq!(result.source_map.map_position(5)?, 4);
assert_eq!(result.source_map.map_position(24)?, 8);
assert_eq!(result.source_map.map_position(48)?, 20);
Ok(())
}
#[test]
fn test_greedy_matching_single_plus_passthrough() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "Test +A+B+ end";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(result.passthroughs.len(), 1);
let Some(first) = result.passthroughs.first() else {
panic!("expected first passthrough");
};
assert!(matches!(&first.text, Some(s) if *s == "A+B"));
let input2 = "Test +A+ +B+ end";
let state2 = setup_state(input2);
let result2 = inline_preprocessing::run(input2, &attributes, &state2)?;
assert_eq!(result2.passthroughs.len(), 2);
let Some(first) = result2.passthroughs.first() else {
panic!("expected first passthrough");
};
let Some(second) = result2.passthroughs.get(1) else {
panic!("expected second passthrough");
};
assert!(matches!(&first.text, Some(s) if *s == "A"));
assert!(matches!(&second.text, Some(s) if *s == "B"));
let input3 = "Test +A+B+C+D+ end";
let state3 = setup_state(input3);
let result3 = inline_preprocessing::run(input3, &attributes, &state3)?;
assert_eq!(result3.passthroughs.len(), 1);
let Some(first) = result3.passthroughs.first() else {
panic!("expected first passthrough");
};
assert!(matches!(&first.text, Some(s) if *s == "A+B+C+D"));
let input4 = "Test +<em>+text+ end";
let state4 = setup_state(input4);
let result4 = inline_preprocessing::run(input4, &attributes, &state4)?;
assert_eq!(result4.passthroughs.len(), 1);
let Some(first) = result4.passthroughs.first() else {
panic!("expected first passthrough");
};
assert!(matches!(&first.text, Some(s) if *s == "<em>+text"));
let input5 = "Look +here+there+, ok";
let state5 = setup_state(input5);
let result5 = inline_preprocessing::run(input5, &attributes, &state5)?;
assert_eq!(result5.passthroughs.len(), 1);
let Some(first) = result5.passthroughs.first() else {
panic!("expected first passthrough");
};
assert!(matches!(&first.text, Some(s) if *s == "here+there"));
let input6 = "Hello +<h1>+World+</h1>+ and +<u>+Gemini+</u>+ end";
let state6 = setup_state(input6);
let result6 = inline_preprocessing::run(input6, &attributes, &state6)?;
assert_eq!(result6.passthroughs.len(), 2);
let Some(first) = result6.passthroughs.first() else {
panic!("expected first passthrough");
};
let Some(second) = result6.passthroughs.get(1) else {
panic!("expected second passthrough");
};
assert!(matches!(&first.text, Some(s) if *s == "<h1>+World"));
assert!(matches!(&second.text, Some(s) if *s == "<u>+Gemini"));
Ok(())
}
#[test]
fn test_all_character_replacement_attributes() -> Result<(), Error> {
let attributes = DocumentAttributes::default();
let input = concat!(
"{empty}{blank}{sp}{nbsp}{zwsp}{wj}",
"{apos}{quot}{lsquo}{rsquo}{ldquo}{rdquo}",
"{deg}{plus}{brvbar}{vbar}{amp}{lt}{gt}",
"{startsb}{endsb}{caret}{asterisk}{tilde}{backslash}{backtick}",
"{two-colons}{two-semicolons}{cpp}{cxx}{pp}"
);
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
let p = |i: usize| format!("\u{FFFD}\u{FFFD}\u{FFFD}{i}\u{FFFD}\u{FFFD}\u{FFFD}");
let expected = format!(
concat!(
"", "", " ", "\u{00A0}", "\u{200B}", "\u{2060}",
"{}", "{}", "\u{2018}", "\u{2019}", "\u{201C}", "\u{201D}",
"\u{00B0}", "{}", "\u{00A6}", "{}", "{}", "{}", "{}", "{}", "{}", "{}", "{}", "{}", "{}", "{}", "{}", "{}", "{}", "{}", "{}", ),
p(0),
p(1),
p(2),
p(3),
p(4),
p(5),
p(6),
p(7),
p(8),
p(9),
p(10),
p(11),
p(12),
p(13),
p(14),
p(15),
p(16),
p(17),
p(18),
);
assert_eq!(
result.text, expected,
"Character replacement attributes did not produce expected values"
);
assert_eq!(
result.passthroughs.len(),
19,
"Should have 19 passthroughs for all ASCII char replacement attributes"
);
assert_eq!(result.passthroughs[0].text, Some("'")); assert_eq!(result.passthroughs[2].text, Some("+")); assert_eq!(result.passthroughs[4].text, Some("&")); assert_eq!(result.passthroughs[16].text, Some("C++"));
Ok(())
}
#[test]
fn test_character_replacement_in_context() -> Result<(), Error> {
let attributes = DocumentAttributes::default();
let input1 = "The temperature is 100{deg}F";
let state1 = setup_state(input1);
let result1 = inline_preprocessing::run(input1, &attributes, &state1)?;
assert_eq!(result1.text, "The temperature is 100\u{00B0}F");
let input2 = "Use {startsb}option{endsb} syntax";
let state2 = setup_state(input2);
let result2 = inline_preprocessing::run(input2, &attributes, &state2)?;
assert_eq!(
result2.text,
"Use \u{FFFD}\u{FFFD}\u{FFFD}0\u{FFFD}\u{FFFD}\u{FFFD}option\u{FFFD}\u{FFFD}\u{FFFD}1\u{FFFD}\u{FFFD}\u{FFFD} syntax"
);
assert_eq!(result2.passthroughs.len(), 2);
assert_eq!(result2.passthroughs[0].text, Some("["));
assert_eq!(result2.passthroughs[1].text, Some("]"));
let input3 = "{ldquo}Hello{rdquo}";
let state3 = setup_state(input3);
let result3 = inline_preprocessing::run(input3, &attributes, &state3)?;
assert_eq!(result3.text, "\u{201C}Hello\u{201D}");
let input4 = "before{empty}after";
let state4 = setup_state(input4);
let result4 = inline_preprocessing::run(input4, &attributes, &state4)?;
assert_eq!(result4.text, "beforeafter");
let input5 = "before{blank}after";
let state5 = setup_state(input5);
let result5 = inline_preprocessing::run(input5, &attributes, &state5)?;
assert_eq!(result5.text, "beforeafter");
let input6 = "{cpp} is same as {cxx}";
let state6 = setup_state(input6);
let result6 = inline_preprocessing::run(input6, &attributes, &state6)?;
assert_eq!(
result6.text,
"\u{FFFD}\u{FFFD}\u{FFFD}0\u{FFFD}\u{FFFD}\u{FFFD} is same as \u{FFFD}\u{FFFD}\u{FFFD}1\u{FFFD}\u{FFFD}\u{FFFD}"
);
assert_eq!(result6.passthroughs.len(), 2);
assert_eq!(result6.passthroughs[0].text, Some("C++"));
assert_eq!(result6.passthroughs[1].text, Some("C++"));
Ok(())
}
#[test]
fn test_counter_reference_collects_warning() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "Count: {counter:mycount}";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(result.text, "Count: ");
let warnings = state.warnings.borrow();
assert_eq!(warnings.len(), 1);
let msg = warnings[0].to_string();
assert!(msg.contains("counter"), "got: {msg}");
assert!(msg.contains("mycount"), "got: {msg}");
assert!(warnings[0].source_location().is_some());
Ok(())
}
#[test]
fn test_counter_reference_deduplication_is_per_position() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "{counter:hits} and {counter:hits}";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(result.text, " and ");
let warnings = state.warnings.borrow();
assert_eq!(
warnings.len(),
2,
"each counter reference is its own diagnostic site, got: {warnings:?}",
);
let loc0 = warnings[0].source_location().expect("loc0");
let loc1 = warnings[1].source_location().expect("loc1");
assert_ne!(loc0, loc1, "locations should differ between occurrences");
Ok(())
}
#[test]
fn test_distinct_counter_references_produce_separate_warnings() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "{counter:a} and {counter2:b}";
let state = setup_state(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(result.text, " and ");
let warnings = state.warnings.borrow();
assert_eq!(
warnings.len(),
2,
"different counter warnings should both be collected"
);
Ok(())
}
fn setup_state_macros_disabled(content: &str) -> InlinePreprocessorParserState<'_> {
let arena: &'static Bump = Box::leak(Box::new(Bump::new()));
InlinePreprocessorParserState {
pass_found_count: Cell::new(0),
passthroughs: RefCell::new(Vec::new()),
current_offset: Cell::new(0),
line_map: Rc::new(LineMap::new(content)),
full_input: content,
arena,
source_map: RefCell::new(SourceMap::default()),
input: RefCell::new(content),
substring_start_offset: Cell::new(0),
warnings: RefCell::new(Vec::new()),
macros_enabled: false,
attributes_enabled: true,
defer_monospace: true,
attribute_value_ranges: Vec::new(),
}
}
#[test]
fn test_pass_macro_a_with_macros_disabled_expands_attributes() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "pass:a[{version}]";
let state = setup_state_macros_disabled(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(result.text, "pass:a[1.0]");
assert_eq!(result.passthroughs, []);
Ok(())
}
#[test]
fn test_pass_macro_no_subs_with_macros_disabled_expands_attributes() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "pass:[{version}]";
let state = setup_state_macros_disabled(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(result.text, "pass:[1.0]");
assert_eq!(result.passthroughs, []);
Ok(())
}
#[test]
fn test_pass_macro_q_with_macros_disabled_preserves_content() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "pass:q[text]";
let state = setup_state_macros_disabled(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(result.text, "pass:q[text]");
assert_eq!(result.passthroughs, []);
Ok(())
}
#[test]
fn test_pass_macro_a_q_with_macros_disabled_expands_attributes() -> Result<(), Error> {
let attributes = setup_attributes();
let input = "pass:a,q[{version}]";
let state = setup_state_macros_disabled(input);
let result = inline_preprocessing::run(input, &attributes, &state)?;
assert_eq!(result.text, "pass:a,q[1.0]");
assert_eq!(result.passthroughs, []);
Ok(())
}
}