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}