stern4rust/source_file.rs
1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5// One source file, already normalised so rules never have to think about how it
6// reached the disk.
7//
8// Two normalisations, both of which would otherwise make every file on a Windows
9// checkout fail a rule that is really about content:
10//
11// - a trailing carriage return is stripped, because git's autocrlf rewrites
12// line endings on checkout and a byte-for-byte comparison would fail on
13// every line of every file
14// - a leading UTF-8 byte order mark is stripped, because editors add one
15// invisibly and it would otherwise sit in front of the first character of
16// line 1
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SourceFile {
19 relative_path: String,
20 lines: Vec<String>,
21}
22
23impl SourceFile {
24 pub fn new(relative_path: &str, contents: &str) -> Self {
25 let contents = contents.strip_prefix('\u{feff}').unwrap_or(contents);
26 Self {
27 relative_path: relative_path.replace('\\', "/"),
28 lines: contents
29 .split('\n')
30 .map(|line| line.strip_suffix('\r').unwrap_or(line).to_string())
31 .collect(),
32 }
33 }
34
35 pub fn relative_path(&self) -> &str {
36 &self.relative_path
37 }
38
39 pub fn lines(&self) -> &[String] {
40 &self.lines
41 }
42
43 // Rejoined from the normalised lines rather than kept alongside them, so a
44 // parser and a line-counting rule can never disagree about what the file
45 // says.
46 pub fn contents(&self) -> String {
47 self.lines.join(
48 "
49",
50 )
51 }
52
53 // An empty file splits into one empty line, which is not the same as having
54 // a line of content.
55 pub fn is_empty(&self) -> bool {
56 self.lines.iter().all(|line| line.trim().is_empty())
57 }
58}