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