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