diff --git a/crates/styled-str/src/tests.rs b/crates/styled-str/src/tests.rs index 7a2e7089..9d560d6f 100644 --- a/crates/styled-str/src/tests.rs +++ b/crates/styled-str/src/tests.rs @@ -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); +} diff --git a/crates/styled-str/src/types/slice.rs b/crates/styled-str/src/types/slice.rs index 2e278286..20dc9dee 100644 --- a/crates/styled-str/src/types/slice.rs +++ b/crates/styled-str/src/types/slice.rs @@ -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, diff --git a/crates/styled-str/src/types/spans.rs b/crates/styled-str/src/types/spans.rs index f63a092f..0b064894 100644 --- a/crates/styled-str/src/types/spans.rs +++ b/crates/styled-str/src/types/spans.rs @@ -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. diff --git a/crates/styled-str/src/types/str.rs b/crates/styled-str/src/types/str.rs index 1cc52a9a..530d4255 100644 --- a/crates/styled-str/src/types/str.rs +++ b/crates/styled-str/src/types/str.rs @@ -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 { + 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 diff --git a/crates/styled-str/src/types/string.rs b/crates/styled-str/src/types/string.rs index 13ba1f28..cd20f805 100644 --- a/crates/styled-str/src/types/string.rs +++ b/crates/styled-str/src/types/string.rs @@ -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!( @@ -123,6 +124,20 @@ impl From for StyledStringBuilder { } } +impl ops::AddAssign