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