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
56 changes: 56 additions & 0 deletions crates/styled-str/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,3 +483,59 @@ fn debugging_strings() {
let debug = format!("{styled:?}");
assert_eq!(debug, r#""[[red on white!]]Hello,\n\"world[[italic]]!\"""#);
}

#[test]
fn starts_with_basics() {
let styled = styled!("[[green]]Hello, [[inverted]]world[[/]]!");
assert!(styled.starts_with(StyledStr::default()));
assert!(styled.starts_with(styled));

assert!(styled.starts_with(styled!("[[green]]Hell")));
assert!(!styled.starts_with(styled!("Hell")));
assert!(!styled.starts_with(styled!("[[red]]H")));
assert!(styled.starts_with(styled!("[[green]]Hello, ")));
assert!(styled.starts_with(styled!("[[green]]Hello, [[inverted]]w")));
assert!(!styled.starts_with(styled!("[[green]]Hello, w")));
assert!(styled.starts_with(styled!("[[green]]Hello, [[inverted]]world")));
}

#[test]
fn ends_with_basics() {
let styled = styled!("[[green]]Hello, [[inverted]]world[[/]]!");
assert!(styled.ends_with(StyledStr::default()));
assert!(styled.ends_with(styled));

assert!(styled.ends_with(styled!("!")));
assert!(!styled.ends_with(styled!("d!")));
assert!(!styled.ends_with(styled!("[[invert]]!")));
assert!(styled.ends_with(styled!("[[invert]]d[[/]]!")));
assert!(styled.ends_with(styled!("[[inverted]]world[[/]]!")));
assert!(!styled.ends_with(styled!("[[inverted]], world[[/]]!")));
assert!(styled.ends_with(styled!("[[green]]o, [[inverted]]world[[/]]!")));
}

#[test]
fn find_basics() {
let styled = styled!("[[green]]Hello, [[inverted]]world[[/]]!");
assert_eq!(styled.find(StyledStr::default()), Some(0));
assert_eq!(styled.find(styled!("H")), None);
assert_eq!(styled.find(styled!("[[red]]H")), None);

// Single span in needle
assert_eq!(styled.find(styled!("[[green]]l")), Some(2));
assert_eq!(styled.find(styled!("[[green]], ")), Some(5));
assert_eq!(styled.find(styled!("[[invert]]w")), Some(7));
assert_eq!(styled.find(styled!("[[inverted]]world!")), None);

// Multiple spans in needle
assert_eq!(styled.find(styled!("[[green]], [[invert]]w")), Some(5));
assert_eq!(styled.find(styled!("[[inverted]]world[[/]]!")), Some(7));
assert_eq!(styled.find(styled!("[[inverted]]world[[bold]]!")), None);
}

#[test]
fn find_proptest_regression() {
let haystack = styled!("ллллл[[bold]]ллллaaлaaлaaaлллaллaaлл[[/]]a[[bold]]aaл");
let needle = styled!("[[bold]]лa[[/]]a");
assert_eq!(haystack.find(needle), None);
}
34 changes: 34 additions & 0 deletions crates/styled-str/src/types/slice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,40 @@ impl<'a> SpansSlice<'a> {
}
}

pub(crate) fn start_with(&self, needle: &SpansSlice<'_>) -> bool {
let needle_len = needle.len();
if needle_len > self.len() {
return false;
}

needle
.iter()
.zip(self.iter())
.enumerate()
.all(|(i, (needle_span, this_span))| {
this_span.style == needle_span.style && {
let cmp = this_span.len.cmp(&needle_span.len);
cmp.is_eq() || (i + 1 == needle_len && cmp.is_gt())
}
})
}

pub(crate) fn end_with(&self, needle: &SpansSlice<'_>) -> bool {
let needle_len = needle.len();
if needle_len > self.len() {
return false;
}

needle.iter().rev().zip(self.iter().rev()).enumerate().all(
|(i, (needle_span, this_span))| {
this_span.style == needle_span.style && {
let cmp = this_span.len.cmp(&needle_span.len);
cmp.is_eq() || (i + 1 == needle_len && cmp.is_gt())
}
},
)
}

