Skip to main content

rich_micro/
markup.rs

1//! `:micro:name:` markup (#569, #570).
2//!
3//! `:micro:name:` is the one syntax (`[micro=…]` would collide with core's
4//! markup tags). `\:micro:` escapes it: the backslash is dropped and the rest
5//! stays literal. A well-formed token naming no known asset stays literal
6//! too, and is reported as a [`Diagnostic`]; so is `:micro:` followed by
7//! something that is not a name.
8//!
9//! Two ways in:
10//!
11//! - **Before parsing** ([`PreparedMarkup`], [`render_markup`],
12//!   [`markup_text`]): tokens are swapped for private-use stand-ins, core
13//!   parses the rest (markup tags, `:emoji:` codes), and the stand-ins become
14//!   placeholders. This is the robust path: `:micro:rocket::fire:` gets both,
15//!   and core's emoji pass never sees a micro token.
16//! - **After parsing** ([`expand`], [`MicroTransform`], `MicroExt`): tokens in
17//!   a [`Text`]'s plain string are replaced. Use it for text that was never
18//!   markup, or in a transform pipeline. Core's emoji pass leaves
19//!   `:micro:name:` alone, but it consumes the colons it scans, so a
20//!   `:code:` written directly after a token (`:micro:rocket::fire:`) is not
21//!   an emoji on this path.
22
23use std::ops::Range;
24use std::sync::Arc;
25
26use rich::measure::Measurement;
27use rich::text::Span;
28use rich::{Console, ConsoleOptions, Renderable, Segment, Text};
29use rich_plugin_api::{PluginError, TextTransform};
30
31use crate::model::MicroAsset;
32use crate::name::is_valid_name;
33use crate::registry::MicroRegistry;
34use crate::render::{
35    FallbackPreference, MicroMeta, PAD_CELL, SENTINEL_BASE, SENTINEL_LAST, fallback_cells,
36    is_sentinel, placeholder_with,
37};
38
39/// What opens a token.
40pub const TOKEN_PREFIX: &str = ":micro:";
41
42/// Something in the input that was left as written.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct Diagnostic {
45    /// Byte offset of the token in the input (the markup, or the text's
46    /// plain string).
47    pub offset: usize,
48    /// The token as written.
49    pub token: String,
50    pub kind: DiagnosticKind,
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub enum DiagnosticKind {
55    /// A well-formed name no layer has.
56    UnknownAsset(String),
57    /// `:micro:` not followed by `name:`.
58    Malformed,
59    /// Not expanded before parsing: the input already holds characters from
60    /// U+100000..=U+10FFFD, which the pre-pass reserves as stand-ins (or
61    /// more tokens than there are stand-ins). Use [`expand`] on such text.
62    Reserved,
63}
64
65impl std::fmt::Display for Diagnostic {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match &self.kind {
68            DiagnosticKind::UnknownAsset(name) => {
69                write!(f, "unknown micro asset {name:?} at byte {}", self.offset)
70            }
71            DiagnosticKind::Malformed => write!(
72                f,
73                "{:?} at byte {} is not a micro asset token (write :micro:name:)",
74                self.token, self.offset
75            ),
76            DiagnosticKind::Reserved => write!(
77                f,
78                "{:?} at byte {} was left as written: the input holds reserved \
79                 private-use characters",
80                self.token, self.offset
81            ),
82        }
83    }
84}
85
86enum Token {
87    /// `\:micro:` — drop the backslash at this offset.
88    Escape(usize),
89    Asset {
90        range: Range<usize>,
91        name: String,
92    },
93    Malformed(Range<usize>),
94}
95
96/// Find tokens in `s`. With `skip_tags`, text inside core markup tags
97/// (`[bold]`, `[link=…]`) is left alone.
98fn scan(s: &str, skip_tags: bool) -> Vec<Token> {
99    let bytes = s.as_bytes();
100    let mut tokens = Vec::new();
101    let mut at = 0;
102    while at < bytes.len() {
103        if skip_tags && bytes[at] == b'[' {
104            let backslashes = bytes[..at]
105                .iter()
106                .rev()
107                .take_while(|b| **b == b'\\')
108                .count();
109            let opens_tag = bytes
110                .get(at + 1)
111                .is_some_and(|b| b.is_ascii_lowercase() || matches!(b, b'#' | b'/' | b'@'));
112            if backslashes % 2 == 0 && opens_tag {
113                // Core's tag: `[` ... the first `]`, with no `[` between.
114                if let Some(close) = s[at + 1..].find([']', '['])
115                    && bytes[at + 1 + close] == b']'
116                {
117                    at += close + 2;
118                    continue;
119                }
120            }
121        }
122        if s[at..].starts_with(TOKEN_PREFIX) {
123            if at > 0 && bytes[at - 1] == b'\\' {
124                tokens.push(Token::Escape(at - 1));
125                at += TOKEN_PREFIX.len();
126                continue;
127            }
128            let body = at + TOKEN_PREFIX.len();
129            let length = bytes[body..]
130                .iter()
131                .take_while(|b| {
132                    b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'/')
133                })
134                .count();
135            let name = &s[body..body + length];
136            if bytes.get(body + length) == Some(&b':') && is_valid_name(name) {
137                tokens.push(Token::Asset {
138                    range: at..body + length + 1,
139                    name: name.to_string(),
140                });
141                at = body + length + 1;
142            } else {
143                tokens.push(Token::Malformed(at..body));
144                at = body;
145            }
146            continue;
147        }
148        at += s[at..].chars().next().map_or(1, char::len_utf8);
149    }
150    tokens
151}
152
153/// One replacement of a byte range in a text.
154struct Edit {
155    range: Range<usize>,
156    insert: Option<Text>,
157}
158
159/// Replace byte ranges of `text` (sorted, disjoint), moving every span with
160/// them: a span edge inside a replaced range snaps to the replacement's
161/// edge, so a style over a token covers its placeholder. Each inserted
162/// text's spans (the placeholder tag) go on top.
163fn splice(text: &Text, edits: &[Edit]) -> Text {
164    if edits.is_empty() {
165        return text.clone();
166    }
167    let plain = text.plain();
168    let mut out = String::with_capacity(plain.len());
169    // (old range, new range) per edit.
170    let mut moved: Vec<(Range<usize>, Range<usize>)> = Vec::with_capacity(edits.len());
171    let mut inserted: Vec<Span> = Vec::new();
172    let mut cursor = 0;
173    for edit in edits {
174        out.push_str(&plain[cursor..edit.range.start]);
175        let start = out.len();
176        if let Some(insert) = &edit.insert {
177            out.push_str(insert.plain());
178            for span in insert.spans() {
179                inserted.push(Span {
180                    start: span.start + start,
181                    end: span.end + start,
182                    style: span.style.clone(),
183                });
184            }
185        }
186        moved.push((edit.range.clone(), start..out.len()));
187        cursor = edit.range.end;
188    }
189    out.push_str(&plain[cursor..]);
190    let map = |position: usize, is_end: bool| -> usize {
191        let mut delta: isize = 0;
192        for (old, new) in &moved {
193            if position <= old.start {
194                break;
195            }
196            if position < old.end {
197                return if is_end { new.end } else { new.start };
198            }
199            delta = new.end as isize - old.end as isize;
200        }
201        (position as isize + delta) as usize
202    };
203    let mut result = text.blank_copy();
204    result.append(&out, None);
205    let mut spans: Vec<Span> = text
206        .spans()
207        .iter()
208        .filter_map(|span| {
209            let start = map(span.start, false);
210            let end = map(span.end, true);
211            (end > start).then(|| Span {
212                start,
213                end,
214                style: span.style.clone(),
215            })
216        })
217        .collect();
218    spans.extend(inserted);
219    result.set_spans(spans);
220    result
221}
222
223/// Replace every `:micro:name:` token in `text`'s plain string with its
224/// placeholder, and drop the backslash of each `\:micro:`. Unknown and
225/// malformed tokens stay as written and are reported.
226pub fn expand(
227    text: &Text,
228    registry: &MicroRegistry,
229    preference: FallbackPreference,
230) -> (Text, Vec<Diagnostic>) {
231    let plain = text.plain();
232    let mut edits = Vec::new();
233    let mut diagnostics = Vec::new();
234    for token in scan(plain, false) {
235        match token {
236            Token::Escape(at) => edits.push(Edit {
237                range: at..at + 1,
238                insert: None,
239            }),
240            Token::Asset { range, name } => match registry.resolve(&name) {
241                Some(asset) => edits.push(Edit {
242                    range,
243                    insert: Some(placeholder_with(asset, preference, MicroMeta::new(asset))),
244                }),
245                None => diagnostics.push(Diagnostic {
246                    offset: range.start,
247                    token: plain[range].to_string(),
248                    kind: DiagnosticKind::UnknownAsset(name),
249                }),
250            },
251            Token::Malformed(range) => diagnostics.push(Diagnostic {
252                offset: range.start,
253                token: plain[range].to_string(),
254                kind: DiagnosticKind::Malformed,
255            }),
256        }
257    }
258    (splice(text, &edits), diagnostics)
259}
260
261/// Markup with its micro tokens swapped for stand-ins, ready for core to
262/// parse; [`finish`](Self::finish) turns the stand-ins into placeholders.
263///
264/// ```
265/// use rich_micro::{FallbackPreference, MicroAsset, MicroRegistry, Layer, PreparedMarkup};
266///
267/// let mut registry = MicroRegistry::new();
268/// registry.add(Layer::Inline, MicroAsset::new("ship", "rocket")?.with_emoji("🚀")?)?;
269/// let prepared = PreparedMarkup::new("[b]Go[/b] :micro:ship::fire:", &registry, FallbackPreference::Emoji);
270/// let parsed = rich::markup::render_emoji(prepared.markup()).unwrap();
271/// let text = prepared.finish(&parsed);
272/// assert_eq!(text.plain(), "Go 🚀🔥");
273/// # Ok::<(), rich_micro::MicroError>(())
274/// ```
275#[derive(Clone, Debug)]
276pub struct PreparedMarkup {
277    markup: String,
278    /// Stand-in, and the asset it stands for.
279    tokens: Vec<(char, Arc<MicroAsset>)>,
280    preference: FallbackPreference,
281    diagnostics: Vec<Diagnostic>,
282}
283
284impl PreparedMarkup {
285    pub fn new(markup: &str, registry: &MicroRegistry, preference: FallbackPreference) -> Self {
286        let mut prepared = PreparedMarkup {
287            markup: String::with_capacity(markup.len()),
288            tokens: Vec::new(),
289            preference,
290            diagnostics: Vec::new(),
291        };
292        // Input that already holds stand-in characters, or more tokens than
293        // there are stand-ins, is expanded after parsing instead.
294        let usable = !markup.chars().any(is_sentinel);
295        let mut cursor = 0;
296        for token in scan(markup, true) {
297            match token {
298                Token::Escape(at) => {
299                    prepared.markup.push_str(&markup[cursor..at]);
300                    cursor = at + 1;
301                }
302                Token::Asset { range, name } => match registry.resolve(&name) {
303                    Some(asset)
304                        if usable
305                            && prepared.tokens.len()
306                                <= (SENTINEL_LAST - SENTINEL_BASE) as usize =>
307                    {
308                        let sentinel = char::from_u32(SENTINEL_BASE + prepared.tokens.len() as u32)
309                            .expect("private-use code point");
310                        prepared.markup.push_str(&markup[cursor..range.start]);
311                        prepared.markup.push(sentinel);
312                        prepared.tokens.push((sentinel, Arc::clone(asset)));
313                        cursor = range.end;
314                    }
315                    Some(_) => prepared.diagnostics.push(Diagnostic {
316                        offset: range.start,
317                        token: markup[range].to_string(),
318                        kind: DiagnosticKind::Reserved,
319                    }),
320                    None => prepared.diagnostics.push(Diagnostic {
321                        offset: range.start,
322                        token: markup[range].to_string(),
323                        kind: DiagnosticKind::UnknownAsset(name),
324                    }),
325                },
326                Token::Malformed(range) => prepared.diagnostics.push(Diagnostic {
327                    offset: range.start,
328                    token: markup[range].to_string(),
329                    kind: DiagnosticKind::Malformed,
330                }),
331            }
332        }
333        prepared.markup.push_str(&markup[cursor..]);
334        prepared
335    }
336
337    /// The markup to hand to core.
338    pub fn markup(&self) -> &str {
339        &self.markup
340    }
341
342    /// What was left as written.
343    pub fn diagnostics(&self) -> &[Diagnostic] {
344        &self.diagnostics
345    }
346
347    /// Replace the stand-ins in `parsed` (core's rendering of
348    /// [`markup`](Self::markup)) with placeholders.
349    pub fn finish(&self, parsed: &Text) -> Text {
350        let plain = parsed.plain();
351        let mut edits = Vec::new();
352        for (at, c) in plain.char_indices() {
353            if !is_sentinel(c) {
354                continue;
355            }
356            if let Some((_, asset)) = self.tokens.iter().find(|(s, _)| *s == c) {
357                edits.push(Edit {
358                    range: at..at + c.len_utf8(),
359                    insert: Some(placeholder_with(
360                        asset,
361                        self.preference,
362                        MicroMeta::new(asset),
363                    )),
364                });
365            }
366        }
367        splice(parsed, &edits)
368    }
369}
370
371/// Parse `markup` the way `console` parses printed strings (its emoji and
372/// highlighting settings), with micro tokens as placeholders.
373pub fn render_markup(
374    console: &Console,
375    markup: &str,
376    registry: &MicroRegistry,
377    preference: FallbackPreference,
378) -> (Text, Vec<Diagnostic>) {
379    let prepared = PreparedMarkup::new(markup, registry, preference);
380    let parsed = console.render_str(prepared.markup(), None);
381    (prepared.finish(&parsed), prepared.diagnostics)
382}
383
384/// Parse `markup` with core's markup parser (and, with `emoji`, its
385/// `:emoji:` codes), with micro tokens as placeholders.
386pub fn markup_text(
387    markup: &str,
388    registry: &MicroRegistry,
389    emoji: bool,
390    preference: FallbackPreference,
391) -> rich::errors::Result<(Text, Vec<Diagnostic>)> {
392    let prepared = PreparedMarkup::new(markup, registry, preference);
393    let source = prepared.markup();
394    let parsed = if !source.contains('[') {
395        Text::new(if emoji {
396            rich::emoji::replace(source)
397        } else {
398            source.to_string()
399        })
400    } else if emoji {
401        rich::markup::render_emoji(source)?
402    } else {
403        rich::markup::render(source)?
404    };
405    Ok((prepared.finish(&parsed), prepared.diagnostics))
406}
407
408/// Byte ranges of `source` whose tokens stay as written: what the Markdown
409/// parser calls code (fenced and indented blocks, wherever they sit, and
410/// inline spans, across line breaks too), and what is a URL rather than
411/// text (link and image destinations, autolinks, reference definitions).
412/// Parsed with the options core's `Markdown` uses, so the two agree.
413fn markdown_literal(source: &str) -> Vec<Range<usize>> {
414    use pulldown_cmark::{Event, LinkType, Options, Parser, Tag, TagEnd};
415    let parser = Parser::new_ext(source, Options::ENABLE_TABLES);
416    let mut ranges: Vec<Range<usize>> = parser
417        .reference_definitions()
418        .iter()
419        .map(|(_, def)| def.span.clone())
420        .collect();
421    // (whole link or image, end of the last thing inside it) per open link.
422    let mut links: Vec<(Range<usize>, usize)> = Vec::new();
423    for (event, range) in parser.into_offset_iter() {
424        match event {
425            Event::Code(_) | Event::Start(Tag::CodeBlock(_)) => ranges.push(range.clone()),
426            Event::Start(Tag::Link {
427                link_type: LinkType::Autolink | LinkType::Email,
428                ..
429            }) => ranges.push(range.clone()),
430            Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
431                links.push((range.clone(), range.start + 1));
432                continue;
433            }
434            Event::End(TagEnd::Link | TagEnd::Image) => {
435                // From the text's end to the link's: `](dest "title")` or
436                // `][label]`.
437                if let Some((whole, text_end)) = links.pop() {
438                    ranges.push(text_end..whole.end);
439                    if let Some(outer) = links.last_mut() {
440                        outer.1 = outer.1.max(whole.end);
441                    }
442                }
443                continue;
444            }
445            _ => {}
446        }
447        if let Some(open) = links.last_mut()
448            && !range.is_empty()
449            && range.end <= open.0.end
450        {
451            open.1 = open.1.max(range.end);
452        }
453    }
454    ranges
455}
456
457/// A Markdown source with its micro tokens swapped for stand-in cells, ready
458/// for a Markdown renderer; [`view`](Self::view) turns the stand-ins in its
459/// output into placeholders.
460///
461/// Markdown has no place for a tag, so each token becomes as many copies of
462/// one private-use character as the asset has columns (one cell each), and
463/// the layout sizes it as the asset. Tokens in code (blocks and inline
464/// spans) and in URLs (link and image destinations, autolinks, reference
465/// definitions) stay as written; so does `\:micro:`, whose backslash
466/// Markdown drops itself. Link text expands.
467///
468/// Markdown's layout (quotes, lists, tables) is core's, with no room for a
469/// row below a line, so an asset two rows high shows its first row: its
470/// fallback, never an image over the line below.
471///
472/// ```
473/// use rich::markdown::Markdown;
474/// use rich_micro::{FallbackPreference, Layer, MicroAsset, MicroRegistry, PreparedMarkdown};
475///
476/// let mut registry = MicroRegistry::new();
477/// registry.add(Layer::Inline, MicroAsset::new("ship", "rocket")?.with_emoji("🚀")?)?;
478/// let prepared = PreparedMarkdown::new(
479///     "Go :micro:ship: `:micro:ship:`",
480///     &registry,
481///     FallbackPreference::Emoji,
482/// );
483/// let console = rich::Console::builder().width(40).build();
484/// let shown = console.render_export(&prepared.view(Markdown::new(prepared.source())));
485/// assert!(shown.starts_with("Go 🚀 "), "{shown:?}");
486/// assert!(shown.contains(":micro:ship:"), "{shown:?}");
487/// # Ok::<(), rich_micro::MicroError>(())
488/// ```
489#[derive(Clone, Debug)]
490pub struct PreparedMarkdown {
491    source: String,
492    /// Stand-in, the asset, and its occurrence tag.
493    tokens: Vec<(char, Arc<MicroAsset>, MicroMeta)>,
494    preference: FallbackPreference,
495    diagnostics: Vec<Diagnostic>,
496}
497
498impl PreparedMarkdown {
499    pub fn new(source: &str, registry: &MicroRegistry, preference: FallbackPreference) -> Self {
500        let mut prepared = PreparedMarkdown {
501            source: String::with_capacity(source.len()),
502            tokens: Vec::new(),
503            preference,
504            diagnostics: Vec::new(),
505        };
506        if !source.contains(TOKEN_PREFIX) {
507            prepared.source.push_str(source);
508            return prepared;
509        }
510        let usable = !source.chars().any(is_sentinel);
511        let literal = markdown_literal(source);
512        let literal_at = |token: &Range<usize>| {
513            literal
514                .iter()
515                .any(|range| range.start < token.end && token.start < range.end)
516        };
517        let mut cursor = 0;
518        for token in scan(source, false) {
519            match token {
520                // Markdown drops the backslash of `\:` itself.
521                Token::Escape(_) => {}
522                Token::Asset { range, .. } | Token::Malformed(range) if literal_at(&range) => {}
523                Token::Asset { range, name } => match registry.resolve(&name) {
524                    Some(asset)
525                        if usable
526                            && prepared.tokens.len()
527                                <= (SENTINEL_LAST - SENTINEL_BASE) as usize =>
528                    {
529                        let sentinel = char::from_u32(SENTINEL_BASE + prepared.tokens.len() as u32)
530                            .expect("private-use code point");
531                        prepared.source.push_str(&source[cursor..range.start]);
532                        prepared
533                            .source
534                            .extend(std::iter::repeat_n(sentinel, asset.cols()));
535                        prepared
536                            .tokens
537                            .push((sentinel, Arc::clone(asset), MicroMeta::new(asset)));
538                        cursor = range.end;
539                    }
540                    Some(_) => prepared.diagnostics.push(Diagnostic {
541                        offset: range.start,
542                        token: source[range].to_string(),
543                        kind: DiagnosticKind::Reserved,
544                    }),
545                    None => prepared.diagnostics.push(Diagnostic {
546                        offset: range.start,
547                        token: source[range].to_string(),
548                        kind: DiagnosticKind::UnknownAsset(name),
549                    }),
550                },
551                Token::Malformed(range) => prepared.diagnostics.push(Diagnostic {
552                    offset: range.start,
553                    token: source[range].to_string(),
554                    kind: DiagnosticKind::Malformed,
555                }),
556            }
557        }
558        prepared.source.push_str(&source[cursor..]);
559        prepared
560    }
561
562    /// The Markdown to render.
563    pub fn source(&self) -> &str {
564        &self.source
565    }
566
567    /// Whether any token was swapped.
568    pub fn has_assets(&self) -> bool {
569        !self.tokens.is_empty()
570    }
571
572    /// What was left as written.
573    pub fn diagnostics(&self) -> &[Diagnostic] {
574        &self.diagnostics
575    }
576
577    /// `inner` (a renderer of [`source`](Self::source)) with each run of
578    /// stand-ins drawn as its asset's placeholder cells, tagged, so a
579    /// [`MicroView`](crate::MicroView) can draw it. A run the layout cut
580    /// (wrapped or cropped) shows blank cells.
581    pub fn view<R: Renderable>(&self, inner: R) -> MarkdownView<R> {
582        MarkdownView {
583            inner,
584            tokens: self.tokens.clone(),
585            preference: self.preference,
586        }
587    }
588}
589
590/// [`PreparedMarkdown::view`].
591pub struct MarkdownView<R> {
592    inner: R,
593    tokens: Vec<(char, Arc<MicroAsset>, MicroMeta)>,
594    preference: FallbackPreference,
595}
596
597impl<R> MarkdownView<R> {
598    /// `segment` with each run of stand-ins replaced by placeholder cells.
599    fn restore(&self, segment: Segment, out: &mut Vec<Segment>) {
600        if segment.control || !segment.text.chars().any(is_sentinel) {
601            out.push(segment);
602            return;
603        }
604        let text = segment.text.as_str();
605        let mut plain_start = 0;
606        let mut chars = text.char_indices().peekable();
607        while let Some((at, c)) = chars.next() {
608            if !is_sentinel(c) {
609                continue;
610            }
611            let mut count = 1;
612            while chars.peek().is_some_and(|(_, next)| *next == c) {
613                chars.next();
614                count += 1;
615            }
616            let end = chars.peek().map_or(text.len(), |(next, _)| *next);
617            if at > plain_start {
618                out.push(Segment::new(&text[plain_start..at], segment.style.clone()));
619            }
620            plain_start = end;
621            let blank = PAD_CELL.to_string().repeat(count);
622            let Some((_, asset, meta)) = self.tokens.iter().find(|(s, _, _)| *s == c) else {
623                out.push(Segment::new(blank, segment.style.clone()));
624                continue;
625            };
626            let tag = meta.to_style();
627            let style = Some(match &segment.style {
628                Some(style) => style.combine(&tag),
629                None => tag,
630            });
631            let cells = if count == meta.cols {
632                fallback_cells(asset, self.preference)
633            } else {
634                blank
635            };
636            out.push(Segment::new(cells, style));
637        }
638        if plain_start < text.len() {
639            out.push(Segment::new(&text[plain_start..], segment.style.clone()));
640        }
641    }
642}
643
644impl<R: Renderable> Renderable for MarkdownView<R> {
645    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
646        let segments = self.inner.rich_render(console, options);
647        if self.tokens.is_empty() {
648            return segments;
649        }
650        let mut out = Vec::with_capacity(segments.len());
651        for segment in segments {
652            self.restore(segment, &mut out);
653        }
654        out
655    }
656
657    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
658        self.inner.measure(console, options)
659    }
660}
661
662/// The substitution as a plugin [`TextTransform`] (after parsing; see the
663/// module docs). Unknown names stay literal; a transform has no channel for
664/// diagnostics, so use [`expand`] to see them.
665#[derive(Clone, Debug)]
666pub struct MicroTransform {
667    registry: Arc<MicroRegistry>,
668    preference: FallbackPreference,
669}
670
671impl MicroTransform {
672    pub fn new(registry: Arc<MicroRegistry>) -> Self {
673        MicroTransform {
674            registry,
675            preference: FallbackPreference::default(),
676        }
677    }
678
679    #[must_use]
680    pub fn preference(mut self, preference: FallbackPreference) -> Self {
681        self.preference = preference;
682        self
683    }
684}
685
686impl TextTransform for MicroTransform {
687    fn transform(&self, text: Text) -> Result<Text, PluginError> {
688        Ok(expand(&text, &self.registry, self.preference).0)
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn scanning() {
698        let tokens = scan(
699            r"a :micro:x/y: \:micro:z: :micro:Bad: [link=:micro:q:]t[/]",
700            true,
701        );
702        let kinds: Vec<String> = tokens
703            .iter()
704            .map(|t| match t {
705                Token::Escape(at) => format!("esc@{at}"),
706                Token::Asset { name, .. } => format!("asset {name}"),
707                Token::Malformed(r) => format!("bad@{}", r.start),
708            })
709            .collect();
710        assert_eq!(kinds, ["asset x/y", "esc@14", "bad@25"]);
711    }
712
713    #[test]
714    fn splice_moves_spans() {
715        let mut text = Text::new("ab:micro:x:cd");
716        text.stylize("red", 0, 13);
717        text.stylize("blue", 5, 7); // inside the token
718        text.stylize("green", 11, 13); // after it
719        let edits = [Edit {
720            range: 2..11,
721            insert: Some(Text::new("XY")),
722        }];
723        let out = splice(&text, &edits);
724        assert_eq!(out.plain(), "abXYcd");
725        let ranges: Vec<(usize, usize)> = out.spans().iter().map(|s| (s.start, s.end)).collect();
726        assert_eq!(ranges, [(0, 6), (2, 4), (4, 6)]);
727    }
728}