lumis 0.9.0

Syntax Highlighter powered by Tree-sitter and Neovim themes
Documentation
//! HTML formatter with linked CSS classes.
//!
//! This module provides the [`HtmlLinked`] formatter that generates HTML output with
//! CSS classes for syntax highlighting. Requires external CSS files for styling.
//! Supports line highlighting and custom CSS classes.
//!
//! # Example Output
//!
//! For the Rust code `fn main() { println!("Hello"); }`, the formatter generates
//! HTML with CSS classes like:
//!
//! ```html
//! <pre class="lumis"><code class="language-rust" translate="no" tabindex="0"><div class="line" data-line="1"><span class="keyword-function">fn</span> <span class="function">main</span><span class="punctuation-bracket">(</span><span class="punctuation-bracket">)</span> <span class="punctuation-bracket">&lbrace;</span> <span class="keyword-exception">println</span><span class="function-macro">!</span><span class="punctuation-bracket">(</span><span class="string">&quot;Hello&quot;</span><span class="punctuation-bracket">)</span><span class="punctuation-delimiter">;</span> <span class="punctuation-bracket">&rbrace;</span></div></code></pre>
//! ```
//!
//! See the [formatter](crate::formatter) module for more information and examples.

use super::{Formatter, HtmlElement};
use crate::highlight;
use crate::languages::Language;
use derive_builder::Builder;
use lumis_core::formatter::Formatter as _;
use std::{
    io::{self, Write},
    ops::RangeInclusive,
};

/// Configuration for highlighting specific lines in HTML linked output.
///
/// This struct allows you to specify which lines should be highlighted using
/// CSS classes. The highlighting is applied by adding the specified class
/// to the line elements, allowing for flexible styling via external CSS.
///
/// # Examples
///
/// With default "highlighted" class:
/// ```rust
/// use lumis::formatters::html_linked::HighlightLines;
///
/// let highlight_lines = HighlightLines {
///     lines: vec![1..=1, 5..=7],
///     ..Default::default()
/// };
/// ```
/// The resulting HTML will look like:
/// ```html
/// <div class="line highlighted" data-line="2">...</div>
/// ```
///
/// Using a custom CSS class:
/// ```rust
/// use lumis::formatters::html_linked::HighlightLines;
///
/// let highlight_lines = HighlightLines {
///     lines: vec![2..=3],
///     class: "transition-colors duration-500 w-full inline-block bg-yellow-500".to_string(),
/// };
/// ```
///
/// The resulting HTML will include the classes in line elements:
/// ```html
/// <div class="line transition-colors duration-500 w-full inline-block bg-yellow-500" data-line="2">...</div>
/// ```
#[derive(Clone, Debug)]
pub struct HighlightLines {
    /// List of line ranges to highlight.
    ///
    /// Each range is inclusive on both ends. Line numbers are 1-based.
    /// Multiple ranges can overlap and will be merged during rendering.
    pub lines: Vec<RangeInclusive<usize>>,
    /// The CSS class name to add to highlighted line elements.
    ///
    /// Highlighted lines will have both "highlighted" and this custom class added to the existing "line" class,
    /// resulting in elements like `<div class="line highlighted your-class-name" data-line="N">`.
    /// You can then style these classes in your CSS to achieve the desired highlighting effect.
    ///
    /// Note that themes include a `highlighted` class for convenience,
    /// which contains the colors from the theme's "CursorLine" highlight from Neovim.
    ///
    /// Defaults to `"highlighted"`.
    /// ```rust
    /// use lumis::formatters::html_linked::HighlightLines;
    ///
    /// let highlight_lines = HighlightLines {
    ///     lines: vec![1..=2],
    ///     class: "highlighted".to_string(),
    /// };
    /// ```
    pub class: String,
}

impl Default for HighlightLines {
    fn default() -> Self {
        Self {
            lines: Vec::new(),
            class: "highlighted".to_string(),
        }
    }
}

