Skip to main content

lux_lib/lua_rockspec/
test_spec.rs

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