alint_output/style.rs
1//! Styles, glyphs, and color-choice plumbing for the human
2//! formatter.
3//!
4//! Two independent axes are handled here:
5//!
6//! 1. **ANSI color** — whether SGR escape sequences are emitted.
7//! Delegated to [`anstream`] and [`anstyle`]: the CLI wraps
8//! stdout in an `anstream::AutoStream`, which strips SGR codes
9//! on pipes, honors `NO_COLOR` / `CLICOLOR_FORCE`, and respects
10//! an explicit `--color` choice. Formatters just write
11//! `{STYLE}text{STYLE:#}` into the writer; the stream decides
12//! whether to keep the bytes.
13//!
14//! 2. **Glyph set** — Unicode vs. ASCII fallback for sigils,
15//! separators, and the like. Orthogonal to color: a no-color
16//! terminal can still render `✗`, and a color terminal with
17//! `--ascii` should still emit `x`. Controlled by [`GlyphSet`]
18//! with an auto-detect fallback for `TERM=dumb`.
19
20use anstyle::{AnsiColor, Color, Style};
21
22// ---------------------------------------------------------------
23// Role-based style constants.
24//
25// Centralized so swapping palette is a one-file edit and every
26// formatter call site reads as intent (`style::ERROR`) rather
27// than a raw SGR code.
28// ---------------------------------------------------------------
29
30/// Errors — the thing the user most needs to notice.
31pub const ERROR: Style = Style::new()
32 .fg_color(Some(Color::Ansi(AnsiColor::Red)))
33 .bold();
34
35/// Warnings — actionable but not blocking.
36pub const WARNING: Style = Style::new()
37 .fg_color(Some(Color::Ansi(AnsiColor::Yellow)))
38 .bold();
39
40/// Info — purely advisory.
41pub const INFO: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan)));
42
43/// Success / "passed" — all-clear banner.
44pub const SUCCESS: Style = Style::new()
45 .fg_color(Some(Color::Ansi(AnsiColor::Green)))
46 .bold();
47
48/// File path headers.
49pub const PATH: Style = Style::new().bold();
50
51/// Rule identifiers — dimmed so they're secondary to the
52/// message.
53pub const RULE_ID: Style = Style::new().dimmed();
54
55/// Documentation / policy URLs — fallback styling for terminals
56/// that do NOT support OSC 8 hyperlinks. Blue + underline reads
57/// as the universal link convention.
58pub const DOCS: Style = Style::new()
59 .fg_color(Some(Color::Ansi(AnsiColor::Blue)))
60 .underline();
61
62/// Documentation / policy URLs — styling when the surrounding
63/// emission wraps the URL in an OSC 8 hyperlink. The terminal
64/// itself handles the link affordance (typically a hover-driven
65/// underline + `pointer` cursor), so emitting our own `\e[4m`
66/// on top causes some renderers (notably `asciinema-player`)
67/// to extend the underline past the link text to the end of the
68/// row. Drop the explicit underline; keep blue as the convention.
69pub const DOCS_LINKED: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Blue)));
70
71/// "fixable" tag — green to read as a positive affordance.
72pub const FIXABLE: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)));
73
74/// Dimmed ancillary text (counts, timings, footer notes).
75pub const DIM: Style = Style::new().dimmed();
76
77// ---------------------------------------------------------------
78// Glyphs.
79// ---------------------------------------------------------------
80
81/// The set of single-character glyphs used in the human output.
82///
83/// Two variants are shipped: [`GlyphSet::UNICODE`] (default) for
84/// modern terminals and [`GlyphSet::ASCII`] for `TERM=dumb` or
85/// explicit `--ascii`. A future variant could add Nerd Font
86/// glyphs on `COLORTERM=truecolor`, but isn't needed today.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct GlyphSet {
89 pub error: &'static str,
90 pub warning: &'static str,
91 pub info: &'static str,
92 pub success: &'static str,
93 /// Horizontal-rule glyph used for section separators.
94 pub rule: &'static str,
95 /// Bullet for list items / summary lines.
96 pub bullet: &'static str,
97 /// Arrow for call-to-action lines (`→ run alint fix`).
98 pub arrow: &'static str,
99}
100
101impl GlyphSet {
102 pub const UNICODE: Self = Self {
103 error: "✗",
104 warning: "⚠",
105 info: "ℹ",
106 success: "✓",
107 rule: "─",
108 bullet: "·",
109 arrow: "→",
110 };
111 pub const ASCII: Self = Self {
112 error: "x",
113 warning: "!",
114 info: "i",
115 success: "v",
116 rule: "-",
117 bullet: "*",
118 arrow: "->",
119 };
120
121 /// Pick the Unicode set unless the caller forces ASCII or the
122 /// environment signals a dumb terminal.
123 ///
124 /// Reads `$TERM` as the only signal. Thin wrapper around
125 /// [`GlyphSet::decide`] — test that directly if you're
126 /// exercising the decision logic.
127 #[must_use]
128 pub fn detect(force_ascii: bool) -> Self {
129 Self::decide(force_ascii, std::env::var("TERM").ok().as_deref())
130 }
131
132 /// Pure version of [`GlyphSet::detect`] — takes `TERM` as an
133 /// explicit argument so tests don't have to mutate process
134 /// env (which is `unsafe` under edition 2024).
135 #[must_use]
136 pub fn decide(force_ascii: bool, term: Option<&str>) -> Self {
137 if force_ascii || matches!(term, Some("dumb")) {
138 Self::ASCII
139 } else {
140 Self::UNICODE
141 }
142 }
143}
144
145impl Default for GlyphSet {
146 fn default() -> Self {
147 Self::UNICODE
148 }
149}
150
151// ---------------------------------------------------------------
152// ColorChoice.
153// ---------------------------------------------------------------
154
155/// How to resolve whether to emit ANSI color codes. Parsed from
156/// `--color=<auto|always|never>`.
157///
158/// On `Auto`, [`ColorChoice::resolve`] consults the
159/// `CLICOLOR_FORCE` env var (which `anstream` does NOT check on
160/// its own) and pre-resolves to `Always` when it's set to
161/// anything other than `"0"`. The resulting choice is then
162/// handed to `anstream::AutoStream`, which honors `NO_COLOR` and
163/// the TTY check on the remaining `Auto` cases.
164///
165/// `Always` / `Never` are explicit user overrides and bypass the
166/// env-var resolution entirely — useful when piping into a pager
167/// that understands ANSI, or when capturing output for a
168/// snapshot test.
169///
170/// Precedence summary:
171///
172/// | `--color` | env | result |
173/// |-----------|------------------------------------|-----------|
174/// | `always` | (any) | colors on |
175/// | `never` | (any) | colors off|
176/// | `auto` | `CLICOLOR_FORCE=1` | colors on |
177/// | `auto` | `NO_COLOR=…` (any value) | colors off|
178/// | `auto` | TTY | colors on |
179/// | `auto` | non-TTY | colors off|
180///
181/// `CLICOLOR_FORCE=0` (or unset) is treated as "no force"; only
182/// `1` (or any non-`"0"` value) forces colors on.
183#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
184pub enum ColorChoice {
185 #[default]
186 Auto,
187 Always,
188 Never,
189}
190
191impl ColorChoice {
192 /// Pre-resolve `Auto` against the `CLICOLOR_FORCE` env var
193 /// before handing off to `anstream`. `Auto + CLICOLOR_FORCE`
194 /// → `Always`; everything else returns `self` unchanged.
195 ///
196 /// `anstream::ColorChoice::Auto` already honors `NO_COLOR`
197 /// and the TTY check, so we don't intercept those — the
198 /// only env-var contract `anstream` doesn't cover is
199 /// `CLICOLOR_FORCE`, which this method adds.
200 #[must_use]
201 pub fn resolve(self) -> Self {
202 if matches!(self, Self::Auto) && cliclor_force_is_set() {
203 return Self::Always;
204 }
205 self
206 }
207
208 /// Map to `anstream`'s own enum so `AutoStream::new` accepts
209 /// it directly. **Callers should normally call
210 /// [`ColorChoice::resolve`] first** to pre-apply the
211 /// `CLICOLOR_FORCE` precedence.
212 #[must_use]
213 pub fn to_anstream(self) -> anstream::ColorChoice {
214 match self {
215 Self::Auto => anstream::ColorChoice::Auto,
216 Self::Always => anstream::ColorChoice::Always,
217 Self::Never => anstream::ColorChoice::Never,
218 }
219 }
220}
221
222fn cliclor_force_is_set() -> bool {
223 cliclor_force_is_set_in(std::env::var_os("CLICOLOR_FORCE").as_deref())
224}
225
226/// Pure decision function lifted out so unit tests can exercise
227/// the parsing logic without mutating the process env (the
228/// workspace forbids `unsafe` and `set_var` / `remove_var` are
229/// `unsafe` in the 2024 edition).
230fn cliclor_force_is_set_in(env_var: Option<&std::ffi::OsStr>) -> bool {
231 // bixense convention: "1" (or any non-"0" value) forces
232 // colors on; "0" or unset means no force.
233 match env_var {
234 Some(v) => v != "0",
235 None => false,
236 }
237}
238
239impl std::str::FromStr for ColorChoice {
240 type Err = String;
241 fn from_str(s: &str) -> Result<Self, Self::Err> {
242 match s.to_ascii_lowercase().as_str() {
243 "auto" | "" => Ok(Self::Auto),
244 "always" | "yes" | "true" | "on" => Ok(Self::Always),
245 "never" | "no" | "false" | "off" => Ok(Self::Never),
246 other => Err(format!(
247 "invalid --color value {other:?}; expected auto|always|never"
248 )),
249 }
250 }
251}
252
253// ---------------------------------------------------------------
254// OSC 8 hyperlinks.
255// ---------------------------------------------------------------
256
257/// Write `text` as an OSC 8 hyperlink targeting `url` when
258/// `enabled`, or as plain `text` otherwise.
259///
260/// The OSC 8 sequence (`ESC ] 8 ; ; URL ESC \ text ESC ] 8 ; ; ESC \`)
261/// is understood by modern terminals (`iTerm2`, `Kitty`, `WezTerm`,
262/// `Alacritty`, `VSCode`'s integrated terminal, Windows Terminal,
263/// GNOME Terminal, …). Terminals that don't recognize it are
264/// supposed to pass the payload through unchanged — in practice
265/// most do, so we only emit the sequence when the CLI has
266/// *positively* detected hyperlink support via the
267/// `supports-hyperlinks` crate.
268///
269/// The surrounding SGR (underline + blue) is the caller's
270/// responsibility — we keep concerns separate so the same helper
271/// can render a cross-reference or a docs link with different
272/// styling.
273pub fn write_hyperlink(
274 w: &mut dyn std::io::Write,
275 url: &str,
276 text: &str,
277 enabled: bool,
278) -> std::io::Result<()> {
279 if enabled {
280 // ST = ESC \ (C1 string terminator). BEL (\x07) works too
281 // in most terminals but ESC \ is the standard spelling.
282 write!(w, "\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
283 } else {
284 write!(w, "{text}")
285 }
286}
287
288// ---------------------------------------------------------------
289// Per-render options.
290// ---------------------------------------------------------------
291
292/// Renderer options shared across the human formatter family.
293/// Kept as a struct so new knobs (`--compact`, timing, etc.) can
294/// be added without touching every call site.
295///
296/// The `Default` impl gives Unicode glyphs, no hyperlinks,
297/// `None` for width (formatter falls back to
298/// [`HumanOptions::DEFAULT_WIDTH`]), `compact = false`, and
299/// `show_docs = true` — i.e. the same shape the CLI produces
300/// when no flags are passed.
301#[derive(Debug, Clone, Copy)]
302pub struct HumanOptions {
303 pub glyphs: GlyphSet,
304 /// Whether the output sink supports OSC 8 hyperlinks. Detected
305 /// by the CLI (via `supports-hyperlinks`) and threaded down
306 /// here so formatters decide per-call whether to emit the
307 /// OSC 8 sequence.
308 pub hyperlinks: bool,
309 /// Terminal width in columns, used for stretching section
310 /// separators. `None` signals "no TTY / couldn't detect" and
311 /// formatters fall back to [`HumanOptions::DEFAULT_WIDTH`].
312 pub width: Option<usize>,
313 /// Use the one-line-per-violation compact renderer instead of
314 /// the grouped full layout. Designed for piping into editors /
315 /// grep / `wc -l`. When this is `true`, the full-layout
316 /// formatter delegates to the internal compact writer.
317 pub compact: bool,
318 /// Whether to print per-violation `docs:` URLs in the grouped
319 /// full layout. Defaults to `true` (current behaviour). Set
320 /// `false` for narrow terminals, screen recordings, or CI
321 /// logs where long URLs disrupt visual alignment. Has no
322 /// effect on JSON / SARIF / GitHub / markdown output (URLs
323 /// always present in machine-readable formats).
324 pub show_docs: bool,
325}
326
327impl Default for HumanOptions {
328 fn default() -> Self {
329 Self {
330 glyphs: GlyphSet::default(),
331 hyperlinks: false,
332 width: None,
333 compact: false,
334 // Default `true` so library callers (and the no-flags
335 // CLI invocation) get the same shape they got pre-v0.9.19.
336 // The CLI's `--no-docs` flag flips this to `false`.
337 show_docs: true,
338 }
339 }
340}
341
342impl HumanOptions {
343 /// Width used when no terminal is attached (pipes, files,
344 /// non-TTY log capture). Chosen to match POSIX `COLUMNS`
345 /// default and what most CLI tools settle on.
346 pub const DEFAULT_WIDTH: usize = 80;
347
348 /// Effective render width — the detected terminal width or
349 /// `DEFAULT_WIDTH` when detection failed. Capped at a sane
350 /// max so a 1000-col terminal doesn't produce section headers
351 /// longer than the reader can scan.
352 #[must_use]
353 pub fn effective_width(&self) -> usize {
354 self.width.unwrap_or(Self::DEFAULT_WIDTH).clamp(40, 120)
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn color_choice_parses_common_forms() {
364 assert_eq!("auto".parse::<ColorChoice>().unwrap(), ColorChoice::Auto);
365 assert_eq!(
366 "always".parse::<ColorChoice>().unwrap(),
367 ColorChoice::Always
368 );
369 assert_eq!("never".parse::<ColorChoice>().unwrap(), ColorChoice::Never);
370 assert_eq!("YES".parse::<ColorChoice>().unwrap(), ColorChoice::Always);
371 assert_eq!("off".parse::<ColorChoice>().unwrap(), ColorChoice::Never);
372 assert!("sparkles".parse::<ColorChoice>().is_err());
373 }
374
375 #[test]
376 fn glyph_set_decide_respects_dumb_term() {
377 assert_eq!(GlyphSet::decide(false, Some("dumb")), GlyphSet::ASCII);
378 assert_eq!(
379 GlyphSet::decide(false, Some("xterm-256color")),
380 GlyphSet::UNICODE
381 );
382 assert_eq!(GlyphSet::decide(false, None), GlyphSet::UNICODE);
383 }
384
385 #[test]
386 fn glyph_set_force_ascii_overrides_term() {
387 assert_eq!(
388 GlyphSet::decide(true, Some("xterm-256color")),
389 GlyphSet::ASCII
390 );
391 assert_eq!(GlyphSet::decide(true, Some("dumb")), GlyphSet::ASCII);
392 assert_eq!(GlyphSet::decide(true, None), GlyphSet::ASCII);
393 }
394
395 #[test]
396 fn hyperlink_enabled_emits_osc8_sequence() {
397 let mut out = Vec::new();
398 write_hyperlink(&mut out, "https://example.com", "click", true).unwrap();
399 let s = String::from_utf8(out).unwrap();
400 // ESC ] 8 ; ; URL ESC \ TEXT ESC ] 8 ; ; ESC \
401 assert_eq!(s, "\x1b]8;;https://example.com\x1b\\click\x1b]8;;\x1b\\");
402 }
403
404 #[test]
405 fn hyperlink_disabled_emits_plain_text() {
406 let mut out = Vec::new();
407 write_hyperlink(&mut out, "https://example.com", "click", false).unwrap();
408 assert_eq!(String::from_utf8(out).unwrap(), "click");
409 }
410
411 #[test]
412 fn docs_style_carries_underline_and_blue() {
413 // Non-OSC-8 fallback path: underline + blue keeps the
414 // visual link convention for terminals that can't render
415 // the OSC 8 hyperlink semantic themselves.
416 let s = format!("{DOCS}link{DOCS:#}");
417 assert!(s.contains("\x1b[4m"), "DOCS must carry underline: {s:?}");
418 // 4-bit ANSI blue is `\e[34m`.
419 assert!(s.contains("\x1b[34m"), "DOCS must carry blue: {s:?}");
420 }
421
422 #[test]
423 fn docs_linked_drops_underline_keeps_blue() {
424 // OSC-8 path: the terminal handles the link affordance
425 // itself (hover-driven underline + pointer cursor).
426 // Emitting our own `\e[4m` on top causes some renderers
427 // — notably `asciinema-player` — to extend the underline
428 // past the link text to the end of the row. The
429 // DOCS_LINKED style drops the explicit underline; blue
430 // stays as the universal "link" convention.
431 let s = format!("{DOCS_LINKED}link{DOCS_LINKED:#}");
432 assert!(
433 !s.contains("\x1b[4m"),
434 "DOCS_LINKED must NOT carry an explicit underline: {s:?}"
435 );
436 assert!(
437 s.contains("\x1b[34m"),
438 "DOCS_LINKED must keep blue as the link convention: {s:?}"
439 );
440 }
441
442 // ColorChoice::resolve tests. The workspace forbids
443 // `unsafe`, and `std::env::set_var` is `unsafe` in the 2024
444 // edition. We therefore test the pure decision function
445 // (`cliclor_force_is_set_in`) directly with hand-built
446 // `OsStr` values; the `resolve()` end-to-end behaviour is
447 // covered by the `cliclor-force-emits-on-non-tty.toml`
448 // trycmd snapshot in `crates/alint/tests/cli/`, which sets
449 // the env var via trycmd's per-case `env.*` field.
450
451 use std::ffi::OsStr;
452
453 #[test]
454 fn cliclor_force_helper_treats_one_as_set() {
455 assert!(cliclor_force_is_set_in(Some(OsStr::new("1"))));
456 }
457
458 #[test]
459 fn cliclor_force_helper_treats_other_truthy_values_as_set() {
460 // The bixense convention only specifies "1" → force, but
461 // existing tools (Git, less) treat any non-"0" value as
462 // "force on". We follow that convention.
463 assert!(cliclor_force_is_set_in(Some(OsStr::new("yes"))));
464 assert!(cliclor_force_is_set_in(Some(OsStr::new("true"))));
465 assert!(cliclor_force_is_set_in(Some(OsStr::new(""))));
466 }
467
468 #[test]
469 fn cliclor_force_helper_treats_zero_as_no_force() {
470 assert!(!cliclor_force_is_set_in(Some(OsStr::new("0"))));
471 }
472
473 #[test]
474 fn cliclor_force_helper_unset_returns_false() {
475 assert!(!cliclor_force_is_set_in(None));
476 }
477
478 #[test]
479 fn resolve_is_idempotent_for_explicit_choices() {
480 // `Always` and `Never` are explicit user choices — they
481 // pass through `resolve()` unchanged regardless of the
482 // env. (We don't mutate the env here; the explicit
483 // branches don't read it.)
484 assert_eq!(ColorChoice::Always.resolve(), ColorChoice::Always);
485 assert_eq!(ColorChoice::Never.resolve(), ColorChoice::Never);
486 }
487}