Skip to main content

use_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    pub const fn new(value: usize) -> Self {
20        Self(value)
21    }
22
23    /// Returns the raw 1-based number.
24    pub const fn get(self) -> usize {
25        self.0
26    }
27}
28
29/// Aggregate line counts derived from text.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub struct LineStats {
32    /// Total number of logical lines.
33    pub total: usize,
34    /// Number of logical lines whose trimmed content is not empty.
35    pub non_empty: usize,
36}
37
38impl LineStats {
39    /// Builds stats from the input text.
40    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/// Supported line-ending shapes.
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub enum LineEnding {
51    /// `\n`
52    Lf,
53    /// `\r\n`
54    Crlf,
55    /// `\r`
56    Cr,
57}
58
59impl LineEnding {
60    /// Returns the concrete line-ending string.
61    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
70/// Counts logical lines, ignoring a trailing empty line created only by a final line ending.
71pub fn line_count(input: &str) -> usize {
72    logical_lines(input).len()
73}
74
75/// Counts logical lines whose trimmed content is not empty.
76pub 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
83/// Trims each logical line independently and preserves the input line-ending style when possible.
84pub fn trim_lines(input: &str) -> String {
85    transform_lines(input, |line| line.trim().to_owned())
86}
87
88/// Normalizes line endings to the requested target.
89pub fn normalize_line_endings(input: &str, ending: LineEnding) -> String {
90    normalize_to_lf(input).replace('\n', ending.as_str())
91}
92
93/// Prefixes each logical line with the provided indent string.
94pub 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
103/// Removes the common indentation shared by non-empty lines.
104pub 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
129/// Returns numbered logical lines.
130pub 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}