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