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}