typst_batch/
diagnostic.rs

1//! Diagnostic formatting for Typst compilation errors and warnings.
2//!
3//! This module provides human-readable formatting for [`SourceDiagnostic`] similar
4//! to the official `typst-cli` output.
5//!
6//! # Two-Layer API
7//!
8//! This module provides two levels of API via [`DiagnosticsExt`] trait:
9//!
10//! ## Simple: Ready-to-Use Formatting
11//!
12//! ```ignore
13//! use typst_batch::DiagnosticsExt;
14//!
15//! let output = result.diagnostics.format(&world);
16//! eprintln!("{}", output);
17//! ```
18//!
19//! ## Advanced: Full Customization
20//!
21//! Use `.resolve()` to get structured [`DiagnosticInfo`] and render
22//! it however you want:
23//!
24//! ```ignore
25//! use typst_batch::DiagnosticsExt;
26//!
27//! for info in result.diagnostics.resolve(&world) {
28//!     // Custom rendering: JSON, HTML, IDE integration, etc.
29//!     println!("{}: {} at {}:{}",
30//!         info.severity, info.message, info.path, info.line);
31//! }
32//! ```
33//!
34//! # Example Output
35//!
36//! ```text
37//! error: `invalid:meta.typ` is not a valid package namespace
38//!   ┌─ content/index.typ:1:8
39//!   │
40//! 1 │ #import "@invalid:meta.typ" as meta
41//!   │         ^^^^^^^^^^^^^^^^
42//! ```
43
44use std::fmt::{self, Write};
45
46use thiserror::Error;
47use typst::diag::Severity;
48use typst::syntax::Span;
49use typst::World;
50
51// Re-export for user convenience
52pub use typst::diag::{Severity as DiagnosticSeverity, SourceDiagnostic};
53
54// ============================================================================
55// Diagnostic Options
56// ============================================================================
57
58/// Display style for diagnostic output.
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
60pub enum DisplayStyle {
61    /// Rich output with source snippets and highlighting.
62    #[default]
63    Rich,
64    /// Short output with just file:line:col and message.
65    Short,
66}
67
68/// Options for controlling diagnostic formatting.
69///
70/// # Example
71///
72/// ```ignore
73/// use typst_batch::diagnostic::{DiagnosticOptions, DisplayStyle};
74///
75/// // Default: colored rich output
76/// let opts = DiagnosticOptions::default();
77///
78/// // Plain text (no ANSI colors) for logging
79/// let opts = DiagnosticOptions::plain();
80///
81/// // Short format for CI/IDE integration
82/// let opts = DiagnosticOptions::short();
83///
84/// // Custom configuration
85/// let opts = DiagnosticOptions::new()
86///     .with_color(true)
87///     .with_style(DisplayStyle::Rich)
88///     .with_snippets(true);
89/// ```
90#[derive(Debug, Clone)]
91pub struct DiagnosticOptions {
92    /// Whether to use ANSI colors in output.
93    pub colored: bool,
94    /// Display style (rich with snippets or short).
95    pub style: DisplayStyle,
96    /// Whether to include source code snippets.
97    pub snippets: bool,
98    /// Whether to include hints.
99    pub hints: bool,
100    /// Whether to include trace information.
101    pub traces: bool,
102    /// Tab width for display (default: 2).
103    pub tab_width: usize,
104}
105
106impl Default for DiagnosticOptions {
107    fn default() -> Self {
108        Self {
109            colored: true,
110            style: DisplayStyle::Rich,
111            snippets: true,
112            hints: true,
113            traces: true,
114            tab_width: 2,
115        }
116    }
117}
118
119impl DiagnosticOptions {
120    /// Create new options with default settings.
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    /// Create options for colored terminal output.
126    pub fn colored() -> Self {
127        Self::default()
128    }
129
130    /// Create options for plain text output (no ANSI colors).
131    pub fn plain() -> Self {
132        Self {
133            colored: false,
134            ..Self::default()
135        }
136    }
137
138    /// Create options for short format (file:line:col: message).
139    pub fn short() -> Self {
140        Self {
141            style: DisplayStyle::Short,
142            snippets: false,
143            traces: false,
144            ..Self::default()
145        }
146    }
147
148    /// Set whether to use colors.
149    pub fn with_colored(mut self, colored: bool) -> Self {
150        self.colored = colored;
151        self
152    }
153
154    /// Set display style.
155    pub fn with_style(mut self, style: DisplayStyle) -> Self {
156        self.style = style;
157        self
158    }
159
160    /// Set whether to include source snippets.
161    pub fn with_snippets(mut self, snippets: bool) -> Self {
162        self.snippets = snippets;
163        self
164    }
165
166    /// Set whether to include hints.
167    pub fn with_hints(mut self, hints: bool) -> Self {
168        self.hints = hints;
169        self
170    }
171
172    /// Set whether to include traces.
173    pub fn with_traces(mut self, traces: bool) -> Self {
174        self.traces = traces;
175        self
176    }
177
178    /// Set tab width for display.
179    pub fn with_tab_width(mut self, width: usize) -> Self {
180        self.tab_width = width;
181        self
182    }
183}
184
185// ============================================================================
186// Compilation Error Type
187// ============================================================================
188
189/// Error type for Typst compilation failures.
190///
191/// This provides structured access to compilation errors for programmatic handling,
192/// while also implementing `Display` for human-readable output.
193///
194/// # Example
195///
196/// ```ignore
197/// match compile_html(path, root) {
198///     Ok(result) => { /* success */ }
199///     Err(CompileError::Compilation { diagnostics, .. }) => {
200///         // Access individual errors
201///         for diag in &diagnostics {
202///             if diag.severity == Severity::Error {
203///                 // Handle error...
204///             }
205///         }
206///     }
207///     Err(CompileError::HtmlExport { message }) => {
208///         eprintln!("HTML export failed: {message}");
209///     }
210///     Err(e) => eprintln!("{e}"),
211/// }
212/// ```
213#[derive(Debug, Error)]
214pub enum CompileError {
215    /// Typst compilation failed with diagnostics.
216    #[error("Typst compilation failed:\n{formatted}")]
217    Compilation {
218        /// The raw diagnostics for programmatic access.
219        diagnostics: Vec<SourceDiagnostic>,
220        /// Pre-formatted error message.
221        formatted: String,
222    },
223
224    /// HTML export failed.
225    #[error("HTML export failed: {message}")]
226    HtmlExport {
227        /// Error message from typst_html.
228        message: String,
229    },
230
231    /// File I/O error.
232    #[error("I/O error: {0}")]
233    Io(#[from] std::io::Error),
234}
235
236impl CompileError {
237    /// Create a compilation error from diagnostics.
238    pub fn compilation<W: World>(world: &W, diagnostics: Vec<SourceDiagnostic>) -> Self {
239        let formatted = format_diagnostics(world, &diagnostics);
240        Self::Compilation {
241            diagnostics,
242            formatted,
243        }
244    }
245
246    /// Create a compilation error with custom formatting options.
247    pub fn compilation_with_options<W: World>(
248        world: &W,
249        diagnostics: Vec<SourceDiagnostic>,
250        options: &DiagnosticOptions,
251    ) -> Self {
252        let formatted = format_diagnostics_with_options(world, &diagnostics, options);
253        Self::Compilation {
254            diagnostics,
255            formatted,
256        }
257    }
258
259    /// Create an HTML export error.
260    pub fn html_export(message: impl Into<String>) -> Self {
261        Self::HtmlExport {
262            message: message.into(),
263        }
264    }
265
266    /// Check if this error contains any fatal errors (vs just warnings).
267    pub fn has_fatal_errors(&self) -> bool {
268        match self {
269            Self::Compilation { diagnostics, .. } => has_errors(diagnostics),
270            _ => true,
271        }
272    }
273
274    /// Get the diagnostics if this is a compilation error.
275    pub fn diagnostics(&self) -> Option<&[SourceDiagnostic]> {
276        match self {
277            Self::Compilation { diagnostics, .. } => Some(diagnostics),
278            _ => None,
279        }
280    }
281}
282
283// ============================================================================
284// Diagnostic Summary
285// ============================================================================
286
287/// Summary of diagnostic counts.
288#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
289pub struct DiagnosticSummary {
290    /// Number of errors.
291    pub errors: usize,
292    /// Number of warnings.
293    pub warnings: usize,
294}
295
296impl DiagnosticSummary {
297    /// Create summary from diagnostics.
298    pub fn from_diagnostics(diagnostics: &[SourceDiagnostic]) -> Self {
299        let (errors, warnings) = count_diagnostics(diagnostics);
300        Self { errors, warnings }
301    }
302
303    /// Total number of diagnostics.
304    pub fn total(&self) -> usize {
305        self.errors + self.warnings
306    }
307
308    /// Whether there are any errors.
309    pub fn has_errors(&self) -> bool {
310        self.errors > 0
311    }
312
313    /// Whether there are any diagnostics at all.
314    pub fn is_empty(&self) -> bool {
315        self.total() == 0
316    }
317}
318
319impl fmt::Display for DiagnosticSummary {
320    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321        match (self.errors, self.warnings) {
322            (0, 0) => write!(f, "no diagnostics"),
323            (e, 0) => write!(f, "{e} error{}", if e == 1 { "" } else { "s" }),
324            (0, w) => write!(f, "{w} warning{}", if w == 1 { "" } else { "s" }),
325            (e, w) => write!(
326                f,
327                "{e} error{}, {w} warning{}",
328                if e == 1 { "" } else { "s" },
329                if w == 1 { "" } else { "s" }
330            ),
331        }
332    }
333}
334
335// ============================================================================
336// Diagnostics Extension Trait
337// ============================================================================
338
339/// Extension trait for working with diagnostic slices.
340///
341/// This trait provides convenient methods for analyzing collections of
342/// [`SourceDiagnostic`]s without importing standalone functions.
343///
344/// # Example
345///
346/// ```ignore
347/// use typst_batch::{compile_html, DiagnosticsExt};
348///
349/// let result = compile_html(path, root)?;
350///
351/// if result.diagnostics.has_errors() {
352///     eprintln!("Compilation failed with {} errors", result.diagnostics.error_count());
353/// }
354///
355/// let summary = result.diagnostics.summary();
356/// println!("{}", summary);  // "2 errors, 1 warning"
357///
358/// // Format for display
359/// let formatted = result.diagnostics.format(&world);
360///
361/// // Or get structured data for custom rendering
362/// let infos = result.diagnostics.resolve(&world);
363/// ```
364pub trait DiagnosticsExt {
365    /// Check if there are any errors in the diagnostics.
366    fn has_errors(&self) -> bool;
367
368    /// Check if there are any warnings in the diagnostics.
369    fn has_warnings(&self) -> bool;
370
371    /// Check if the diagnostic list is empty.
372    fn is_empty(&self) -> bool;
373
374    /// Get the number of diagnostics.
375    fn len(&self) -> usize;
376
377    /// Count errors in the diagnostics.
378    fn error_count(&self) -> usize;
379
380    /// Count warnings in the diagnostics.
381    fn warning_count(&self) -> usize;
382
383    /// Get counts of errors and warnings.
384    fn counts(&self) -> (usize, usize);
385
386    /// Get a summary of the diagnostics.
387    fn summary(&self) -> DiagnosticSummary;
388
389    /// Filter out diagnostics matching any of the given filters.
390    ///
391    /// # Example
392    ///
393    /// ```ignore
394    /// use typst_batch::{DiagnosticsExt, DiagnosticFilter};
395    ///
396    /// // Filter out HTML export warnings and external package warnings
397    /// let filtered = diagnostics.filter_out(&[
398    ///     DiagnosticFilter::HtmlExport,
399    ///     DiagnosticFilter::ExternalPackages,
400    /// ]);
401    /// ```
402    fn filter_out(&self, filters: &[DiagnosticFilter]) -> Vec<SourceDiagnostic>;
403
404    /// Filter out known HTML export development warnings.
405    ///
406    /// Shorthand for `filter_out(&[DiagnosticFilter::HtmlExport])`.
407    fn filter_html_warnings(&self) -> Vec<SourceDiagnostic> {
408        self.filter_out(&[DiagnosticFilter::HtmlExport])
409    }
410
411    /// Format diagnostics into a human-readable string.
412    ///
413    /// Uses default options (colored, rich format with snippets).
414    fn format<W: World>(&self, world: &W) -> String;
415
416    /// Format diagnostics with custom options.
417    fn format_with<W: World>(&self, world: &W, options: &DiagnosticOptions) -> String;
418
419    /// Resolve diagnostics to structured data for custom rendering.
420    ///
421    /// Use this when you need full control over output format (JSON, HTML, IDE integration, etc.)
422    fn resolve<W: World>(&self, world: &W) -> Vec<DiagnosticInfo>;
423}
424
425/// Filters for excluding diagnostics.
426///
427/// Used with [`DiagnosticsExt::filter_out`] to remove unwanted diagnostics.
428#[derive(Debug, Clone, PartialEq, Eq)]
429pub enum DiagnosticFilter {
430    /// Filter out HTML export development warnings.
431    ///
432    /// Matches: "html export is under active development"
433    HtmlExport,
434
435    /// Filter out warnings from external packages (not user code).
436    ///
437    /// Keeps diagnostics from paths that don't start with `@` (packages).
438    ExternalPackages,
439
440    /// Filter out all warnings (keep only errors).
441    AllWarnings,
442
443    /// Filter out diagnostics containing specific text in message.
444    MessageContains(String),
445}
446
447impl DiagnosticFilter {
448    /// Check if a diagnostic should be filtered out.
449    fn matches(&self, diag: &SourceDiagnostic) -> bool {
450        match self {
451            DiagnosticFilter::HtmlExport => {
452                diag.severity == Severity::Warning
453                    && diag.message.contains("html export is under active development")
454            }
455            DiagnosticFilter::ExternalPackages => {
456                diag.severity == Severity::Warning && is_external_package(diag)
457            }
458            DiagnosticFilter::AllWarnings => diag.severity == Severity::Warning,
459            DiagnosticFilter::MessageContains(text) => diag.message.contains(text.as_str()),
460        }
461    }
462}
463
464/// Check if a diagnostic originates from an external package.
465fn is_external_package(diag: &SourceDiagnostic) -> bool {
466    // Check span's file id for package path
467    if let Some(id) = diag.span.id() {
468        let path = id.vpath().as_rootless_path();
469        // External packages have paths like "@preview/..." or "@local/..."
470        path.to_string_lossy().starts_with('@')
471    } else {
472        false
473    }
474}
475
476impl DiagnosticsExt for [SourceDiagnostic] {
477    fn has_errors(&self) -> bool {
478        self.iter().any(|d| d.severity == Severity::Error)
479    }
480
481    fn has_warnings(&self) -> bool {
482        self.iter().any(|d| d.severity == Severity::Warning)
483    }
484
485    fn is_empty(&self) -> bool {
486        <[SourceDiagnostic]>::is_empty(self)
487    }
488
489    fn len(&self) -> usize {
490        <[SourceDiagnostic]>::len(self)
491    }
492
493    fn error_count(&self) -> usize {
494        self.iter()
495            .filter(|d| d.severity == Severity::Error)
496            .count()
497    }
498
499    fn warning_count(&self) -> usize {
500        self.iter()
501            .filter(|d| d.severity == Severity::Warning)
502            .count()
503    }
504
505    fn counts(&self) -> (usize, usize) {
506        self.iter().fold((0, 0), |(errors, warnings), d| match d.severity {
507            Severity::Error => (errors + 1, warnings),
508            Severity::Warning => (errors, warnings + 1),
509        })
510    }
511
512    fn summary(&self) -> DiagnosticSummary {
513        let (errors, warnings) = self.counts();
514        DiagnosticSummary { errors, warnings }
515    }
516
517    fn filter_out(&self, filters: &[DiagnosticFilter]) -> Vec<SourceDiagnostic> {
518        self.iter()
519            .filter(|d| !filters.iter().any(|f| f.matches(d)))
520            .cloned()
521            .collect()
522    }
523
524    fn format<W: World>(&self, world: &W) -> String {
525        format_diagnostics(world, self)
526    }
527
528    fn format_with<W: World>(&self, world: &W, options: &DiagnosticOptions) -> String {
529        format_diagnostics_with_options(world, self, options)
530    }
531
532    fn resolve<W: World>(&self, world: &W) -> Vec<DiagnosticInfo> {
533        self.iter().map(|d| resolve_diagnostic(world, d)).collect()
534    }
535}
536
537impl DiagnosticsExt for Vec<SourceDiagnostic> {
538    fn has_errors(&self) -> bool {
539        self.as_slice().has_errors()
540    }
541
542    fn has_warnings(&self) -> bool {
543        self.as_slice().has_warnings()
544    }
545
546    fn is_empty(&self) -> bool {
547        Vec::is_empty(self)
548    }
549
550    fn len(&self) -> usize {
551        Vec::len(self)
552    }
553
554    fn error_count(&self) -> usize {
555        self.as_slice().error_count()
556    }
557
558    fn warning_count(&self) -> usize {
559        self.as_slice().warning_count()
560    }
561
562    fn counts(&self) -> (usize, usize) {
563        self.as_slice().counts()
564    }
565
566    fn summary(&self) -> DiagnosticSummary {
567        self.as_slice().summary()
568    }
569
570    fn filter_out(&self, filters: &[DiagnosticFilter]) -> Vec<SourceDiagnostic> {
571        self.as_slice().filter_out(filters)
572    }
573
574    fn format<W: World>(&self, world: &W) -> String {
575        self.as_slice().format(world)
576    }
577
578    fn format_with<W: World>(&self, world: &W, options: &DiagnosticOptions) -> String {
579        self.as_slice().format_with(world, options)
580    }
581
582    fn resolve<W: World>(&self, world: &W) -> Vec<DiagnosticInfo> {
583        self.as_slice().resolve(world)
584    }
585}
586
587// ============================================================================
588// Gutter Characters
589// ============================================================================
590
591/// Box-drawing characters for source code display.
592mod gutter {
593    pub const HEADER: &str = "┌─";
594    pub const BAR: &str = "│";
595    pub const SPAN_START: &str = "╭";
596    pub const SPAN_END: &str = "╰";
597    pub const DASH: &str = "─";
598    pub const MARKER: &str = "^";
599}
600
601// ============================================================================
602// Diagnostic Info - Structured data for custom rendering
603// ============================================================================
604
605/// Structured diagnostic information for custom rendering.
606///
607/// This struct contains all the information needed to render a diagnostic
608/// in any format (terminal, HTML, JSON, etc.).
609///
610/// # Example
611///
612/// ```ignore
613/// use typst_batch::diagnostic::{DiagnosticInfo, resolve_diagnostic};
614///
615/// let info = resolve_diagnostic(&world, &diag);
616///
617/// // Now you have full control over formatting
618/// println!("Error at {}:{}", info.path.unwrap_or("?"), info.line.unwrap_or(0));
619/// for line in &info.source_lines {
620///     println!("{}: {}", line.line_num, line.text);
621/// }
622/// ```
623#[derive(Debug, Clone)]
624pub struct DiagnosticInfo {
625    /// Error severity (error or warning).
626    pub severity: Severity,
627    /// The error message.
628    pub message: String,
629    /// File path (if available).
630    pub path: Option<String>,
631    /// Line number (1-indexed, if available).
632    pub line: Option<usize>,
633    /// Column number (1-indexed, if available).
634    pub column: Option<usize>,
635    /// Source code lines with highlighting info.
636    pub source_lines: Vec<SourceLine>,
637    /// Hint messages.
638    pub hints: Vec<String>,
639    /// Stack trace entries.
640    pub traces: Vec<TraceInfo>,
641}
642
643/// A source code line with optional highlighting.
644#[derive(Debug, Clone)]
645pub struct SourceLine {
646    /// Line number (1-indexed).
647    pub line_num: usize,
648    /// The source text.
649    pub text: String,
650    /// Highlight range (start_col, end_col), 0-indexed.
651    pub highlight: Option<(usize, usize)>,
652}
653
654/// Stack trace entry.
655#[derive(Debug, Clone)]
656pub struct TraceInfo {
657    /// Description of the trace point (from Typst's Tracepoint::Display).
658    pub message: String,
659    /// File path (if available).
660    pub path: Option<String>,
661    /// Line number (1-indexed, if available).
662    pub line: Option<usize>,
663    /// Column number (1-indexed, if available).
664    pub column: Option<usize>,
665    /// Source code lines at this trace point.
666    pub source_lines: Vec<SourceLine>,
667}
668
669/// Resolve a diagnostic to structured info for custom rendering.
670/// (Internal - use `diagnostics.resolve(&world)` instead)
671pub(crate) fn resolve_diagnostic<W: World>(world: &W, diag: &SourceDiagnostic) -> DiagnosticInfo {
672    let location = SpanLocation::from_span(world, diag.span);
673
674    let (path, line, column, source_lines) = match &location {
675        Some(loc) => (
676            Some(loc.path.clone()),
677            Some(loc.start_line),
678            Some(loc.start_col + 1), // 1-indexed for display
679            loc.to_source_lines(),
680        ),
681        None => (None, None, None, vec![]),
682    };
683
684    let hints = diag.hints.iter().map(|h| h.to_string()).collect();
685
686    let traces = diag
687        .trace
688        .iter()
689        .filter_map(|t| {
690            use typst::diag::Tracepoint;
691
692            // Skip import traces - they just show content importing template
693            if matches!(t.v, Tracepoint::Import) {
694                return None;
695            }
696
697            // Use Tracepoint's Display impl for consistent messages
698            let message = t.v.to_string();
699
700            let loc = SpanLocation::from_span(world, t.span);
701            Some(TraceInfo {
702                message,
703                path: loc.as_ref().map(|l| l.path.clone()),
704                line: loc.as_ref().map(|l| l.start_line),
705                column: loc.as_ref().map(|l| l.start_col + 1),
706                source_lines: loc.as_ref().map(|l| l.to_source_lines()).unwrap_or_default(),
707            })
708        })
709        .collect();
710
711    DiagnosticInfo {
712        severity: diag.severity,
713        message: diag.message.to_string(),
714        path,
715        line,
716        column,
717        source_lines,
718        hints,
719        traces,
720    }
721}
722
723/// Resolve all diagnostics to structured info.
724/// (Internal - use `diagnostics.resolve(&world)` instead)
725#[allow(dead_code)]
726pub(crate) fn resolve_diagnostics<W: World>(
727    world: &W,
728    diagnostics: &[SourceDiagnostic],
729) -> Vec<DiagnosticInfo> {
730    diagnostics
731        .iter()
732        .map(|d| resolve_diagnostic(world, d))
733        .collect()
734}
735
736// ============================================================================
737// Internal Coloring (private)
738// ============================================================================
739
740/// Apply color to text based on severity.
741#[cfg(feature = "colored-diagnostics")]
742fn colorize(text: &str, severity: Severity) -> String {
743    use colored::Colorize;
744    match severity {
745        Severity::Error => text.red().to_string(),
746        Severity::Warning => text.yellow().to_string(),
747    }
748}
749
750#[cfg(feature = "colored-diagnostics")]
751fn colorize_help(text: &str) -> String {
752    use colored::Colorize;
753    text.cyan().to_string()
754}
755
756#[cfg(not(feature = "colored-diagnostics"))]
757fn colorize(text: &str, _severity: Severity) -> String {
758    text.to_owned()
759}
760
761#[cfg(not(feature = "colored-diagnostics"))]
762fn colorize_help(text: &str) -> String {
763    text.to_owned()
764}
765
766/// Get paint function based on options.
767fn get_paint_fn(options: &DiagnosticOptions, severity: Severity) -> Box<dyn Fn(&str) -> String> {
768    if options.colored {
769        Box::new(move |s| colorize(s, severity))
770    } else {
771        Box::new(|s: &str| s.to_owned())
772    }
773}
774
775fn get_help_paint_fn(options: &DiagnosticOptions) -> Box<dyn Fn(&str) -> String> {
776    if options.colored {
777        Box::new(colorize_help)
778    } else {
779        Box::new(|s: &str| s.to_owned())
780    }
781}
782
783// ============================================================================
784// Span Location
785// ============================================================================
786
787/// Resolved source location information for a diagnostic span.
788///
789/// Contains all information needed to display a source code snippet
790/// with proper highlighting.
791struct SpanLocation {
792    /// File path (relative to project root)
793    path: String,
794    /// Starting line number (1-indexed)
795    start_line: usize,
796    /// Starting column (0-indexed)
797    start_col: usize,
798    /// Source lines covered by the span
799    lines: Vec<String>,
800    /// Column where highlighting starts in first line (0-indexed)
801    highlight_start_col: usize,
802    /// Column where highlighting ends in last line (0-indexed, exclusive)
803    highlight_end_col: usize,
804}
805
806impl SpanLocation {
807    /// Resolve a span to its source location.
808    fn from_span<W: World>(world: &W, span: Span) -> Option<Self> {
809        let id = span.id()?;
810        let source = world.source(id).ok()?;
811        let range = source.range(span)?;
812        let text = source.text();
813
814        // Calculate line boundaries
815        let start_line_start = text[..range.start].rfind('\n').map_or(0, |i| i + 1);
816        let end_line_end = text[range.end..]
817            .find('\n')
818            .map_or(text.len(), |i| range.end + i);
819        let end_line_start = text[..range.end].rfind('\n').map_or(0, |i| i + 1);
820
821        // Calculate positions
822        // Column numbers are 0-indexed to match typst-cli output
823        let start_line = text[..range.start].matches('\n').count() + 1;
824        let start_col = text[start_line_start..range.start].chars().count();
825        let end_col = text[end_line_start..range.end].chars().count();
826
827        // Extract source lines
828        let lines = text[start_line_start..end_line_end]
829            .lines()
830            .map(String::from)
831            .collect();
832
833        // Build path
834        let path = id.vpath().as_rootless_path().to_string_lossy().into_owned();
835
836        Some(Self {
837            path,
838            start_line,
839            start_col,
840            lines,
841            highlight_start_col: start_col,
842            highlight_end_col: end_col,
843        })
844    }
845
846    /// Check if this span covers multiple lines.
847    #[inline]
848    const fn is_multiline(&self) -> bool {
849        self.lines.len() > 1
850    }
851
852    /// Get the last line number covered by this span.
853    #[inline]
854    const fn end_line(&self) -> usize {
855        self.start_line + self.lines.len() - 1
856    }
857
858    /// Calculate the width needed to display line numbers.
859    #[inline]
860    fn line_num_width(&self) -> usize {
861        self.end_line().to_string().len().max(1)
862    }
863
864    /// Convert to structured source lines for DiagnosticInfo.
865    fn to_source_lines(&self) -> Vec<SourceLine> {
866        self.lines
867            .iter()
868            .enumerate()
869            .map(|(i, text)| {
870                let line_num = self.start_line + i;
871                let highlight = if i == 0 {
872                    Some((self.highlight_start_col, self.highlight_end_col))
873                } else if self.lines.len() > 1 {
874                    // Multi-line: highlight entire line after first
875                    Some((0, text.chars().count()))
876                } else {
877                    None
878                };
879                SourceLine {
880                    line_num,
881                    text: text.clone(),
882                    highlight,
883                }
884            })
885            .collect()
886    }
887}
888
889// ============================================================================
890// Snippet Writer
891// ============================================================================
892
893/// Helper for writing formatted source snippets.
894///
895/// Encapsulates the logic for formatting source code with proper
896/// alignment, gutter characters, and highlighting.
897struct SnippetWriter<'a, F>
898where
899    F: Fn(&str) -> String,
900{
901    output: &'a mut String,
902    paint: F,
903    line_num_width: usize,
904}
905
906impl<'a, F> SnippetWriter<'a, F>
907where
908    F: Fn(&str) -> String,
909{
910    fn new(output: &'a mut String, paint: F, line_num_width: usize) -> Self {
911        Self {
912            output,
913            paint,
914            line_num_width,
915        }
916    }
917
918    /// Write the location header: "  ┌─ path:line:col"
919    fn write_header(&mut self, path: &str, line: usize, col: usize) {
920        _ = writeln!(
921            self.output,
922            "{:>width$} {} {}:{}:{}",
923            "",
924            (self.paint)(gutter::HEADER),
925            path,
926            line,
927            col,
928            width = self.line_num_width
929        );
930    }
931
932    /// Write an empty gutter line: "  │"
933    fn write_empty_gutter(&mut self) {
934        _ = writeln!(
935            self.output,
936            "{:>width$} {}",
937            "",
938            (self.paint)(gutter::BAR),
939            width = self.line_num_width
940        );
941    }
942
943    /// Write a source line with optional box character and highlighting.
944    fn write_source_line(
945        &mut self,
946        line_num: usize,
947        line_text: &str,
948        box_char: Option<&str>,
949        highlight_range: Option<(usize, usize)>,
950    ) {
951        let line_num_str = format!("{:>width$}", line_num, width = self.line_num_width);
952
953        let formatted_line = match (box_char, highlight_range) {
954            (Some(bc), Some((start, end))) => {
955                let (before, highlighted, after) = split_line(line_text, start, end);
956                format!(
957                    "{} {} {} {}{}{}",
958                    (self.paint)(&line_num_str),
959                    (self.paint)(gutter::BAR),
960                    (self.paint)(bc),
961                    before,
962                    (self.paint)(&highlighted),
963                    after
964                )
965            }
966            (None, Some((start, end))) => {
967                let (before, highlighted, after) = split_line(line_text, start, end);
968                format!(
969                    "{} {} {}{}{}",
970                    (self.paint)(&line_num_str),
971                    (self.paint)(gutter::BAR),
972                    before,
973                    (self.paint)(&highlighted),
974                    after
975                )
976            }
977            _ => {
978                format!(
979                    "{} {} {}",
980                    (self.paint)(&line_num_str),
981                    (self.paint)(gutter::BAR),
982                    line_text
983                )
984            }
985        };
986
987        _ = writeln!(self.output, "{formatted_line}");
988    }
989
990    /// Write marker line for single-line spans: "  │   ^^^^"
991    fn write_single_line_marker(&mut self, start_col: usize, span_len: usize) {
992        let spaces = " ".repeat(start_col);
993        let markers = gutter::MARKER.repeat(span_len.max(1));
994        _ = writeln!(
995            self.output,
996            "{:>width$} {} {}{}",
997            "",
998            (self.paint)(gutter::BAR),
999            spaces,
1000            (self.paint)(&markers),
1001            width = self.line_num_width
1002        );
1003    }
1004
1005    /// Write marker line for multi-line spans: "  │ ╰────^"
1006    fn write_multiline_end_marker(&mut self, end_col: usize) {
1007        let dashes = gutter::DASH.repeat(end_col);
1008        _ = writeln!(
1009            self.output,
1010            "{:>width$} {} {}{}{}",
1011            "",
1012            (self.paint)(gutter::BAR),
1013            (self.paint)(gutter::SPAN_END),
1014            (self.paint)(&dashes),
1015            (self.paint)(gutter::MARKER),
1016            width = self.line_num_width
1017        );
1018    }
1019}
1020
1021/// Split a line into (before, highlighted, after) based on column range.
1022/// Both `start_col` and `end_col` are 0-indexed.
1023fn split_line(line: &str, start_col: usize, end_col: usize) -> (String, String, String) {
1024    let chars: Vec<char> = line.chars().collect();
1025    let start_idx = start_col.min(chars.len());
1026    let end_idx = end_col.min(chars.len());
1027
1028    let before: String = chars[..start_idx].iter().collect();
1029    let highlighted: String = chars[start_idx..end_idx].iter().collect();
1030    let after: String = chars[end_idx..].iter().collect();
1031
1032    (before, highlighted, after)
1033}
1034
1035// ============================================================================
1036// Public API
1037// ============================================================================
1038
1039/// Format compilation diagnostics into a human-readable string.
1040/// (Internal - use `diagnostics.format(&world)` instead)
1041pub(crate) fn format_diagnostics<W: World>(world: &W, diagnostics: &[SourceDiagnostic]) -> String {
1042    format_diagnostics_with_options(world, diagnostics, &DiagnosticOptions::default())
1043}
1044
1045/// Format compilation diagnostics with custom options.
1046/// (Internal - use `diagnostics.format_with(&world, &options)` instead)
1047pub(crate) fn format_diagnostics_with_options<W: World>(
1048    world: &W,
1049    diagnostics: &[SourceDiagnostic],
1050    options: &DiagnosticOptions,
1051) -> String {
1052    let mut output = String::new();
1053
1054    // Partition and sort: errors first, then warnings
1055    let (errors, warnings): (Vec<_>, Vec<_>) = diagnostics
1056        .iter()
1057        .partition(|d| d.severity == Severity::Error);
1058
1059    let all_diags: Vec<_> = errors.iter().chain(warnings.iter()).collect();
1060    for (i, diag) in all_diags.iter().enumerate() {
1061        format_diagnostic_internal(&mut output, world, diag, options);
1062        // Add blank line between diagnostics (but not after the last one)
1063        if i < all_diags.len() - 1 {
1064            output.push('\n');
1065        }
1066    }
1067
1068    output
1069}
1070
1071/// Count errors and warnings in a diagnostic list.
1072pub fn count_diagnostics(diagnostics: &[SourceDiagnostic]) -> (usize, usize) {
1073    diagnostics.iter().fold((0, 0), |(errors, warnings), d| {
1074        match d.severity {
1075            Severity::Error => (errors + 1, warnings),
1076            Severity::Warning => (errors, warnings + 1),
1077        }
1078    })
1079}
1080
1081/// Check if there are any errors in the diagnostics.
1082pub fn has_errors(diagnostics: &[SourceDiagnostic]) -> bool {
1083    diagnostics.iter().any(|d| d.severity == Severity::Error)
1084}
1085
1086/// Filter out known HTML export development warnings.
1087///
1088/// Typst's HTML export is experimental and always produces a warning.
1089/// This function filters out that warning to reduce noise in error output.
1090pub fn filter_html_warnings(diagnostics: &[SourceDiagnostic]) -> Vec<SourceDiagnostic> {
1091    diagnostics
1092        .iter()
1093        .filter(|d| {
1094            // Keep all errors
1095            if d.severity == Severity::Error {
1096                return true;
1097            }
1098            // Filter out HTML export warning
1099            !d.message.contains("html export is under active development")
1100        })
1101        .cloned()
1102        .collect()
1103}
1104
1105/// Disable colored output globally (for tests).
1106#[cfg(all(test, feature = "colored-diagnostics"))]
1107pub fn disable_colors() {
1108    colored::control::set_override(false);
1109}
1110
1111// ============================================================================
1112// Diagnostic Formatting (Internal)
1113// ============================================================================
1114
1115/// Format a single diagnostic with its source snippet.
1116fn format_diagnostic_internal<W: World>(
1117    output: &mut String,
1118    world: &W,
1119    diag: &SourceDiagnostic,
1120    options: &DiagnosticOptions,
1121) {
1122    let label = match diag.severity {
1123        Severity::Error => "error",
1124        Severity::Warning => "warning",
1125    };
1126    let paint = get_paint_fn(options, diag.severity);
1127
1128    match options.style {
1129        DisplayStyle::Short => {
1130            format_diagnostic_short(output, world, diag, label, &paint);
1131        }
1132        DisplayStyle::Rich => {
1133            format_diagnostic_rich(output, world, diag, label, &paint, options);
1134        }
1135    }
1136}
1137
1138/// Format diagnostic in short style: "file:line:col: severity: message"
1139fn format_diagnostic_short<W: World>(
1140    output: &mut String,
1141    world: &W,
1142    diag: &SourceDiagnostic,
1143    label: &str,
1144    paint: &dyn Fn(&str) -> String,
1145) {
1146    if let Some(loc) = SpanLocation::from_span(world, diag.span) {
1147        _ = writeln!(
1148            output,
1149            "{}:{}:{}: {}: {}",
1150            loc.path,
1151            loc.start_line,
1152            loc.start_col + 1, // 1-indexed for display
1153            paint(label),
1154            diag.message
1155        );
1156    } else {
1157        _ = writeln!(output, "{}: {}", paint(label), diag.message);
1158    }
1159}
1160
1161/// Format diagnostic in rich style with source snippets.
1162fn format_diagnostic_rich<W: World>(
1163    output: &mut String,
1164    world: &W,
1165    diag: &SourceDiagnostic,
1166    label: &str,
1167    paint: &dyn Fn(&str) -> String,
1168    options: &DiagnosticOptions,
1169) {
1170    // Header: "error: message"
1171    _ = writeln!(output, "{}: {}", paint(label), diag.message);
1172
1173    // Source snippet (if enabled)
1174    if options.snippets
1175        && let Some(location) = SpanLocation::from_span(world, diag.span)
1176    {
1177        write_snippet(output, &location, paint);
1178    }
1179
1180    // Trace information (call stack) - if enabled
1181    if options.traces {
1182        let help_paint = get_help_paint_fn(options);
1183        for trace in &diag.trace {
1184            write_trace(output, world, &trace.v, trace.span, &help_paint);
1185        }
1186    }
1187
1188    // Hints - if enabled
1189    if options.hints {
1190        let help_paint = get_help_paint_fn(options);
1191        for hint in &diag.hints {
1192            _ = writeln!(
1193                output,
1194                "  {} hint: {}",
1195                help_paint("="),
1196                hint
1197            );
1198        }
1199    }
1200}
1201
1202/// Write a source code snippet with highlighting.
1203fn write_snippet(output: &mut String, location: &SpanLocation, paint: &dyn Fn(&str) -> String) {
1204    let mut writer = SnippetWriter::new(output, |s| paint(s), location.line_num_width());
1205
1206    writer.write_header(&location.path, location.start_line, location.start_col);
1207    writer.write_empty_gutter();
1208
1209    if location.is_multiline() {
1210        write_multiline_snippet(&mut writer, location);
1211    } else {
1212        write_singleline_snippet(&mut writer, location);
1213    }
1214}
1215
1216/// Write a single-line source snippet.
1217fn write_singleline_snippet<F>(writer: &mut SnippetWriter<F>, location: &SpanLocation)
1218where
1219    F: Fn(&str) -> String,
1220{
1221    let line_text = location.lines.first().map_or("", String::as_str);
1222
1223    let span_len = location
1224        .highlight_end_col
1225        .saturating_sub(location.highlight_start_col)
1226        .max(1);
1227
1228    writer.write_source_line(
1229        location.start_line,
1230        line_text,
1231        None,
1232        Some((location.highlight_start_col, location.highlight_end_col)),
1233    );
1234    writer.write_single_line_marker(location.highlight_start_col, span_len);
1235}
1236
1237/// Write a multi-line source snippet with box drawing.
1238fn write_multiline_snippet<F>(writer: &mut SnippetWriter<F>, location: &SpanLocation)
1239where
1240    F: Fn(&str) -> String,
1241{
1242    for (i, line_text) in location.lines.iter().enumerate() {
1243        let line_num = location.start_line + i;
1244        let is_first = i == 0;
1245        let line_len = line_text.chars().count();
1246
1247        let (box_char, highlight_range) = if is_first {
1248            (
1249                gutter::SPAN_START,
1250                (location.highlight_start_col, line_len + 1),
1251            )
1252        } else {
1253            (gutter::BAR, (1, line_len + 1))
1254        };
1255
1256        writer.write_source_line(line_num, line_text, Some(box_char), Some(highlight_range));
1257    }
1258
1259    writer.write_multiline_end_marker(location.highlight_end_col);
1260}
1261
1262/// Write trace information with help theme.
1263///
1264/// Skips Import traces since they just show which content file imported
1265/// the failing template, which is usually not helpful context.
1266fn write_trace<W: World>(
1267    output: &mut String,
1268    world: &W,
1269    tracepoint: &typst::diag::Tracepoint,
1270    span: Span,
1271    help_paint: &dyn Fn(&str) -> String,
1272) {
1273    use typst::diag::Tracepoint;
1274
1275    // Skip import traces - they just show content importing template
1276    if matches!(tracepoint, Tracepoint::Import) {
1277        return;
1278    }
1279
1280    let message = match tracepoint {
1281        Tracepoint::Call(Some(name)) => {
1282            format!("error occurred in this call of function `{name}`")
1283        }
1284        Tracepoint::Call(None) => "error occurred in this function call".into(),
1285        Tracepoint::Show(name) => format!("error occurred in this show rule for `{name}`"),
1286        Tracepoint::Import => unreachable!(), // Handled above
1287    };
1288
1289    _ = writeln!(
1290        output,
1291        "{}: {}",
1292        help_paint("help"),
1293        message
1294    );
1295
1296    if let Some(location) = SpanLocation::from_span(world, span) {
1297        write_snippet(output, &location, help_paint);
1298    }
1299}
1300
1301// ============================================================================
1302// Tests
1303// ============================================================================
1304
1305#[cfg(test)]
1306mod tests {
1307    use super::*;
1308
1309    #[test]
1310    fn test_count_diagnostics() {
1311        let diags = vec![
1312            SourceDiagnostic::error(Span::detached(), "error 1"),
1313            SourceDiagnostic::error(Span::detached(), "error 2"),
1314            SourceDiagnostic::warning(Span::detached(), "warning 1"),
1315            SourceDiagnostic::warning(Span::detached(), "warning 2"),
1316        ];
1317
1318        let (errors, warnings) = count_diagnostics(&diags);
1319        assert_eq!(errors, 2);
1320        assert_eq!(warnings, 2);
1321    }
1322
1323    #[test]
1324    fn test_has_errors() {
1325        let warnings_only = vec![
1326            SourceDiagnostic::warning(Span::detached(), "warning 1"),
1327            SourceDiagnostic::warning(Span::detached(), "warning 2"),
1328        ];
1329        assert!(!has_errors(&warnings_only));
1330
1331        let with_errors = vec![
1332            SourceDiagnostic::warning(Span::detached(), "warning 1"),
1333            SourceDiagnostic::error(Span::detached(), "error 1"),
1334        ];
1335        assert!(has_errors(&with_errors));
1336
1337        let empty: Vec<SourceDiagnostic> = vec![];
1338        assert!(!has_errors(&empty));
1339    }
1340
1341    #[test]
1342    fn test_split_line_helper() {
1343        // Test normal case: "hello world", cols 6-11 -> "hello " + "world" + ""
1344        let (before, highlighted, after) = split_line("hello world", 6, 11);
1345        assert_eq!(before, "hello ");
1346        assert_eq!(highlighted, "world");
1347        assert_eq!(after, "");
1348
1349        // Test start at beginning: "abc", cols 0-1 -> "" + "a" + "bc"
1350        let (before, highlighted, after) = split_line("abc", 0, 1);
1351        assert_eq!(before, "");
1352        assert_eq!(highlighted, "a");
1353        assert_eq!(after, "bc");
1354
1355        // Test full line: "test", cols 0-4 -> "" + "test" + ""
1356        let (before, highlighted, after) = split_line("test", 0, 4);
1357        assert_eq!(before, "");
1358        assert_eq!(highlighted, "test");
1359        assert_eq!(after, "");
1360
1361        // Test with Unicode: "你好世界" (Hello World), cols 0-2 -> "" + "你好" + "世界"
1362        let (before, highlighted, after) = split_line("你好世界", 0, 2);
1363        assert_eq!(before, "");
1364        assert_eq!(highlighted, "你好");
1365        assert_eq!(after, "世界");
1366    }
1367
1368    #[test]
1369    fn test_span_location_methods() {
1370        let location = SpanLocation {
1371            path: "test.typ".to_string(),
1372            start_line: 10,
1373            start_col: 0, // 0-indexed
1374            lines: vec!["line1".into(), "line2".into(), "line3".into()],
1375            highlight_start_col: 0,
1376            highlight_end_col: 5,
1377        };
1378
1379        assert!(location.is_multiline());
1380        assert_eq!(location.end_line(), 12);
1381        assert_eq!(location.line_num_width(), 2);
1382
1383        let single_line = SpanLocation {
1384            path: "test.typ".to_string(),
1385            start_line: 5,
1386            start_col: 0, // 0-indexed
1387            lines: vec!["single".into()],
1388            highlight_start_col: 0,
1389            highlight_end_col: 6,
1390        };
1391
1392        assert!(!single_line.is_multiline());
1393        assert_eq!(single_line.end_line(), 5);
1394        assert_eq!(single_line.line_num_width(), 1);
1395    }
1396
1397    #[test]
1398    fn test_filter_html_warnings() {
1399        let diags = vec![
1400            SourceDiagnostic::error(Span::detached(), "error 1"),
1401            SourceDiagnostic::warning(
1402                Span::detached(),
1403                "html export is under active development",
1404            ),
1405            SourceDiagnostic::warning(Span::detached(), "other warning"),
1406        ];
1407
1408        let filtered = filter_html_warnings(&diags);
1409        assert_eq!(filtered.len(), 2);
1410        assert!(filtered.iter().any(|d| d.message == "error 1"));
1411        assert!(filtered.iter().any(|d| d.message == "other warning"));
1412    }
1413}