lux_lib/git/
shorthand.rs

1use std::{fmt::Display, str::FromStr};
2
3use chumsky::{prelude::*, Parser};
4use git_url_parse::{GitUrl, GitUrlParseError};
5use serde::{de, Deserialize, Deserializer};
6use thiserror::Error;
7
8const GITHUB: &str = "github";
9const GITLAB: &str = "gitlab";
10const SOURCEHUT: &str = "sourcehut";
11const CODEBERG: &str = "codeberg";
12
13#[derive(Debug, Error)]
14#[error("error parsing git source: {0:#?}")]
15pub struct ParseError(Vec<String>);
16
17/// Helper for parsing Git URLs from shorthands, e.g. "gitlab:owner/repo"
18#[derive(Debug, Clone)]
19pub struct GitUrlShorthand(GitUrl);
20
21impl GitUrlShorthand {
22    pub fn parse_with_prefix(s: &str) -> Result<Self, ParseError> {
23        prefix_parser()
24            .parse(s)
25            .into_result()
26            .map_err(|err| ParseError(err.into_iter().map(|e| e.to_string()).collect()))
27    }
28    pub fn repo_name() {}
29}
30
31impl FromStr for GitUrlShorthand {
32    type Err = ParseError;
33
34    fn from_str(s: &str) -> Result<Self, Self::Err> {
35        match parser()
36            .parse(s)
37            .into_result()
38            .map_err(|err| ParseError(err.into_iter().map(|e| e.to_string()).collect()))
39        {
40            Ok(url) => Ok(url),
41            Err(err) => match s.parse() {
42                // fall back to parsing the URL directly
43                Ok(url) => Ok(Self(url)),
44                Err(_) => Err(err),
45            },
46        }
47    }
48}
49
50impl<'de> Deserialize<'de> for GitUrlShorthand {
51    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
52    where
53        D: Deserializer<'de>,
54    {
55        String::deserialize(deserializer)?
56            .parse()
57            .map_err(de::Error::custom)
58    }
59}
60
61impl From<GitUrl> for GitUrlShorthand {
62    fn from(value: GitUrl) -> Self {
63        Self(value)
64    }
65}
66
67impl From<GitUrlShorthand> for GitUrl {
68    fn from(value: GitUrlShorthand) -> Self {
69        value.0
70    }
71}
72
73#[derive(Debug, Default)]
74enum GitHost {
75    #[default]
76    Github,
77    Gitlab,
78    Sourcehut,
79    Codeberg,
80}
81
82fn url_from_git_host(
83    host: GitHost,
84    owner: String,
85    repo: String,
86) -> Result<GitUrlShorthand, GitUrlParseError> {
87    let url_str = match host {
88        GitHost::Github => format!("https://github.com/{}/{}.git", owner, repo),
89        GitHost::Gitlab => format!("https://gitlab.com/{}/{}.git", owner, repo),
90        GitHost::Sourcehut => format!("https://git.sr.ht/~{}/{}", owner, repo),
91        GitHost::Codeberg => format!("https://codeberg.org/~{}/{}.git", owner, repo),
92    };
93    let url = url_str.parse()?;
94    Ok(GitUrlShorthand(url))
95}
96
97fn to_tuple<T>(v: Vec<T>) -> (T, T)
98where
99    T: Clone,
100{
101    (v[0].clone(), v[1].clone())
102}
103
104// A parser that expects a prefix
105fn prefix_parser<'a>(
106) -> impl Parser<'a, &'a str, GitUrlShorthand, chumsky::extra::Err<Rich<'a, char>>> {
107    let git_host_prefix = just(GITHUB)
108        .or(just(GITLAB).or(just(SOURCEHUT).or(just(CODEBERG))))
109        .then_ignore(just(":"))
110        .map(|prefix| match prefix {
111            GITHUB => GitHost::Github,
112            GITLAB => GitHost::Gitlab,
113            SOURCEHUT => GitHost::Sourcehut,
114            CODEBERG => GitHost::Codeberg,
115            _ => unreachable!(),
116        })
117        .map_err(|err: Rich<'a, char>| {
118            let span = *err.span();
119            Rich::custom(span, "missing git host prefix. Expected 'github:', 'gitlab:', 'sourcehut:' or 'codeberg:'.")
120        });
121    let owner_repo = none_of('/')
122        .repeated()
123        .collect::<String>()
124        .separated_by(just('/'))
125        .exactly(2)
126        .collect::<Vec<String>>()
127        .map(to_tuple);
128    git_host_prefix
129        .then(owner_repo)
130        .try_map(|(host, (owner, repo)), span| {
131            let url = url_from_git_host(host, owner, repo).map_err(|err| {
132                Rich::custom(span, format!("error parsing git url shorthand: {}", err))
133            })?;
134            Ok(url)
135        })
136}
137
138// A more lenient parser that defaults to github: if there is not prefix
139fn parser<'a>() -> impl Parser<'a, &'a str, GitUrlShorthand, chumsky::extra::Err<Rich<'a, char>>> {
140    let git_host_prefix = just(GITHUB)
141        .or(just(GITLAB).or(just(SOURCEHUT).or(just(CODEBERG))))
142        .then_ignore(just(":"))
143        .or_not()
144        .map(|prefix| match prefix {
145            Some(GITHUB) => GitHost::Github,
146            Some(GITLAB) => GitHost::Gitlab,
147            Some(SOURCEHUT) => GitHost::Sourcehut,
148            Some(CODEBERG) => GitHost::Codeberg,
149            _ => GitHost::default(),
150        });
151    let owner_repo = none_of('/')
152        .repeated()
153        .collect::<String>()
154        .separated_by(just('/'))
155        .exactly(2)
156        .collect::<Vec<String>>()
157        .map(to_tuple);
158    git_host_prefix
159        .then(owner_repo)
160        .try_map(|(host, (owner, repo)), span| {
161            let url = url_from_git_host(host, owner, repo).map_err(|err| {
162                Rich::custom(span, format!("error parsing git url shorthand: {}", err))
163            })?;
164            Ok(url)
165        })
166}
167
168impl Display for GitUrlShorthand {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        match (&self.0.host, &self.0.owner) {
171            (Some(host), Some(owner)) if host == "github.com" => {
172                format!("{}:{}/{}", GITHUB, owner, self.0.name)
173            }
174            (Some(host), Some(owner)) if host == "gitlab.com" => {
175                format!("{}:{}/{}", GITLAB, owner, self.0.name)
176            }
177            (Some(host), Some(owner)) if host == "git.sr.ht" => {
178                format!("{}:{}/{}", SOURCEHUT, owner.replace('~', ""), self.0.name)
179            }
180            (Some(host), Some(owner)) if host == "codeberg.org" => {
181                format!("{}:{}/{}", CODEBERG, owner.replace('~', ""), self.0.name)
182            }
183            _ => format!("{}", self.0),
184        }
185        .fmt(f)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[tokio::test]
194    async fn owner_repo_shorthand() {
195        let url_shorthand: GitUrlShorthand = "nvim-neorocks/lux".parse().unwrap();
196        assert_eq!(url_shorthand.0.owner, Some("nvim-neorocks".to_string()));
197        assert_eq!(url_shorthand.0.name, "lux".to_string());
198    }
199
200    #[tokio::test]
201    async fn github_shorthand() {
202        let url_shorthand_str = "github:nvim-neorocks/lux";
203        let url_shorthand: GitUrlShorthand = url_shorthand_str.parse().unwrap();
204        assert_eq!(url_shorthand.0.host, Some("github.com".to_string()));
205        assert_eq!(url_shorthand.0.owner, Some("nvim-neorocks".to_string()));
206        assert_eq!(url_shorthand.0.name, "lux".to_string());
207        assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
208    }
209
210    #[tokio::test]
211    async fn gitlab_shorthand() {
212        let url_shorthand_str = "gitlab:nvim-neorocks/lux";
213        let url_shorthand: GitUrlShorthand = url_shorthand_str.parse().unwrap();
214        assert_eq!(url_shorthand.0.host, Some("gitlab.com".to_string()));
215        assert_eq!(url_shorthand.0.owner, Some("nvim-neorocks".to_string()));
216        assert_eq!(url_shorthand.0.name, "lux".to_string());
217        assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
218    }
219
220    #[tokio::test]
221    async fn sourcehut_shorthand() {
222        let url_shorthand_str = "sourcehut:nvim-neorocks/lux";
223        let url_shorthand: GitUrlShorthand = url_shorthand_str.parse().unwrap();
224        assert_eq!(url_shorthand.0.host, Some("git.sr.ht".to_string()));
225        assert_eq!(url_shorthand.0.owner, Some("~nvim-neorocks".to_string()));
226        assert_eq!(url_shorthand.0.name, "lux".to_string());
227        assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
228    }
229
230    #[tokio::test]
231    async fn codeberg_shorthand() {
232        let url_shorthand_str = "codeberg:nvim-neorocks/lux";
233        let url_shorthand: GitUrlShorthand = url_shorthand_str.parse().unwrap();
234        assert_eq!(url_shorthand.0.host, Some("codeberg.org".to_string()));
235        assert_eq!(url_shorthand.0.owner, Some("~nvim-neorocks".to_string()));
236        assert_eq!(url_shorthand.0.name, "lux".to_string());
237        assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
238    }
239
240    #[tokio::test]
241    async fn regular_https_url() {
242        let url_shorthand: GitUrlShorthand =
243            "https://github.com/nvim-neorocks/lux.git".parse().unwrap();
244        assert_eq!(url_shorthand.0.host, Some("github.com".to_string()));
245        assert_eq!(url_shorthand.0.owner, Some("nvim-neorocks".to_string()));
246        assert_eq!(url_shorthand.0.name, "lux".to_string());
247        assert_eq!(
248            url_shorthand.to_string(),
249            "github:nvim-neorocks/lux".to_string()
250        );
251    }
252
253    #[tokio::test]
254    async fn regular_ssh_url() {
255        let url_str = "git@github.com:nvim-neorocks/lux.git";
256        let url_shorthand: GitUrlShorthand = url_str.parse().unwrap();
257        assert_eq!(url_shorthand.0.host, Some("github.com".to_string()));
258        assert_eq!(
259            url_shorthand.0.owner,
260            Some("git@github.com:nvim-neorocks".to_string())
261        );
262        assert_eq!(url_shorthand.0.name, "lux".to_string());
263    }
264
265    #[tokio::test]
266    async fn parse_with_prefix() {
267        GitUrlShorthand::parse_with_prefix("nvim-neorocks/lux").unwrap_err();
268        GitUrlShorthand::parse_with_prefix("github:nvim-neorocks/lux").unwrap();
269        GitUrlShorthand::parse_with_prefix("gitlab:nvim-neorocks/lux").unwrap();
270        GitUrlShorthand::parse_with_prefix("sourcehut:nvim-neorocks/lux").unwrap();
271        GitUrlShorthand::parse_with_prefix("codeberg:nvim-neorocks/lux").unwrap();
272        GitUrlShorthand::parse_with_prefix("bla:nvim-neorocks/lux").unwrap_err();
273    }
274}