big_code_analysis/output/color.rs
1//! Color-mode selection and the shared stdout writer for the terminal
2//! dump serializers.
3//!
4//! The library deliberately performs **no** environment or tty
5//! inspection itself: a binary embedding `big-code-analysis` owns the
6//! policy decision of whether its stdout is a terminal, whether
7//! `NO_COLOR` is set, and whether the user passed a `--color` flag. The
8//! caller resolves those signals into a [`ColorMode`] and hands it to
9//! the `*_with_color` dump entry points; the library only translates
10//! that choice into a concrete [`termcolor::ColorChoice`].
11//!
12//! Keeping the detection out of the library avoids surprising a
13//! downstream embedder whose process is not the `bca` CLI (a GUI, an
14//! LSP server, a test harness) with implicit reads of `NO_COLOR` or the
15//! ambient `TERM`.
16
17use std::io::{StdoutLock, Write};
18
19use termcolor::{Buffer, BufferWriter, ColorChoice, ColorSpec, WriteColor};
20
21/// Whether the terminal dump serializers ([`crate::dump_root`],
22/// [`crate::dump_ops`], [`crate::dump_node`],
23/// [`crate::dump_function_spans`]) emit ANSI color escapes.
24///
25/// This is the library-facing color policy. The CLI resolves user
26/// intent (an explicit `--color` flag, the `NO_COLOR` convention, and
27/// stdout tty detection) into one of these variants and threads it into
28/// the `*_with_color` dump entry points.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum ColorMode {
31 /// Color only when the underlying stream and environment permit it.
32 ///
33 /// Maps to [`termcolor::ColorChoice::Auto`], which honors `NO_COLOR`
34 /// and `TERM=dumb`. Note that `Auto` does **not** itself check
35 /// whether stdout is a terminal — a caller that wants
36 /// "no color when piped" must resolve a redirected stream to
37 /// [`ColorMode::Never`] before constructing the writer (the CLI
38 /// does this via `std::io::IsTerminal`).
39 #[default]
40 Auto,
41 /// Always emit color escapes, regardless of stream or environment.
42 Always,
43 /// Never emit color escapes.
44 Never,
45}
46
47impl ColorMode {
48 /// Translate the policy into the concrete `termcolor` choice used to
49 /// construct the stdout writer.
50 pub(crate) fn to_color_choice(self) -> ColorChoice {
51 match self {
52 Self::Auto => ColorChoice::Auto,
53 Self::Always => ColorChoice::Always,
54 Self::Never => ColorChoice::Never,
55 }
56 }
57}
58
59/// Bytes a render may accumulate in memory before the buffer is emitted
60/// to stdout and reset.
61///
62/// The cap exists because the rendered document is not proportional to
63/// the source: [`crate::dump_node`]'s walk is `O(nodes × depth)`, so a
64/// deeply nested file renders far larger than it reads. A 16 KB C file
65/// of 8,000 nested parentheses renders 545 MB, which an unbounded
66/// buffer would hold resident all at once.
67///
68/// 64 KiB is where per-write overhead is already amortized — the same
69/// threshold and reasoning as the CLI's own output buffer — so a larger
70/// cap buys no syscalls back, only resident bytes. A 545 MB document
71/// costs ~8,300 `write(2)` calls at this size, against the ~1.5 million
72/// the unbuffered `StandardStream` form issued for 23 MB.
73const STDOUT_CHUNK_BYTES: usize = 64 * 1_024;
74
75/// The destination [`print_to_stdout`] renders through.
76///
77/// Exists so tests can substitute a sink that counts emissions and
78/// captures bytes: [`BufferWriter`] can only be constructed over the
79/// process's real stdout or stderr, which left the whole buffered path
80/// untestable when it shipped.
81pub(crate) trait ColorSink {
82 /// A fresh buffer carrying this sink's color capability.
83 fn new_buffer(&self) -> Buffer;
84
85 /// Write one finished buffer to the destination.
86 fn emit(&self, buffer: &Buffer) -> std::io::Result<()>;
87
88 /// Exclude other writers for the span of a chunked document.
89 ///
90 /// Only the real stdout sink has anything to exclude; the returned
91 /// guard is held from the first chunk to the last so a document too
92 /// large for one buffer still lands contiguously.
93 fn exclusive(&self) -> Option<StdoutLock<'static>> {
94 None
95 }
96}
97
98/// The process's stdout as a [`ColorSink`].
99///
100/// A newtype rather than an impl on [`BufferWriter`] itself, because
101/// [`ColorSink::exclusive`] below hands back the *stdout* lock and that
102/// is only the right guard for a writer pointed at stdout.
103/// `BufferWriter::stderr` has the identical type, so an impl on the bare
104/// type would silently give a stderr writer stdout's lock — excluding
105/// the wrong writers and leaving two stderr renders free to interleave.
106struct StdoutSink(BufferWriter);
107
108impl ColorSink for StdoutSink {
109 fn new_buffer(&self) -> Buffer {
110 self.0.buffer()
111 }
112
113 fn emit(&self, buffer: &Buffer) -> std::io::Result<()> {
114 self.0.print(buffer)
115 }
116
117 fn exclusive(&self) -> Option<StdoutLock<'static>> {
118 // `Stdout::lock` is reentrant, so the nested lock `print` takes
119 // per chunk — and the one `bca dump` already holds around its
120 // banner — is fine on this thread while excluding every other.
121 Some(std::io::stdout().lock())
122 }
123}
124
125/// A [`WriteColor`] that accumulates into a [`Buffer`] and hands it to a
126/// [`ColorSink`] every [`STDOUT_CHUNK_BYTES`].
127struct ChunkedSink<'s, S: ColorSink> {
128 sink: &'s S,
129 buffer: Buffer,
130 /// Taken at the first emission, released when the render ends.
131 exclusive: Option<StdoutLock<'static>>,
132}
133
134impl<S: ColorSink> ChunkedSink<'_, S> {
135 /// Emit whatever has accumulated and reset the buffer.
136 ///
137 /// The buffer is cleared even when the emission failed, so a
138 /// half-written chunk is never offered to the sink twice.
139 fn emit_pending(&mut self) -> std::io::Result<()> {
140 if self.buffer.is_empty() {
141 return Ok(());
142 }
143 if self.exclusive.is_none() {
144 self.exclusive = self.sink.exclusive();
145 }
146 let emitted = self.sink.emit(&self.buffer);
147 self.buffer.clear();
148 emitted
149 }
150}
151
152impl<S: ColorSink> Write for ChunkedSink<'_, S> {
153 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
154 let written = self.buffer.write(buf)?;
155 if self.buffer.len() >= STDOUT_CHUNK_BYTES {
156 self.emit_pending()?;
157 }
158 Ok(written)
159 }
160
161 fn flush(&mut self) -> std::io::Result<()> {
162 self.emit_pending()
163 }
164}
165
166impl<S: ColorSink> WriteColor for ChunkedSink<'_, S> {
167 fn supports_color(&self) -> bool {
168 self.buffer.supports_color()
169 }
170
171 fn set_color(&mut self, spec: &ColorSpec) -> std::io::Result<()> {
172 self.buffer.set_color(spec)
173 }
174
175 fn reset(&mut self) -> std::io::Result<()> {
176 self.buffer.reset()
177 }
178}
179
180/// Render a tree with `render` through a bounded in-memory buffer,
181/// emitting it to stdout in at most [`STDOUT_CHUNK_BYTES`] chunks.
182///
183/// This is the shared stdout seam for all four terminal dump entry
184/// points. It replaces a `StandardStream` lock, which is a `LineWriter`:
185/// every `writeln!` in the walk cost its own `write(2)` — a whole-repo
186/// `bca metrics` text dump measured 1.5 million of them for 23 MB of
187/// output. Buffering issues one `write_all` per chunk instead.
188///
189/// Two properties of the write-through shape are preserved deliberately:
190///
191/// - **Atomicity.** `StandardStream::lock` held the stdout lock for the
192/// whole walk, so a parallel walk never interleaved two files' trees.
193/// A document that fits in one chunk is written under the single lock
194/// `print` takes; one that does not holds [`ColorSink::exclusive`]
195/// from its first chunk to its last. Either way no other worker's
196/// output can land inside a file's. Note the CLI adds its own,
197/// stronger, guard on top for `bca dump` / `bca find`: it holds the
198/// stdout lock across the per-file banner *and* the tree, which
199/// serializes rendering across workers there regardless of document
200/// size — matching what the write-through form did.
201/// - **Error propagation.** Rendering targets memory and so cannot fail
202/// on I/O; the pending buffer is emitted before a renderer error is
203/// returned, so a partial tree still reaches the terminal exactly as
204/// it did when the walk wrote through. The real I/O failure (a broken
205/// pipe from `bca metrics | head`, a full disk) surfaces from the
206/// emission.
207///
208/// # Memory
209///
210/// Resident output is bounded by [`STDOUT_CHUNK_BYTES`] per worker
211/// (plus the largest single `write` call, a line), independent of both
212/// the input size and the rendered size. That bound is the point: the
213/// rendered document is emphatically *not* proportional to the source it
214/// describes. Measured ratios of rendered bytes to source bytes for
215/// `bca dump` run 10–22× on ordinary code (`src/metrics/cognitive.rs`,
216/// 333 KB → 3.5 MB; a 437 KB C++ translation unit → 9.8 MB) and are
217/// unbounded on pathological nesting, where the `O(nodes × depth)` walk
218/// turns 16 KB of source into 545 MB of tree.
219pub(crate) fn print_to_stdout<F>(color_mode: ColorMode, render: F) -> std::io::Result<()>
220where
221 F: FnOnce(&mut dyn WriteColor) -> std::io::Result<()>,
222{
223 render_chunked(
224 &StdoutSink(BufferWriter::stdout(color_mode.to_color_choice())),
225 render,
226 )
227}
228
229/// [`print_to_stdout`] with the destination injected — see
230/// [`ColorSink`].
231fn render_chunked<S, F>(sink: &S, render: F) -> std::io::Result<()>
232where
233 S: ColorSink,
234 F: FnOnce(&mut dyn WriteColor) -> std::io::Result<()>,
235{
236 let mut chunked = ChunkedSink {
237 sink,
238 buffer: sink.new_buffer(),
239 exclusive: None,
240 };
241 let rendered = render(&mut chunked);
242 chunked.emit_pending()?;
243 rendered
244}
245
246#[cfg(test)]
247mod tests {
248 use std::cell::RefCell;
249 use std::fmt::Write as _;
250
251 use super::*;
252
253 /// Counts emissions and records their sizes, so a test can assert
254 /// how much a render held resident rather than only what it wrote.
255 #[derive(Default)]
256 struct CountingSink {
257 chunks: RefCell<Vec<usize>>,
258 bytes: RefCell<Vec<u8>>,
259 }
260
261 impl ColorSink for CountingSink {
262 fn new_buffer(&self) -> Buffer {
263 Buffer::no_color()
264 }
265
266 fn emit(&self, buffer: &Buffer) -> std::io::Result<()> {
267 self.chunks.borrow_mut().push(buffer.len());
268 self.bytes.borrow_mut().extend_from_slice(buffer.as_slice());
269 Ok(())
270 }
271 }
272
273 /// A sink whose every emission fails, standing in for a broken pipe
274 /// or a full disk.
275 struct FailingSink;
276
277 impl ColorSink for FailingSink {
278 fn new_buffer(&self) -> Buffer {
279 Buffer::no_color()
280 }
281
282 fn emit(&self, _buffer: &Buffer) -> std::io::Result<()> {
283 Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe))
284 }
285 }
286
287 /// The buffering is the point: a document that fits under the cap
288 /// costs exactly one write, not one per `writeln!`. Reverting
289 /// `render_chunked` to a write-through sink turns the count into
290 /// `LINES`.
291 #[test]
292 fn a_small_render_costs_one_emission() {
293 const LINES: usize = 500;
294 let sink = CountingSink::default();
295
296 render_chunked(&sink, |out| {
297 for i in 0..LINES {
298 writeln!(out, "line {i}")?;
299 }
300 Ok(())
301 })
302 .expect("the counting sink never fails");
303
304 assert_eq!(sink.chunks.borrow().len(), 1);
305 let expected: String = (0..LINES).fold(String::new(), |mut acc, i| {
306 let _ = writeln!(acc, "line {i}");
307 acc
308 });
309 assert_eq!(sink.bytes.borrow().as_slice(), expected.as_bytes());
310 }
311
312 /// The cap is the memory bound: no chunk may exceed it by more than
313 /// the single `write` that tripped it, however large the document
314 /// grows. Deleting the `emit_pending` call from
315 /// `ChunkedSink::write` makes this one emission of ~4 MB.
316 #[test]
317 fn a_large_render_stays_bounded_by_the_chunk_size() {
318 // Enough to fill the cap many times over without being slow.
319 const LINE: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\n";
320 const LINES: usize = 64 * 1_024;
321
322 let sink = CountingSink::default();
323 render_chunked(&sink, |out| {
324 for _ in 0..LINES {
325 out.write_all(LINE.as_bytes())?;
326 }
327 Ok(())
328 })
329 .expect("the counting sink never fails");
330
331 let chunks = sink.chunks.borrow();
332 assert!(chunks.len() > 1, "expected chunking, got {}", chunks.len());
333 let largest = chunks.iter().copied().max().unwrap_or_default();
334 assert!(
335 largest <= STDOUT_CHUNK_BYTES + LINE.len(),
336 "chunk of {largest} B exceeds the {STDOUT_CHUNK_BYTES} B cap"
337 );
338 assert_eq!(sink.bytes.borrow().len(), LINES * LINE.len());
339 }
340
341 /// A renderer that gives up partway must still get what it produced
342 /// onto the terminal — the write-through form had already printed
343 /// those lines by the time it failed.
344 #[test]
345 fn a_render_error_still_emits_the_partial_buffer() {
346 let sink = CountingSink::default();
347
348 let err = render_chunked(&sink, |out| {
349 writeln!(out, "rendered before the failure")?;
350 Err(std::io::Error::from(std::io::ErrorKind::InvalidData))
351 })
352 .expect_err("the render error propagates");
353
354 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
355 assert_eq!(
356 sink.bytes.borrow().as_slice(),
357 b"rendered before the failure\n"
358 );
359 }
360
361 /// An emission failure reaches the caller rather than being lost
362 /// with the buffer.
363 #[test]
364 fn an_emission_error_reaches_the_caller() {
365 let err = render_chunked(&FailingSink, |out| writeln!(out, "anything"))
366 .expect_err("the sink always fails");
367
368 assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe);
369 }
370
371 /// Nothing written means nothing emitted — an empty `bca find`
372 /// result must not take the stdout lock or push a zero-byte write.
373 #[test]
374 fn an_empty_render_emits_nothing() {
375 let sink = CountingSink::default();
376 render_chunked(&sink, |_| Ok(())).expect("no output, no failure");
377 assert!(sink.chunks.borrow().is_empty());
378 }
379
380 /// A sink whose buffers carry ANSI color, so the delegating
381 /// `WriteColor` impl has something to delegate *to*.
382 #[derive(Default)]
383 struct AnsiSink {
384 bytes: RefCell<Vec<u8>>,
385 }
386
387 impl ColorSink for AnsiSink {
388 fn new_buffer(&self) -> Buffer {
389 Buffer::ansi()
390 }
391
392 fn emit(&self, buffer: &Buffer) -> std::io::Result<()> {
393 self.bytes.borrow_mut().extend_from_slice(buffer.as_slice());
394 Ok(())
395 }
396 }
397
398 /// `ChunkedSink` sits between the renderer and the buffer, so every
399 /// `WriteColor` method has to reach the buffer rather than answer for
400 /// it. Both sinks are exercised because a hardcoded `true` or a
401 /// `set_color` that dropped its spec would still pass the ANSI half
402 /// alone — the no-color half is what discriminates.
403 #[test]
404 fn chunked_sink_delegates_color_capability_to_its_buffer() {
405 let ansi = AnsiSink::default();
406 let mut ansi_supported = None;
407 render_chunked(&ansi, |out| {
408 ansi_supported = Some(out.supports_color());
409 out.set_color(ColorSpec::new().set_bold(true))?;
410 write!(out, "bold")?;
411 out.reset()?;
412 write!(out, "plain")
413 })
414 .expect("ansi render");
415
416 assert_eq!(ansi_supported, Some(true), "an ansi buffer supports color");
417 let text = String::from_utf8(ansi.bytes.borrow().clone()).expect("utf-8");
418 // Positional, not a bare `contains`: `reset` alone emits an
419 // escape, so "there is an escape somewhere" passes even when
420 // `set_color` silently drops its spec. Requiring one escape
421 // *before* the styled word and another *between* the two words
422 // pins each method separately.
423 let (before, rest) = text.split_once("bold").expect("styled word present");
424 let (between, after) = rest.split_once("plain").expect("plain word present");
425 assert!(
426 before.contains('\u{1b}'),
427 "set_color must emit before the styled text: {text:?}"
428 );
429 assert!(
430 between.contains('\u{1b}'),
431 "reset must emit between the styled and plain text: {text:?}"
432 );
433 assert!(after.is_empty(), "nothing trails the render: {text:?}");
434
435 let plain = CountingSink::default();
436 let mut plain_supported = None;
437 render_chunked(&plain, |out| {
438 plain_supported = Some(out.supports_color());
439 out.set_color(ColorSpec::new().set_bold(true))?;
440 write!(out, "bold")?;
441 out.reset()
442 })
443 .expect("no-color render");
444
445 assert_eq!(
446 plain_supported,
447 Some(false),
448 "a no-color buffer reports no color support"
449 );
450 let text = String::from_utf8(plain.bytes.borrow().clone()).expect("utf-8");
451 assert_eq!(text, "bold", "a no-color buffer emits no escapes");
452 }
453}