use std::ops::Range;
use std::sync::Arc;
use rich::measure::Measurement;
use rich::text::Span;
use rich::{Console, ConsoleOptions, Renderable, Segment, Text};
use rich_plugin_api::{PluginError, TextTransform};
use crate::model::MicroAsset;
use crate::name::is_valid_name;
use crate::registry::MicroRegistry;
use crate::render::{
FallbackPreference, MicroMeta, PAD_CELL, SENTINEL_BASE, SENTINEL_LAST, fallback_cells,
is_sentinel, placeholder_with,
};
pub const TOKEN_PREFIX: &str = ":micro:";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Diagnostic {
pub offset: usize,
pub token: String,
pub kind: DiagnosticKind,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DiagnosticKind {
UnknownAsset(String),
Malformed,
Reserved,
}
impl std::fmt::Display for Diagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.kind {
DiagnosticKind::UnknownAsset(name) => {
write!(f, "unknown micro asset {name:?} at byte {}", self.offset)
}
DiagnosticKind::Malformed => write!(
f,
"{:?} at byte {} is not a micro asset token (write :micro:name:)",
self.token, self.offset
),
DiagnosticKind::Reserved => write!(
f,
"{:?} at byte {} was left as written: the input holds reserved \
private-use characters",
self.token, self.offset
),
}
}
}
enum Token {
Escape(usize),
Asset {
range: Range<usize>,
name: String,
},
Malformed(Range<usize>),
}
fn scan(s: &str, skip_tags: bool) -> Vec<Token> {
let bytes = s.as_bytes();
let mut tokens = Vec::new();
let mut at = 0;
while at < bytes.len() {
if skip_tags && bytes[at] == b'[' {
let backslashes = bytes[..at]
.iter()
.rev()
.take_while(|b| **b == b'\\')
.count();
let opens_tag = bytes
.get(at + 1)
.is_some_and(|b| b.is_ascii_lowercase() || matches!(b, b'#' | b'/' | b'@'));
if backslashes % 2 == 0 && opens_tag {
if let Some(close) = s[at + 1..].find([']', '['])
&& bytes[at + 1 + close] == b']'
{
at += close + 2;
continue;
}
}
}
if s[at..].starts_with(TOKEN_PREFIX) {
if at > 0 && bytes[at - 1] == b'\\' {
tokens.push(Token::Escape(at - 1));
at += TOKEN_PREFIX.len();
continue;
}
let body = at + TOKEN_PREFIX.len();
let length = bytes[body..]
.iter()
.take_while(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'/')
})
.count();
let name = &s[body..body + length];
if bytes.get(body + length) == Some(&b':') && is_valid_name(name) {
tokens.push(Token::Asset {
range: at..body + length + 1,
name: name.to_string(),
});
at = body + length + 1;
} else {
tokens.push(Token::Malformed(at..body));
at = body;
}
continue;
}
at += s[at..].chars().next().map_or(1, char::len_utf8);
}
tokens
}
struct Edit {
range: Range<usize>,
insert: Option<Text>,
}
fn splice(text: &Text, edits: &[Edit]) -> Text {
if edits.is_empty() {
return text.clone();
}
let plain = text.plain();
let mut out = String::with_capacity(plain.len());
let mut moved: Vec<(Range<usize>, Range<usize>)> = Vec::with_capacity(edits.len());
let mut inserted: Vec<Span> = Vec::new();
let mut cursor = 0;
for edit in edits {
out.push_str(&plain[cursor..edit.range.start]);
let start = out.len();
if let Some(insert) = &edit.insert {
out.push_str(insert.plain());
for span in insert.spans() {
inserted.push(Span {
start: span.start + start,
end: span.end + start,
style: span.style.clone(),
});
}
}
moved.push((edit.range.clone(), start..out.len()));
cursor = edit.range.end;
}
out.push_str(&plain[cursor..]);
let map = |position: usize, is_end: bool| -> usize {
let mut delta: isize = 0;
for (old, new) in &moved {
if position <= old.start {
break;
}
if position < old.end {
return if is_end { new.end } else { new.start };
}
delta = new.end as isize - old.end as isize;
}
(position as isize + delta) as usize
};
let mut result = text.blank_copy();
result.append(&out, None);
let mut spans: Vec<Span> = text
.spans()
.iter()
.filter_map(|span| {
let start = map(span.start, false);
let end = map(span.end, true);
(end > start).then(|| Span {
start,
end,
style: span.style.clone(),
})
})
.collect();
spans.extend(inserted);
result.set_spans(spans);
result
}
pub fn expand(
text: &Text,
registry: &MicroRegistry,
preference: FallbackPreference,
) -> (Text, Vec<Diagnostic>) {
let plain = text.plain();
let mut edits = Vec::new();
let mut diagnostics = Vec::new();
for token in scan(plain, false) {
match token {
Token::Escape(at) => edits.push(Edit {
range: at..at + 1,
insert: None,
}),
Token::Asset { range, name } => match registry.resolve(&name) {
Some(asset) => edits.push(Edit {
range,
insert: Some(placeholder_with(asset, preference, MicroMeta::new(asset))),
}),
None => diagnostics.push(Diagnostic {
offset: range.start,
token: plain[range].to_string(),
kind: DiagnosticKind::UnknownAsset(name),
}),
},
Token::Malformed(range) => diagnostics.push(Diagnostic {
offset: range.start,
token: plain[range].to_string(),
kind: DiagnosticKind::Malformed,
}),
}
}
(splice(text, &edits), diagnostics)
}
#[derive(Clone, Debug)]
pub struct PreparedMarkup {
markup: String,
tokens: Vec<(char, Arc<MicroAsset>)>,
preference: FallbackPreference,
diagnostics: Vec<Diagnostic>,
}
impl PreparedMarkup {
pub fn new(markup: &str, registry: &MicroRegistry, preference: FallbackPreference) -> Self {
let mut prepared = PreparedMarkup {
markup: String::with_capacity(markup.len()),
tokens: Vec::new(),
preference,
diagnostics: Vec::new(),
};
let usable = !markup.chars().any(is_sentinel);
let mut cursor = 0;
for token in scan(markup, true) {
match token {
Token::Escape(at) => {
prepared.markup.push_str(&markup[cursor..at]);
cursor = at + 1;
}
Token::Asset { range, name } => match registry.resolve(&name) {
Some(asset)
if usable
&& prepared.tokens.len()
<= (SENTINEL_LAST - SENTINEL_BASE) as usize =>
{
let sentinel = char::from_u32(SENTINEL_BASE + prepared.tokens.len() as u32)
.expect("private-use code point");
prepared.markup.push_str(&markup[cursor..range.start]);
prepared.markup.push(sentinel);
prepared.tokens.push((sentinel, Arc::clone(asset)));
cursor = range.end;
}
Some(_) => prepared.diagnostics.push(Diagnostic {
offset: range.start,
token: markup[range].to_string(),
kind: DiagnosticKind::Reserved,
}),
None => prepared.diagnostics.push(Diagnostic {
offset: range.start,
token: markup[range].to_string(),
kind: DiagnosticKind::UnknownAsset(name),
}),
},
Token::Malformed(range) => prepared.diagnostics.push(Diagnostic {
offset: range.start,
token: markup[range].to_string(),
kind: DiagnosticKind::Malformed,
}),
}
}
prepared.markup.push_str(&markup[cursor..]);
prepared
}
pub fn markup(&self) -> &str {
&self.markup
}
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
pub fn finish(&self, parsed: &Text) -> Text {
let plain = parsed.plain();
let mut edits = Vec::new();
for (at, c) in plain.char_indices() {
if !is_sentinel(c) {
continue;
}
if let Some((_, asset)) = self.tokens.iter().find(|(s, _)| *s == c) {
edits.push(Edit {
range: at..at + c.len_utf8(),
insert: Some(placeholder_with(
asset,
self.preference,
MicroMeta::new(asset),
)),
});
}
}
splice(parsed, &edits)
}
}
pub fn render_markup(
console: &Console,
markup: &str,
registry: &MicroRegistry,
preference: FallbackPreference,
) -> (Text, Vec<Diagnostic>) {
let prepared = PreparedMarkup::new(markup, registry, preference);
let parsed = console.render_str(prepared.markup(), None);
(prepared.finish(&parsed), prepared.diagnostics)
}
pub fn markup_text(
markup: &str,
registry: &MicroRegistry,
emoji: bool,
preference: FallbackPreference,
) -> rich::errors::Result<(Text, Vec<Diagnostic>)> {
let prepared = PreparedMarkup::new(markup, registry, preference);
let source = prepared.markup();
let parsed = if !source.contains('[') {
Text::new(if emoji {
rich::emoji::replace(source)
} else {
source.to_string()
})
} else if emoji {
rich::markup::render_emoji(source)?
} else {
rich::markup::render(source)?
};
Ok((prepared.finish(&parsed), prepared.diagnostics))
}
fn markdown_literal(source: &str) -> Vec<Range<usize>> {
use pulldown_cmark::{Event, LinkType, Options, Parser, Tag, TagEnd};
let parser = Parser::new_ext(source, Options::ENABLE_TABLES);
let mut ranges: Vec<Range<usize>> = parser
.reference_definitions()
.iter()
.map(|(_, def)| def.span.clone())
.collect();
let mut links: Vec<(Range<usize>, usize)> = Vec::new();
for (event, range) in parser.into_offset_iter() {
match event {
Event::Code(_) | Event::Start(Tag::CodeBlock(_)) => ranges.push(range.clone()),
Event::Start(Tag::Link {
link_type: LinkType::Autolink | LinkType::Email,
..
}) => ranges.push(range.clone()),
Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
links.push((range.clone(), range.start + 1));
continue;
}
Event::End(TagEnd::Link | TagEnd::Image) => {
if let Some((whole, text_end)) = links.pop() {
ranges.push(text_end..whole.end);
if let Some(outer) = links.last_mut() {
outer.1 = outer.1.max(whole.end);
}
}
continue;
}
_ => {}
}
if let Some(open) = links.last_mut()
&& !range.is_empty()
&& range.end <= open.0.end
{
open.1 = open.1.max(range.end);
}
}
ranges
}
#[derive(Clone, Debug)]
pub struct PreparedMarkdown {
source: String,
tokens: Vec<(char, Arc<MicroAsset>, MicroMeta)>,
preference: FallbackPreference,
diagnostics: Vec<Diagnostic>,
}
impl PreparedMarkdown {
pub fn new(source: &str, registry: &MicroRegistry, preference: FallbackPreference) -> Self {
let mut prepared = PreparedMarkdown {
source: String::with_capacity(source.len()),
tokens: Vec::new(),
preference,
diagnostics: Vec::new(),
};
if !source.contains(TOKEN_PREFIX) {
prepared.source.push_str(source);
return prepared;
}
let usable = !source.chars().any(is_sentinel);
let literal = markdown_literal(source);
let literal_at = |token: &Range<usize>| {
literal
.iter()
.any(|range| range.start < token.end && token.start < range.end)
};
let mut cursor = 0;
for token in scan(source, false) {
match token {
Token::Escape(_) => {}
Token::Asset { range, .. } | Token::Malformed(range) if literal_at(&range) => {}
Token::Asset { range, name } => match registry.resolve(&name) {
Some(asset)
if usable
&& prepared.tokens.len()
<= (SENTINEL_LAST - SENTINEL_BASE) as usize =>
{
let sentinel = char::from_u32(SENTINEL_BASE + prepared.tokens.len() as u32)
.expect("private-use code point");
prepared.source.push_str(&source[cursor..range.start]);
prepared
.source
.extend(std::iter::repeat_n(sentinel, asset.cols()));
prepared
.tokens
.push((sentinel, Arc::clone(asset), MicroMeta::new(asset)));
cursor = range.end;
}
Some(_) => prepared.diagnostics.push(Diagnostic {
offset: range.start,
token: source[range].to_string(),
kind: DiagnosticKind::Reserved,
}),
None => prepared.diagnostics.push(Diagnostic {
offset: range.start,
token: source[range].to_string(),
kind: DiagnosticKind::UnknownAsset(name),
}),
},
Token::Malformed(range) => prepared.diagnostics.push(Diagnostic {
offset: range.start,
token: source[range].to_string(),
kind: DiagnosticKind::Malformed,
}),
}
}
prepared.source.push_str(&source[cursor..]);
prepared
}
pub fn source(&self) -> &str {
&self.source
}
pub fn has_assets(&self) -> bool {
!self.tokens.is_empty()
}
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
pub fn view<R: Renderable>(&self, inner: R) -> MarkdownView<R> {
MarkdownView {
inner,
tokens: self.tokens.clone(),
preference: self.preference,
}
}
}
pub struct MarkdownView<R> {
inner: R,
tokens: Vec<(char, Arc<MicroAsset>, MicroMeta)>,
preference: FallbackPreference,
}
impl<R> MarkdownView<R> {
fn restore(&self, segment: Segment, out: &mut Vec<Segment>) {
if segment.control || !segment.text.chars().any(is_sentinel) {
out.push(segment);
return;
}
let text = segment.text.as_str();
let mut plain_start = 0;
let mut chars = text.char_indices().peekable();
while let Some((at, c)) = chars.next() {
if !is_sentinel(c) {
continue;
}
let mut count = 1;
while chars.peek().is_some_and(|(_, next)| *next == c) {
chars.next();
count += 1;
}
let end = chars.peek().map_or(text.len(), |(next, _)| *next);
if at > plain_start {
out.push(Segment::new(&text[plain_start..at], segment.style.clone()));
}
plain_start = end;
let blank = PAD_CELL.to_string().repeat(count);
let Some((_, asset, meta)) = self.tokens.iter().find(|(s, _, _)| *s == c) else {
out.push(Segment::new(blank, segment.style.clone()));
continue;
};
let tag = meta.to_style();
let style = Some(match &segment.style {
Some(style) => style.combine(&tag),
None => tag,
});
let cells = if count == meta.cols {
fallback_cells(asset, self.preference)
} else {
blank
};
out.push(Segment::new(cells, style));
}
if plain_start < text.len() {
out.push(Segment::new(&text[plain_start..], segment.style.clone()));
}
}
}
impl<R: Renderable> Renderable for MarkdownView<R> {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let segments = self.inner.rich_render(console, options);
if self.tokens.is_empty() {
return segments;
}
let mut out = Vec::with_capacity(segments.len());
for segment in segments {
self.restore(segment, &mut out);
}
out
}
fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
self.inner.measure(console, options)
}
}
#[derive(Clone, Debug)]
pub struct MicroTransform {
registry: Arc<MicroRegistry>,
preference: FallbackPreference,
}
impl MicroTransform {
pub fn new(registry: Arc<MicroRegistry>) -> Self {
MicroTransform {
registry,
preference: FallbackPreference::default(),
}
}
#[must_use]
pub fn preference(mut self, preference: FallbackPreference) -> Self {
self.preference = preference;
self
}
}
impl TextTransform for MicroTransform {
fn transform(&self, text: Text) -> Result<Text, PluginError> {
Ok(expand(&text, &self.registry, self.preference).0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scanning() {
let tokens = scan(
r"a :micro:x/y: \:micro:z: :micro:Bad: [link=:micro:q:]t[/]",
true,
);
let kinds: Vec<String> = tokens
.iter()
.map(|t| match t {
Token::Escape(at) => format!("esc@{at}"),
Token::Asset { name, .. } => format!("asset {name}"),
Token::Malformed(r) => format!("bad@{}", r.start),
})
.collect();
assert_eq!(kinds, ["asset x/y", "esc@14", "bad@25"]);
}
#[test]
fn splice_moves_spans() {
let mut text = Text::new("ab:micro:x:cd");
text.stylize("red", 0, 13);
text.stylize("blue", 5, 7); text.stylize("green", 11, 13); let edits = [Edit {
range: 2..11,
insert: Some(Text::new("XY")),
}];
let out = splice(&text, &edits);
assert_eq!(out.plain(), "abXYcd");
let ranges: Vec<(usize, usize)> = out.spans().iter().map(|s| (s.start, s.end)).collect();
assert_eq!(ranges, [(0, 6), (2, 4), (4, 6)]);
}
}