perf: make CLI streaming render incremental instead of O(n^2) (#10487)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Manas Agarwal
2026-07-30 15:53:01 -04:00
committed by GitHub
parent 2859dc8fd3
commit ee61c7c499
2 changed files with 234 additions and 13 deletions
+22 -8
View File
@@ -1002,15 +1002,29 @@ fn print_markdown(content: &str, theme: Theme) {
}
/// Renders markdown content using bat (no table processing)
///
/// The printer is cached per thread because `PrettyPrinter::new()`
/// deserializes bat's bundled syntax/theme assets; `print()` drains the
/// queued inputs but leaves the printer reusable.
fn print_markdown_raw(content: &str, theme: Theme) {
bat::PrettyPrinter::new()
.input(bat::Input::from_bytes(content.as_bytes()))
.theme(theme.as_str())
.colored_output(env_no_color())
.language("Markdown")
.wrapping_mode(WrappingMode::NoWrapping(true))
.print()
.unwrap();
use std::cell::RefCell;
thread_local! {
static PRINTER: RefCell<bat::PrettyPrinter<'static>> =
RefCell::new(bat::PrettyPrinter::new());
}
PRINTER.with(|printer| {
printer
.borrow_mut()
.input(bat::Input::from_reader(Box::new(std::io::Cursor::new(
content.as_bytes().to_vec(),
))))
.theme(theme.as_str())
.colored_output(env_no_color())
.language("Markdown")
.wrapping_mode(WrappingMode::NoWrapping(true))
.print()
.unwrap();
});
}
fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> {
@@ -224,6 +224,19 @@ static INLINE_TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| {
#[derive(Default)]
pub struct MarkdownBuffer {
buffer: String,
checkpoint: ScanCheckpoint,
}
/// Scan progress carried across `push` calls: `pos` is a line-start byte
/// offset in `buffer`; `state`/`last_safe` are the parse state and last clean
/// boundary as of `pos`. Only persisted at line starts on or before
/// `stable_scan_limit`, past which line-start decisions could still be
/// re-interpreted as the buffer grows.
#[derive(Default, Debug, Clone)]
struct ScanCheckpoint {
pos: usize,
state: ParseState,
last_safe: usize,
}
/// Tracks the current parsing state for markdown constructs.
@@ -287,6 +300,10 @@ impl MarkdownBuffer {
// - The regex tokenizer operates on &str which guarantees UTF-8
let to_render = self.buffer[..safe_end].to_string();
self.buffer = self.buffer[safe_end..].to_string();
// A drain changes how the remainder parses (its first byte becomes
// a line start), so the next scan must start from scratch exactly
// like a full rescan of the remainder would.
self.checkpoint = ScanCheckpoint::default();
Some(truncate_code_blocks(&to_render))
} else {
None
@@ -298,20 +315,60 @@ impl MarkdownBuffer {
/// Call this at the end of a stream to get any buffered content,
/// even if markdown constructs are unclosed.
pub fn flush(&mut self) -> String {
self.checkpoint = ScanCheckpoint::default();
std::mem::take(&mut self.buffer)
}
/// Find the last byte position where the parse state is "clean".
fn find_safe_end(&self) -> usize {
let mut state = ParseState::default();
let mut last_safe: usize = 0;
/// Byte offset where the buffer's "mutable tail" begins: the trailing
/// incomplete line, plus any run of complete lines above it containing
/// only whitespace and/or fence characters. Line-start decisions inside
/// this region (blank-line lookahead, fence runs still growing at the
/// buffer edge) can be re-interpreted when more bytes arrive, so no
/// checkpoint may be persisted past it.
fn stable_scan_limit(&self) -> usize {
let bytes = self.buffer.as_bytes();
let mut limit = match self.buffer.rfind('\n') {
Some(nl) => nl + 1,
None => 0,
};
while limit > 0 {
let prev_start = match self.buffer[..limit - 1].rfind('\n') {
Some(nl) => nl + 1,
None => 0,
};
let reinterpretable = bytes[prev_start..limit]
.iter()
.all(|&c| matches!(c, b'`' | b'~' | b' ' | b'\t' | b'\r' | b'\n'));
if !reinterpretable {
break;
}
limit = prev_start;
}
limit
}
/// Find the last byte position where the parse state is "clean",
/// resuming from the previous call's checkpoint so each push scans only
/// the new tail of the buffer.
fn find_safe_end(&mut self) -> usize {
let mut state = self.checkpoint.state.clone();
let mut last_safe: usize = self.checkpoint.last_safe;
let bytes = self.buffer.as_bytes();
let len = bytes.len();
let mut pos: usize = 0;
let mut pos: usize = self.checkpoint.pos;
let stable_limit = self.stable_scan_limit();
while pos < len {
let at_line_start = pos == 0 || bytes[pos - 1] == b'\n';
if at_line_start && pos <= stable_limit {
self.checkpoint = ScanCheckpoint {
pos,
state: state.clone(),
last_safe,
};
}
if at_line_start {
if let Some(new_pos) = self.process_line_start(&mut state, pos) {
pos = new_pos;
@@ -823,4 +880,154 @@ mod tests {
assert_eq!(parse_positive_lines("not-a-number"), None);
assert_eq!(parse_positive_lines("3.14"), None);
}
// ===========================================
// Incremental scan checkpointing
// ===========================================
#[test_case(
&["``", "`rust\n", "fn main() {}\n", "``", "`\n"],
&["```rust\nfn main() {}\n```\n"]
; "fence marker split across pushes"
)]
#[test_case(
&["intro\n\n", "```rust\n", "code\n", "```\n"],
&["intro\n\n", "```rust\ncode\n```\n"]
; "blank line drained before fence arrives"
)]
#[test_case(
&["a **b", "** c\nd `e", "` f\n"],
&["a ", "**b** c\nd ", "`e` f\n"]
; "consecutive split constructs drain independently"
)]
fn test_checkpoint_boundaries(chunks: &[&str], expected: &[&str]) {
assert_eq!(stream(chunks), expected);
}
/// Streaming any chunking of an input must emit exactly the input,
/// in order, once flushed — regardless of where checkpoints landed.
#[test]
fn chunked_output_reassembles_input() {
let corpus = "# Title\n\nSome **bold** and `code`.\n\n\
```python\nfor i in range(3):\n print(i)\n```\n\n\
A [link](https://example.com) and *italic*.\n\
| a | b |\n|---|---|\n| 1 | 2 |\n\ntail";
for chunk_size in [1, 2, 3, 7, 16, corpus.len()] {
let chunks: Vec<&str> = corpus
.as_bytes()
.chunks(chunk_size)
.map(|c| std::str::from_utf8(c).unwrap())
.collect();
let reassembled = stream(&chunks).concat();
assert_eq!(reassembled, corpus, "chunk_size={chunk_size}");
}
}
/// Per-push scanning must not degrade with accumulated block size.
/// Run manually for perf evidence:
/// cargo test -p goose-cli --release -- --ignored large_code_block --nocapture
#[test]
#[ignore]
fn large_code_block_streams_in_linear_time() {
let line = "let x = compute_something_interesting(1234);\n";
let start = std::time::Instant::now();
let mut buf = MarkdownBuffer::new();
buf.push("```rust\n");
for _ in 0..4000 {
for chunk in line.as_bytes().chunks(8) {
buf.push(std::str::from_utf8(chunk).unwrap());
}
}
let out = buf.push("```\n").expect("block should drain at close");
let elapsed = start.elapsed();
println!("4000-line block, 8-byte chunks: {elapsed:?}");
assert!(out.starts_with("```rust\n"));
assert!(buf.flush().is_empty());
}
/// Differential check: the checkpointed scan must produce exactly the
/// same drain sequence as a full rescan from scratch (a zeroed checkpoint
/// degenerates to the pre-checkpoint algorithm), across randomized
/// markdown streams and randomized chunk boundaries.
// SAFETY: `j` is advanced to a char boundary before every slice, and `i`
// always takes its value from a previous `j`.
#[allow(clippy::string_slice)]
#[test]
fn incremental_scan_matches_full_rescan_on_random_streams() {
const TOKENS: &[&str] = &[
"text ",
"word",
"\u{e9}\u{2713}",
"\n",
"\n\n",
" ",
"**",
"*",
"___",
"~~",
"`",
"``",
"```",
"```rust\n",
"~~~",
"#",
"## ",
"|",
"| a | b |\n",
"[",
"](",
")",
"![",
"]",
"\\*",
"code line\n",
"```\n",
];
let mut seed: u64 = 0x00C0_FFEE;
let mut next = move || {
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(seed >> 33) as usize
};
for case in 0..500 {
let doc: String = (0..(next() % 80 + 1))
.map(|_| TOKENS[next() % TOKENS.len()])
.collect();
let mut incremental = MarkdownBuffer::new();
let mut full_rescan = MarkdownBuffer::new();
let mut inc_out: Vec<String> = Vec::new();
let mut full_out: Vec<String> = Vec::new();
let mut i = 0;
while i < doc.len() {
let mut j = (i + next() % 9 + 1).min(doc.len());
while !doc.is_char_boundary(j) {
j += 1;
}
let chunk = &doc[i..j];
i = j;
if let Some(s) = incremental.push(chunk) {
inc_out.push(s);
}
full_rescan.checkpoint = ScanCheckpoint::default();
if let Some(s) = full_rescan.push(chunk) {
full_out.push(s);
}
assert_eq!(
incremental.buffer, full_rescan.buffer,
"case {case}: buffers diverged, doc={doc:?}"
);
}
inc_out.push(incremental.flush());
full_out.push(full_rescan.flush());
assert_eq!(
inc_out, full_out,
"case {case}: outputs diverged, doc={doc:?}"
);
}
}
}