use std::sync::LazyLock;
use regex::Regex;
use textlane_core::hast::{Element, Node, Property, PropertyItem, PropertyValue};
use textlane_core::{Allocator, ArenaVec, Error};
use crate::actions::code::transform::{configure_transformer, read_classes};
use crate::models::highlight::{CodeBlock, CodeToken};
use crate::models::options::{ConfiguredTransformer, HighlightTransformer};
pub(crate) struct Decoration {
start: usize,
end: usize,
classes: Vec<String>,
}
const MAX_LINE_DECORATIONS: usize = 1_000;
static META_WORD: LazyLock<Option<Regex>> =
LazyLock::new(|| Regex::new(r"/((?:\\.|[^/])+)/").ok());
static ESCAPE: LazyLock<Option<Regex>> = LazyLock::new(|| Regex::new(r"\\(.)").ok());
pub(crate) fn read_decorations(
code: &str,
meta: &str,
transformers: &[HighlightTransformer],
) -> Result<Vec<Decoration>, Error> {
let mut decorations = Vec::new();
for transformer in transformers {
let ConfiguredTransformer::MetaWordHighlight { class_name } =
configure_transformer(transformer)
else {
continue;
};
if meta.is_empty() {
continue;
}
let classes = read_classes(&class_name, "highlighted-word");
for word in read_words(meta) {
for start in find_all(code, &word) {
let end = start + word.encode_utf16().count();
decorations.push(Decoration {
start,
end,
classes: classes.clone(),
});
}
}
}
check_intersections(&decorations)?;
Ok(decorations)
}
pub(crate) fn split_tokens(
block: &mut CodeBlock,
code: &str,
decorations: &[Decoration],
) {
let mut breakpoints: Vec<usize> =
decorations.iter().flat_map(|d| [d.start, d.end]).collect();
breakpoints.sort_unstable();
breakpoints.dedup();
let mut line_start = 0;
for (line, text) in block.lines.iter_mut().zip(code.split('\n')) {
let mut offset = line_start;
let mut tokens = Vec::with_capacity(line.tokens.len());
for token in std::mem::take(&mut line.tokens).into_iter().flatten() {
let length = token.text.encode_utf16().count();
let first = breakpoints.partition_point(|&point| point <= offset);
let inside: Vec<usize> = breakpoints[first..]
.iter()
.take_while(|&&point| point < offset + length)
.map(|point| point - offset)
.collect();
offset += length;
if inside.is_empty() {
tokens.push(Some(token));
continue;
}
let units: Vec<u16> = token.text.encode_utf16().collect();
let mut last = 0;
for point in inside.into_iter().chain([length]) {
tokens.push(Some(CodeToken {
text: String::from_utf16_lossy(&units[last..point]),
style: token.style.clone(),
classes: token.classes.clone(),
}));
last = point;
}
}
line.tokens = tokens;
line_start += text.encode_utf16().count() + 1;
}
}
pub(crate) fn apply_decorations<'a>(
children: &mut ArenaVec<'a, Node<'a>>,
code: &str,
decorations: &mut [Decoration],
alloc: &'a Allocator,
) -> Result<(), Error> {
let lengths: Vec<usize> = split_lines(code);
let mut starts = Vec::with_capacity(lengths.len());
let mut total = 0;
for length in &lengths {
starts.push(total);
total += length;
}
let lines: Vec<usize> = (0..children.len())
.filter(
|&index| matches!(&children[index], Node::Element(e) if e.tag_name == "span"),
)
.collect();
if lines.len() != lengths.len() {
return Err(failed(&format!(
"Number of lines in code element ({}) does not match the number of lines in the source ({}). Failed to apply decorations.",
lines.len(),
lengths.len()
)));
}
decorations.sort_by(|a, b| b.start.cmp(&a.start).then(a.end.cmp(&b.end)));
let mut applied = vec![0usize; lines.len()];
let mut prefixes: Vec<Option<Vec<usize>>> = vec![None; lines.len()];
for decoration in decorations.iter() {
let (start_line, start) = to_position(&starts, total, decoration.start);
let (end_line, end) = to_position(&starts, total, decoration.end);
if start_line != end_line {
return Err(failed("A decoration spans lines, which a meta word cannot"));
}
if applied[start_line] >= MAX_LINE_DECORATIONS {
continue;
}
applied[start_line] += 1;
let Some(Node::Element(line)) = children.get_mut(lines[start_line]) else {
continue;
};
let prefix = prefixes[start_line].get_or_insert_with(|| read_prefix(line));
apply_section(line, prefix, start, end, &decoration.classes, alloc)?;
}
Ok(())
}
fn read_words(meta: &str) -> Vec<String> {
let (Some(word), Some(escape)) = (META_WORD.as_ref(), ESCAPE.as_ref()) else {
return Vec::new();
};
word.captures_iter(meta)
.filter_map(|captures| captures.get(1))
.map(|found| escape.replace_all(found.as_str(), "$1").into_owned())
.collect()
}
fn find_all(code: &str, word: &str) -> Vec<usize> {
let mut found = Vec::new();
let mut cursor = 0;
let mut units = 0;
while let Some(at) = code[cursor..].find(word) {
let byte = cursor + at;
units += code[cursor..byte].encode_utf16().count();
found.push(units);
let next = byte + word.len().max(1);
if next > code.len() {
break;
}
units += code[byte..next].encode_utf16().count();
cursor = next;
}
found
}
fn check_intersections(items: &[Decoration]) -> Result<(), Error> {
let mut sorted: Vec<(usize, usize)> =
items.iter().map(|d| (d.start, d.end)).collect();
sorted.sort_unstable();
let mut open: Vec<(usize, usize)> = Vec::new();
for (start, end) in sorted {
while open.last().is_some_and(|&(_, last_end)| last_end <= start) {
open.pop();
}
if let Some(&(outer_start, outer_end)) = open.last()
&& end > outer_end
&& !(start == end || (outer_start == start && outer_end <= end))
{
return Err(failed("Decorations intersect."));
}
open.push((start, end));
}
Ok(())
}
fn split_lines(code: &str) -> Vec<usize> {
let mut lengths: Vec<usize> = code
.split('\n')
.map(|line| line.encode_utf16().count() + 1)
.collect();
if let Some(last) = lengths.last_mut() {
*last -= 1;
}
lengths
}
fn to_position(starts: &[usize], total: usize, index: usize) -> (usize, usize) {
if index >= total {
let last = starts.len().saturating_sub(1);
return (last, index - starts.get(last).copied().unwrap_or(0));
}
let line = starts
.partition_point(|&start| start <= index)
.saturating_sub(1);
(line, index - starts.get(line).copied().unwrap_or(0))
}
fn apply_section<'a>(
line: &mut Element<'a>,
prefix: &mut Vec<usize>,
start: usize,
end: usize,
classes: &[String],
alloc: &'a Allocator,
) -> Result<(), Error> {
let start_index = find_index(prefix, start)
.ok_or_else(|| failed("Failed to find start index for decoration"))?;
let end_index = find_index(prefix, end)
.ok_or_else(|| failed("Failed to find end index for decoration"))?;
let count = end_index.saturating_sub(start_index);
if count == line.children.len() {
add_class(&mut line.properties, classes, alloc);
return Ok(());
}
if count == 1
&& let Some(Node::Element(token)) = line.children.get_mut(start_index)
{
add_class(&mut token.properties, classes, alloc);
return Ok(());
}
if end_index < start_index {
return Ok(());
}
let mut wrapped = ArenaVec::with_capacity_in(count, alloc);
wrapped.extend(line.children.drain(start_index..end_index));
let mut properties = ArenaVec::new_in(alloc);
add_class(&mut properties, classes, alloc);
line.children.insert(
start_index,
Node::Element(Element {
tag_name: "span",
properties,
children: wrapped,
position: None,
data: None,
content: None,
}),
);
if end_index > start_index + 1 {
prefix.drain(start_index + 1..end_index);
} else if end_index == start_index {
let at = prefix[start_index];
prefix.insert(start_index + 1, at);
}
Ok(())
}
fn read_prefix(line: &Element<'_>) -> Vec<usize> {
let mut prefix = Vec::with_capacity(line.children.len() + 1);
let mut length = 0;
prefix.push(0);
for child in line.children.iter() {
length += text_length(child);
prefix.push(length);
}
prefix
}
fn find_index(prefix: &[usize], offset: usize) -> Option<usize> {
if offset == 0 {
return Some(0);
}
let at = prefix.partition_point(|&length| length < offset);
(at < prefix.len() && prefix[at] == offset && at > 0).then_some(at)
}
fn text_length(node: &Node<'_>) -> usize {
let mut length = 0;
let mut stack = vec![node];
while let Some(node) = stack.pop() {
match node {
Node::Text(text) => length += text.value.encode_utf16().count(),
Node::Element(element) => stack.extend(element.children.iter()),
_ => {}
}
}
length
}
fn add_class<'a>(
properties: &mut ArenaVec<'a, Property<'a>>,
classes: &[String],
alloc: &'a Allocator,
) {
let index = match properties
.iter()
.position(|property| property.name == "className")
{
Some(index) => index,
None => {
properties.push(Property {
name: "className",
value: PropertyValue::List(ArenaVec::new_in(alloc)),
});
properties.len() - 1
}
};
if let PropertyValue::List(items) = &mut properties[index].value {
for class in classes {
let present = items
.iter()
.any(|item| matches!(item, PropertyItem::String(n) if n == class));
if !class.is_empty() && !present {
items.push(PropertyItem::String(alloc.alloc_str(class)));
}
}
}
}
fn failed(message: &str) -> Error {
Error::new("highlight-failed", message)
}