Skip to main content

use_text_line/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// A numbered logical line.
5#[derive(Clone, Debug, Eq, PartialEq)]
6pub struct Line {
7    /// The 1-based line number.
8    pub number: LineNumber,
9    /// The line contents without the line ending.
10    pub text: String,
11}
12
13/// A 1-based line number.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub struct LineNumber(usize);
16
17impl LineNumber {
18    /// Creates a new 1-based line number.
19    #[must_use]
20    pub const fn new(value: usize) -> Self {
21        Self(value)
22    }
23
24    /// Returns the raw 1-based number.
25    #[must_use]
26    pub const fn get(self) -> usize {
27        self.0
28    }
29}
30
31/// Aggregate line counts derived from text.
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct LineStats {
34    /// Total number of logical lines.
35    pub total: usize,
36    /// Number of logical lines whose trimmed content is not empty.
37    pub non_empty: usize,
38}
39
40impl LineStats {
41    /// Builds stats from the input text.
42    #[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/// Supported line-ending shapes.
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub enum LineEnding {
54    /// `\n`
55    Lf,
56    /// `\r\n`
57    Crlf,
58    /// `\r`
59    Cr,
60}
61
62impl LineEnding {
63    /// Returns the concrete line-ending string.
64    #[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/// Counts logical lines, ignoring a trailing empty line created only by a final line ending.
75#[must_use]
76pub fn line_count(input: &str) -> usize {
77    logical_lines(input).len()
78}
79
80/// Counts logical lines whose trimmed content is not empty.
81#[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/// Trims each logical line independently and preserves the input line-ending style when possible.
90#[must_use]
91pub fn trim_lines(input: &str) -> String {
92    transform_lines(input, |line| line.trim().to_owned())
93}
94
95/// Normalizes line endings to the requested target.
96#[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/// Prefixes each logical line with the provided indent string.
102#[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/// Removes the common indentation shared by non-empty lines.
113#[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/// Returns numbered logical lines.
140#[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}