Skip to main content

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}