use super::{check_source_ranges, source_text, Formatter};
use crate::decorations::{
compose_line_decorations, rainbow_scope_index, Decoration, LineSelection, SteppedLineRange,
};
use crate::events::HighlightEvent;
use crate::languages::Language;
use derive_builder::Builder;
use std::io::{self, Write};
use std::ops::RangeInclusive;
const HIGHLIGHTED_TAG: &str = "highlighted";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HighlightLines {
pub lines: Vec<RangeInclusive<usize>>,
}
#[derive(Builder, Clone, Debug)]
#[builder(default)]
pub struct BBCodeScoped {
#[builder(setter(custom))]
language: Language,
highlight_lines: Option<HighlightLines>,
#[builder(setter(skip), default)]
stepped_highlight_lines: Vec<SteppedLineRange>,
}
impl BBCodeScopedBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn language(&mut self, language: Language) -> &mut Self {
self.language = Some(language);
self
}
#[deprecated(since = "0.1.0", note = "use `.language(...)` instead")]
pub fn lang(&mut self, language: Language) -> &mut Self {
self.language(language)
}
}
impl BBCodeScoped {
pub fn new(language: Language, highlight_lines: Option<HighlightLines>) -> Self {
Self {
language,
highlight_lines,
stepped_highlight_lines: Vec::new(),
}
}
#[doc(hidden)]
pub fn set_stepped_highlight_lines(&mut self, lines: Vec<SteppedLineRange>) {
self.stepped_highlight_lines = lines;
}
fn line_selection(&self) -> LineSelection {
LineSelection::new(
self.highlight_lines
.as_ref()
.map_or(&[][..], |highlight| &highlight.lines),
&self.stepped_highlight_lines,
)
}
}
impl Default for BBCodeScoped {
fn default() -> Self {
Self {
language: Language::PlainText,
highlight_lines: None,
stepped_highlight_lines: Vec::new(),
}
}
}
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<T> Formatter<T> for BBCodeScoped {
fn language(&self) -> Language {
self.language
}
fn render(
&self,
source: &str,
events: &[HighlightEvent<'_, T>],
output: &mut dyn Write,
) -> io::Result<()> {
let source_bytes = source.as_bytes();
let mut scope_stack: Vec<String> = Vec::new();
let selection = self.line_selection();
let composed;
let events: &[HighlightEvent<'_, T>] = if selection.is_empty() {
events
} else {
check_source_ranges(source_bytes, events)?;
composed = compose_line_decorations(source, events, &selection);
&composed
};
let mut decorations = Vec::new();
for event in events {
match event {
HighlightEvent::Source { start, end } => {
write_escaped_text(output, source_text(source_bytes, *start, *end)?)?;
}
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}]")?;
}
}
HighlightEvent::DecorationStart { decoration } => {
decorations.push(*decoration);
match decoration {
Decoration::Line {
highlighted: true, ..
} => write!(output, "[{HIGHLIGHTED_TAG}]")?,
Decoration::Line { .. } => {}
Decoration::RainbowBracket { depth } => {
let tag_name =
tag_name(rainbow_scope_index(*depth), self.language.id_name());
write!(output, "[{tag_name}]")?;
}
Decoration::DiffWord { kind } => {
let tag_name = tag_name(kind.scope_index(), self.language.id_name());
write!(output, "[{tag_name}]")?;
}
}
}
HighlightEvent::DecorationEnd => match decorations.pop() {
Some(Decoration::Line {
highlighted: true, ..
}) => write!(output, "[/{HIGHLIGHTED_TAG}]")?,
Some(Decoration::Line { .. }) | None => {}
Some(Decoration::RainbowBracket { depth }) => {
let tag_name =
tag_name(rainbow_scope_index(depth), self.language.id_name());
write!(output, "[/{tag_name}]")?;
}
Some(Decoration::DiffWord { kind }) => {
let tag_name = tag_name(kind.scope_index(), self.language.id_name());
write!(output, "[/{tag_name}]")?;
}
},
HighlightEvent::AnnotationStart { .. } | HighlightEvent::AnnotationEnd => {}
}
}
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();
let events: [HighlightEvent<'_, ()>; 1] = [HighlightEvent::Source { start: 0, end: 7 }];
formatter.render("[url=x]", &events, &mut output).unwrap();
assert_eq!(String::from_utf8(output).unwrap(), "[url=x]");
}
#[test]
fn leaves_the_stream_alone_when_no_line_is_highlighted() {
let formatter = BBCodeScoped::default();
let mut output = Vec::new();
let events: [HighlightEvent<'_, ()>; 3] = [
HighlightEvent::Start {
scope_index: scope_index("string"),
language: "json".to_string(),
},
HighlightEvent::Source { start: 0, end: 3 },
HighlightEvent::End,
];
formatter.render("a\nb", &events, &mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"[string-json]a\nb[/string-json]"
);
}
#[test]
fn wraps_a_highlighted_line_and_the_newline_that_ends_it() {
let formatter = BBCodeScoped::new(
Language::PlainText,
Some(HighlightLines {
lines: std::iter::once(1..=1).collect(),
}),
);
let mut output = Vec::new();
let events: [HighlightEvent<'_, ()>; 1] = [HighlightEvent::Source { start: 0, end: 3 }];
formatter.render("a\nb", &events, &mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"[highlighted]a\n[/highlighted]b"
);
}
#[test]
fn a_scope_crossing_a_highlighted_line_boundary_nests_inside_it() {
let formatter = BBCodeScoped::new(
Language::PlainText,
Some(HighlightLines {
lines: std::iter::once(2..=2).collect(),
}),
);
let mut output = Vec::new();
let events: [HighlightEvent<'_, ()>; 3] = [
HighlightEvent::Start {
scope_index: scope_index("string"),
language: "json".to_string(),
},
HighlightEvent::Source { start: 0, end: 3 },
HighlightEvent::End,
];
formatter.render("a\nb", &events, &mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"[string-json]a\n[/string-json][highlighted][string-json]b[/string-json][/highlighted]"
);
}
#[test]
fn an_out_of_range_source_is_refused_with_or_without_highlighting() {
let events: [HighlightEvent<'_, ()>; 1] = [HighlightEvent::Source { start: 0, end: 99 }];
for highlight_lines in [
None,
Some(HighlightLines {
lines: std::iter::once(1..=1).collect(),
}),
] {
let formatter = BBCodeScoped::new(Language::PlainText, highlight_lines);
let mut output = Vec::new();
let error = formatter
.render("a\nb", &events, &mut output)
.expect_err("an out-of-range source range is invalid data");
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
}
#[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");
let events: [HighlightEvent<'_, ()>; 7] = [
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,
];
formatter.render("`<a>`", &events, &mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"[string-javascript]`[tag-html]<a>[/tag-html]`[/string-javascript]"
);
}
}