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}