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