From 7cd0308d869465e4fa082d9175893e1162d0e46a Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 25 Jul 2026 16:06:09 -0700 Subject: [PATCH 1/3] feat(hm-common): add string utils (ellipsis, align, escape) Port budget-aware, display-width/grapheme-correct string helpers from atuin (crates/atuin-common/src/string/, MIT) into a new hm_common::string module: - ellipsis: EllipsizeExt (ellipsize/pad_ellipsize), Indicator, Pos, Measure - align: AlignExt::pad_to, Alignment - escape: EscapeNonPrintablePosixExt (cat -v control-char escaping) Always compiled here (upstream gates align/ellipsis behind a `unicode` feature). Tests ported and adapted to repo conventions: rstest over bare #[test], std assert_eq in place of pretty_assertions. Adds unicode-width and unicode-segmentation workspace deps. Also point CLAUDE.md's shared-utilities crate at hm-common as the source of truth. --- CLAUDE.md | 2 +- Cargo.lock | 2 + Cargo.toml | 2 + crates/hm-common/Cargo.toml | 2 + crates/hm-common/src/lib.rs | 1 + crates/hm-common/src/string.rs | 43 ++ crates/hm-common/src/string/align.rs | 99 ++++ crates/hm-common/src/string/ellipsis.rs | 690 ++++++++++++++++++++++++ crates/hm-common/src/string/escape.rs | 121 +++++ 9 files changed, 961 insertions(+), 1 deletion(-) create mode 100644 crates/hm-common/src/string.rs create mode 100644 crates/hm-common/src/string/align.rs create mode 100644 crates/hm-common/src/string/ellipsis.rs create mode 100644 crates/hm-common/src/string/escape.rs diff --git a/CLAUDE.md b/CLAUDE.md index 9daa33df..4f684846 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ The `cli/` directory is a Cargo workspace. - `crates/hm-exec/` — the `ExecutionBackend` trait + `LocalDockerBackend` (in-process Docker DAG scheduler) + `CloudBackend` (submit+watch over the SDK). The `hm` binary renders the emitted `BuildEvent` stream (via `hm-render`) and owns Ctrl-C; auth is injected (the crate takes a built `HarmontClient`). - `crates/hm-render/` — `drive_stream`: consumes an `EventStream` and writes terminal/JSON output. No I/O beyond stdout. - `crates/hm-pipeline-ir/` — pipeline IR schema (serde structs only, no runtime). -- `crates/hm-util/` — shared OS and filesystem utilities. +- `crates/hm-common/` — shared utilities (OS/filesystem, formatting, and other cross-crate helpers). This is the source of truth for common code; prefer adding shared helpers here. - `crates/hm-plugin-protocol/` — wire types (serde structs only). - `crates/hm-plugin-sdk/` — authoring SDK for plugin writers. Run `cargo build` from the workspace root. diff --git a/Cargo.lock b/Cargo.lock index bceb2f8b..17b3e148 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1266,6 +1266,8 @@ dependencies = [ "tempfile", "tokio", "tracing", + "unicode-segmentation", + "unicode-width", "which 7.0.3", ] diff --git a/Cargo.toml b/Cargo.toml index 07b87c09..3205fb11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "registry"] } rstest = "0.23" proptest = "1" +unicode-width = "0.2" +unicode-segmentation = "1" [workspace.lints.rust] unsafe_code = "deny" diff --git a/crates/hm-common/Cargo.toml b/crates/hm-common/Cargo.toml index 0e6cd49a..d658751c 100644 --- a/crates/hm-common/Cargo.toml +++ b/crates/hm-common/Cargo.toml @@ -21,6 +21,8 @@ include_dir = "0.7" tempfile = "3" tokio = { version = "1", features = ["process", "fs"] } tracing = "0.1" +unicode-width = { workspace = true } +unicode-segmentation = { workspace = true } which = "7" [dev-dependencies] diff --git a/crates/hm-common/src/lib.rs b/crates/hm-common/src/lib.rs index df5bdfbd..cae56868 100644 --- a/crates/hm-common/src/lib.rs +++ b/crates/hm-common/src/lib.rs @@ -2,3 +2,4 @@ pub mod format; pub mod fs; +pub mod string; diff --git a/crates/hm-common/src/string.rs b/crates/hm-common/src/string.rs new file mode 100644 index 00000000..013ca65b --- /dev/null +++ b/crates/hm-common/src/string.rs @@ -0,0 +1,43 @@ +//! String-related utilities and extension traits. +//! +//! [`align`] and [`ellipsis`] are ported from atuin +//! (`crates/atuin-common/src/string/`), MIT-licensed. See +//! . Upstream gates these behind a `unicode` +//! Cargo feature; here they are always compiled. + +pub mod align; +pub mod ellipsis; +pub mod escape; + +pub use align::{AlignExt, Alignment}; +pub use ellipsis::{EllipsizeExt, Ellipsized, Indicator, Pos}; +pub use escape::EscapeNonPrintablePosixExt; + +use unicode_width::UnicodeWidthStr; + +/// How much room to truncate or pad into, and the unit it is measured in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Measure { + /// A UTF-8 byte budget. + Bytes(usize), + /// A display-column budget via `unicode-width` - a double-width glyph such + /// as `世` or `🦀` counts as two. Use for presentation. + Columns(usize), +} + +impl Measure { + /// The numeric limit, in this budget's own unit. + pub(crate) const fn amount(self) -> usize { + match self { + Self::Bytes(n) | Self::Columns(n) => n, + } + } + + /// Total cost of `s` in this budget's unit. + pub(crate) fn cost(self, s: &str) -> usize { + match self { + Self::Bytes(_) => s.len(), + Self::Columns(_) => s.width(), + } + } +} diff --git a/crates/hm-common/src/string/align.rs b/crates/hm-common/src/string/align.rs new file mode 100644 index 00000000..d2186754 --- /dev/null +++ b/crates/hm-common/src/string/align.rs @@ -0,0 +1,99 @@ +//! Extension trait for padding a string to a budget with an alignment. +//! +//! Ported from atuin (`crates/atuin-common/src/string/align.rs`), MIT-licensed. +//! Upstream: . + +use std::borrow::Cow; + +use super::Measure; + +/// Which side to pad toward when the string is shorter than the budget. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Alignment { + /// Content flush to the start, padding on the end (left-aligned). + Start, + /// Content centered, padding split across both sides. + Center, + /// Content flush to the end, padding on the start (right-aligned). + End, +} + +pub trait AlignExt: AsRef { + /// Pad `self` with spaces to fill `budget`, distributing the padding per `align`. + /// + /// Does not truncate. + fn pad_to(&self, budget: Measure, align: Alignment) -> Cow<'_, str> { + let s = self.as_ref(); + let pad = budget.amount().saturating_sub(budget.cost(s)); + if pad == 0 { + return Cow::Borrowed(s); + } + // Split the padding across the two sides per `align`. + let (left, right) = match align { + Alignment::Start => (0, pad), + Alignment::End => (pad, 0), + Alignment::Center => (pad / 2, pad - pad / 2), + }; + let mut out = String::with_capacity(s.len() + pad); + for _ in 0..left { + out.push(' '); + } + out.push_str(s); + for _ in 0..right { + out.push(' '); + } + Cow::Owned(out) + } +} + +impl> AlignExt for T {} + +#[cfg(test)] +mod tests { + use super::{AlignExt, Alignment}; + use crate::string::Measure; + use rstest::rstest; + + #[rstest] + #[case::start_pads_on_the_end("hi", Measure::Columns(5), Alignment::Start, "hi ")] + #[case::end_pads_on_the_start("hi", Measure::Columns(5), Alignment::End, " hi")] + #[case::center_splits_evenly("hi", Measure::Columns(6), Alignment::Center, " hi ")] + #[case::center_odd_extra_on_right("hi", Measure::Columns(5), Alignment::Center, " hi ")] + #[case::exact_fit_unchanged("hello", Measure::Columns(5), Alignment::Start, "hello")] + #[case::empty_pads("", Measure::Columns(3), Alignment::End, " ")] + #[case::wide_glyph_pads_by_display_columns("世", Measure::Columns(3), Alignment::Start, "世 ")] + #[case::pads_by_bytes_under_byte_budget("世", Measure::Bytes(4), Alignment::Start, "世 ")] + #[case::too_wide_is_never_truncated( + "hello world", + Measure::Columns(3), + Alignment::Start, + "hello world" + )] + fn pads_per_table( + #[case] input: &str, + #[case] budget: Measure, + #[case] align: Alignment, + #[case] expected: &str, + ) { + assert_eq!(input.pad_to(budget, align).as_ref(), expected); + } + + #[rstest] + fn borrows_only_when_no_padding_needed() { + // Exact fit: no padding -> borrowed. + assert!(matches!( + "hello".pad_to(Measure::Columns(5), Alignment::Start), + std::borrow::Cow::Borrowed(_) + )); + // Too wide: not truncated, no padding -> borrowed. + assert!(matches!( + "hello world".pad_to(Measure::Columns(3), Alignment::Start), + std::borrow::Cow::Borrowed(_) + )); + // Padding needed -> owned. + assert!(matches!( + "hi".pad_to(Measure::Columns(5), Alignment::Start), + std::borrow::Cow::Owned(_) + )); + } +} diff --git a/crates/hm-common/src/string/ellipsis.rs b/crates/hm-common/src/string/ellipsis.rs new file mode 100644 index 00000000..9f362cca --- /dev/null +++ b/crates/hm-common/src/string/ellipsis.rs @@ -0,0 +1,690 @@ +//! Extension trait for truncating a string to a budget (display columns or +//! bytes) with an ellipsis. +//! +//! Ported from atuin (`crates/atuin-common/src/string/ellipsis.rs`), +//! MIT-licensed. Upstream: . + +use std::borrow::Cow; +use std::fmt; + +use unicode_segmentation::UnicodeSegmentation; + +use super::Measure; +use super::align::{AlignExt, Alignment}; + +/// Which side of the string to elide when it does not fit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pos { + /// Keep the tail, elide the head: `…orld`. + Start, + /// Keep both ends, elide the middle: `he…ld`. + Middle, + /// Keep the head, elide the tail: `hello…`. + End, +} + +/// The marker spliced in where content was dropped: [`Indicator::ASCII`] +/// (`...`), [`Indicator::UNICODE`] (`…`), or any custom string via +/// [`Indicator::new`] (e.g. `[output truncated]`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::AsRef)] +pub struct Indicator<'a>(#[as_ref(str)] &'a str); + +impl<'a> Indicator<'a> { + /// Three ASCII periods `...` (3 columns, 3 bytes). + pub const ASCII: Self = Self("..."); + /// The single Unicode ellipsis `…` (U+2026): 1 column, 3 bytes. + pub const UNICODE: Self = Self("…"); + + /// Wrap an arbitrary marker string. + #[must_use] + pub const fn new(marker: &'a str) -> Self { + Self(marker) + } +} + +impl Default for Indicator<'_> { + /// The Unicode ellipsis `…`. + fn default() -> Self { + Self::UNICODE + } +} + +pub trait EllipsizeExt: AsRef { + /// Truncate this string to fit within `budget`, splicing in `indicator` on + /// `side` if any content had to be dropped. Returns a lazy [`Ellipsized`] + /// view - no allocation until you ask for an owned string. + fn ellipsize<'a>( + &'a self, + budget: Measure, + side: Pos, + indicator: Indicator<'a>, + ) -> Ellipsized<'a> { + let s = self.as_ref(); + let amount = budget.amount(); + let len = s.len(); + let cost = |seg: &str| budget.cost(seg); + + if cost(s) <= amount { + return Ellipsized::contiguous(s, 0); + } + + // Not enough room for the indicator itself: hard-truncate to a bare, unmarked slice. + let indicator_cost = cost(indicator.as_ref()); + if amount < indicator_cost { + return match side { + Pos::Start => { + let start = suffix_boundary(s, amount, cost); + Ellipsized::contiguous(&s[start..], start) + } + Pos::Middle | Pos::End => { + Ellipsized::contiguous(&s[..prefix_boundary(s, amount, cost)], 0) + } + }; + } + + let content = amount - indicator_cost; + match side { + Pos::End => Ellipsized::spliced(s, prefix_boundary(s, content, cost), len, indicator), + Pos::Start => Ellipsized::spliced(s, 0, suffix_boundary(s, content, cost), indicator), + Pos::Middle => { + let end = prefix_boundary(s, content.div_ceil(2), cost); + let start = suffix_boundary(s, content / 2, cost).max(end); + Ellipsized::spliced(s, end, start, indicator) + } + } + } + + /// Fit `self` to `budget`: ellipsize it on `side` with `indicator` when it exceeds the budget, + /// otherwise pad it with spaces, aligned per `align`. + fn pad_ellipsize<'a>( + &'a self, + budget: Measure, + side: Pos, + indicator: Indicator<'a>, + align: Alignment, + ) -> Cow<'a, str> + where + Self: Sized, + { + if budget.cost(self.as_ref()) > budget.amount() { + self.ellipsize(budget, side, indicator).into() + } else { + self.pad_to(budget, align) + } + } +} + +impl> EllipsizeExt for T {} + +/// A budget-truncated view of a source string - either a single contiguous +/// slice (it fit, or there was no room even for the indicator) or a head + +/// indicator + tail with content elided between them. +/// +/// Cheap `Copy`. `Display` writes the pieces straight to the formatter. +#[derive(Debug, Clone, Copy)] +pub struct Ellipsized<'a>(Repr<'a>); + +#[derive(Debug, Clone, Copy)] +enum Repr<'a> { + /// The whole result is one contiguous slice of the source; `source_offset` + /// is the slice's byte offset in the source. + Contiguous { text: &'a str, source_offset: usize }, + /// A head slice, an indicator, and a tail slice - always with a marker. + Spliced { + head: &'a str, + indicator: Indicator<'a>, + tail: &'a str, + /// Byte offset of `tail` in the source, for [`Ellipsized::source_index`]. + tail_source_offset: usize, + }, +} + +impl<'a> Ellipsized<'a> { + const fn contiguous(text: &'a str, source_offset: usize) -> Self { + Self(Repr::Contiguous { + text, + source_offset, + }) + } + + fn spliced( + source: &'a str, + head_end: usize, + tail_start: usize, + indicator: Indicator<'a>, + ) -> Self { + Self(Repr::Spliced { + head: &source[..head_end], + tail: &source[tail_start..], + tail_source_offset: tail_start, + indicator, + }) + } + + /// Map a byte offset in the output back to its byte offset in the source, + /// or `None` if it lands on the spliced indicator. + #[must_use] + pub fn source_index(self, output_byte: usize) -> Option { + match self.0 { + Repr::Contiguous { source_offset, .. } => Some(output_byte + source_offset), + Repr::Spliced { + head, + indicator, + tail_source_offset, + .. + } => { + let indicator_len = indicator.as_ref().len(); + if output_byte < head.len() { + Some(output_byte) + } else if output_byte >= head.len() + indicator_len { + Some(output_byte - head.len() - indicator_len + tail_source_offset) + } else { + None + } + } + } + } +} + +impl fmt::Display for Ellipsized<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + Repr::Contiguous { text, .. } => f.write_str(text), + Repr::Spliced { + head, + indicator, + tail, + .. + } => { + f.write_str(head)?; + f.write_str(indicator.as_ref())?; + f.write_str(tail) + } + } + } +} + +impl<'a> From> for Cow<'a, str> { + /// Borrowed for a contiguous slice; owned only when an indicator is spliced. + fn from(ellipsized: Ellipsized<'a>) -> Self { + match ellipsized.0 { + Repr::Contiguous { text, .. } => Cow::Borrowed(text), + Repr::Spliced { .. } => Cow::Owned(ellipsized.to_string()), + } + } +} + +impl PartialEq for Ellipsized<'_> { + /// Compares against the concatenated output without allocating. + fn eq(&self, other: &str) -> bool { + match self.0 { + Repr::Contiguous { text, .. } => text == other, + Repr::Spliced { + head, + indicator, + tail, + .. + } => [head, indicator.as_ref(), tail] + .into_iter() + .try_fold(other, |rest, piece| rest.strip_prefix(piece)) + .is_some_and(str::is_empty), + } + } +} + +impl PartialEq<&str> for Ellipsized<'_> { + fn eq(&self, other: &&str) -> bool { + self == *other + } +} + +/// Total byte length of the longest leading run of `graphemes` whose summed +/// `cost` is at most `max`. +fn fitting_bytes<'a>( + graphemes: impl Iterator, + max: usize, + cost: impl Fn(&str) -> usize, +) -> usize { + let mut used = 0; + let mut bytes = 0; + for seg in graphemes { + let seg_cost = cost(seg); + if used + seg_cost > max { + break; + } + used += seg_cost; + bytes += seg.len(); + } + bytes +} + +/// Byte end index of the longest prefix of `s` whose summed per-grapheme cost is +/// at most `max`. +fn prefix_boundary(s: &str, max: usize, cost: impl Fn(&str) -> usize) -> usize { + fitting_bytes(s.graphemes(true), max, cost) +} + +/// Byte start index of the longest suffix of `s` whose summed per-grapheme cost +/// is at most `max`. +fn suffix_boundary(s: &str, max: usize, cost: impl Fn(&str) -> usize) -> usize { + s.len() - fitting_bytes(s.graphemes(true).rev(), max, cost) +} + +#[cfg(test)] +mod tests { + use super::{EllipsizeExt, Indicator, Measure, Pos}; + use crate::string::align::Alignment; + use proptest::prelude::*; + use rstest::rstest; + use unicode_width::UnicodeWidthStr; + + fn amount(b: Measure) -> usize { + match b { + Measure::Bytes(n) | Measure::Columns(n) => n, + } + } + + fn cost(b: Measure, s: &str) -> usize { + match b { + Measure::Bytes(_) => s.len(), + Measure::Columns(_) => UnicodeWidthStr::width(s), + } + } + + #[rstest] + #[case::ascii_fits_under_column_budget( + "hello", + Measure::Columns(10), + Pos::End, + Indicator::ASCII, + "hello" + )] + #[case::ascii_exactly_fits_column_budget( + "hello", + Measure::Columns(5), + Pos::End, + Indicator::ASCII, + "hello" + )] + #[case::ascii_truncates_end_with_ascii_indicator( + "hello world", + Measure::Columns(8), + Pos::End, + Indicator::ASCII, + "hello..." + )] + #[case::ascii_truncates_start_with_ascii_indicator( + "hello world", + Measure::Columns(8), + Pos::Start, + Indicator::ASCII, + "...world" + )] + #[case::ascii_truncates_middle_with_ascii_indicator( + "hello world", + Measure::Columns(7), + Pos::Middle, + Indicator::ASCII, + "he...ld" + )] + #[case::ascii_truncates_end_with_unicode_indicator( + "hello world", + Measure::Columns(6), + Pos::End, + Indicator::UNICODE, + "hello…" + )] + #[case::ascii_truncates_start_with_unicode_indicator( + "hello world", + Measure::Columns(6), + Pos::Start, + Indicator::UNICODE, + "…world" + )] + #[case::cjk_truncates_under_column_budget( + "你好世界", + Measure::Columns(5), + Pos::End, + Indicator::ASCII, + "你..." + )] + #[case::cjk_truncates_to_indicator_only_under_tiny_column_budget( + "你好世界", + Measure::Columns(4), + Pos::End, + Indicator::ASCII, + "..." + )] + #[case::cjk_exactly_fits_column_budget( + "你好世界", + Measure::Columns(8), + Pos::End, + Indicator::ASCII, + "你好世界" + )] + #[case::emoji_truncates_end_under_column_budget_with_unicode_indicator( + "🐢🦀🐢🦀", + Measure::Columns(5), + Pos::End, + Indicator::UNICODE, + "🐢🦀…" + )] + #[case::emoji_exactly_fits_column_budget( + "🐢🦀🐢🦀", + Measure::Columns(8), + Pos::End, + Indicator::UNICODE, + "🐢🦀🐢🦀" + )] + #[case::ascii_hard_truncates_end_when_budget_below_indicator_cost( + "hello", + Measure::Columns(2), + Pos::End, + Indicator::ASCII, + "he" + )] + #[case::ascii_hard_truncates_start_when_budget_below_indicator_cost( + "hello", + Measure::Columns(2), + Pos::Start, + Indicator::ASCII, + "lo" + )] + #[case::ascii_zero_budget_yields_empty_string( + "hello", + Measure::Columns(0), + Pos::End, + Indicator::ASCII, + "" + )] + #[case::empty_input_yields_empty_string( + "", + Measure::Columns(5), + Pos::End, + Indicator::ASCII, + "" + )] + #[case::ascii_truncates_end_under_byte_budget( + "hello world", + Measure::Bytes(8), + Pos::End, + Indicator::ASCII, + "hello..." + )] + #[case::ascii_truncates_end_under_byte_budget_with_unicode_indicator( + "hello world", + Measure::Bytes(8), + Pos::End, + Indicator::UNICODE, + "hello…" + )] + #[case::accented_truncates_end_under_byte_budget( + "café", + Measure::Bytes(4), + Pos::End, + Indicator::ASCII, + "c..." + )] + #[case::accented_exactly_fits_byte_budget( + "café", + Measure::Bytes(5), + Pos::End, + Indicator::ASCII, + "café" + )] + #[case::cjk_truncates_to_indicator_only_under_byte_budget( + "你好", + Measure::Bytes(5), + Pos::End, + Indicator::ASCII, + "..." + )] + fn truncates_per_table( + #[case] input: &str, + #[case] budget: Measure, + #[case] side: Pos, + #[case] ellipsis: Indicator<'static>, + #[case] expected: &str, + ) { + assert_eq!(input.ellipsize(budget, side, ellipsis), *expected); + } + + #[rstest] + #[case::start_pads_when_shorter("hi", Measure::Columns(5), Pos::End, Alignment::Start, "hi ")] + #[case::unchanged_when_exact_budget( + "hello", + Measure::Columns(5), + Pos::End, + Alignment::Start, + "hello" + )] + #[case::ellipsizes_end_when_too_wide( + "hello world", + Measure::Columns(6), + Pos::End, + Alignment::Start, + "hello…" + )] + #[case::ellipsizes_start_when_too_wide( + "hello world", + Measure::Columns(6), + Pos::Start, + Alignment::Start, + "…world" + )] + #[case::empty_pads_to_budget("", Measure::Columns(3), Pos::End, Alignment::Start, " ")] + #[case::wide_glyph_exact_column_budget( + "世", + Measure::Columns(2), + Pos::End, + Alignment::Start, + "世" + )] + #[case::wide_glyph_pads_by_display_columns( + "世", + Measure::Columns(3), + Pos::End, + Alignment::Start, + "世 " + )] + #[case::pads_by_bytes_under_byte_budget( + "世", + Measure::Bytes(4), + Pos::End, + Alignment::Start, + "世 " + )] + #[case::end_align_left_pads("hi", Measure::Columns(5), Pos::End, Alignment::End, " hi")] + #[case::center_even_split("hi", Measure::Columns(6), Pos::End, Alignment::Center, " hi ")] + #[case::center_odd_extra_on_right( + "hi", + Measure::Columns(5), + Pos::End, + Alignment::Center, + " hi " + )] + #[case::align_ignored_when_elided( + "hello world", + Measure::Columns(6), + Pos::End, + Alignment::End, + "hello…" + )] + fn pad_ellipsize_table( + #[case] input: &str, + #[case] budget: Measure, + #[case] side: Pos, + #[case] align: Alignment, + #[case] expected: &str, + ) { + assert_eq!( + input + .pad_ellipsize(budget, side, Indicator::UNICODE, align) + .as_ref(), + expected + ); + } + + #[rstest] + fn pad_ellipsize_borrows_only_when_no_alloc_needed() { + // Exact fit: no padding, no elision -> borrowed. + assert!(matches!( + "hello".pad_ellipsize( + Measure::Columns(5), + Pos::End, + Indicator::UNICODE, + Alignment::Start + ), + std::borrow::Cow::Borrowed(_) + )); + // Padding needed -> owned. + assert!(matches!( + "hi".pad_ellipsize( + Measure::Columns(5), + Pos::End, + Indicator::UNICODE, + Alignment::Start + ), + std::borrow::Cow::Owned(_) + )); + } + + fn any_pos() -> impl Strategy { + prop_oneof![Just(Pos::Start), Just(Pos::Middle), Just(Pos::End)] + } + + fn any_indicator() -> impl Strategy> { + prop_oneof![Just(Indicator::ASCII), Just(Indicator::UNICODE)] + } + + fn any_budget() -> impl Strategy { + prop_oneof![ + (0usize..40).prop_map(Measure::Bytes), + (0usize..40).prop_map(Measure::Columns), + ] + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(2048))] + + #[test] + fn never_overflows( + s in r"(?s).*", + budget in any_budget(), + side in any_pos(), + ellipsis in any_indicator(), + ) { + let out = s.ellipsize(budget, side, ellipsis).to_string(); + prop_assert!(cost(budget, out.as_ref()) <= amount(budget)); + } + + #[test] + fn borrowed_when_it_fits( + s in r"(?s).*", + budget in any_budget(), + side in any_pos(), + ellipsis in any_indicator(), + ) { + if cost(budget, &s) <= amount(budget) { + let result = s.ellipsize(budget, side, ellipsis); + prop_assert!(matches!( + std::borrow::Cow::from(result), + std::borrow::Cow::Borrowed(_) + )); + prop_assert!(result == s.as_str()); + } + } + + #[test] + fn never_grows( + s in r"(?s).*", + budget in any_budget(), + side in any_pos(), + ellipsis in any_indicator(), + ) { + let out = s.ellipsize(budget, side, ellipsis).to_string(); + prop_assert!(cost(budget, out.as_ref()) <= cost(budget, &s)); + } + + #[test] + fn valid_byte_cut( + s in r"(?s).*", + n in 0usize..40, + side in any_pos(), + ellipsis in any_indicator(), + ) { + let out = s.ellipsize(Measure::Bytes(n), side, ellipsis).to_string(); + prop_assert!(out.len() <= n); + } + + #[test] + fn ellipsis_present_when_needed( + s in r"(?s).*", + budget in any_budget(), + side in any_pos(), + ellipsis in any_indicator(), + ) { + let glyph = ellipsis.as_ref(); + let ellipsis_cost = cost(budget, glyph); + if cost(budget, &s) > amount(budget) && amount(budget) >= ellipsis_cost { + let out = s.ellipsize(budget, side, ellipsis).to_string(); + prop_assert!(out.contains(glyph)); + } + } + + #[test] + fn source_index_round_trips( + s in r"(?s).*", + budget in any_budget(), + side in any_pos(), + indicator in any_indicator(), + ) { + let e = s.ellipsize(budget, side, indicator); + let out = e.to_string(); + for (i, ch) in out.char_indices() { + if let Some(j) = e.source_index(i) { + prop_assert_eq!(s[j..].chars().next(), Some(ch)); + } + } + } + } + + #[rstest] + fn source_index_middle_maps_head_gap_tail() { + let e = "hello world".ellipsize(Measure::Columns(7), Pos::Middle, Indicator::ASCII); + assert_eq!(e.to_string(), "he...ld"); + assert_eq!(e.source_index(0), Some(0)); + assert_eq!(e.source_index(1), Some(1)); + assert_eq!(e.source_index(2), None); + assert_eq!(e.source_index(4), None); + assert_eq!(e.source_index(5), Some(9)); + assert_eq!(e.source_index(6), Some(10)); + } + + #[rstest] + fn source_index_fits_is_identity() { + let e = "hi".ellipsize(Measure::Columns(10), Pos::Middle, Indicator::ASCII); + assert!(matches!( + std::borrow::Cow::from(e), + std::borrow::Cow::Borrowed(_) + )); + assert_eq!(e.source_index(0), Some(0)); + assert_eq!(e.source_index(1), Some(1)); + } + + #[rstest] + fn display_writes_without_allocating_via_cow() { + let e = "hello world".ellipsize(Measure::Columns(8), Pos::End, Indicator::ASCII); + assert_eq!(e.to_string(), "hello..."); + assert!(matches!( + std::borrow::Cow::from(e), + std::borrow::Cow::Owned(_) + )); + + let fits = "hi".ellipsize(Measure::Columns(8), Pos::End, Indicator::ASCII); + assert!(matches!( + std::borrow::Cow::from(fits), + std::borrow::Cow::Borrowed(_) + )); + } +} diff --git a/crates/hm-common/src/string/escape.rs b/crates/hm-common/src/string/escape.rs new file mode 100644 index 00000000..00087bb3 --- /dev/null +++ b/crates/hm-common/src/string/escape.rs @@ -0,0 +1,121 @@ +//! Escape control characters into a printable, `cat -v`-style representation. +//! +//! Ported from atuin +//! (`crates/atuin-common/src/string/escape_non_printable_posix_ext.rs`), +//! MIT-licensed. Upstream: . + +use std::borrow::Cow; + +/// Extension trait for anything that can behave like a string to make it easy to +/// escape control characters into a printable, `cat -v`-style representation. +/// +/// Intended to help prevent control characters being printed and interpreted by +/// the terminal when printing history as well as to ensure the commands that +/// appear in the interactive search reflect the actual command run rather than +/// just the printable characters. +/// +/// The representation is the POSIX caret/meta notation used by `cat -v`: +/// - C0 controls (`0x00..=0x1F`) and DEL (`0x7F`) become `^` followed by the +/// character xor'd with `0x40`, so NUL is `^@`, tab is `^I`, ESC is `^[`, and +/// DEL is `^?`. +/// - C1 controls (`0x80..=0x9F`) become `M-^` followed by their low 7 bits +/// xor'd with `0x40`, so U+009B (single-byte CSI) is `M-^[`. +/// +/// Everything else — including spaces and printable multi-byte Unicode — is +/// left untouched. +pub trait EscapeNonPrintablePosixExt: AsRef { + fn escape_non_printable(&self) -> Cow<'_, str> { + // Each character escapes to a (possibly empty) prefix followed by a + // single payload character, so every branch yields the same iterator + // type without any fixed-size padding. + let escape_char = |c: char| { + let (prefix, payload): (&str, char) = if c.is_ascii_control() { + // C0 controls and DEL: `^@`..`^_` and `^?`. + ("^", (c as u8 ^ 0x40) as char) + } else if c.is_control() { + // C1 controls (U+0080..=U+009F): `cat -v` meta+caret notation. + ("M-^", ((c as u8 & 0x7f) ^ 0x40) as char) + } else { + // Printable (including spaces and multi-byte Unicode): unchanged. + ("", c) + }; + + prefix.chars().chain(std::iter::once(payload)) + }; + + let s = self.as_ref(); + if !s.contains(|c: char| c.is_control()) { + return Cow::Borrowed(s); + } + + Cow::Owned(s.chars().flat_map(escape_char).collect()) + } +} + +impl> EscapeNonPrintablePosixExt for T {} + +#[cfg(test)] +#[allow(clippy::unwrap_used, reason = "test setup and assertions")] +mod tests { + use super::EscapeNonPrintablePosixExt; + use proptest::prelude::*; + use rstest::rstest; + use std::borrow::Cow; + + /// Table-driven examples of the exact `cat -v` mapping. Each case is its own + /// test, so a failure names precisely which input broke. + #[rstest] + // Nothing to escape — returned unchanged (space is printable). + #[case::plain_text_unchanged("plain text", "plain text")] + #[case::two_words_unchanged("two words", "two words")] + // Printable multi-byte Unicode is preserved; only the control char changes. + #[case::multibyte_unicode_preserved_around_escaped_control("🐢\x1b[32m🦀", "🐢^[[32m🦀")] + // C0 controls and DEL → caret notation (`^` + byte ^ 0x40). + #[case::esc_0x1b("\x1b[31mfoo", "^[[31mfoo")] // ESC (0x1b) + #[case::tab_0x09("foo\tbar", "foo^Ibar")] // TAB (0x09) + #[case::nul_0x00("a\0b", "a^@b")] // NUL (0x00) — the core of issue #3589 + #[case::del_0x7f("a\x7fb", "a^?b")] // DEL (0x7f ^ 0x40 == '?') + // C1 controls (U+0080..=U+009F) → `cat -v` meta+caret notation. + #[case::c1_control_0x80("\u{80}", "M-^@")] + #[case::single_char_csi_0x9b("a\u{9b}b", "aM-^[b")] // single-char CSI: 0x9b & 0x7f == 0x1b → ^[ + #[case::c1_control_0x9f("\u{9f}", "M-^_")] + fn escapes_as_cat_v(#[case] input: &str, #[case] expected: &str) { + assert_eq!(input.escape_non_printable(), expected); + } + + #[rstest] + fn escapes_all_c0_controls_as_caret() { + // Exhaustively check every C0 control 0x00..=0x1f → '^' + (byte ^ 0x40). + for byte in 0x00u8..=0x1f { + let input = (byte as char).to_string(); + let expected = format!("^{}", (byte ^ 0x40) as char); + assert_eq!(input.escape_non_printable(), expected, "byte {byte:#04x}"); + } + } + + proptest! { + /// The whole point of the function: for ANY input string, the escaped + /// output never contains a control character. + #[test] + fn output_never_contains_control_chars(s in r"(?s).*") { + let escaped = s.escape_non_printable(); + prop_assert!(!escaped.chars().any(char::is_control)); + } + + /// A string with no control characters is returned borrowed (zero-copy), + /// and is therefore byte-for-byte unchanged. + #[test] + fn control_free_input_is_borrowed(s in r"[^\p{Cc}]*") { + prop_assert!(matches!(s.escape_non_printable(), Cow::Borrowed(_))); + } + + /// Escaping is idempotent: the output is already fully printable, so + /// escaping it a second time changes nothing. + #[test] + fn escaping_is_idempotent(s in r"(?s).*") { + let once = s.escape_non_printable().into_owned(); + let twice = once.escape_non_printable().into_owned(); + prop_assert_eq!(once, twice); + } + } +} From 1de6f0d7c0f87956d6b35c2b6e948a296fe84b4a Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 25 Jul 2026 19:40:39 -0700 Subject: [PATCH 2/3] refactor: adopt hm-common string utils at display sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the new hm_common::string helpers where the codebase hand-rolled the same logic, fixing latent width/escaping bugs in the process: - scheduler: derive a step's display_name via EllipsizeExt instead of chars().take(39) + "…" — now budgets by display columns and cuts on grapheme boundaries. - progress summary: pad the step-name column with AlignExt::pad_to over a unicode-width budget instead of byte-length {: { let prefix = fmt_key(self.step_key(step_id), self.color); + // `line` is raw subprocess stdout/stderr: escape control chars so + // they can't drive the terminal (cursor moves, title changes, …). + let line = line.escape_non_printable(); format!("{prefix} {line}\n").into_bytes() } @@ -224,6 +228,32 @@ mod tests { assert_eq!(s, "[build] compiling...\n"); } + #[rstest] + #[case::escape_sequence("\x1b[2J", "^[[2J")] + #[case::carriage_return("a\rb", "a^Mb")] + #[case::nul_byte("a\0b", "a^@b")] + #[case::printable_untouched("plain text", "plain text")] + fn step_log_escapes_control_chars(#[case] raw: &str, #[case] expected: &str) { + let mut r = renderer(); + let step_id = Uuid::new_v4(); + + r.on_event(&BuildEvent::StepQueued { + step_id, + key: "build".into(), + chain_idx: 0, + parent_key: None, + display_name: "build".into(), + }); + r.on_event(&BuildEvent::StepLog { + step_id, + stream: StdStream::Stdout, + line: raw.into(), + ts: chrono::Utc::now(), + }); + + assert_eq!(output(&r), format!("[build] {expected}\n")); + } + #[rstest] fn step_log_unknown_key() { let mut r = renderer(); diff --git a/crates/hm-render/src/progress.rs b/crates/hm-render/src/progress.rs index 8c01b982..7a8eb66b 100644 --- a/crates/hm-render/src/progress.rs +++ b/crates/hm-render/src/progress.rs @@ -13,10 +13,12 @@ use std::io::Write; use std::time::Duration; use hm_common::format::CompactDuration as _; +use hm_common::string::{AlignExt as _, Alignment, EscapeNonPrintablePosixExt as _, Measure}; use hm_plugin_protocol::BuildEvent; use indicatif::ProgressStyle; use owo_colors::{OwoColorize, Style}; use tracing::{Span, info_span}; +use unicode_width::UnicodeWidthStr; /// Tracing target for TUI progress-bar spans. /// @@ -132,18 +134,22 @@ impl ProgressRenderer { ); if let Some(lines) = self.log_buffer.get(step_id) { for line in lines { - let _ = writeln!(self.out, "{line}"); + // Buffered log lines are raw subprocess output: escape control + // characters so replaying them can't drive the terminal. + let _ = writeln!(self.out, "{}", line.escape_non_printable()); } } } } fn print_step_summary(&mut self) { - let max_name_len = self + // Measure the column in display width, not bytes, so a step name with + // wide or multibyte glyphs still lines up with its neighbours. + let max_name_cols = self .step_order .iter() .filter_map(|id| self.step_names.get(id)) - .map(String::len) + .map(|name| UnicodeWidthStr::width(name.as_str())) .max() .unwrap_or(0); @@ -193,7 +199,8 @@ impl ProgressRenderer { styled("—", Style::new().dimmed(), self.color), ), }; - let _ = writeln!(self.out, " {indicator} {name: Date: Sat, 25 Jul 2026 19:49:04 -0700 Subject: [PATCH 3/3] deslop --- crates/hm-exec/src/local/scheduler.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/hm-exec/src/local/scheduler.rs b/crates/hm-exec/src/local/scheduler.rs index 481bfbaf..67846b71 100644 --- a/crates/hm-exec/src/local/scheduler.rs +++ b/crates/hm-exec/src/local/scheduler.rs @@ -391,9 +391,6 @@ async fn execute_step( let step_wire = transition.step; let step_key = step_wire.key.clone(); let display_name = step_wire.label.clone().unwrap_or_else(|| { - // Cap the command at 40 display columns, eliding the tail with `…`. - // `ellipsize` measures display width (double-width glyphs count as two) - // and cuts on grapheme boundaries, so no byte-boundary panics. step_wire .cmd .trim()