use crate::ast::{BlankEdges, Block, BlockKind, Span};
use crate::context::ParseContext;
use crate::extension::{
AttrKey, BlockContext, BlockMacroProcessor, BlockMacroRequest, BlockProcessor, BlockRequest,
ExtensionAttributes, MacroContent, NewBlock, NewBlockKind, ProcessContext,
};
use crate::parser::{PendingMeta, PendingTitle};
use std::ops::Range;
use std::sync::Arc;
pub(crate) enum Pending {
Block {
processor: Arc<dyn BlockProcessor>,
name: String,
context: BlockContext,
lines: Vec<String>,
},
Macro {
processor: Arc<dyn BlockMacroProcessor>,
line: String,
off: usize,
name: Range<usize>,
target: Range<usize>,
content: Option<Range<usize>>,
},
}
pub(crate) type MacroRanges = (Range<usize>, Range<usize>, Option<Range<usize>>);
pub(crate) fn custom_block_macro(line: &str) -> Option<MacroRanges> {
let mut chars = line.char_indices();
let (_, first) = chars.next()?;
if !crate::unicode_word::is_word(first) {
return None;
}
let name_end = line
.char_indices()
.find(|&(_, c)| !(crate::unicode_word::is_word(c) || c == '-'))
.map_or(line.len(), |(at, _)| at);
let rest = line[name_end..].strip_prefix("::")?;
let start = name_end + 2;
let close = line.len().checked_sub(1)?;
if !line.ends_with(']') || close < start {
return None;
}
let blank = |c: char| matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c');
let open = rest
.match_indices('[')
.map(|(at, _)| start + at)
.filter(|&at| at < close)
.find(|&at| {
let target = &line[start..at];
target.is_empty()
|| (!target.starts_with(blank) && !target.chars().next_back().is_some_and(blank))
})?;
let content = (open + 1 < close).then_some(open + 1..close);
Some((0..name_end, start..open, content))
}
fn metadata_attributes(pending: &PendingMeta) -> ExtensionAttributes {
let mut attributes = ExtensionAttributes::new();
for key in &pending.list.order {
let value = match key {
AttrKey::Positional(index) => pending.list.value(*index).map(|v| v.text.clone()),
AttrKey::Named(name) => match name.as_str() {
"title" => pending.title.as_ref().map(|t| t.text.clone()),
"id" => pending.id.clone(),
"role" => (!pending.roles.is_empty()).then(|| pending.roles.join(" ")),
"reftext" => pending.reftext.as_ref().map(|r| r.value.clone()),
name => match name.strip_suffix("-option") {
Some(option) if pending.attrs.options.iter().any(|o| o == option) => {
Some(String::new())
}
_ => pending.list.named(name).map(str::to_owned),
},
},
};
match (key, value) {
(_, Some(value)) => attributes.set_key(key.clone(), value),
(AttrKey::Positional(_), None) => attributes.set_value(key.clone(), None),
(AttrKey::Named(_), None) => {}
}
}
attributes
}
fn rekey(attributes: &mut ExtensionAttributes, positional: &[&str], defaults: &[(&str, &str)]) {
for (index, name) in positional.iter().enumerate() {
if let Some(value) = attributes.positional(index + 2).map(str::to_owned) {
attributes.set(*name, value);
}
}
for (name, value) in defaults {
if !attributes.contains(name) {
attributes.set(*name, *value);
}
}
}
pub(crate) fn run(
pending: Pending,
block: Block,
metadata: &mut PendingMeta,
includes: &crate::xref_target::Includes,
context: &mut ParseContext,
) -> Option<Block> {
let span = block.span;
let new = match pending {
Pending::Block {
processor,
name,
context: cloaked,
lines,
} => {
let mut attributes = metadata_attributes(metadata);
rekey(
&mut attributes,
processor.positional_attributes(),
processor.default_attributes(),
);
attributes.set("cloaked-context", cloaked.as_str());
let request = BlockRequest {
name,
context: cloaked,
lines,
attributes,
span,
};
invoke(context, includes, span, |ctx| {
processor.process(request, ctx)
})
}
Pending::Macro {
processor,
line,
off,
name,
target,
content,
} => {
let mut value = line[target.clone()].to_owned();
if value.contains('{') {
let source = crate::image::target_text(&value, off + target.start);
let (expanded, refs) = context.block_attribute_line(source);
if expanded.text.is_empty() && refs.iter().any(|r| r.drops_line) {
return None;
}
value = expanded.text;
}
let mut attributes = metadata_attributes(metadata);
match (processor.content(), content) {
(MacroContent::Attributes, Some(content)) => {
let attr_line = crate::block_attributes::AttrLine {
inner: line[content.clone()].to_owned(),
base: off + content.start,
};
let (list, _) = crate::block_attributes::macro_list(
attr_line,
processor.positional_attributes(),
context,
includes,
);
for (key, value) in metadata_attributes(&list).iter() {
attributes.set_value(key.clone(), value.map(str::to_owned));
}
if list.title.is_some() {
metadata.title = list.title;
}
if list.id.is_some() {
metadata.id = list.id;
}
if !list.roles.is_empty() {
metadata.roles = list.roles;
}
}
(MacroContent::Attributes, None) => {}
(MacroContent::Text, content) => {
let text = content.map_or("", |range| &line[range]);
attributes.set("text", text);
}
}
rekey(&mut attributes, &[], processor.default_attributes());
let request = BlockMacroRequest {
name: line[name].to_owned(),
target: value,
attributes,
span,
};
invoke(context, includes, span, |ctx| {
processor.process(request, ctx)
})
}
}?;
context.discarded_captions = new.discarded_captions.clone();
Some(new_block(new, span, metadata))
}
struct Reading<'p, 'c> {
context: &'p mut ParseContext<'c>,
includes: &'p crate::xref_target::Includes,
}
impl crate::extension::SubstitutionHost for Reading<'_, '_> {
fn substitute(
&mut self,
text: &str,
stages: &[crate::pass_subs::Stage],
span: Span,
) -> Vec<crate::AttributePart> {
self.context.reading_subs(text, stages, span, self.includes)
}
}
fn invoke(
context: &mut ParseContext,
includes: &crate::xref_target::Includes,
span: Span,
process: impl FnOnce(&mut ProcessContext<'_>) -> Option<NewBlock>,
) -> Option<NewBlock> {
let attributes = Arc::clone(&context.attributes);
let base_dir = context.base_dir.clone();
let output = context.output_target().cloned();
let markup = context.markup.clone();
let safe = context.safe_mode();
let (new, diagnostics) = {
let mut reading = Reading { context, includes };
let mut ctx = ProcessContext::new(
&attributes,
safe,
base_dir.as_deref(),
output.as_ref(),
span,
)
.with_markup(markup)
.with_host(&mut reading);
let new = process(&mut ctx);
(new, ctx.into_diagnostics())
};
context.diagnostics.extend(diagnostics);
new
}
fn new_block(new: NewBlock, span: Span, metadata: &mut PendingMeta) -> Block {
let previous = std::mem::take(metadata);
let mut attrs = crate::ast::BlockAttrs::default();
for (key, value) in new.attributes.iter() {
let (AttrKey::Named(name), Some(value)) = (key, value) else {
continue;
};
match name.as_str() {
"title" => {
metadata.title = Some(match &previous.title {
Some(title) if title.text == value => title.clone(),
_ => PendingTitle {
text: value.to_owned(),
base: span.start,
origin: Some(span),
},
});
}
"id" => metadata.id = Some(value.to_owned()),
"role" => {
metadata.roles = value
.split(crate::ruby_strip::is_ruby_space)
.filter(|role| !role.is_empty())
.map(str::to_owned)
.collect()
}
"style" => attrs.style = Some(value.to_owned()),
"reftext" => metadata.reftext = previous.reftext.clone(),
"subs" => {
attrs.named.insert(name.to_owned(), value.to_owned());
}
name => match name.strip_suffix("-option") {
Some(option) => attrs.options.push(option.to_owned()),
None => {
attrs.named.insert(name.to_owned(), value.to_owned());
}
},
}
}
metadata.attrs = attrs;
let prepare = |source: String| {
source
.lines()
.map(|line| line.trim_end_matches([' ', '\t', '\r', '\n', '\x0b', '\x0c', '\0']))
.collect::<Vec<_>>()
.join("\n")
};
let kind = match new.kind {
NewBlockKind::Image { target } => BlockKind::Image {
target,
src: String::new(),
alt: Vec::new(),
svg: None,
},
NewBlockKind::Listing { source } => BlockKind::Listing {
text: prepare(source),
callouts: Vec::new(),
inlines: None,
blank_edges: BlankEdges::default(),
},
NewBlockKind::Literal { source } => BlockKind::Literal {
text: prepare(source),
callouts: Vec::new(),
inlines: None,
},
NewBlockKind::Pass { source } => BlockKind::Passthrough {
text: prepare(source),
inlines: None,
},
};
Block::new(span, kind)
}
pub(crate) fn block_lines(text: &str, paragraph: bool) -> Vec<String> {
if text.is_empty() {
return Vec::new();
}
text.split('\n')
.filter(|line| !(paragraph && line.starts_with("//") && !line[2..].starts_with('/')))
.map(str::to_owned)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn parts(line: &str) -> Option<(&str, &str, Option<&str>)> {
custom_block_macro(line)
.map(|(name, target, content)| (&line[name], &line[target], content.map(|c| &line[c])))
}
#[test]
fn custom_block_macro_follows_rubys_pattern() {
assert_eq!(
parts("plantuml::a.puml[format=svg]"),
Some(("plantuml", "a.puml", Some("format=svg")))
);
assert_eq!(parts("gist::[]"), Some(("gist", "", None)));
assert_eq!(parts("my-ext::x[a]"), Some(("my-ext", "x", Some("a"))));
assert_eq!(parts("m::a[b][c]"), Some(("m", "a", Some("b][c"))));
assert_eq!(parts("m::a b[c]"), Some(("m", "a b", Some("c"))));
assert_eq!(parts("m:: a[c]"), None);
assert_eq!(parts("m::a [c]"), None);
assert_eq!(parts("-m::a[c]"), None);
assert_eq!(parts("m::a[c] "), None);
assert_eq!(parts("m:a[c]"), None);
}
#[test]
fn paragraph_lines_drop_line_comments() {
assert_eq!(
block_lines("a\n// c\n/// d\n//\nb", true),
vec!["a", "/// d", "b"]
);
assert_eq!(block_lines("a\n// c", false), vec!["a", "// c"]);
assert!(block_lines("", false).is_empty());
}
}