Skip to main content

lanekeep_core/
location.rs

1//! Where a violation is.
2
3use std::fmt;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8/// A path as it appears in lanekeep's output.
9///
10/// Two normalizations happen here, both for determinism rather than tidiness.
11///
12/// **Relative to the project root.** An absolute path embeds the checkout directory, so
13/// the same corpus checked in `/home/ana/app` and `/ci/build/app` would produce different
14/// output and different cache entries for identical content.
15///
16/// **Forward slashes always.** `std::path` uses `\` on Windows, so an unnormalized path
17/// would make output — and every committed snapshot — disagree between a developer's
18/// machine and CI. Architecture §11 requires that two runs over identical input produce
19/// identical output; "identical input" has to include the platform.
20#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
21#[serde(transparent)]
22pub struct FilePath(String);
23
24impl FilePath {
25    /// Normalize a path that is already relative to the project root.
26    ///
27    /// Separators are converted to `/`, and a leading `./` is dropped.
28    #[must_use]
29    pub fn new(path: impl AsRef<Path>) -> Self {
30        let raw = path.as_ref().to_string_lossy().replace('\\', "/");
31        let trimmed = raw.strip_prefix("./").unwrap_or(&raw);
32        Self(trimmed.to_owned())
33    }
34
35    /// The normalized path.
36    #[must_use]
37    pub fn as_str(&self) -> &str {
38        &self.0
39    }
40}
41
42impl fmt::Display for FilePath {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str(&self.0)
45    }
46}
47
48/// A one-based position within a file.
49///
50/// One-based because every consumer of this — editors, terminals, humans, and the agents
51/// reading the output — counts from one. Storing zero-based and converting at the edge
52/// means every reporter has the chance to forget.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
54pub struct Position {
55    /// One-based line.
56    pub line: u32,
57    /// One-based column, counted in characters rather than bytes.
58    pub column: u32,
59}
60
61impl Position {
62    /// The first position in a file.
63    pub const START: Self = Self { line: 1, column: 1 };
64
65    /// Build a position from one-based coordinates.
66    #[must_use]
67    pub const fn new(line: u32, column: u32) -> Self {
68        Self { line, column }
69    }
70}
71
72impl fmt::Display for Position {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        write!(f, "{}:{}", self.line, self.column)
75    }
76}
77
78/// A file and a position within it.
79#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
80pub struct Location {
81    /// Path relative to the project root, with forward slashes.
82    pub file: FilePath,
83    /// One-based position.
84    pub position: Position,
85}
86
87impl Location {
88    /// Build a location.
89    #[must_use]
90    pub fn new(file: FilePath, position: Position) -> Self {
91        Self { file, position }
92    }
93}
94
95impl fmt::Display for Location {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(f, "{}:{}", self.file, self.position)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn keeps_a_posix_path_unchanged() {
107        assert_eq!(
108            FilePath::new("src/components/Button.tsx").as_str(),
109            "src/components/Button.tsx"
110        );
111    }
112
113    #[test]
114    fn converts_windows_separators() {
115        // Without this, a snapshot committed from macOS fails on the Windows CI runner
116        // even though nothing about the analysis differs.
117        assert_eq!(
118            FilePath::new(r"src\components\Button.tsx").as_str(),
119            "src/components/Button.tsx"
120        );
121        assert_eq!(FilePath::new(r"src\a/b\c.ts").as_str(), "src/a/b/c.ts");
122    }
123
124    #[test]
125    fn drops_a_leading_dot_slash() {
126        // Glob expansion produces these inconsistently, and `./src/a.ts` and `src/a.ts`
127        // must not be two different cache keys for one file.
128        assert_eq!(FilePath::new("./src/a.ts").as_str(), "src/a.ts");
129        assert_eq!(FilePath::new(r".\src\a.ts").as_str(), "src/a.ts");
130    }
131
132    #[test]
133    fn does_not_mangle_a_dotfile() {
134        assert_eq!(FilePath::new(".eslintrc.ts").as_str(), ".eslintrc.ts");
135        assert_eq!(
136            FilePath::new("src/.hidden/a.ts").as_str(),
137            "src/.hidden/a.ts"
138        );
139    }
140
141    #[test]
142    fn paths_sort_deterministically() {
143        let mut paths: Vec<FilePath> = ["src/b.ts", "src/a.ts", "lib/z.ts", "src/a/b.ts"]
144            .iter()
145            .map(FilePath::new)
146            .collect();
147        paths.sort();
148
149        let rendered: Vec<&str> = paths.iter().map(FilePath::as_str).collect();
150        assert_eq!(rendered, ["lib/z.ts", "src/a.ts", "src/a/b.ts", "src/b.ts"]);
151    }
152
153    #[test]
154    fn positions_order_by_line_then_column() {
155        let mut positions = vec![
156            Position::new(2, 1),
157            Position::new(1, 10),
158            Position::new(1, 2),
159            Position::new(10, 1),
160        ];
161        positions.sort();
162
163        assert_eq!(
164            positions,
165            [
166                Position::new(1, 2),
167                Position::new(1, 10),
168                Position::new(2, 1),
169                Position::new(10, 1)
170            ]
171        );
172    }
173
174    #[test]
175    fn renders_the_way_editors_expect() {
176        let location = Location::new(FilePath::new("src/a.ts"), Position::new(12, 5));
177        assert_eq!(location.to_string(), "src/a.ts:12:5");
178    }
179
180    #[test]
181    fn start_is_one_based() {
182        assert_eq!(Position::START, Position::new(1, 1));
183        assert_eq!(Position::START.to_string(), "1:1");
184    }
185
186    #[test]
187    fn file_path_serializes_as_a_bare_string() {
188        // The JSON schema is a public contract; a path must not appear as `{"0": "..."}`.
189        let path = FilePath::new("src/a.ts");
190        assert_eq!(serde_json::to_string(&path).expect("ok"), "\"src/a.ts\"");
191    }
192}