permalink.rs

  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}