Skip to main content

lux_lib/lua_rockspec/
rock_source.rs

1use crate::{
2    git::{
3        url::{RemoteGitUrl, RemoteGitUrlParseError},
4        GitSource,
5    },
6    lua_rockspec::per_platform_from_intermediate,
7    package::wally::PackageReq as WallyPackageReq,
8};
9
10pub use crate::git::GitRef;
11use miette::Diagnostic;
12use reqwest::Url;
13use serde::{de, Deserialize, Deserializer};
14use std::{
15    convert::Infallible,
16    fmt::Display,
17    fs, io,
18    ops::Deref,
19    path::{Path, PathBuf},
20    str::FromStr,
21};
22use thiserror::Error;
23
24use super::{
25    DisplayAsLuaKV, DisplayLuaKV, DisplayLuaValue, PartialOverride, PerPlatform,
26    PlatformOverridable,
27};
28
29/// Specifies the source of a rock that has been fetched
30#[derive(Default, Deserialize, Clone, Debug, PartialEq)]
31pub struct LocalRockSource {
32    pub archive_name: Option<PathBuf>,
33    pub unpack_dir: Option<PathBuf>,
34}
35
36/// Specifies the source of a remote rock to be fetched
37#[derive(Deserialize, Clone, Debug, PartialEq)]
38pub struct RemoteRockSource {
39    pub(crate) local: LocalRockSource,
40    pub source_spec: RockSourceSpec,
41}
42
43impl RemoteRockSource {
44    /// Builds a remote rock source, normalising a Git rock's `source.dir` to its
45    /// effective source root.
46    ///
47    /// The repository name is the final path component of the Git URL, with any
48    /// `.git` suffix removed.
49    ///
50    /// Luarocks clones Git sources into a `<repository>` directory and resolves
51    /// `source.dir` relative to its parent.
52    /// Lux clones directly into the fetch root, which results in the same
53    /// NAR hash as is used by `fetchgit` (Nix).
54    ///
55    /// For a repository named `foo`:
56    ///
57    /// | input `source.dir` | resulting directory |
58    /// |--------------------|---------------------|
59    /// | `nil`              | `.`                 |
60    /// | `foo`              | `.`                 |
61    /// | `foo/src`          | `src`               |
62    /// | `src`              | `src`               |
63    pub(crate) fn new(mut local: LocalRockSource, source_spec: RockSourceSpec) -> Self {
64        if let RockSourceSpec::Git(git) = &source_spec {
65            let repo = git.url.repo();
66            let normalised = match local.unpack_dir.take() {
67                Some(dir) if dir == Path::new(repo) => ".".into(),
68                Some(dir) => match dir.strip_prefix(repo) {
69                    Ok(stripped) => stripped.to_path_buf(),
70                    Err(_) => dir,
71                },
72                None => PathBuf::from("."),
73            };
74            local.unpack_dir = Some(normalised);
75        }
76        Self { local, source_spec }
77    }
78}
79
80impl From<RockSourceSpec> for RemoteRockSource {
81    fn from(source_spec: RockSourceSpec) -> Self {
82        Self::new(LocalRockSource::default(), source_spec)
83    }
84}
85
86impl Deref for RemoteRockSource {
87    type Target = LocalRockSource;
88
89    fn deref(&self) -> &Self::Target {
90        &self.local
91    }
92}
93
94#[derive(Error, Debug, Diagnostic)]
95#[non_exhaustive]
96pub enum RockSourceError {
97    #[error("invalid rockspec source field combination")]
98    InvalidCombination,
99    #[error(transparent)]
100    #[diagnostic(transparent)]
101    SourceUrl(#[from] SourceUrlError),
102    #[error("source URL missing")]
103    SourceUrlMissing,
104}
105
106impl From<RockSourceInternal> for LocalRockSource {
107    fn from(internal: RockSourceInternal) -> Self {
108        LocalRockSource {
109            archive_name: internal.file,
110            unpack_dir: internal.dir,
111        }
112    }
113}
114
115impl TryFrom<RockSourceInternal> for RemoteRockSource {
116    type Error = RockSourceError;
117
118    fn try_from(internal: RockSourceInternal) -> Result<Self, Self::Error> {
119        let local = LocalRockSource::from(internal.clone());
120
121        // The rockspec.source table allows invalid combinations
122        // This ensures that invalid combinations are caught while parsing.
123        let url = SourceUrl::from_str(&internal.url.ok_or(RockSourceError::SourceUrlMissing)?)?;
124
125        let source_spec = match (url, internal.tag, internal.branch) {
126            (source, None, None) => Ok(RockSourceSpec::default_from_source_url(source)),
127            (SourceUrl::Git(url), Some(tag), None) => Ok(RockSourceSpec::Git(GitSource {
128                url,
129                git_ref: Some(GitRef::Tag(tag)),
130            })),
131            (SourceUrl::Git(url), None, Some(branch)) => Ok(RockSourceSpec::Git(GitSource {
132                url,
133                git_ref: Some(GitRef::Branch(branch)),
134            })),
135            _ => Err(RockSourceError::InvalidCombination),
136        }?;
137
138        Ok(RemoteRockSource::new(local, source_spec))
139    }
140}
141
142impl<'de> Deserialize<'de> for PerPlatform<RemoteRockSource> {
143    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
144    where
145        D: Deserializer<'de>,
146    {
147        per_platform_from_intermediate::<_, RockSourceInternal, _>(deserializer)
148    }
149}
150
151/// Specifies a rock's source
152#[derive(Debug, PartialEq, Eq, Hash, Clone)]
153pub enum RockSourceSpec {
154    Git(GitSource),
155    File(PathBuf),
156    Url(Url),
157    Wally(WallyPackageReq),
158}
159
160impl RockSourceSpec {
161    fn default_from_source_url(url: SourceUrl) -> Self {
162        match url {
163            SourceUrl::File(path) => Self::File(path),
164            SourceUrl::Url(url) => Self::Url(url),
165            SourceUrl::Git(url) => Self::Git(GitSource { url, git_ref: None }),
166        }
167    }
168}
169
170impl Display for RockSourceSpec {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        match self {
173            RockSourceSpec::Git(git_source) => git_source.fmt(f),
174            RockSourceSpec::File(path_buf) => path_buf.display().fmt(f),
175            RockSourceSpec::Url(url) => url.fmt(f),
176            RockSourceSpec::Wally(req) => req.fmt(f),
177        }
178    }
179}
180
181impl<'de> Deserialize<'de> for RockSourceSpec {
182    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
183    where
184        D: Deserializer<'de>,
185    {
186        let url = String::deserialize(deserializer)?;
187        Ok(RockSourceSpec::default_from_source_url(
188            url.parse().map_err(de::Error::custom)?,
189        ))
190    }
191}
192
193impl DisplayAsLuaKV for RockSourceSpec {
194    fn display_lua(&self) -> DisplayLuaKV {
195        match self {
196            RockSourceSpec::Git(git_source) => git_source.display_lua(),
197            RockSourceSpec::File(path) => {
198                let mut source_tbl = Vec::new();
199                source_tbl.push(DisplayLuaKV {
200                    key: "url".to_string(),
201                    value: DisplayLuaValue::String(format!("file://{}", path.display())),
202                });
203                DisplayLuaKV {
204                    key: "source".to_string(),
205                    value: DisplayLuaValue::Table(source_tbl),
206                }
207            }
208            RockSourceSpec::Url(url) => {
209                let mut source_tbl = Vec::new();
210                source_tbl.push(DisplayLuaKV {
211                    key: "url".to_string(),
212                    value: DisplayLuaValue::String(format!("{url}")),
213                });
214                DisplayLuaKV {
215                    key: "source".to_string(),
216                    value: DisplayLuaValue::Table(source_tbl),
217                }
218            }
219            RockSourceSpec::Wally(req) => req.display_lua(),
220        }
221    }
222}
223
224/// Used as a helper for Deserialize,
225/// because the Rockspec schema allows invalid rockspecs (╯°□°)╯︵ ┻━┻
226#[derive(Debug, PartialEq, Deserialize, Clone, Default, lux_macros::DisplayAsLuaKV)]
227#[display_lua(key = "source")]
228pub(crate) struct RockSourceInternal {
229    #[serde(default)]
230    pub(crate) url: Option<String>,
231    pub(crate) file: Option<PathBuf>,
232    pub(crate) dir: Option<PathBuf>,
233    pub(crate) tag: Option<String>,
234    pub(crate) branch: Option<String>,
235}
236
237impl PartialOverride for RockSourceInternal {
238    type Err = Infallible;
239
240    fn apply_overrides(&self, override_spec: &Self) -> Result<Self, Self::Err> {
241        let (tag, branch) = match (&override_spec.tag, &override_spec.branch) {
242            (Some(tag), _) => (Some(tag.clone()), None),
243            (_, Some(branch)) => (None, Some(branch.clone())),
244            (None, None) => (self.tag.clone(), self.branch.clone()),
245        };
246
247        Ok(Self {
248            url: override_opt(override_spec.url.as_ref(), self.url.as_ref()),
249            file: override_opt(override_spec.file.as_ref(), self.file.as_ref()),
250            dir: override_opt(override_spec.dir.as_ref(), self.dir.as_ref()),
251            tag,
252            branch,
253        })
254    }
255}
256
257#[derive(Error, Debug, Diagnostic)]
258#[non_exhaustive]
259#[error("missing source")]
260pub struct RockSourceMissingSource;
261
262impl PlatformOverridable for RockSourceInternal {
263    type Err = RockSourceMissingSource;
264
265    fn on_nil<T>() -> Result<PerPlatform<T>, <Self as PlatformOverridable>::Err>
266    where
267        T: PlatformOverridable,
268    {
269        Err(RockSourceMissingSource)
270    }
271}
272
273fn override_opt<T: Clone>(override_opt: Option<&T>, base: Option<&T>) -> Option<T> {
274    override_opt.or(base).cloned()
275}
276
277/// Internal helper for parsing
278#[derive(Debug, PartialEq, Clone)]
279pub(crate) enum SourceUrl {
280    /// For URLs in the local filesystem
281    File(PathBuf),
282    /// Web URLs
283    Url(Url),
284    /// For the Git source control manager
285    Git(RemoteGitUrl),
286}
287
288const SUPPORTED_URL_FORMATS_HELP: &str = r#"supported formats are:
289- 'git://...'
290- 'git+file://...'
291- 'git+https://...'
292- 'git+http://...'
293- 'git+ssh://...'
294- 'https://...'
295- 'http://...'
296- 'ftp://...'
297- 'file://...'
298"#;
299
300const LUX_ISSUES_URL: &str = "https://github.com/lumen-oss/lux/issues";
301const LUAROCKS_BUILD_RULES_URL: &str =
302    "https://github.com/luarocks/luarocks/blob/v3.13.0/docs/rockspec_format.md#build-rules";
303
304#[derive(Error, Debug, Diagnostic)]
305#[non_exhaustive]
306#[error("failed to parse source url")]
307pub enum SourceUrlError {
308    Io(#[from] io::Error),
309    Git(#[from] RemoteGitUrlParseError),
310    Url(#[source] <Url as FromStr>::Err),
311    #[error("lux does not support rockspecs with CVS sources.")]
312    #[diagnostic(
313        help("if you need support for CVS sources, open an issue"),
314        url("{LUX_ISSUES_URL}")
315    )]
316    CVS,
317    #[error("lux does not support rockspecs with mercurial sources.")]
318    #[diagnostic(
319        help("if you need support for Mercurial sources, open an issue"),
320        url("{LUX_ISSUES_URL}")
321    )]
322    Mercurial,
323    #[error("lux does not support rockspecs with SSCM sources.")]
324    #[diagnostic(
325        help("if you need support for SSCM sources, open an issue"),
326        url("{LUX_ISSUES_URL}")
327    )]
328    SSCM,
329    #[error("lux does not support rockspecs with SVN sources.")]
330    #[diagnostic(
331        help("if you need support for SVN sources, open an issue"),
332        url("{LUX_ISSUES_URL}")
333    )]
334    SVN,
335    #[error("unsupported source URL prefix: '{0}+' in URL '{1}'")]
336    #[diagnostic(
337        help("{SUPPORTED_URL_FORMATS_HELP}"),
338        url("{LUAROCKS_BUILD_RULES_URL}")
339    )]
340    UnsupportedPrefix(String, String),
341    #[error("unsupported source URL: '{0}'")]
342    #[diagnostic(
343        help("{SUPPORTED_URL_FORMATS_HELP}"),
344        url("{LUAROCKS_BUILD_RULES_URL}")
345    )]
346    Unsupported(String),
347}
348
349impl FromStr for SourceUrl {
350    type Err = SourceUrlError;
351
352    fn from_str(str: &str) -> Result<Self, Self::Err> {
353        match str.split_once("+") {
354            Some(("git" | "gitrec", url)) => Ok(Self::Git(url.parse()?)),
355            Some((prefix, _)) => Err(SourceUrlError::UnsupportedPrefix(
356                prefix.to_string(),
357                str.to_string(),
358            )),
359            None => match str {
360                s if s.starts_with("file://") => {
361                    let path_buf: PathBuf = s.trim_start_matches("file://").into();
362                    let path = fs::canonicalize(&path_buf)?;
363                    Ok(Self::File(path))
364                }
365                s if s.starts_with("git://") => {
366                    Ok(Self::Git(s.replacen("git", "https", 1).parse()?))
367                }
368                s if s.ends_with(".git") => Ok(Self::Git(s.parse()?)),
369                s if starts_with_any(s, ["https://", "http://", "ftp://"].into()) => {
370                    Ok(Self::Url(s.parse().map_err(SourceUrlError::Url)?))
371                }
372                s if s.starts_with("cvs://") => Err(SourceUrlError::CVS),
373                s if starts_with_any(
374                    s,
375                    ["hg://", "hg+http://", "hg+https://", "hg+ssh://"].into(),
376                ) =>
377                {
378                    Err(SourceUrlError::Mercurial)
379                }
380                s if s.starts_with("sscm://") => Err(SourceUrlError::SSCM),
381                s if s.starts_with("svn://") => Err(SourceUrlError::SVN),
382                s => Err(SourceUrlError::Unsupported(s.to_string())),
383            },
384        }
385    }
386}
387
388impl<'de> Deserialize<'de> for SourceUrl {
389    fn deserialize<D>(deserializer: D) -> Result<SourceUrl, D::Error>
390    where
391        D: Deserializer<'de>,
392    {
393        SourceUrl::from_str(&String::deserialize(deserializer)?).map_err(de::Error::custom)
394    }
395}
396
397fn starts_with_any(str: &str, prefixes: Vec<&str>) -> bool {
398    prefixes.iter().any(|&prefix| str.starts_with(prefix))
399}
400
401#[cfg(test)]
402mod tests {
403
404    use assert_fs::TempDir;
405
406    use super::*;
407
408    fn eval_lua_global<T: serde::de::DeserializeOwned>(code: &str, key: &'static str) -> T {
409        use ottavino::{Closure, Executor, Fuel, Lua};
410        use ottavino_util::serde::from_value;
411        Lua::core()
412            .try_enter(|ctx| {
413                let closure = Closure::load(ctx, None, code.as_bytes())?;
414                let executor = Executor::start(ctx, closure.into(), ());
415                executor.step(ctx, &mut Fuel::with(i32::MAX))?;
416                from_value(ctx.globals().get_value(ctx, key)).map_err(ottavino::Error::from)
417            })
418            .unwrap()
419    }
420
421    #[test]
422    pub fn rock_source_internal_roundtrip() {
423        let source = RockSourceInternal {
424            url: Some("https://github.com/example/repo/archive/v1.0.tar.gz".into()),
425            file: Some("repo-1.0.tar.gz".into()),
426            dir: Some("repo-1.0".into()),
427            tag: Some("v1.0".into()),
428            branch: None,
429        };
430        let lua = source.display_lua().to_string();
431        let restored: RockSourceInternal = eval_lua_global(&lua, "source");
432        assert_eq!(source, restored);
433    }
434
435    #[test]
436    pub fn rock_source_internal_branch_roundtrip() {
437        let source = RockSourceInternal {
438            url: Some("git+https://github.com/example/repo.git".into()),
439            file: None,
440            dir: None,
441            tag: None,
442            branch: Some("main".into()),
443        };
444        let lua = source.display_lua().to_string();
445        let restored: RockSourceInternal = eval_lua_global(&lua, "source");
446        assert_eq!(source, restored);
447    }
448
449    #[test]
450    pub fn normalises_git_source_dir() {
451        let parse = |url: &str, dir: Option<&str>| {
452            RemoteRockSource::try_from(RockSourceInternal {
453                url: Some(url.into()),
454                file: None,
455                dir: dir.map(Into::into),
456                tag: None,
457                branch: None,
458            })
459            .unwrap()
460            .local
461            .unpack_dir
462        };
463        let git = "git+https://github.com/lumen-oss/foo";
464        assert_eq!(parse(git, None), Some(".".into()));
465        assert_eq!(parse(git, Some("foo")), Some(".".into()));
466        assert_eq!(parse(git, Some("foo/src")), Some("src".into()));
467        assert_eq!(parse(git, Some("src")), Some("src".into()));
468        assert_eq!(
469            parse("https://example.com/repo-1.0.tar.gz", Some("repo-1.0")),
470            Some("repo-1.0".into())
471        );
472    }
473
474    #[tokio::test]
475    async fn parse_source_url() {
476        let dir = TempDir::new().unwrap();
477        let url: SourceUrl = format!("file://{}", dir.to_string_lossy()).parse().unwrap();
478        assert_eq!(url, SourceUrl::File(dir.path().to_path_buf()));
479        let url: SourceUrl = "ftp://example.com/foo/bar".parse().unwrap();
480        assert!(matches!(url, SourceUrl::Url { .. }));
481        let url: SourceUrl = "git://example.com/foo/bar".parse().unwrap();
482        assert!(matches!(url, SourceUrl::Git { .. }));
483        // We don't support file-like URLs, as they are not remote.
484        SourceUrl::from_str("git+file:///path/to/repo.git").unwrap_err();
485        let url: SourceUrl = "git+http://example.com/foo/bar".parse().unwrap();
486        assert!(matches!(url, SourceUrl::Git { .. }));
487        let url: SourceUrl = "git+https://example.com/foo/bar".parse().unwrap();
488        assert!(matches!(url, SourceUrl::Git { .. }));
489        let url: SourceUrl = "git+ssh://example.com/foo/bar".parse().unwrap();
490        assert!(matches!(url, SourceUrl::Git { .. }));
491        let url: SourceUrl = "gitrec+https://example.com/foo/bar".parse().unwrap();
492        assert!(matches!(url, SourceUrl::Git { .. }));
493        let url: SourceUrl = "https://example.com/foo/bar".parse().unwrap();
494        assert!(matches!(url, SourceUrl::Url { .. }));
495        let url: SourceUrl = "http://example.com/foo/bar".parse().unwrap();
496        assert!(matches!(url, SourceUrl::Url { .. }));
497    }
498}