path_range.rs

  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}