1use std::{ops::Range, path::Path, sync::Arc};
2
3#[derive(Debug, Clone, PartialEq)]
4pub struct PathWithRange {
5 pub path: Arc<Path>,
6 pub range: Option<Range<LineCol>>,
7}
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct LineCol {
11 pub line: u32,
12 pub col: Option<u32>,
13}
14
15impl LineCol {
16 pub fn new(str: impl AsRef<str>) -> Option<Self> {
17 let str = str.as_ref();
18 match str.split_once(':') {
19 Some((line, col)) => match (line.parse::<u32>(), col.parse::<u32>()) {
20 (Ok(line), Ok(col)) => Some(Self {
21 line,
22 col: Some(col),
23 }),
24 _ => None,
25 },
26 None => match str.parse::<u32>() {
27 Ok(line) => Some(Self { line, col: None }),
28 Err(_) => None,
29 },
30 }
31 }
32}
33
34impl PathWithRange {
35 // Note: We could try out this as an alternative, and see how it does on evals.
36 //
37 // The closest to a standard way of including a filename is this:
38 // ```rust filename="path/to/file.rs#42:43"
39 // ```
40 //
41 // or, alternatively,
42 // ```rust filename="path/to/file.rs" lines="42:43"
43 // ```
44 //
45 // Examples where it's used this way:
46 // - https://mdxjs.com/guides/syntax-highlighting/#syntax-highlighting-with-the-meta-field
47 // - https://docusaurus.io/docs/markdown-features/code-blocks
48 // - https://spec.commonmark.org/0.31.2/#example-143
49 pub fn new(str: impl AsRef<str>) -> Self {
50 let str = str.as_ref();
51 // Sometimes the model will include a language at the start,
52 // e.g. "```rust zed/crates/markdown/src/markdown.rs#L1"
53 // We just discard that.
54 let str = match str.trim_end().rfind(' ') {
55 Some(space) => &str[space + 1..],
56 None => str.trim_start(),
57 };
58
59 match str.rsplit_once('#') {
60 Some((path, after_hash)) => {
61 // Be tolerant to the model omitting the "L" prefix, lowercasing it,
62 // or including it more than once.
63 let after_hash = after_hash.replace(['L', 'l'], "");
64
65 let range = {
66 let mut iter = after_hash.split('-').flat_map(LineCol::new);
67 iter.next()
68 .map(|start| iter.next().map(|end| start..end).unwrap_or(start..start))
69 };
70
71 Self {
72 path: Path::new(path).into(),
73 range,
74 }
75 }
76 None => Self {
77 path: Path::new(str).into(),
78 range: None,
79 },
80 }
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 #[test]
89 fn test_linecol_parsing() {
90 let line_col = LineCol::new("10:5");
91 assert_eq!(
92 line_col,
93 Some(LineCol {
94 line: 10,
95 col: Some(5)
96 })
97 );
98
99 let line_only = LineCol::new("42");
100 assert_eq!(
101 line_only,
102 Some(LineCol {
103 line: 42,
104 col: None
105 })
106 );
107
108 assert_eq!(LineCol::new(""), None);
109 assert_eq!(LineCol::new("not a number"), None);
110 assert_eq!(LineCol::new("10:not a number"), None);
111 assert_eq!(LineCol::new("not:5"), None);
112 }
113
114 #[test]
115 fn test_pathrange_parsing() {
116 let path_range = PathWithRange::new("file.rs#L10-L20");
117 assert_eq!(path_range.path.as_ref(), Path::new("file.rs"));
118 assert!(path_range.range.is_some());
119 if let Some(range) = path_range.range {
120 assert_eq!(range.start.line, 10);
121 assert_eq!(range.start.col, None);
122 assert_eq!(range.end.line, 20);
123 assert_eq!(range.end.col, None);
124 }
125
126 let single_line = PathWithRange::new("file.rs#L15");
127 assert_eq!(single_line.path.as_ref(), Path::new("file.rs"));
128 assert!(single_line.range.is_some());
129 if let Some(range) = single_line.range {
130 assert_eq!(range.start.line, 15);
131 assert_eq!(range.end.line, 15);
132 }
133
134 let no_range = PathWithRange::new("file.rs");
135 assert_eq!(no_range.path.as_ref(), Path::new("file.rs"));
136 assert!(no_range.range.is_none());
137
138 let lowercase = PathWithRange::new("file.rs#l5-l10");
139 assert_eq!(lowercase.path.as_ref(), Path::new("file.rs"));
140 assert!(lowercase.range.is_some());
141 if let Some(range) = lowercase.range {
142 assert_eq!(range.start.line, 5);
143 assert_eq!(range.end.line, 10);
144 }
145
146 let complex = PathWithRange::new("src/path/to/file.rs#L100");
147 assert_eq!(complex.path.as_ref(), Path::new("src/path/to/file.rs"));
148 assert!(complex.range.is_some());
149 }
150
151 #[test]
152 fn test_pathrange_from_str() {
153 let with_range = PathWithRange::new("file.rs#L10-L20");
154 assert!(with_range.range.is_some());
155 assert_eq!(with_range.path.as_ref(), Path::new("file.rs"));
156
157 let without_range = PathWithRange::new("file.rs");
158 assert!(without_range.range.is_none());
159
160 let single_line = PathWithRange::new("file.rs#L15");
161 assert!(single_line.range.is_some());
162 }
163
164 #[test]
165 fn test_pathrange_leading_text_trimming() {
166 let with_language = PathWithRange::new("```rust file.rs#L10");
167 assert_eq!(with_language.path.as_ref(), Path::new("file.rs"));
168 assert!(with_language.range.is_some());
169 if let Some(range) = with_language.range {
170 assert_eq!(range.start.line, 10);
171 }
172
173 let with_spaces = PathWithRange::new("``` file.rs#L10-L20");
174 assert_eq!(with_spaces.path.as_ref(), Path::new("file.rs"));
175 assert!(with_spaces.range.is_some());
176
177 let with_words = PathWithRange::new("```rust code example file.rs#L15:10");
178 assert_eq!(with_words.path.as_ref(), Path::new("file.rs"));
179 assert!(with_words.range.is_some());
180 if let Some(range) = with_words.range {
181 assert_eq!(range.start.line, 15);
182 assert_eq!(range.start.col, Some(10));
183 }
184
185 let with_whitespace = PathWithRange::new(" file.rs#L5");
186 assert_eq!(with_whitespace.path.as_ref(), Path::new("file.rs"));
187 assert!(with_whitespace.range.is_some());
188
189 let no_leading = PathWithRange::new("file.rs#L10");
190 assert_eq!(no_leading.path.as_ref(), Path::new("file.rs"));
191 assert!(no_leading.range.is_some());
192 }
193
194 #[test]
195 fn test_pathrange_with_line_and_column() {
196 let line_and_col = PathWithRange::new("file.rs#L10:5");
197 assert_eq!(line_and_col.path.as_ref(), Path::new("file.rs"));
198 assert!(line_and_col.range.is_some());
199 if let Some(range) = line_and_col.range {
200 assert_eq!(range.start.line, 10);
201 assert_eq!(range.start.col, Some(5));
202 assert_eq!(range.end.line, 10);
203 assert_eq!(range.end.col, Some(5));
204 }
205
206 let full_range = PathWithRange::new("file.rs#L10:5-L20:15");
207 assert_eq!(full_range.path.as_ref(), Path::new("file.rs"));
208 assert!(full_range.range.is_some());
209 if let Some(range) = full_range.range {
210 assert_eq!(range.start.line, 10);
211 assert_eq!(range.start.col, Some(5));
212 assert_eq!(range.end.line, 20);
213 assert_eq!(range.end.col, Some(15));
214 }
215
216 let mixed_range1 = PathWithRange::new("file.rs#L10:5-L20");
217 assert_eq!(mixed_range1.path.as_ref(), Path::new("file.rs"));
218 assert!(mixed_range1.range.is_some());
219 if let Some(range) = mixed_range1.range {
220 assert_eq!(range.start.line, 10);
221 assert_eq!(range.start.col, Some(5));
222 assert_eq!(range.end.line, 20);
223 assert_eq!(range.end.col, None);
224 }
225
226 let mixed_range2 = PathWithRange::new("file.rs#L10-L20:15");
227 assert_eq!(mixed_range2.path.as_ref(), Path::new("file.rs"));
228 assert!(mixed_range2.range.is_some());
229 if let Some(range) = mixed_range2.range {
230 assert_eq!(range.start.line, 10);
231 assert_eq!(range.start.col, None);
232 assert_eq!(range.end.line, 20);
233 assert_eq!(range.end.col, Some(15));
234 }
235 }
236}