use super::Formatter;
use crate::events::HighlightEvent;
use crate::languages::Language;
use derive_builder::Builder;
use std::io::{self, Write};
#[derive(Builder, Clone, Debug)]
#[builder(default)]
pub struct BBCodeScoped {
lang: Language,
}
impl BBCodeScopedBuilder {
pub fn new() -> Self {
Self::default()
}
}
impl BBCodeScoped {
pub fn new(lang: Language) -> Self {
Self { lang }
}
}
impl Default for BBCodeScoped {
fn default() -> Self {
Self {
lang: Language::PlainText,
}
}
}
fn scope_to_tag_name(scope: &str) -> String {
scope.replace('.', "-")
}
fn write_escaped_text(output: &mut dyn Write, text: &str) -> io::Result<()> {
for ch in text.chars() {
match ch {
'[' => output.write_all(b"[")?,
']' => output.write_all(b"]")?,
_ => write!(output, "{ch}")?,
}
}
Ok(())
}
fn tag_name(scope_index: usize, language: &str) -> String {
let scope = crate::highlights::HIGHLIGHT_NAMES
.get(scope_index)
.copied()
.unwrap_or("");
let specialized = format!("{}.{}", scope, language);
scope_to_tag_name(&specialized)
}
impl Formatter for BBCodeScoped {
fn render(
&self,
source: &str,
events: &[HighlightEvent],
output: &mut dyn Write,
) -> io::Result<()> {
let source_bytes = source.as_bytes();
let mut scope_stack: Vec<String> = Vec::new();
for event in events {
match event {
HighlightEvent::Source { start, end } => {
if *start > *end || *end > source_bytes.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"invalid source range: {}..{} (len={})",
start,
end,
source_bytes.len()
),
));
}
let text = std::str::from_utf8(&source_bytes[*start..*end])
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
write_escaped_text(output, text)?;
}
HighlightEvent::Start {
scope_index,
language,
} => {
let tag_name = tag_name(*scope_index, language);
write!(output, "[{tag_name}]")?;
scope_stack.push(tag_name);
}
HighlightEvent::End => {
if let Some(tag_name) = scope_stack.pop() {
write!(output, "[/{tag_name}]")?;
}
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scope_index(scope: &str) -> usize {
crate::highlights::HIGHLIGHT_NAMES
.iter()
.position(|candidate| *candidate == scope)
.unwrap()
}
#[test]
fn escapes_bbcode_delimiters_in_source_text() {
let formatter = BBCodeScoped::default();
let mut output = Vec::new();
formatter
.render(
"[url=x]",
&[HighlightEvent::Source { start: 0, end: 7 }],
&mut output,
)
.unwrap();
assert_eq!(String::from_utf8(output).unwrap(), "[url=x]");
}
#[test]
fn preserves_nested_scopes() {
let formatter = BBCodeScoped::default();
let mut output = Vec::new();
let string_scope = scope_index("string");
let tag_scope = scope_index("tag");
formatter
.render(
"`<a>`",
&[
HighlightEvent::Start {
scope_index: string_scope,
language: "javascript".to_string(),
},
HighlightEvent::Source { start: 0, end: 1 },
HighlightEvent::Start {
scope_index: tag_scope,
language: "html".to_string(),
},
HighlightEvent::Source { start: 1, end: 4 },
HighlightEvent::End,
HighlightEvent::Source { start: 4, end: 5 },
HighlightEvent::End,
],
&mut output,
)
.unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"[string-javascript]`[tag-html]<a>[/tag-html]`[/string-javascript]"
);
}
}