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