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    #[cfg_attr(
326        feature = "schema",
327        schemars(extend("x-lux-toml-example" = r#"
328[test]
329type = "command"
330command = "make"
331flags = ["test"]
332"#))
333    )]
334    pub(crate) command: Option<String>,
335    /// Lua script to run as the test suite. Mutually exclusive with `command`.
336    #[serde(default, rename = "script", alias = "lua_script")]
337    #[cfg_attr(
338        feature = "schema",
339        schemars(extend("x-lux-toml-example" = r#"
340[test]
341type = "script"
342script = "tests.lua"
343flags = []
344"#))
345    )]
346    pub(crate) lua_script: Option<PathBuf>,
347}
348
349impl PartialOverride for TestSpecInternal {
350    type Err = Infallible;
351
352    fn apply_overrides(&self, override_spec: &Self) -> Result<Self, Self::Err> {
353        Ok(TestSpecInternal {
354            test_type: override_opt(&override_spec.test_type, &self.test_type),
355            flags: match (override_spec.flags.clone(), self.flags.clone()) {
356                (Some(override_vec), Some(base_vec)) => {
357                    let merged: Vec<String> =
358                        base_vec.into_iter().chain(override_vec).unique().collect();
359                    Some(merged)
360                }
361                (None, base_vec @ Some(_)) => base_vec,
362                (override_vec @ Some(_), None) => override_vec,
363                _ => None,
364            },
365            command: match override_spec.lua_script.clone() {
366                Some(_) => None,
367                None => override_opt(&override_spec.command, &self.command),
368            },
369            lua_script: match override_spec.command.clone() {
370                Some(_) => None,
371                None => override_opt(&override_spec.lua_script, &self.lua_script),
372            },
373        })
374    }
375}
376
377impl PlatformOverridable for TestSpecInternal {
378    type Err = Infallible;
379
380    fn on_nil<T>() -> Result<PerPlatform<T>, <Self as PlatformOverridable>::Err>
381    where
382        T: PlatformOverridable,
383        T: Default,
384    {
385        Ok(PerPlatform::default())
386    }
387}
388
389fn override_opt<T: Clone>(override_opt: &Option<T>, base: &Option<T>) -> Option<T> {
390    match override_opt.clone() {
391        override_val @ Some(_) => override_val,
392        None => base.clone(),
393    }
394}
395
396#[cfg(test)]
397mod tests {
398
399    use ottavino::{Closure, Executor, Fuel, Lua};
400    use ottavino_util::serde::from_value;
401
402    use crate::lua_rockspec::PlatformIdentifier;
403
404    use super::*;
405
406    fn exec_lua<T: serde::de::DeserializeOwned>(
407        code: &str,
408        key: &'static str,
409    ) -> Result<T, ottavino::ExternError> {
410        Lua::core().try_enter(|ctx| {
411            let closure = Closure::load(ctx, None, code.as_bytes())?;
412            let executor = Executor::start(ctx, closure.into(), ());
413            executor.step(ctx, &mut Fuel::with(i32::MAX))?;
414            from_value(ctx.globals().get_value(ctx, key)).map_err(ottavino::Error::from)
415        })
416    }
417
418    #[tokio::test]
419    pub async fn test_spec_from_lua() {
420        let lua_content = "
421        test = {\n
422        }\n
423        ";
424        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
425        assert!(matches!(test_spec.default, TestSpec::AutoDetect));
426        let lua_content = "
427        test = {\n
428            type = 'busted',\n
429        }\n
430        ";
431        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
432        assert_eq!(
433            test_spec.default,
434            TestSpec::Busted(BustedTestSpec::default())
435        );
436        let lua_content = "
437        test = {\n
438            type = 'busted',\n
439            flags = { 'foo', 'bar' },\n
440        }\n
441        ";
442        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
443        assert_eq!(
444            test_spec.default,
445            TestSpec::Busted(BustedTestSpec {
446                flags: vec!["foo".into(), "bar".into()],
447            })
448        );
449        let lua_content = "
450        test = {\n
451            type = 'command',\n
452        }\n
453        ";
454        let result: Result<PerPlatform<TestSpec>, _> = exec_lua(lua_content, "test");
455        let _err = result.unwrap_err();
456        let lua_content = "
457        test = {\n
458            type = 'command',\n
459            command = 'foo',\n
460            script = 'bar',\n
461        }\n
462        ";
463        let result: Result<PerPlatform<TestSpec>, _> = exec_lua(lua_content, "test");
464        let _err = result.unwrap_err();
465        let lua_content = "
466        test = {\n
467            type = 'command',\n
468            command = 'baz',\n
469            flags = { 'foo', 'bar' },\n
470        }\n
471        ";
472        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
473        assert_eq!(
474            test_spec.default,
475            TestSpec::Command(CommandTestSpec {
476                command: "baz".into(),
477                flags: vec!["foo".into(), "bar".into()],
478            })
479        );
480        let lua_content = "
481        test = {\n
482            type = 'command',\n
483            script = 'test.lua',\n
484            flags = { 'foo', 'bar' },\n
485        }\n
486        ";
487        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
488        assert_eq!(
489            test_spec.default,
490            TestSpec::Script(LuaScriptTestSpec {
491                script: PathBuf::from("test.lua"),
492                flags: vec!["foo".into(), "bar".into()],
493            })
494        );
495        let lua_content = "
496        test = {\n
497            type = 'command',\n
498            command = 'baz',\n
499            flags = { 'foo', 'bar' },\n
500            platforms = {\n
501                unix = { flags = { 'baz' }, },\n
502                macosx = {\n
503                    script = 'bat.lua',\n
504                    flags = { 'bat' },\n
505                },\n
506                linux = { type = 'busted' },\n
507            },\n
508        }\n
509        ";
510        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
511        assert_eq!(
512            test_spec.default,
513            TestSpec::Command(CommandTestSpec {
514                command: "baz".into(),
515                flags: vec!["foo".into(), "bar".into()],
516            })
517        );
518        let unix = test_spec
519            .per_platform
520            .get(&PlatformIdentifier::Unix)
521            .unwrap();
522        assert_eq!(
523            *unix,
524            TestSpec::Command(CommandTestSpec {
525                command: "baz".into(),
526                flags: vec!["foo".into(), "bar".into(), "baz".into()],
527            })
528        );
529        let macosx = test_spec
530            .per_platform
531            .get(&PlatformIdentifier::MacOSX)
532            .unwrap();
533        assert_eq!(
534            *macosx,
535            TestSpec::Script(LuaScriptTestSpec {
536                script: "bat.lua".into(),
537                flags: vec!["foo".into(), "bar".into(), "bat".into(), "baz".into()],
538            })
539        );
540        let linux = test_spec
541            .per_platform
542            .get(&PlatformIdentifier::Linux)
543            .unwrap();
544        assert_eq!(
545            *linux,
546            TestSpec::Busted(BustedTestSpec {
547                flags: vec!["foo".into(), "bar".into(), "baz".into()],
548            })
549        );
550        let lua_content = "
551        test = {\n
552            type = 'busted-nlua',\n
553        }";
554        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
555        assert_eq!(
556            test_spec.default,
557            TestSpec::BustedNlua(BustedTestSpec { flags: Vec::new() })
558        );
559    }
560}