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