Skip to main content

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}