pub(crate) const fn split_at(&self, mid: usize) -> (Self, Self) {
assert!(
mid <= self.text_end - self.text_start,
Expand Down
4 changes: 4 additions & 0 deletions crates/styled-str/src/types/spans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ impl StyledSpan {
let new_len = self.len.get().checked_sub(sub).expect("length underflow");
self.len = NonZeroUsize::new(new_len).expect("length underflow");
}

pub(crate) fn can_contain(&self, needle: &Self) -> bool {
self.style == needle.style && self.len >= needle.len
}
}

/// Text with a uniform [`Style`] attached to it. Returned by the [`StyledStr::spans()`](crate::StyledStr::spans()) iterator.
Expand Down
129 changes: 129 additions & 0 deletions crates/styled-str/src/types/str.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,135 @@ impl<'a> StyledStr<'a> {
(start, end)
}

/// Checks whether this string starts with a `needle`, matching both its text and styling.
///
/// # Examples
///
/// ```
/// # use styled_str::styled;
/// let styled = styled!("[[green]]Hello, [[* bold]]world");
/// assert!(styled.starts_with(styled!("[[green]]Hello")));
/// // Styling is taken into account
/// assert!(!styled.starts_with(styled!("Hello")));
/// ```
pub fn starts_with(&self, needle: StyledStr<'_>) -> bool {
self.text.starts_with(needle.text) && self.spans.start_with(&needle.spans)
}

/// Checks whether this string ends with a `needle`, matching both its text and styling.
///
/// # Examples
///
/// ```
/// # use styled_str::styled;
/// let styled = styled!("[[green]]Hello, [[* bold]]world");
/// assert!(styled.ends_with(styled!("[[green bold]]ld")));
/// // Styling is taken into account
/// assert!(!styled.ends_with(styled!("world")));
/// ```
pub fn ends_with(&self, needle: StyledStr<'_>) -> bool {
self.text.ends_with(needle.text) && self.spans.end_with(&needle.spans)
}

/// Checks whether `needle` is contained in this string, matching both by text and styling.
///
/// # Examples
///
/// ```
/// # use styled_str::styled;
/// let styled = styled!("[[green]]Hello, [[* bold]]world");
/// assert!(styled.contains(styled!("[[green]]lo, [[* bold]]w")));
/// assert!(!styled.contains(styled!("lo")));
/// ```
pub fn contains(&self, needle: StyledStr<'_>) -> bool {
self.find(needle).is_some()
}

/// Finds the first byte position of `needle` in this string from the string start, matching both by text and styling.
///
/// # Examples
///
/// ```
/// # use styled_str::styled;
/// let styled = styled!("[[green]]Hello, [[* bold]]world");
/// assert_eq!(
/// styled.find(styled!("[[green]]lo, [[* bold]]w")),
/// Some(3)
/// );
/// assert_eq!(styled.find(styled!("lo")), None);
/// ```
#[allow(clippy::missing_panics_doc)] // Internal check that should never be triggered
pub fn find(&self, needle: StyledStr<'_>) -> Option<usize> {
let Some(first_needle_span) = needle.spans.iter().next() else {
// `needle` is empty
return Some(0);
};
let needle_has_multiple_spans = needle.spans.len() > 1;

let mut text_matched_on_prev_iteration = false;
let mut start_pos = 0;
loop {
// First, find a candidate by styling by considering the starting span.
// This is efficient if the styled string doesn't contain many styles.
let spans_suffix = self
.spans
.get_by_text_range((ops::Bound::Included(start_pos), ops::Bound::Unbounded));
let offset_by_spans = spans_suffix.iter().find_map(|span| {
span.can_contain(&first_needle_span).then(|| {
let mut offset = span.start;
if needle_has_multiple_spans {
// Need to align the span end.
offset += span.len.get() - first_needle_span.len.get();
}
offset
})
});
let Some(offset_by_spans) = offset_by_spans else {
// No matching style spans
return None;
};
start_pos += offset_by_spans;
// We cannot guarantee that `start_pos` is at the char boundary, and the code below demands it.
start_pos = utils::ceil_char_boundary(self.text.as_bytes(), start_pos);

let offset_by_text = if offset_by_spans == 0 && text_matched_on_prev_iteration {
// Can reuse the text match found on the previous iteration
0
} else {
let Some(offset_by_text) = self.text[start_pos..].find(needle.text) else {
// No text mentions
return None;
};
offset_by_text
};
start_pos += offset_by_text;

if offset_by_text == 0 {
// The text match *may* correspond to the style match; check the spans slice completely.
let range = (
ops::Bound::Included(start_pos),
ops::Bound::Excluded(start_pos + needle.text.len()),
);
let spans_slice = self.spans.get_by_text_range(range);
if spans_slice == needle.spans {
return Some(start_pos);
}

// We guarantee that the first style span matches, so this case can only happen if the needle
// contains multiple spans. Because the first and second span styles differ, we can advance
// the search position by first + second span lengths (i.e., at least 2).
assert!(needle_has_multiple_spans);
let second_span_len = needle.spans.get(1).unwrap().len;
let offset = first_needle_span.len.get() + second_span_len.get();
start_pos += offset;
start_pos = utils::ceil_char_boundary(self.text.as_bytes(), start_pos);
}
// Otherwise, the text match is somewhere after the found style match, so we refine the style match
// on the next loop iteration.
text_matched_on_prev_iteration = offset_by_text != 0;
}
}

/// Iterates over spans contained in this string.
///
/// # Examples
Expand Down
19 changes: 17 additions & 2 deletions crates/styled-str/src/types/string.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ use crate::{
/// builder.push_style(AnsiColor::BrightGreen.on(AnsiColor::White).bold());
/// builder.push_text("Hello");
/// builder.push_text(",");
/// builder.push_style(Style::new());
/// // It's possible to use `+=` as syntactic sugar for `push_str()` / `push_style()`.
/// builder += Style::new();
/// builder.push_text(" world");
/// builder.push_str(styled!("[[it, dim]]!"));
/// builder += styled!("[[it, dim]]!");
///
/// let s = builder.build();
/// assert_eq!(
Expand Down Expand Up @@ -123,6 +124,20 @@ impl From<StyledString> for StyledStringBuilder {
}
}

impl ops::AddAssign<Style> for StyledStringBuilder {
fn add_assign(&mut self, rhs: Style) {
self.push_style(rhs);
}
}

impl ops::AddAssign<StyledStr<'_>> for StyledStringBuilder {
fn add_assign(&mut self, rhs: StyledStr<'_>) {
self.push_str(rhs);
}
}

// We don't implement `AddAssign<&str>` for `StyledStringBuilder` to avoid ambiguity what the string represents.

/// Heap-allocated styled string.
///
/// `StyledString` represents the owned string variant in contrast to [`StyledStr`], which is borrowed.
Expand Down
2 changes: 1 addition & 1 deletion crates/styled-str/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ const fn is_same_color(lhs: Color, rhs: Color) -> bool {
const UTF8_CONTINUATION_MASK: u8 = 0b1100_0000;
const UTF8_CONTINUATION_MARKER: u8 = 0b1000_0000;

const fn ceil_char_boundary(bytes: &[u8], mut pos: usize) -> usize {
pub(crate) const fn ceil_char_boundary(bytes: &[u8], mut pos: usize) -> usize {
assert!(pos <= bytes.len());

while pos < bytes.len() && bytes[pos] & UTF8_CONTINUATION_MASK == UTF8_CONTINUATION_MARKER {
Expand Down
Loading
Loading