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