Skip to main content

lux_lib/git/
shorthand.rs

1use std::{fmt::Display, str::FromStr};
2
3use chumsky::{prelude::*, Parser};
4use serde::{de, Deserialize, Deserializer};
5use thiserror::Error;
6
7use crate::git::url::{RemoteGitUrl, RemoteGitUrlParseError};
8
9const GITHUB: &str = "github";
10const GITLAB: &str = "gitlab";
11const SOURCEHUT: &str = "sourcehut";
12const CODEBERG: &str = "codeberg";
13
14#[derive(Debug, Error)]
15#[error("error parsing git source: {0:#?}")]
16pub struct ParseError(Vec<String>);
17
18/// Helper for parsing Git URLs from shorthands, e.g. "gitlab:owner/repo"
19#[derive(Debug, Clone)]
20pub struct RemoteGitUrlShorthand(RemoteGitUrl);
21
22impl RemoteGitUrlShorthand {
23    pub fn parse_with_prefix(s: &str) -> Result<Self, ParseError> {
24        prefix_parser()
25            .parse(s)
26            .into_result()
27            .map_err(|err| ParseError(err.into_iter().map(|e| e.to_string()).collect()))
28    }
29    pub fn repo_name() {}
30}
31
32impl FromStr for RemoteGitUrlShorthand {
33    type Err = ParseError;
34
35    fn from_str(s: &str) -> Result<Self, Self::Err> {
36        match parser()
37            .parse(s)
38            .into_result()
39            .map_err(|err| ParseError(err.into_iter().map(|e| e.to_string()).collect()))
40        {
41            Ok(url) => Ok(url),
42            Err(err) => match s.parse() {
43                // fall back to parsing the URL directly
44                Ok(url) => Ok(Self(url)),
45                Err(_) => Err(err),
46            },
47        }
48    }
49}
50
51impl<'de> Deserialize<'de> for RemoteGitUrlShorthand {
52    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
53    where
54        D: Deserializer<'de>,
55    {
56        String::deserialize(deserializer)?
57            .parse()
58            .map_err(de::Error::custom)
59    }
60}
61
62impl From<RemoteGitUrl> for RemoteGitUrlShorthand {
63    fn from(value: RemoteGitUrl) -> Self {
64        Self(value)
65    }
66}
67
68impl From<RemoteGitUrlShorthand> for RemoteGitUrl {
69    fn from(value: RemoteGitUrlShorthand) -> Self {
70        value.0
71    }
72}
73
74#[derive(Debug, Default)]
75enum GitHost {
76    #[default]
77    Github,
78    Gitlab,
79    Sourcehut,
80    Codeberg,
81}
82
83fn url_from_git_host(
84    host: GitHost,
85    owner: String,
86    repo: String,
87) -> Result<RemoteGitUrlShorthand, RemoteGitUrlParseError> {
88    let url_str = match host {
89        GitHost::Github => format!("https://github.com/{owner}/{repo}.git"),
90        GitHost::Gitlab => format!("https://gitlab.com/{owner}/{repo}.git"),
91        GitHost::Sourcehut => format!("https://git.sr.ht/~{owner}/{repo}"),
92        GitHost::Codeberg => format!("https://codeberg.org/~{owner}/{repo}.git"),
93    };
94    let url = url_str.parse()?;
95    Ok(RemoteGitUrlShorthand(url))
96}
97
98fn to_tuple<T>(v: Vec<T>) -> (T, T)
99where
100    T: Clone,
101{
102    (v[0].clone(), v[1].clone())
103}
104
105// A parser that expects a prefix
106fn prefix_parser<'a>(
107) -> impl Parser<'a, &'a str, RemoteGitUrlShorthand, chumsky::extra::Err<Rich<'a, char>>> {
108    let git_host_prefix = just(GITHUB)
109        .or(just(GITLAB).or(just(SOURCEHUT).or(just(CODEBERG))))
110        .then_ignore(just(":"))
111        .map(|prefix| match prefix {
112            GITHUB => GitHost::Github,
113            GITLAB => GitHost::Gitlab,
114            SOURCEHUT => GitHost::Sourcehut,
115            CODEBERG => GitHost::Codeberg,
116            _ => unreachable!(),
117        })
118        .map_err(|err: Rich<'a, char>| {
119            let span = *err.span();
120            Rich::custom(span, "missing git host prefix. Expected 'github:', 'gitlab:', 'sourcehut:' or 'codeberg:'.")
121        });
122    let owner_repo = none_of('/')
123        .repeated()
124        .collect::<String>()
125        .separated_by(just('/'))
126        .exactly(2)
127        .collect::<Vec<String>>()
128        .map(to_tuple);
129    git_host_prefix
130        .then(owner_repo)
131        .try_map(|(host, (owner, repo)), span| {
132            let url = url_from_git_host(host, owner, repo).map_err(|err| {
133                Rich::custom(span, format!("error parsing git url shorthand: {err}"))
134            })?;
135            Ok(url)
136        })
137}
138
139// A parser for scp-style git URLs, converting them into ssh: URLs.
140fn scp_style_parser<'a>(
141) -> impl Parser<'a, &'a str, RemoteGitUrlShorthand, chumsky::extra::Err<Rich<'a, char>>> {
142    none_of(":/")
143        .repeated()
144        .collect::<String>()
145        .then(
146            just(':')
147                .ignore_then(just("//").not())
148                .ignore_then(any().repeated().collect::<String>()),
149        )
150        .try_map(|(host, path), span| {
151            let inner = format!("ssh://{host}/{path}")
152                .parse::<RemoteGitUrl>()
153                .map_err(|err| {
154                    Rich::custom(span, format!("error parsing scp style git url: {err}"))
155                })?;
156            Ok(RemoteGitUrlShorthand(inner))
157        })
158}
159
160// A parser that tries to parse as such:
161//
162// * If it can be parsed exactly as one of our shorthand prefixes, it is expanded into that host reference.
163// * If it can be parsed as a simple owner/repo pair, it is considered to be a github shorthand reference.
164// * If the string has at least one colon, and the first colon does not follow any slashes and is not immediately followed by two slashes, it is considered to be in scp style.
165fn parser<'a>(
166) -> impl Parser<'a, &'a str, RemoteGitUrlShorthand, chumsky::extra::Err<Rich<'a, char>>> {
167    let owner_repo = none_of(":/")
168        .repeated()
169        .collect::<String>()
170        .separated_by(just('/'))
171        .exactly(2)
172        .collect::<Vec<String>>()
173        .map(to_tuple);
174    owner_repo
175        .try_map(|(owner, repo), span| {
176            let url = url_from_git_host(GitHost::default(), owner, repo).map_err(|err| {
177                Rich::custom(span, format!("error parsing git url shorthand: {err}"))
178            })?;
179            Ok(url)
180        })
181        .or(prefix_parser())
182        .or(scp_style_parser())
183}
184
185impl Display for RemoteGitUrlShorthand {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        match (self.0.url.host_str(), self.0.owner()) {
188            (Some("github.com"), Some(owner)) => {
189                format!("{}:{}/{}", GITHUB, owner, self.0.repo())
190            }
191            (Some("gitlab.com"), Some(owner)) => {
192                format!("{}:{}/{}", GITLAB, owner, self.0.repo())
193            }
194            (Some("git.sr.ht"), Some(owner)) => {
195                format!("{}:{}/{}", SOURCEHUT, owner.replace('~', ""), self.0.repo())
196            }
197            (Some("codeberg.org"), Some(owner)) => {
198                format!("{}:{}/{}", CODEBERG, owner.replace('~', ""), self.0.repo())
199            }
200            _ => {
201                format!("{}", self.0)
202            }
203        }
204        .fmt(f)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[tokio::test]
213    async fn owner_repo_shorthand() {
214        let url_shorthand: RemoteGitUrlShorthand = "lumen-oss/lux".parse().unwrap();
215        assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
216        assert_eq!(url_shorthand.0.repo(), "lux");
217    }
218
219    #[tokio::test]
220    async fn github_shorthand() {
221        let url_shorthand_str = "github:lumen-oss/lux";
222        let url_shorthand: RemoteGitUrlShorthand = url_shorthand_str.parse().unwrap();
223        assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
224        assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
225        assert_eq!(url_shorthand.0.repo(), "lux");
226        assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
227    }
228
229    #[tokio::test]
230    async fn gitlab_shorthand() {
231        let url_shorthand_str = "gitlab:lumen-oss/lux";
232        let url_shorthand: RemoteGitUrlShorthand = url_shorthand_str.parse().unwrap();
233        assert_eq!(url_shorthand.0.url.host_str(), Some("gitlab.com"));
234        assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
235        assert_eq!(url_shorthand.0.repo(), "lux");
236        assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
237    }
238
239    #[tokio::test]
240    async fn sourcehut_shorthand() {
241        let url_shorthand_str = "sourcehut:lumen-oss/lux";
242        let url_shorthand: RemoteGitUrlShorthand = url_shorthand_str.parse().unwrap();
243        assert_eq!(url_shorthand.0.url.host_str(), Some("git.sr.ht"));
244        assert_eq!(url_shorthand.0.owner(), Some("~lumen-oss"));
245        assert_eq!(url_shorthand.0.repo(), "lux");
246        assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
247    }
248
249    #[tokio::test]
250    async fn codeberg_shorthand() {
251        let url_shorthand_str = "codeberg:lumen-oss/lux";
252        let url_shorthand: RemoteGitUrlShorthand = url_shorthand_str.parse().unwrap();
253        assert_eq!(url_shorthand.0.url.host_str(), Some("codeberg.org"));
254        assert_eq!(url_shorthand.0.owner(), Some("~lumen-oss"));
255        assert_eq!(url_shorthand.0.repo(), "lux");
256        assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
257    }
258
259    #[tokio::test]
260    async fn regular_https_url() {
261        let url_shorthand: RemoteGitUrlShorthand =
262            "https://github.com/lumen-oss/lux.git".parse().unwrap();
263        assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
264        assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
265        assert_eq!(url_shorthand.0.repo(), "lux");
266        assert_eq!(
267            url_shorthand.to_string(),
268            "github:lumen-oss/lux".to_string()
269        );
270    }
271
272    #[tokio::test]
273    async fn regular_http_url() {
274        let url_shorthand: RemoteGitUrlShorthand =
275            "http://github.com/lumen-oss/lux.git".parse().unwrap();
276        assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
277        assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
278        assert_eq!(url_shorthand.0.repo(), "lux");
279        assert_eq!(
280            url_shorthand.to_string(),
281            "github:lumen-oss/lux".to_string()
282        );
283    }
284
285    #[tokio::test]
286    async fn regular_ssh_url() {
287        let url_shorthand: RemoteGitUrlShorthand =
288            "ssh://git@github.com/lumen-oss/lux.git".parse().unwrap();
289        assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
290        assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
291        assert_eq!(url_shorthand.0.repo(), "lux");
292        assert_eq!(
293            url_shorthand.to_string(),
294            "github:lumen-oss/lux".to_string()
295        );
296    }
297
298    #[tokio::test]
299    async fn regular_ftp_url() {
300        let url_shorthand: RemoteGitUrlShorthand =
301            "ftp://github.com/lumen-oss/lux.git".parse().unwrap();
302        assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
303        assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
304        assert_eq!(url_shorthand.0.repo(), "lux");
305        assert_eq!(
306            url_shorthand.to_string(),
307            "github:lumen-oss/lux".to_string()
308        );
309    }
310
311    #[tokio::test]
312    async fn regular_ftps_url() {
313        let url_shorthand: RemoteGitUrlShorthand =
314            "ftps://github.com/lumen-oss/lux.git".parse().unwrap();
315        assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
316        assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
317        assert_eq!(url_shorthand.0.repo(), "lux");
318        assert_eq!(
319            url_shorthand.to_string(),
320            "github:lumen-oss/lux".to_string()
321        );
322    }
323
324    #[tokio::test]
325    async fn illegal_scheme_url() {
326        RemoteGitUrlShorthand::from_str("git+https://github.com/lumen-oss/lux.git")
327            .expect_err("git+ handling should be done in an outer layer.");
328        RemoteGitUrlShorthand::from_str("file:///lumen-oss/lux.git")
329            .expect_err("local filesystems are not supported as a Remote URL.");
330        RemoteGitUrlShorthand::from_str("xyz:///lumen-oss/lux.git")
331            .expect_err("Unknown schemes are rejected");
332    }
333
334    #[tokio::test]
335    async fn git_scheme_url() {
336        let url_shorthand: RemoteGitUrlShorthand =
337            "git://git@github.com/lumen-oss/lux.git".parse().unwrap();
338        assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
339        assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
340        assert_eq!(url_shorthand.0.repo(), "lux");
341        assert_eq!(
342            url_shorthand.to_string(),
343            "github:lumen-oss/lux".to_string()
344        );
345    }
346
347    #[tokio::test]
348    async fn scp_style_url() {
349        let url_str = "git@github.com:lumen-oss/lux.git";
350        let url_shorthand: RemoteGitUrlShorthand = url_str.parse().unwrap();
351        assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
352        assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
353        assert_eq!(url_shorthand.0.repo(), "lux");
354    }
355
356    #[tokio::test]
357    async fn parse_with_prefix() {
358        RemoteGitUrlShorthand::parse_with_prefix("lumen-oss/lux").unwrap_err();
359        RemoteGitUrlShorthand::parse_with_prefix("github:lumen-oss/lux").unwrap();
360        RemoteGitUrlShorthand::parse_with_prefix("gitlab:lumen-oss/lux").unwrap();
361        RemoteGitUrlShorthand::parse_with_prefix("sourcehut:lumen-oss/lux").unwrap();
362        RemoteGitUrlShorthand::parse_with_prefix("codeberg:lumen-oss/lux").unwrap();
363        RemoteGitUrlShorthand::parse_with_prefix("bla:lumen-oss/lux").unwrap_err();
364    }
365}