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