nvim_oxi/tests/
build.rs

1use core::error::Error;
2use std::ffi::OsStr;
3use std::path::Path;
4use std::process::Command;
5use std::{env, io};
6
7use cargo_metadata::camino::Utf8PathBuf;
8
9/// Builds the library required to run integration tests within Neovim.
10///
11/// This function is designed to be used in the build script (`build.rs`) of a
12/// crate containing integration tests for Neovim plugins. It is tightly
13/// coupled with the [`test`](crate::test) macro, which is used to annotate the
14/// functions to test.
15///
16/// Together, they enable a workflow where the build script compiles the test
17/// crate into a dynamic library, and the macro generates functions that load
18/// this library into Neovim and execute the tests within it.
19///
20/// # Usage
21///
22/// Add the following to your test crate's `build.rs`:
23///
24/// ```ignore
25/// fn main() -> Result<(), nvim_oxi::tests::BuildError> {
26///     nvim_oxi::tests::build()
27/// }
28/// ```
29///
30/// # Notes
31///
32/// Like the plugin crate, the test crate must also be configured to be built
33/// as a dynamic library by including the following in its Cargo.toml:
34///
35/// ```toml
36/// [lib]
37/// crate-type = ["cdylib"]
38/// ```
39pub fn build() -> Result<(), BuildError> {
40    let Some(_g) = BuildGuard::<EnvVarGuard>::new()? else { return Ok(()) };
41    let compilation_opts = CompilationOpts::from_env()?;
42    let manifest_path = EnvVar::get("CARGO_MANIFEST_PATH")?;
43    let manifest = CargoManifest::from_path(manifest_path.as_str())?;
44    let features = EnabledFeatures::from_env(&manifest)?;
45    BuildCommand::new(&manifest, &compilation_opts, &features).exec()?;
46    println!(
47        "cargo:rustc-env={}={}",
48        manifest.profile_env(),
49        compilation_opts.profile.as_str()
50    );
51    // Rerun the build script if the compiled library is removed/changed.
52    println!(
53        "cargo:rerun-if-changed=\"{}\"",
54        manifest.library_path(compilation_opts.profile.as_str()),
55    );
56    Ok(())
57}
58
59/// An opaque error returned when [`build`]ing a test crate fails.
60#[derive(Debug, thiserror::Error)]
61#[error(transparent)]
62pub struct BuildError {
63    #[from]
64    kind: BuildErrorKind,
65}
66
67pub(super) struct CargoManifest {
68    metadata: cargo_metadata::Metadata,
69}
70
71struct BuildGuard<G: Guard> {
72    guard: Option<G>,
73}
74
75struct EnvVarGuard;
76
77struct CompilationOpts {
78    profile: Profile,
79}
80
81enum Profile {
82    Debug,
83    Release,
84    Other(EnvVar),
85}
86
87struct EnabledFeatures {
88    features: Vec<String>,
89}
90
91struct BuildCommand {
92    command: Command,
93}
94
95impl EnvVarGuard {
96    const NAME: &'static str = "NVIM_OXI_BUILDING_TESTS";
97}
98
99struct EnvVar(String);
100
101#[derive(Debug, thiserror::Error)]
102enum BuildErrorKind {
103    #[error("couldn't build tests: {0}")]
104    Build(io::Error),
105
106    #[error("couldn't acquire guard: {0}")]
107    CouldntAcquireGuard(Box<dyn Error>),
108
109    #[error("couldn't read manifest: {0}")]
110    CouldntReadManifest(cargo_metadata::Error),
111
112    #[error("nvim_oxi::tests::build() can only be used inside a build script")]
113    NotInBuildScript,
114
115    #[error("couldn't get the root package")]
116    NoRootPackage,
117}
118
119impl<G: Guard> BuildGuard<G> {
120    fn new() -> Result<Option<Self>, BuildError> {
121        match G::acquire() {
122            Ok(guard) => Ok(Some(Self { guard: Some(guard) })),
123            Err(Ok(_busy)) => Ok(None),
124            Err(Err(acquire_err)) => {
125                Err(BuildErrorKind::CouldntAcquireGuard(Box::new(acquire_err))
126                    .into())
127            },
128        }
129    }
130}
131
132impl CompilationOpts {
133    fn from_env() -> Result<Self, BuildError> {
134        Ok(Self { profile: Profile::from_env_var(EnvVar::get("PROFILE")?) })
135    }
136}
137
138impl Profile {
139    fn as_args(&self) -> Vec<impl AsRef<OsStr> + '_> {
140        enum Arg<'a> {
141            Str(&'a str),
142            EnvVar(&'a EnvVar),
143        }
144
145        impl AsRef<OsStr> for Arg<'_> {
146            fn as_ref(&self) -> &OsStr {
147                match self {
148                    Arg::Str(s) => s.as_ref(),
149                    Arg::EnvVar(s) => s.as_str().as_ref(),
150                }
151            }
152        }
153
154        match self {
155            Profile::Debug => vec![],
156            Profile::Release => vec![Arg::Str("--release")],
157            Profile::Other(other) => {
158                vec![Arg::Str("--profile"), Arg::EnvVar(other)]
159            },
160        }
161    }
162
163    fn as_str(&self) -> &str {
164        match self {
165            Profile::Debug => "debug",
166            Profile::Release => "release",
167            Profile::Other(other) => other.as_str(),
168        }
169    }
170
171    fn from_env_var(profile: EnvVar) -> Self {
172        match profile.as_str() {
173            "debug" => Self::Debug,
174            "release" => Self::Release,
175            _ => Self::Other(profile),
176        }
177    }
178}
179
180impl CargoManifest {
181    pub(super) fn from_path(
182        path: impl AsRef<Path>,
183    ) -> Result<Self, BuildError> {
184        let metadata = cargo_metadata::MetadataCommand::new()
185            .manifest_path(path.as_ref())
186            .exec()
187            .map_err(BuildErrorKind::CouldntReadManifest)?;
188
189        if metadata.root_package().is_none() {
190            return Err(BuildErrorKind::NoRootPackage.into());
191        }
192
193        Ok(Self { metadata })
194    }
195
196    /// The name of the environment variable representing the profile the test
197    /// crate was compiled for.
198    pub(super) fn profile_env(&self) -> String {
199        format!(
200            "NVIM_OXI_TEST_BUILD_PROFILE_{}",
201            self.root_package().name.to_ascii_uppercase().replace('-', "_")
202        )
203    }
204
205    /// The path to the target directory containing the compiled test library
206    /// for the crate represented by this [`CargoManifest`].
207    pub(super) fn target_dir(&self) -> Utf8PathBuf {
208        self.metadata
209            .target_directory
210            // We have to use a different target directory to avoid a deadlock
211            // caused by invoking `cargo build` in a build script.
212            //
213            // See https://github.com/rust-lang/cargo/issues/6412 for more.
214            .join("nvim_oxi_tests")
215            // Namespace by the package name to allow for multiple test crates
216            // in the same workspace.
217            .join(&self.root_package().name)
218    }
219
220    pub(super) fn library_path(&self, profile_name: &str) -> Utf8PathBuf {
221        let library_name = format!(
222            "{prefix}{crate_name}{suffix}",
223            prefix = env::consts::DLL_PREFIX,
224            suffix = env::consts::DLL_SUFFIX,
225            crate_name = self.root_package().name.replace('-', "_"),
226        );
227        self.target_dir().join(profile_name).join(library_name)
228    }
229
230    fn root_package(&self) -> &cargo_metadata::Package {
231        self.metadata.root_package().expect("checked in `from_path()`")
232    }
233}
234
235impl EnabledFeatures {
236    fn from_env(manifest: &CargoManifest) -> Result<Self, BuildError> {
237        let mut features = Vec::new();
238
239        for feature in manifest.root_package().features.keys() {
240            let env = format!(
241                "CARGO_FEATURE_{}",
242                feature.to_ascii_uppercase().replace('-', "_")
243            );
244            if EnvVar::get(&env).is_ok() {
245                features.push(feature.clone());
246            }
247        }
248
249        Ok(Self { features })
250    }
251}
252
253impl BuildCommand {
254    fn exec(mut self) -> Result<(), BuildError> {
255        self.command
256            .status()
257            .map(|_| ())
258            .map_err(|io_err| BuildErrorKind::Build(io_err).into())
259    }
260
261    fn new(
262        manifest: &CargoManifest,
263        compilation_opts: &CompilationOpts,
264        enabled_features: &EnabledFeatures,
265    ) -> Self {
266        let mut command = Command::new("cargo");
267        command
268            .arg("build")
269            .args(compilation_opts.profile.as_args())
270            .args(["--target-dir", manifest.target_dir().as_str()])
271            .arg("--no-default-features")
272            .arg("--features")
273            .arg(enabled_features.features.join(","));
274        Self { command }
275    }
276}
277
278impl EnvVar {
279    fn as_str(&self) -> &str {
280        &self.0
281    }
282
283    fn get(env: &str) -> Result<Self, BuildError> {
284        match env::var(env) {
285            Ok(value) => Ok(Self(value)),
286            Err(_) => Err(BuildErrorKind::NotInBuildScript.into()),
287        }
288    }
289}
290
291impl Guard for EnvVarGuard {
292    type Error = env::VarError;
293
294    fn acquire() -> Result<Self, Result<GuardBusy, Self::Error>> {
295        match env::var(Self::NAME) {
296            Ok(_) => Err(Ok(GuardBusy)),
297            Err(env::VarError::NotPresent) => unsafe {
298                env::set_var(Self::NAME, "1");
299                Ok(Self)
300            },
301            Err(var_error) => Err(Err(var_error)),
302        }
303    }
304
305    fn release(self) -> Result<(), Self::Error> {
306        // Env variables are process-local.
307        Ok(())
308    }
309}
310
311impl<G: Guard> Drop for BuildGuard<G> {
312    fn drop(&mut self) {
313        if let Err(err) = self.guard.take().unwrap().release() {
314            panic!("couldn't release guard: {err}");
315        }
316    }
317}
318
319trait Guard: Sized {
320    type Error: Error + 'static;
321    fn acquire() -> Result<Self, Result<GuardBusy, Self::Error>>;
322    fn release(self) -> Result<(), Self::Error>;
323}
324
325/// A sentinel value returned by [`Guard::acquire()`] indicating that the guard
326/// has already been acquired by another build process.
327struct GuardBusy;