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