diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 912bfcc..32125cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,12 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: + features: [fancy, syntect-highlighter] rust: [1.56.0, stable] os: [ubuntu-latest, macOS-latest, windows-latest] + exclude: + - features: syntect-highlighter + rust: 1.56.0 steps: - uses: actions/checkout@v4 @@ -43,10 +47,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 @@ -78,5 +82,4 @@ jobs: with: toolchain: nightly - name: Run minimal version build - run: cargo build -Z minimal-versions --all-features - + run: cargo build -Z minimal-versions --features fancy,no-format-args-capture diff --git a/Cargo.toml b/Cargo.toml index a730148..0123cc0 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.3.0", 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" @@ -42,6 +43,7 @@ regex = "1.5" lazy_static = "1.4" serde_json = "1.0.64" +strip-ansi-escapes = "0.2.0" [features] default = ["derive"] @@ -57,6 +59,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 3c09373..a564f44 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; @@ -59,6 +61,7 @@ pub struct MietteHandlerOpts { pub(crate) wrap_lines: Option, pub(crate) word_separator: Option, pub(crate) word_splitter: Option, + pub(crate) highlighter: Option, } impl MietteHandlerOpts { @@ -84,6 +87,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); @@ -246,10 +286,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_themed(theme) .with_width(width) - .with_links(linkify); + .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 7812925..d7e1883 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, ReportHandler, SourceCode, SourceSpan, SpanContents}; @@ -34,6 +35,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)] @@ -59,6 +61,7 @@ impl GraphicalReportHandler { break_words: true, word_separator: None, word_splitter: None, + highlighter: MietteHighlighter::default(), } } @@ -76,6 +79,7 @@ impl GraphicalReportHandler { break_words: true, word_separator: None, word_splitter: None, + highlighter: MietteHighlighter::default(), } } @@ -169,6 +173,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 { @@ -472,6 +493,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(&*contents); + // 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. @@ -545,7 +568,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 @@ -881,13 +906,26 @@ impl GraphicalReportHandler { /// Returns an iterator over the visual width of each character in a line. fn line_visual_char_width<'a>(&self, text: &'a str) -> impl Iterator + 'a { let mut column = 0; + let mut escaped = false; let tab_width = self.tab_width; text.chars().map(move |c| { - let width = if c == '\t' { + let width = match (escaped, c) { // Round up to the next multiple of tab_width - tab_width - column % tab_width - } else { - c.width().unwrap_or(0) + (false, '\t') => tab_width - column % tab_width, + // start of ANSI escape + (false, '\x1b') => { + escaped = true; + 0 + } + // use Unicode width for all other characters + (false, c) => c.width().unwrap_or(0), + // end of ANSI escape + (true, 'm') => { + escaped = false; + 0 + } + // characters are zero width within escape sequence + (true, _) => 0, }; column += width; width diff --git a/src/highlighters/blank.rs b/src/highlighters/blank.rs new file mode 100644 index 0000000..50a9c65 --- /dev/null +++ b/src/highlighters/blank.rs @@ -0,0 +1,36 @@ +use owo_colors::Style; + +use crate::SpanContents; + +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 SpanContents<'_>, + ) -> 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..47e7f7c --- /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::SpanContents; +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`](crate::SourceCode) snippets. +pub trait Highlighter { + /// Creates a new [HighlighterState] to begin parsing and highlighting + /// a [SpanContents]. + /// + /// The [GraphicalReportHandler](crate::GraphicalReportHandler) will call + /// this method at the start of rendering a [SpanContents]. + /// + /// The [SpanContents] 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 SpanContents<'_>, + ) -> 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..57ebadf --- /dev/null +++ b/src/highlighters/syntect.rs @@ -0,0 +1,170 @@ +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}, + SpanContents, +}; + +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 SpanContents<'_>, + ) -> Box { + if let Some(syntax) = self.detect_syntax(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 detect_syntax(&self, contents: &dyn SpanContents<'_>) -> Option<&syntect::SyntaxReference> { + // use language if given + if let Some(language) = contents.language() { + return self.syntax_set.find_syntax_by_name(language); + } + // otherwise try to use any file extension provided in the name + if let Some(name) = contents.name() { + if let Some(ext) = Path::new(name).extension() { + return self + .syntax_set + .find_syntax_by_extension(ext.to_string_lossy().as_ref()); + } + } + // finally, attempt to guess syntax based on first line + return self.syntax_set.find_syntax_by_first_line( + &std::str::from_utf8(contents.data()) + .ok()? + .split('\n') + .next()?, + ); + } +} + +/// 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 862e242..f8d92f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,6 +47,7 @@ //! - [... delayed source code](#-delayed-source-code) //! - [... handler options](#-handler-options) //! - [... dynamic diagnostics](#-dynamic-diagnostics) +//! - [... syntax highlighting](#-syntax-highlighting) //! - [Acknowledgements](#acknowledgements) //! - [License](#license) //! @@ -643,6 +644,34 @@ //! println!("{:?}", report) //! ``` //! +//! ### ... syntax highlighting +//! +//! `miette` can be configured to highlight syntax in source code snippets. +//! +//! +//! +//! To use the built-in highlighting functionality, you must enable the +//! `syntect-highlighter` crate feature. When this feature is enabled, `miette` will +//! automatically use the [`syntect`] crate to highlight the `#[source_code]` +//! field of your [`Diagnostic`]. +//! +//! Syntax detection with [`syntect`] is handled by checking 2 methods on the [`SpanContents`] trait, in order: +//! * [language()](SpanContents::language) - Provides the name of the language +//! as a string. For example `"Rust"` will indicate Rust syntax highlighting. +//! You can set the language of the [`SpanContents`] produced by a +//! [`NamedSource`] via the [`with_language`](NamedSource::with_language) +//! method. +//! * [name()](SpanContents::name) - In the absence of an explicitly set +//! language, the name is assumed to contain a file name or file path. +//! The highlighter will check for a file extension at the end of the name and +//! try to guess the syntax from that. +//! +//! If you want to use a custom highlighter, you can provide a custom +//! implementation of the [`Highlighter`](highlighters::Highlighter) +//! trait to [`MietteHandlerOpts`] by calling the +//! [`with_syntax_highlighting`](MietteHandlerOpts::with_syntax_highlighting) +//! method. See the [`highlighters`] module docs for more details. +//! //! ## Acknowledgements //! //! `miette` was not developed in a void. It owes enormous credit to various @@ -691,6 +720,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 8b0b75a..99e74a8 100644 --- a/src/named_source.rs +++ b/src/named_source.rs @@ -6,13 +6,15 @@ use crate::{MietteError, MietteSpanContents, SourceCode, SpanContents}; pub struct NamedSource { source: S, name: String, + language: Option, } impl std::fmt::Debug for NamedSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("NamedSource") .field("name", &self.name) - .field("source", &""); + .field("source", &"") + .field("language", &self.language); Ok(()) } } @@ -27,6 +29,7 @@ impl NamedSource { Self { source, name: name.as_ref().to_string(), + language: None, } } @@ -40,6 +43,12 @@ impl NamedSource { pub fn inner(&self) -> &S { &self.source } + + /// Sets the [`language`](SpanContents::language) for this source code. + pub fn with_language(mut self, language: impl Into) -> Self { + self.language = Some(language.into()); + self + } } impl SourceCode for NamedSource { @@ -49,16 +58,20 @@ impl SourceCode for NamedSource { context_lines_before: usize, context_lines_after: usize, ) -> Result + 'a>, MietteError> { - let contents = self - .inner() - .read_span(span, context_lines_before, context_lines_after)?; - Ok(Box::new(MietteSpanContents::new_named( + let inner_contents = + self.inner() + .read_span(span, context_lines_before, context_lines_after)?; + let mut contents = MietteSpanContents::new_named( self.name.clone(), - contents.data(), - *contents.span(), - contents.line(), - contents.column(), - contents.line_count(), - ))) + inner_contents.data(), + *inner_contents.span(), + inner_contents.line(), + inner_contents.column(), + inner_contents.line_count(), + ); + if let Some(language) = &self.language { + contents = contents.with_language(language); + } + Ok(Box::new(contents)) } } diff --git a/src/protocol.rs b/src/protocol.rs index b403494..042526e 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -433,6 +433,15 @@ pub trait SpanContents<'a> { fn column(&self) -> usize; /// Total number of lines covered by this `SpanContents`. fn line_count(&self) -> usize; + + /// Optional method. The language name for this source code, if any. + /// This is used to drive syntax highlighting. + /// + /// Examples: Rust, TOML, C + /// + fn language(&self) -> Option<&str> { + None + } } /** @@ -452,6 +461,8 @@ pub struct MietteSpanContents<'a> { line_count: usize, // Optional filename name: Option, + // Optional language + language: Option, } impl<'a> MietteSpanContents<'a> { @@ -470,6 +481,7 @@ impl<'a> MietteSpanContents<'a> { column, line_count, name: None, + language: None, } } @@ -489,8 +501,15 @@ impl<'a> MietteSpanContents<'a> { column, line_count, name: Some(name), + language: None, } } + + /// Sets the [`language`](SourceCode::language) for syntax highlighting. + pub fn with_language(mut self, language: impl Into) -> Self { + self.language = Some(language.into()); + self + } } impl<'a> SpanContents<'a> for MietteSpanContents<'a> { @@ -512,6 +531,9 @@ impl<'a> SpanContents<'a> for MietteSpanContents<'a> { fn name(&self) -> Option<&str> { self.name.as_deref() } + fn language(&self) -> Option<&str> { + self.language.as_deref() + } } /// Span within a [`SourceCode`] diff --git a/tests/graphical.rs b/tests/graphical.rs index ffb701c..ac9ec10 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(); @@ -1758,6 +1760,102 @@ fn single_line_with_wide_char_unaligned_span_empty() -> Result<(), MietteError> Ok(()) } +#[test] +#[cfg(feature = "syntect-highlighter")] +fn syntax_highlighter() { + std::env::set_var("REPLACE_TABS", "4"); + #[derive(Debug, Error, Diagnostic)] + #[error("This is an error")] + #[diagnostic()] + pub struct Test { + #[source_code] + src: NamedSource, + #[label("this is a label")] + src_span: SourceSpan, + } + let src = NamedSource::new( + "hello_world", //NOTE: intentionally missing file extension + "fn main() {\n println!(\"Hello, World!\");\n}\n".to_string(), + ) + .with_language("Rust"); + let err = Test { + src, + src_span: (16, 26).into(), + }; + let mut out = String::new(); + GraphicalReportHandler::new_themed(GraphicalTheme::unicode()) + .render_report(&mut out, &err) + .unwrap(); + let expected = r#" × This is an error + ╭─[hello_world:2:5] + 1 │ fn main() { + 2 │ println!("Hello, World!"); + · ─────────────┬──────────── + · ╰── this is a label + 3 │ } + ╰──── +"#; + assert!(out.contains("\u{1b}[38;2;180;142;173m")); + assert_eq!(expected, strip_ansi_escapes::strip_str(out)) +} + +// This test reads a line from the current source file and renders it with Rust +// syntax highlighting. The goal is to test syntax highlighting on a non-trivial +// source code example. However, if tests are running in an environment where +// source files are missing, this will cause problems. In that case, it would +// be better to use include_str!() on a sufficiently complex example file. +#[test] +#[cfg(feature = "syntect-highlighter")] +fn syntax_highlighter_on_real_file() { + std::env::set_var("REPLACE_TABS", "4"); + + #[derive(Debug, Error, Diagnostic)] + #[error("This is an error")] + #[diagnostic()] + pub struct Test { + #[source_code] + src: NamedSource, + #[label("this is a label")] + src_span: SourceSpan, + } + // BEGIN SOURCE SNIPPET + + let (filename, line) = (file!(), line!() as usize); + + // END SOURCE SNIPPET + // SourceSpan constants for column and length + const CO: usize = 28; + const LEN: usize = 27; + let file_src = std::fs::read_to_string(&filename).unwrap(); + let offset = miette::SourceOffset::from_location(&file_src, line, CO); + let err = Test { + src: NamedSource::new(&filename, file_src.clone()), + src_span: SourceSpan::new(offset, LEN.into()), + }; + + let mut out = String::new(); + GraphicalReportHandler::new_themed(GraphicalTheme::unicode()) + .with_context_lines(1) + .render_report(&mut out, &err) + .unwrap(); + + let expected = format!( + r#" × This is an error + ╭─[{filename}:{l2}:{CO}] + {l1} │ + {l2} │ let (filename, line) = (file!(), line!() as usize); + · ─────────────┬───────────── + · ╰── this is a label + {l3} │ + ╰──── +"#, + l1 = line - 1, + l2 = line, + l3 = line + 1 + ); + assert!(out.contains("\u{1b}[38;2;180;142;173m")); + assert_eq!(expected, strip_ansi_escapes::strip_str(out)); + #[test] fn triple_adjacent_highlight() -> Result<(), MietteError> { #[derive(Debug, Diagnostic, Error)]