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<'_> {}