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