/// HTML formatter with CSS classes.
///
/// Generates HTML with CSS classes instead of inline styles. Requires external CSS files.
/// Use this for better performance and smaller HTML when serving multiple code blocks.
/// Use [`HtmlLinkedBuilder`] to create instances.
///
/// # When to use
///
/// - Multiple code blocks sharing the same theme (smaller HTML output)
/// - Need to customize or override styles with CSS
/// - Prefer separation of content and styling
///
/// # Example
///
/// ```rust
/// use lumis::{HtmlLinkedBuilder, languages::Language, formatters::Formatter};
/// use std::io::Write;
///
/// let code = "print('Hello')";
///
/// let formatter = HtmlLinkedBuilder::new()
///     .language(Language::Python)
///     .pre_class(Some("my-code".to_string()))
///     .build()
///     .unwrap();
///
/// let mut output = Vec::new();
/// formatter.format(code, &mut output).unwrap();
/// // Remember to include the corresponding CSS file for your theme
/// ```
#[derive(Builder, Clone, Debug)]
#[builder(default)]
pub struct HtmlLinked {
    #[builder(setter(custom))]
    language: Language,
    pre_class: Option<String>,
    highlight_lines: Option<HighlightLines>,
    header: Option<HtmlElement>,
}

impl HtmlLinkedBuilder {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn language(&mut self, language: Language) -> &mut Self {
        self.language = Some(language);
        self
    }

    #[deprecated(note = "use `.language(...)` instead")]
    pub fn lang(&mut self, language: Language) -> &mut Self {
        self.language(language)
    }
}

impl HtmlLinked {
    pub fn new(
        language: Language,
        pre_class: Option<String>,
        highlight_lines: Option<HighlightLines>,
        header: Option<HtmlElement>,
    ) -> Self {
        Self {
            language,
            pre_class,
            highlight_lines,
            header,
        }
    }
}

impl Default for HtmlLinked {
    fn default() -> Self {
        Self {
            language: Language::PlainText,
            pre_class: None,
            highlight_lines: None,
            header: None,
        }
    }
}

impl Formatter for HtmlLinked {
    fn format(&self, source: &str, output: &mut dyn Write) -> io::Result<()> {
        let events =
            highlight::highlight_events(source, self.language).map_err(io::Error::other)?;

        let core_formatter = lumis_core::formatter::html_linked::HtmlLinked::new(
            self.language,
            self.pre_class.clone(),
            self.highlight_lines.clone().map(map_highlight_lines),
            self.header.clone(),
        );

        core_formatter.render(source, &events, output)
    }
}

