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    borrow::Cow,
  8    fmt,
  9    ops::RangeInclusive,
 10    path::{Path, PathBuf},
 11};
 12use ui::{App, IconName, SharedString};
 13use url::Url;
 14use urlencoding::decode;
 15use util::paths::PathStyle;
 16
 17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
 18pub enum MentionUri {
 19    File {
 20        abs_path: PathBuf,
 21    },
 22    PastedImage,
 23    Directory {
 24        abs_path: PathBuf,
 25    },
 26    Symbol {
 27        abs_path: PathBuf,
 28        name: String,
 29        line_range: RangeInclusive<u32>,
 30    },
 31    Thread {
 32        id: acp::SessionId,
 33        name: String,
 34    },
 35    TextThread {
 36        path: PathBuf,
 37        name: String,
 38    },
 39    Rule {
 40        id: PromptId,
 41        name: String,
 42    },
 43    Selection {
 44        #[serde(default, skip_serializing_if = "Option::is_none")]
 45        abs_path: Option<PathBuf>,
 46        line_range: RangeInclusive<u32>,
 47    },
 48    Fetch {
 49        url: Url,
 50    },
 51}
 52
 53impl MentionUri {
 54    pub fn parse(input: &str, path_style: PathStyle) -> Result<Self> {
 55        fn parse_line_range(fragment: &str) -> Result<RangeInclusive<u32>> {
 56            let range = fragment
 57                .strip_prefix("L")
 58                .context("Line range must start with \"L\"")?;
 59            let (start, end) = range
 60                .split_once(":")
 61                .context("Line range must use colon as separator")?;
 62            let range = start
 63                .parse::<u32>()
 64                .context("Parsing line range start")?
 65                .checked_sub(1)
 66                .context("Line numbers should be 1-based")?
 67                ..=end
 68                    .parse::<u32>()
 69                    .context("Parsing line range end")?
 70                    .checked_sub(1)
 71                    .context("Line numbers should be 1-based")?;
 72            Ok(range)
 73        }
 74
 75        let url = url::Url::parse(input)?;
 76        let path = url.path();
 77        match url.scheme() {
 78            "file" => {
 79                let normalized = if path_style.is_windows() {
 80                    path.trim_start_matches("/")
 81                } else {
 82                    path
 83                };
 84                let decoded = decode(normalized).unwrap_or(Cow::Borrowed(normalized));
 85                let path = decoded.as_ref();
 86
 87                if let Some(fragment) = url.fragment() {
 88                    let line_range = parse_line_range(fragment)?;
 89                    if let Some(name) = single_query_param(&url, "symbol")? {
 90                        Ok(Self::Symbol {
 91                            name,
 92                            abs_path: path.into(),
 93                            line_range,
 94                        })
 95                    } else {
 96                        Ok(Self::Selection {
 97                            abs_path: Some(path.into()),
 98                            line_range,
 99                        })
100                    }
101                } else if input.ends_with("/") {
102                    Ok(Self::Directory {
103                        abs_path: path.into(),
104                    })
105                } else {
106                    Ok(Self::File {
107                        abs_path: path.into(),
108                    })
109                }
110            }
111            "zed" => {
112                if let Some(thread_id) = path.strip_prefix("/agent/thread/") {
113                    let name = single_query_param(&url, "name")?.context("Missing thread name")?;
114                    Ok(Self::Thread {
115                        id: acp::SessionId::new(thread_id),
116                        name,
117                    })
118                } else if let Some(path) = path.strip_prefix("/agent/text-thread/") {
119                    let name = single_query_param(&url, "name")?.context("Missing thread name")?;
120                    Ok(Self::TextThread {
121                        path: path.into(),
122                        name,
123                    })
124                } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") {
125                    let name = single_query_param(&url, "name")?.context("Missing rule name")?;
126                    let rule_id = UserPromptId(rule_id.parse()?);
127                    Ok(Self::Rule {
128                        id: rule_id.into(),
129                        name,
130                    })
131                } else if path.starts_with("/agent/pasted-image") {
132                    Ok(Self::PastedImage)
133                } else if path.starts_with("/agent/untitled-buffer") {
134                    let fragment = url
135                        .fragment()
136                        .context("Missing fragment for untitled buffer selection")?;
137                    let line_range = parse_line_range(fragment)?;
138                    Ok(Self::Selection {
139                        abs_path: None,
140                        line_range,
141                    })
142                } else if let Some(name) = path.strip_prefix("/agent/symbol/") {
143                    let fragment = url
144                        .fragment()
145                        .context("Missing fragment for untitled buffer selection")?;
146                    let line_range = parse_line_range(fragment)?;
147                    let path =
148                        single_query_param(&url, "path")?.context("Missing path for symbol")?;
149                    Ok(Self::Symbol {
150                        name: name.to_string(),
151                        abs_path: path.into(),
152                        line_range,
153                    })
154                } else if path.starts_with("/agent/file") {
155                    let path =
156                        single_query_param(&url, "path")?.context("Missing path for file")?;
157                    Ok(Self::File {
158                        abs_path: path.into(),
159                    })
160                } else if path.starts_with("/agent/directory") {
161                    let path =
162                        single_query_param(&url, "path")?.context("Missing path for directory")?;
163                    Ok(Self::Directory {
164                        abs_path: path.into(),
165                    })
166                } else if path.starts_with("/agent/selection") {
167                    let fragment = url.fragment().context("Missing fragment for selection")?;
168                    let line_range = parse_line_range(fragment)?;
169                    let path =
170                        single_query_param(&url, "path")?.context("Missing path for selection")?;
171                    Ok(Self::Selection {
172                        abs_path: Some(path.into()),
173                        line_range,
174                    })
175                } else {
176                    bail!("invalid zed url: {:?}", input);
177                }
178            }
179            "http" | "https" => Ok(MentionUri::Fetch { url }),
180            other => bail!("unrecognized scheme {:?}", other),
181        }
182    }
183
184    pub fn name(&self) -> String {
185        match self {
186            MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path
187                .file_name()
188                .unwrap_or_default()
189                .to_string_lossy()
190                .into_owned(),
191            MentionUri::PastedImage => "Image".to_string(),
192            MentionUri::Symbol { name, .. } => name.clone(),
193            MentionUri::Thread { name, .. } => name.clone(),
194            MentionUri::TextThread { name, .. } => name.clone(),
195            MentionUri::Rule { name, .. } => name.clone(),
196            MentionUri::Selection {
197                abs_path: path,
198                line_range,
199                ..
200            } => selection_name(path.as_deref(), line_range),
201            MentionUri::Fetch { url } => url.to_string(),
202        }
203    }
204
205    pub fn icon_path(&self, cx: &mut App) -> SharedString {
206        match self {
207            MentionUri::File { abs_path } => {
208                FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())
209            }
210            MentionUri::PastedImage => IconName::Image.path().into(),
211            MentionUri::Directory { abs_path } => FileIcons::get_folder_icon(false, abs_path, cx)
212                .unwrap_or_else(|| IconName::Folder.path().into()),
213            MentionUri::Symbol { .. } => IconName::Code.path().into(),
214            MentionUri::Thread { .. } => IconName::Thread.path().into(),
215            MentionUri::TextThread { .. } => IconName::Thread.path().into(),
216            MentionUri::Rule { .. } => IconName::Reader.path().into(),
217            MentionUri::Selection { .. } => IconName::Reader.path().into(),
218            MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
219        }
220    }
221
222    pub fn as_link<'a>(&'a self) -> MentionLink<'a> {
223        MentionLink(self)
224    }
225
226    pub fn to_uri(&self) -> Url {
227        match self {
228            MentionUri::File { abs_path } => {
229                let mut url = Url::parse("file:///").unwrap();
230                url.set_path(&abs_path.to_string_lossy());
231                url
232            }
233            MentionUri::PastedImage => Url::parse("zed:///agent/pasted-image").unwrap(),
234            MentionUri::Directory { abs_path } => {
235                let mut url = Url::parse("file:///").unwrap();
236                url.set_path(&abs_path.to_string_lossy());
237                url
238            }
239            MentionUri::Symbol {
240                abs_path,
241                name,
242                line_range,
243            } => {
244                let mut url = Url::parse("file:///").unwrap();
245                url.set_path(&abs_path.to_string_lossy());
246                url.query_pairs_mut().append_pair("symbol", name);
247                url.set_fragment(Some(&format!(
248                    "L{}:{}",
249                    line_range.start() + 1,
250                    line_range.end() + 1
251                )));
252                url
253            }
254            MentionUri::Selection {
255                abs_path,
256                line_range,
257            } => {
258                let mut url = if let Some(path) = abs_path {
259                    let mut url = Url::parse("file:///").unwrap();
260                    url.set_path(&path.to_string_lossy());
261                    url
262                } else {
263                    let mut url = Url::parse("zed:///").unwrap();
264                    url.set_path("/agent/untitled-buffer");
265                    url
266                };
267                url.set_fragment(Some(&format!(
268                    "L{}:{}",
269                    line_range.start() + 1,
270                    line_range.end() + 1
271                )));
272                url
273            }
274            MentionUri::Thread { name, id } => {
275                let mut url = Url::parse("zed:///").unwrap();
276                url.set_path(&format!("/agent/thread/{id}"));
277                url.query_pairs_mut().append_pair("name", name);
278                url
279            }
280            MentionUri::TextThread { path, name } => {
281                let mut url = Url::parse("zed:///").unwrap();
282                url.set_path(&format!(
283                    "/agent/text-thread/{}",
284                    path.to_string_lossy().trim_start_matches('/')
285                ));
286                url.query_pairs_mut().append_pair("name", name);
287                url
288            }
289            MentionUri::Rule { name, id } => {
290                let mut url = Url::parse("zed:///").unwrap();
291                url.set_path(&format!("/agent/rule/{id}"));
292                url.query_pairs_mut().append_pair("name", name);
293                url
294            }
295            MentionUri::Fetch { url } => url.clone(),
296        }
297    }
298}
299
300pub struct MentionLink<'a>(&'a MentionUri);
301
302impl fmt::Display for MentionLink<'_> {
303    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304        write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
305    }
306}
307
308fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
309    let pairs = url.query_pairs().collect::<Vec<_>>();
310    match pairs.as_slice() {
311        [] => Ok(None),
312        [(k, v)] => {
313            if k != name {
314                bail!("invalid query parameter")
315            }
316
317            Ok(Some(v.to_string()))
318        }
319        _ => bail!("too many query pairs"),
320    }
321}
322
323pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive<u32>) -> String {
324    format!(
325        "{} ({}:{})",
326        path.and_then(|path| path.file_name())
327            .unwrap_or("Untitled".as_ref())
328            .display(),
329        *line_range.start() + 1,
330        *line_range.end() + 1
331    )
332}
333
334#[cfg(test)]
335mod tests {
336    use util::{path, uri};
337
338    use super::*;
339
340    #[test]
341    fn test_parse_file_uri() {
342        let file_uri = uri!("file:///path/to/file.rs");
343        let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
344        match &parsed {
345            MentionUri::File { abs_path } => {
346                assert_eq!(abs_path, Path::new(path!("/path/to/file.rs")));
347            }
348            _ => panic!("Expected File variant"),
349        }
350        assert_eq!(parsed.to_uri().to_string(), file_uri);
351    }
352
353    #[test]
354    fn test_parse_directory_uri() {
355        let file_uri = uri!("file:///path/to/dir/");
356        let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
357        match &parsed {
358            MentionUri::Directory { abs_path } => {
359                assert_eq!(abs_path, Path::new(path!("/path/to/dir/")));
360            }
361            _ => panic!("Expected Directory variant"),
362        }
363        assert_eq!(parsed.to_uri().to_string(), file_uri);
364    }
365
366    #[test]
367    fn test_to_directory_uri_without_slash() {
368        let uri = MentionUri::Directory {
369            abs_path: PathBuf::from(path!("/path/to/dir/")),
370        };
371        let expected = uri!("file:///path/to/dir/");
372        assert_eq!(uri.to_uri().to_string(), expected);
373    }
374
375    #[test]
376    fn test_parse_symbol_uri() {
377        let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20");
378        let parsed = MentionUri::parse(symbol_uri, PathStyle::local()).unwrap();
379        match &parsed {
380            MentionUri::Symbol {
381                abs_path: path,
382                name,
383                line_range,
384            } => {
385                assert_eq!(path, Path::new(path!("/path/to/file.rs")));
386                assert_eq!(name, "MySymbol");
387                assert_eq!(line_range.start(), &9);
388                assert_eq!(line_range.end(), &19);
389            }
390            _ => panic!("Expected Symbol variant"),
391        }
392        assert_eq!(parsed.to_uri().to_string(), symbol_uri);
393    }
394
395    #[test]
396    fn test_parse_selection_uri() {
397        let selection_uri = uri!("file:///path/to/file.rs#L5:15");
398        let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
399        match &parsed {
400            MentionUri::Selection {
401                abs_path: path,
402                line_range,
403            } => {
404                assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
405                assert_eq!(line_range.start(), &4);
406                assert_eq!(line_range.end(), &14);
407            }
408            _ => panic!("Expected Selection variant"),
409        }
410        assert_eq!(parsed.to_uri().to_string(), selection_uri);
411    }
412
413    #[test]
414    fn test_parse_file_uri_with_non_ascii() {
415        let file_uri = uri!("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt");
416        let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
417        match &parsed {
418            MentionUri::File { abs_path } => {
419                assert_eq!(abs_path, Path::new(path!("/path/to/日本語.txt")));
420            }
421            _ => panic!("Expected File variant"),
422        }
423        assert_eq!(parsed.to_uri().to_string(), file_uri);
424    }
425
426    #[test]
427    fn test_parse_untitled_selection_uri() {
428        let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10");
429        let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
430        match &parsed {
431            MentionUri::Selection {
432                abs_path: None,
433                line_range,
434            } => {
435                assert_eq!(line_range.start(), &0);
436                assert_eq!(line_range.end(), &9);
437            }
438            _ => panic!("Expected Selection variant without path"),
439        }
440        assert_eq!(parsed.to_uri().to_string(), selection_uri);
441    }
442
443    #[test]
444    fn test_parse_thread_uri() {
445        let thread_uri = "zed:///agent/thread/session123?name=Thread+name";
446        let parsed = MentionUri::parse(thread_uri, PathStyle::local()).unwrap();
447        match &parsed {
448            MentionUri::Thread {
449                id: thread_id,
450                name,
451            } => {
452                assert_eq!(thread_id.to_string(), "session123");
453                assert_eq!(name, "Thread name");
454            }
455            _ => panic!("Expected Thread variant"),
456        }
457        assert_eq!(parsed.to_uri().to_string(), thread_uri);
458    }
459
460    #[test]
461    fn test_parse_rule_uri() {
462        let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
463        let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap();
464        match &parsed {
465            MentionUri::Rule { id, name } => {
466                assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52");
467                assert_eq!(name, "Some rule");
468            }
469            _ => panic!("Expected Rule variant"),
470        }
471        assert_eq!(parsed.to_uri().to_string(), rule_uri);
472    }
473
474    #[test]
475    fn test_parse_fetch_http_uri() {
476        let http_uri = "http://example.com/path?query=value#fragment";
477        let parsed = MentionUri::parse(http_uri, PathStyle::local()).unwrap();
478        match &parsed {
479            MentionUri::Fetch { url } => {
480                assert_eq!(url.to_string(), http_uri);
481            }
482            _ => panic!("Expected Fetch variant"),
483        }
484        assert_eq!(parsed.to_uri().to_string(), http_uri);
485    }
486
487    #[test]
488    fn test_parse_fetch_https_uri() {
489        let https_uri = "https://example.com/api/endpoint";
490        let parsed = MentionUri::parse(https_uri, PathStyle::local()).unwrap();
491        match &parsed {
492            MentionUri::Fetch { url } => {
493                assert_eq!(url.to_string(), https_uri);
494            }
495            _ => panic!("Expected Fetch variant"),
496        }
497        assert_eq!(parsed.to_uri().to_string(), https_uri);
498    }
499
500    #[test]
501    fn test_invalid_scheme() {
502        assert!(MentionUri::parse("ftp://example.com", PathStyle::local()).is_err());
503        assert!(MentionUri::parse("ssh://example.com", PathStyle::local()).is_err());
504        assert!(MentionUri::parse("unknown://example.com", PathStyle::local()).is_err());
505    }
506
507    #[test]
508    fn test_invalid_zed_path() {
509        assert!(MentionUri::parse("zed:///invalid/path", PathStyle::local()).is_err());
510        assert!(MentionUri::parse("zed:///agent/unknown/test", PathStyle::local()).is_err());
511    }
512
513    #[test]
514    fn test_invalid_line_range_format() {
515        // Missing L prefix
516        assert!(
517            MentionUri::parse(uri!("file:///path/to/file.rs#10:20"), PathStyle::local()).is_err()
518        );
519
520        // Missing colon separator
521        assert!(
522            MentionUri::parse(uri!("file:///path/to/file.rs#L1020"), PathStyle::local()).is_err()
523        );
524
525        // Invalid numbers
526        assert!(
527            MentionUri::parse(uri!("file:///path/to/file.rs#L10:abc"), PathStyle::local()).is_err()
528        );
529        assert!(
530            MentionUri::parse(uri!("file:///path/to/file.rs#Labc:20"), PathStyle::local()).is_err()
531        );
532    }
533
534    #[test]
535    fn test_invalid_query_parameters() {
536        // Invalid query parameter name
537        assert!(
538            MentionUri::parse(
539                uri!("file:///path/to/file.rs#L10:20?invalid=test"),
540                PathStyle::local()
541            )
542            .is_err()
543        );
544
545        // Too many query parameters
546        assert!(
547            MentionUri::parse(
548                uri!("file:///path/to/file.rs#L10:20?symbol=test&another=param"),
549                PathStyle::local()
550            )
551            .is_err()
552        );
553    }
554
555    #[test]
556    fn test_zero_based_line_numbers() {
557        // Test that 0-based line numbers are rejected (should be 1-based)
558        assert!(
559            MentionUri::parse(uri!("file:///path/to/file.rs#L0:10"), PathStyle::local()).is_err()
560        );
561        assert!(
562            MentionUri::parse(uri!("file:///path/to/file.rs#L1:0"), PathStyle::local()).is_err()
563        );
564        assert!(
565            MentionUri::parse(uri!("file:///path/to/file.rs#L0:0"), PathStyle::local()).is_err()
566        );
567    }
568}