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