1use std::ops::Range;
2
3use anyhow::{anyhow, Result};
4use language::Point;
5use url::Url;
6
7enum GitHostingProvider {
8 Github,
9 Gitlab,
10 Gitee,
11 BitbucketCloud,
12}
13
14impl GitHostingProvider {
15 fn base_url(&self) -> Url {
16 let base_url = match self {
17 Self::Github => "https://github.com",
18 Self::Gitlab => "https://gitlab.com",
19 Self::Gitee => "https://gitee.com",
20 Self::BitbucketCloud => "https://bitbucket.org",
21 };
22
23 Url::parse(&base_url).unwrap()
24 }
25
26 /// Returns the fragment portion of the URL for the selected lines in
27 /// the representation the [`GitHostingProvider`] expects.
28 fn line_fragment(&self, selection: &Range<Point>) -> String {
29 if selection.start.row == selection.end.row {
30 let line = selection.start.row + 1;
31
32 match self {
33 Self::Github | Self::Gitlab | Self::Gitee => format!("L{}", line),
34 Self::BitbucketCloud => format!("lines-{}", line),
35 }
36 } else {
37 let start_line = selection.start.row + 1;
38 let end_line = selection.end.row + 1;
39
40 match self {
41 Self::Github => format!("L{}-L{}", start_line, end_line),
42 Self::Gitlab | Self::Gitee => format!("L{}-{}", start_line, end_line),
43 Self::BitbucketCloud => format!("lines-{}:{}", start_line, end_line),
44 }
45 }
46 }
47}
48
49pub fn build_permalink(
50 remote_url: &str,
51 sha: &str,
52 path: &str,
53 selection: Option<Range<Point>>,
54) -> Result<Url> {
55 let ParsedGitRemote {
56 provider,
57 owner,
58 repo,
59 } = parse_git_remote_url(remote_url)
60 .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
61
62 let path = match provider {
63 GitHostingProvider::Github => format!("{owner}/{repo}/blob/{sha}/{path}"),
64 GitHostingProvider::Gitlab => format!("{owner}/{repo}/-/blob/{sha}/{path}"),
65 GitHostingProvider::Gitee => format!("{owner}/{repo}/blob/{sha}/{path}"),
66 GitHostingProvider::BitbucketCloud => format!("{owner}/{repo}/src/{sha}/{path}"),
67 };
68 let line_fragment = selection.map(|selection| provider.line_fragment(&selection));
69
70 let mut permalink = provider.base_url().join(&path).unwrap();
71 permalink.set_fragment(line_fragment.as_deref());
72
73 Ok(permalink)
74}
75
76struct ParsedGitRemote<'a> {
77 pub provider: GitHostingProvider,
78 pub owner: &'a str,
79 pub repo: &'a str,
80}
81
82fn parse_git_remote_url(url: &str) -> Option<ParsedGitRemote> {
83 if url.starts_with("git@github.com:") || url.starts_with("https://github.com/") {
84 let repo_with_owner = url
85 .trim_start_matches("git@github.com:")
86 .trim_start_matches("https://github.com/")
87 .trim_end_matches(".git");
88
89 let (owner, repo) = repo_with_owner.split_once("/")?;
90
91 return Some(ParsedGitRemote {
92 provider: GitHostingProvider::Github,
93 owner,
94 repo,
95 });
96 }
97
98 if url.starts_with("git@gitlab.com:") || url.starts_with("https://gitlab.com/") {
99 let repo_with_owner = url
100 .trim_start_matches("git@gitlab.com:")
101 .trim_start_matches("https://gitlab.com/")
102 .trim_end_matches(".git");
103
104 let (owner, repo) = repo_with_owner.split_once("/")?;
105
106 return Some(ParsedGitRemote {
107 provider: GitHostingProvider::Gitlab,
108 owner,
109 repo,
110 });
111 }
112
113 if url.starts_with("git@gitee.com:") || url.starts_with("https://gitee.com/") {
114 let repo_with_owner = url
115 .trim_start_matches("git@gitee.com:")
116 .trim_start_matches("https://gitee.com/")
117 .trim_end_matches(".git");
118
119 let (owner, repo) = repo_with_owner.split_once("/")?;
120
121 return Some(ParsedGitRemote {
122 provider: GitHostingProvider::Gitee,
123 owner,
124 repo,
125 });
126 }
127
128 if url.contains("bitbucket.org") {
129 let (_, repo_with_owner) = url.trim_end_matches(".git").split_once("bitbucket.org")?;
130 let (owner, repo) = repo_with_owner
131 .trim_start_matches("/")
132 .trim_start_matches(":")
133 .split_once("/")?;
134
135 return Some(ParsedGitRemote {
136 provider: GitHostingProvider::BitbucketCloud,
137 owner,
138 repo,
139 });
140 }
141
142 None
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn test_build_github_permalink_from_ssh_url() {
151 let permalink = build_permalink(
152 "git@github.com:zed-industries/zed.git",
153 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
154 "crates/editor/src/git/permalink.rs",
155 None,
156 )
157 .unwrap();
158
159 let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
160 assert_eq!(permalink.to_string(), expected_url.to_string())
161 }
162
163 #[test]
164 fn test_build_github_permalink_from_ssh_url_single_line_selection() {
165 let permalink = build_permalink(
166 "git@github.com:zed-industries/zed.git",
167 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
168 "crates/editor/src/git/permalink.rs",
169 Some(Point::new(6, 1)..Point::new(6, 10)),
170 )
171 .unwrap();
172
173 let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
174 assert_eq!(permalink.to_string(), expected_url.to_string())
175 }
176
177 #[test]
178 fn test_build_github_permalink_from_ssh_url_multi_line_selection() {
179 let permalink = build_permalink(
180 "git@github.com:zed-industries/zed.git",
181 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
182 "crates/editor/src/git/permalink.rs",
183 Some(Point::new(23, 1)..Point::new(47, 10)),
184 )
185 .unwrap();
186
187 let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
188 assert_eq!(permalink.to_string(), expected_url.to_string())
189 }
190
191 #[test]
192 fn test_build_github_permalink_from_https_url() {
193 let permalink = build_permalink(
194 "https://github.com/zed-industries/zed.git",
195 "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
196 "crates/zed/src/main.rs",
197 None,
198 )
199 .unwrap();
200
201 let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
202 assert_eq!(permalink.to_string(), expected_url.to_string())
203 }
204
205 #[test]
206 fn test_build_github_permalink_from_https_url_single_line_selection() {
207 let permalink = build_permalink(
208 "https://github.com/zed-industries/zed.git",
209 "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
210 "crates/zed/src/main.rs",
211 Some(Point::new(6, 1)..Point::new(6, 10)),
212 )
213 .unwrap();
214
215 let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
216 assert_eq!(permalink.to_string(), expected_url.to_string())
217 }
218
219 #[test]
220 fn test_build_github_permalink_from_https_url_multi_line_selection() {
221 let permalink = build_permalink(
222 "https://github.com/zed-industries/zed.git",
223 "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
224 "crates/zed/src/main.rs",
225 Some(Point::new(23, 1)..Point::new(47, 10)),
226 )
227 .unwrap();
228
229 let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-L48";
230 assert_eq!(permalink.to_string(), expected_url.to_string())
231 }
232
233 #[test]
234 fn test_build_gitlab_permalink_from_ssh_url() {
235 let permalink = build_permalink(
236 "git@gitlab.com:zed-industries/zed.git",
237 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
238 "crates/editor/src/git/permalink.rs",
239 None,
240 )
241 .unwrap();
242
243 let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
244 assert_eq!(permalink.to_string(), expected_url.to_string())
245 }
246
247 #[test]
248 fn test_build_gitlab_permalink_from_ssh_url_single_line_selection() {
249 let permalink = build_permalink(
250 "git@gitlab.com:zed-industries/zed.git",
251 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
252 "crates/editor/src/git/permalink.rs",
253 Some(Point::new(6, 1)..Point::new(6, 10)),
254 )
255 .unwrap();
256
257 let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
258 assert_eq!(permalink.to_string(), expected_url.to_string())
259 }
260
261 #[test]
262 fn test_build_gitlab_permalink_from_ssh_url_multi_line_selection() {
263 let permalink = build_permalink(
264 "git@gitlab.com:zed-industries/zed.git",
265 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
266 "crates/editor/src/git/permalink.rs",
267 Some(Point::new(23, 1)..Point::new(47, 10)),
268 )
269 .unwrap();
270
271 let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-48";
272 assert_eq!(permalink.to_string(), expected_url.to_string())
273 }
274
275 #[test]
276 fn test_build_gitlab_permalink_from_https_url() {
277 let permalink = build_permalink(
278 "https://gitlab.com/zed-industries/zed.git",
279 "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
280 "crates/zed/src/main.rs",
281 None,
282 )
283 .unwrap();
284
285 let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
286 assert_eq!(permalink.to_string(), expected_url.to_string())
287 }
288
289 #[test]
290 fn test_build_gitlab_permalink_from_https_url_single_line_selection() {
291 let permalink = build_permalink(
292 "https://gitlab.com/zed-industries/zed.git",
293 "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
294 "crates/zed/src/main.rs",
295 Some(Point::new(6, 1)..Point::new(6, 10)),
296 )
297 .unwrap();
298
299 let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
300 assert_eq!(permalink.to_string(), expected_url.to_string())
301 }
302
303 #[test]
304 fn test_build_gitlab_permalink_from_https_url_multi_line_selection() {
305 let permalink = build_permalink(
306 "https://gitlab.com/zed-industries/zed.git",
307 "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
308 "crates/zed/src/main.rs",
309 Some(Point::new(23, 1)..Point::new(47, 10)),
310 )
311 .unwrap();
312
313 let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-48";
314 assert_eq!(permalink.to_string(), expected_url.to_string())
315 }
316
317 #[test]
318 fn test_build_gitee_permalink_from_ssh_url() {
319 let permalink = build_permalink(
320 "git@gitee.com:libkitten/zed.git",
321 "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
322 "crates/editor/src/git/permalink.rs",
323 None,
324 )
325 .unwrap();
326
327 let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs";
328 assert_eq!(permalink.to_string(), expected_url.to_string())
329 }
330
331 #[test]
332 fn test_build_gitee_permalink_from_ssh_url_single_line_selection() {
333 let permalink = build_permalink(
334 "git@gitee.com:libkitten/zed.git",
335 "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
336 "crates/editor/src/git/permalink.rs",
337 Some(Point::new(6, 1)..Point::new(6, 10)),
338 )
339 .unwrap();
340
341 let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L7";
342 assert_eq!(permalink.to_string(), expected_url.to_string())
343 }
344
345 #[test]
346 fn test_build_gitee_permalink_from_ssh_url_multi_line_selection() {
347 let permalink = build_permalink(
348 "git@gitee.com:libkitten/zed.git",
349 "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
350 "crates/editor/src/git/permalink.rs",
351 Some(Point::new(23, 1)..Point::new(47, 10)),
352 )
353 .unwrap();
354
355 let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L24-48";
356 assert_eq!(permalink.to_string(), expected_url.to_string())
357 }
358
359 #[test]
360 fn test_build_gitee_permalink_from_https_url() {
361 let permalink = build_permalink(
362 "https://gitee.com/libkitten/zed.git",
363 "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
364 "crates/zed/src/main.rs",
365 None,
366 )
367 .unwrap();
368
369 let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs";
370 assert_eq!(permalink.to_string(), expected_url.to_string())
371 }
372
373 #[test]
374 fn test_build_gitee_permalink_from_https_url_single_line_selection() {
375 let permalink = build_permalink(
376 "https://gitee.com/libkitten/zed.git",
377 "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
378 "crates/zed/src/main.rs",
379 Some(Point::new(6, 1)..Point::new(6, 10)),
380 )
381 .unwrap();
382
383 let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs#L7";
384 assert_eq!(permalink.to_string(), expected_url.to_string())
385 }
386
387 #[test]
388 fn test_build_gitee_permalink_from_https_url_multi_line_selection() {
389 let permalink = build_permalink(
390 "https://gitee.com/libkitten/zed.git",
391 "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
392 "crates/zed/src/main.rs",
393 Some(Point::new(23, 1)..Point::new(47, 10)),
394 )
395 .unwrap();
396 let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs#L24-48";
397 assert_eq!(permalink.to_string(), expected_url.to_string())
398 }
399
400 #[test]
401 fn test_parse_git_remote_url_bitbucket_https_with_username() {
402 let url = "https://thorstenballzed@bitbucket.org/thorstenzed/testingrepo.git";
403 let parsed = parse_git_remote_url(url).unwrap();
404 assert!(matches!(
405 parsed.provider,
406 GitHostingProvider::BitbucketCloud
407 ));
408 assert_eq!(parsed.owner, "thorstenzed");
409 assert_eq!(parsed.repo, "testingrepo");
410 }
411
412 #[test]
413 fn test_parse_git_remote_url_bitbucket_https_without_username() {
414 let url = "https://bitbucket.org/thorstenzed/testingrepo.git";
415 let parsed = parse_git_remote_url(url).unwrap();
416 assert!(matches!(
417 parsed.provider,
418 GitHostingProvider::BitbucketCloud
419 ));
420 assert_eq!(parsed.owner, "thorstenzed");
421 assert_eq!(parsed.repo, "testingrepo");
422 }
423
424 #[test]
425 fn test_parse_git_remote_url_bitbucket_git() {
426 let url = "git@bitbucket.org:thorstenzed/testingrepo.git";
427 let parsed = parse_git_remote_url(url).unwrap();
428 assert!(matches!(
429 parsed.provider,
430 GitHostingProvider::BitbucketCloud
431 ));
432 assert_eq!(parsed.owner, "thorstenzed");
433 assert_eq!(parsed.repo, "testingrepo");
434 }
435
436 #[test]
437 fn test_build_bitbucket_permalink_from_ssh_url() {
438 let permalink = build_permalink(
439 "git@bitbucket.org:thorstenzed/testingrepo.git",
440 "f00b4r",
441 "main.rs",
442 None,
443 )
444 .unwrap();
445
446 let expected_url = "https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs";
447 assert_eq!(permalink.to_string(), expected_url.to_string())
448 }
449
450 #[test]
451 fn test_build_bitbucket_permalink_from_ssh_url_single_line_selection() {
452 let permalink = build_permalink(
453 "git@bitbucket.org:thorstenzed/testingrepo.git",
454 "f00b4r",
455 "main.rs",
456 Some(Point::new(6, 1)..Point::new(6, 10)),
457 )
458 .unwrap();
459
460 let expected_url =
461 "https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs#lines-7";
462 assert_eq!(permalink.to_string(), expected_url.to_string())
463 }
464
465 #[test]
466 fn test_build_bitbucket_permalink_from_ssh_url_multi_line_selection() {
467 let permalink = build_permalink(
468 "git@bitbucket.org:thorstenzed/testingrepo.git",
469 "f00b4r",
470 "main.rs",
471 Some(Point::new(23, 1)..Point::new(47, 10)),
472 )
473 .unwrap();
474
475 let expected_url =
476 "https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs#lines-24:48";
477 assert_eq!(permalink.to_string(), expected_url.to_string())
478 }
479}