Skip to main content

choir_cli/
style.rs

1//! Terminal styling for the parts of `choir` a person reads (D58).
2//!
3//! Two rules hold this together, and they are what make colour safe in a
4//! tool whose other half is an agent:
5//!
6//! 1. **Data goes to stdout, unstyled, always.** Every command that
7//!    answers with JSON answers with exactly the bytes the node sent.
8//!    Nothing in here is ever applied to that stream, so a pipeline sees
9//!    the same thing whether or not a terminal is attached.
10//! 2. **Diagnostics go to stderr, styled only when a person is looking.**
11//!    A person is looking when stderr is a terminal, `NO_COLOR` is unset,
12//!    and `TERM` is not `dumb`.
13//!
14//! Hand-rolled rather than `owo-colors` or `console` for the reason the
15//! rest of the workspace hand-rolls: this is four escape sequences and a
16//! predicate, and the two dependencies it would replace pull in
17//! terminal-detection stacks whose behaviour we would then have to test
18//! anyway.
19//!
20//! # Examples
21//!
22//! ```
23//! let plain = choir_cli::style::Style::plain();
24//! assert_eq!(plain.bold("choir"), "choir");
25//! ```
26
27use std::io::IsTerminal;
28
29/// Whether styled output is wanted, and the sequences for it.
30///
31/// Construct with [`Style::for_stderr`] at the point of use rather than
32/// caching one: the decision is cheap, and a cached one taken before a
33/// stream was redirected would be wrong.
34#[derive(Clone, Copy, Debug)]
35pub struct Style {
36    colour: bool,
37}
38
39/// `NO_COLOR` and `TERM` are read here and nowhere else.
40///
41/// The workspace rule is that no crate takes *configuration* from the
42/// environment. These are not configuration: they carry no setting of
43/// ours, they change no behaviour a script can observe, and there is
44/// deliberately no `--color` flag for them to be a shortcut around. They
45/// describe the terminal, which is the one thing a flag cannot know.
46fn a_person_is_looking(stream_is_terminal: bool) -> bool {
47    if !stream_is_terminal {
48        return false;
49    }
50    // Any value at all disables colour, per the NO_COLOR convention; an
51    // empty value does not, since that is how a variable is unset in
52    // shells that cannot unset it.
53    if std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) {
54        return false;
55    }
56    !matches!(std::env::var("TERM").as_deref(), Ok("dumb"))
57}
58
59impl Style {
60    /// The style for diagnostics: colour only when stderr is a terminal.
61    #[must_use]
62    pub fn for_stderr() -> Self {
63        Self {
64            colour: a_person_is_looking(std::io::stderr().is_terminal()),
65        }
66    }
67
68    /// The style for help text, which is printed to stdout when it was
69    /// asked for and to stderr when it is a refusal.
70    #[must_use]
71    pub fn for_stdout() -> Self {
72        Self {
73            colour: a_person_is_looking(std::io::stdout().is_terminal()),
74        }
75    }
76
77    /// A style that emits no escape sequences, whatever is attached.
78    #[must_use]
79    pub fn plain() -> Self {
80        Self { colour: false }
81    }
82
83    /// Whether anything this style produces will differ from plain text.
84    ///
85    /// Read by callers whose whole output is decoration: a summary that
86    /// nobody is looking at is not worth the two lines it would push a
87    /// real error off the top of the screen with.
88    #[must_use]
89    pub fn is_painted(self) -> bool {
90        self.colour
91    }
92
93    fn paint(self, code: &str, text: &str) -> String {
94        if self.colour {
95            format!("\x1b[{code}m{text}\x1b[0m")
96        } else {
97            text.to_string()
98        }
99    }
100
101    /// The one word on a line that carries its meaning.
102    #[must_use]
103    pub fn bold(self, text: &str) -> String {
104        self.paint("1", text)
105    }
106
107    /// Context that must not compete with what it qualifies.
108    #[must_use]
109    pub fn dim(self, text: &str) -> String {
110        self.paint("2", text)
111    }
112
113    /// A refusal.
114    #[must_use]
115    pub fn red(self, text: &str) -> String {
116        self.paint("31", text)
117    }
118
119    /// An acceptance.
120    #[must_use]
121    pub fn green(self, text: &str) -> String {
122        self.paint("32", text)
123    }
124
125    /// Something to type.
126    #[must_use]
127    pub fn cyan(self, text: &str) -> String {
128        self.paint("36", text)
129    }
130}
131
132/// Levenshtein distance between two ASCII-ish words, for "did you mean".
133///
134/// Two rows rather than a full matrix, because the only caller compares
135/// one typo against thirty-two short names and a full matrix would be
136/// more code for the same answer.
137#[must_use]
138pub fn distance(a: &str, b: &str) -> usize {
139    let a: Vec<char> = a.chars().collect();
140    let b: Vec<char> = b.chars().collect();
141    if a.is_empty() {
142        return b.len();
143    }
144    let mut previous: Vec<usize> = (0..=b.len()).collect();
145    let mut current = vec![0; b.len() + 1];
146    for (i, &ca) in a.iter().enumerate() {
147        current[0] = i + 1;
148        for (j, &cb) in b.iter().enumerate() {
149            let substitute = previous[j] + usize::from(ca != cb);
150            current[j + 1] = substitute.min(previous[j + 1] + 1).min(current[j] + 1);
151        }
152        std::mem::swap(&mut previous, &mut current);
153    }
154    previous[b.len()]
155}
156
157/// The closest command name to `typed`, when one is close enough to be
158/// worth suggesting.
159///
160/// The threshold is deliberately tight. A suggestion that is wrong is
161/// worse than none: the reader types it, gets a second refusal, and now
162/// distrusts the first one. One third of the typed length, so `revieww`
163/// suggests `review` and `deploy` suggests nothing at all.
164#[must_use]
165pub fn nearest<'a>(typed: &str, names: impl Iterator<Item = &'a str>) -> Option<&'a str> {
166    let budget = (typed.chars().count() / 3).max(1);
167    let mut best: Option<(usize, &'a str)> = None;
168    for name in names {
169        // A command whose name is two words is not a typo of its first
170        // word, it is that word plus the half the reader has not typed
171        // yet -- and edit distance, which sees seven insertions, would
172        // never suggest it. Ranked at zero so it wins outright.
173        let d = if name.starts_with(typed) && name[typed.len()..].starts_with(' ') {
174            0
175        } else {
176            distance(typed, name)
177        };
178        if d > budget && d != 0 {
179            continue;
180        }
181        if best.is_none_or(|(bd, bn)| (d, name.len()) < (bd, bn.len())) {
182            best = Some((d, name));
183        }
184    }
185    best.map(|(_, name)| name)
186}