diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 912bfcc..40dfc94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: + features: [fancy, syntect-highlighter] rust: [1.56.0, stable] os: [ubuntu-latest, macOS-latest, windows-latest] @@ -43,10 +44,10 @@ jobs: run: cargo clippy --all -- -D warnings - name: Run tests if: matrix.rust == 'stable' - run: cargo test --all --verbose --features fancy + run: cargo test --all --verbose --features ${{matrix.features}} - name: Run tests if: matrix.rust == '1.56.0' - run: cargo test --all --verbose --features fancy no-format-args-capture + run: cargo test --all --verbose --features ${{matrix.features}} no-format-args-capture miri: name: Miri diff --git a/Cargo.toml b/Cargo.toml index f8c2cb5..3e4d9ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ miette-derive = { path = "miette-derive", version = "=5.10.0", optional = true } once_cell = "1.8.0" unicode-width = "0.1.9" -owo-colors = { version = "3.0.0", optional = true } +owo-colors = { version = "3.4.0", optional = true } is-terminal = { version = "0.4.0", optional = true } textwrap = { version = "0.15.0", optional = true } supports-hyperlinks = { version = "2.0.0", optional = true } @@ -28,6 +28,7 @@ backtrace = { version = "0.3.61", optional = true } terminal_size = { version = "0.1.17", optional = true } backtrace-ext = { version = "0.2.1", optional = true } serde = { version = "1.0.162", features = ["derive"], optional = true } +syntect = { version = "5.1.0", optional = true } [dev-dependencies] semver = "1.0.4" @@ -57,6 +58,7 @@ fancy-no-backtrace = [ "supports-unicode", ] fancy = ["fancy-no-backtrace", "backtrace", "backtrace-ext"] +syntect-highlighter = ["fancy-no-backtrace", "syntect"] [workspace] members = ["miette-derive"] diff --git a/src/handler.rs b/src/handler.rs index e32f3ef..8f1385c 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,5 +1,7 @@ use std::fmt; +use crate::highlighters::Highlighter; +use crate::highlighters::MietteHighlighter; use crate::protocol::Diagnostic; use crate::GraphicalReportHandler; use crate::GraphicalTheme; @@ -58,6 +60,7 @@ pub struct MietteHandlerOpts { pub(crate) break_words: Option, pub(crate) word_separator: Option, pub(crate) word_splitter: Option, + pub(crate) highlighter: Option, } impl MietteHandlerOpts { @@ -83,6 +86,43 @@ impl MietteHandlerOpts { self } + /// Set a syntax highlighter when rendering in graphical mode. + /// Use [`force_graphical()`](MietteHandlerOpts::force_graphical()) to + /// force graphical mode. + /// + /// Syntax highlighting is disabled by default unless the + /// `syntect-highlighter` feature is enabled. Call this method + /// to override the default and use a custom highlighter + /// implmentation instead. + /// + /// Use + /// [`without_syntax_highlighting()`](MietteHandlerOpts::without_syntax_highlighting()) + /// To disable highlighting completely. + /// + /// Setting this option will not force color output. In all cases, the + /// current color configuration via + /// [`color()`](MietteHandlerOpts::color()) takes precedence over + /// highlighter configuration. + pub fn with_syntax_highlighting( + mut self, + highlighter: impl Highlighter + Send + Sync + 'static, + ) -> Self { + self.highlighter = Some(MietteHighlighter::from(highlighter)); + self + } + + /// Disables syntax highlighting when rendering in graphical mode. + /// Use [`force_graphical()`](MietteHandlerOpts::force_graphical()) to + /// force graphical mode. + /// + /// Syntax highlighting is disabled by default unless the + /// `syntect-highlighter` feature is enabled. Call this method if you want + /// to disable highlighting when building with this feature. + pub fn without_syntax_highlighting(mut self) -> Self { + self.highlighter = Some(MietteHighlighter::nocolor()); + self + } + /// Sets the width to wrap the report at. Defaults to 80. pub fn width(mut self, width: usize) -> Self { self.width = Some(width); @@ -236,11 +276,34 @@ impl MietteHandlerOpts { } else { ThemeStyles::none() }; + #[cfg(not(feature = "syntect-highlighter"))] + let highlighter = self.highlighter.unwrap_or_else(MietteHighlighter::nocolor); + #[cfg(feature = "syntect-highlighter")] + let highlighter = if self.color == Some(false) { + MietteHighlighter::nocolor() + } else if self.color == Some(true) + || supports_color::on(supports_color::Stream::Stderr).is_some() + { + match self.highlighter { + Some(highlighter) => highlighter, + None => match self.rgb_colors { + // Because the syntect highlighter currently only supports 24-bit truecolor, + // respect RgbColor::Never by disabling the highlighter. + // TODO: In the future, find a way to convert the RGB syntect theme + // into an ANSI color theme. + RgbColors::Never => MietteHighlighter::nocolor(), + _ => MietteHighlighter::syntect_truecolor(), + }, + } + } else { + MietteHighlighter::nocolor() + }; let theme = self.theme.unwrap_or(GraphicalTheme { characters, styles }); let mut handler = GraphicalReportHandler::new() .with_width(width) .with_links(linkify) .with_theme(theme); + handler.highlighter = highlighter; if let Some(with_cause_chain) = self.with_cause_chain { if with_cause_chain { handler = handler.with_cause_chain(); diff --git a/src/handlers/graphical.rs b/src/handlers/graphical.rs index 3193472..b94d706 100644 --- a/src/handlers/graphical.rs +++ b/src/handlers/graphical.rs @@ -1,10 +1,11 @@ use std::fmt::{self, Write}; -use owo_colors::{OwoColorize, Style}; +use owo_colors::{OwoColorize, Style, StyledList}; use unicode_width::UnicodeWidthChar; use crate::diagnostic_chain::{DiagnosticChain, ErrorKind}; use crate::handlers::theme::*; +use crate::highlighters::{Highlighter, MietteHighlighter}; use crate::protocol::{Diagnostic, Severity}; use crate::{LabeledSpan, MietteError, ReportHandler, SourceCode, SourceSpan, SpanContents}; @@ -33,6 +34,7 @@ pub struct GraphicalReportHandler { pub(crate) break_words: bool, pub(crate) word_separator: Option, pub(crate) word_splitter: Option, + pub(crate) highlighter: MietteHighlighter, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -57,6 +59,7 @@ impl GraphicalReportHandler { break_words: true, word_separator: None, word_splitter: None, + highlighter: MietteHighlighter::default(), } } @@ -73,6 +76,7 @@ impl GraphicalReportHandler { break_words: true, word_separator: None, word_splitter: None, + highlighter: MietteHighlighter::default(), } } @@ -160,6 +164,23 @@ impl GraphicalReportHandler { self.context_lines = lines; self } + + /// Enable syntax highlighting for source code snippets, using the given + /// [`Highlighter`]. See the [crate::highlighters] crate for more details. + pub fn with_syntax_highlighting( + mut self, + highlighter: impl Highlighter + Send + Sync + 'static, + ) -> Self { + self.highlighter = MietteHighlighter::from(highlighter); + self + } + + /// Disable syntax highlighting. This uses the + /// [`crate::highlighters::BlankHighlighter`] as a no-op highlighter. + pub fn without_syntax_highlighting(mut self) -> Self { + self.highlighter = MietteHighlighter::nocolor(); + self + } } impl Default for GraphicalReportHandler { @@ -461,6 +482,8 @@ impl GraphicalReportHandler { .map(|(label, st)| FancySpan::new(label.label().map(String::from), *label.inner(), st)) .collect::>(); + let mut highlighter_state = self.highlighter.start_highlighter_state(source); + // The max number of gutter-lines that will be active at any given // point. We need this to figure out indentation, so we do one loop // over the lines to see what the damage is gonna be. @@ -534,7 +557,9 @@ impl GraphicalReportHandler { self.render_line_gutter(f, max_gutter, line, &labels)?; // And _now_ we can print out the line text itself! - self.render_line_text(f, &line.text)?; + let styled_text = + StyledList::from(highlighter_state.highlight_line(&line.text)).to_string(); + self.render_line_text(f, &styled_text)?; // Next, we write all the highlights that apply to this particular line. let (single_line, multi_line): (Vec<_>, Vec<_>) = labels diff --git a/src/highlighters/blank.rs b/src/highlighters/blank.rs new file mode 100644 index 0000000..681d401 --- /dev/null +++ b/src/highlighters/blank.rs @@ -0,0 +1,34 @@ +use owo_colors::Style; + +use super::{Highlighter, HighlighterState}; + +/// The default syntax highlighter. It applies `Style::default()` to input text. +/// This is used by default when no syntax highlighting features are enabled. +#[derive(Debug, Clone)] +pub struct BlankHighlighter; + +impl Highlighter for BlankHighlighter { + fn start_highlighter_state<'h>( + &'h self, + _source: &dyn crate::SourceCode, + ) -> Box { + Box::new(BlankHighlighterState) + } +} + +impl Default for BlankHighlighter { + fn default() -> Self { + BlankHighlighter + } +} + +/// The default highlighter state. It applies `Style::default()` to input text. +/// This is used by default when no syntax highlighting features are enabled. +#[derive(Debug, Clone)] +pub struct BlankHighlighterState; + +impl HighlighterState for BlankHighlighterState { + fn highlight_line<'s>(&mut self, line: &'s str) -> Vec> { + vec![Style::default().style(line)] + } +} diff --git a/src/highlighters/mod.rs b/src/highlighters/mod.rs new file mode 100644 index 0000000..2ffe693 --- /dev/null +++ b/src/highlighters/mod.rs @@ -0,0 +1,116 @@ +//! This module provides a trait for creating custom syntax highlighters that +//! highlight [`Diagnostic`](crate::Diagnostic) source code with ANSI escape +//! sequences when rendering with the [`GraphicalReportHighlighter`](crate::GraphicalReportHandler). +//! +//! It also provides built-in highlighter implementations that you can use out of the box. +//! By default, there are no syntax highlighters exported by miette +//! (except for the no-op [`BlankHighlighter`]). +//! To enable support for specific highlighters, you should enable their associated feature flag. +//! +//! Currently supported syntax highlighters and their feature flags: +//! * `syntect-highlighter` - Enables [`syntect`](https://docs.rs/syntect/latest/syntect/) syntax highlighting support via the [`SyntectHighlighter`] +//! + +use std::{ops::Deref, sync::Arc}; + +use crate::SourceCode; +use owo_colors::Styled; + +#[cfg(feature = "syntect-highlighter")] +pub use self::syntect::*; +pub use blank::*; + +mod blank; +#[cfg(feature = "syntect-highlighter")] +mod syntect; + +/// A syntax highlighter for highlighting miette [SourceCode] snippets. +pub trait Highlighter { + /// Creates a new [HighlighterState] to begin parsing and highlighting + /// a [SourceCode] snippet. + /// + /// The [GraphicalReportHandler](crate::GraphicalReportHandler) will call + /// this method at the start of rendering a [Diagnostic](crate::Diagnostic). + /// + /// The source is provided as input only so that the Highlighter can detect + /// language syntax and make other initialization decisions prior + /// to highlighting, but it is not intended that the Highlighter begin + /// highlighting at this point. The returned [HighlighterState] is + /// responsible for the actual rendering. + fn start_highlighter_state<'h>( + &'h self, + source: &dyn SourceCode, + ) -> Box; +} + +/// A stateful highlighter that incrementally highlights lines of a particular +/// source code. +/// +/// The [GraphicalReportHandler](crate::GraphicalReportHandler) +/// will create a highlighter state by calling +/// [start_highlighter_state](Highlighter::start_highlighter_state) at the +/// start of rendering, then it will iteratively call +/// [highlight_line](HighlighterState::highlight_line) to render individual +/// highlighted lines. This allows [Highlighter] implementations to maintain +/// mutable parsing and highlighting state. +pub trait HighlighterState { + /// Highlight an individual line from the source code by returning a vector of [Styled] + /// regions. + fn highlight_line<'s>(&mut self, line: &'s str) -> Vec>; +} + +/// Arcified trait object for Highlighter. Used internally by [GraphicalReportHandler] +/// +/// Wrapping the trait object in this way allows us to implement Debug and Clone. +#[derive(Clone)] +#[repr(transparent)] +pub(crate) struct MietteHighlighter(Arc); + +impl MietteHighlighter { + pub(crate) fn nocolor() -> Self { + Self::from(BlankHighlighter) + } + + #[cfg(feature = "syntect-highlighter")] + pub(crate) fn syntect_truecolor() -> Self { + Self::from(SyntectHighlighter::default()) + } +} + +impl Default for MietteHighlighter { + #[cfg(feature = "syntect-highlighter")] + fn default() -> Self { + use is_terminal::IsTerminal; + match std::env::var("NO_COLOR") { + _ if !std::io::stdout().is_terminal() || !std::io::stderr().is_terminal() => { + //TODO: should use ANSI styling instead of 24-bit truecolor here + Self(Arc::new(SyntectHighlighter::default())) + } + Ok(string) if string != "0" => MietteHighlighter::nocolor(), + _ => Self(Arc::new(SyntectHighlighter::default())), + } + } + #[cfg(not(feature = "syntect-highlighter"))] + fn default() -> Self { + return MietteHighlighter::nocolor(); + } +} + +impl From for MietteHighlighter { + fn from(value: T) -> Self { + Self(Arc::new(value)) + } +} + +impl std::fmt::Debug for MietteHighlighter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "MietteHighlighter(...)") + } +} + +impl Deref for MietteHighlighter { + type Target = dyn Highlighter + Send + Sync; + fn deref(&self) -> &Self::Target { + &*self.0 + } +} diff --git a/src/highlighters/syntect.rs b/src/highlighters/syntect.rs new file mode 100644 index 0000000..f1a500c --- /dev/null +++ b/src/highlighters/syntect.rs @@ -0,0 +1,161 @@ +use std::path::Path; + +// all syntect imports are explicitly qualified, but their paths are shortened for convenience +mod syntect { + pub(super) use syntect::{ + highlighting::{ + Color, HighlightIterator, HighlightState, Highlighter, Style, Theme, ThemeSet, + }, + parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet}, + }; +} + +use owo_colors::{Rgb, Style, Styled}; + +use crate::{ + highlighters::{Highlighter, HighlighterState}, + SourceCode, +}; + +use super::BlankHighlighterState; + +/// Highlights miette [SourceCode] with the [syntect](https://docs.rs/syntect/latest/syntect/) highlighting crate. +/// +/// Currently only 24-bit truecolor output is supported due to syntect themes +/// representing color as RGBA. +#[derive(Debug, Clone)] +pub struct SyntectHighlighter { + theme: syntect::Theme, + syntax_set: syntect::SyntaxSet, + use_bg_color: bool, +} + +impl Default for SyntectHighlighter { + fn default() -> Self { + let theme_set = syntect::ThemeSet::load_defaults(); + let theme = theme_set.themes["base16-ocean.dark"].clone(); + Self::new_themed(theme, false) + } +} + +impl Highlighter for SyntectHighlighter { + fn start_highlighter_state<'h>( + &'h self, + source: &dyn SourceCode, + ) -> Box { + if let Some(syntax) = self.get_syntax_from_source(source) { + let highlighter = syntect::Highlighter::new(&self.theme); + let parse_state = syntect::ParseState::new(syntax); + let highlight_state = + syntect::HighlightState::new(&highlighter, syntect::ScopeStack::new()); + Box::new(SyntectHighlighterState { + syntax_set: &self.syntax_set, + highlighter, + parse_state, + highlight_state, + use_bg_color: self.use_bg_color, + }) + } else { + Box::new(BlankHighlighterState) + } + } +} + +impl SyntectHighlighter { + /// Create a syntect highlighter with the given theme and syntax set. + pub fn new(syntax_set: syntect::SyntaxSet, theme: syntect::Theme, use_bg_color: bool) -> Self { + Self { + theme, + syntax_set, + use_bg_color, + } + } + + /// Create a syntect highlighter with the given theme and the default syntax set. + pub fn new_themed(theme: syntect::Theme, use_bg_color: bool) -> Self { + Self::new( + syntect::SyntaxSet::load_defaults_nonewlines(), + theme, + use_bg_color, + ) + } + + /// Determine syntect SyntaxReference to use for given SourceCode + fn get_syntax_from_source(&self, source: &dyn SourceCode) -> Option<&syntect::SyntaxReference> { + if let Some(name) = source.name() { + if let Some(ext) = Path::new(name).extension() { + self.syntax_set + .find_syntax_by_extension(ext.to_string_lossy().as_ref()) + } else { + None + } + } else { + None + } + } +} + +/// Stateful highlighting iterator for [SyntectHighlighter] +#[derive(Debug)] +pub(crate) struct SyntectHighlighterState<'h> { + syntax_set: &'h syntect::SyntaxSet, + highlighter: syntect::Highlighter<'h>, + parse_state: syntect::ParseState, + highlight_state: syntect::HighlightState, + use_bg_color: bool, +} + +impl<'h> HighlighterState for SyntectHighlighterState<'h> { + fn highlight_line<'s>(&mut self, line: &'s str) -> Vec> { + if let Ok(ops) = self.parse_state.parse_line(line, &self.syntax_set) { + let use_bg_color = self.use_bg_color; + syntect::HighlightIterator::new( + &mut self.highlight_state, + &ops, + line, + &mut self.highlighter, + ) + .map(|(style, str)| (convert_style(style, use_bg_color).style(str))) + .collect() + } else { + vec![Style::default().style(line)] + } + } +} + +/// Convert syntect [syntect::Style] into owo_colors [Style] */ +#[inline] +fn convert_style(syntect_style: syntect::Style, use_bg_color: bool) -> Style { + if use_bg_color { + let fg = blend_fg_color(syntect_style); + let bg = convert_color(syntect_style.background); + Style::new().color(fg).on_color(bg) + } else { + let fg = convert_color(syntect_style.foreground); + Style::new().color(fg) + } +} + +/// Blend foreground RGB into background RGB according to alpha channel +#[inline] +fn blend_fg_color(syntect_style: syntect::Style) -> Rgb { + let fg = syntect_style.foreground; + if fg.a == 0xff { + return convert_color(fg); + } + let bg = syntect_style.background; + let ratio = fg.a as u32; + let r = (fg.r as u32 * ratio + bg.r as u32 * (255 - ratio)) / 255; + let g = (fg.g as u32 * ratio + bg.g as u32 * (255 - ratio)) / 255; + let b = (fg.b as u32 * ratio + bg.b as u32 * (255 - ratio)) / 255; + Rgb(r as u8, g as u8, b as u8) +} + +/// Convert syntect color into owo color. +/// +/// Note: ignores alpha channel. use [`blend_fg_color`] if you need that +/// +#[inline] +fn convert_color(color: syntect::Color) -> Rgb { + Rgb(color.r, color.g, color.b) +} diff --git a/src/lib.rs b/src/lib.rs index d80efa7..f5c987c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -691,6 +691,8 @@ mod eyreish; #[cfg(feature = "fancy-no-backtrace")] mod handler; mod handlers; +#[cfg(feature = "fancy-no-backtrace")] +pub mod highlighters; #[doc(hidden)] pub mod macro_helpers; mod miette_diagnostic; diff --git a/src/named_source.rs b/src/named_source.rs index 31ad1d1..2dc3f14 100644 --- a/src/named_source.rs +++ b/src/named_source.rs @@ -58,4 +58,8 @@ impl SourceCode for NamedSource { contents.line_count(), ))) } + + fn name(&self) -> Option<&str> { + Some(&self.name) + } } diff --git a/src/protocol.rs b/src/protocol.rs index 4a6012c..fa8b9e2 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -240,6 +240,11 @@ pub trait SourceCode: Send + Sync { context_lines_before: usize, context_lines_after: usize, ) -> Result + 'a>, MietteError>; + + /// Optional method. The name of this source, if any + fn name(&self) -> Option<&str> { + None + } } /// A labeled [`SourceSpan`]. diff --git a/tests/graphical.rs b/tests/graphical.rs index aabf167..24bfbc2 100644 --- a/tests/graphical.rs +++ b/tests/graphical.rs @@ -21,12 +21,14 @@ fn fmt_report(diag: Report) -> String { .unwrap(); } else if let Ok(w) = std::env::var("REPLACE_TABS") { GraphicalReportHandler::new_themed(GraphicalTheme::unicode_nocolor()) + .without_syntax_highlighting() .with_width(80) .tab_width(w.parse().expect("Invalid tab width.")) .render_report(&mut out, diag.as_ref()) .unwrap(); } else { GraphicalReportHandler::new_themed(GraphicalTheme::unicode_nocolor()) + .without_syntax_highlighting() .with_width(80) .render_report(&mut out, diag.as_ref()) .unwrap();