Skip to main content

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}