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