fn map_highlight_lines(
    highlight_lines: HighlightLines,
) -> lumis_core::formatter::html_linked::HighlightLines {
    lumis_core::formatter::html_linked::HighlightLines {
        lines: highlight_lines.lines,
        class: highlight_lines.class,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::formatters::HtmlLinkedBuilder;

    #[cfg(test)]
    use pretty_assertions::assert_str_eq;

    #[test]
    fn test_no_attrs() {
        let code = "@lang :rust";
        let formatter = HtmlLinked::new(Language::Elixir, None, None, None);
        let mut buffer = Vec::new();
        formatter.format(code, &mut buffer).unwrap();
        let result = String::from_utf8(buffer).unwrap();
        let expected = r#"<pre class="lumis"><code class="language-elixir" translate="no" tabindex="0"><div class="line" data-line="1"><span class="operator"><span class="constant">@<span class="function-call"><span class="constant">lang <span class="string-special-symbol">:rust</span></span></span></span></span>
</div></code></pre>"#;
        assert_eq!(result, expected)
    }

    #[test]
    fn test_include_pre_class() {
        let formatter = HtmlLinked::new(
            Language::PlainText,
            Some("test-pre-class".to_string()),
            None,
            None,
        );
        let mut buffer = Vec::new();
        crate::formatters::html::open_pre_tag(&mut buffer, formatter.pre_class.as_deref(), None)
            .unwrap();
        let result = String::from_utf8(buffer).unwrap();
        let expected = r#"<pre class="lumis test-pre-class">"#;
        assert_str_eq!(result, expected);
    }

    #[test]
    fn test_code_tag_with_language() {
        let formatter = HtmlLinked::new(Language::Rust, None, None, None);
        let mut buffer = Vec::new();
        crate::formatters::html::open_code_tag(&mut buffer, &formatter.language).unwrap();
        let result = String::from_utf8(buffer).unwrap();
        let expected = r#"<code class="language-rust" translate="no" tabindex="0">"#;
        assert_str_eq!(result, expected);
    }

    #[test]
    fn test_builder_pattern() {
        let formatter = HtmlLinkedBuilder::new()
            .language(Language::Rust)
            .pre_class(Some("test-pre-class".to_string()))
            .build()
            .unwrap();

        let mut buffer = Vec::new();
        crate::formatters::html::open_pre_tag(&mut buffer, formatter.pre_class.as_deref(), None)
            .unwrap();
        let pre_result = String::from_utf8(buffer).unwrap();
        let pre_expected = r#"<pre class="lumis test-pre-class">"#;
        assert_str_eq!(pre_result, pre_expected);

        let mut buffer = Vec::new();
        crate::formatters::html::open_code_tag(&mut buffer, &formatter.language).unwrap();
        let code_result = String::from_utf8(buffer).unwrap();
        let code_expected = r#"<code class="language-rust" translate="no" tabindex="0">"#;
        assert_str_eq!(code_result, code_expected);
    }

    #[test]
    fn test_default_highlight_lines() {
        let code = "line 1\nline 2\nline 3";
        let highlight_lines = HighlightLines {
            lines: vec![2..=2],
            ..Default::default()
        };

        let formatter = HtmlLinked::new(Language::PlainText, None, Some(highlight_lines), None);

        let mut buffer = Vec::new();
        formatter.format(code, &mut buffer).unwrap();
        let result = String::from_utf8(buffer).unwrap();

        let expected = r#"<pre class="lumis"><code class="language-plaintext" translate="no" tabindex="0"><div class="line" data-line="1">line 1
</div><div class="line highlighted" data-line="2">line 2
</div><div class="line" data-line="3">line 3
</div></code></pre>"#;
        assert_str_eq!(result, expected);
    }

    #[test]
    fn test_highlight_lines() {
        let code = "line 1\nline 2\nline 3\nline 4\nline 5";
        let highlight_lines = HighlightLines {
            lines: vec![1..=1, 3..=4],
            class: "custom-hl".to_string(),
        };
        let formatter = HtmlLinked::new(Language::PlainText, None, Some(highlight_lines), None);

        let mut buffer = Vec::new();
        formatter.format(code, &mut buffer).unwrap();
        let result = String::from_utf8(buffer).unwrap();

        let expected = r#"<pre class="lumis"><code class="language-plaintext" translate="no" tabindex="0"><div class="line custom-hl" data-line="1">line 1
</div><div class="line" data-line="2">line 2
</div><div class="line custom-hl" data-line="3">line 3
</div><div class="line custom-hl" data-line="4">line 4
</div><div class="line" data-line="5">line 5
</div></code></pre>"#;
        assert_str_eq!(result, expected);
    }

    #[test]
    fn test_header_wrapping() {
        let header = HtmlElement {
            open_tag: "<div class=\"code-wrapper\">".to_string(),
            close_tag: "</div>".to_string(),
        };
        let code = "line 1\nline 2";
        let formatter = HtmlLinked::new(Language::PlainText, None, None, Some(header));

        let mut buffer = Vec::new();
        formatter.format(code, &mut buffer).unwrap();
        let result = String::from_utf8(buffer).unwrap();

        let expected = r#"<div class="code-wrapper"><pre class="lumis"><code class="language-plaintext" translate="no" tabindex="0"><div class="line" data-line="1">line 1
</div><div class="line" data-line="2">line 2
</div></code></pre></div>"#;
        assert_str_eq!(result, expected);
    }

    #[test]
    fn test_header_with_highlight_lines() {
        let header = HtmlElement {
            open_tag: "<section class=\"code-section\">".to_string(),
            close_tag: "</section>".to_string(),
        };
        let highlight_lines = HighlightLines {
            lines: vec![1..=1],
            class: "highlighted".to_string(),
        };
        let code = "line 1\nline 2";
        let formatter = HtmlLinked::new(
            Language::PlainText,
            Some("custom-pre".to_string()),
            Some(highlight_lines),
            Some(header),
        );

        let mut buffer = Vec::new();
        formatter.format(code, &mut buffer).unwrap();
        let result = String::from_utf8(buffer).unwrap();

        let expected = r#"<section class="code-section"><pre class="lumis custom-pre"><code class="language-plaintext" translate="no" tabindex="0"><div class="line highlighted" data-line="1">line 1
</div><div class="line" data-line="2">line 2
</div></code></pre></section>"#;
        assert_str_eq!(result, expected);
    }
}