lanekeep_core/
location.rs1use std::fmt;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
21#[serde(transparent)]
22pub struct FilePath(String);
23
24impl FilePath {
25 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
54pub struct Position {
55 pub line: u32,
57 pub column: u32,
59}
60
61impl Position {
62 pub const START: Self = Self { line: 1, column: 1 };
64
65 #[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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
80pub struct Location {
81 pub file: FilePath,
83 pub position: Position,
85}
86
87impl Location {
88 #[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 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 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 let path = FilePath::new("src/a.ts");
190 assert_eq!(serde_json::to_string(&path).expect("ok"), "\"src/a.ts\"");
191 }
192}