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 {
130                    bail!("invalid zed url: {:?}", input);
131                }
132            }
133            "http" | "https" => Ok(MentionUri::Fetch { url }),
134            other => bail!("unrecognized scheme {:?}", other),
135        }
136    }
137
138    pub fn name(&self) -> String {
139        match self {
140            MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path
141                .file_name()
142                .unwrap_or_default()
143                .to_string_lossy()
144                .into_owned(),
145            MentionUri::PastedImage => "Image".to_string(),
146            MentionUri::Symbol { name, .. } => name.clone(),
147            MentionUri::Thread { name, .. } => name.clone(),
148            MentionUri::TextThread { name, .. } => name.clone(),
149            MentionUri::Rule { name, .. } => name.clone(),
150            MentionUri::Selection {
151                abs_path: path,
152                line_range,
153                ..
154            } => selection_name(path.as_deref(), line_range),
155            MentionUri::Fetch { url } => url.to_string(),
156        }
157    }
158
159    pub fn icon_path(&self, cx: &mut App) -> SharedString {
160        match self {
161            MentionUri::File { abs_path } => {
162                FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())
163            }
164            MentionUri::PastedImage => IconName::Image.path().into(),
165            MentionUri::Directory { abs_path } => FileIcons::get_folder_icon(false, abs_path, cx)
166                .unwrap_or_else(|| IconName::Folder.path().into()),
167            MentionUri::Symbol { .. } => IconName::Code.path().into(),
168            MentionUri::Thread { .. } => IconName::Thread.path().into(),
169            MentionUri::TextThread { .. } => IconName::Thread.path().into(),
170            MentionUri::Rule { .. } => IconName::Reader.path().into(),
171            MentionUri::Selection { .. } => IconName::Reader.path().into(),
172            MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
173        }
174    }
175
176    pub fn as_link<'a>(&'a self) -> MentionLink<'a> {
177        MentionLink(self)
178    }
179
180    pub fn to_uri(&self) -> Url {
181        match self {
182            MentionUri::File { abs_path } => {
183                Url::from_file_path(abs_path).expect("mention path should be absolute")
184            }
185            MentionUri::PastedImage => Url::parse("zed:///agent/pasted-image").unwrap(),
186            MentionUri::Directory { abs_path } => {
187                Url::from_directory_path(abs_path).expect("mention path should be absolute")
188            }
189            MentionUri::Symbol {
190                abs_path,
191                name,
192                line_range,
193            } => {
194                let mut url =
195                    Url::from_file_path(abs_path).expect("mention path should be absolute");
196                url.query_pairs_mut().append_pair("symbol", name);
197                url.set_fragment(Some(&format!(
198                    "L{}:{}",
199                    line_range.start() + 1,
200                    line_range.end() + 1
201                )));
202                url
203            }
204            MentionUri::Selection {
205                abs_path: path,
206                line_range,
207            } => {
208                let mut url = if let Some(path) = path {
209                    Url::from_file_path(path).expect("mention path should be absolute")
210                } else {
211                    let mut url = Url::parse("zed:///").unwrap();
212                    url.set_path("/agent/untitled-buffer");
213                    url
214                };
215                url.set_fragment(Some(&format!(
216                    "L{}:{}",
217                    line_range.start() + 1,
218                    line_range.end() + 1
219                )));
220                url
221            }
222            MentionUri::Thread { name, id } => {
223                let mut url = Url::parse("zed:///").unwrap();
224                url.set_path(&format!("/agent/thread/{id}"));
225                url.query_pairs_mut().append_pair("name", name);
226                url
227            }
228            MentionUri::TextThread { path, name } => {
229                let mut url = Url::parse("zed:///").unwrap();
230                url.set_path(&format!(
231                    "/agent/text-thread/{}",
232                    path.to_string_lossy().trim_start_matches('/')
233                ));
234                url.query_pairs_mut().append_pair("name", name);
235                url
236            }
237            MentionUri::Rule { name, id } => {
238                let mut url = Url::parse("zed:///").unwrap();
239                url.set_path(&format!("/agent/rule/{id}"));
240                url.query_pairs_mut().append_pair("name", name);
241                url
242            }
243            MentionUri::Fetch { url } => url.clone(),
244        }
245    }
246}
247
248impl FromStr for MentionUri {
249    type Err = anyhow::Error;
250
251    fn from_str(s: &str) -> anyhow::Result<Self> {
252        Self::parse(s)
253    }
254}
255
256pub struct MentionLink<'a>(&'a MentionUri);
257
258impl fmt::Display for MentionLink<'_> {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
261    }
262}
263
264fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
265    let pairs = url.query_pairs().collect::<Vec<_>>();
266    match pairs.as_slice() {
267        [] => Ok(None),
268        [(k, v)] => {
269            if k != name {
270                bail!("invalid query parameter")
271            }
272
273            Ok(Some(v.to_string()))
274        }
275        _ => bail!("too many query pairs"),
276    }
277}
278
279pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive<u32>) -> String {
280    format!(
281        "{} ({}:{})",
282        path.and_then(|path| path.file_name())
283            .unwrap_or("Untitled".as_ref())
284            .display(),
285        *line_range.start() + 1,
286        *line_range.end() + 1
287    )
288}
289
290#[cfg(test)]
291mod tests {
292    use util::{path, uri};
293
294    use super::*;
295
296    #[test]
297    fn test_parse_file_uri() {
298        let file_uri = uri!("file:///path/to/file.rs");
299        let parsed = MentionUri::parse(file_uri).unwrap();
300        match &parsed {
301            MentionUri::File { abs_path } => {
302                assert_eq!(abs_path.to_str().unwrap(), path!("/path/to/file.rs"));
303            }
304            _ => panic!("Expected File variant"),
305        }
306        assert_eq!(parsed.to_uri().to_string(), file_uri);
307    }
308
309    #[test]
310    fn test_parse_directory_uri() {
311        let file_uri = uri!("file:///path/to/dir/");
312        let parsed = MentionUri::parse(file_uri).unwrap();
313        match &parsed {
314            MentionUri::Directory { abs_path } => {
315                assert_eq!(abs_path.to_str().unwrap(), path!("/path/to/dir/"));
316            }
317            _ => panic!("Expected Directory variant"),
318        }
319        assert_eq!(parsed.to_uri().to_string(), file_uri);
320    }
321
322    #[test]
323    fn test_to_directory_uri_with_slash() {
324        let uri = MentionUri::Directory {
325            abs_path: PathBuf::from(path!("/path/to/dir/")),
326        };
327        let expected = uri!("file:///path/to/dir/");
328        assert_eq!(uri.to_uri().to_string(), expected);
329    }
330
331    #[test]
332    fn test_to_directory_uri_without_slash() {
333        let uri = MentionUri::Directory {
334            abs_path: PathBuf::from(path!("/path/to/dir")),
335        };
336        let expected = uri!("file:///path/to/dir/");
337        assert_eq!(uri.to_uri().to_string(), expected);
338    }
339
340    #[test]
341    fn test_parse_symbol_uri() {
342        let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20");
343        let parsed = MentionUri::parse(symbol_uri).unwrap();
344        match &parsed {
345            MentionUri::Symbol {
346                abs_path: path,
347                name,
348                line_range,
349            } => {
350                assert_eq!(path.to_str().unwrap(), path!("/path/to/file.rs"));
351                assert_eq!(name, "MySymbol");
352                assert_eq!(line_range.start(), &9);
353                assert_eq!(line_range.end(), &19);
354            }
355            _ => panic!("Expected Symbol variant"),
356        }
357        assert_eq!(parsed.to_uri().to_string(), symbol_uri);
358    }
359
360    #[test]
361    fn test_parse_selection_uri() {
362        let selection_uri = uri!("file:///path/to/file.rs#L5:15");
363        let parsed = MentionUri::parse(selection_uri).unwrap();
364        match &parsed {
365            MentionUri::Selection {
366                abs_path: path,
367                line_range,
368            } => {
369                assert_eq!(
370                    path.as_ref().unwrap().to_str().unwrap(),
371                    path!("/path/to/file.rs")
372                );
373                assert_eq!(line_range.start(), &4);
374                assert_eq!(line_range.end(), &14);
375            }
376            _ => panic!("Expected Selection variant"),
377        }
378        assert_eq!(parsed.to_uri().to_string(), selection_uri);
379    }
380
381    #[test]
382    fn test_parse_untitled_selection_uri() {
383        let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10");
384        let parsed = MentionUri::parse(selection_uri).unwrap();
385        match &parsed {
386            MentionUri::Selection {
387                abs_path: None,
388                line_range,
389            } => {
390                assert_eq!(line_range.start(), &0);
391                assert_eq!(line_range.end(), &9);
392            }
393            _ => panic!("Expected Selection variant without path"),
394        }
395        assert_eq!(parsed.to_uri().to_string(), selection_uri);
396    }
397
398    #[test]
399    fn test_parse_thread_uri() {
400        let thread_uri = "zed:///agent/thread/session123?name=Thread+name";
401        let parsed = MentionUri::parse(thread_uri).unwrap();
402        match &parsed {
403            MentionUri::Thread {
404                id: thread_id,
405                name,
406            } => {
407                assert_eq!(thread_id.to_string(), "session123");
408                assert_eq!(name, "Thread name");
409            }
410            _ => panic!("Expected Thread variant"),
411        }
412        assert_eq!(parsed.to_uri().to_string(), thread_uri);
413    }
414
415    #[test]
416    fn test_parse_rule_uri() {
417        let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
418        let parsed = MentionUri::parse(rule_uri).unwrap();
419        match &parsed {
420            MentionUri::Rule { id, name } => {
421                assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52");
422                assert_eq!(name, "Some rule");
423            }
424            _ => panic!("Expected Rule variant"),
425        }
426        assert_eq!(parsed.to_uri().to_string(), rule_uri);
427    }
428
429    #[test]
430    fn test_parse_fetch_http_uri() {
431        let http_uri = "http://example.com/path?query=value#fragment";
432        let parsed = MentionUri::parse(http_uri).unwrap();
433        match &parsed {
434            MentionUri::Fetch { url } => {
435                assert_eq!(url.to_string(), http_uri);
436            }
437            _ => panic!("Expected Fetch variant"),
438        }
439        assert_eq!(parsed.to_uri().to_string(), http_uri);
440    }
441
442    #[test]
443    fn test_parse_fetch_https_uri() {
444        let https_uri = "https://example.com/api/endpoint";
445        let parsed = MentionUri::parse(https_uri).unwrap();
446        match &parsed {
447            MentionUri::Fetch { url } => {
448                assert_eq!(url.to_string(), https_uri);
449            }
450            _ => panic!("Expected Fetch variant"),
451        }
452        assert_eq!(parsed.to_uri().to_string(), https_uri);
453    }
454
455    #[test]
456    fn test_invalid_scheme() {
457        assert!(MentionUri::parse("ftp://example.com").is_err());
458        assert!(MentionUri::parse("ssh://example.com").is_err());
459        assert!(MentionUri::parse("unknown://example.com").is_err());
460    }
461
462    #[test]
463    fn test_invalid_zed_path() {
464        assert!(MentionUri::parse("zed:///invalid/path").is_err());
465        assert!(MentionUri::parse("zed:///agent/unknown/test").is_err());
466    }
467
468    #[test]
469    fn test_invalid_line_range_format() {
470        // Missing L prefix
471        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#10:20")).is_err());
472
473        // Missing colon separator
474        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L1020")).is_err());
475
476        // Invalid numbers
477        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L10:abc")).is_err());
478        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#Labc:20")).is_err());
479    }
480
481    #[test]
482    fn test_invalid_query_parameters() {
483        // Invalid query parameter name
484        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L10:20?invalid=test")).is_err());
485
486        // Too many query parameters
487        assert!(
488            MentionUri::parse(uri!(
489                "file:///path/to/file.rs#L10:20?symbol=test&another=param"
490            ))
491            .is_err()
492        );
493    }
494
495    #[test]
496    fn test_zero_based_line_numbers() {
497        // Test that 0-based line numbers are rejected (should be 1-based)
498        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L0:10")).is_err());
499        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L1:0")).is_err());
500        assert!(MentionUri::parse(uri!("file:///path/to/file.rs#L0:0")).is_err());
501    }
502}