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