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