diff --git a/Cargo.lock b/Cargo.lock index 6bd41d3f..15a96e6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3972,13 +3972,38 @@ dependencies = [ "rattler-build", "rattler_conda_types", "recipe-stage0", - "rstest", + "rstest 0.25.0", "serde", "serde_json", "strum", "tokio", ] +[[package]] +name = "pixi-build-mojo" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "indexmap 2.10.0", + "insta", + "miette", + "minijinja", + "pixi-build-backend", + "pixi_build_type_conversions", + "pixi_build_types", + "pixi_manifest", + "rattler-build", + "rattler_conda_types", + "rattler_package_streaming", + "recipe-stage0", + "rstest 0.23.0", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "pixi-build-python" version = "0.3.0" @@ -4033,7 +4058,7 @@ dependencies = [ "pixi_build_types", "rattler_conda_types", "recipe-stage0", - "rstest", + "rstest 0.25.0", "serde", "serde_json", "temp-env", @@ -5477,6 +5502,18 @@ dependencies = [ "serde", ] +[[package]] +name = "rstest" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros 0.23.0", + "rustc_version", +] + [[package]] name = "rstest" version = "0.25.0" @@ -5485,8 +5522,26 @@ checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" dependencies = [ "futures-timer", "futures-util", - "rstest_macros", + "rstest_macros 0.25.0", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" +dependencies = [ + "cfg-if 1.0.0", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", "rustc_version", + "syn", + "unicode-ident", ] [[package]] diff --git a/crates/pixi-build-mojo/Cargo.toml b/crates/pixi-build-mojo/Cargo.toml new file mode 100644 index 00000000..5ba8e93b --- /dev/null +++ b/crates/pixi-build-mojo/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "pixi-build-mojo" +version = "0.1.0" +edition.workspace = true + +[profile.dev.package] +insta.opt-level = 3 +similar.opt-level = 3 + +[dependencies] +async-trait = { workspace = true } +chrono = { workspace = true } +indexmap = { workspace = true } +miette = { workspace = true } +minijinja = { workspace = true } +rattler_conda_types = { workspace = true } +rattler_package_streaming = { workspace = true } +rattler-build = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros"] } + +pixi-build-backend = { workspace = true } + +pixi_build_types = { workspace = true } +pixi_manifest = { workspace = true } +pixi_build_type_conversions = { workspace = true } + +recipe-stage0 = { workspace = true } + +[dev-dependencies] +insta = { version = "1.42.1", features = ["yaml", "redactions", "filters"] } +rstest = "0.23" diff --git a/crates/pixi-build-mojo/src/build_script.j2 b/crates/pixi-build-mojo/src/build_script.j2 new file mode 100644 index 00000000..b52c850c --- /dev/null +++ b/crates/pixi-build-mojo/src/build_script.j2 @@ -0,0 +1,22 @@ +{%- set is_cmd_exe = build_platform == "windows" -%} +{%- macro env(key) -%} +{%- if is_cmd_exe %}{{ "%" ~ key ~ "%" }}{% else %}{{ "$" ~key }}{% endif -%} +{% endmacro -%} + +{# - Set up common variables -#} +{%- set library_prefix = "%LIBRARY_PREFIX%" if build_platform == "windows" else "$PREFIX" -%} + +mojo --version + + +{#- Build any binaries -#} +{% if bins %} + {% for bin in bins %} + mojo build {{ bin.extra_args | join(" ") }} {{ bin.path }} -o {{ library_prefix }}/bin/{{ bin.name }} + {% endfor %} +{% endif %} + +{#- Build pkg -#} +{% if pkg %} + mojo package {{ pkg.extra_args | join(" ") }} {{ pkg.path }} -o {{ library_prefix }}/lib/mojo/{{ pkg.name}}.mojopkg +{% endif %} diff --git a/crates/pixi-build-mojo/src/build_script.rs b/crates/pixi-build-mojo/src/build_script.rs new file mode 100644 index 00000000..1cd4f871 --- /dev/null +++ b/crates/pixi-build-mojo/src/build_script.rs @@ -0,0 +1,24 @@ +use super::config::{MojoBinConfig, MojoPkgConfig}; +use minijinja::Environment; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct BuildScriptContext { + /// The directory where the source code is located, the manifest root. + pub source_dir: String, + /// Any executable artifacts to create. + pub bins: Option>, + /// Any packages to create. + pub pkg: Option, +} + +impl BuildScriptContext { + pub fn render(&self) -> Vec { + let env = Environment::new(); + let template = env + .template_from_str(include_str!("build_script.j2")) + .unwrap(); + let rendered = template.render(self).unwrap().to_string(); + rendered.lines().map(|s| s.to_string()).collect() + } +} diff --git a/crates/pixi-build-mojo/src/config.rs b/crates/pixi-build-mojo/src/config.rs new file mode 100644 index 00000000..b77407b4 --- /dev/null +++ b/crates/pixi-build-mojo/src/config.rs @@ -0,0 +1,686 @@ +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, +}; + +use indexmap::IndexMap; +use miette::Error; +use pixi_build_backend::generated_recipe::BackendConfig; +use serde::{Deserialize, Serialize}; + +/// Top level config struct for the Mojo backend. +#[derive(Debug, Default, Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct MojoBackendConfig { + /// Environment Variables + #[serde(default)] + pub env: IndexMap, + + /// Dir that can be specified for outputting pixi debug state. + pub debug_dir: Option, + + /// Extra input globs to include in addition to the default ones. + #[serde(default)] + pub extra_input_globs: Vec, + + /// Binary executables to produce. + pub bins: Option>, + + /// Packages to produce. + pub pkg: Option, +} + +impl BackendConfig for MojoBackendConfig { + fn debug_dir(&self) -> Option<&Path> { + self.debug_dir.as_deref() + } + + /// Merge this configuration with a target-specific configuration. + /// Target-specific values override base values using the following rules: + /// + /// - env: Platform env vars override base, others merge + /// - debug_dir: Not allowed to have target specific value + /// - extra_input_globs: Platform-specific completely replaces base + /// - bins: Any bins with matching not-None names will be merged, + /// Any set-settings on the platform specific pkg override base + /// Any bins found only in target_config will be kept + /// - pkg: Any set-settings on the platform specific pkg override base + fn merge_with_target_config(&self, target_config: &Self) -> miette::Result { + if target_config.debug_dir.is_some() { + miette::bail!("`debug_dir` cannot have a target specific value"); + } + + let pkg = if target_config.pkg.is_some() { + if self.pkg.is_some() { + Some( + self.pkg + .as_ref() + .unwrap() + .merge_with_target_config(target_config.pkg.as_ref().unwrap())?, + ) + } else { + target_config.pkg.clone() + } + } else { + self.pkg.clone() + }; + + let bins = if target_config.bins.is_some() { + if self.bins.is_some() { + // Both base and target have binaries configured + // Override base with anything found in both target and base. + // If something is found only in base, drop it. + // If something is found only in target, drop it. + let base_bins: HashMap<_, _> = self + .bins + .as_ref() + .unwrap() + .iter() + .filter(|p| p.name.is_some()) + .map(|p| (p.name.clone().unwrap(), p.clone())) + .collect(); + + Some( + target_config + .bins + .as_ref() + .unwrap() + .iter() + .map(|p| match p.name.as_ref() { + Some(name) => { + if let Some(base_bin) = base_bins.get(name) { + base_bin.merge_with_target_config(p) + } else { + Ok(p.clone()) + } + } + None => Ok(p.clone()), + }) + .collect::>()?, + ) + } else { + target_config.bins.clone() + } + } else { + self.bins.clone() + }; + + Ok(Self { + env: { + let mut merged_env = self.env.clone(); + merged_env.extend(target_config.env.clone()); + merged_env + }, + debug_dir: self.debug_dir.clone(), + extra_input_globs: if target_config.extra_input_globs.is_empty() { + self.extra_input_globs.clone() + } else { + target_config.extra_input_globs.clone() + }, + bins, + pkg, + }) + } +} + +impl MojoBackendConfig { + /// Auto-derive the bins and pkg config if they have not been specified by the user, + /// or if they have only been partially specified. + /// + /// See [`MojoPkgConfig`] and [`MojoBinConfig`] for details on how they are derived. + /// The following rules are applied for choosing when to use derived configs: + /// + /// - If a `pkg` has been specified by user, don't derive `bins` + /// - If a `bin` has been specified by user, don't derive `pkg` + /// - If both a `pkg` and `bin` have been auto-derived, only keep the `bin` + pub fn auto_derive( + &self, + manifest_root: &Path, + project_name: &str, + ) -> miette::Result<(Option>, Option)> { + // Update bins configs + let (mut bins, bin_autodetected) = + MojoBinConfig::auto_derive(self.bins.as_ref(), manifest_root, project_name)?; + + // Update pkg config + let (mut pkg, pkg_autodetected) = + MojoPkgConfig::auto_derive(self.pkg.as_ref(), manifest_root, project_name)?; + + // Make sure we have at least one of the two + if bins.is_none() && pkg.is_none() { + return Err(Error::msg("No bin or pkg configuration detected.")); + } + + // If we are auto-generating both, keep only the bin + if bin_autodetected && pkg_autodetected { + pkg = None; + } + // If either wasn't auto-detected, disable auto-detection of the other + else if bin_autodetected && (!pkg_autodetected && pkg.is_some()) { + // If I'm publishing a pkg, I may not want to also publish a bin + bins = None + } else if (!bin_autodetected && bins.is_some()) && pkg_autodetected { + // If I'm publishing a bin, I may not want to publish the associated pkg + pkg = None + } + + Ok((bins, pkg)) + } +} + +/// Config object for a Mojo binary. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct MojoBinConfig { + /// Name of the binary. + /// + /// This will default to the name of the project for the first + /// binary selected, any dashes will be replaced with `_`. + pub name: Option, + + /// Path to file that has the `main` method. + /// + /// This will default to looking for a `main.mojo` file in: + /// - `/main.mojo` + pub path: Option, + + /// Extra args to pass to the compiler. + #[serde(default, rename(serialize = "extra_args"))] + pub extra_args: Option>, +} + +impl MojoBinConfig { + /// Fill in any missing info and or try to find our default options. + /// + /// - If None, try to find a `main.mojo` file in manifest_root + /// - If any, for the first one, see if name or path need to be filled in + /// - If any, verify that there are no name collisions + pub fn auto_derive( + conf: Option<&Vec>, + manifest_root: &Path, + project_name: &str, + ) -> miette::Result<(Option>, bool)> { + let main = Self::find_main(manifest_root).map(|p| p.display().to_string()); + + // No configuration specified + if conf.is_none() { + if let Some(main) = main { + return Ok(( + Some(vec![Self { + name: Some(project_name.to_owned()), + path: Some(main), + ..Default::default() + }]), + true, + )); + } else { + return Ok((None, false)); + } + } + + // Some configuration specified + let mut conf = conf.unwrap().clone(); // checked above + if conf.is_empty() { + return Ok((None, false)); + } + + if conf[0].name.is_none() { + conf[0].name = Some(project_name.to_owned()); + } + if conf[0].path.is_none() { + if main.is_none() { + return Err(Error::msg("Could not find main.mojo for configured binary")); + } + conf[0].path = main; + } + + // Verify no name collisions and that the rest of the binaries have a name and path + let mut names = HashSet::new(); + for (i, c) in conf.iter().enumerate() { + if c.name.is_none() { + return Err(Error::msg(format!( + "Binary configuration {} is missing a name.", + i + 1 + ))); + } + if c.path.is_none() { + return Err(Error::msg(format!( + "Binary configuration {} is missing a path.", + c.name.as_ref().unwrap(), + ))); + } + if names.contains(c.name.as_deref().unwrap()) { + return Err(Error::msg(format!( + "Binary name has been used twice: {}", + c.name.as_ref().unwrap() + ))); + } + + names.insert(c.name.clone().unwrap()); + } + + Ok((Some(conf), false)) + } + + /// Try to find main.mojo in: + /// - /main.mojo + fn find_main(root: &Path) -> Option { + let mut path = root.join("main"); + for ext in ["mojo", "🔥"] { + path.set_extension(ext); + if path.exists() { + return Some(path); + } + } + None + } + + /// Merge with a target-specific configuration. + /// + /// All target-settings that are not None will override base. + /// + /// **Note** bins must have the same name to be merged. + fn merge_with_target_config(&self, target_config: &Self) -> miette::Result { + if self.name.is_some() && target_config.name.is_some() { + if self.name.as_ref().unwrap() != target_config.name.as_ref().unwrap() { + miette::bail!("Both bins must have a set name to be merged"); + } + } else { + miette::bail!("Both bins must have a set name to be merged"); + } + + let path = if target_config.path.is_some() { + target_config.path.clone() + } else { + self.path.clone() + }; + + let extra_args = if target_config.extra_args.is_some() { + target_config.extra_args.clone() + } else { + self.extra_args.clone() + }; + + Ok(Self { + name: self.name.clone(), + path, + extra_args, + }) + } +} + +/// Config object for a Mojo package. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct MojoPkgConfig { + /// Name to give the mojo package (.mojopkg suffix will be added). + /// + /// This will default to the name of the project, any dashes will + /// be replaced with `_`. + pub name: Option, + + /// Path to the directory that constitutes the package. + /// + /// This will default to lookingo for a folder with an `__init__.mojo` in + /// in the following order: + /// - `//__init__.mojo` + /// - `/src/__init__.mojo` + pub path: Option, + + /// Extra args to pass to the compiler. + #[serde(default, rename(serialize = "extra_args"))] + pub extra_args: Option>, +} + +impl MojoPkgConfig { + /// Fill in any missing info anod or try to find our default options. + /// + /// - If None, try to find a `` or `src` dir with an `__init__.mojo` file in it. + /// - If Some, see if name or path need to be filled in. + pub fn auto_derive( + conf: Option<&Self>, + manifest_root: &Path, + package_name: &str, + ) -> miette::Result<(Option, bool)> { + if let Some(conf) = conf { + // A conf was given, make sure it has a name and path + let mut conf = conf.clone(); + if conf.name.is_none() { + conf.name = Some(package_name.to_owned()); + } + + let path = Self::find_init_parent(manifest_root, package_name); + if conf.path.is_none() { + if path.is_none() { + return Err(Error::msg(format!( + "Could not find valid package path for {}", + conf.name.unwrap() + ))); + } + conf.path = path.map(|p| p.display().to_string()); + } + Ok((Some(conf), false)) + } else { + // No conf given check if we can find a valid package + let path = Self::find_init_parent(manifest_root, package_name); + if path.is_none() { + return Ok((None, false)); + } + Ok(( + Some(Self { + name: Some(package_name.to_owned()), + path: path.map(|p| p.display().to_string()), + ..Default::default() + }), + true, + )) + } + } + + /// Find the parent directory of a possible package `__init__.mojo` file. + /// + /// This checks (in this order): + /// - `` + /// - src + /// + /// and returns the first one found. + fn find_init_parent(root: &Path, project_name: &str) -> Option { + for dir in [project_name, "src"] { + let mut path = root.join(dir).join("__init__"); + for ext in ["mojo", "🔥"] { + path.set_extension(ext); + if path.exists() { + return Some(root.join(dir)); + } + } + } + None + } + + /// Merge with a target-specific configuration. + /// + /// All target-settings that are not None will override base. + fn merge_with_target_config(&self, target_config: &Self) -> miette::Result { + let name = if target_config.name.is_some() { + target_config.name.clone() + } else { + self.name.clone() + }; + + let path = if target_config.path.is_some() { + target_config.path.clone() + } else { + self.path.clone() + }; + + let extra_args = if target_config.extra_args.is_some() { + target_config.extra_args.clone() + } else { + self.extra_args.clone() + }; + + Ok(Self { + name, + path, + extra_args, + }) + } +} + +/// Clean the package name for use in [`MojoPkgConfig`] and [`MojoBinconfig`]. +/// +/// This just entails converting - to _. +pub fn clean_project_name(s: &str) -> String { + s.to_owned().replace("-", "_") +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use serde_json::json; + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_ensure_deseralize_from_empty() { + let json_data = json!({}); + serde_json::from_value::(json_data).unwrap(); + } + + #[derive(Debug)] + enum ExpectedBinResult { + /// A possible binary name that would be found, as well as whether or not + /// it was auto-detected, or whether it was found via user configuration. + Success { + binary_name: Option<&'static str>, + autodetected: bool, + }, + /// There was some misconfiguration resulting in an error to show the user. + Error(&'static str), + } + + struct BinTestCase { + /// User specified config. + config: Option>, + /// Path to a project `main.mojo` file to be created in a temp dir. + main_file: Option<&'static str>, + /// The expected result of the test. + expected: ExpectedBinResult, + } + + #[rstest] + #[case::no_config_no_main(BinTestCase { + config: None, + main_file: None, + expected: ExpectedBinResult::Success { binary_name: None, autodetected: false } + })] + #[case::no_config_with_main_mojo(BinTestCase { + config: None, + main_file: Some("main.mojo"), + expected: ExpectedBinResult::Success { binary_name: Some("test_project"), autodetected: true } + })] + #[case::no_config_with_main_fire(BinTestCase { + config: None, + main_file: Some("main.🔥"), + expected: ExpectedBinResult::Success { binary_name: Some("test_project"), autodetected: true } + })] + #[case::empty_config(BinTestCase { + config: Some(vec![]), + main_file: None, + expected: ExpectedBinResult::Success { binary_name: None, autodetected: false } + })] + #[case::config_missing_name_and_path(BinTestCase { + config: Some(vec![MojoBinConfig::default()]), + main_file: Some("main.mojo"), + expected: ExpectedBinResult::Success { binary_name: Some("test_project"), autodetected: false } + })] + #[case::config_missing_path_no_main(BinTestCase { + config: Some(vec![MojoBinConfig::default()]), + main_file: None, + expected: ExpectedBinResult::Error("Could not find main.mojo for configured binary") + })] + #[case::multiple_bins_missing_name(BinTestCase { + config: Some(vec![ + MojoBinConfig { name: Some("bin1".to_string()), path: Some("main1.mojo".to_string()), ..Default::default() }, + MojoBinConfig { path: Some("main2.mojo".to_string()), ..Default::default() }, + ]), + main_file: None, + expected: ExpectedBinResult::Error("Binary configuration 2 is missing a name.") + })] + #[case::multiple_bins_missing_path(BinTestCase { + config: Some(vec![ + MojoBinConfig { name: Some("bin1".to_string()), path: Some("main1.mojo".to_string()), ..Default::default() }, + MojoBinConfig { name: Some("bin2".to_string()), ..Default::default() }, + ]), + main_file: None, + expected: ExpectedBinResult::Error("Binary configuration bin2 is missing a path.") + })] + #[case::duplicate_names(BinTestCase { + config: Some(vec![ + MojoBinConfig { name: Some("mybin".to_string()), path: Some("main1.mojo".to_string()), ..Default::default() }, + MojoBinConfig { name: Some("mybin".to_string()), path: Some("main2.mojo".to_string()), ..Default::default() }, + ]), + main_file: None, + expected: ExpectedBinResult::Error("Binary name has been used twice: mybin") + })] + fn test_mojo_bin_config_fill_defaults(#[case] test_case: BinTestCase) { + let temp = TempDir::new().unwrap(); + let manifest_root = temp.path().to_path_buf(); + + // Write the main.mojo file specified by test case, if present + if let Some(filename) = test_case.main_file { + std::fs::write(manifest_root.join(filename), "def main():\n pass").unwrap(); + } + + let result = + MojoBinConfig::auto_derive(test_case.config.as_ref(), &manifest_root, "test_project"); + + match test_case.expected { + ExpectedBinResult::Success { + binary_name: expected_name, + autodetected: expected_autodetected, + } => { + let (bins, autodetected) = result.unwrap(); + assert_eq!(autodetected, expected_autodetected); + + if let Some(expected_name) = expected_name { + assert!(bins.is_some()); + let bins = bins.unwrap(); + assert_eq!(bins.len(), 1); + assert_eq!(bins[0].name, Some(expected_name.to_string())); + if let Some(filename) = test_case.main_file { + assert_eq!( + bins[0].path, + Some(manifest_root.join(filename).display().to_string()) + ); + } + } else { + assert_eq!(bins, None); + } + } + ExpectedBinResult::Error(expected_error) => { + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), expected_error); + } + } + } + + #[derive(Debug)] + enum ExpectedPkgResult { + /// A possible pkg name that would be found, as well as whether or not + /// it was auto-detected, or whether it was found via user configuration. + Success { + name: Option<&'static str>, + autodetected: bool, + }, + /// An expected error message that the user would be shown. + Error(&'static str), + } + + struct PkgTestCase { + /// User defined config for the pkg + config: Option, + /// Path to a `__init__.mojo` file to be created in a temp dir. + init_file: Option<(&'static str, &'static str)>, // (directory, filename) + /// Expected result of the test. + expected: ExpectedPkgResult, + } + + #[rstest] + #[case::no_config_no_init(PkgTestCase { + config: None, + init_file: None, + expected: ExpectedPkgResult::Success { name: None, autodetected: false } + })] + #[case::no_config_with_init_in_project_dir(PkgTestCase { + config: None, + init_file: Some(("test_project", "__init__.mojo")), + expected: ExpectedPkgResult::Success { name: Some("test_project"), autodetected: true } + })] + #[case::no_config_with_init_in_src(PkgTestCase { + config: None, + init_file: Some(("src", "__init__.mojo")), + expected: ExpectedPkgResult::Success { name: Some("test_project"), autodetected: true } + })] + #[case::no_config_with_init_fire_emoji(PkgTestCase { + config: None, + init_file: Some(("src", "__init__.🔥")), + expected: ExpectedPkgResult::Success { name: Some("test_project"), autodetected: true } + })] + #[case::config_missing_name_and_path(PkgTestCase { + config: Some(MojoPkgConfig::default()), + init_file: Some(("src", "__init__.mojo")), + expected: ExpectedPkgResult::Success { name: Some("test_project"), autodetected: false } + })] + #[case::config_with_all_fields(PkgTestCase { + config: Some(MojoPkgConfig { + name: Some("mypackage".to_string()), + path: Some("custom/path".to_string()), + extra_args: Some(vec!["-O3".to_string()]), + }), + init_file: None, + expected: ExpectedPkgResult::Success { name: Some("mypackage"), autodetected: false } + })] + #[case::config_missing_path_no_init(PkgTestCase { + config: Some(MojoPkgConfig::default()), + init_file: None, + expected: ExpectedPkgResult::Error("Could not find valid package path for test_project") + })] + fn test_mojo_pkg_config_fill_defaults(#[case] test_case: PkgTestCase) { + let temp = TempDir::new().unwrap(); + let manifest_root = temp.path().to_path_buf(); + + if let Some((dir, filename)) = test_case.init_file { + let init_dir = manifest_root.join(dir); + std::fs::create_dir_all(&init_dir).unwrap(); + std::fs::write(init_dir.join(filename), "").unwrap(); + } + + let result = + MojoPkgConfig::auto_derive(test_case.config.as_ref(), &manifest_root, "test_project"); + + match test_case.expected { + ExpectedPkgResult::Success { + name: expected_name, + autodetected: expected_autodetected, + } => { + let (pkg, autodetected) = result.unwrap(); + assert_eq!(autodetected, expected_autodetected); + + if let Some(expected_name) = expected_name { + assert!(pkg.is_some()); + let pkg = pkg.unwrap(); + assert_eq!(pkg.name, Some(expected_name.to_string())); + + // For the custom config case, check the custom path and args + if expected_name == "mypackage" { + assert_eq!(pkg.path, Some("custom/path".to_string())); + assert_eq!(pkg.extra_args, Some(vec!["-O3".to_string()])); + } else if let Some((dir, _)) = test_case.init_file { + assert_eq!( + pkg.path, + Some(manifest_root.join(dir).display().to_string()) + ); + } + } else { + assert_eq!(pkg, None); + } + } + ExpectedPkgResult::Error(expected_error) => { + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), expected_error); + } + } + } + + #[rstest] + #[case("my-project", "my_project")] + #[case("test_project", "test_project")] + #[case("some-complex-name", "some_complex_name")] + #[case("nodashes", "nodashes")] + #[case("multiple-dashes-here", "multiple_dashes_here")] + fn test_clean_project_name(#[case] input: &str, #[case] expected: &str) { + assert_eq!(clean_project_name(input), expected); + } +} diff --git a/crates/pixi-build-mojo/src/main.rs b/crates/pixi-build-mojo/src/main.rs new file mode 100644 index 00000000..b9673190 --- /dev/null +++ b/crates/pixi-build-mojo/src/main.rs @@ -0,0 +1,365 @@ +mod build_script; +mod config; + +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, + sync::Arc, +}; + +use build_script::BuildScriptContext; +use config::{MojoBackendConfig, clean_project_name}; +use miette::{Error, IntoDiagnostic}; +use pixi_build_backend::{ + generated_recipe::{GenerateRecipe, GeneratedRecipe, PythonParams}, + intermediate_backend::IntermediateBackendInstantiator, +}; +use rattler_build::{NormalizedKey, recipe::variable::Variable}; +use rattler_conda_types::{PackageName, Platform}; +use recipe_stage0::recipe::Script; + +#[derive(Default, Clone)] +pub struct MojoGenerator {} + +impl GenerateRecipe for MojoGenerator { + type Config = MojoBackendConfig; + + fn generate_recipe( + &self, + model: &pixi_build_types::ProjectModelV1, + config: &Self::Config, + manifest_root: std::path::PathBuf, + host_platform: rattler_conda_types::Platform, + _python_params: Option, + ) -> miette::Result { + let mut generated_recipe = + GeneratedRecipe::from_model(model.clone(), manifest_root.clone()); + + let cleaned_project_name = clean_project_name( + generated_recipe + .recipe + .package + .name + .concrete() + .ok_or(Error::msg("Package is missing a name"))?, + ); + + // Auto-derive bins and pkg fields/configs if needed + let (bins, pkg) = config.auto_derive(&manifest_root, &cleaned_project_name)?; + + // Add compiler + let requirements = &mut generated_recipe.recipe.requirements; + let resolved_requirements = requirements.resolve(Some(host_platform)); + + // Ensure the compiler function is added to the build requirements + // only if a specific compiler is not already present. + let mojo_compiler_pkg = "max".to_string(); + + if !resolved_requirements + .build + .contains_key(&PackageName::new_unchecked(&mojo_compiler_pkg)) + { + requirements + .build + .push(mojo_compiler_pkg.parse().into_diagnostic()?); + } + + let build_script = BuildScriptContext { + source_dir: manifest_root.display().to_string(), + bins, + pkg, + } + .render(); + + generated_recipe.recipe.build.script = Script { + content: build_script, + env: config.env.clone(), + ..Default::default() + }; + + generated_recipe.build_input_globs = Self::globs().collect::>(); + + Ok(generated_recipe) + } + + fn extract_input_globs_from_build( + config: &Self::Config, + _workdir: impl AsRef, + _editable: bool, + ) -> BTreeSet { + Self::globs() + .chain(config.extra_input_globs.clone()) + .collect() + } + + fn default_variants(&self, _host_platform: Platform) -> BTreeMap> { + BTreeMap::new() + } +} + +impl MojoGenerator { + fn globs() -> impl Iterator { + [ + // Source files + "**/*.{mojo,🔥}", + ] + .iter() + .map(|s: &&str| s.to_string()) + } +} + +#[tokio::main] +pub async fn main() { + if let Err(err) = pixi_build_backend::cli::main(|log| { + IntermediateBackendInstantiator::::new(log, Arc::default()) + }) + .await + { + eprintln!("{err:?}"); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use indexmap::IndexMap; + use pixi_build_types::ProjectModelV1; + + use crate::config::{MojoBinConfig, MojoPkgConfig}; + + use super::*; + + #[test] + fn test_input_globs_includes_extra_globs() { + let config = MojoBackendConfig { + extra_input_globs: vec![String::from("**/.c")], + ..Default::default() + }; + + let result = MojoGenerator::extract_input_globs_from_build(&config, PathBuf::new(), false); + + insta::assert_debug_snapshot!(result); + } + + #[macro_export] + macro_rules! project_fixture { + ($($json:tt)+) => { + serde_json::from_value::( + serde_json::json!($($json)+) + ).expect("Failed to create TestProjectModel from JSON fixture.") + }; + } + + #[test] + fn test_mojo_bin_is_set() { + let project_model = project_fixture!({ + "name": "foobar", + "version": "0.1.0", + "targets": { + "defaultTarget": { + "runDependencies": { + "boltons": { + "binary": { + "version": "*" + } + } + } + }, + } + }); + + let generated_recipe = MojoGenerator::default() + .generate_recipe( + &project_model, + &MojoBackendConfig { + bins: Some(vec![MojoBinConfig { + name: Some(String::from("example")), + path: Some(String::from("./main.mojo")), + extra_args: Some(vec![String::from("-I"), String::from(".")]), + }]), + ..Default::default() + }, + PathBuf::from("."), + Platform::Linux64, + None, + ) + .expect("Failed to generate recipe"); + + insta::assert_yaml_snapshot!(generated_recipe.recipe, { + ".source[0].path" => "[ ... path ... ]", + }); + } + + #[test] + fn test_mojo_pkg_is_set() { + let project_model = project_fixture!({ + "name": "foobar", + "version": "0.1.0", + "targets": { + "defaultTarget": { + "runDependencies": { + "boltons": { + "binary": { + "version": "*" + } + } + } + }, + } + }); + + let generated_recipe = MojoGenerator::default() + .generate_recipe( + &project_model, + &MojoBackendConfig { + bins: Some(vec![MojoBinConfig { + name: Some(String::from("example")), + path: Some(String::from("./main.mojo")), + extra_args: Some(vec![String::from("-i"), String::from(".")]), + }]), + pkg: Some(MojoPkgConfig { + name: Some(String::from("lib")), + path: Some(String::from("mylib")), + extra_args: Some(vec![String::from("-i"), String::from(".")]), + }), + ..Default::default() + }, + PathBuf::from("."), + Platform::Linux64, + None, + ) + .expect("Failed to generate recipe"); + + insta::assert_yaml_snapshot!(generated_recipe.recipe, { + ".source[0].path" => "[ ... path ... ]", + }); + } + + #[test] + fn test_max_is_in_build_requirements() { + let project_model = project_fixture!({ + "name": "foobar", + "version": "0.1.0", + "targets": { + "defaultTarget": { + "runDependencies": { + "boltons": { + "binary": { + "version": "*" + } + } + } + }, + } + }); + + // Create a temporary directory with a main.mojo file so the test has something to build + let temp = tempfile::TempDir::new().unwrap(); + std::fs::write(temp.path().join("main.mojo"), "def main():\n pass").unwrap(); + + let generated_recipe = MojoGenerator::default() + .generate_recipe( + &project_model, + &MojoBackendConfig::default(), + temp.path().to_path_buf(), + Platform::Linux64, + None, + ) + .expect("Failed to generate recipe"); + + insta::assert_yaml_snapshot!(generated_recipe.recipe, { + ".source[0].path" => "[ ... path ... ]", + ".build.script" => "[ ... script ... ]", + }); + } + + #[test] + fn test_env_vars_are_set() { + let project_model = project_fixture!({ + "name": "foobar", + "version": "0.1.0", + "targets": { + "defaultTarget": { + "runDependencies": { + "boltons": { + "binary": { + "version": "*" + } + } + } + }, + } + }); + + let env = IndexMap::from([("foo".to_string(), "bar".to_string())]); + + // Create a temporary directory with a main.mojo file so the test has something to build + let temp = tempfile::TempDir::new().unwrap(); + std::fs::write(temp.path().join("main.mojo"), "def main():\n pass").unwrap(); + + let generated_recipe = MojoGenerator::default() + .generate_recipe( + &project_model, + &MojoBackendConfig { + env: env.clone(), + ..Default::default() + }, + temp.path().to_path_buf(), + Platform::Linux64, + None, + ) + .expect("Failed to generate recipe"); + + insta::assert_yaml_snapshot!(generated_recipe.recipe.build.script, + { + ".content" => "[ ... script ... ]", + }); + } + + #[test] + fn test_max_is_not_added_if_max_is_already_present() { + let project_model = project_fixture!({ + "name": "foobar", + "version": "0.1.0", + "targets": { + "defaultTarget": { + "runDependencies": { + "boltons": { + "binary": { + "version": "*" + } + } + }, + "buildDependencies": { + "max": { + "binary": { + "version": "*" + } + } + } + }, + } + }); + + // Create a temporary directory with a main.mojo file so the test has something to build + let temp = tempfile::TempDir::new().unwrap(); + std::fs::write(temp.path().join("main.mojo"), "def main():\n pass").unwrap(); + + let generated_recipe = MojoGenerator::default() + .generate_recipe( + &project_model, + &MojoBackendConfig::default(), + temp.path().to_path_buf(), + Platform::Linux64, + None, + ) + .expect("Failed to generate recipe"); + + insta::assert_yaml_snapshot!(generated_recipe.recipe, { + ".source[0].path" => "[ ... path ... ]", + ".build.script" => "[ ... script ... ]", + }); + } +} diff --git a/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__env_vars_are_set.snap b/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__env_vars_are_set.snap new file mode 100644 index 00000000..d8a41120 --- /dev/null +++ b/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__env_vars_are_set.snap @@ -0,0 +1,8 @@ +--- +source: crates/pixi-build-mojo/src/main.rs +expression: generated_recipe.recipe.build.script +--- +content: "[ ... script ... ]" +env: + foo: bar +secrets: [] diff --git a/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__input_globs_includes_extra_globs.snap b/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__input_globs_includes_extra_globs.snap new file mode 100644 index 00000000..ead8397c --- /dev/null +++ b/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__input_globs_includes_extra_globs.snap @@ -0,0 +1,8 @@ +--- +source: crates/pixi-build-mojo/src/main.rs +expression: result +--- +{ + "**/*.{mojo,🔥}", + "**/.c", +} diff --git a/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__max_is_in_build_requirements.snap b/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__max_is_in_build_requirements.snap new file mode 100644 index 00000000..1cde0bcc --- /dev/null +++ b/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__max_is_in_build_requirements.snap @@ -0,0 +1,24 @@ +--- +source: crates/pixi-build-mojo/src/main.rs +expression: generated_recipe.recipe +--- +context: {} +package: + name: foobar + version: 0.1.0 +source: + - path: "[ ... path ... ]" + sha256: ~ +build: + number: ~ + script: "[ ... script ... ]" +requirements: + build: + - max + host: [] + run: + - boltons + run_constraints: [] +tests: [] +about: ~ +extra: ~ diff --git a/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__max_is_not_added_if_max_is_already_present.snap b/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__max_is_not_added_if_max_is_already_present.snap new file mode 100644 index 00000000..1cde0bcc --- /dev/null +++ b/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__max_is_not_added_if_max_is_already_present.snap @@ -0,0 +1,24 @@ +--- +source: crates/pixi-build-mojo/src/main.rs +expression: generated_recipe.recipe +--- +context: {} +package: + name: foobar + version: 0.1.0 +source: + - path: "[ ... path ... ]" + sha256: ~ +build: + number: ~ + script: "[ ... script ... ]" +requirements: + build: + - max + host: [] + run: + - boltons + run_constraints: [] +tests: [] +about: ~ +extra: ~ diff --git a/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__mojo_bin_is_set.snap b/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__mojo_bin_is_set.snap new file mode 100644 index 00000000..899172ea --- /dev/null +++ b/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__mojo_bin_is_set.snap @@ -0,0 +1,31 @@ +--- +source: crates/pixi-build-mojo/src/main.rs +expression: generated_recipe.recipe +--- +context: {} +package: + name: foobar + version: 0.1.0 +source: + - path: "[ ... path ... ]" + sha256: ~ +build: + number: ~ + script: + content: + - mojo --version + - "\t" + - "\t\tmojo build -I . ./main.mojo -o $PREFIX/bin/example" + - "\t" + env: {} + secrets: [] +requirements: + build: + - max + host: [] + run: + - boltons + run_constraints: [] +tests: [] +about: ~ +extra: ~ diff --git a/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__mojo_pkg_is_set.snap b/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__mojo_pkg_is_set.snap new file mode 100644 index 00000000..87c276d0 --- /dev/null +++ b/crates/pixi-build-mojo/src/snapshots/pixi_build_mojo__tests__mojo_pkg_is_set.snap @@ -0,0 +1,33 @@ +--- +source: crates/pixi-build-mojo/src/main.rs +expression: generated_recipe.recipe +--- +context: {} +package: + name: foobar + version: 0.1.0 +source: + - path: "[ ... path ... ]" + sha256: ~ +build: + number: ~ + script: + content: + - mojo --version + - "\t" + - "\t\tmojo build -i . ./main.mojo -o $PREFIX/bin/example" + - "\t" + - "" + - "\tmojo package -i . mylib -o $PREFIX/lib/mojo/lib.mojopkg" + env: {} + secrets: [] +requirements: + build: + - max + host: [] + run: + - boltons + run_constraints: [] +tests: [] +about: ~ +extra: ~ diff --git a/docs/backends/pixi-build-mojo.md b/docs/backends/pixi-build-mojo.md new file mode 100644 index 00000000..f6b17a38 --- /dev/null +++ b/docs/backends/pixi-build-mojo.md @@ -0,0 +1,293 @@ +# pixi-build-mojo + +The `pixi-build-mojo` backend is designed for building Mojo projects. It provides seamless integration with Pixi's package management workflow. + +!!! warning + `pixi-build` is a preview feature, and will change until it is stabilized. + This is why we require users to opt in to that feature by adding "pixi-build" to `workspace.preview`. + + ```toml + [workspace] + preview = ["pixi-build"] + ``` + +## Overview + +This backend automatically generates conda packages from Mojo projects. + +The generated packages can be installed into local envs for development, or packaged for distribution. + +### Auto-derive of pkg and bin + +The Mojo backend includes auto-discovery of your project structure and will derive the following: + +- **Binaries**: Automatically searches for `main.mojo` or `main.🔥` in: + - `/main.mojo` +- **Packages**: Automatically searches for directories with `__init__.mojo` or `__init__.🔥` in: + - `//` + - `/src/` + +This means in most cases, you don't need to explicitly configure the `bins` or `pkg` fields. + +**Caveats**: +- If both a `bin` and a `pkg` are auto-derived, only the `bin` will be created, you must manually specify the pkg. +- If the user specifies a `pkg` a `bin` will not be auto-derived. +- If the user specifies a `bin` a `pkg` will not be auto-derived. + + +## Basic Usage + +To use the Mojo backend in your `pixi.toml`, add it to your package's build configuration. The backend will automatically discover your project structure: + + +```txt +# Example project layout for combined binary/library. +. +├── greetings +│   ├── __init__.mojo +│   └── lib.mojo +├── main.mojo +├── pixi.lock +├── pixi.toml +└── README.md +``` + +With the project structure above, pixi-build-mojo will automatically discover: +- The binary from `main.mojo` +- The package from `greetings/__init__.mojo` + +Here's a minimal configuration that leverages auto-derive: + +```toml +[workspace] +authors = ["J. Doe "] +platforms = ["linux-64"] +preview = ["pixi-build"] +channels = [ + "conda-forge", + "https://conda.modular.com/max-nightly", + "https://prefix.dev/pixi-build-backends", + "https://repo.prefix.dev/modular-community" +] + +[package] +name = "greetings" +version = "0.1.0" + +[package.build] +backend = { name = "pixi-build-mojo", version = "0.1.*" } + +[tasks] + +[package.host-dependencies] +max = "=25.4.0" + +[package.build-dependencies] +max = "=25.4.0" +small_time = ">=25.4.1,<26" +extramojo = ">=0.16.0,<0.17" + +[package.run-dependencies] +max = "=25.4.0" + +[dependencies] +# For running `mojo test` while developing add all dependencies under +# `[package.build-dependencies]` here as well. +greetings = { path = "." } +``` + +### Project Structure Examples + +The auto-derive feature supports various common project layouts: + +#### Binary-only project +```txt +. +├── main.mojo # Auto-derive as binary +├── pixi.toml +└── README.md +``` + +#### Package-only project +```txt +. +├── mypackage/ # Auto-derive if matches project name +│ ├── __init__.mojo +│ └── utils.mojo +├── pixi.toml +└── README.md +``` + +#### Source directory layout +```txt +. +├── src/ +│ ├── __init__.mojo # Auto-derive as package +│ └── lib.mojo +├── pixi.toml +└── README.md +``` + +#### Combined project (shown earlier) +```txt +. +├── greetings/ +│ ├── __init__.mojo # NOT auto-derived as package +│ └── lib.mojo +├── main.mojo # Auto-derived as binary +├── pixi.toml +└── README.md +``` + +### Required Dependencies + +- `max` package for both the compiler and linked runtime + +## Configuration Options + +You can customize the Mojo backend behavior using the `[package.build.configuration]` section in your `pixi.toml`. The backend supports the following configuration options: + +#### `env` + +- **Type**: `Map` +- **Default**: `{}` + +Environment variables to set during the build process. + +```toml +[package.build.configuration] +env = { ASSERT = "all" } +``` + +#### `debug-dir` + +- **Type**: `String` (path) +- **Default**: Not set + +Directory to place internal pixi debug information into. + +```toml +[package.build.configuration] +debug-dir = ".build-debug" +``` + +#### `extra-input-globs` + +- **Type**: `Array` +- **Default**: `[]` + +Additional globs to pass to pixi to discover if the package should be rebuilt. + +```toml +[package.build.configuration] +extra-input-globs = ["**/*.c", "assets/**/*", "*.md"] +``` + +### `bins` + +- **Type**: `Array` +- **Default**: Auto-derived if not specified + +List of binary configurations to build. The created binary will be placed in the `$PREFIX/bin` dir and will be in the path after running `pixi install` assuming the package is listed as a dependency as in the example above. `pixi build` will create a conda package that includes the binary. + +**Auto-derive behavior:** +- If `bins` is not specified, pixi-build-mojo will search for a `main.mojo` or `main.🔥` file in the project root +- If found, it creates a binary with the name set to the project name +- If a pkg has been manually configured, a bin will not be auto-derived and must be manually configured. + +#### `bins[].name` + +- **Type**: `String` +- **Default**: Project name (with dashes converted to underscores) for the first binary + +The name of the binary executable to create. If not specified: +- For the first binary in the list, defaults to the project name +- For additional binaries, this field is required + +```toml +[[package.build.configuration.bins]] +# name = "greet" # Optional for first binary, defaults to project name +``` + +#### `bins[].path` + +- **Type**: `String` (path) +- **Default**: Auto-derived for the first binary + +The path to the Mojo file that contains a `main` function. If not specified: +- For the first binary, searches for `main.mojo` or `main.🔥` in the project root +- For additional binaries, this field is required + +```toml +[[package.build.configuration.bins]] +# path = "./main.mojo" # Optional if main.mojo exists in project root +``` + +#### `bins[].extra-args` + +- **Type**: `Array` +- **Default**: `[]` + +Additional command-line arguments to pass to the Mojo compiler when building this binary. + +```toml +[[package.build.configuration.bins]] +extra-args = ["-I", "special-thing"] +``` + +### `pkg` + +- **Type**: `PkgConfig` +- **Default**: Auto-derived if not specified + +Package configuration for creating Mojo package. The created Mojo package will be placed in the `$PREFIX/lib/mojo` dir, which will make it discoverable to anything that depends on the package. + +**Auto-derive behavior:** +- If `pkg` is not specified, pixi-build-mojo will search for a directory containing `__init__.mojo` or `__init__.🔥` in the following order: + 1. `//` + 2. `/src/` +- If found, it creates a package with the name set to the project name +- If no valid package directory is found, no package is built +- If a binary is manually configured, a pkg will not be auto-derived and must be manually specified. +- If a binary is also auto-derive, a pkg will not be generated and must be manually specified + +#### `pkg.name` + +- **Type**: `String` +- **Default**: Project name (with dashes converted to underscores) + +The name to give the Mojo package. The `.mojopkg` suffix will be added automatically. If not specified, defaults to the project name. + +```toml +[package.build.configuration.pkg] +name = "greetings" +``` + +#### `pkg.path` + +- **Type**: `String` (path) +- **Default**: Auto-derive + +The path to the directory that constitutes the package. If not specified, searches for a directory with `__init__.mojo` or `__init__.🔥` as described above. + +```toml +[package.build.configuration.pkg] +path = "greetings" +``` + +#### `pkg.extra-args` + +- **Type**: `Array` +- **Default**: `[]` + +Additional command-line arguments to pass to the Mojo compiler when building this package. + +```toml +[package.build.configuration.pkg] +extra-args = ["-I", "special-thing"] +``` + +## See Also + +- [Mojo Pixi Basic](https://docs.modular.com/pixi/) +- [Modular Community Packages](https://github.com/modular/modular-community) diff --git a/docs/index.md b/docs/index.md index 6f1decb4..1ff6f33f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,6 +31,7 @@ The repository currently provides four specialized build backends: | [**`pixi-build-python`**](./backends/pixi-build-python.md) | Building Python packages | | [**`pixi-build-rattler-build`**](./backends/pixi-build-rattler-build.md) | Direct `recipe.yaml` builds with full control | | [**`pixi-build-rust`**](./backends/pixi-build-rust.md) | Cargo-based Rust applications and libraries | +| [**`pixi-build-mojo`**](./backends/pixi-build-mojo.md) | Mojo applications and packages | All backends are available through the [prefix.dev/pixi-build-backends](https://prefix.dev/channels/pixi-build-backends) conda channel and work across multiple platforms (Linux, macOS, Windows). diff --git a/mkdocs.yml b/mkdocs.yml index bb70d03a..aa095c0e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -106,6 +106,7 @@ nav: - pixi-build-python: backends/pixi-build-python.md - pixi-build-rattler-build: backends/pixi-build-rattler-build.md - pixi-build-rust: backends/pixi-build-rust.md + - pixi-build-mojo: backends/pixi-build-mojo.md validation: omitted_files: warn diff --git a/pixi.toml b/pixi.toml index 142bd43a..f7daea28 100644 --- a/pixi.toml +++ b/pixi.toml @@ -21,11 +21,13 @@ install-pixi-build-python = { cmd = "cargo install --path crates/pixi-build-pyth install-pixi-build-cmake = { cmd = "cargo install --path crates/pixi-build-cmake --locked --force" } install-pixi-build-rattler-build = { cmd = "cargo install --path crates/pixi-build-rattler-build --locked --force" } install-pixi-build-rust = { cmd = "cargo install --path crates/pixi-build-rust --locked --force" } +install-pixi-build-mojo = { cmd = "cargo install --path crates/pixi-build-mojo --locked --force" } install-pixi-backends = { depends-on = [ "install-pixi-build-python", "install-pixi-build-cmake", "install-pixi-build-rattler-build", "install-pixi-build-rust", + "install-pixi-build-mojo", ] }