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}