diff --git a/Cargo.lock b/Cargo.lock index 00e3068f..a5a92ba3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,10 +57,10 @@ dependencies = [ "acdc-parser", "base64", "chrono", + "giallo", "pretty_assertions", "rstest", "sha2", - "syntect", "tempfile", "thiserror", "tracing", @@ -122,11 +122,11 @@ dependencies = [ "bumpalo", "comfy-table", "crossterm", + "giallo", "libghostty-vt", "pretty_assertions", "serde", "serde_json", - "syntect", "thiserror", "tracing", "unicode-width 0.2.2", @@ -654,6 +654,30 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitcode" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6ed1b54d8dc333e7be604d00fa9262f4635485ffea923647b6521a5fff045d" +dependencies = [ + "arrayvec", + "bitcode_derive", + "bytemuck", + "glam", + "serde", +] + +[[package]] +name = "bitcode_derive" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238b90427dfad9da4a9abd60f3ec1cdee6b80454bde49ed37f1781dd8e9dc7f9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1482,6 +1506,16 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "git+https://github.com/fancy-regex/fancy-regex/?branch=main#d00f0f7a23821d4bcacd2d3090c32532a7a94d83" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -1724,6 +1758,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "giallo" +version = "0.4.1" +source = "git+https://github.com/getzola/giallo.git?branch=fancy-regex#e7526390283ece17c575a2d453c200aa3b78f74f" +dependencies = [ + "bitcode", + "fancy-regex 0.18.0", + "papaya", + "serde", + "serde_json", + "zstd", +] + [[package]] name = "gif" version = "0.14.2" @@ -1740,6 +1787,12 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "glam" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436" + [[package]] name = "glidesort" version = "0.1.2" @@ -3021,28 +3074,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "onig" -version = "6.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" -dependencies = [ - "bitflags 2.13.0", - "libc", - "once_cell", - "onig_sys", -] - -[[package]] -name = "onig_sys" -version = "69.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "oorandom" version = "11.1.5" @@ -3105,6 +3136,16 @@ dependencies = [ "syn", ] +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -4054,6 +4095,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -4466,11 +4517,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ "bincode", - "fancy-regex", + "fancy-regex 0.16.2", "flate2", "fnv", "once_cell", - "onig", "plist", "regex-syntax", "serde", @@ -6137,6 +6187,34 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/converters/html/CHANGELOG.md b/converters/html/CHANGELOG.md index 1a4faacd..474e7937 100644 --- a/converters/html/CHANGELOG.md +++ b/converters/html/CHANGELOG.md @@ -358,6 +358,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Syntax highlighting now uses giallo instead of syntect** — both inline-style and + class-based HTML output use giallo's built-in grammars and themes. The preferred + attributes are now `:highlight-style:` and `:highlight-css:`, while the legacy + `:syntect-style:` and `:syntect-css:` names remain supported. - **Use shared `replace_apostrophes` from converters-core** — smart apostrophe logic moved to converters-core for reuse across converters. - **Use `strip_quotes` for attribute value handling** — replaced manual `trim_matches('"')` diff --git a/converters/html/Cargo.toml b/converters/html/Cargo.toml index 2f279cf6..93f7cd31 100644 --- a/converters/html/Cargo.toml +++ b/converters/html/Cargo.toml @@ -10,8 +10,8 @@ publish = false default = ["pre-spec-subs"] # Asciidoctor-compatible subs= attribute. Experimental - may be removed when AsciiDoc spec finalizes. pre-spec-subs = ["acdc-parser/pre-spec-subs", "acdc-converters-core/pre-spec-subs"] -# Enable syntax highlighting for source blocks using syntect -highlighting = ["dep:syntect"] +# Enable syntax highlighting for source blocks using giallo +highlighting = ["dep:giallo"] # Render terminal-converter output into selectable HTML via libghostty-vt. terminal = ["dep:acdc-converters-terminal", "acdc-converters-terminal/emulator", "acdc-converters-terminal/highlighting"] @@ -20,7 +20,7 @@ acdc-converters-core.workspace = true acdc-converters-terminal = { workspace = true, optional = true } acdc-parser.workspace = true chrono.workspace = true -syntect = { version = "5.3", optional = true, default-features = false, features = ["default-fancy"] } +giallo = { git = "https://github.com/getzola/giallo.git", branch = "fancy-regex", optional = true, features = ["dump"] } thiserror.workspace = true tracing.workspace = true diff --git a/converters/html/README.adoc b/converters/html/README.adoc index 1bfa4426..efc541be 100644 --- a/converters/html/README.adoc +++ b/converters/html/README.adoc @@ -72,7 +72,7 @@ Table of contents entries are also numbered when `:sectnums:` is set. === Syntax Highlighting -Source blocks with a language specified render with syntax highlighting (requires the `highlighting` feature flag). Uses syntect with inline CSS styles. +Source blocks with a language specified render with syntax highlighting (requires the `highlighting` feature flag). The converter uses giallo and emits inline CSS styles by default. Set `:highlight-style:` to choose a giallo theme, or `:highlight-css: class` to emit reusable `syntax-*` classes instead. The legacy `:syntect-style:` and `:syntect-css:` names remain accepted for compatibility. [source,console] .... @@ -347,7 +347,7 @@ server-side, to keep that input away from the emulator. ==== Syntax highlighting CSS -When using class-based syntax highlighting (`:syntect-css: class`), the highlighting CSS is handled the same way, embedded by default, or linked/written to disk when `:linkcss:` is set. See the <> section above. +When using class-based syntax highlighting (`:highlight-css: class`), the highlighting CSS is handled the same way, embedded by default, or linked/written to disk when `:linkcss:` is set. See the <> section above. === Docinfo files @@ -453,7 +453,7 @@ Most output is class-based and fully overridable by the seams above. A few eleme |=== | Inline-styled | How to override -| Syntax-highlighted code colours | Inline is the default; set `:syntect-css: class` to emit overridable `syntax-*` classes (plus a `")?; diff --git a/converters/html/src/lib.rs b/converters/html/src/lib.rs index 8cb523f8..26e9e4c0 100644 --- a/converters/html/src/lib.rs +++ b/converters/html/src/lib.rs @@ -422,10 +422,10 @@ pub struct RenderOptions { pub(crate) const COPYCSS_DEFAULT: &str = ""; pub(crate) const STYLESDIR_DEFAULT: &str = "."; pub(crate) const STYLESHEET_DEFAULT: &str = ""; -/// Default filename for the syntect syntax highlighting stylesheet (class-based mode). +/// Default filename for the syntax highlighting stylesheet (class-based mode). /// Analogous to asciidoctor's `asciidoctor-coderay.css` / `asciidoctor-pygments.css`. #[cfg(feature = "highlighting")] -pub(crate) const SYNTECT_STYLESHEET: &str = "acdc-syntect.css"; +pub(crate) const HIGHLIGHT_STYLESHEET: &str = "acdc-highlight.css"; // NOTE: If you change the values below, you need to also change them in `load_css` pub(crate) const STYLESHEET_LIGHT_MODE: &str = "asciidoctor-light-mode.css"; pub(crate) const STYLESHEET_DARK_MODE: &str = "asciidoctor-dark-mode.css"; @@ -459,8 +459,10 @@ pub(crate) fn load_css(dark_mode: bool, variant: HtmlVariant) -> &'static str { /// Resolve the syntax highlighting theme name and mode from document attributes. /// -/// - `:syntect-style:` overrides the theme (falls back to light/dark default). -/// - `:syntect-css: class` switches to CSS-class mode (default is inline). +/// - `:highlight-style:` (or legacy `:syntect-style:`) overrides the theme +/// (falls back to light/dark default). +/// - `:highlight-css: class` (or legacy `:syntect-css: class`) switches to +/// CSS-class mode (default is inline). #[cfg(feature = "highlighting")] pub(crate) fn resolve_highlight_settings( document_attributes: &DocumentAttributes<'_>, @@ -470,7 +472,8 @@ pub(crate) fn resolve_highlight_settings( .is_some_and(|v| !matches!(v, AttributeValue::Bool(false) | AttributeValue::None)); let theme_name: String = document_attributes - .get("syntect-style") + .get("highlight-style") + .or_else(|| document_attributes.get("syntect-style")) .and_then(|v| match v { AttributeValue::String(s) if !s.is_empty() => Some(s.to_string()), AttributeValue::String(_) | AttributeValue::Bool(_) | AttributeValue::None | _ => None, @@ -484,7 +487,8 @@ pub(crate) fn resolve_highlight_settings( }); let mode = if document_attributes - .get("syntect-css") + .get("highlight-css") + .or_else(|| document_attributes.get("syntect-css")) .is_some_and(|v| matches!(v, AttributeValue::String(s) if s == "class")) { syntax::HighlightMode::Class @@ -762,8 +766,8 @@ impl<'a> Processor<'a> { } } - /// Write the syntect CSS file next to the HTML output when `linkcss` is set - /// and class-based syntax highlighting is active. + /// Write the syntax highlighting CSS file next to the HTML output when + /// `linkcss` is set and class-based syntax highlighting is active. /// /// Analogous to how asciidoctor writes `asciidoctor-coderay.css` / /// `asciidoctor-pygments.css` alongside the output. @@ -811,7 +815,7 @@ impl<'a> Processor<'a> { output_dir.join(&stylesdir) }; - let dest_path = dest_dir.join(SYNTECT_STYLESHEET); + let dest_path = dest_dir.join(HIGHLIGHT_STYLESHEET); if let Err(e) = std::fs::create_dir_all(&dest_dir) { diagnostics.warn_with_advice( diff --git a/converters/html/src/syntax.rs b/converters/html/src/syntax.rs index 59699b31..5f55833e 100644 --- a/converters/html/src/syntax.rs +++ b/converters/html/src/syntax.rs @@ -1,4 +1,4 @@ -//! Syntax highlighting for source code blocks using syntect. +//! Syntax highlighting for source code blocks using giallo. //! //! This module provides syntax highlighting for code blocks when the //! `highlighting` feature is enabled. It supports two output modes: @@ -6,7 +6,8 @@ //! - **Inline** (default): `` — themes baked into HTML //! - **Class**: `` — themes applied via CSS classes //! -//! The mode is controlled by the `:syntect-css:` document attribute. +//! The mode is controlled by the `:highlight-css:` document attribute +//! (also accepts the legacy `:syntect-css:` attribute for backward compatibility). //! //! # Callout Handling //! @@ -26,12 +27,18 @@ use acdc_parser::InlineNode; #[cfg(feature = "highlighting")] use crate::Error; -/// Default light theme. +/// Default light theme (giallo theme name). #[cfg(feature = "highlighting")] -pub(crate) const DEFAULT_THEME_LIGHT: &str = "InspiredGitHub"; -/// Default dark theme. +pub(crate) const DEFAULT_THEME_LIGHT: &str = "github-light"; +/// Default dark theme (giallo theme name). #[cfg(feature = "highlighting")] -pub(crate) const DEFAULT_THEME_DARK: &str = "base16-eighties.dark"; +pub(crate) const DEFAULT_THEME_DARK: &str = "github-dark"; + +/// CSS class prefix used for class-based syntax highlighting output. +/// Every scope token is prefixed with this so the generated classes +/// don't collide with the rest of the page's styles. +#[cfg(feature = "highlighting")] +const SYNTAX_CLASS_PREFIX: &str = "syntax-"; /// How syntax-highlighted spans are styled in the HTML output. #[cfg(feature = "highlighting")] @@ -45,16 +52,29 @@ pub(crate) enum HighlightMode { Class, } -/// The `ClassStyle` used for class-based output. Every scope token is -/// prefixed with `syntax-` so the generated classes don't collide with -/// the rest of the page's styles. +/// Get or initialize the shared giallo `Registry`. +/// +/// The registry is loaded once from the builtin dump on first use and +/// then reused for all subsequent calls. This is thread-safe via +/// `OnceLock`. #[cfg(feature = "highlighting")] -const SYNTAX_CLASS_STYLE: syntect::html::ClassStyle = - syntect::html::ClassStyle::SpacedPrefixed { prefix: "syntax-" }; +fn get_registry() -> &'static giallo::Registry { + use std::sync::OnceLock; + + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(|| { + let mut registry = giallo::Registry::builtin().unwrap_or_else(|e| { + tracing::error!("failed to load builtin giallo registry: {e}"); + giallo::Registry::default() + }); + registry.link_grammars(); + registry + }) +} /// Highlight code and write HTML output. /// -/// When the `highlighting` feature is enabled, this uses syntect for syntax +/// When the `highlighting` feature is enabled, this uses giallo for syntax /// highlighting. The `mode` parameter controls whether inline styles or CSS /// classes are emitted. Otherwise, it outputs plain escaped text. /// @@ -70,171 +90,219 @@ pub(crate) fn highlight_code( diagnostics: Option<&mut Diagnostics<'_>>, ) -> Result<(), Error> { let (code, callouts) = extract_text_and_callouts(inlines, diagnostics); - let syntax_set = syntect::parsing::SyntaxSet::load_defaults_newlines(); - - let syntax = syntax_set - .find_syntax_by_token(language) - .or_else(|| syntax_set.find_syntax_by_extension(language)) - .unwrap_or_else(|| syntax_set.find_syntax_plain_text()); match mode { - HighlightMode::Inline => highlight_code_inline( - writer, - &code, - &callouts, - inlines, - &syntax_set, - syntax, - theme_name, - ), + HighlightMode::Inline => { + let registry = get_registry(); + let theme_variant = giallo::ThemeVariant::Single(theme_name); + let options = + giallo::HighlightOptions::new(language, theme_variant).fallback_to_plain(true); + + let highlighted = match registry.highlight(&code, &options) { + Ok(h) => h, + Err(e) => { + tracing::warn!("giallo highlighting failed for language '{language}': {e}"); + return write_escaped_code_with_callouts(writer, inlines); + } + }; + render_inline(writer, &highlighted, &callouts) + } HighlightMode::Class => { - highlight_code_classed(writer, &code, &callouts, &syntax_set, syntax) + highlight_code_classed(writer, &code, &callouts, language, theme_name) } } } -/// Inline-style highlighting (existing behaviour). +/// Render highlighted tokens as inline-style HTML spans. +/// +/// Uses giallo's public API to compare each token's style against the +/// theme default and emit `` elements. #[cfg(feature = "highlighting")] -fn highlight_code_inline( +fn render_inline( writer: &mut W, - code: &str, + highlighted: &giallo::HighlightedCode<'_>, callouts: &HashMap, - inlines: &[InlineNode], - syntax_set: &syntect::parsing::SyntaxSet, - syntax: &syntect::parsing::SyntaxReference, - theme_name: &str, ) -> Result<(), Error> { - use syntect::{highlighting::ThemeSet, html::highlighted_html_for_string}; + let default_style = match &highlighted.theme { + giallo::ThemeVariant::Single(theme) => &theme.default_style, + giallo::ThemeVariant::Dual { light, .. } => &light.default_style, + }; - let theme_set = ThemeSet::load_defaults(); - let Some(theme) = theme_set.themes.get(theme_name) else { - return write_escaped_code_with_callouts(writer, inlines); + let last_line_idx = if highlighted.tokens.last().is_some_and(Vec::is_empty) { + // giallo produces a trailing empty line for content ending with \n; skip it + highlighted.tokens.len().saturating_sub(1) + } else { + highlighted.tokens.len() }; - let html = highlighted_html_for_string(code, syntax_set, syntax, theme) - .map_err(|e| Error::Io(std::io::Error::other(e)))?; + for (line_idx, line_tokens) in highlighted.tokens.iter().enumerate() { + if line_idx >= last_line_idx { + break; + } + if line_idx > 0 { + writer.write_all(b"\n")?; + } - // syntect wraps output in
 which we don't want
-    // since we already have our own 
 wrapper. Extract just the inner content.
-    let content = extract_inner_content(&html);
+        for token in line_tokens {
+            let escaped = html_escape(&token.text);
+            let style = match &token.style {
+                giallo::ThemeVariant::Single(s) => *s,
+                giallo::ThemeVariant::Dual { light, .. } => *light,
+            };
+
+            if style == *default_style {
+                write!(writer, "{escaped}")?;
+            } else {
+                let css = build_inline_css(&style, default_style);
+                if css.is_empty() {
+                    write!(writer, "{escaped}")?;
+                } else {
+                    write!(writer, "{escaped}")?;
+                }
+            }
+        }
 
-    if callouts.is_empty() {
-        write!(writer, "{content}")?;
-    } else {
-        let output = insert_callouts_into_highlighted_html(content, callouts);
-        write!(writer, "{output}")?;
+        // Append callout marker if this line has one
+        if let Some(&callout_num) = callouts.get(&line_idx) {
+            write!(
+                writer,
+                " ({callout_num})"
+            )?;
+        }
     }
 
     Ok(())
 }
 
-/// Class-based highlighting using `ClassedHTMLGenerator`.
+/// Build a CSS style string from differences between a token style and the default.
 #[cfg(feature = "highlighting")]
-fn highlight_code_classed(
-    writer: &mut W,
-    code: &str,
-    callouts: &HashMap,
-    syntax_set: &syntect::parsing::SyntaxSet,
-    syntax: &syntect::parsing::SyntaxReference,
-) -> Result<(), Error> {
-    use syntect::html::ClassedHTMLGenerator;
-
-    let mut generator =
-        ClassedHTMLGenerator::new_with_class_style(syntax, syntax_set, SYNTAX_CLASS_STYLE);
-    for line in syntect::util::LinesWithEndings::from(code) {
-        generator
-            .parse_html_for_line_which_includes_newline(line)
-            .map_err(|e| Error::Io(std::io::Error::other(e)))?;
-    }
-    let html = generator.finalize();
+fn build_inline_css(style: &giallo::Style, default: &giallo::Style) -> String {
+    use std::fmt::Write as FmtWrite;
 
-    if callouts.is_empty() {
-        write!(writer, "{html}")?;
-    } else {
-        let output = insert_callouts_into_classed_html(&html, callouts);
-        write!(writer, "{output}")?;
+    let mut css = String::with_capacity(40);
+
+    if style.foreground != default.foreground {
+        let _ = write!(css, "color: {};", style.foreground.as_hex());
+    }
+    if style.background != default.background {
+        let _ = write!(css, "background-color: {};", style.background.as_hex());
+    }
+    if style.font_style != default.font_style {
+        if style.font_style.contains(giallo::FontStyle::BOLD) {
+            css.push_str("font-weight: bold;");
+        }
+        if style.font_style.contains(giallo::FontStyle::ITALIC) {
+            css.push_str("font-style: italic;");
+        }
+        if style.font_style.contains(giallo::FontStyle::UNDERLINE)
+            && style.font_style.contains(giallo::FontStyle::STRIKETHROUGH)
+        {
+            css.push_str("text-decoration: underline line-through;");
+        } else if style.font_style.contains(giallo::FontStyle::UNDERLINE) {
+            css.push_str("text-decoration: underline;");
+        } else if style.font_style.contains(giallo::FontStyle::STRIKETHROUGH) {
+            css.push_str("text-decoration: line-through;");
+        }
     }
 
-    Ok(())
+    css
 }
 
-/// Generate a CSS stylesheet for class-based syntax highlighting.
+/// Class-based highlighting using giallo's `HtmlRenderer`.
 ///
-/// Returns the CSS rules that map `.syntax-*` classes to the colours of
-/// the given theme. Embed this in a `