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