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}