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, Color, 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// Accept `&mut dyn WriteColor` rather than `&mut StandardStreamLock` so
247// tests (e.g. `function::dump_spans`) can substitute `termcolor::NoColor`
248// over a `Vec<u8>` to capture the rendered bytes. Production callers
249// continue to pass `&mut StandardStreamLock`, which unsized-coerces to
250// the trait object at the call site.
251#[inline]
252pub(crate) fn color(stdout: &mut dyn WriteColor, color: Color) -> std::io::Result<()> {
253 stdout.set_color(ColorSpec::new().set_fg(Some(color)))
254}
255
256#[inline]
257pub(crate) fn intense_color(stdout: &mut dyn WriteColor, color: Color) -> std::io::Result<()> {
258 stdout.set_color(ColorSpec::new().set_fg(Some(color)).set_intense(true))
259}
260
261#[cfg(test)]
262mod tests {
263 use std::cell::RefCell;
264 use std::fmt::Write as _;
265
266 use super::*;
267
268 /// Counts emissions and records their sizes, so a test can assert
269 /// how much a render held resident rather than only what it wrote.
270 #[derive(Default)]
271 struct CountingSink {
272 chunks: RefCell<Vec<usize>>,
273 bytes: RefCell<Vec<u8>>,
274 }
275
276 impl ColorSink for CountingSink {
277 fn new_buffer(&self) -> Buffer {
278 Buffer::no_color()
279 }
280
281 fn emit(&self, buffer: &Buffer) -> std::io::Result<()> {
282 self.chunks.borrow_mut().push(buffer.len());
283 self.bytes.borrow_mut().extend_from_slice(buffer.as_slice());
284 Ok(())
285 }
286 }
287
288 /// A sink whose every emission fails, standing in for a broken pipe
289 /// or a full disk.
290 struct FailingSink;
291
292 impl ColorSink for FailingSink {
293 fn new_buffer(&self) -> Buffer {
294 Buffer::no_color()
295 }
296
297 fn emit(&self, _buffer: &Buffer) -> std::io::Result<()> {
298 Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe))
299 }
300 }
301
302 /// The buffering is the point: a document that fits under the cap
303 /// costs exactly one write, not one per `writeln!`. Reverting
304 /// `render_chunked` to a write-through sink turns the count into
305 /// `LINES`.
306 #[test]
307 fn a_small_render_costs_one_emission() {
308 const LINES: usize = 500;
309 let sink = CountingSink::default();
310
311 render_chunked(&sink, |out| {
312 for i in 0..LINES {
313 writeln!(out, "line {i}")?;
314 }
315 Ok(())
316 })
317 .expect("the counting sink never fails");
318
319 assert_eq!(sink.chunks.borrow().len(), 1);
320 let expected: String = (0..LINES).fold(String::new(), |mut acc, i| {
321 let _ = writeln!(acc, "line {i}");
322 acc
323 });
324 assert_eq!(sink.bytes.borrow().as_slice(), expected.as_bytes());
325 }
326
327 /// The cap is the memory bound: no chunk may exceed it by more than
328 /// the single `write` that tripped it, however large the document
329 /// grows. Deleting the `emit_pending` call from
330 /// `ChunkedSink::write` makes this one emission of ~4 MB.
331 #[test]
332 fn a_large_render_stays_bounded_by_the_chunk_size() {
333 // Enough to fill the cap many times over without being slow.
334 const LINE: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\n";
335 const LINES: usize = 64 * 1_024;
336
337 let sink = CountingSink::default();
338 render_chunked(&sink, |out| {
339 for _ in 0..LINES {
340 out.write_all(LINE.as_bytes())?;
341 }
342 Ok(())
343 })
344 .expect("the counting sink never fails");
345
346 let chunks = sink.chunks.borrow();
347 assert!(chunks.len() > 1, "expected chunking, got {}", chunks.len());
348 let largest = chunks.iter().copied().max().unwrap_or_default();
349 assert!(
350 largest <= STDOUT_CHUNK_BYTES + LINE.len(),
351 "chunk of {largest} B exceeds the {STDOUT_CHUNK_BYTES} B cap"
352 );
353 assert_eq!(sink.bytes.borrow().len(), LINES * LINE.len());
354 }
355
356 /// A renderer that gives up partway must still get what it produced
357 /// onto the terminal — the write-through form had already printed
358 /// those lines by the time it failed.
359 #[test]
360 fn a_render_error_still_emits_the_partial_buffer() {
361 let sink = CountingSink::default();
362
363 let err = render_chunked(&sink, |out| {
364 writeln!(out, "rendered before the failure")?;
365 Err(std::io::Error::from(std::io::ErrorKind::InvalidData))
366 })
367 .expect_err("the render error propagates");
368
369 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
370 assert_eq!(
371 sink.bytes.borrow().as_slice(),
372 b"rendered before the failure\n"
373 );
374 }
375
376 /// An emission failure reaches the caller rather than being lost
377 /// with the buffer.
378 #[test]
379 fn an_emission_error_reaches_the_caller() {
380 let err = render_chunked(&FailingSink, |out| writeln!(out, "anything"))
381 .expect_err("the sink always fails");
382
383 assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe);
384 }
385
386 /// Nothing written means nothing emitted — an empty `bca find`
387 /// result must not take the stdout lock or push a zero-byte write.
388 #[test]
389 fn an_empty_render_emits_nothing() {
390 let sink = CountingSink::default();
391 render_chunked(&sink, |_| Ok(())).expect("no output, no failure");
392 assert!(sink.chunks.borrow().is_empty());
393 }
394
395 /// A sink whose buffers carry ANSI color, so the delegating
396 /// `WriteColor` impl has something to delegate *to*.
397 #[derive(Default)]
398 struct AnsiSink {
399 bytes: RefCell<Vec<u8>>,
400 }
401
402 impl ColorSink for AnsiSink {
403 fn new_buffer(&self) -> Buffer {
404 Buffer::ansi()
405 }
406
407 fn emit(&self, buffer: &Buffer) -> std::io::Result<()> {
408 self.bytes.borrow_mut().extend_from_slice(buffer.as_slice());
409 Ok(())
410 }
411 }
412
413 /// `ChunkedSink` sits between the renderer and the buffer, so every
414 /// `WriteColor` method has to reach the buffer rather than answer for
415 /// it. Both sinks are exercised because a hardcoded `true` or a
416 /// `set_color` that dropped its spec would still pass the ANSI half
417 /// alone — the no-color half is what discriminates.
418 #[test]
419 fn chunked_sink_delegates_color_capability_to_its_buffer() {
420 let ansi = AnsiSink::default();
421 let mut ansi_supported = None;
422 render_chunked(&ansi, |out| {
423 ansi_supported = Some(out.supports_color());
424 out.set_color(ColorSpec::new().set_bold(true))?;
425 write!(out, "bold")?;
426 out.reset()?;
427 write!(out, "plain")
428 })
429 .expect("ansi render");
430
431 assert_eq!(ansi_supported, Some(true), "an ansi buffer supports color");
432 let text = String::from_utf8(ansi.bytes.borrow().clone()).expect("utf-8");
433 // Positional, not a bare `contains`: `reset` alone emits an
434 // escape, so "there is an escape somewhere" passes even when
435 // `set_color` silently drops its spec. Requiring one escape
436 // *before* the styled word and another *between* the two words
437 // pins each method separately.
438 let (before, rest) = text.split_once("bold").expect("styled word present");
439 let (between, after) = rest.split_once("plain").expect("plain word present");
440 assert!(
441 before.contains('\u{1b}'),
442 "set_color must emit before the styled text: {text:?}"
443 );
444 assert!(
445 between.contains('\u{1b}'),
446 "reset must emit between the styled and plain text: {text:?}"
447 );
448 assert!(after.is_empty(), "nothing trails the render: {text:?}");
449
450 let plain = CountingSink::default();
451 let mut plain_supported = None;
452 render_chunked(&plain, |out| {
453 plain_supported = Some(out.supports_color());
454 out.set_color(ColorSpec::new().set_bold(true))?;
455 write!(out, "bold")?;
456 out.reset()
457 })
458 .expect("no-color render");
459
460 assert_eq!(
461 plain_supported,
462 Some(false),
463 "a no-color buffer reports no color support"
464 );
465 let text = String::from_utf8(plain.bytes.borrow().clone()).expect("utf-8");
466 assert_eq!(text, "bold", "a no-color buffer emits no escapes");
467 }
468}