Skip to main content

styled_str/types/
str.rs

1//! `StyledStr`.
2
3use core::{fmt, ops};
4
5use anstyle::Style;
6
7use super::{slice::SpansSlice, spans::StyledSpan};
8use crate::{
9    Diff, Lines, RichStyle, SpanStr, StyleDiff, StyledString, TextDiff, rich_parser::EscapedText,
10    utils,
11};
12
13/// Borrowed ANSI-styled string.
14///
15/// A `StyledStr` instance consists of two parts:
16///
17/// - Original text (a `&str`).
18/// - A sequence of styled spans covering the text.
19///
20/// `StyledStr` represents the borrowed string variant in contrast to [`StyledString`], which is owned.
21/// A `StyledStr` can be parsed from [rich syntax](crate#rich-syntax) in compile time via the [`styled!`](crate::styled!) macro,
22/// or borrowed from a string using [`StyledString::as_str()`].
23///
24/// # Examples
25///
26/// See [crate-level docs](crate) for the examples of usage.
27#[derive(Clone, Copy, Default, PartialEq)]
28pub struct StyledStr<'a> {
29    pub(crate) text: &'a str,
30    pub(crate) spans: SpansSlice<'a>,
31}
32
33impl<'a> StyledStr<'a> {
34    /// Checks whether this string is empty.
35    pub const fn is_empty(&self) -> bool {
36        self.text.is_empty()
37    }
38
39    /// Checks whether this string is plain (doesn't include non-default styled spans).
40    ///
41    /// # Examples
42    ///
43    /// ```
44    /// # use styled_str::{StyledStr, styled};
45    /// assert!(StyledStr::default().is_plain());
46    /// assert!(styled!("Hello").is_plain());
47    /// assert!(!styled!("[[green]]Hello").is_plain());
48    /// ```
49    pub const fn is_plain(&self) -> bool {
50        if self.spans.len() > 1 {
51            return false;
52        }
53        match self.spans.get(0) {
54            None => true,
55            Some(span) => span.style.is_plain(),
56        }
57    }
58
59    /// Returns the unstyled text.
60    pub const fn text(&self) -> &'a str {
61        self.text
62    }
63
64    /// Diffs this against the `other` styled string.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if the styled strings differ either in text, or in applied styles.
69    pub fn diff(self, other: Self) -> Result<(), Diff<'a>> {
70        if self.text == other.text {
71            let style_diff = StyleDiff::new(self, other);
72            if style_diff.is_empty() {
73                Ok(())
74            } else {
75                Err(Diff::Style(style_diff))
76            }
77        } else {
78            Err(Diff::Text(TextDiff::new(self.text, other.text)))
79        }
80    }
81
82    /// Returns a slice of this string. This works similarly to [`str::get()`] and returns `None`
83    /// under the same conditions.
84    ///
85    /// # Examples
86    ///
87    /// ```
88    /// # use styled_str::styled;
89    /// let styled = styled!("[[green]]Hello, [[it]]world[[/]]!");
90    /// let slice = styled.get(3..=8).unwrap();
91    /// assert_eq!(slice, styled!("[[green]]lo, [[it]]wo"));
92    ///
93    /// let slice = styled.get(10..).unwrap();
94    /// assert_eq!(slice, styled!("[[it]]ld[[/]]!"));
95    /// ```
96    pub fn get(&self, range: impl ops::RangeBounds<usize>) -> Option<Self> {
97        let range = (
98            range.start_bound().map(|&val| val),
99            range.end_bound().map(|&val| val),
100        );
101        Some(Self {
102            text: self.text.get(range)?,
103            spans: self.spans.get_by_text_range(range),
104        })
105    }
106
107    /// Splits this string into two at the specified position.
108    ///
109    /// # Panics
110    ///
111    /// Panics in the same situations as [`str::split_at()`].
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// # use styled_str::styled;
117    /// let styled = styled!("[[green]]Hello, [[it]]world[[/]]!");
118    /// let (start, end) = styled.split_at(5);
119    /// assert_eq!(start, styled!("[[green]]Hello"));
120    /// assert_eq!(end, styled!("[[green]], [[it]]world[[/]]!"));
121    /// ```
122    pub const fn split_at(self, mid: usize) -> (Self, Self) {
123        let (start_text, end_text) = self.text.split_at(mid);
124        let (start_spans, end_spans) = self.spans.split_at(mid);
125        let start = Self {
126            text: start_text,
127            spans: start_spans,
128        };
129        let end = Self {
130            text: end_text,
131            spans: end_spans,
132        };
133        (start, end)
134    }
135
136    /// Checks whether this string starts with a `needle`, matching both its text and styling.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// # use styled_str::styled;
142    /// let styled = styled!("[[green]]Hello, [[* bold]]world");
143    /// assert!(styled.starts_with(styled!("[[green]]Hello")));
144    /// // Styling is taken into account
145    /// assert!(!styled.starts_with(styled!("Hello")));
146    /// ```
147    pub fn starts_with(&self, needle: StyledStr<'_>) -> bool {
148        self.text.starts_with(needle.text) && self.spans.start_with(&needle.spans)
149    }
150
151    /// Checks whether this string ends with a `needle`, matching both its text and styling.
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// # use styled_str::styled;
157    /// let styled = styled!("[[green]]Hello, [[* bold]]world");
158    /// assert!(styled.ends_with(styled!("[[green bold]]ld")));
159    /// // Styling is taken into account
160    /// assert!(!styled.ends_with(styled!("world")));
161    /// ```
162    pub fn ends_with(&self, needle: StyledStr<'_>) -> bool {
163        self.text.ends_with(needle.text) && self.spans.end_with(&needle.spans)
164    }
165
166    /// Checks whether `needle` is contained in this string, matching both by text and styling.
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// # use styled_str::styled;
172    /// let styled = styled!("[[green]]Hello, [[* bold]]world");
173    /// assert!(styled.contains(styled!("[[green]]lo, [[* bold]]w")));
174    /// assert!(!styled.contains(styled!("lo")));
175    /// ```
176    pub fn contains(&self, needle: StyledStr<'_>) -> bool {
177        self.find(needle).is_some()
178    }
179
180    /// Finds the first byte position of `needle` in this string from the string start, matching both by text and styling.
181    ///
182    /// # Examples
183    ///
184    /// ```
185    /// # use styled_str::styled;
186    /// let styled = styled!("[[green]]Hello, [[* bold]]world");
187    /// assert_eq!(
188    ///     styled.find(styled!("[[green]]lo, [[* bold]]w")),
189    ///     Some(3)
190    /// );
191    /// assert_eq!(styled.find(styled!("lo")), None);
192    /// ```
193    #[allow(clippy::missing_panics_doc)] // Internal check that should never be triggered
194    pub fn find(&self, needle: StyledStr<'_>) -> Option<usize> {
195        let Some(first_needle_span) = needle.spans.iter().next() else {
196            // `needle` is empty
197            return Some(0);
198        };
199        let needle_has_multiple_spans = needle.spans.len() > 1;
200
201        let mut text_matched_on_prev_iteration = false;
202        let mut start_pos = 0;
203        loop {
204            // First, find a candidate by styling by considering the starting span.
205            // This is efficient if the styled string doesn't contain many styles.
206            let spans_suffix = self
207                .spans
208                .get_by_text_range((ops::Bound::Included(start_pos), ops::Bound::Unbounded));
209            let offset_by_spans = spans_suffix.iter().find_map(|span| {
210                span.can_contain(&first_needle_span).then(|| {
211                    let mut offset = span.start;
212                    if needle_has_multiple_spans {
213                        // Need to align the span end.
214                        offset += span.len.get() - first_needle_span.len.get();
215                    }
216                    offset
217                })
218            });
219            let Some(offset_by_spans) = offset_by_spans else {
220                // No matching style spans
221                return None;
222            };
223            start_pos += offset_by_spans;
224            // We cannot guarantee that `start_pos` is at the char boundary, and the code below demands it.
225            start_pos = utils::ceil_char_boundary(self.text.as_bytes(), start_pos);
226
227            let offset_by_text = if offset_by_spans == 0 && text_matched_on_prev_iteration {
228                // Can reuse the text match found on the previous iteration
229                0
230            } else {
231                let Some(offset_by_text) = self.text[start_pos..].find(needle.text) else {
232                    // No text mentions
233                    return None;
234                };
235                offset_by_text
236            };
237            start_pos += offset_by_text;
238
239            if offset_by_text == 0 {
240                // The text match *may* correspond to the style match; check the spans slice completely.
241                let range = (
242                    ops::Bound::Included(start_pos),
243                    ops::Bound::Excluded(start_pos + needle.text.len()),
244                );
245                let spans_slice = self.spans.get_by_text_range(range);
246                if spans_slice == needle.spans {
247                    return Some(start_pos);
248                }
249
250                // We guarantee that the first style span matches, so this case can only happen if the needle
251                // contains multiple spans. Because the first and second span styles differ, we can advance
252                // the search position by first + second span lengths (i.e., at least 2).
253                assert!(needle_has_multiple_spans);
254                let second_span_len = needle.spans.get(1).unwrap().len;
255                let offset = first_needle_span.len.get() + second_span_len.get();
256                start_pos += offset;
257                start_pos = utils::ceil_char_boundary(self.text.as_bytes(), start_pos);
258            }
259            // Otherwise, the text match is somewhere after the found style match, so we refine the style match
260            // on the next loop iteration.
261            text_matched_on_prev_iteration = offset_by_text != 0;
262        }
263    }
264
265    /// Iterates over spans contained in this string.
266    ///
267    /// # Examples
268    ///
269    /// ```
270    /// # use styled_str::{styled, SpanStr};
271    /// # use anstyle::AnsiColor;
272    /// let styled = styled!("[[green]]Hello, [[* bold]]world");
273    /// let mut spans = styled.spans();
274    /// assert_eq!(spans.len(), 2);
275    ///
276    /// assert_eq!(
277    ///     spans.next().unwrap(),
278    ///     SpanStr::new("Hello, ", AnsiColor::Green.on_default())
279    /// );
280    /// assert_eq!(
281    ///     spans.next().unwrap(),
282    ///     SpanStr::new("world", AnsiColor::Green.on_default().bold())
283    /// );
284    /// ```
285    pub fn spans(&self) -> impl ExactSizeIterator<Item = SpanStr<'a>> + DoubleEndedIterator + 'a {
286        self.spans
287            .iter()
288            .map(|span| Self::map_span(self.text, span))
289    }
290
291    const fn map_span(text: &'a str, span: StyledSpan) -> SpanStr<'a> {
292        SpanStr {
293            text: utils::const_slice_unchecked(text, span.start..span.end()),
294            style: span.style,
295        }
296    }
297
298    /// Returns a span by the *span* index.
299    ///
300    /// Use [`Self::span_at()`] if you need to locate the span covering the specified position in the text.
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// # use styled_str::styled;
306    /// # use anstyle::AnsiColor;
307    /// let styled = styled!("[[bold blue!]]INFO[[/]] [[dim it]](2 min ago)[[/]] Important");
308    /// let span = styled.span(0).unwrap();
309    /// assert_eq!(span.text, "INFO");
310    /// assert_eq!(span.style.get_fg_color(), Some(AnsiColor::BrightBlue.into()));
311    ///
312    /// let span = styled.span(2).unwrap();
313    /// assert_eq!(span.text, "(2 min ago)");
314    /// ```
315    pub const fn span(&self, span_idx: usize) -> Option<SpanStr<'a>> {
316        let Some(span) = self.spans.get(span_idx) else {
317            return None;
318        };
319        Some(Self::map_span(self.text, span))
320    }
321
322    /// Looks up a span covering the specified position in the unstyled text.
323    ///
324    /// Use [`Self::span()`] if you need to locate the span by its index.
325    ///
326    /// # Examples
327    ///
328    /// ```
329    /// # use styled_str::styled;
330    /// # use anstyle::Effects;
331    /// let styled = styled!("[[bold blue!]]INFO[[/]] [[dim it]](2 min ago)[[/]] Important");
332    /// let span = styled.span_at(7).unwrap();
333    /// assert_eq!(span.text, "(2 min ago)");
334    /// assert_eq!(span.style.get_effects(), Effects::ITALIC | Effects::DIMMED);
335    /// ```
336    pub const fn span_at(&self, text_pos: usize) -> Option<SpanStr<'a>> {
337        if text_pos >= self.text.len() {
338            return None;
339        }
340        let Some(span) = self.spans.get_by_text_pos(text_pos) else {
341            return None;
342        };
343        Some(Self::map_span(self.text, span))
344    }
345
346    /// Splits this text by lines.
347    ///
348    /// # Examples
349    ///
350    /// ```
351    /// # use styled_str::styled;
352    /// let styled = styled!("[[bold green!]]Hello,\n  :[[* -color]]world\n");
353    /// let lines: Vec<_> = styled.lines().collect();
354    /// assert_eq!(lines, [
355    ///     styled!("[[bold green!]]Hello,"),
356    ///     styled!("[[bold green!]]  :[[bold]]world"),
357    /// ]);
358    /// ```
359    pub fn lines(self) -> Lines<'a> {
360        Lines::new(self)
361    }
362
363    /// Returns a string with embedded ANSI escapes.
364    ///
365    /// # Examples
366    ///
367    /// ```
368    /// # use styled_str::{styled, StyledString};
369    /// let styled = styled!("[[bold blue!]]INFO[[/]] [[dim it]](2 min ago)[[/]] Important");
370    /// let ansi_str = styled.ansi().to_string();
371    /// assert!(ansi_str.contains('\u{1b}'));
372    ///
373    /// // The ANSI string can be parsed back via `from_ansi()`.
374    /// let restored = StyledString::from_ansi(&ansi_str)?;
375    /// assert_eq!(restored, styled);
376    /// # anyhow::Ok(())
377    /// ```
378    pub fn ansi(&self) -> impl fmt::Display + '_ {
379        Ansi(*self)
380    }
381
382    /// Pops a single char from the end of the string.
383    ///
384    /// # Examples
385    ///
386    /// ```
387    /// # use styled_str::styled;
388    /// # use anstyle::Style;
389    /// let mut styled = styled!("[[bold green!]]Hello[[it]]!❤");
390    /// let (ch, style) = styled.pop().unwrap();
391    /// assert_eq!(ch, '❤');
392    /// assert_eq!(style, Style::new().italic());
393    /// assert_eq!(styled, styled!("[[bold green!]]Hello[[it]]!"));
394    ///
395    /// styled.pop().unwrap();
396    /// assert_eq!(styled, styled!("[[bold green!]]Hello"));
397    /// ```
398    pub fn pop(&mut self) -> Option<(char, Style)> {
399        let ch = self.text.chars().next_back()?;
400        self.text = &self.text[..self.text.len() - ch.len_utf8()];
401        let style = self.spans.pop_char(ch.len_utf8());
402        Some((ch, style))
403    }
404
405    /// Converts this string to the owned variant.
406    ///
407    /// Note that this shadows [`ToOwned::to_owned()`], but this shouldn't be an issue since `StyledStr`
408    /// implements [`Copy`].
409    pub fn to_owned(self) -> StyledString {
410        self.into()
411    }
412
413    fn format(&self, formatter: &mut fmt::Formatter<'_>, escape_chars: bool) -> fmt::Result {
414        for (i, span) in self.spans.iter().enumerate() {
415            let text = &self.text[span.start..span.end()];
416            if i == 0 && span.style.is_plain() {
417                // Special case: do not output an extra `[[/]]` at the string start.
418                write!(formatter, "{}", EscapedText::new(text, escape_chars))?;
419            } else {
420                write!(
421                    formatter,
422                    "[[{style}]]{text}",
423                    style = RichStyle(&span.style),
424                    text = EscapedText::new(text, escape_chars)
425                )?;
426            }
427        }
428        Ok(())
429    }
430}
431
432impl<'a, T> From<StyledStr<'a>> for StyledString<T>
433where
434    T: From<&'a str> + ops::Deref<Target = str>,
435{
436    fn from(str: StyledStr<'a>) -> Self {
437        Self {
438            text: str.text.into(),
439            spans: str.spans.iter().collect(),
440        }
441    }
442}
443
444impl<T> PartialEq<StyledString<T>> for StyledStr<'_>
445where
446    T: ops::Deref<Target = str>,
447{
448    fn eq(&self, other: &StyledString<T>) -> bool {
449        *self == other.as_str()
450    }
451}
452
453impl<T> PartialEq<StyledStr<'_>> for StyledString<T>
454where
455    T: ops::Deref<Target = str>,
456{
457    fn eq(&self, other: &StyledStr<'_>) -> bool {
458        self.as_str() == *other
459    }
460}
461
462impl fmt::Debug for StyledStr<'_> {
463    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
464        formatter.write_str("\"")?;
465        self.format(formatter, true)?;
466        formatter.write_str("\"")
467    }
468}
469
470/// Outputs a string with rich syntax.
471impl fmt::Display for StyledStr<'_> {
472    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
473        self.format(formatter, false)
474    }
475}
476
477#[derive(Debug)]
478struct Ansi<'a>(StyledStr<'a>);
479
480impl fmt::Display for Ansi<'_> {
481    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
482        for span in self.0.spans.iter() {
483            write!(
484                formatter,
485                "{style}{text}{style:#}",
486                style = span.style,
487                text = &self.0.text[span.start..span.end()]
488            )?;
489        }
490        Ok(())
491    }
492}