Skip to main content

praxis_source/
style.rs

1//! Terminal styling: a tiny, dependency-free ANSI palette.
2//!
3//! `praxis-source` is the workspace's leaf crate (ADR-003) and must not pull in
4//! any external dependency, so the palette is built from raw ANSI escape codes —
5//! no `colored`, `nu-ansi-term`, or `yansi`. The output is identical to what
6//! those crates produce; it is just spelled out here.
7//!
8//! ## Design
9//!
10//! [`Palette`] gates whether styling is emitted at all, and is the set of
11//! (foreground, weight) pairs the diagnostic renderer and the crash debugger
12//! use. `Palette::plain()` is a no-op palette — the default for the `Renderer`
13//! and for all snapshot tests, so diagnostics stay byte-stable across test runs;
14//! the styled palette matches rustc's conventions: errors red & bold, warnings
15//! yellow &c.
16//!
17//! Deciding *when* to style — the `--color auto|always|never` tri-state, and the
18//! terminal check `auto` needs — is the CLI's job; it resolves the flag and
19//! hands down a [`Palette::from_enabled`]. Keeping that decision out of this
20//! crate keeps the leaf free of terminal I/O.
21
22/// An ANSI SGR (Select Graphic Rendition) code. Stored as the numeric parameter
23/// so the palette is data, not a method per style. The full standard set is
24/// named here even though only some are used today, so adding a style later is
25/// a one-line change.
26#[derive(Clone, Copy, Debug)]
27struct Sgr(u8);
28
29#[allow(dead_code)]
30impl Sgr {
31    const RESET: Sgr = Sgr(0);
32    const BOLD: Sgr = Sgr(1);
33    const DIM: Sgr = Sgr(2);
34    const RED: Sgr = Sgr(31);
35    const GREEN: Sgr = Sgr(32);
36    const YELLOW: Sgr = Sgr(33);
37    const BLUE: Sgr = Sgr(34);
38    const MAGENTA: Sgr = Sgr(35);
39    const CYAN: Sgr = Sgr(36);
40}
41
42/// The semantic styles a diagnostic uses. Each maps to zero or more SGR codes.
43/// Keeping these semantic (rather than `Red`/`Bold`) lets the whole palette be
44/// retuned in one place.
45#[derive(Clone, Copy, Debug)]
46pub enum Style {
47    /// The `error`/`warning`/`note`/`help` label and the message in the header.
48    Severity(Severity),
49    /// The diagnostic code, e.g. `[Y001]`.
50    Code,
51    /// The caret run underlining a span, in the severity's color.
52    Caret(Severity),
53    /// The `path:line:col` location and the `|` gutter — dimmed.
54    Location,
55    /// Backtrace frame numbers (`#0`) in the crash debugger — dimmed.
56    Dim,
57}
58
59/// The severity the renderer is formatting (drives the caret/label color).
60#[derive(Clone, Copy, Debug)]
61pub enum Severity {
62    Error,
63    Warning,
64    Note,
65    Help,
66}
67
68/// A palette: a function from [`Style`] to a sequence of SGR codes. `plain`
69/// produces no codes at all (plain text); `styled` produces rustc-like colors.
70#[derive(Clone, Copy, Debug)]
71pub struct Palette {
72    styled: bool,
73}
74
75impl Palette {
76    /// A no-op palette: every style renders as plain text. Used by the default
77    /// `Renderer` and by snapshot tests.
78    pub const fn plain() -> Palette {
79        Palette { styled: false }
80    }
81
82    /// The colored palette (rustc-like). Only emits ANSI when `styled`.
83    pub const fn styled() -> Palette {
84        Palette { styled: true }
85    }
86
87    /// Build a palette from a resolved color decision.
88    pub fn from_enabled(enabled: bool) -> Palette {
89        if enabled {
90            Palette::styled()
91        } else {
92            Palette::plain()
93        }
94    }
95
96    /// Whether this palette emits ANSI styling.
97    #[must_use]
98    pub fn is_styled(self) -> bool {
99        self.styled
100    }
101
102    /// Wrap `text` in the ANSI codes for `style`, or return it unchanged when
103    /// this palette is plain. Returns a freshly-allocated `String`.
104    #[must_use]
105    pub fn paint(&self, style: Style, text: &str) -> String {
106        if !self.styled {
107            return text.to_string();
108        }
109        let codes = self.codes(style);
110        if codes.is_empty() {
111            return text.to_string();
112        }
113        let mut out = String::with_capacity(text.len() + codes.len() * 4 + 5);
114        write_codes(&mut out, &codes);
115        out.push_str(text);
116        write_codes(&mut out, &[Sgr::RESET]);
117        out
118    }
119
120    fn codes(&self, style: Style) -> Vec<Sgr> {
121        match style {
122            Style::Severity(Severity::Error) => vec![Sgr::BOLD, Sgr::RED],
123            Style::Severity(Severity::Warning) => vec![Sgr::BOLD, Sgr::YELLOW],
124            Style::Severity(Severity::Note) => vec![Sgr::BOLD, Sgr::BLUE],
125            Style::Severity(Severity::Help) => vec![Sgr::BOLD, Sgr::CYAN],
126            Style::Code => vec![Sgr::BOLD],
127            Style::Caret(Severity::Error) => vec![Sgr::RED],
128            Style::Caret(Severity::Warning) => vec![Sgr::YELLOW],
129            Style::Caret(Severity::Note) => vec![Sgr::BLUE],
130            Style::Caret(Severity::Help) => vec![Sgr::CYAN],
131            Style::Location | Style::Dim => vec![Sgr::DIM],
132        }
133    }
134}
135
136/// Write `\x1b[` + the SGR codes joined by `;` + `m` into `out`.
137fn write_codes(out: &mut String, codes: &[Sgr]) {
138    use std::fmt::Write;
139    out.push_str("\x1b[");
140    for (i, c) in codes.iter().enumerate() {
141        if i > 0 {
142            out.push(';');
143        }
144        let _ = write!(out, "{}", c.0);
145    }
146    out.push('m');
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn plain_palette_emits_no_ansi() {
155        let p = Palette::plain();
156        assert_eq!(p.paint(Style::Severity(Severity::Error), "boom"), "boom");
157        assert_eq!(p.paint(Style::Code, "[Y001]"), "[Y001]");
158    }
159
160    #[test]
161    fn styled_palette_wraps_text() {
162        let p = Palette::styled();
163        // Error: bold + red => ESC[1;31m ... ESC[0m
164        assert_eq!(
165            p.paint(Style::Severity(Severity::Error), "boom"),
166            "\x1b[1;31mboom\x1b[0m"
167        );
168        // Code: bold only => ESC[1m ... ESC[0m
169        assert_eq!(p.paint(Style::Code, "[Y001]"), "\x1b[1m[Y001]\x1b[0m");
170        // Location: dim => ESC[2m ... ESC[0m
171        assert_eq!(
172            p.paint(Style::Location, "f.px:1:0"),
173            "\x1b[2mf.px:1:0\x1b[0m"
174        );
175    }
176
177    #[test]
178    fn from_enabled_toggles() {
179        assert!(Palette::from_enabled(true).is_styled());
180        assert!(!Palette::from_enabled(false).is_styled());
181    }
182}