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, 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}