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