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}