diff --git a/crates/lash-agent/src/tokens.rs b/crates/lash-agent/src/tokens.rs index 2c17f58..f39c758 100644 --- a/crates/lash-agent/src/tokens.rs +++ b/crates/lash-agent/src/tokens.rs @@ -143,8 +143,10 @@ pub fn truncate_to_budget(text: &str, token_budget: usize) -> String { return "...".to_string(); } - // Truncate to character budget, accounting for ellipsis - let truncate_at = char_budget.saturating_sub(3).min(text.len()); + // Truncate to character budget, accounting for ellipsis; clamp to a char + // boundary so slicing can't land inside a multi-byte character + let truncate_at = + lash_types::text::floor_char_boundary(text, char_budget.saturating_sub(3).min(text.len())); // Try to truncate at a word boundary let truncated = if let Some(last_space) = text[..truncate_at].rfind(char::is_whitespace) { diff --git a/crates/lash-cli/src/commands/ascii_graph.rs b/crates/lash-cli/src/commands/ascii_graph.rs index 61752e9..b934f84 100644 --- a/crates/lash-cli/src/commands/ascii_graph.rs +++ b/crates/lash-cli/src/commands/ascii_graph.rs @@ -537,7 +537,7 @@ impl<'a> AsciiGraphRenderer<'a> { let max_title_len = terminal_width.saturating_sub(20); if title.len() > max_title_len && max_title_len > 3 { - format!("{}...", &title[..max_title_len - 3]) + lash_types::text::truncate_with_ellipsis(title, max_title_len) } else { title.to_string() } diff --git a/crates/lash-cli/src/commands/list.rs b/crates/lash-cli/src/commands/list.rs index dc85ff9..ea5fe16 100644 --- a/crates/lash-cli/src/commands/list.rs +++ b/crates/lash-cli/src/commands/list.rs @@ -656,11 +656,7 @@ fn truncate_description(description: &str, max_len: usize) -> String { if collapsed.len() <= max_len { collapsed } else { - // Find a valid char boundary at or before max_len - let mut boundary = max_len; - while !collapsed.is_char_boundary(boundary) && boundary > 0 { - boundary -= 1; - } + let boundary = lash_types::text::floor_char_boundary(&collapsed, max_len); format!("{}...", &collapsed[..boundary]) } } diff --git a/crates/lash-cli/src/diff_display.rs b/crates/lash-cli/src/diff_display.rs index 1389bbd..ec564cb 100644 --- a/crates/lash-cli/src/diff_display.rs +++ b/crates/lash-cli/src/diff_display.rs @@ -385,7 +385,7 @@ impl DiffDisplay { fn format_line(line: &str) -> String { if line.len() > MAX_LINE_LENGTH { - let truncated = &line[..MAX_LINE_LENGTH]; + let truncated = &line[..lash_types::text::floor_char_boundary(line, MAX_LINE_LENGTH)]; format!("{truncated}...") } else { line.to_string() diff --git a/crates/lash-db/src/search.rs b/crates/lash-db/src/search.rs index 16a0a21..941090a 100644 --- a/crates/lash-db/src/search.rs +++ b/crates/lash-db/src/search.rs @@ -820,13 +820,7 @@ fn generate_snippet( // Find a safe character boundary for truncation if body_text.len() > 100 { - // Find the last character boundary at or before index 100 - let truncate_at = body_text - .char_indices() - .take_while(|(idx, _)| *idx <= 100) - .last() - .map_or(0, |(idx, ch)| idx + ch.len_utf8()); - + let truncate_at = lash_types::text::floor_char_boundary(body_text, 100); snippet.push_str(&body_text[..truncate_at]); snippet.push_str("..."); } else { @@ -842,12 +836,7 @@ fn generate_snippet( // Truncate long notes if note_text.len() > 100 { - let truncate_at = note_text - .char_indices() - .take_while(|(idx, _)| *idx <= 100) - .last() - .map_or(0, |(idx, ch)| idx + ch.len_utf8()); - + let truncate_at = lash_types::text::floor_char_boundary(¬e_text, 100); snippet.push_str(¬e_text[..truncate_at]); snippet.push_str("..."); } else { diff --git a/crates/lash-tui/src/ui/logo.rs b/crates/lash-tui/src/ui/logo.rs index 5dd201e..3bcd5e6 100644 --- a/crates/lash-tui/src/ui/logo.rs +++ b/crates/lash-tui/src/ui/logo.rs @@ -94,7 +94,7 @@ fn truncate_title(title: &str, max_width: usize) -> String { if title.len() <= max_width { title.to_string() } else if max_width > 3 { - format!("{}...", &title[..max_width - 3]) + lash_types::text::truncate_with_ellipsis(title, max_width) } else { title.chars().take(max_width).collect() } diff --git a/crates/lash-tui/src/ui/theme_selector.rs b/crates/lash-tui/src/ui/theme_selector.rs index 3ef0fa1..afcd610 100644 --- a/crates/lash-tui/src/ui/theme_selector.rs +++ b/crates/lash-tui/src/ui/theme_selector.rs @@ -105,7 +105,10 @@ fn render_scheme_list( // Scheme name (truncate if too long) let display_name = if name.len() > 28 { - format!("{}… ", &name[..27]) + format!( + "{}… ", + &name[..lash_types::text::floor_char_boundary(name, 27)] + ) } else { format!("{name:<30}") }; diff --git a/crates/lash-types/src/lib.rs b/crates/lash-types/src/lib.rs index d8de9b7..58be73f 100644 --- a/crates/lash-types/src/lib.rs +++ b/crates/lash-types/src/lib.rs @@ -33,6 +33,7 @@ pub mod path_utils; pub mod report; pub mod status; pub mod task; +pub mod text; pub mod tree; pub use config::{ConfigBuilder, LashConfig, TreeViewConfig, UserConfig}; diff --git a/crates/lash-types/src/task.rs b/crates/lash-types/src/task.rs index ce56e44..0334839 100644 --- a/crates/lash-types/src/task.rs +++ b/crates/lash-types/src/task.rs @@ -209,18 +209,15 @@ impl ContextualNote { /// Get a truncated version of the text for display purposes. /// + /// Truncation is UTF-8-safe: a multi-byte character straddling the cut + /// point is dropped rather than split. + /// /// # Arguments /// - /// * `max_len` - Maximum length before truncation (including ellipsis) + /// * `max_len` - Maximum length in bytes before truncation (including ellipsis) #[must_use] pub fn truncated_text(&self, max_len: usize) -> String { - if self.text.len() <= max_len { - self.text.clone() - } else if max_len <= 3 { - "...".to_string() - } else { - format!("{}...", &self.text[..max_len - 3]) - } + crate::text::truncate_with_ellipsis(&self.text, max_len) } } @@ -1171,6 +1168,23 @@ mod tests { assert_eq!(note.truncated_text(2), "..."); } + #[test] + fn test_contextual_note_truncated_text_multibyte_boundary() { + // Regression test for issue #70: a note long enough to trigger + // W_NOTE_TOO_LONG with an em dash straddling the truncation point + // (bytes 55..58) made truncated_text(60) panic on a byte slice. + let text = format!("{}— trailing text {}", "a".repeat(55), "b".repeat(150)); + let note = ContextualNote::new(&text, 1); + assert!(note.exceeds_warning_threshold()); + assert_eq!(note.truncated_text(60), format!("{}...", "a".repeat(55))); + + // No cut point may panic, whatever the multi-byte layout. + let note = ContextualNote::new("héllo wörld — ünïcode täsk nôte", 1); + for max_len in 0..=note.text.len() + 3 { + let _ = note.truncated_text(max_len); + } + } + #[test] fn test_contextual_note_serialization_roundtrip() { let note = ContextualNote::new("Test note", 42); diff --git a/crates/lash-types/src/text.rs b/crates/lash-types/src/text.rs new file mode 100644 index 0000000..64db48a --- /dev/null +++ b/crates/lash-types/src/text.rs @@ -0,0 +1,128 @@ +//! UTF-8-safe text truncation helpers. +//! +//! Byte-index slicing (`&s[..n]`) panics when `n` falls inside a multi-byte +//! character. These helpers clamp indices to char boundaries so display +//! truncation never panics on user text (em dashes, accents, CJK, emoji). + +/// Find the largest index at or below `index` that is a char boundary of `s`. +/// +/// Returns `s.len()` if `index` is past the end of the string. This mirrors +/// the unstable `str::floor_char_boundary`; swap to the std method once it +/// stabilizes. +/// +/// # Examples +/// +/// ``` +/// use lash_types::text::floor_char_boundary; +/// +/// let s = "a—b"; // '—' occupies bytes 1..4 +/// assert_eq!(floor_char_boundary(s, 2), 1); +/// assert_eq!(floor_char_boundary(s, 4), 4); +/// assert_eq!(floor_char_boundary(s, 99), s.len()); +/// ``` +#[must_use] +pub fn floor_char_boundary(s: &str, index: usize) -> usize { + if index >= s.len() { + return s.len(); + } + let mut i = index; + while !s.is_char_boundary(i) { + i -= 1; + } + i +} + +/// Truncate `s` to at most `max_len` bytes, appending `"..."` when truncated. +/// +/// The ellipsis counts toward `max_len`, so the result is never longer than +/// `max_len` bytes (except when `max_len < 3`, where `"..."` is returned +/// as-is). Truncation lands on a char boundary, so multi-byte characters at +/// the cut point are dropped rather than split. +/// +/// # Examples +/// +/// ``` +/// use lash_types::text::truncate_with_ellipsis; +/// +/// assert_eq!(truncate_with_ellipsis("short", 10), "short"); +/// assert_eq!(truncate_with_ellipsis("Hello, World!", 10), "Hello, ..."); +/// assert_eq!(truncate_with_ellipsis("Hello", 2), "..."); +/// +/// // A multi-byte char straddling the cut point is dropped, not split. +/// let s = "aaaa—bbbb"; // '—' occupies bytes 4..7 +/// assert_eq!(truncate_with_ellipsis(s, 8), "aaaa..."); +/// ``` +#[must_use] +pub fn truncate_with_ellipsis(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else if max_len <= 3 { + "...".to_string() + } else { + format!("{}...", &s[..floor_char_boundary(s, max_len - 3)]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn floor_boundary_on_ascii_is_identity() { + let s = "abcdef"; + for i in 0..=s.len() { + assert_eq!(floor_char_boundary(s, i), i); + } + } + + #[test] + fn floor_boundary_walks_back_inside_multibyte_char() { + let s = "a—b"; // bytes: a=0, — =1..4, b=4 + assert_eq!(floor_char_boundary(s, 1), 1); + assert_eq!(floor_char_boundary(s, 2), 1); + assert_eq!(floor_char_boundary(s, 3), 1); + assert_eq!(floor_char_boundary(s, 4), 4); + } + + #[test] + fn floor_boundary_clamps_past_end() { + assert_eq!(floor_char_boundary("abc", 100), 3); + assert_eq!(floor_char_boundary("", 5), 0); + } + + #[test] + fn truncate_short_string_unchanged() { + assert_eq!(truncate_with_ellipsis("hi", 10), "hi"); + assert_eq!(truncate_with_ellipsis("exact", 5), "exact"); + } + + #[test] + fn truncate_tiny_budget_returns_bare_ellipsis() { + assert_eq!(truncate_with_ellipsis("hello", 3), "..."); + assert_eq!(truncate_with_ellipsis("hello", 0), "..."); + } + + #[test] + fn truncate_never_splits_multibyte_chars() { + // '—' is 3 bytes; place it so every cut point from 4..=9 lands + // somewhere interesting and none of them panic. + let s = "aaaa—bbbb"; + for max_len in 4..=s.len() + 3 { + let out = truncate_with_ellipsis(s, max_len); + assert!(out.len() <= max_len.max(3)); + assert!(out.is_char_boundary(out.len())); + } + } + + #[test] + fn truncate_handles_emoji_and_cjk() { + let s = "日本語のテキストです、長いので切り詰められます"; + for max_len in 0..=s.len() + 3 { + let _ = truncate_with_ellipsis(s, max_len); // must not panic + } + let e = "🦀🦀🦀🦀🦀"; + for max_len in 0..=e.len() + 3 { + let _ = truncate_with_ellipsis(e, max_len); + } + } +} diff --git a/devlog.md b/devlog.md index cd2c6a8..3e1df84 100644 --- a/devlog.md +++ b/devlog.md @@ -1,6 +1,23 @@ # Lash Development Log -## 2026-08-07 - Rename `lash-cli` package to `lash`; first release (v0.1.0) +## 2026-08-27 - Fix `lash lint` panic on multi-byte chars at truncation boundaries + +### Summary + +`lash lint` crashed with exit 101 when a note long enough for +`W_NOTE_TOO_LONG` had a multi-byte character straddling byte 57 (issue +#70). `ContextualNote::truncated_text` sliced the text by byte index, and +`&text[..57]` panics when byte 57 falls inside a character such as an em +dash. Added a `lash_types::text` module with `floor_char_boundary` and +`truncate_with_ellipsis`, and rewrote `truncated_text` on top of it. + +The audit suggested in the issue turned up the same latent panic in five +more places, all now fixed via the shared helpers: diff display line +truncation, ASCII graph titles, the TUI logo title, TUI theme-selector +names, and agent token-budget truncation. Search snippets and `lash list` +descriptions already walked back to a char boundary by hand; both now call +the shared helper instead. Regression tests cover the exact repro from the +issue plus exhaustive cut points over multi-byte text. ### Summary