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