Four changes that compound to a 2-5x speedup on VHI rebuild for large files, plus eliminating one full wasted VHI pass on initial open. * spawn_indexer no longer computes VHI inline (progressive_reader.rs). The inline VHI used a hardcoded width=80, was invalidated immediately on Loading->Ready transition (gutter digit-boundary), and triggered a second full rebuild. Now returns visual_height_index: None and lets the UI trigger one rebuild with the correct post-layout width. * spawn_visual_height_rebuild rewritten as a single linear pass over mmap bytes using memchr::memchr (SIMD) to find newlines, replacing the byte-by-byte BufRead::read_until scan. Cancel check throttled to every 1MB instead of per-line. * wrap_line_count(&str, width) -> usize added as a zero-allocation counterpart to wrap_line_chars. Property tests cover ASCII, CJK, emoji, combining marks, tabs, zero-width chars, control chars, and real log lines across many widths - all match wrap_line_chars().len() exactly. compute_text_visual_height now uses wrap_line_count, removing 10M Vec allocations on a 10M-line file. * format_json_line returns Cow<'_, str> instead of String. Non-JSON lines borrow the input (zero allocation); only pretty-printed JSON objects allocate. Removes another 10M allocations when json_format=true. Removes now-unused compute_visual_heights (only caller was the deleted inline VHI path).
417 lines
12 KiB
Rust
417 lines
12 KiB
Rust
use std::borrow::Cow;
|
|
|
|
/// Maximum input length for wrap/format operations (10 MB).
|
|
/// Callers should check against this constant before invoking `wrap_line_chars`
|
|
/// to avoid pathological cases on oversized lines.
|
|
pub const MAX_WRAP_INPUT_LEN: usize = 10 * 1024 * 1024;
|
|
|
|
/// Column spacing for tab stop alignment.
|
|
const TAB_WIDTH: usize = 4;
|
|
|
|
/// Split a line into chunks of exactly `width` display columns.
|
|
/// For a log viewer, we want character-level wrapping, not word-level.
|
|
/// Uses `unicode-width` for correct CJK/emoji/zero-width handling.
|
|
/// Tab characters expand to the next tab-stop boundary and split across
|
|
/// rows when the expansion exceeds the remaining width.
|
|
pub fn wrap_line_chars(line: &str, width: usize) -> Vec<String> {
|
|
use unicode_width::UnicodeWidthChar;
|
|
|
|
if width == 0 {
|
|
return vec![String::new()];
|
|
}
|
|
if line.is_empty() {
|
|
return vec![String::new()];
|
|
}
|
|
let mut result = Vec::new();
|
|
let mut row = String::new();
|
|
let mut col = 0;
|
|
for ch in line.chars() {
|
|
if ch == '\t' {
|
|
let tab_stop = TAB_WIDTH - (col % TAB_WIDTH);
|
|
let mut remaining = tab_stop;
|
|
while remaining > 0 {
|
|
let avail = width.saturating_sub(col);
|
|
let fill = remaining.min(avail);
|
|
for _ in 0..fill {
|
|
row.push(' ');
|
|
}
|
|
col += fill;
|
|
remaining -= fill;
|
|
if col >= width {
|
|
result.push(std::mem::take(&mut row));
|
|
col = 0;
|
|
}
|
|
}
|
|
} else {
|
|
let w = if ch.is_control() {
|
|
// Control characters (except tab): width 0, still pushed to preserve content.
|
|
// Visible rendering is the caller's responsibility.
|
|
0
|
|
} else {
|
|
ch.width().unwrap_or(0)
|
|
};
|
|
if col + w > width && !row.is_empty() {
|
|
result.push(std::mem::take(&mut row));
|
|
col = 0;
|
|
}
|
|
row.push(ch);
|
|
col += w;
|
|
if col >= width {
|
|
result.push(std::mem::take(&mut row));
|
|
col = 0;
|
|
}
|
|
}
|
|
}
|
|
if !row.is_empty() {
|
|
result.push(row);
|
|
}
|
|
if result.is_empty() {
|
|
result.push(String::new());
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Count wrapped rows for a line without allocating the wrapped strings.
|
|
/// MUST produce the same count as `wrap_line_chars(line, width).len()`.
|
|
pub fn wrap_line_count(line: &str, width: usize) -> usize {
|
|
use unicode_width::UnicodeWidthChar;
|
|
|
|
if width == 0 || line.is_empty() {
|
|
return 1;
|
|
}
|
|
let mut count = 0usize;
|
|
let mut col = 0usize;
|
|
let mut row_has_content = false;
|
|
|
|
for ch in line.chars() {
|
|
if ch == '\t' {
|
|
let tab_stop = TAB_WIDTH - (col % TAB_WIDTH);
|
|
let mut remaining = tab_stop;
|
|
while remaining > 0 {
|
|
let avail = width.saturating_sub(col);
|
|
let fill = remaining.min(avail);
|
|
col += fill;
|
|
remaining -= fill;
|
|
if col >= width {
|
|
count += 1;
|
|
col = 0;
|
|
row_has_content = false;
|
|
} else {
|
|
row_has_content = true;
|
|
}
|
|
}
|
|
} else {
|
|
let w = if ch.is_control() {
|
|
0
|
|
} else {
|
|
ch.width().unwrap_or(0)
|
|
};
|
|
if col + w > width && row_has_content {
|
|
count += 1;
|
|
col = 0;
|
|
}
|
|
col += w;
|
|
if col >= width {
|
|
count += 1;
|
|
col = 0;
|
|
row_has_content = false;
|
|
} else {
|
|
row_has_content = true;
|
|
}
|
|
}
|
|
}
|
|
if row_has_content {
|
|
count += 1;
|
|
}
|
|
if count == 0 {
|
|
count = 1;
|
|
}
|
|
count
|
|
}
|
|
|
|
/// Format a line as pretty-printed JSON if it's a JSON Object.
|
|
/// Returns the original line borrowed for non-JSON or non-Object content,
|
|
/// only allocating when pretty-printing actually happens.
|
|
pub fn format_json_line(line: &str) -> Cow<'_, str> {
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
return Cow::Borrowed("");
|
|
}
|
|
if !trimmed.starts_with('{') {
|
|
return Cow::Borrowed(line);
|
|
}
|
|
match serde_json::from_str::<serde_json::Value>(line) {
|
|
Ok(value) if value.is_object() => match serde_json::to_string_pretty(&value) {
|
|
Ok(s) => Cow::Owned(s),
|
|
Err(_) => Cow::Borrowed(line),
|
|
},
|
|
_ => Cow::Borrowed(line),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_wrap_empty_line() {
|
|
let result = wrap_line_chars("", 80);
|
|
assert_eq!(result, vec![""]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_zero_width() {
|
|
let result = wrap_line_chars("hello", 0);
|
|
assert_eq!(result, vec![""]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_short_line() {
|
|
let result = wrap_line_chars("hello", 80);
|
|
assert_eq!(result, vec!["hello"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_exact_width() {
|
|
let result = wrap_line_chars("abc", 3);
|
|
assert_eq!(result, vec!["abc"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_multi_row() {
|
|
let result = wrap_line_chars("abcdef", 3);
|
|
assert_eq!(result, vec!["abc", "def"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_with_tab() {
|
|
let result = wrap_line_chars("a\tb", 4);
|
|
assert_eq!(result, vec!["a ", "b"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_empty() {
|
|
assert_eq!(format_json_line(""), "");
|
|
assert_eq!(format_json_line(" "), "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_non_json() {
|
|
assert_eq!(format_json_line("hello world"), "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_valid_object() {
|
|
let input = r#"{"key":"value"}"#;
|
|
let output = format_json_line(input);
|
|
assert!(
|
|
output.contains('\n'),
|
|
"pretty-printed JSON should have newlines"
|
|
);
|
|
assert!(output.contains("key"));
|
|
assert!(output.contains("value"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_array_unchanged() {
|
|
let input = r#"[1,2,3]"#;
|
|
assert_eq!(format_json_line(input), input);
|
|
}
|
|
|
|
#[test]
|
|
fn test_max_wrap_input_len_constant() {
|
|
assert_eq!(MAX_WRAP_INPUT_LEN, 10 * 1024 * 1024);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_cjk_chars() {
|
|
let result = wrap_line_chars("你好", 3);
|
|
assert_eq!(result, vec!["你", "好"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_cjk_ascii_mixed() {
|
|
let result = wrap_line_chars("a你好", 4);
|
|
assert_eq!(result, vec!["a你", "好"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_zero_width_char() {
|
|
let result = wrap_line_chars("a\u{200B}b", 2);
|
|
assert_eq!(result, vec!["a\u{200B}b"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_emoji() {
|
|
let result = wrap_line_chars("😀a", 3);
|
|
assert_eq!(result, vec!["😀a"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_emoji_exact_wrap() {
|
|
let result = wrap_line_chars("😀a", 2);
|
|
assert_eq!(result, vec!["😀", "a"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_combining_mark() {
|
|
// Scalar-width wrapping: combining mark (width 0) stays with next base char,
|
|
// not the preceding one, because the base char already triggered a flush.
|
|
let result = wrap_line_chars("a\u{0301}b", 1);
|
|
assert_eq!(result, vec!["a", "\u{0301}b"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_cjk_width_one() {
|
|
let result = wrap_line_chars("你好", 1);
|
|
assert_eq!(result, vec!["你", "好"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tab_narrow_width() {
|
|
let result = wrap_line_chars("\t", 2);
|
|
assert_eq!(result, vec![" ", " "]);
|
|
let result = wrap_line_chars("\t", 1);
|
|
assert_eq!(result, vec![" ", " ", " ", " "]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tab_stop_alignment() {
|
|
assert_eq!(wrap_line_chars("a\tb", 8), vec!["a b"]);
|
|
assert_eq!(wrap_line_chars("ab\t", 4), vec!["ab "]);
|
|
assert_eq!(wrap_line_chars("abc\tb", 8), vec!["abc b"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tab_at_line_boundary() {
|
|
let result = wrap_line_chars("a\tb", 4);
|
|
assert_eq!(result, vec!["a ", "b"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tab_regression_ab_tab() {
|
|
let result = wrap_line_chars("ab\t", 4);
|
|
assert_eq!(result, vec!["ab "]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_count_matches_chars_empty() {
|
|
assert_eq!(wrap_line_count("", 80), wrap_line_chars("", 80).len());
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_count_matches_chars_zero_width() {
|
|
assert_eq!(
|
|
wrap_line_count("hello", 0),
|
|
wrap_line_chars("hello", 0).len()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_count_matches_chars_ascii_short() {
|
|
for width in 1..20 {
|
|
assert_eq!(
|
|
wrap_line_count("hello world", width),
|
|
wrap_line_chars("hello world", width).len(),
|
|
"width={width}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_count_matches_chars_cjk() {
|
|
for width in 1..10 {
|
|
assert_eq!(
|
|
wrap_line_count("你好世界", width),
|
|
wrap_line_chars("你好世界", width).len(),
|
|
"width={width}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_count_matches_chars_emoji() {
|
|
for width in 1..6 {
|
|
assert_eq!(
|
|
wrap_line_count("😀👨👩👧", width),
|
|
wrap_line_chars("😀👨👩👧", width).len(),
|
|
"width={width}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_count_matches_chars_combining_mark() {
|
|
for width in 1..6 {
|
|
assert_eq!(
|
|
wrap_line_count("a\u{0301}b\u{0301}c", width),
|
|
wrap_line_chars("a\u{0301}b\u{0301}c", width).len(),
|
|
"width={width}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_count_matches_chars_tab_variants() {
|
|
let inputs = ["\t", "a\tb", "ab\t", "\t\t", "abc\tb", "a\t\t\tb"];
|
|
for input in inputs {
|
|
for width in 1..9 {
|
|
assert_eq!(
|
|
wrap_line_count(input, width),
|
|
wrap_line_chars(input, width).len(),
|
|
"input={input:?} width={width}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_count_matches_chars_mixed() {
|
|
let input = "log: 你好 😀\tvalue=true [ERROR]";
|
|
for width in 1..40 {
|
|
assert_eq!(
|
|
wrap_line_count(input, width),
|
|
wrap_line_chars(input, width).len(),
|
|
"width={width}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_count_matches_chars_zero_width_chars() {
|
|
let input = "a\u{200B}b\u{200C}c\u{200D}d";
|
|
for width in 1..8 {
|
|
assert_eq!(
|
|
wrap_line_count(input, width),
|
|
wrap_line_chars(input, width).len(),
|
|
"width={width}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_count_matches_chars_control_chars() {
|
|
let input = "a\x07b\x1Fc";
|
|
for width in 1..8 {
|
|
assert_eq!(
|
|
wrap_line_count(input, width),
|
|
wrap_line_chars(input, width).len(),
|
|
"width={width}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_count_matches_chars_long_log_line() {
|
|
let input = "2025-01-14T10:23:45.123Z [INFO] [auth] request handled status=200 \
|
|
latency=0.234s endpoint=/api/v1/users user=user_42 request_id=req_abc123def456";
|
|
for width in [10, 20, 40, 60, 80, 100, 120] {
|
|
assert_eq!(
|
|
wrap_line_count(input, width),
|
|
wrap_line_chars(input, width).len(),
|
|
"width={width}"
|
|
);
|
|
}
|
|
}
|
|
}
|