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    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}