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