typst_ansi_hl/
lib.rs

1//! `typst-ansi-hl` highlights your Typst code using ANSI escape sequences.
2//!
3//! ```
4//! # use typst_ansi_hl::Highlighter;
5//! let output = Highlighter::default()
6//!     .for_discord()
7//!     .with_soft_limit(2000)
8//!     .highlight("This is _Typst_ #underline[code].");
9//! ```
10use std::{io::Write, sync::LazyLock};
11
12use syntect::{
13    easy::HighlightLines, highlighting::FontStyle, parsing::SyntaxSet, util::LinesWithEndings,
14};
15use termcolor::{Color, ColorSpec, WriteColor};
16use two_face::theme::{EmbeddedLazyThemeSet, EmbeddedThemeName};
17use typst_syntax::{
18    ast::{self, AstNode},
19    LinkedNode, Tag,
20};
21
22/// Module with external dependencies exposed by this library.
23pub mod ext {
24    pub use syntect;
25    pub use termcolor;
26    pub use typst_syntax;
27}
28
29const ZERO_WIDTH_JOINER: char = '\u{200D}';
30
31/// Any error returned by this library.
32#[derive(Debug, thiserror::Error)]
33#[non_exhaustive]
34pub enum Error {
35    #[error(transparent)]
36    Io(#[from] std::io::Error),
37    #[error(transparent)]
38    Syntect(#[from] syntect::Error),
39}
40
41/// The kind of input syntax.
42#[derive(Debug, Clone, Copy)]
43pub enum SyntaxMode {
44    Code,
45    Markup,
46    Math,
47}
48
49#[derive(Debug, Clone, Copy)]
50pub struct Highlighter {
51    discord: bool,
52    syntax_mode: SyntaxMode,
53    soft_limit: Option<usize>,
54}
55
56impl Default for Highlighter {
57    fn default() -> Self {
58        Highlighter {
59            discord: false,
60            syntax_mode: SyntaxMode::Markup,
61            soft_limit: None,
62        }
63    }
64}
65
66impl Highlighter {
67    /// Enable output specifically for Discord.
68    ///
69    /// If enabled, output will be surrounded by a `ansi` language code block.
70    /// Additionally, any code blocks will be escaped.
71    /// The output might not look like the input.
72    ///
73    /// Default: `false`.
74    pub fn for_discord(&mut self) -> &mut Self {
75        self.discord = true;
76        self
77    }
78
79    /// When parsing, how the input should be interpreted.
80    ///
81    /// Default: [`SyntaxMode::Markup`].
82    pub fn with_syntax_mode(&mut self, mode: SyntaxMode) -> &mut Self {
83        self.syntax_mode = mode;
84        self
85    }
86
87    /// Softly enforce a byte size limit.
88    ///
89    /// This means that if the size limit is exceeded, less colors are used
90    /// in order to get below that size limit.
91    /// If it is not possible to get below that limit, the text is printed anyway.
92    pub fn with_soft_limit(&mut self, soft_limit: usize) -> &mut Self {
93        self.soft_limit = Some(soft_limit);
94        self
95    }
96
97    /// Highlight Typst code and return the highlighted string.
98    pub fn highlight(&self, input: &str) -> Result<String, Error> {
99        let mut out = termcolor::Ansi::new(Vec::new());
100        self.highlight_to(input, &mut out)?;
101        Ok(String::from_utf8(out.into_inner()).expect("the output should be entirely UTF-8"))
102    }
103
104    /// Highlight Typst code and write it to the given output.
105    pub fn highlight_to<W: WriteColor>(&self, input: &str, out: W) -> Result<(), Error> {
106        let parsed = match self.syntax_mode {
107            SyntaxMode::Code => typst_syntax::parse_code(input),
108            SyntaxMode::Markup => typst_syntax::parse(input),
109            SyntaxMode::Math => typst_syntax::parse_math(input),
110        };
111        let linked = typst_syntax::LinkedNode::new(&parsed);
112        self.highlight_node_to(&linked, out)
113    }
114
115    /// Highlight a linked syntax node and write it to the given output.
116    ///
117    /// Use [`typst_syntax::parse`] to parse a string into a [`SyntaxNode`], and then
118    /// use [`LinkedNode::new`] on the parsed syntax node to obtain a [`LinkedNode`]
119    /// you can use with this function.
120    ///
121    /// [`SyntaxNode`]: typst_syntax::SyntaxNode
122    pub fn highlight_node_to<W: WriteColor>(
123        &self,
124        node: &LinkedNode,
125        mut out: W,
126    ) -> Result<(), Error> {
127        fn inner_highlight_node<W: WriteColor>(
128            highlighter: &Highlighter,
129            hl_level: HighlightLevel,
130            node: &LinkedNode,
131            out: &mut DeferredWriter<W>,
132            color: &mut ColorSpec,
133        ) -> Result<(), Error> {
134            let prev_color = color.clone();
135
136            if let Some(tag) = typst_syntax::highlight(node) {
137                out.set_color(&highlighter.tag_to_color(hl_level, tag))?;
138            }
139
140            if let Some(raw) = ast::Raw::from_untyped(node) {
141                highlighter.highlight_raw(hl_level, out, raw)?;
142            } else if node.text().is_empty() {
143                for child in node.children() {
144                    inner_highlight_node(highlighter, hl_level, &child, out, color)?;
145                }
146            } else {
147                write!(out, "{}", node.text())?;
148            }
149
150            out.set_color(&prev_color)?;
151            *color = prev_color;
152
153            Ok(())
154        }
155
156        fn inner<W: WriteColor>(
157            highlighter: &Highlighter,
158            node: &LinkedNode,
159            out: W,
160            hl_level: HighlightLevel,
161        ) -> Result<(), Error> {
162            let mut out = DeferredWriter::new(out);
163            if highlighter.discord {
164                writeln!(out, "```ansi")?;
165            }
166
167            inner_highlight_node(highlighter, hl_level, node, &mut out, &mut ColorSpec::new())?;
168
169            if highlighter.discord {
170                // Make sure that the closing fences are on their own line.
171                let mut last_leaf = node.clone();
172                while let Some(child) = last_leaf.children().last() {
173                    last_leaf = child;
174                }
175                if !last_leaf.text().ends_with('\n') {
176                    writeln!(out)?;
177                }
178                writeln!(out, "```")?;
179            }
180            Ok(())
181        }
182
183        if let Some(soft_limit) = self.soft_limit {
184            // Because a soft limit is given, we highlight everything to an in-memory buffer
185            // and check whether the output length is less than the limit.
186            // If the limit was reached, we lower the highlight level.
187            // Otherwise, we write it to the real output.
188            // If the highlight level was reached, we _always_ write the output without highlighting.
189            let mut buf_out = termcolor::Ansi::new(Vec::new());
190            let mut level = HighlightLevel::All;
191            loop {
192                inner(self, node, &mut buf_out, level)?;
193                let mut buf = buf_out.into_inner();
194                if buf.len() < soft_limit || level == HighlightLevel::Off {
195                    out.write_all(&buf)?;
196                    break;
197                } else {
198                    buf.clear();
199                    buf_out = termcolor::Ansi::new(buf);
200                    level = level.restrict();
201                }
202            }
203        } else {
204            inner(self, node, out, HighlightLevel::All)?;
205        }
206
207        Ok(())
208    }
209
210    fn highlight_raw<W: WriteColor>(
211        &self,
212        hl_level: HighlightLevel,
213        out: &mut DeferredWriter<W>,
214        raw: ast::Raw<'_>,
215    ) -> Result<(), Error> {
216        let text = raw.to_untyped().clone().into_text();
217
218        // Collect backticks and escape if discord is enabled.
219        let backticks: String = text.chars().take_while(|&c| c == '`').collect();
220        let (fence, is_pure_fence, include_content) = {
221            if self.discord && backticks.len() >= 3 {
222                let mut fence: String = backticks
223                    .chars()
224                    .flat_map(|c| [c, ZERO_WIDTH_JOINER])
225                    .collect();
226                fence.pop();
227                (fence, false, true)
228            } else if backticks.len() == 2 {
229                ("`".to_string(), true, false)
230            } else {
231                (backticks, true, true)
232            }
233        };
234
235        // Write opening fence.
236        if self.discord && !is_pure_fence {
237            out.set_color(&self.tag_to_color(hl_level, Tag::Comment))?;
238            write!(out, "/* when copying, remove and retype these --> */")?;
239        }
240        out.set_color(&self.tag_to_color(hl_level, Tag::Raw))?;
241        write!(out, "{fence}")?;
242
243        if include_content {
244            if let Some(lang) = raw.lang() {
245                write!(out, "{}", lang.get())?;
246            }
247
248            // Trim starting fences.
249            let mut inner = text.trim_start_matches('`');
250            // Trim closing fences.
251            inner = &inner[..inner.len() - (text.len() - inner.len())];
252
253            if let Some(lang) = raw.lang().filter(|_| hl_level >= HighlightLevel::WithRaw) {
254                let lang = lang.get();
255                inner = &inner[lang.len()..]; // Trim language tag.
256                highlight_lang(inner, lang, out)?;
257            } else {
258                write!(out, "{inner}")?;
259            }
260        }
261
262        // Write closing fence.
263        out.set_color(&self.tag_to_color(hl_level, Tag::Raw))?;
264        write!(out, "{fence}")?;
265        if self.discord && !is_pure_fence {
266            out.set_color(&self.tag_to_color(hl_level, Tag::Comment))?;
267            write!(out, "/* <-- when copying, remove and retype these */")?;
268        }
269
270        Ok(())
271    }
272
273    fn tag_to_color(&self, hl_level: HighlightLevel, tag: Tag) -> ColorSpec {
274        let mut color = ColorSpec::default();
275        if hl_level == HighlightLevel::Off {
276            return color;
277        }
278
279        let l1 = hl_level >= HighlightLevel::L1;
280        let l2 = hl_level >= HighlightLevel::L2;
281        let l3 = hl_level >= HighlightLevel::L3;
282        let with_styles = hl_level >= HighlightLevel::WithStyles;
283        match tag {
284            Tag::Comment => {
285                if self.discord {
286                    color.set_fg(Some(Color::Black))
287                } else {
288                    color.set_dimmed(true)
289                }
290            }
291            Tag::Punctuation if l3 => color.set_fg(None),
292            Tag::Escape => color.set_fg(Some(Color::Cyan)),
293            Tag::Strong if l3 => color.set_fg(Some(Color::Yellow)).set_bold(with_styles),
294            Tag::Emph if l3 => color.set_fg(Some(Color::Yellow)).set_italic(with_styles),
295            Tag::Link if l3 => color.set_fg(Some(Color::Blue)).set_underline(with_styles),
296            Tag::Raw if l2 => color.set_fg(Some(Color::White)),
297            Tag::Label if l1 => color.set_fg(Some(Color::Blue)).set_underline(with_styles),
298            Tag::Ref if l1 => color.set_fg(Some(Color::Blue)).set_underline(with_styles),
299            Tag::Heading if l2 => color.set_fg(Some(Color::Cyan)).set_bold(with_styles),
300            Tag::ListMarker if l2 => color.set_fg(Some(Color::Cyan)),
301            Tag::ListTerm if l2 => color.set_fg(Some(Color::Cyan)),
302            Tag::MathDelimiter if l3 => color.set_fg(Some(Color::Cyan)),
303            Tag::MathOperator if l2 => color.set_fg(Some(Color::Cyan)),
304            Tag::Keyword => color.set_fg(Some(Color::Magenta)),
305            Tag::Operator if l3 => color.set_fg(Some(Color::Cyan)),
306            Tag::Number if l1 => color.set_fg(Some(Color::Yellow)),
307            Tag::String if l1 => color.set_fg(Some(Color::Green)),
308            Tag::Function if l3 => color.set_fg(Some(Color::Blue)).set_italic(with_styles),
309            Tag::Interpolated if l3 => color.set_fg(Some(Color::White)),
310            Tag::Error => color.set_fg(Some(Color::Red)),
311            _ => &mut color,
312        };
313        color
314    }
315}
316
317static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(two_face::syntax::extra_newlines);
318static THEME_SET: LazyLock<EmbeddedLazyThemeSet> = LazyLock::new(two_face::theme::extra);
319
320fn highlight_lang<W: WriteColor>(
321    input: &str,
322    lang: &str,
323    out: &mut DeferredWriter<W>,
324) -> Result<(), Error> {
325    let Some(syntax) = SYNTAX_SET.find_syntax_by_token(lang) else {
326        write!(out, "{input}")?;
327        return Ok(());
328    };
329    let ansi_theme = THEME_SET.get(EmbeddedThemeName::Base16);
330
331    let mut highlighter = HighlightLines::new(syntax, ansi_theme);
332    for line in LinesWithEndings::from(input) {
333        let ranges = highlighter.highlight_line(line, &SYNTAX_SET)?;
334        for (styles, text) in ranges {
335            let fg = styles.foreground;
336            let fg = convert_rgb_to_ansi_color(fg.r, fg.g, fg.b, fg.a);
337            let mut color = ColorSpec::new();
338            color.set_fg(fg);
339
340            let font_style = styles.font_style;
341            color.set_bold(font_style.contains(FontStyle::BOLD));
342            color.set_italic(font_style.contains(FontStyle::ITALIC));
343            color.set_underline(font_style.contains(FontStyle::UNDERLINE));
344
345            out.set_color(&color)?;
346            write!(out, "{text}")?;
347        }
348    }
349
350    Ok(())
351}
352
353/// Converts an RGB color from the theme to a [`Color`].
354///
355/// Inspired by an equivalent function in `bat`[^1].
356/// [^1]: https://github.com/sharkdp/bat/blob/07c26adc357f70a48f2b412008d5c37d43e084c5/src/terminal.rs#L6
357fn convert_rgb_to_ansi_color(r: u8, g: u8, b: u8, a: u8) -> Option<Color> {
358    match a {
359        0 => Some(match r {
360            // Use predefined colors for wider support.
361            0x00 => Color::Black,
362            0x01 => Color::Red,
363            0x02 => Color::Green,
364            0x03 => Color::Yellow,
365            0x04 => Color::Blue,
366            0x05 => Color::Magenta,
367            0x06 => Color::Cyan,
368            0x07 => Color::White,
369            _ => Color::Ansi256(r),
370        }),
371        1 => None,
372        _ => Some(Color::Ansi256(ansi_colours::ansi256_from_rgb((r, g, b)))),
373    }
374}
375
376/// What things to highlight.
377/// Lower values mean less highlighting.
378///
379/// Used when a soft limit is set.
380#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
381enum HighlightLevel {
382    Off,
383    L0,
384    L1,
385    L2,
386    L3,
387    /// Highlight raw blocks.
388    WithRaw,
389    /// Use styles like bold, italic, underline.
390    WithStyles,
391    All,
392}
393
394impl HighlightLevel {
395    fn restrict(self) -> HighlightLevel {
396        match self {
397            HighlightLevel::Off => HighlightLevel::Off,
398            HighlightLevel::L0 => HighlightLevel::Off,
399            HighlightLevel::L1 => HighlightLevel::L0,
400            HighlightLevel::L2 => HighlightLevel::L1,
401            HighlightLevel::L3 => HighlightLevel::L2,
402            HighlightLevel::WithRaw => HighlightLevel::L3,
403            HighlightLevel::WithStyles => HighlightLevel::WithRaw,
404            HighlightLevel::All => HighlightLevel::WithStyles,
405        }
406    }
407}
408
409/// A writer that only sets the color when content is written.
410/// This is intended to lessen the size impact of unnecessary escape codes.
411struct DeferredWriter<W> {
412    inner: W,
413    current_color: ColorSpec,
414    next_color: Option<ColorSpec>,
415}
416
417impl<W> DeferredWriter<W> {
418    fn new(writer: W) -> DeferredWriter<W> {
419        DeferredWriter {
420            inner: writer,
421            current_color: ColorSpec::new(),
422            next_color: None,
423        }
424    }
425}
426
427impl<W: WriteColor> Write for DeferredWriter<W> {
428    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
429        if let Some(color) = self.next_color.take() {
430            self.inner.set_color(&color)?;
431            self.current_color = color;
432        }
433        self.inner.write(buf)
434    }
435
436    fn flush(&mut self) -> std::io::Result<()> {
437        self.inner.flush()
438    }
439}
440
441impl<W: WriteColor> WriteColor for DeferredWriter<W> {
442    fn supports_color(&self) -> bool {
443        self.inner.supports_color()
444    }
445
446    fn set_color(&mut self, spec: &ColorSpec) -> std::io::Result<()> {
447        if &self.current_color == spec {
448            self.next_color = None;
449        } else {
450            self.next_color = Some(spec.clone());
451        }
452        Ok(())
453    }
454
455    fn reset(&mut self) -> std::io::Result<()> {
456        let mut color = ColorSpec::new();
457        color.set_reset(true);
458        self.next_color = Some(color);
459        Ok(())
460    }
461
462    fn is_synchronous(&self) -> bool {
463        self.inner.is_synchronous()
464    }
465
466    fn set_hyperlink(&mut self, link: &termcolor::HyperlinkSpec) -> std::io::Result<()> {
467        self.inner.set_hyperlink(link)
468    }
469
470    fn supports_hyperlinks(&self) -> bool {
471        self.inner.supports_hyperlinks()
472    }
473}