#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Line {
pub number: LineNumber,
pub text: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LineNumber(usize);
impl LineNumber {
pub const fn new(value: usize) -> Self {
Self(value)
}
pub const fn get(self) -> usize {
self.0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LineStats {
pub total: usize,
pub non_empty: usize,
}
impl LineStats {
pub fn from_text(input: &str) -> Self {
Self {
total: line_count(input),
non_empty: non_empty_line_count(input),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LineEnding {
Lf,
Crlf,
Cr,
}
impl LineEnding {
pub const fn as_str(self) -> &'static str {
match self {
Self::Lf => "\n",
Self::Crlf => "\r\n",
Self::Cr => "\r",
}
}
}
pub fn line_count(input: &str) -> usize {
logical_lines(input).len()
}
pub fn non_empty_line_count(input: &str) -> usize {
logical_lines(input)
.into_iter()
.filter(|line| !line.trim().is_empty())
.count()
}
pub fn trim_lines(input: &str) -> String {
transform_lines(input, |line| line.trim().to_owned())
}
pub fn normalize_line_endings(input: &str, ending: LineEnding) -> String {
normalize_to_lf(input).replace('\n', ending.as_str())
}
pub fn indent_lines(input: &str, indent: &str) -> String {
transform_lines(input, |line| {
let mut output = String::with_capacity(indent.len() + line.len());
output.push_str(indent);
output.push_str(line);
output
})
}
pub fn dedent_lines(input: &str) -> String {
let ending = preferred_line_ending(input);
let normalized = normalize_to_lf(input);
let mut lines: Vec<String> = normalized.split('\n').map(ToOwned::to_owned).collect();
if normalized.is_empty() {
lines.clear();
}
let indent = common_indent(&lines);
if indent.is_empty() {
return lines.join(ending.as_str());
}
for line in &mut lines {
if line.trim().is_empty() {
line.clear();
} else if line.starts_with(&indent) {
line.drain(..indent.len());
}
}
lines.join(ending.as_str())
}
pub fn lines_with_numbers(input: &str) -> Vec<Line> {
logical_lines(input)
.into_iter()
.enumerate()
.map(|(index, text)| Line {
number: LineNumber::new(index + 1),
text: text.to_owned(),
})
.collect()
}
fn transform_lines<F>(input: &str, mut transform: F) -> String
where
F: FnMut(&str) -> String,
{
let ending = preferred_line_ending(input);
let normalized = normalize_to_lf(input);
if normalized.is_empty() {
return String::new();
}
normalized
.split('\n')
.map(&mut transform)
.collect::<Vec<_>>()
.join(ending.as_str())
}
fn normalize_to_lf(input: &str) -> String {
input.replace("\r\n", "\n").replace('\r', "\n")
}
fn preferred_line_ending(input: &str) -> LineEnding {
if input.contains("\r\n") {
LineEnding::Crlf
} else if input.contains('\r') {
LineEnding::Cr
} else {
LineEnding::Lf
}
}
fn logical_lines(input: &str) -> Vec<String> {
let normalized = normalize_to_lf(input);
if normalized.is_empty() {
return Vec::new();
}
let mut lines: Vec<String> = normalized.split('\n').map(ToOwned::to_owned).collect();
if normalized.ends_with('\n') {
let _ = lines.pop();
}
lines
}
fn common_indent(lines: &[String]) -> String {
let mut non_empty = lines.iter().filter(|line| !line.trim().is_empty());
let Some(first) = non_empty.next() else {
return String::new();
};
let mut common: Vec<char> = leading_indent(first).chars().collect();
for line in non_empty {
let indent: Vec<char> = leading_indent(line).chars().collect();
let shared = common
.iter()
.zip(indent.iter())
.take_while(|(left, right)| left == right)
.count();
common.truncate(shared);
if common.is_empty() {
break;
}
}
common.into_iter().collect()
}
fn leading_indent(line: &str) -> &str {
let end = line
.char_indices()
.find(|(_, character)| *character != ' ' && *character != '\t')
.map_or(line.len(), |(index, _)| index);
&line[..end]
}
#[cfg(test)]
mod tests {
use super::{
LineEnding, LineStats, dedent_lines, indent_lines, line_count, lines_with_numbers,
non_empty_line_count, normalize_line_endings, trim_lines,
};
#[test]
fn counts_empty_and_whitespace_only_inputs() {
assert_eq!(line_count(""), 0);
assert_eq!(line_count("\n"), 1);
assert_eq!(non_empty_line_count(" \n\t"), 0);
}
#[test]
fn trims_and_normalizes_multiline_text() {
assert_eq!(trim_lines(" alpha \n beta "), "alpha\nbeta");
assert_eq!(
normalize_line_endings("a\r\nb\rc\n", LineEnding::Lf),
"a\nb\nc\n"
);
assert_eq!(normalize_line_endings("a\nb", LineEnding::Crlf), "a\r\nb");
}
#[test]
fn indents_and_dedents_lines() {
assert_eq!(indent_lines("alpha\nbeta", " "), " alpha\n beta");
assert_eq!(
dedent_lines(" alpha\n beta\n \n gamma"),
"alpha\n beta\n\ngamma"
);
}
#[test]
fn numbers_lines_and_builds_stats() {
let lines = lines_with_numbers("alpha\nbeta\n");
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].number.get(), 1);
assert_eq!(lines[1].text, "beta");
let stats = LineStats::from_text("alpha\n\n beta ");
assert_eq!(stats.total, 3);
assert_eq!(stats.non_empty, 2);
}
#[test]
fn handles_unicode_text_without_special_cases() {
assert_eq!(trim_lines(" café \n Straße "), "café\nStraße");
}
}