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