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