Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion crates/cli/src/common_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,23 @@ pub fn parse_optional_dotnet_version(dotnet_version: Option<&str>) -> anyhow::Re
dotnet_version.map(parse_dotnet_version).transpose()
}

pub(crate) const NATIVEAOT_UNSUPPORTED_MESSAGE: &str =
"NativeAOT-LLVM in only supported on Windows and Linux (.NET 10).";

pub(crate) fn nativeaot_unsupported_on_host(os: &str, dotnet_version: Option<u8>) -> bool {
os == "macos" || (os == "linux" && dotnet_version == Some(8))
}

pub(crate) fn ensure_nativeaot_supported_on_host(dotnet_version: Option<u8>) -> anyhow::Result<()> {
if nativeaot_unsupported_on_host(std::env::consts::OS, dotnet_version) {
anyhow::bail!(NATIVEAOT_UNSUPPORTED_MESSAGE);
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::parse_optional_dotnet_version;
use super::{nativeaot_unsupported_on_host, parse_optional_dotnet_version};

#[test]
fn dotnet_version_accepts_supported_sdk_majors() {
Expand All @@ -92,4 +106,17 @@ mod tests {
assert!(parse_optional_dotnet_version(Some("9")).is_err());
assert!(parse_optional_dotnet_version(Some("not-a-number")).is_err());
}

#[test]
fn dotnet10_nativeaot_is_only_unsupported_on_macos_hosts() {
assert!(nativeaot_unsupported_on_host("macos", Some(10)));
assert!(nativeaot_unsupported_on_host("macos", Some(8)));
assert!(nativeaot_unsupported_on_host("macos", None));
assert!(nativeaot_unsupported_on_host("linux", Some(8)));
assert!(!nativeaot_unsupported_on_host("linux", Some(10)));
assert!(!nativeaot_unsupported_on_host("linux", None));
assert!(!nativeaot_unsupported_on_host("windows", Some(8)));
assert!(!nativeaot_unsupported_on_host("windows", Some(10)));
assert!(!nativeaot_unsupported_on_host("windows", None));
}
}
3 changes: 3 additions & 0 deletions crates/cli/src/subcommands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ pub async fn exec_with_argstring(
};
let build_debug = arg_matches.get_flag("debug");
let dotnet_version = dotnet_version.or_else(|| arg_matches.get_one::<u8>("dotnet_version").copied());
if native_aot {
common_args::ensure_nativeaot_supported_on_host(dotnet_version)?;
}

run_build(module_path, lint_dir, build_debug, features, native_aot, dotnet_version)
}
Expand Down
92 changes: 68 additions & 24 deletions crates/cli/src/subcommands/dev.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use crate::common_args::parse_optional_dotnet_version;
use crate::common_args::ClearMode;
use crate::common_args::{parse_optional_dotnet_version, ClearMode};
use crate::config::Config;
use crate::generate::Language;
use crate::spacetime_config::{
Expand Down Expand Up @@ -95,6 +94,12 @@ pub fn cli() -> Command {
.help("Template ID or GitHub repository (owner/repo or URL) for project initialization"),
)
.arg(common_args::dotnet_version())
.arg(
Arg::new("native_aot")
.long("native-aot")
.action(clap::ArgAction::SetTrue)
.help("Build C# projects with NativeAOT-LLVM. Ignored with .NET 10 because NativeAOT-LLVM is always used."),
)
.arg(
Arg::new("run")
.long("run")
Expand Down Expand Up @@ -191,7 +196,12 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
let no_config = args.get_flag("no_config");
let skip_publish = args.get_flag("skip_publish");
let skip_generate = args.get_flag("skip_generate");
let dotnet_version = args.get_one::<u8>("dotnet_version").map(u8::to_string);
let native_aot_from_cli = args.get_flag("native_aot");
let dotnet_major = args
.get_one::<u8>("dotnet_version")
.copied()
.unwrap_or_else(init::resolve_default_dotnet_major);
let dotnet_version = dotnet_major.to_string();

// --env defaults to "dev" for spacetime dev
let env = args.get_one::<String>("env").map(|s| s.as_str()).unwrap_or("dev");
Expand Down Expand Up @@ -407,7 +417,8 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
project_name_default: database_name_from_cli_for_init.clone(),
database_name_default: database_name_from_cli_for_init.clone(),
skip_next_steps: true,
dotnet_version: parse_optional_dotnet_version(dotnet_version.as_deref())?,
native_aot: native_aot_from_cli,
dotnet_version: parse_optional_dotnet_version(Some(dotnet_version.as_str()))?,
..Default::default()
};
let created_project_path = init::exec_with_options(&mut config, &init_options).await?;
Expand Down Expand Up @@ -557,6 +568,17 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
publish_configs = vec![CommandConfig::new(&publish_schema, config_map, &publish_args)?];
}

let native_aot = native_aot_from_cli
|| publish_configs.iter().any(|config_entry| {
config_entry
.get_config_value("native_aot")
.and_then(|v| v.as_bool())
.unwrap_or(false)
});
if native_aot {
common_args::ensure_nativeaot_supported_on_host(Some(dotnet_major))?;
}

if !no_config {
let db_to_persist = database_name_from_cli_for_init.as_deref().or_else(|| {
publish_configs
Expand Down Expand Up @@ -750,7 +772,8 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
using_spacetime_config,
server_from_cli,
force,
dotnet_version.as_deref(),
dotnet_version.as_str(),
native_aot,
skip_publish,
skip_generate,
)
Expand Down Expand Up @@ -865,7 +888,8 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
using_spacetime_config,
server_from_cli,
force,
dotnet_version.as_deref(),
dotnet_version.as_str(),
native_aot,
skip_publish,
skip_generate,
)
Expand Down Expand Up @@ -1012,7 +1036,8 @@ async fn generate_build_and_publish(
using_spacetime_config: bool,
server: Option<&str>,
yes: bool,
dotnet_version: Option<&str>,
dotnet_version: &str,
native_aot: bool,
skip_publish: bool,
skip_generate: bool,
) -> Result<(), anyhow::Error> {
Expand All @@ -1022,8 +1047,8 @@ async fn generate_build_and_publish(
Some(Path::new("src")),
false,
None,
false,
parse_optional_dotnet_version(dotnet_version)?,
native_aot,
parse_optional_dotnet_version(Some(dotnet_version))?,
)
.context("Failed to build project")?;
println!("{}", "Build complete!".green());
Expand Down Expand Up @@ -1061,7 +1086,7 @@ async fn generate_build_and_publish(
);
} else {
println!("{}", "Generating module bindings from spacetime.json...".cyan());
let generate_configs = with_dotnet_version_override(generate_configs.to_vec(), dotnet_version);
let generate_configs = with_dotnet_version_for_dev(generate_configs.to_vec(), dotnet_version);
generate::exec_from_entries(generate_configs, crate::generate::extract_descriptions, yes, config_dir)
.await?;
}
Expand All @@ -1074,9 +1099,7 @@ async fn generate_build_and_publish(
Some(resolved_client_language),
Some(module_bindings_dir),
);
if let Some(version) = dotnet_version {
generate_entry.insert("dotnet-version".to_string(), json!(version));
}
generate_entry.insert("dotnet-version".to_string(), json!(dotnet_version));
generate::exec_from_entries(
vec![generate_entry],
crate::generate::extract_descriptions,
Expand Down Expand Up @@ -1131,10 +1154,15 @@ async fn generate_build_and_publish(
publish_entry.insert("build-options".to_string(), json!(build_opts));
}

if let Some(version) =
dotnet_version.or_else(|| config_entry.get_config_value("dotnet_version").and_then(|v| v.as_str()))
publish_entry.insert("dotnet-version".to_string(), json!(dotnet_version));

if native_aot
|| config_entry
.get_config_value("native_aot")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
publish_entry.insert("dotnet-version".to_string(), json!(version));
publish_entry.insert("native-aot".to_string(), json!(true));
}

// Forward break-clients if set
Expand All @@ -1155,14 +1183,12 @@ async fn generate_build_and_publish(
Ok(())
}

fn with_dotnet_version_override(
fn with_dotnet_version_for_dev(
mut entries: Vec<HashMap<String, serde_json::Value>>,
dotnet_version: Option<&str>,
dotnet_version: &str,
) -> Vec<HashMap<String, serde_json::Value>> {
if let Some(version) = dotnet_version {
for entry in &mut entries {
entry.insert("dotnet-version".to_string(), json!(version));
}
for entry in &mut entries {
entry.insert("dotnet-version".to_string(), json!(dotnet_version));
}
entries
}
Expand Down Expand Up @@ -1995,17 +2021,35 @@ mod tests {
assert_eq!(matches.get_one::<u8>("dotnet_version").copied(), Some(8));
}

#[test]
fn test_dev_cli_parses_native_aot() {
let matches = cli().get_matches_from(vec!["dev", "--native-aot"]);

assert!(matches.get_flag("native_aot"));
}

#[test]
fn test_dev_cli_rejects_unsupported_dotnet_version() {
assert!(cli().try_get_matches_from(["dev", "--dotnet-version", "9"]).is_err());
}

#[test]
fn test_with_dotnet_version_override_updates_generate_entries() {
fn test_with_dotnet_version_for_dev_updates_generate_entries() {
let mut entry = HashMap::new();
entry.insert("language".to_string(), json!("csharp"));

let entries = with_dotnet_version_for_dev(vec![entry], "10");

assert_eq!(entries[0].get("dotnet-version"), Some(&json!("10")));
}

#[test]
fn test_with_dotnet_version_for_dev_replaces_existing_entry() {
let mut entry = HashMap::new();
entry.insert("language".to_string(), json!("csharp"));
entry.insert("dotnet-version".to_string(), json!("8"));

let entries = with_dotnet_version_override(vec![entry], Some("10"));
let entries = with_dotnet_version_for_dev(vec![entry], "10");

assert_eq!(entries[0].get("dotnet-version"), Some(&json!("10")));
}
Expand Down
97 changes: 81 additions & 16 deletions crates/cli/src/subcommands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,18 @@ pub async fn exec_with_options(config: &mut Config, options: &InitOptions) -> an
None
};

// Add NativeAOT-LLVM project configuration for C# projects when:
// - --native-aot was explicitly specified, OR
// - .NET 10 was selected/detected as the target
let needs_native_aot = if template_config.server_lang == Some(ServerLanguage::Csharp) {
options.native_aot || dotnet_major == Some(10)
} else {
false
};
if needs_native_aot {
common_args::ensure_nativeaot_supported_on_host(dotnet_major)?;
}

ensure_empty_directory(
&template_config.project_name,
&template_config.project_path,
Expand All @@ -578,14 +590,6 @@ pub async fn exec_with_options(config: &mut Config, options: &InitOptions) -> an
)
.await?;

// Add NativeAOT-LLVM project configuration for C# projects when:
// - --native-aot was explicitly specified, OR
// - .NET 10 was selected/detected as the target
let needs_native_aot = if template_config.server_lang == Some(ServerLanguage::Csharp) {
options.native_aot || dotnet_major == Some(10)
} else {
false
};
if needs_native_aot {
let server_dir = template_config.project_path.join("spacetimedb");
add_native_aot_packages_to_csproj(&server_dir, dotnet_major)?;
Expand Down Expand Up @@ -1627,7 +1631,7 @@ fn check_for_cargo() -> bool {
false
}

fn resolve_default_dotnet_major() -> u8 {
pub(crate) fn resolve_default_dotnet_major() -> u8 {
let installed_majors = installed_dotnet_sdk_majors();
if let Some(reason) = dotnet8_default_reason(std::env::consts::OS, installed_majors.as_deref()) {
match reason {
Expand Down Expand Up @@ -1898,6 +1902,28 @@ fn configure_csharp_project_files(project_path: &Path, dotnet_major: u8) -> anyh
Ok(())
}

fn csproj_has_package_reference(content: &str, include: &str, version: &str) -> bool {
fn element_has_package_reference(element: &xmltree::Element, include: &str, version: &str) -> bool {
if element.name == "PackageReference"
&& element.attributes.get("Include").map(String::as_str) == Some(include)
&& element.attributes.get("Version").map(String::as_str) == Some(version)
{
return true;
}

element.children.iter().any(|node| {
let xmltree::XMLNode::Element(child) = node else {
return false;
};
element_has_package_reference(child, include, version)
})
}

xmltree::Element::parse(content.as_bytes())
.map(|root| element_has_package_reference(&root, include, version))
.unwrap_or(false)
}

/// Adds NativeAOT-LLVM project configuration to an existing C# .csproj file and creates NuGet.Config.
///
/// The configuration differs depending on the target .NET version:
Expand All @@ -1919,18 +1945,30 @@ fn add_native_aot_packages_to_csproj(project_path: &Path, dotnet_major: Option<u
let content = std::fs::read_to_string(&csproj_path)?;

let new_content = if dotnet_major == Some(8) {
// .NET 8 AOT: keep net8.0 TFM, add explicit ILCompiler.LLVM package references.
let native_aot_config = r#"
let has_net8_native_aot_refs =
csproj_has_package_reference(&content, "Microsoft.DotNet.ILCompiler.LLVM", "8.0.0-*")
&& csproj_has_package_reference(
&content,
"runtime.$(NETCoreSdkPortableRuntimeIdentifier).Microsoft.DotNet.ILCompiler.LLVM",
"8.0.0-*",
);

if has_net8_native_aot_refs {
content
} else {
// .NET 8 AOT: keep net8.0 TFM, add explicit ILCompiler.LLVM package references.
let native_aot_config = r#"
<ItemGroup Condition="'$(EXPERIMENTAL_WASM_AOT)' == '1'">
<PackageReference Include="Microsoft.DotNet.ILCompiler.LLVM" Version="8.0.0-*" />
<PackageReference Include="runtime.$(NETCoreSdkPortableRuntimeIdentifier).Microsoft.DotNet.ILCompiler.LLVM" Version="8.0.0-*" />
</ItemGroup>
"#;
if let Some(pos) = content.rfind("</Project>") {
let (before, after) = content.split_at(pos);
format!("{}{}{}", before.trim_end(), native_aot_config, after)
} else {
anyhow::bail!("Invalid .csproj file: missing </Project> tag");
if let Some(pos) = content.rfind("</Project>") {
let (before, after) = content.split_at(pos);
format!("{}{}{}", before.trim_end(), native_aot_config, after)
} else {
anyhow::bail!("Invalid .csproj file: missing </Project> tag");
}
}
} else {
// .NET 10 AOT: directly set TFM to net10.0 (no conditional needed).
Expand Down Expand Up @@ -2533,6 +2571,33 @@ bytes.workspace = true
assert!(!net10.contains("<TargetFrameworks"));
}

#[test]
fn test_add_native_aot_packages_does_not_duplicate_template_refs() {
let temp = tempfile::TempDir::new().unwrap();
let csproj = temp.path().join("StdbModule.csproj");
let template = include_str!("../../../../templates/basic-cs/spacetimedb/StdbModule.csproj");
let net8 = csharp_csproj_for_target(template, 8).unwrap();
std::fs::write(&csproj, net8).unwrap();

add_native_aot_packages_to_csproj(temp.path(), Some(8)).unwrap();

let content = std::fs::read_to_string(csproj).unwrap();
assert_eq!(
content
.matches(r#"PackageReference Include="Microsoft.DotNet.ILCompiler.LLVM" Version="8.0.0-*""#)
.count(),
1
);
assert_eq!(
content
.matches(
r#"PackageReference Include="runtime.$(NETCoreSdkPortableRuntimeIdentifier).Microsoft.DotNet.ILCompiler.LLVM" Version="8.0.0-*""#
)
.count(),
1
);
}

#[test]
fn test_create_default_spacetime_config_if_missing_creates_expected_config() {
let temp = tempfile::TempDir::new().unwrap();
Expand Down
Loading
Loading