Skip to main content

lux_lib/lua_rockspec/
mod.rs

1mod build;
2mod dependency;
3mod deploy;
4mod partial;
5mod platform;
6mod rock_source;
7mod serde_util;
8mod test_spec;
9
10use std::{
11    collections::HashMap, convert::Infallible, fmt::Display, io, path::PathBuf, str::FromStr,
12};
13
14use ottavino::{Closure, Executor, Fuel};
15use ottavino_util::serde::from_value;
16use serde::{de::DeserializeOwned, Deserialize, Serialize};
17
18pub use build::*;
19pub use dependency::*;
20pub use deploy::*;
21use miette::{Diagnostic, NamedSource, SourceSpan};
22pub use partial::*;
23pub use platform::*;
24pub use rock_source::*;
25use ssri::Integrity;
26pub use test_spec::*;
27use thiserror::Error;
28use url::Url;
29
30pub(crate) use lux_macros::DisplayAsLuaKV;
31pub(crate) use serde_util::*;
32
33impl DisplayAsLuaValue for Url {
34    fn display_lua_value(&self) -> DisplayLuaValue {
35        DisplayLuaValue::String(self.to_string())
36    }
37}
38
39use crate::{
40    hash::HasIntegrity,
41    lua_version::{LuaVersion, LuaVersionUnset},
42    package::{PackageName, PackageSpec, PackageVersion, PackageVersionReq},
43    project::{project_toml::ProjectTomlError, ProjectRoot},
44    rockspec::{lua_dependency::LuaDependencySpec, Rockspec},
45    ROCKSPEC_FUEL_LIMIT,
46};
47
48#[derive(Error, Debug, Diagnostic)]
49pub enum LuaRockspecError {
50    #[error("manifest exceeds computational limit of {ROCKSPEC_FUEL_LIMIT} steps")]
51    #[diagnostic(
52        code(lux_lib::lua_rockspec::fuel_limit_exceeded),
53        help(
54            r#"the rockspec may be too complex or contain too many dependencies.
55try reducing the number of dependencies or simplifying build instructions.
56"#
57        )
58    )]
59    FuelLimitExceeded,
60    #[error("could not parse rockspec")]
61    #[diagnostic(code(lux_lib::lua_rockspec::execution),
62        help("check the rockspec or lux.toml for valid syntax and make sure it matches the specification."),
63        url("https://lux.lumen-labs.org/reference/lux-toml")
64    )]
65    ExecutionError {
66        cause: Box<String>,
67        #[source_code]
68        content: NamedSource<String>,
69        #[label("this package")]
70        pkg_span: SourceSpan,
71        #[label("{cause}")]
72        err_span: Option<SourceSpan>,
73    },
74    #[error("{}copy_directories cannot contain the rockspec name", ._0.as_ref().map(|p| format!("{p}: ")).unwrap_or_default())]
75    CopyDirectoriesContainRockspecName(Option<String>),
76    #[error("cannot create Lua rockspec with off-spec dependency: {0}")]
77    #[diagnostic(
78        code(lux_lib::lua_rockspec::off_spec_dependency),
79        url("https://lux.lumen-labs.org/reference/lux-toml#dependencies")
80    )]
81    OffSpecDependency(PackageName),
82    #[error("cannot create Lua rockspec with off-spec build dependency: {0}")]
83    #[diagnostic(
84        code(lux_lib::lua_rockspec::off_spec_build_dependency),
85        url("https://lux.lumen-labs.org/reference/lux-toml#dependencies")
86    )]
87    OffSpecBuildDependency(PackageName),
88    #[error("cannot create Lua rockspec with off-spec test dependency: {0}")]
89    #[diagnostic(
90        code(lux_lib::lua_rockspec::off_spec_test_dependency),
91        url("https://lux.lumen-labs.org/reference/lux-toml#dependencies")
92    )]
93    OffSpecTestDependency(PackageName),
94    #[error(transparent)]
95    #[diagnostic(transparent)]
96    ProjectToml(#[from] ProjectTomlError),
97}
98
99impl LuaRockspecError {
100    fn from_ottavino(cause: ottavino::ExternError, rockspec_content: &str) -> Self {
101        let msg = match &cause {
102            ottavino::ExternError::Runtime(rt) => rt.to_string(),
103            _ => cause.to_string(),
104        };
105        let field = msg
106            .strip_prefix("invalid rockspec field '")
107            .and_then(|rest| rest.split('\'').next())
108            .map(|f| f.to_owned());
109        let err_span = field.as_deref().and_then(|field| {
110            let span = find_key_span(rockspec_content, field);
111            if span.is_empty() {
112                None
113            } else {
114                Some(span)
115            }
116        });
117        LuaRockspecError::ExecutionError {
118            cause: Box::new(msg),
119            content: NamedSource::new("rockspec", rockspec_content.to_string()),
120            pkg_span: find_value_span(rockspec_content, "package"),
121            err_span,
122        }
123    }
124}
125
126/// This error can be returned within `ottavino::Lua::try_enter`.
127/// ottavino wraps it in an [`ottavino::ExternError`], which Lux wraps inn
128/// a [`LuaRockspecError::ExecutionError`]
129#[derive(Error, Debug, Diagnostic)]
130enum ParseLuaRockspecFieldError {
131    #[error("invalid rockspec field '{field}': {source}")]
132    Deserialize {
133        field: String,
134        source: Box<ottavino_util::serde::de::Error>,
135    },
136    #[error(
137        r#"invalid rockspec field '{field}'.
138Failed to parse a table field: {source}"#
139    )]
140    DeserializeTable {
141        field: String,
142        source: Box<ottavino_util::serde::de::Error>,
143    },
144    #[error("invalid rockspec field '{field}'. expected a table, but got: {invalid_type}")]
145    NotATable { field: String, invalid_type: String },
146}
147
148/// RockSpec for a local rock installation, deserialized from a `.rockspec` file
149#[derive(Clone, Debug)]
150#[cfg_attr(test, derive(PartialEq))]
151pub struct LocalLuaRockspec {
152    /// The file format version. Example: "1.0"
153    rockspec_format: Option<RockspecFormat>,
154    /// The name of the package. Example: "luasocket"
155    package: PackageName,
156    /// The version of the package, plus a suffix indicating the revision of the rockspec. Example: "2.0.1-1"
157    version: PackageVersion,
158    description: RockDescription,
159    supported_platforms: PlatformSupport,
160    /// The Lua version requirement for this rock
161    lua: PackageVersionReq,
162    dependencies: PerPlatform<Vec<LuaDependencySpec>>,
163    build_dependencies: PerPlatform<Vec<LuaDependencySpec>>,
164    external_dependencies: PerPlatform<HashMap<String, ExternalDependencySpec>>,
165    test_dependencies: PerPlatform<Vec<LuaDependencySpec>>,
166    build: PerPlatform<BuildSpec>,
167    source: PerPlatform<RemoteRockSource>,
168    test: PerPlatform<TestSpec>,
169    deploy: PerPlatform<DeploySpec>,
170    /// The original content of this rockspec, needed by luarocks
171    raw_content: String,
172}
173
174trait HasRockspecKey<'gc> {
175    fn get_rockspec_key<V: Deserialize<'gc>>(
176        &self,
177        ctx: ottavino::Context<'gc>,
178        key: &str,
179    ) -> Result<V, ParseLuaRockspecFieldError>;
180}
181
182impl<'gc> HasRockspecKey<'gc> for ottavino::Table<'gc> {
183    fn get_rockspec_key<V: Deserialize<'gc>>(
184        &self,
185        ctx: ottavino::Context<'gc>,
186        key: &str,
187    ) -> Result<V, ParseLuaRockspecFieldError> {
188        let value = self.get_value(ctx, key.to_string());
189        let is_table = matches!(&value, ottavino::Value::Table(_));
190        from_value(value).map_err(|source| {
191            if is_table {
192                ParseLuaRockspecFieldError::DeserializeTable {
193                    field: key.into(),
194                    source: Box::new(source),
195                }
196            } else {
197                ParseLuaRockspecFieldError::Deserialize {
198                    field: key.into(),
199                    source: Box::new(source),
200                }
201            }
202        })
203    }
204}
205
206impl LocalLuaRockspec {
207    pub fn new(
208        rockspec_content: &str,
209        project_root: ProjectRoot,
210    ) -> Result<Self, LuaRockspecError> {
211        let mut lua = ottavino::Lua::core();
212
213        let rockspec = lua
214            .try_enter(|ctx| {
215                let closure = Closure::load(ctx, None, rockspec_content.as_bytes())?;
216
217                let executor = Executor::start(ctx, closure.into(), ());
218
219                let output = executor.step(ctx, &mut Fuel::with(ROCKSPEC_FUEL_LIMIT))?;
220
221                if !output {
222                    return Ok(Err(LuaRockspecError::FuelLimitExceeded));
223                }
224
225                let globals = ctx.globals();
226
227                let dependencies: PerPlatform<Vec<LuaDependencySpec>> =
228                    globals.get_rockspec_key(ctx, "dependencies")?;
229
230                let lua_version_req = dependencies
231                    .current_platform()
232                    .iter()
233                    .find(|dep| dep.name().to_string() == "lua")
234                    .cloned()
235                    .map(|dep| dep.version_req().clone())
236                    .unwrap_or(PackageVersionReq::Any);
237
238                fn strip_lua(
239                    dependencies: PerPlatform<Vec<LuaDependencySpec>>,
240                ) -> PerPlatform<Vec<LuaDependencySpec>> {
241                    dependencies.map(|deps| {
242                        deps.iter()
243                            .filter(|dep| dep.name().to_string() != "lua")
244                            .cloned()
245                            .collect()
246                    })
247                }
248
249                let build_dependencies: PerPlatform<Vec<LuaDependencySpec>> =
250                    globals.get_rockspec_key(ctx, "build_dependencies")?;
251
252                let test_dependencies: PerPlatform<Vec<LuaDependencySpec>> =
253                    globals.get_rockspec_key(ctx, "test_dependencies")?;
254
255                let source: PerPlatform<RemoteRockSource> = match globals.get_value(ctx, "source") {
256                    ottavino::Value::Nil => {
257                        PerPlatform::new(RockSourceSpec::File(project_root.to_path_buf()).into())
258                    }
259                    value => from_value(value).map_err(|source| {
260                        ParseLuaRockspecFieldError::Deserialize {
261                            field: "source".into(),
262                            source: Box::new(source),
263                        }
264                    })?,
265                };
266
267                let rockspec = LocalLuaRockspec {
268                    rockspec_format: globals.get_rockspec_key(ctx, "rockspec_format")?,
269                    package: globals.get_rockspec_key(ctx, "package")?,
270                    version: globals.get_rockspec_key(ctx, "version")?,
271                    description: parse_lua_tbl_or_default(ctx, "description")?,
272                    supported_platforms: parse_lua_tbl_or_default(ctx, "supported_platforms")?,
273                    lua: lua_version_req,
274                    dependencies: strip_lua(dependencies),
275                    build_dependencies: strip_lua(build_dependencies),
276                    test_dependencies: strip_lua(test_dependencies),
277                    external_dependencies: globals
278                        .get_rockspec_key(ctx, "external_dependencies")?,
279                    build: globals.get_rockspec_key(ctx, "build")?,
280                    test: globals.get_rockspec_key(ctx, "test")?,
281                    deploy: globals.get_rockspec_key(ctx, "deploy")?,
282                    raw_content: rockspec_content.into(),
283
284                    source,
285                };
286
287                Ok(Ok(rockspec))
288            })
289            .map_err(|cause| LuaRockspecError::from_ottavino(cause, rockspec_content))??;
290
291        let rockspec_file_name = format!("{}-{}.rockspec", rockspec.package(), rockspec.version());
292
293        if rockspec
294            .build()
295            .default
296            .copy_directories
297            .contains(&PathBuf::from(&rockspec_file_name))
298        {
299            return Err(LuaRockspecError::CopyDirectoriesContainRockspecName(None));
300        }
301
302        for (platform, build_override) in &rockspec.build().per_platform {
303            if build_override
304                .copy_directories
305                .contains(&PathBuf::from(&rockspec_file_name))
306            {
307                return Err(LuaRockspecError::CopyDirectoriesContainRockspecName(Some(
308                    platform.to_string(),
309                )));
310            }
311        }
312        Ok(rockspec)
313    }
314}
315
316impl Rockspec for LocalLuaRockspec {
317    type Error = Infallible;
318
319    fn package(&self) -> &PackageName {
320        &self.package
321    }
322
323    fn version(&self) -> &PackageVersion {
324        &self.version
325    }
326
327    fn description(&self) -> &RockDescription {
328        &self.description
329    }
330
331    fn supported_platforms(&self) -> &PlatformSupport {
332        &self.supported_platforms
333    }
334
335    fn lua(&self) -> &PackageVersionReq {
336        &self.lua
337    }
338
339    fn dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
340        &self.dependencies
341    }
342
343    fn build_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
344        &self.build_dependencies
345    }
346
347    fn external_dependencies(&self) -> &PerPlatform<HashMap<String, ExternalDependencySpec>> {
348        &self.external_dependencies
349    }
350
351    fn test_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
352        &self.test_dependencies
353    }
354
355    fn build(&self) -> &PerPlatform<BuildSpec> {
356        &self.build
357    }
358
359    fn test(&self) -> &PerPlatform<TestSpec> {
360        &self.test
361    }
362
363    fn source(&self) -> &PerPlatform<RemoteRockSource> {
364        &self.source
365    }
366
367    fn deploy(&self) -> &PerPlatform<DeploySpec> {
368        &self.deploy
369    }
370
371    fn build_mut(&mut self) -> &mut PerPlatform<BuildSpec> {
372        &mut self.build
373    }
374
375    fn test_mut(&mut self) -> &mut PerPlatform<TestSpec> {
376        &mut self.test
377    }
378
379    fn source_mut(&mut self) -> &mut PerPlatform<RemoteRockSource> {
380        &mut self.source
381    }
382
383    fn deploy_mut(&mut self) -> &mut PerPlatform<DeploySpec> {
384        &mut self.deploy
385    }
386
387    fn format(&self) -> &Option<RockspecFormat> {
388        &self.rockspec_format
389    }
390
391    fn to_lua_remote_rockspec_string(&self) -> Result<String, Self::Error> {
392        Ok(self.raw_content.clone())
393    }
394}
395
396impl HasIntegrity for LocalLuaRockspec {
397    fn hash(&self) -> io::Result<Integrity> {
398        Ok(Integrity::from(&self.raw_content))
399    }
400}
401
402/// RockSpec for a remote rock, deserialized from a `.rockspec` file
403#[derive(Clone, Debug)]
404#[cfg_attr(test, derive(PartialEq))]
405pub struct RemoteLuaRockspec {
406    local: LocalLuaRockspec,
407    source: PerPlatform<RemoteRockSource>,
408}
409
410impl RemoteLuaRockspec {
411    pub fn new(rockspec_content: &str) -> Result<Self, LuaRockspecError> {
412        let mut lua = ottavino::Lua::core();
413
414        let source = lua
415            .try_enter(|ctx| {
416                let closure = Closure::load(ctx, None, rockspec_content.as_bytes())?;
417
418                let executor = Executor::start(ctx, closure.into(), ());
419
420                let output = executor.step(ctx, &mut Fuel::with(ROCKSPEC_FUEL_LIMIT))?;
421
422                if !output {
423                    return Ok(Err(LuaRockspecError::FuelLimitExceeded));
424                }
425
426                let globals = ctx.globals();
427
428                let source: PerPlatform<RemoteRockSource> =
429                    globals.get_rockspec_key(ctx, "source")?;
430
431                Ok(Ok(source))
432            })
433            .map_err(|cause| LuaRockspecError::from_ottavino(cause, rockspec_content))??;
434
435        Ok(RemoteLuaRockspec {
436            // NOTE: Calling this outside of `lua.try_enter` reduces error message repetition
437            local: LocalLuaRockspec::new(rockspec_content, ProjectRoot::new())?,
438            source,
439        })
440    }
441
442    pub fn from_package_and_source_spec(
443        package_spec: PackageSpec,
444        source_spec: RockSourceSpec,
445    ) -> Self {
446        let version = package_spec.version().clone();
447        let rockspec_format = RockspecFormat::default();
448        let raw_content = format!(
449            r#"
450rockspec_format = "{}"
451package = "{}"
452version = "{}"
453{}
454build = {{
455  type = "source"
456}}"#,
457            &rockspec_format,
458            package_spec.name(),
459            &version,
460            &source_spec.display_lua(),
461        );
462
463        let source: RemoteRockSource = source_spec.into();
464
465        let local = LocalLuaRockspec {
466            rockspec_format: Some(rockspec_format),
467            package: package_spec.name().clone(),
468            version,
469            description: RockDescription::default(),
470            supported_platforms: PlatformSupport::default(),
471            lua: PackageVersionReq::Any,
472            dependencies: PerPlatform::default(),
473            build_dependencies: PerPlatform::default(),
474            external_dependencies: PerPlatform::default(),
475            test_dependencies: PerPlatform::default(),
476            build: PerPlatform::new(BuildSpec {
477                build_backend: Some(BuildBackendSpec::Source),
478                install: InstallSpec::default(),
479                copy_directories: Vec::new(),
480                patches: HashMap::new(),
481            }),
482            source: PerPlatform::new(source.clone()),
483            test: PerPlatform::default(),
484            deploy: PerPlatform::default(),
485            raw_content,
486        };
487        Self {
488            local,
489            source: PerPlatform::new(source),
490        }
491    }
492}
493
494impl Rockspec for RemoteLuaRockspec {
495    type Error = Infallible;
496
497    fn package(&self) -> &PackageName {
498        self.local.package()
499    }
500
501    fn version(&self) -> &PackageVersion {
502        self.local.version()
503    }
504
505    fn description(&self) -> &RockDescription {
506        self.local.description()
507    }
508
509    fn supported_platforms(&self) -> &PlatformSupport {
510        self.local.supported_platforms()
511    }
512
513    fn lua(&self) -> &PackageVersionReq {
514        self.local.lua()
515    }
516
517    fn dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
518        self.local.dependencies()
519    }
520
521    fn build_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
522        match self.format() {
523            // Rockspec formats < 3.0 don't support `build_dependencies`,
524            // so we have to return regular dependencies if the build backend might need to use them.
525            Some(RockspecFormat::_1_0 | RockspecFormat::_2_0)
526                if self
527                    .build()
528                    .current_platform()
529                    .build_backend
530                    .as_ref()
531                    .is_some_and(|build_backend| build_backend.can_use_build_dependencies()) =>
532            {
533                self.local.dependencies()
534            }
535            _ => self.local.build_dependencies(),
536        }
537    }
538
539    fn external_dependencies(&self) -> &PerPlatform<HashMap<String, ExternalDependencySpec>> {
540        self.local.external_dependencies()
541    }
542
543    fn test_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
544        self.local.test_dependencies()
545    }
546
547    fn build(&self) -> &PerPlatform<BuildSpec> {
548        self.local.build()
549    }
550
551    fn test(&self) -> &PerPlatform<TestSpec> {
552        self.local.test()
553    }
554
555    fn source(&self) -> &PerPlatform<RemoteRockSource> {
556        &self.source
557    }
558
559    fn deploy(&self) -> &PerPlatform<DeploySpec> {
560        self.local.deploy()
561    }
562
563    fn build_mut(&mut self) -> &mut PerPlatform<BuildSpec> {
564        self.local.build_mut()
565    }
566
567    fn test_mut(&mut self) -> &mut PerPlatform<TestSpec> {
568        self.local.test_mut()
569    }
570
571    fn source_mut(&mut self) -> &mut PerPlatform<RemoteRockSource> {
572        &mut self.source
573    }
574
575    fn deploy_mut(&mut self) -> &mut PerPlatform<DeploySpec> {
576        self.local.deploy_mut()
577    }
578
579    fn format(&self) -> &Option<RockspecFormat> {
580        self.local.format()
581    }
582
583    fn to_lua_remote_rockspec_string(&self) -> Result<String, Self::Error> {
584        Ok(self.local.raw_content.clone())
585    }
586}
587
588#[derive(Error, Debug, Diagnostic)]
589pub enum LuaVersionError {
590    #[error(
591        r#"
592The lua version {0} is not supported by {1} version {2}.
593
594HINT: If Lux has auto-detected an incompatible Lua installation,
595      use `--lua-version` to specify the Lua version to use.
596      Valid versions are: '5.1', '5.2', '5.3', '5.4', '5.5', 'jit' and 'jit52'.
597"#
598    )]
599    LuaVersionUnsupported(LuaVersion, PackageName, PackageVersion),
600    #[error(transparent)]
601    #[diagnostic(transparent)]
602    LuaVersionUnset(#[from] LuaVersionUnset),
603}
604
605impl HasIntegrity for RemoteLuaRockspec {
606    fn hash(&self) -> io::Result<Integrity> {
607        Ok(Integrity::from(&self.local.raw_content))
608    }
609}
610
611/// A rock's metadata, to be displayed on the remote package server
612#[derive(Clone, Deserialize, Debug, PartialEq, Default, lux_macros::DisplayAsLuaKV)]
613#[display_lua(key = "description")]
614pub struct RockDescription {
615    /// A one-line description of the package.
616    pub summary: Option<String>,
617    /// A longer description of the package.
618    pub detailed: Option<String>,
619    /// The license used by the package.
620    pub license: Option<String>,
621    /// An URL for the project. This is not the URL for the tarball, but the address of a website.
622    #[serde(default, deserialize_with = "deserialize_url")]
623    pub homepage: Option<Url>,
624    /// An URL for the project's issue tracker.
625    #[serde(default, deserialize_with = "deserialize_url")]
626    pub issues_url: Option<Url>,
627    /// Contact information for the rockspec maintainer.
628    pub maintainer: Option<String>,
629    /// A list of short strings that specify labels for categorization of this rock.
630    #[serde(default)]
631    pub labels: Vec<String>,
632}
633
634fn find_key_span(rockspec_content: &str, key: &str) -> SourceSpan {
635    rockspec_content
636        .match_indices(key)
637        .find(|&(start, _)| {
638            let end = start + key.len();
639            let prefix_ok =
640                start == 0 || !rockspec_content.as_bytes()[start - 1].is_ascii_alphanumeric();
641            let suffix_ok = rockspec_content
642                .get(end..)
643                .is_some_and(|s| s.starts_with('=') || s.trim_start().starts_with('='));
644            prefix_ok && suffix_ok
645        })
646        .map(|(start, _)| (start..start + key.len()).into())
647        .unwrap_or((0..0).into())
648}
649
650fn find_value_span(rockspec_content: &str, key: &str) -> SourceSpan {
651    let key = find_key_span(rockspec_content, key);
652    if key.is_empty() {
653        return key;
654    }
655    let key_offset = key.offset();
656    let key_end = key_offset + key.len();
657    let eq_start = key_end + rockspec_content[key_end..].find('=').unwrap_or(0);
658    let value_offset = eq_start + 1;
659    let line_end = rockspec_content[value_offset..]
660        .find('\n')
661        .map(|i| value_offset + i)
662        .unwrap_or(rockspec_content.len());
663    let trimmed = &rockspec_content[value_offset..line_end];
664    let start = value_offset + (trimmed.len() - trimmed.trim_start().len());
665    let end = line_end - (trimmed.len() - trimmed.trim_end().len());
666    (start..end).into()
667}
668
669fn deserialize_url<'de, D>(deserializer: D) -> Result<Option<Url>, D::Error>
670where
671    D: serde::Deserializer<'de>,
672{
673    let s = Option::<String>::deserialize(deserializer)?;
674    s.map(|s| Url::parse(&s).map_err(serde::de::Error::custom))
675        .transpose()
676}
677
678#[derive(Error, Debug, Diagnostic)]
679#[error("invalid rockspec format: {0}")]
680pub struct InvalidRockspecFormat(String);
681
682#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
683pub enum RockspecFormat {
684    #[serde(rename = "1.0")]
685    _1_0,
686    #[serde(rename = "2.0")]
687    _2_0,
688    #[serde(rename = "3.0")]
689    #[default]
690    _3_0,
691}
692
693impl FromStr for RockspecFormat {
694    type Err = InvalidRockspecFormat;
695
696    fn from_str(s: &str) -> Result<Self, Self::Err> {
697        match s {
698            "1.0" => Ok(Self::_1_0),
699            "2.0" => Ok(Self::_2_0),
700            "3.0" => Ok(Self::_3_0),
701            txt => Err(InvalidRockspecFormat(txt.to_string())),
702        }
703    }
704}
705
706impl Display for RockspecFormat {
707    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
708        match self {
709            Self::_1_0 => write!(f, "1.0"),
710            Self::_2_0 => write!(f, "2.0"),
711            Self::_3_0 => write!(f, "3.0"),
712        }
713    }
714}
715
716fn parse_lua_tbl_or_default<T>(
717    ctx: ottavino::Context<'_>,
718    field: &str,
719) -> Result<T, ParseLuaRockspecFieldError>
720where
721    T: Default,
722    T: DeserializeOwned,
723{
724    let ret = match ctx.globals().get_value(ctx, field.to_string()) {
725        ottavino::Value::Nil => T::default(),
726        value @ ottavino::Value::Table(_) => {
727            from_value(value).map_err(|source| ParseLuaRockspecFieldError::DeserializeTable {
728                field: field.to_string(),
729                source: Box::new(source),
730            })?
731        }
732        value => Err(ParseLuaRockspecFieldError::NotATable {
733            field: field.to_string(),
734            invalid_type: value.type_name().to_string(),
735        })?,
736    };
737    Ok(ret)
738}
739
740#[cfg(test)]
741mod tests {
742    use std::path::PathBuf;
743
744    use crate::git::GitSource;
745    use crate::lua_rockspec::PlatformIdentifier;
746    use crate::package::PackageSpec;
747
748    use super::*;
749
750    #[test]
751    pub fn parse_rockspec() {
752        let rockspec_content = "
753        rockspec_format = '1.0'\n
754        package = 'foo'\n
755        version = '1.0.0-1'\n
756        source = {\n
757            url = 'https://github.com/lumen-oss/rocks.nvim/archive/1.0.0/rocks.nvim.zip',\n
758        }\n
759        "
760        .to_string();
761        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
762        assert_eq!(rockspec.local.rockspec_format, Some(RockspecFormat::_1_0));
763        assert_eq!(rockspec.local.package, "foo".into());
764        assert_eq!(rockspec.local.version, "1.0.0-1".parse().unwrap());
765        assert_eq!(rockspec.local.description, RockDescription::default());
766
767        let rockspec_content = "
768        package = 'bar'\n
769        version = '2.0.0-1'\n
770        description = {}\n
771        source = {\n
772            url = 'https://github.com/lumen-oss/rocks.nvim/archive/1.0.0/rocks.nvim.zip',\n
773        }\n
774        "
775        .to_string();
776        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
777        assert_eq!(rockspec.local.rockspec_format, None);
778        assert_eq!(rockspec.local.package, "bar".into());
779        assert_eq!(rockspec.local.version, "2.0.0-1".parse().unwrap());
780        assert_eq!(rockspec.local.description, RockDescription::default());
781
782        let rockspec_content = "
783        package = 'rocks.nvim'\n
784        version = '3.0.0-1'\n
785        description = {\n
786            summary = 'some summary',
787            detailed = 'some detailed description',
788            license = 'MIT',
789            homepage = 'https://github.com/lumen-oss/rocks.nvim',
790            issues_url = 'https://github.com/lumen-oss/rocks.nvim/issues',
791            maintainer = 'Lumen Labs',
792        }\n
793        source = {\n
794            url = 'https://github.com/lumen-oss/rocks.nvim/archive/1.0.0/rocks.nvim.zip',\n
795        }\n
796        "
797        .to_string();
798        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
799        assert_eq!(rockspec.local.rockspec_format, None);
800        assert_eq!(rockspec.local.package, "rocks.nvim".into());
801        assert_eq!(rockspec.local.version, "3.0.0-1".parse().unwrap());
802        let expected_description = RockDescription {
803            summary: Some("some summary".into()),
804            detailed: Some("some detailed description".into()),
805            license: Some("MIT".into()),
806            homepage: Some(Url::parse("https://github.com/lumen-oss/rocks.nvim").unwrap()),
807            issues_url: Some(Url::parse("https://github.com/lumen-oss/rocks.nvim/issues").unwrap()),
808            maintainer: Some("Lumen Labs".into()),
809            labels: Vec::new(),
810        };
811        assert_eq!(rockspec.local.description, expected_description);
812
813        let rockspec_content = "
814        package = 'rocks.nvim'\n
815        version = '3.0.0-1'\n
816        description = {\n
817            summary = 'some summary',
818            detailed = 'some detailed description',
819            license = 'MIT',
820            homepage = 'https://github.com/lumen-oss/rocks.nvim',
821            issues_url = 'https://github.com/lumen-oss/rocks.nvim/issues',
822            maintainer = 'Lumen Labs',
823            labels = {},
824        }\n
825        external_dependencies = { FOO = { library = 'foo' } }\n
826        source = {\n
827            url = 'https://github.com/lumen-oss/rocks.nvim/archive/1.0.0/rocks.nvim.zip',\n
828        }\n
829        "
830        .to_string();
831        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
832        assert_eq!(rockspec.local.rockspec_format, None);
833        assert_eq!(rockspec.local.package, "rocks.nvim".into());
834        assert_eq!(rockspec.local.version, "3.0.0-1".parse().unwrap());
835        let expected_description = RockDescription {
836            summary: Some("some summary".into()),
837            detailed: Some("some detailed description".into()),
838            license: Some("MIT".into()),
839            homepage: Some(Url::parse("https://github.com/lumen-oss/rocks.nvim").unwrap()),
840            issues_url: Some(Url::parse("https://github.com/lumen-oss/rocks.nvim/issues").unwrap()),
841            maintainer: Some("Lumen Labs".into()),
842            labels: Vec::new(),
843        };
844        assert_eq!(rockspec.local.description, expected_description);
845        assert_eq!(
846            *rockspec
847                .local
848                .external_dependencies
849                .default
850                .get("FOO")
851                .unwrap(),
852            ExternalDependencySpec {
853                library: Some("foo".into()),
854                header: None
855            }
856        );
857
858        let rockspec_content = "
859        package = 'rocks.nvim'\n
860        version = '3.0.0-1'\n
861        description = {\n
862            summary = 'some summary',
863            detailed = 'some detailed description',
864            license = 'MIT',
865            homepage = 'https://github.com/lumen-oss/rocks.nvim',
866            issues_url = 'https://github.com/lumen-oss/rocks.nvim/issues',
867            maintainer = 'Lumen Labs',
868            labels = { 'package management', },
869        }\n
870        supported_platforms = { 'unix', '!windows' }\n
871        dependencies = { 'neorg ~> 6' }\n
872        build_dependencies = { 'foo' }\n
873        external_dependencies = { FOO = { header = 'foo.h' } }\n
874        test_dependencies = { 'busted >= 2.0.0' }\n
875        source = {\n
876            url = 'git+https://github.com/lumen-oss/rocks.nvim',\n
877            hash = 'sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=',\n
878        }\n
879        "
880        .to_string();
881        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
882        assert_eq!(rockspec.local.rockspec_format, None);
883        assert_eq!(rockspec.local.package, "rocks.nvim".into());
884        assert_eq!(rockspec.local.version, "3.0.0-1".parse().unwrap());
885        let expected_description = RockDescription {
886            summary: Some("some summary".into()),
887            detailed: Some("some detailed description".into()),
888            license: Some("MIT".into()),
889            homepage: Some(Url::parse("https://github.com/lumen-oss/rocks.nvim").unwrap()),
890            issues_url: Some(Url::parse("https://github.com/lumen-oss/rocks.nvim/issues").unwrap()),
891            maintainer: Some("Lumen Labs".into()),
892            labels: vec!["package management".into()],
893        };
894        assert_eq!(rockspec.local.description, expected_description);
895        assert!(rockspec
896            .local
897            .supported_platforms
898            .is_supported(&PlatformIdentifier::Unix));
899        assert!(!rockspec
900            .local
901            .supported_platforms
902            .is_supported(&PlatformIdentifier::Windows));
903        let neorg = PackageSpec::parse("neorg".into(), "6.0.0".into()).unwrap();
904        assert!(rockspec
905            .local
906            .dependencies
907            .default
908            .into_iter()
909            .any(|dep| dep.matches(&neorg)));
910        let foo = PackageSpec::parse("foo".into(), "1.0.0".into()).unwrap();
911        assert!(rockspec
912            .local
913            .build_dependencies
914            .default
915            .into_iter()
916            .any(|dep| dep.matches(&foo)));
917        let busted = PackageSpec::parse("busted".into(), "2.2.0".into()).unwrap();
918        assert_eq!(
919            *rockspec
920                .local
921                .external_dependencies
922                .default
923                .get("FOO")
924                .unwrap(),
925            ExternalDependencySpec {
926                header: Some("foo.h".into()),
927                library: None
928            }
929        );
930        assert!(rockspec
931            .local
932            .test_dependencies
933            .default
934            .into_iter()
935            .any(|dep| dep.matches(&busted)));
936
937        let rockspec_content = "
938        rockspec_format = '1.0'\n
939        package = 'foo'\n
940        version = '1.0.0-1'\n
941        source = {\n
942            url = 'git+https://hub.com/owner/example-project/',\n
943            branch = 'bar',\n
944        }\n
945        "
946        .to_string();
947        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
948        assert_eq!(
949            rockspec.local.source.default.source_spec,
950            RockSourceSpec::Git(GitSource {
951                url: "https://hub.com/owner/example-project/".parse().unwrap(),
952                checkout_ref: Some("bar".into())
953            })
954        );
955        assert_eq!(rockspec.local.test, PerPlatform::default());
956        let rockspec_content = "
957        rockspec_format = '1.0'\n
958        package = 'foo'\n
959        version = '1.0.0-1'\n
960        source = {\n
961            url = 'git+https://hub.com/owner/example-project/',\n
962            tag = 'bar',\n
963        }\n
964        "
965        .to_string();
966        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
967        assert_eq!(
968            rockspec.local.source.default.source_spec,
969            RockSourceSpec::Git(GitSource {
970                url: "https://hub.com/owner/example-project/".parse().unwrap(),
971                checkout_ref: Some("bar".into())
972            })
973        );
974        let rockspec_content = "
975        rockspec_format = '1.0'\n
976        package = 'foo'\n
977        version = '1.0.0-1'\n
978        source = {\n
979            url = 'git+https://hub.com/owner/example-project/',\n
980            branch = 'bar',\n
981            tag = 'baz',\n
982        }\n
983        "
984        .to_string();
985        let _rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap_err();
986        let rockspec_content = "
987        rockspec_format = '1.0'\n
988        package = 'foo'\n
989        version = '1.0.0-1'\n
990        source = {\n
991            url = 'git+https://hub.com/owner/example-project/',\n
992            tag = 'bar',\n
993            file = 'foo.tar.gz',\n
994        }\n
995        build = {\n
996            install = {\n
997                conf = {['foo.bar'] = 'config/bar.toml'},\n
998            },\n
999        }\n
1000        "
1001        .to_string();
1002        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
1003        assert_eq!(
1004            rockspec.local.source.default.archive_name,
1005            Some("foo.tar.gz".into())
1006        );
1007        let foo_bar_path = rockspec
1008            .local
1009            .build
1010            .default
1011            .install
1012            .conf
1013            .get("foo.bar")
1014            .unwrap();
1015        assert_eq!(*foo_bar_path, PathBuf::from("config/bar.toml"));
1016        let rockspec_content = "
1017        rockspec_format = '1.0'\n
1018        package = 'foo'\n
1019        version = '1.0.0-1'\n
1020        source = {\n
1021            url = 'git+https://hub.com/example-project/foo.zip',\n
1022        }\n
1023        build = {\n
1024            install = {\n
1025                lua = {\n
1026                    'foo.lua',\n
1027                    ['foo.bar'] = 'src/bar.lua',\n
1028                },\n
1029                bin = {['foo.bar'] = 'bin/bar'},\n
1030            },\n
1031        }\n
1032        "
1033        .to_string();
1034        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
1035        assert!(matches!(
1036            rockspec.local.build.default.build_backend,
1037            Some(BuildBackendSpec::Builtin { .. })
1038        ));
1039        let install_lua_spec = rockspec.local.build.default.install.lua;
1040        let foo_bar_path = install_lua_spec
1041            .get(&LuaModule::from_str("foo.bar").unwrap())
1042            .unwrap();
1043        assert_eq!(*foo_bar_path, PathBuf::from("src/bar.lua"));
1044        let foo_path = install_lua_spec
1045            .get(&LuaModule::from_str("foo").unwrap())
1046            .unwrap();
1047        assert_eq!(*foo_path, PathBuf::from("foo.lua"));
1048        let foo_bar_path = rockspec
1049            .local
1050            .build
1051            .default
1052            .install
1053            .bin
1054            .get("foo.bar")
1055            .unwrap();
1056        assert_eq!(*foo_bar_path, PathBuf::from("bin/bar"));
1057        let rockspec_content = "
1058        rockspec_format = '1.0'\n
1059        package = 'foo'\n
1060        version = '1.0.0-1'\n
1061        source = {\n
1062            url = 'git+https://hub.com/example-project/',\n
1063        }\n
1064        build = {\n
1065            copy_directories = { 'lua' },\n
1066        }\n
1067        "
1068        .to_string();
1069        let _rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap_err();
1070        let rockspec_content = "
1071        rockspec_format = '1.0'\n
1072        package = 'foo'\n
1073        version = '1.0.0-1'\n
1074        source = {\n
1075            url = 'git+https://hub.com/example-project/',\n
1076        }\n
1077        build = {\n
1078            copy_directories = { 'lib' },\n
1079        }\n
1080        "
1081        .to_string();
1082        let _rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap_err();
1083        let rockspec_content = "
1084        rockspec_format = '1.0'\n
1085        package = 'foo'\n
1086        version = '1.0.0-1'\n
1087        source = {\n
1088            url = 'git+https://hub.com/example-project/',\n
1089        }\n
1090        build = {\n
1091            copy_directories = { 'rock_manifest' },\n
1092        }\n
1093        "
1094        .to_string();
1095        let _rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap_err();
1096        let rockspec_content = "
1097        rockspec_format = '1.0'\n
1098        package = 'foo'\n
1099        version = '1.0.0-1'\n
1100        source = {\n
1101            url = 'git+https://hub.com/example-project/foo.zip',\n
1102            dir = 'baz',\n
1103        }\n
1104        build = {\n
1105            type = 'make',\n
1106            install = {\n
1107                lib = {['foo.so'] = 'lib/bar.so'},\n
1108            },\n
1109            copy_directories = {\n
1110                'plugin',\n
1111                'ftplugin',\n
1112            },\n
1113            patches = {\n
1114                ['lua51-support.diff'] = [[\n
1115                    --- before.c\n
1116                    +++ path/to/after.c\n
1117                ]],\n
1118            },\n
1119        }\n
1120        "
1121        .to_string();
1122        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
1123        assert_eq!(rockspec.local.source.default.unpack_dir, Some("baz".into()));
1124        assert_eq!(
1125            rockspec.local.build.default.build_backend,
1126            Some(BuildBackendSpec::Make(MakeBuildSpec::default()))
1127        );
1128        let foo_bar_path = rockspec
1129            .local
1130            .build
1131            .default
1132            .install
1133            .lib
1134            .get("foo.so")
1135            .unwrap();
1136        assert_eq!(*foo_bar_path, PathBuf::from("lib/bar.so"));
1137        let copy_directories = rockspec.local.build.default.copy_directories;
1138        assert_eq!(
1139            copy_directories,
1140            vec![PathBuf::from("plugin"), PathBuf::from("ftplugin")]
1141        );
1142        let patches = rockspec.local.build.default.patches;
1143        let _patch = patches.get(&PathBuf::from("lua51-support.diff")).unwrap();
1144        let rockspec_content = "
1145        rockspec_format = '1.0'\n
1146        package = 'foo'\n
1147        version = '1.0.0-1'\n
1148        source = {\n
1149            url = 'git+https://hub.com/example-project/foo.zip',\n
1150        }\n
1151        build = {\n
1152            type = 'cmake',\n
1153        }\n
1154        "
1155        .to_string();
1156        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
1157        assert_eq!(
1158            rockspec.local.build.default.build_backend,
1159            Some(BuildBackendSpec::CMake(CMakeBuildSpec::default()))
1160        );
1161        let rockspec_content = "
1162        rockspec_format = '1.0'\n
1163        package = 'foo'\n
1164        version = '1.0.0-1'\n
1165        source = {\n
1166            url = 'git+https://hub.com/example-project/foo.zip',\n
1167        }\n
1168        build = {\n
1169            type = 'command',\n
1170            build_command = 'foo',\n
1171            install_command = 'bar',\n
1172        }\n
1173        "
1174        .to_string();
1175        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
1176        assert!(matches!(
1177            rockspec.local.build.default.build_backend,
1178            Some(BuildBackendSpec::Command(CommandBuildSpec { .. }))
1179        ));
1180        let rockspec_content = "
1181        rockspec_format = '1.0'\n
1182        package = 'foo'\n
1183        version = '1.0.0-1'\n
1184        source = {\n
1185            url = 'git+https://hub.com/example-project/foo.zip',\n
1186        }\n
1187        build = {\n
1188            type = 'command',\n
1189            install_command = 'foo',\n
1190        }\n
1191        "
1192        .to_string();
1193        RemoteLuaRockspec::new(&rockspec_content).unwrap();
1194        let rockspec_content = "
1195        rockspec_format = '1.0'\n
1196        package = 'foo'\n
1197        version = '1.0.0-1'\n
1198        source = {\n
1199            url = 'git+https://hub.com/example-project/foo.zip',\n
1200        }\n
1201        build = {\n
1202            type = 'command',\n
1203            build_command = 'foo',\n
1204        }\n
1205        "
1206        .to_string();
1207        RemoteLuaRockspec::new(&rockspec_content).unwrap();
1208        // platform overrides
1209        let rockspec_content = "
1210        package = 'rocks'\n
1211        version = '3.0.0-1'\n
1212        dependencies = {\n
1213          'neorg ~> 6',\n
1214          'toml-edit ~> 1',\n
1215          platforms = {\n
1216            windows = {\n
1217              'neorg = 5.0.0',\n
1218              'toml = 1.0.0',\n
1219            },\n
1220            unix = {\n
1221              'neorg = 5.0.0',\n
1222            },\n
1223            linux = {\n
1224              'toml = 1.0.0',\n
1225            },\n
1226          },\n
1227        }\n
1228        source = {\n
1229            url = 'git+https://github.com/lumen-oss/rocks.nvim',\n
1230            hash = 'sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=',\n
1231        }\n
1232        "
1233        .to_string();
1234        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
1235        let neorg_override = PackageSpec::parse("neorg".into(), "5.0.0".into()).unwrap();
1236        let toml_edit = PackageSpec::parse("toml-edit".into(), "1.0.0".into()).unwrap();
1237        let toml = PackageSpec::parse("toml".into(), "1.0.0".into()).unwrap();
1238        assert_eq!(rockspec.local.dependencies.default.len(), 2);
1239        let per_platform = &rockspec.local.dependencies.per_platform;
1240        assert_eq!(
1241            per_platform
1242                .get(&PlatformIdentifier::Windows)
1243                .unwrap()
1244                .iter()
1245                .filter(|dep| dep.matches(&neorg_override)
1246                    || dep.matches(&toml_edit)
1247                    || dep.matches(&toml))
1248                .count(),
1249            3
1250        );
1251        assert_eq!(
1252            per_platform
1253                .get(&PlatformIdentifier::Unix)
1254                .unwrap()
1255                .iter()
1256                .filter(|dep| dep.matches(&neorg_override)
1257                    || dep.matches(&toml_edit)
1258                    || dep.matches(&toml))
1259                .count(),
1260            2
1261        );
1262        assert_eq!(
1263            per_platform
1264                .get(&PlatformIdentifier::Linux)
1265                .unwrap()
1266                .iter()
1267                .filter(|dep| dep.matches(&neorg_override)
1268                    || dep.matches(&toml_edit)
1269                    || dep.matches(&toml))
1270                .count(),
1271            3
1272        );
1273        let rockspec_content = "
1274        package = 'rocks'\n
1275        version = '3.0.0-1'\n
1276        external_dependencies = {\n
1277            FOO = { library = 'foo' },\n
1278            platforms = {\n
1279              windows = {\n
1280                FOO = { library = 'foo.dll' },\n
1281              },\n
1282              unix = {\n
1283                BAR = { header = 'bar.h' },\n
1284              },\n
1285              linux = {\n
1286                FOO = { library = 'foo.so' },\n
1287              },\n
1288            },\n
1289        }\n
1290        source = {\n
1291            url = 'https://github.com/lumen-oss/rocks.nvim/archive/1.0.0/rocks.nvim.zip',\n
1292        }\n
1293        "
1294        .to_string();
1295        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
1296        assert_eq!(
1297            *rockspec
1298                .local
1299                .external_dependencies
1300                .default
1301                .get("FOO")
1302                .unwrap(),
1303            ExternalDependencySpec {
1304                library: Some("foo".into()),
1305                header: None
1306            }
1307        );
1308        let per_platform = rockspec.local.external_dependencies.per_platform;
1309        assert_eq!(
1310            *per_platform
1311                .get(&PlatformIdentifier::Windows)
1312                .and_then(|it| it.get("FOO"))
1313                .unwrap(),
1314            ExternalDependencySpec {
1315                library: Some("foo.dll".into()),
1316                header: None
1317            }
1318        );
1319        assert_eq!(
1320            *per_platform
1321                .get(&PlatformIdentifier::Unix)
1322                .and_then(|it| it.get("FOO"))
1323                .unwrap(),
1324            ExternalDependencySpec {
1325                library: Some("foo".into()),
1326                header: None
1327            }
1328        );
1329        assert_eq!(
1330            *per_platform
1331                .get(&PlatformIdentifier::Unix)
1332                .and_then(|it| it.get("BAR"))
1333                .unwrap(),
1334            ExternalDependencySpec {
1335                header: Some("bar.h".into()),
1336                library: None
1337            }
1338        );
1339        assert_eq!(
1340            *per_platform
1341                .get(&PlatformIdentifier::Linux)
1342                .and_then(|it| it.get("BAR"))
1343                .unwrap(),
1344            ExternalDependencySpec {
1345                header: Some("bar.h".into()),
1346                library: None
1347            }
1348        );
1349        assert_eq!(
1350            *per_platform
1351                .get(&PlatformIdentifier::Linux)
1352                .and_then(|it| it.get("FOO"))
1353                .unwrap(),
1354            ExternalDependencySpec {
1355                library: Some("foo.so".into()),
1356                header: None
1357            }
1358        );
1359        let rockspec_content = "
1360        rockspec_format = '1.0'\n
1361        package = 'foo'\n
1362        version = '1.0.0-1'\n
1363        source = {\n
1364            url = 'git+https://hub.com/example-project/.git',\n
1365            branch = 'bar',\n
1366            platforms = {\n
1367                macosx = {\n
1368                    branch = 'mac',\n
1369                },\n
1370                windows = {\n
1371                    url = 'git+https://winhub.com/example-project/.git',\n
1372                    branch = 'win',\n
1373                },\n
1374            },\n
1375        }\n
1376        "
1377        .to_string();
1378        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
1379        assert_eq!(
1380            rockspec.local.source.default.source_spec,
1381            RockSourceSpec::Git(GitSource {
1382                url: "https://hub.com/example-project/.git".parse().unwrap(),
1383                checkout_ref: Some("bar".into())
1384            })
1385        );
1386        assert_eq!(
1387            rockspec
1388                .source
1389                .per_platform
1390                .get(&PlatformIdentifier::MacOSX)
1391                .map(|it| it.source_spec.clone())
1392                .unwrap(),
1393            RockSourceSpec::Git(GitSource {
1394                url: "https://hub.com/example-project/.git".parse().unwrap(),
1395                checkout_ref: Some("mac".into())
1396            })
1397        );
1398        assert_eq!(
1399            rockspec
1400                .source
1401                .per_platform
1402                .get(&PlatformIdentifier::Windows)
1403                .map(|it| it.source_spec.clone())
1404                .unwrap(),
1405            RockSourceSpec::Git(GitSource {
1406                url: "https://winhub.com/example-project/.git".parse().unwrap(),
1407                checkout_ref: Some("win".into())
1408            })
1409        );
1410        let rockspec_content = "
1411        rockspec_format = '1.0'\n
1412        package = 'foo'\n
1413        version = '1.0.0-1'\n
1414        source = { url = 'git+https://hub.com/example-project/foo.zip' }\n
1415        build = {\n
1416            type = 'make',\n
1417            install = {\n
1418                lib = {['foo.bar'] = 'lib/bar.so'},\n
1419            },\n
1420            copy_directories = { 'plugin' },\n
1421            platforms = {\n
1422                unix = {\n
1423                    copy_directories = { 'ftplugin' },\n
1424                },\n
1425                linux = {\n
1426                    copy_directories = { 'foo' },\n
1427                },\n
1428            },\n
1429        }\n
1430        "
1431        .to_string();
1432        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
1433        let per_platform = rockspec.local.build.per_platform;
1434        let unix = per_platform.get(&PlatformIdentifier::Unix).unwrap();
1435        assert_eq!(
1436            unix.copy_directories,
1437            vec![PathBuf::from("plugin"), PathBuf::from("ftplugin")]
1438        );
1439        let linux = per_platform.get(&PlatformIdentifier::Linux).unwrap();
1440        assert_eq!(
1441            linux.copy_directories,
1442            vec![
1443                PathBuf::from("plugin"),
1444                PathBuf::from("foo"),
1445                PathBuf::from("ftplugin")
1446            ]
1447        );
1448        let rockspec_content = "
1449        package = 'foo'\n
1450        version = '1.0.0-1'\n
1451        source = { url = 'git+https://hub.com/example-project/foo.zip' }\n
1452        build = {\n
1453            type = 'builtin',\n
1454            modules = {\n
1455                cjson = {\n
1456                    sources = { 'lua_cjson.c', 'strbuf.c', 'fpconv.c' },\n
1457                }\n
1458            },\n
1459            platforms = {\n
1460                win32 = { modules = { cjson = { defines = {\n
1461                    'DISABLE_INVALID_NUMBERS', 'USE_INTERNAL_ISINF'\n
1462                } } } }\n
1463            },\n
1464        }\n
1465        "
1466        .to_string();
1467        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
1468        let win32 = rockspec.local.build.get(&PlatformIdentifier::Windows);
1469        assert_eq!(
1470            win32.build_backend,
1471            Some(BuildBackendSpec::Builtin(BuiltinBuildSpec {
1472                modules: vec![(
1473                    LuaModule::from_str("cjson").unwrap(),
1474                    ModuleSpec::ModulePaths(ModulePaths {
1475                        sources: vec!["lua_cjson.c".into(), "strbuf.c".into(), "fpconv.c".into()],
1476                        libraries: Vec::default(),
1477                        defines: vec![
1478                            ("DISABLE_INVALID_NUMBERS".into(), None),
1479                            ("USE_INTERNAL_ISINF".into(), None)
1480                        ],
1481                        incdirs: Vec::default(),
1482                        libdirs: Vec::default(),
1483                    })
1484                )]
1485                .into_iter()
1486                .collect()
1487            }))
1488        );
1489        let rockspec_content = "
1490        rockspec_format = '1.0'\n
1491        package = 'foo'\n
1492        version = '1.0.0-1'\n
1493        deploy = {\n
1494            wrap_bin_scripts = false,\n
1495        }\n
1496        source = { url = 'git+https://hub.com/example-project/foo.zip' }\n
1497        ";
1498        let rockspec = RemoteLuaRockspec::new(rockspec_content).unwrap();
1499        let deploy_spec = &rockspec.deploy().current_platform();
1500        assert!(!deploy_spec.wrap_bin_scripts);
1501    }
1502
1503    #[test]
1504    pub fn parse_scm_rockspec() {
1505        let rockspec_content = "
1506        package = 'foo'\n
1507        version = 'scm-1'\n
1508        source = {\n
1509            url = 'https://github.com/lumen-oss/rocks.nvim/archive/1.0.0/rocks.nvim.zip',\n
1510        }\n
1511        "
1512        .to_string();
1513        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
1514        assert_eq!(rockspec.local.package, "foo".into());
1515        assert_eq!(rockspec.local.version, "scm-1".parse().unwrap());
1516    }
1517
1518    #[test]
1519    pub fn regression_luasystem() {
1520        let rockspec_content = String::from_utf8(
1521            std::fs::read(
1522                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1523                    .join("resources/test/luasystem-0.4.4-1.rockspec"),
1524            )
1525            .unwrap(),
1526        )
1527        .unwrap();
1528        let rockspec = RemoteLuaRockspec::new(&rockspec_content).unwrap();
1529        let build_spec = rockspec.local.build.current_platform();
1530        assert!(matches!(
1531            build_spec.build_backend,
1532            Some(BuildBackendSpec::Builtin { .. })
1533        ));
1534        if let Some(BuildBackendSpec::Builtin(BuiltinBuildSpec { modules })) =
1535            &build_spec.build_backend
1536        {
1537            assert_eq!(
1538                modules.get(&LuaModule::from_str("system.init").unwrap()),
1539                Some(&ModuleSpec::SourcePath("system/init.lua".into()))
1540            );
1541            assert_eq!(
1542                modules.get(&LuaModule::from_str("system.core").unwrap()),
1543                Some(&ModuleSpec::ModulePaths(ModulePaths {
1544                    sources: vec![
1545                        "src/core.c".into(),
1546                        "src/compat.c".into(),
1547                        "src/time.c".into(),
1548                        "src/environment.c".into(),
1549                        "src/random.c".into(),
1550                        "src/term.c".into(),
1551                        "src/bitflags.c".into(),
1552                        "src/wcwidth.c".into(),
1553                    ],
1554                    defines: luasystem_expected_defines(),
1555                    libraries: luasystem_expected_libraries(),
1556                    incdirs: luasystem_expected_incdirs(),
1557                    libdirs: luasystem_expected_libdirs(),
1558                }))
1559            );
1560        }
1561        if let Some(BuildBackendSpec::Builtin(BuiltinBuildSpec { modules })) = &rockspec
1562            .local
1563            .build
1564            .get(&PlatformIdentifier::Windows)
1565            .build_backend
1566        {
1567            if let ModuleSpec::ModulePaths(paths) = modules
1568                .get(&LuaModule::from_str("system.core").unwrap())
1569                .unwrap()
1570            {
1571                assert_eq!(paths.libraries, luasystem_expected_windows_libraries());
1572            };
1573        }
1574        if let Some(BuildBackendSpec::Builtin(BuiltinBuildSpec { modules })) = &rockspec
1575            .local
1576            .build
1577            .get(&PlatformIdentifier::Win32)
1578            .build_backend
1579        {
1580            if let ModuleSpec::ModulePaths(paths) = modules
1581                .get(&LuaModule::from_str("system.core").unwrap())
1582                .unwrap()
1583            {
1584                assert_eq!(paths.libraries, luasystem_expected_windows_libraries());
1585            };
1586        }
1587    }
1588
1589    fn luasystem_expected_defines() -> Vec<(String, Option<String>)> {
1590        if cfg!(target_os = "windows") {
1591            vec![
1592                ("WINVER".into(), Some("0x0600".into())),
1593                ("_WIN32_WINNT".into(), Some("0x0600".into())),
1594            ]
1595        } else {
1596            Vec::default()
1597        }
1598    }
1599
1600    fn luasystem_expected_windows_libraries() -> Vec<PathBuf> {
1601        vec!["advapi32".into(), "winmm".into()]
1602    }
1603    fn luasystem_expected_libraries() -> Vec<PathBuf> {
1604        if cfg!(any(target_os = "linux", target_os = "android")) {
1605            vec!["rt".into()]
1606        } else if cfg!(target_os = "windows") {
1607            luasystem_expected_windows_libraries()
1608        } else {
1609            Vec::default()
1610        }
1611    }
1612
1613    fn luasystem_expected_incdirs() -> Vec<PathBuf> {
1614        Vec::default()
1615    }
1616
1617    fn luasystem_expected_libdirs() -> Vec<PathBuf> {
1618        Vec::default()
1619    }
1620
1621    #[test]
1622    pub fn rust_mlua_rockspec() {
1623        let rockspec_content = "
1624    package = 'foo'\n
1625    version = 'scm-1'\n
1626    source = {\n
1627        url = 'https://github.com/lumen-oss/rocks.nvim/archive/1.0.0/rocks.nvim.zip',\n
1628    }\n
1629    build = {
1630        type = 'rust-mlua',
1631        modules = {
1632            'foo',
1633            bar = 'baz',
1634        },
1635        target_path = 'path/to/cargo/target/directory',
1636        default_features = false,
1637        include = {
1638            'file.lua',
1639            ['path/to/another/file.lua'] = 'another-file.lua',
1640        },
1641        features = {'extra', 'features'},
1642    }
1643            ";
1644        let rockspec = RemoteLuaRockspec::new(rockspec_content).unwrap();
1645        let build_spec = rockspec.local.build.current_platform();
1646        if let Some(BuildBackendSpec::RustMlua(build_spec)) = build_spec.build_backend.to_owned() {
1647            assert_eq!(
1648                build_spec.modules.get("foo").unwrap(),
1649                &PathBuf::from(format!("libfoo.{}", std::env::consts::DLL_EXTENSION))
1650            );
1651            assert_eq!(
1652                build_spec.modules.get("bar").unwrap(),
1653                &PathBuf::from(format!("libbaz.{}", std::env::consts::DLL_EXTENSION))
1654            );
1655            assert_eq!(
1656                build_spec.include.get(&PathBuf::from("file.lua")).unwrap(),
1657                &PathBuf::from("file.lua")
1658            );
1659            assert_eq!(
1660                build_spec
1661                    .include
1662                    .get(&PathBuf::from("path/to/another/file.lua"))
1663                    .unwrap(),
1664                &PathBuf::from("another-file.lua")
1665            );
1666        } else {
1667            panic!("Expected RustMlua build backend");
1668        }
1669    }
1670
1671    #[tokio::test]
1672    pub async fn regression_ltui() {
1673        let content = String::from_utf8(
1674            std::fs::read(
1675                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1676                    .join("resources/test/ltui-2.8-2.rockspec"),
1677            )
1678            .unwrap(),
1679        )
1680        .unwrap();
1681        RemoteLuaRockspec::new(&content).unwrap();
1682    }
1683
1684    // Luarocks allows the `install.bin` field to be a list, even though it
1685    // should only allow a table.
1686    #[test]
1687    pub fn regression_off_spec_install_binaries() {
1688        let rockspec_content = r#"
1689            package = "WSAPI"
1690            version = "1.7-1"
1691
1692            source = {
1693              url = "git://github.com/keplerproject/wsapi",
1694              tag = "v1.7",
1695            }
1696
1697            build = {
1698              type = "builtin",
1699              modules = {
1700                ["wsapi"] = "src/wsapi.lua",
1701              },
1702              -- Offending Line
1703              install = { bin = { "src/launcher/wsapi.cgi" } }
1704            }
1705        "#;
1706
1707        let rockspec = RemoteLuaRockspec::new(rockspec_content).unwrap();
1708
1709        assert_eq!(
1710            rockspec.build().current_platform().install.bin,
1711            HashMap::from([("wsapi.cgi".into(), PathBuf::from("src/launcher/wsapi.cgi"))])
1712        );
1713    }
1714
1715    #[test]
1716    pub fn regression_external_dependencies() {
1717        let content = String::from_utf8(
1718            std::fs::read(
1719                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1720                    .join("resources/test/luaossl-20220711-0.rockspec"),
1721            )
1722            .unwrap(),
1723        )
1724        .unwrap();
1725        let rockspec = RemoteLuaRockspec::new(&content).unwrap();
1726        if cfg!(target_family = "unix") {
1727            assert_eq!(
1728                rockspec
1729                    .local
1730                    .external_dependencies
1731                    .current_platform()
1732                    .get("OPENSSL")
1733                    .unwrap(),
1734                &ExternalDependencySpec {
1735                    library: Some("ssl".into()),
1736                    header: Some("openssl/ssl.h".into()),
1737                }
1738            );
1739        }
1740        let per_platform = rockspec.local.external_dependencies.per_platform;
1741        assert_eq!(
1742            *per_platform
1743                .get(&PlatformIdentifier::Windows)
1744                .and_then(|it| it.get("OPENSSL"))
1745                .unwrap(),
1746            ExternalDependencySpec {
1747                library: Some("libeay32".into()),
1748                header: Some("openssl/ssl.h".into()),
1749            }
1750        );
1751    }
1752
1753    #[test]
1754    pub fn remote_lua_rockspec_from_package_and_source_spec() {
1755        let package_req = "foo@1.0.5".parse().unwrap();
1756        let source = GitSource {
1757            url: "https://hub.com/owner/example-project.git".parse().unwrap(),
1758            checkout_ref: Some("1.0.5".into()),
1759        };
1760        let source_spec = RockSourceSpec::Git(source);
1761        let rockspec =
1762            RemoteLuaRockspec::from_package_and_source_spec(package_req, source_spec.clone());
1763        let generated_rockspec_str = rockspec.local.raw_content;
1764        let rockspec2 = RemoteLuaRockspec::new(&generated_rockspec_str).unwrap();
1765        assert_eq!(rockspec2.local.package, "foo".into());
1766        assert_eq!(rockspec2.local.version, "1.0.5".parse().unwrap());
1767        assert_eq!(rockspec2.local.source, PerPlatform::new(source_spec.into()));
1768    }
1769
1770    #[test]
1771    pub fn regression_complex_source_field() {
1772        let rockspec_content = r#"
1773            package = "say"
1774            local rock_version = "1.4.1"
1775            local rock_release = "3"
1776            local namespace = "lunarmodules"
1777            local repository = package
1778
1779            version = ("%s-%s"):format(rock_version, rock_release)
1780
1781            source = {
1782              url = ("git+https://github.com/%s/%s.git"):format(namespace, repository),
1783              branch = rock_version == "scm" and "master" or nil,
1784              tag = rock_version ~= "scm" and "v"..rock_version or nil,
1785            }
1786
1787            description = {
1788              summary = "Lua string hashing/indexing library",
1789            }
1790
1791            dependencies = {
1792              "lua >= 5.1",
1793            }
1794
1795            build = {
1796              type = "builtin",
1797            }
1798        "#
1799        .to_string();
1800        RemoteLuaRockspec::new(&rockspec_content).unwrap();
1801    }
1802
1803    fn eval_lua_global<T: serde::de::DeserializeOwned>(code: &str, key: &'static str) -> T {
1804        use ottavino::{Closure, Executor, Fuel, Lua};
1805        use ottavino_util::serde::from_value;
1806        Lua::core()
1807            .try_enter(|ctx| {
1808                let closure = Closure::load(ctx, None, code.as_bytes())?;
1809                let executor = Executor::start(ctx, closure.into(), ());
1810                executor.step(ctx, &mut Fuel::with(i32::MAX))?;
1811                from_value(ctx.globals().get_value(ctx, key)).map_err(ottavino::Error::from)
1812            })
1813            .unwrap()
1814    }
1815
1816    #[test]
1817    pub fn rock_description_roundtrip() {
1818        let desc = RockDescription {
1819            summary: Some("A great package".into()),
1820            detailed: Some("Detailed description here".into()),
1821            license: Some("MIT".into()),
1822            homepage: Some("https://example.com".parse().unwrap()),
1823            issues_url: Some("https://example.com/issues".parse().unwrap()),
1824            maintainer: Some("Some Maintainer <maintainer@example.com>".into()),
1825            labels: vec!["neovim".into(), "lua".into()],
1826        };
1827        let lua = desc.display_lua().to_string();
1828        let restored: RockDescription = eval_lua_global(&lua, "description");
1829        assert_eq!(desc, restored);
1830    }
1831
1832    #[test]
1833    pub fn rock_description_empty_roundtrip() {
1834        let desc = RockDescription::default();
1835        let lua = desc.display_lua().to_string();
1836        // An empty description produces an empty assignment; restore from empty table.
1837        let lua = if lua.trim().is_empty() {
1838            "description = {}".to_string()
1839        } else {
1840            lua
1841        };
1842        let restored: RockDescription = eval_lua_global(&lua, "description");
1843        assert_eq!(desc, restored);
1844    }
1845
1846    #[test]
1847    pub fn regression_luasec() {
1848        let rockspec_content = r#"
1849package = "LuaSec"
1850version = "1.3.2-1"
1851source = {
1852  url = "git+https://github.com/brunoos/luasec",
1853  tag = "v1.3.2",
1854}
1855external_dependencies = {
1856   platforms = {
1857      unix = {
1858         OPENSSL = {
1859            header = "openssl/ssl.h",
1860            library = "ssl"
1861         }
1862      },
1863      windows = {
1864         OPENSSL = {
1865            header = "openssl/ssl.h",
1866         }
1867      },
1868   }
1869}
1870"#;
1871        let rockspec = RemoteLuaRockspec::new(rockspec_content).unwrap();
1872        let linux_external_deps = rockspec
1873            .external_dependencies()
1874            .get(&PlatformIdentifier::Linux);
1875        assert!(linux_external_deps.get("OPENSSL").is_some());
1876        let windows_external_deps = rockspec
1877            .external_dependencies()
1878            .get(&PlatformIdentifier::Windows);
1879        assert!(windows_external_deps.get("OPENSSL").is_some());
1880    }
1881
1882    #[test]
1883    pub fn find_value_span_spaces() {
1884        let source = "package = 'foo'\nversion = '1.0'";
1885        let span = find_value_span(source, "package");
1886        assert_eq!(&source[span.offset()..span.offset() + span.len()], "'foo'");
1887    }
1888
1889    #[test]
1890    pub fn find_value_span_no_spaces() {
1891        let source = "package='foo'";
1892        let span = find_value_span(source, "package");
1893        assert_eq!(&source[span.offset()..span.offset() + span.len()], "'foo'");
1894    }
1895
1896    #[test]
1897    pub fn find_value_span_returns_empty_when_not_found() {
1898        let source = "version = '1.0'";
1899        let span = find_value_span(source, "package");
1900        assert_eq!(span.len(), 0);
1901    }
1902
1903    #[test]
1904    pub fn find_value_span_skips_url_containing_field() {
1905        let source = "homepage = 'https://luapackager.com'\npackage = 'foo'";
1906        let span = find_value_span(source, "package");
1907        assert_eq!(&source[span.offset()..span.offset() + span.len()], "'foo'");
1908    }
1909
1910    #[test]
1911    pub fn find_value_span_double_quoted() {
1912        let source = r#"package = "foo""#;
1913        let span = find_value_span(source, "package");
1914        assert_eq!(
1915            &source[span.offset()..span.offset() + span.len()],
1916            r#""foo""#
1917        );
1918    }
1919
1920    #[test]
1921    pub fn find_value_span_table() {
1922        let source = "description = {\n    summary = 'bar'\n}";
1923        let span = find_value_span(source, "description");
1924        assert_eq!(&source[span.offset()..span.offset() + span.len()], "{");
1925    }
1926
1927    #[test]
1928    pub fn parse_lua_rockspec_field_error() {
1929        let rockspec_content = "package = 'my-lua-lib'\nfoo = 123";
1930        let extern_err = ottavino::Lua::core()
1931            .try_enter(|_ctx| {
1932                Err(ParseLuaRockspecFieldError::Deserialize {
1933                    field: "foo".into(),
1934                    source: Box::new(ottavino_util::serde::de::Error::Message(
1935                        "expected bar".into(),
1936                    )),
1937                })?;
1938                Ok(())
1939            })
1940            .unwrap_err();
1941        let err = LuaRockspecError::from_ottavino(extern_err, rockspec_content);
1942        match err {
1943            LuaRockspecError::ExecutionError {
1944                cause,
1945                err_span: Some(span),
1946                ..
1947            } => {
1948                assert!(cause.contains("invalid rockspec field 'foo'"));
1949                assert_eq!(
1950                    &rockspec_content[span.offset()..span.offset() + span.len()],
1951                    "foo"
1952                );
1953            }
1954            other => panic!("expected ExecutionError with err_span, got: {other:?}"),
1955        }
1956        let extern_err = ottavino::Lua::core()
1957            .try_enter(|_ctx| {
1958                Err(ParseLuaRockspecFieldError::DeserializeTable {
1959                    field: "foo".into(),
1960                    source: Box::new(ottavino_util::serde::de::Error::Message(
1961                        "expected bar".into(),
1962                    )),
1963                })?;
1964                Ok(())
1965            })
1966            .unwrap_err();
1967        let err = LuaRockspecError::from_ottavino(extern_err, rockspec_content);
1968        match err {
1969            LuaRockspecError::ExecutionError {
1970                cause,
1971                err_span: Some(span),
1972                ..
1973            } => {
1974                assert!(cause.contains("invalid rockspec field 'foo'"));
1975                assert_eq!(
1976                    &rockspec_content[span.offset()..span.offset() + span.len()],
1977                    "foo"
1978                );
1979            }
1980            other => panic!("expected ExecutionError with err_span, got: {other:?}"),
1981        }
1982        let extern_err = ottavino::Lua::core()
1983            .try_enter(|_ctx| {
1984                Err(ParseLuaRockspecFieldError::NotATable {
1985                    field: "foo".into(),
1986                    invalid_type: "integer".into(),
1987                })?;
1988                Ok(())
1989            })
1990            .unwrap_err();
1991        let err = LuaRockspecError::from_ottavino(extern_err, rockspec_content);
1992        match err {
1993            LuaRockspecError::ExecutionError {
1994                cause,
1995                err_span: Some(span),
1996                ..
1997            } => {
1998                assert!(cause.contains("invalid rockspec field 'foo'"));
1999                assert_eq!(
2000                    &rockspec_content[span.offset()..span.offset() + span.len()],
2001                    "foo"
2002                );
2003            }
2004            other => panic!("expected ExecutionError with err_span, got: {other:?}"),
2005        }
2006    }
2007}