Files
logViewer/crates/core/src/io/wrap.rs
dailz 5cb56dafd8 fix(core): correct tab-stop alignment and width overflow in wrap_line_chars
- Extract TAB_WIDTH constant (4) replacing magic numbers
- Calculate tab stop as TAB_WIDTH - (col % TAB_WIDTH) for proper alignment
- Split tab expansion across rows when width < TAB_WIDTH
- Update test_wrap_with_tab expected value for new behavior
- Add tests: narrow width, stop alignment, line boundary, regression

Fixes: #27
2026-06-11 13:37:14 +08:00

235 lines
6.7 KiB
Rust

/// 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
}
/// Format a line as pretty-printed JSON if it's a JSON Object.
/// Returns the original line unchanged for non-JSON or non-Object content.
pub fn format_json_line(line: &str) -> String {
if line.trim().is_empty() {
return String::new();
}
// Quick pre-check: only try parsing if it starts with '{'
if !line.trim_start().starts_with('{') {
return line.to_string();
}
match serde_json::from_str::<serde_json::Value>(line) {
Ok(value) if value.is_object() => {
serde_json::to_string_pretty(&value).unwrap_or_else(|_| line.to_string())
}
_ => line.to_string(),
}
}
#[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 "]);
}
}