Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/lash-agent/src/tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion crates/lash-cli/src/commands/ascii_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
6 changes: 1 addition & 5 deletions crates/lash-cli/src/commands/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/lash-cli/src/diff_display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
15 changes: 2 additions & 13 deletions crates/lash-db/src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(&note_text, 100);
snippet.push_str(&note_text[..truncate_at]);
snippet.push_str("...");
} else {
Expand Down
2 changes: 1 addition & 1 deletion crates/lash-tui/src/ui/logo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
5 changes: 4 additions & 1 deletion crates/lash-tui/src/ui/theme_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
};
Expand Down
1 change: 1 addition & 0 deletions crates/lash-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
30 changes: 22 additions & 8 deletions crates/lash-types/src/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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);
Expand Down
128 changes: 128 additions & 0 deletions crates/lash-types/src/text.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
19 changes: 18 additions & 1 deletion devlog.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Loading