Skip to main content

split_paragraphs/
lib.rs

1//! A crate that provides paragraph iteration for strings.
2//!
3//! This crate extends [`str`] with the ability to iterate over paragraphs via the [`SplitParagraphs`] trait.
4//! A paragraph is defined as one or more consecutive non-empty lines, separated by one or more blank lines.
5//!
6//! # Example
7//! ```
8//! use split_paragraphs::SplitParagraphs;
9//!
10//! let text = "foo\r\nbar\n\nbaz\r";
11//! let mut paragraphs = text.paragraphs();
12//!
13//! assert_eq!(paragraphs.next(), Some("foo\r\nbar"));
14//! assert_eq!(paragraphs.next(), Some("baz\r"));
15//! assert_eq!(paragraphs.next(), None);
16//! ```
17
18#![no_std]
19#![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
20#![allow(clippy::cast_sign_loss)]
21
22use core::iter::FusedIterator;
23use core::slice;
24use core::str::{Lines, from_utf8_unchecked};
25
26/// Trait extending [`str`] with [`paragraphs`].
27///
28/// [`paragraphs`]: SplitParagraphs::paragraphs
29pub trait SplitParagraphs {
30    /// Returns an iterator over paragraphs of a string, as string slices.
31    ///
32    /// A paragraph consists of one or more lines containing non-whitespace characters,
33    /// separated by empty lines or lines containing only whitespace.
34    ///
35    /// Paragraphs always contain at least one line with at least one non-whitespace
36    /// character.
37    ///
38    /// Paragraphs never contain empty lines or whitespace-only lines.
39    ///
40    /// Paragraphs support line endings that are either newlines (`\n`) or
41    /// carriage return followed by line feed (`\r\n`).
42    ///
43    /// Line terminators between paragraphs are not included in the returned slices.
44    ///
45    /// Line terminators within paragraphs are preserved in their original form.
46    ///
47    /// Handling of line endings matches [`lines`]. See its documentation for more details.
48    ///
49    /// # Examples
50    ///
51    /// Basic usage:
52    ///
53    /// ```
54    /// # use split_paragraphs::SplitParagraphs;
55    /// let text = "foo\r\nbar\n\nbaz\r";
56    /// let mut paragraphs = text.paragraphs();
57    ///
58    /// assert_eq!(Some("foo\r\nbar"), paragraphs.next());
59    /// // Trailing carriage return is included in the last paragraph
60    /// assert_eq!(Some("baz\r"), paragraphs.next());
61    ///
62    /// assert_eq!(None, paragraphs.next());
63    /// ```
64    ///
65    /// The final paragraph does not require any ending:
66    ///
67    /// ```
68    /// # use split_paragraphs::SplitParagraphs;
69    /// let text = "\n\n\nfoo\nbar\n\r\nbaz";
70    /// let mut paragraphs = text.paragraphs();
71    ///
72    /// assert_eq!(Some("foo\nbar"), paragraphs.next());
73    /// assert_eq!(Some("baz"), paragraphs.next());
74    ///
75    /// assert_eq!(None, paragraphs.next());
76    /// ```
77    ///
78    /// [`paragraphs`]: SplitParagraphs::paragraphs
79    /// [`lines`]: str::lines
80    fn paragraphs(&self) -> Paragraphs<'_>;
81}
82
83/// An iterator over the paragraphs of a string, as string slices.
84///
85/// This struct is created with the [`paragraphs`] method on [`str`] via
86/// the [`SplitParagraphs`] trait.
87/// See its documentation for more.
88///
89/// [`paragraphs`]: SplitParagraphs::paragraphs
90#[must_use = "iterators are lazy and do nothing unless consumed"]
91#[derive(Clone, Debug)]
92pub struct Paragraphs<'a> {
93    lines: Lines<'a>,
94}
95
96impl SplitParagraphs for str {
97    #[inline]
98    fn paragraphs(&self) -> Paragraphs<'_> {
99        Paragraphs {
100            lines: self.lines(),
101        }
102    }
103}
104
105impl<'a> Iterator for Paragraphs<'a> {
106    type Item = &'a str;
107
108    #[inline]
109    fn size_hint(&self) -> (usize, Option<usize>) {
110        (0, self.lines.size_hint().1.map(|n| n.div_ceil(2)))
111    }
112
113    #[inline]
114    fn next(&mut self) -> Option<Self::Item> {
115        let first_line = self.lines.next()?;
116
117        let first_non_empty_line = if first_line.trim().is_empty() {
118            loop {
119                let line = self.lines.next()?;
120                if !line.trim().is_empty() {
121                    break line;
122                }
123            }
124        } else {
125            first_line
126        };
127
128        let mut last_non_empty_line = first_non_empty_line;
129        for line in self.lines.by_ref() {
130            if line.trim().is_empty() {
131                break;
132            }
133            last_non_empty_line = line;
134        }
135
136        // SAFETY: Both lines are ordered slices of the same input string, and line
137        // boundaries are UTF-8 boundaries, so the contiguous span is valid UTF-8.
138        let result: &str = unsafe {
139            from_utf8_unchecked(slice::from_raw_parts(
140                first_non_empty_line.as_ptr(),
141                (last_non_empty_line
142                    .as_ptr()
143                    .offset_from(first_non_empty_line.as_ptr()) as usize)
144                    .unchecked_add(last_non_empty_line.len()),
145            ))
146        };
147
148        Some(result)
149    }
150}
151
152impl DoubleEndedIterator for Paragraphs<'_> {
153    #[inline]
154    fn next_back(&mut self) -> Option<Self::Item> {
155        let last_line = self.lines.next_back()?;
156
157        let last_non_empty_line = if last_line.trim().is_empty() {
158            loop {
159                let line = self.lines.next_back()?;
160                if !line.trim().is_empty() {
161                    break line;
162                }
163            }
164        } else {
165            last_line
166        };
167
168        let mut first_non_empty_line = last_non_empty_line;
169        while let Some(line) = self.lines.next_back() {
170            if line.trim().is_empty() {
171                break;
172            }
173            first_non_empty_line = line;
174        }
175
176        // SAFETY: Both lines are ordered slices of the same input string, and line
177        // boundaries are UTF-8 boundaries, so the contiguous span is valid UTF-8.
178        let result: &str = unsafe {
179            from_utf8_unchecked(slice::from_raw_parts(
180                first_non_empty_line.as_ptr(),
181                (last_non_empty_line
182                    .as_ptr()
183                    .offset_from(first_non_empty_line.as_ptr()) as usize)
184                    .unchecked_add(last_non_empty_line.len()),
185            ))
186        };
187
188        Some(result)
189    }
190}
191
192impl FusedIterator for Paragraphs<'_> {}