Skip to main content

lux_lib/lua_rockspec/
test_spec.rs

1use itertools::Itertools;
2
3use miette::Diagnostic;
4use path_slash::PathExt;
5use serde::{Deserialize, Deserializer};
6use serde_enum_str::Serialize_enum_str;
7use std::{convert::Infallible, path::PathBuf};
8use thiserror::Error;
9
10use crate::lua_version::LuaVersion;
11
12use crate::{
13    config::{Config, ConfigBuilder, ConfigError},
14    lua_rockspec::per_platform_from_intermediate,
15    package::PackageReq,
16    project::{project_toml::LocalProjectTomlValidationError, Project},
17    rockspec::Rockspec,
18};
19
20use super::{PartialOverride, PerPlatform, PlatformOverridable};
21
22#[cfg(target_family = "unix")]
23const NLUA_EXE: &str = "nlua";
24#[cfg(target_family = "windows")]
25const NLUA_EXE: &str = "nlua.bat";
26
27#[derive(Error, Debug, Diagnostic)]
28pub enum TestSpecDecodeError {
29    #[error("the 'command' test type must specify either a 'command' or 'script' field")]
30    NoCommandOrScript,
31    #[error("the 'command' test type cannot have both 'command' and 'script' fields")]
32    CommandAndScript,
33}
34
35#[derive(Error, Debug, Diagnostic)]
36pub enum TestSpecError {
37    #[error("could not auto-detect the test spec. Please add one to your lux.toml")]
38    NoTestSpecDetected,
39    #[error("project validation failed:\n{0}")]
40    #[diagnostic(forward(0))]
41    LocalProjectTomlValidation(#[from] LocalProjectTomlValidationError),
42}
43
44#[derive(Clone, Debug, PartialEq, Default)]
45pub enum TestSpec {
46    #[default]
47    AutoDetect,
48    Busted(BustedTestSpec),
49    BustedNlua(BustedTestSpec),
50    Command(CommandTestSpec),
51    Script(LuaScriptTestSpec),
52}
53
54#[derive(Clone, Debug, PartialEq)]
55pub(crate) enum ValidatedTestSpec {
56    Busted {
57        spec: BustedTestSpec,
58        dependencies: Vec<PackageReq>,
59    },
60    BustedNlua {
61        spec: BustedTestSpec,
62        dependencies: Vec<PackageReq>,
63    },
64    Command(CommandTestSpec),
65    LuaScript(LuaScriptTestSpec),
66}
67
68impl TestSpec {
69    pub(crate) fn test_dependencies(&self, project: &Project) -> Vec<PackageReq> {
70        self.to_validated(project)
71            .ok()
72            .iter()
73            .flat_map(|spec| spec.test_dependencies())
74            .collect_vec()
75    }
76
77    pub(crate) fn to_validated(
78        &self,
79        project: &Project,
80    ) -> Result<ValidatedTestSpec, TestSpecError> {
81        let project_root = project.root();
82        let toml = project.toml().into_local()?;
83        let test_dependencies = toml.test_dependencies().current_platform();
84        let busted_dependency = test_dependencies
85            .iter()
86            .find(|dep| dep.name().to_string() == "busted");
87        let busted = busted_dependency
88            .map(|dep| dep.package_req.clone())
89            .unwrap_or_else(|| unsafe { PackageReq::new_unchecked("busted".into(), None) });
90        let nlua_dependency = test_dependencies
91            .iter()
92            .find(|dep| dep.name().to_string() == "nlua");
93        let nlua = nlua_dependency
94            .map(|dep| dep.package_req.clone())
95            .unwrap_or_else(|| unsafe { PackageReq::new_unchecked("nlua".into(), None) });
96        let is_busted = project_root.join(".busted").is_file() || busted_dependency.is_some();
97        match self {
98            Self::AutoDetect if is_busted => {
99                if nlua_dependency.is_some() {
100                    Ok(ValidatedTestSpec::BustedNlua {
101                        spec: BustedTestSpec::default(),
102                        dependencies: vec![busted, nlua],
103                    })
104                } else {
105                    Ok(ValidatedTestSpec::Busted {
106                        spec: BustedTestSpec::default(),
107                        dependencies: vec![busted],
108                    })
109                }
110            }
111            Self::Busted(spec) => Ok(ValidatedTestSpec::Busted {
112                spec: spec.clone(),
113                dependencies: vec![busted],
114            }),
115            Self::BustedNlua(spec) => Ok(ValidatedTestSpec::BustedNlua {
116                spec: spec.clone(),
117                dependencies: vec![busted, nlua],
118            }),
119            Self::Command(spec) => Ok(ValidatedTestSpec::Command(spec.clone())),
120            Self::Script(spec) => Ok(ValidatedTestSpec::LuaScript(spec.clone())),
121            Self::AutoDetect => Err(TestSpecError::NoTestSpecDetected),
122        }
123    }
124}
125
126impl ValidatedTestSpec {
127    pub fn args(&self) -> Vec<String> {
128        match self {
129            Self::Busted {
130                spec,
131                dependencies: _,
132            } => spec.flags.clone(),
133            Self::BustedNlua {
134                spec,
135                dependencies: _,
136            } => {
137                let mut flags = spec.flags.clone();
138                // If there's a .busted config file which has lua set to "nlua",
139                // we tell busted to ignore it, because we set the correct
140                // platform-dependent "nlua" executable in the wrapper script.
141                flags.push("--ignore-lua".into());
142                flags
143            }
144            Self::Command(spec) => spec.flags.clone(),
145            Self::LuaScript(spec) => std::iter::once(spec.script.to_slash_lossy().to_string())
146                .chain(spec.flags.clone())
147                .collect_vec(),
148        }
149    }
150
151    pub(crate) fn test_config(&self, config: &Config) -> Result<Config, ConfigError> {
152        match self {
153            Self::BustedNlua { .. } => {
154                let config_builder: ConfigBuilder = config.clone().into();
155
156                // XXX: On macos and msvc, Neovim and LuaJIT segfault when
157                // requiring luafilesystem's `lfs` module
158                // if it is built with Lua 5.1 headers.
159                #[cfg(not(any(target_os = "macos", target_env = "msvc")))]
160                let lua_version = LuaVersion::Lua51;
161
162                #[cfg(any(target_os = "macos", target_env = "msvc"))]
163                let lua_version = LuaVersion::LuaJIT;
164
165                Ok(config_builder
166                    .lua_version(Some(lua_version))
167                    .variables(Some(
168                        vec![("LUA".to_string(), NLUA_EXE.to_string())]
169                            .into_iter()
170                            .collect(),
171                    ))
172                    .build()?)
173            }
174            _ => Ok(config.clone()),
175        }
176    }
177
178    fn test_dependencies(&self) -> Vec<PackageReq> {
179        match self {
180            Self::Busted {
181                spec: _,
182                dependencies,
183            } => dependencies.clone(),
184            Self::BustedNlua {
185                spec: _,
186                dependencies,
187            } => dependencies.clone(),
188            Self::Command(_) => Vec::new(),
189            Self::LuaScript(_) => Vec::new(),
190        }
191    }
192}
193
194impl TryFrom<TestSpecInternal> for TestSpec {
195    type Error = TestSpecDecodeError;
196
197    fn try_from(internal: TestSpecInternal) -> Result<Self, Self::Error> {
198        let test_spec = match internal.test_type {
199            Some(TestType::Busted) => Ok(Self::Busted(BustedTestSpec {
200                flags: internal.flags.unwrap_or_default(),
201            })),
202            Some(TestType::BustedNlua) => Ok(Self::BustedNlua(BustedTestSpec {
203                flags: internal.flags.unwrap_or_default(),
204            })),
205            Some(TestType::Command) => match (internal.command, internal.lua_script) {
206                (None, None) => Err(TestSpecDecodeError::NoCommandOrScript),
207                (None, Some(script)) => Ok(Self::Script(LuaScriptTestSpec {
208                    script,
209                    flags: internal.flags.unwrap_or_default(),
210                })),
211                (Some(command), None) => Ok(Self::Command(CommandTestSpec {
212                    command,
213                    flags: internal.flags.unwrap_or_default(),
214                })),
215                (Some(_), Some(_)) => Err(TestSpecDecodeError::CommandAndScript),
216            },
217            None => Ok(Self::default()),
218        }?;
219        Ok(test_spec)
220    }
221}
222
223impl<'de> Deserialize<'de> for PerPlatform<TestSpec> {
224    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
225    where
226        D: Deserializer<'de>,
227    {
228        per_platform_from_intermediate::<_, TestSpecInternal, _>(deserializer)
229    }
230}
231
232impl<'de> Deserialize<'de> for TestSpec {
233    fn deserialize<D>(deserializer: D) -> Result<TestSpec, D::Error>
234    where
235        D: Deserializer<'de>,
236    {
237        let internal = TestSpecInternal::deserialize(deserializer)?;
238        let test_spec = TestSpec::try_from(internal).map_err(serde::de::Error::custom)?;
239        Ok(test_spec)
240    }
241}
242
243/// Specification for running a test suite with busted
244#[derive(Clone, Debug, PartialEq, Default)]
245pub struct BustedTestSpec {
246    /// Additional CLI flags to pass to Busted when running
247    pub(crate) flags: Vec<String>,
248}
249
250impl BustedTestSpec {
251    pub fn flags(&self) -> &Vec<String> {
252        &self.flags
253    }
254}
255
256/// Specification for running a test suite with a command
257#[derive(Clone, Debug, PartialEq)]
258pub struct CommandTestSpec {
259    /// The command to run
260    pub(crate) command: String,
261    /// Additional CLI flags to pass to Busted when running
262    pub(crate) flags: Vec<String>,
263}
264
265impl CommandTestSpec {
266    pub fn flags(&self) -> &Vec<String> {
267        &self.flags
268    }
269
270    pub fn command(&self) -> &str {
271        &self.command
272    }
273}
274
275/// Specification for running a test suite with a Lua script
276#[derive(Clone, Debug, PartialEq)]
277pub struct LuaScriptTestSpec {
278    /// The script to run
279    pub(crate) script: PathBuf,
280    /// Additional CLI flags to pass to the script when running
281    pub(crate) flags: Vec<String>,
282}
283
284impl LuaScriptTestSpec {
285    pub fn flags(&self) -> &Vec<String> {
286        &self.flags
287    }
288
289    pub fn script(&self) -> &PathBuf {
290        &self.script
291    }
292}
293
294#[derive(Debug, Deserialize, Serialize_enum_str, PartialEq, Clone)]
295#[serde(rename_all = "kebab-case")]
296pub(crate) enum TestType {
297    Busted,
298    BustedNlua,
299    Command,
300}
301
302#[derive(Debug, PartialEq, Deserialize, Default, Clone)]
303pub(crate) struct TestSpecInternal {
304    #[serde(default, rename = "type")]
305    pub(crate) test_type: Option<TestType>,
306    #[serde(default)]
307    pub(crate) flags: Option<Vec<String>>,
308    #[serde(default)]
309    pub(crate) command: Option<String>,
310    #[serde(default, rename = "script", alias = "lua_script")]
311    pub(crate) lua_script: Option<PathBuf>,
312}
313
314impl PartialOverride for TestSpecInternal {
315    type Err = Infallible;
316
317    fn apply_overrides(&self, override_spec: &Self) -> Result<Self, Self::Err> {
318        Ok(TestSpecInternal {
319            test_type: override_opt(&override_spec.test_type, &self.test_type),
320            flags: match (override_spec.flags.clone(), self.flags.clone()) {
321                (Some(override_vec), Some(base_vec)) => {
322                    let merged: Vec<String> =
323                        base_vec.into_iter().chain(override_vec).unique().collect();
324                    Some(merged)
325                }
326                (None, base_vec @ Some(_)) => base_vec,
327                (override_vec @ Some(_), None) => override_vec,
328                _ => None,
329            },
330            command: match override_spec.lua_script.clone() {
331                Some(_) => None,
332                None => override_opt(&override_spec.command, &self.command),
333            },
334            lua_script: match override_spec.command.clone() {
335                Some(_) => None,
336                None => override_opt(&override_spec.lua_script, &self.lua_script),
337            },
338        })
339    }
340}
341
342impl PlatformOverridable for TestSpecInternal {
343    type Err = Infallible;
344
345    fn on_nil<T>() -> Result<PerPlatform<T>, <Self as PlatformOverridable>::Err>
346    where
347        T: PlatformOverridable,
348        T: Default,
349    {
350        Ok(PerPlatform::default())
351    }
352}
353
354fn override_opt<T: Clone>(override_opt: &Option<T>, base: &Option<T>) -> Option<T> {
355    match override_opt.clone() {
356        override_val @ Some(_) => override_val,
357        None => base.clone(),
358    }
359}
360
361#[cfg(test)]
362mod tests {
363
364    use ottavino::{Closure, Executor, Fuel, Lua};
365    use ottavino_util::serde::from_value;
366
367    use crate::lua_rockspec::PlatformIdentifier;
368
369    use super::*;
370
371    fn exec_lua<T: serde::de::DeserializeOwned>(
372        code: &str,
373        key: &'static str,
374    ) -> Result<T, ottavino::ExternError> {
375        Lua::core().try_enter(|ctx| {
376            let closure = Closure::load(ctx, None, code.as_bytes())?;
377            let executor = Executor::start(ctx, closure.into(), ());
378            executor.step(ctx, &mut Fuel::with(i32::MAX))?;
379            from_value(ctx.globals().get_value(ctx, key)).map_err(ottavino::Error::from)
380        })
381    }
382
383    #[tokio::test]
384    pub async fn test_spec_from_lua() {
385        let lua_content = "
386        test = {\n
387        }\n
388        ";
389        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
390        assert!(matches!(test_spec.default, TestSpec::AutoDetect));
391        let lua_content = "
392        test = {\n
393            type = 'busted',\n
394        }\n
395        ";
396        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
397        assert_eq!(
398            test_spec.default,
399            TestSpec::Busted(BustedTestSpec::default())
400        );
401        let lua_content = "
402        test = {\n
403            type = 'busted',\n
404            flags = { 'foo', 'bar' },\n
405        }\n
406        ";
407        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
408        assert_eq!(
409            test_spec.default,
410            TestSpec::Busted(BustedTestSpec {
411                flags: vec!["foo".into(), "bar".into()],
412            })
413        );
414        let lua_content = "
415        test = {\n
416            type = 'command',\n
417        }\n
418        ";
419        let result: Result<PerPlatform<TestSpec>, _> = exec_lua(lua_content, "test");
420        let _err = result.unwrap_err();
421        let lua_content = "
422        test = {\n
423            type = 'command',\n
424            command = 'foo',\n
425            script = 'bar',\n
426        }\n
427        ";
428        let result: Result<PerPlatform<TestSpec>, _> = exec_lua(lua_content, "test");
429        let _err = result.unwrap_err();
430        let lua_content = "
431        test = {\n
432            type = 'command',\n
433            command = 'baz',\n
434            flags = { 'foo', 'bar' },\n
435        }\n
436        ";
437        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
438        assert_eq!(
439            test_spec.default,
440            TestSpec::Command(CommandTestSpec {
441                command: "baz".into(),
442                flags: vec!["foo".into(), "bar".into()],
443            })
444        );
445        let lua_content = "
446        test = {\n
447            type = 'command',\n
448            script = 'test.lua',\n
449            flags = { 'foo', 'bar' },\n
450        }\n
451        ";
452        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
453        assert_eq!(
454            test_spec.default,
455            TestSpec::Script(LuaScriptTestSpec {
456                script: PathBuf::from("test.lua"),
457                flags: vec!["foo".into(), "bar".into()],
458            })
459        );
460        let lua_content = "
461        test = {\n
462            type = 'command',\n
463            command = 'baz',\n
464            flags = { 'foo', 'bar' },\n
465            platforms = {\n
466                unix = { flags = { 'baz' }, },\n
467                macosx = {\n
468                    script = 'bat.lua',\n
469                    flags = { 'bat' },\n
470                },\n
471                linux = { type = 'busted' },\n
472            },\n
473        }\n
474        ";
475        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
476        assert_eq!(
477            test_spec.default,
478            TestSpec::Command(CommandTestSpec {
479                command: "baz".into(),
480                flags: vec!["foo".into(), "bar".into()],
481            })
482        );
483        let unix = test_spec
484            .per_platform
485            .get(&PlatformIdentifier::Unix)
486            .unwrap();
487        assert_eq!(
488            *unix,
489            TestSpec::Command(CommandTestSpec {
490                command: "baz".into(),
491                flags: vec!["foo".into(), "bar".into(), "baz".into()],
492            })
493        );
494        let macosx = test_spec
495            .per_platform
496            .get(&PlatformIdentifier::MacOSX)
497            .unwrap();
498        assert_eq!(
499            *macosx,
500            TestSpec::Script(LuaScriptTestSpec {
501                script: "bat.lua".into(),
502                flags: vec!["foo".into(), "bar".into(), "bat".into(), "baz".into()],
503            })
504        );
505        let linux = test_spec
506            .per_platform
507            .get(&PlatformIdentifier::Linux)
508            .unwrap();
509        assert_eq!(
510            *linux,
511            TestSpec::Busted(BustedTestSpec {
512                flags: vec!["foo".into(), "bar".into(), "baz".into()],
513            })
514        );
515        let lua_content = "
516        test = {\n
517            type = 'busted-nlua',\n
518        }";
519        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
520        assert_eq!(
521            test_spec.default,
522            TestSpec::BustedNlua(BustedTestSpec { flags: Vec::new() })
523        );
524    }
525}