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