1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Clone, Debug, Eq, PartialEq)]
6pub struct Line {
7 pub number: LineNumber,
9 pub text: String,
11}
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub struct LineNumber(usize);
16
17impl LineNumber {
18 #[must_use]
20 pub const fn new(value: usize) -> Self {
21 Self(value)
22 }
23
24 #[must_use]
26 pub const fn get(self) -> usize {
27 self.0
28 }
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct LineStats {
34 pub total: usize,
36 pub non_empty: usize,
38}
39
40impl LineStats {
41 #[must_use]
43 pub fn from_text(input: &str) -> Self {
44 Self {
45 total: line_count(input),
46 non_empty: non_empty_line_count(input),
47 }
48 }
49}
50
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub enum LineEnding {
54 Lf,
56 Crlf,
58 Cr,
60}
61
62impl LineEnding {
63 #[must_use]
65 pub const fn as_str(self) -> &'static str {
66 match self {
67 Self::Lf => "\n",
68 Self::Crlf => "\r\n",
69 Self::Cr => "\r",
70 }
71 }
72}
73
74#[must_use]
76pub fn line_count(input: &str) -> usize {
77 logical_lines(input).len()
78}
79
80#[must_use]
82pub fn non_empty_line_count(input: &str) -> usize {
83 logical_lines(input)
84 .into_iter()
85 .filter(|line| !line.trim().is_empty())
86 .count()
87}
88
89#[must_use]
91pub fn trim_lines(input: &str) -> String {
92 transform_lines(input, |line| line.trim().to_owned())
93}
94
95#[must_use]
97pub fn normalize_line_endings(input: &str, ending: LineEnding) -> String {
98 normalize_to_lf(input).replace('\n', ending.as_str())
99}
100
101#[must_use]
103pub fn indent_lines(input: &str, indent: &str) -> String {
104 transform_lines(input, |line| {
105 let mut output = String::with_capacity(indent.len() + line.len());
106 output.push_str(indent);
107 output.push_str(line);
108 output
109 })
110}
111
112#[must_use]
114pub fn dedent_lines(input: &str) -> String {
115 let ending = preferred_line_ending(input);
116 let normalized = normalize_to_lf(input);
117 let mut lines: Vec<String> = normalized.split('\n').map(ToOwned::to_owned).collect();
118
119 if normalized.is_empty() {
120 lines.clear();
121 }
122
123 let indent = common_indent(&lines);
124 if indent.is_empty() {
125 return lines.join(ending.as_str());
126 }
127
128 for line in &mut lines {
129 if line.trim().is_empty() {
130 line.clear();
131 } else if line.starts_with(&indent) {
132 line.drain(..indent.len());
133 }
134 }
135
136 lines.join(ending.as_str())
137}
138
139#[must_use]
141pub fn lines_with_numbers(input: &str) -> Vec<Line> {
142 logical_lines(input)
143 .into_iter()
144 .enumerate()
145 .map(|(index, text)| Line {
146 number: LineNumber::new(index + 1),
147 text,
148 })
149 .collect()
150}
151
152fn transform_lines<F>(input: &str, mut transform: F) -> String
153where
154 F: FnMut(&str) -> String,
155{
156 let ending = preferred_line_ending(input);
157 let normalized = normalize_to_lf(input);
158 if normalized.is_empty() {
159 return String::new();
160 }
161
162 normalized
163 .split('\n')
164 .map(&mut transform)
165 .collect::<Vec<_>>()
166 .join(ending.as_str())
167}
168
169fn normalize_to_lf(input: &str) -> String {
170 input.replace("\r\n", "\n").replace('\r', "\n")
171}
172
173fn preferred_line_ending(input: &str) -> LineEnding {
174 if input.contains("\r\n") {
175 LineEnding::Crlf
176 } else if input.contains('\r') {
177 LineEnding::Cr
178 } else {
179 LineEnding::Lf
180 }
181}
182
183fn logical_lines(input: &str) -> Vec<String> {
184 let normalized = normalize_to_lf(input);
185 if normalized.is_empty() {
186 return Vec::new();
187 }
188
189 let mut lines: Vec<String> = normalized.split('\n').map(ToOwned::to_owned).collect();
190 if normalized.ends_with('\n') {
191 let _ = lines.pop();
192 }
193 lines
194}
195
196fn common_indent(lines: &[String]) -> String {
197 let mut non_empty = lines.iter().filter(|line| !line.trim().is_empty());
198 let Some(first) = non_empty.next() else {
199 return String::new();
200 };
201
202 let mut common: Vec<char> = leading_indent(first).chars().collect();
203 for line in non_empty {
204 let indent: Vec<char> = leading_indent(line).chars().collect();
205 let shared = common
206 .iter()
207 .zip(indent.iter())
208 .take_while(|(left, right)| left == right)
209 .count();
210 common.truncate(shared);
211 if common.is_empty() {
212 break;
213 }
214 }
215
216 common.into_iter().collect()
217}
218
219fn leading_indent(line: &str) -> &str {
220 let end = line
221 .char_indices()
222 .find(|(_, character)| *character != ' ' && *character != '\t')
223 .map_or(line.len(), |(index, _)| index);
224 &line[..end]
225}
226
227#[cfg(test)]
228mod tests {
229 use super::{
230 LineEnding, LineStats, dedent_lines, indent_lines, line_count, lines_with_numbers,
231 non_empty_line_count, normalize_line_endings, trim_lines,
232 };
233
234 #[test]
235 fn counts_empty_and_whitespace_only_inputs() {
236 assert_eq!(line_count(""), 0);
237 assert_eq!(line_count("\n"), 1);
238 assert_eq!(non_empty_line_count(" \n\t"), 0);
239 }
240
241 #[test]
242 fn trims_and_normalizes_multiline_text() {
243 assert_eq!(trim_lines(" alpha \n beta "), "alpha\nbeta");
244 assert_eq!(
245 normalize_line_endings("a\r\nb\rc\n", LineEnding::Lf),
246 "a\nb\nc\n"
247 );
248 assert_eq!(normalize_line_endings("a\nb", LineEnding::Crlf), "a\r\nb");
249 }
250
251 #[test]
252 fn indents_and_dedents_lines() {
253 assert_eq!(indent_lines("alpha\nbeta", " "), " alpha\n beta");
254 assert_eq!(
255 dedent_lines(" alpha\n beta\n \n gamma"),
256 "alpha\n beta\n\ngamma"
257 );
258 }
259
260 #[test]
261 fn numbers_lines_and_builds_stats() {
262 let lines = lines_with_numbers("alpha\nbeta\n");
263 assert_eq!(lines.len(), 2);
264 assert_eq!(lines[0].number.get(), 1);
265 assert_eq!(lines[1].text, "beta");
266
267 let stats = LineStats::from_text("alpha\n\n beta ");
268 assert_eq!(stats.total, 3);
269 assert_eq!(stats.non_empty, 2);
270 }
271
272 #[test]
273 fn handles_unicode_text_without_special_cases() {
274 assert_eq!(trim_lines(" café \n Straße "), "café\nStraße");
275 }
276}