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