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