mention.rs

  1use agent_client_protocol as acp;
  2use anyhow::{Context as _, Result, bail};
  3use file_icons::FileIcons;
  4use prompt_store::{PromptId, UserPromptId};
  5use serde::{Deserialize, Serialize};
  6use std::{
  7    fmt,
  8    ops::Range,
  9    path::{Path, PathBuf},
 10    str::FromStr,
 11};
 12use ui::{App, IconName, SharedString};
 13use url::Url;
 14
 15#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
 16pub enum MentionUri {
 17    File {
 18        abs_path: PathBuf,
 19    },
 20    Directory {
 21        abs_path: PathBuf,
 22    },
 23    Symbol {
 24        path: PathBuf,
 25        name: String,
 26        line_range: Range<u32>,
 27    },
 28    Thread {
 29        id: acp::SessionId,
 30        name: String,
 31    },
 32    TextThread {
 33        path: PathBuf,
 34        name: String,
 35    },
 36    Rule {
 37        id: PromptId,
 38        name: String,
 39    },
 40    Selection {
 41        path: PathBuf,
 42        line_range: Range<u32>,
 43    },
 44    Fetch {
 45        url: Url,
 46    },
 47}
 48
 49impl MentionUri {
 50    pub fn parse(input: &str) -> Result<Self> {
 51        let url = url::Url::parse(input)?;
 52        let path = url.path();
 53        match url.scheme() {
 54            "file" => {
 55                let path = url.to_file_path().ok().context("Extracting file path")?;
 56                if let Some(fragment) = url.fragment() {
 57                    let range = fragment
 58                        .strip_prefix("L")
 59                        .context("Line range must start with \"L\"")?;
 60                    let (start, end) = range
 61                        .split_once(":")
 62                        .context("Line range must use colon as separator")?;
 63                    let line_range = start
 64                        .parse::<u32>()
 65                        .context("Parsing line range start")?
 66                        .checked_sub(1)
 67                        .context("Line numbers should be 1-based")?
 68                        ..end
 69                            .parse::<u32>()
 70                            .context("Parsing line range end")?
 71                            .checked_sub(1)
 72                            .context("Line numbers should be 1-based")?;
 73                    if let Some(name) = single_query_param(&url, "symbol")? {
 74                        Ok(Self::Symbol {
 75                            name,
 76                            path,
 77                            line_range,
 78                        })
 79                    } else {
 80                        Ok(Self::Selection { path, line_range })
 81                    }
 82                } else if input.ends_with("/") {
 83                    Ok(Self::Directory { abs_path: path })
 84                } else {
 85                    Ok(Self::File { abs_path: path })
 86                }
 87            }
 88            "zed" => {
 89                if let Some(thread_id) = path.strip_prefix("/agent/thread/") {
 90                    let name = single_query_param(&url, "name")?.context("Missing thread name")?;
 91                    Ok(Self::Thread {
 92                        id: acp::SessionId(thread_id.into()),
 93                        name,
 94                    })
 95                } else if let Some(path) = path.strip_prefix("/agent/text-thread/") {
 96                    let name = single_query_param(&url, "name")?.context("Missing thread name")?;
 97                    Ok(Self::TextThread {
 98                        path: path.into(),
 99                        name,
100                    })
101                } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") {
102                    let name = single_query_param(&url, "name")?.context("Missing rule name")?;
103                    let rule_id = UserPromptId(rule_id.parse()?);
104                    Ok(Self::Rule {
105                        id: rule_id.into(),
106                        name,
107                    })
108                } else {
109                    bail!("invalid zed url: {:?}", input);
110                }
111            }
112            "http" | "https" => Ok(MentionUri::Fetch { url }),
113            other => bail!("unrecognized scheme {:?}", other),
114        }
115    }
116
117    pub fn name(&self) -> String {
118        match self {
119            MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path
120                .file_name()
121                .unwrap_or_default()
122                .to_string_lossy()
123                .into_owned(),
124            MentionUri::Symbol { name, .. } => name.clone(),
125            MentionUri::Thread { name, .. } => name.clone(),
126            MentionUri::TextThread { name, .. } => name.clone(),
127            MentionUri::Rule { name, .. } => name.clone(),
128            MentionUri::Selection {
129                path, line_range, ..
130            } => selection_name(path, line_range),
131            MentionUri::Fetch { url } => url.to_string(),
132        }
133    }
134
135    pub fn icon_path(&self, cx: &mut App) -> SharedString {
136        match self {
137            MentionUri::File { abs_path } => {
138                FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())
139            }
140            MentionUri::Directory { .. } => FileIcons::get_folder_icon(false, cx)
141                .unwrap_or_else(|| IconName::Folder.path().into()),
142            MentionUri::Symbol { .. } => IconName::Code.path().into(),
143            MentionUri::Thread { .. } => IconName::Thread.path().into(),
144            MentionUri::TextThread { .. } => IconName::Thread.path().into(),
145            MentionUri::Rule { .. } => IconName::Reader.path().into(),
146            MentionUri::Selection { .. } => IconName::Reader.path().into(),
147            MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
148        }
149    }
150
151    pub fn as_link<'a>(&'a self) -> MentionLink<'a> {
152        MentionLink(self)
153    }
154
155    pub fn to_uri(&self) -> Url {
156        match self {
157            MentionUri::File { abs_path } => {
158                Url::from_file_path(abs_path).expect("mention path should be absolute")
159            }
160            MentionUri::Directory { abs_path } => {
161                Url::from_directory_path(abs_path).expect("mention path should be absolute")
162            }
163            MentionUri::Symbol {
164                path,
165                name,
166                line_range,
167            } => {
168                let mut url = Url::from_file_path(path).expect("mention path should be absolute");
169                url.query_pairs_mut().append_pair("symbol", name);
170                url.set_fragment(Some(&format!(
171                    "L{}:{}",
172                    line_range.start + 1,
173                    line_range.end + 1
174                )));
175                url
176            }
177            MentionUri::Selection { path, line_range } => {
178                let mut url = Url::from_file_path(path).expect("mention path should be absolute");
179                url.set_fragment(Some(&format!(
180                    "L{}:{}",
181                    line_range.start + 1,
182                    line_range.end + 1
183                )));
184                url
185            }
186            MentionUri::Thread { name, id } => {
187                let mut url = Url::parse("zed:///").unwrap();
188                url.set_path(&format!("/agent/thread/{id}"));
189                url.query_pairs_mut().append_pair("name", name);
190                url
191            }
192            MentionUri::TextThread { path, name } => {
193                let mut url = Url::parse("zed:///").unwrap();
194                url.set_path(&format!("/agent/text-thread/{}", path.to_string_lossy()));
195                url.query_pairs_mut().append_pair("name", name);
196                url
197            }
198            MentionUri::Rule { name, id } => {
199                let mut url = Url::parse("zed:///").unwrap();
200                url.set_path(&format!("/agent/rule/{id}"));
201                url.query_pairs_mut().append_pair("name", name);
202                url
203            }
204            MentionUri::Fetch { url } => url.clone(),
205        }
206    }
207}
208
209impl FromStr for MentionUri {
210    type Err = anyhow::Error;
211
212    fn from_str(s: &str) -> anyhow::Result<Self> {
213        Self::parse(s)
214    }
215}
216
217pub struct MentionLink<'a>(&'a MentionUri);
218
219impl fmt::Display for MentionLink<'_> {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
222    }
223}
224
225fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
226    let pairs = url.query_pairs().collect::<Vec<_>>();
227    match pairs.as_slice() {
228        [] => Ok(None),
229        [(k, v)] => {
230            if k != name {
231                bail!("invalid query parameter")
232            }
233
234            Ok(Some(v.to_string()))
235        }
236        _ => bail!("too many query pairs"),
237    }
238}
239
240pub fn selection_name(path: &Path, line_range: &Range<u32>) -> String {
241    format!(
242        "{} ({}:{})",
243        path.file_name().unwrap_or_default().display(),
244        line_range.start + 1,
245        line_range.end + 1
246    )
247}
248
249#[cfg(test)]
250mod tests {
251    use util::{path, uri};
252
253    use super::*;
254
255    #[test]
256    fn test_parse_file_uri() {
257        let file_uri = uri!("file:///path/to/file.rs");
258        let parsed = MentionUri::parse(file_uri).unwrap();
259        match &parsed {
260            MentionUri::File { abs_path } => {
261                assert_eq!(abs_path.to_str().unwrap(), path!("/path/to/file.rs"));
262            }
263            _ => panic!("Expected File variant"),
264        }
265        assert_eq!(parsed.to_uri().to_string(), file_uri);
266    }
267
268    #[test]
269    fn test_parse_directory_uri() {
270        let file_uri = uri!("file:///path/to/dir/");
271        let parsed = MentionUri::parse(file_uri).unwrap();
272        match &parsed {
273            MentionUri::Directory { abs_path } => {
274                assert_eq!(abs_path.to_str().unwrap(), path!("/path/to/dir/"));
275            }
276            _ => panic!("Expected Directory variant"),
277        }
278        assert_eq!(parsed.to_uri().to_string(), file_uri);
279    }
280
281    #[test]
282    fn test_to_directory_uri_with_slash() {
283        let uri = MentionUri::Directory {
284            abs_path: PathBuf::from(path!("/path/to/dir/")),
285        };
286        let expected = uri!("file:///path/to/dir/");
287        assert_eq!(uri.to_uri().to_string(), expected);
288    }
289
290    #[test]
291    fn test_to_directory_uri_without_slash() {
292        let uri = MentionUri::Directory {
293            abs_path: PathBuf::from(path!("/path/to/dir")),
294        };
295        let expected = uri!("file:///path/to/dir/");
296        assert_eq!(uri.to_uri().to_string(), expected);
297    }
298
299    #[test]
300    fn test_parse_symbol_uri() {
301        let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20");
302        let parsed = MentionUri::parse(symbol_uri).unwrap();
303        match &parsed {
304            MentionUri::Symbol {
305                path,
306                name,
307                line_range,
308            } => {
309                assert_eq!(path.to_str().unwrap(), path!("/path/to/file.rs"));
310                assert_eq!(name, "MySymbol");
311                assert_eq!(line_range.start, 9);
312                assert_eq!(line_range.end, 19);
313            }
314            _ => panic!("Expected Symbol variant"),
315        }
316        assert_eq!(parsed.to_uri().to_string(), symbol_uri);
317    }
318
319    #[test]
320    fn test_parse_selection_uri() {
321        let selection_uri = uri!("file:///path/to/file.rs#L5:15");
322        let parsed = MentionUri::parse(selection_uri).unwrap();
323        match &parsed {
324            MentionUri::Selection { path, line_range } => {
325                assert_eq!(path.to_str().unwrap(), path!("/path/to/file.rs"));
326                assert_eq!(line_range.start, 4);
327                assert_eq!(line_range.end, 14);
328            }
329            _ => panic!("Expected Selection variant"),
330        }
331        assert_eq!(parsed.to_uri().to_string(), selection_uri);
332    }
333
334    #[test]
335    fn test_parse_thread_uri() {
336        let thread_uri = "zed:///agent/thread/session123?name=Thread+name";
337        let parsed = MentionUri::parse(thread_uri).unwrap();
338        match &parsed {
339            MentionUri::Thread {
340                id: thread_id,
341                name,
342            } => {
343                assert_eq!(thread_id.to_string(), "session123");
344                assert_eq!(name, "Thread name");
345            }
346            _ => panic!("Expected Thread variant"),
347        }
348        assert_eq!(parsed.to_uri().to_string(), thread_uri);
349    }
350
351    #[test]
352    fn test_parse_rule_uri() {
353        let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
354        let parsed = MentionUri::parse(rule_uri).unwrap();
355        match &parsed {
356            MentionUri::Rule { id, name } => {
357                assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52");
358                assert_eq!(name, "Some rule");
359            }
360            _ => panic!("Expected Rule variant"),
361        }
362        assert_eq!(parsed.to_uri().to_string(), rule_uri);
363    }
364
365    #[test]
366    fn test_parse_fetch_http_uri() {
367        let http_uri = "http://example.com/path?query=value#fragment";
368        let parsed = MentionUri::parse(http_uri).unwrap();
369        match &parsed {
370            MentionUri::Fetch { url } => {
371                assert_eq!(url.to_string(), http_uri);
372            }
373            _ => panic!("Expected Fetch variant"),
374        }
375        assert_eq!(parsed.to_uri().to_string(), http_uri);
376    }
377
378    #[test]
379    fn test_parse_fetch_https_uri() {
380        let https_uri = "https://example.com/api/endpoint";
381        let parsed = MentionUri::parse(https_uri).unwrap();
382        match &parsed {
383            MentionUri::Fetch { url } => {
384                assert_eq!(url.to_string(), https_uri);
385            }
386            _ => panic!("Expected Fetch variant"),
387        }
388        assert_eq!(parsed.to_uri().to_string(), https_uri);
389    }
390
391    #[test]
392    fn test_invalid_scheme() {
393        assert!(MentionUri::parse("ftp://example.com").is_err());
394        assert!(MentionUri::parse("ssh://example.com").is_err());
395        assert!(MentionUri::parse("unknown://example.com").is_err());
396    }
397
398    #[test]
399    fn test_invalid_zed_path() {
400        assert!(MentionUri::parse("zed:///invalid/path").is_err());
401        assert!(MentionUri::parse("zed:///agent/unknown/test").is_err());
402    }
403
404    #[test]
405    fn test_invalid_line_range_format() {
406        // Missing L prefix
407        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#10:20")).is_err());
408
409        // Missing colon separator
410        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L1020")).is_err());
411
412        // Invalid numbers
413        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L10:abc")).is_err());
414        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#Labc:20")).is_err());
415    }
416
417    #[test]
418    fn test_invalid_query_parameters() {
419        // Invalid query parameter name
420        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L10:20?invalid=test")).is_err());
421
422        // Too many query parameters
423        assert!(
424            MentionUri::parse(uri!(
425                "file:///path/to/file.rs#L10:20?symbol=test&another=param"
426            ))
427            .is_err()
428        );
429    }
430
431    #[test]
432    fn test_zero_based_line_numbers() {
433        // Test that 0-based line numbers are rejected (should be 1-based)
434        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L0:10")).is_err());
435        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L1:0")).is_err());
436        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L0:0")).is_err());
437    }
438}