diff --git a/Cargo.lock b/Cargo.lock index 02bb0950e..c3ad78746 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ide-agent" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dunce", "encoding_rs", @@ -4061,7 +4061,7 @@ dependencies = [ [[package]] name = "ide-analysis" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ast-grep-core", "ide-core", @@ -4075,7 +4075,7 @@ dependencies = [ [[package]] name = "ide-build" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dirs 6.0.0", "dunce", @@ -4090,7 +4090,7 @@ dependencies = [ [[package]] name = "ide-clipboard" -version = "0.1.32" +version = "0.1.33" dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", @@ -4099,7 +4099,7 @@ dependencies = [ [[package]] name = "ide-core" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dunce", "encoding_rs", @@ -4115,7 +4115,7 @@ dependencies = [ [[package]] name = "ide-coverage" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "ide-run", @@ -4129,7 +4129,7 @@ dependencies = [ [[package]] name = "ide-dap" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "ide-jsonrpc", @@ -4147,7 +4147,7 @@ dependencies = [ [[package]] name = "ide-db" -version = "0.1.32" +version = "0.1.33" dependencies = [ "bb8", "dirs 6.0.0", @@ -4169,7 +4169,7 @@ dependencies = [ [[package]] name = "ide-docker" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "libc", @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "ide-fonts" -version = "0.1.32" +version = "0.1.33" dependencies = [ "objc2-core-foundation", "objc2-core-text", @@ -4195,7 +4195,7 @@ dependencies = [ [[package]] name = "ide-github" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "ide-vcs", @@ -4209,7 +4209,7 @@ dependencies = [ [[package]] name = "ide-host" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dirs 6.0.0", "dunce", @@ -4254,7 +4254,7 @@ dependencies = [ [[package]] name = "ide-http" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "parking_lot", @@ -4272,7 +4272,7 @@ dependencies = [ [[package]] name = "ide-index" -version = "0.1.32" +version = "0.1.33" dependencies = [ "blake3", "criterion", @@ -4294,7 +4294,7 @@ dependencies = [ [[package]] name = "ide-jsonrpc" -version = "0.1.32" +version = "0.1.33" dependencies = [ "encoding_rs", "futures", @@ -4310,7 +4310,7 @@ dependencies = [ [[package]] name = "ide-language" -version = "0.1.32" +version = "0.1.33" dependencies = [ "blake3", "criterion", @@ -4365,7 +4365,7 @@ dependencies = [ [[package]] name = "ide-lsp" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dunce", "futures", @@ -4390,7 +4390,7 @@ dependencies = [ [[package]] name = "ide-notebook" -version = "0.1.32" +version = "0.1.33" dependencies = [ "futures", "ide-core", @@ -4413,7 +4413,7 @@ dependencies = [ [[package]] name = "ide-plugin" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "ide-language", @@ -4431,7 +4431,7 @@ dependencies = [ [[package]] name = "ide-profiler" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "ide-run", @@ -4444,7 +4444,7 @@ dependencies = [ [[package]] name = "ide-remote" -version = "0.1.32" +version = "0.1.33" dependencies = [ "globset", "ide-core", @@ -4461,7 +4461,7 @@ dependencies = [ [[package]] name = "ide-run" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dunce", "encoding_rs", @@ -4484,7 +4484,7 @@ dependencies = [ [[package]] name = "ide-sdk" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dunce", "ide-core", @@ -4503,7 +4503,7 @@ dependencies = [ [[package]] name = "ide-search" -version = "0.1.32" +version = "0.1.33" dependencies = [ "criterion", "globset", @@ -4523,7 +4523,7 @@ dependencies = [ [[package]] name = "ide-spell" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "parking_lot", @@ -4536,7 +4536,7 @@ dependencies = [ [[package]] name = "ide-ssh" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dirs 6.0.0", "flate2", @@ -4555,7 +4555,7 @@ dependencies = [ [[package]] name = "ide-syntax" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "ide-language", @@ -4570,7 +4570,7 @@ dependencies = [ [[package]] name = "ide-terminal" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "libc", @@ -4583,7 +4583,7 @@ dependencies = [ [[package]] name = "ide-text" -version = "0.1.32" +version = "0.1.33" dependencies = [ "criterion", "ide-core", @@ -4595,7 +4595,7 @@ dependencies = [ [[package]] name = "ide-tools" -version = "0.1.32" +version = "0.1.33" dependencies = [ "flate2", "hex", @@ -4616,7 +4616,7 @@ dependencies = [ [[package]] name = "ide-vcs" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dunce", "encoding_rs", @@ -4635,7 +4635,7 @@ dependencies = [ [[package]] name = "ide-vfs" -version = "0.1.32" +version = "0.1.33" dependencies = [ "chardetng", "dunce", @@ -4657,7 +4657,7 @@ dependencies = [ [[package]] name = "ide-web" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "open", @@ -4673,7 +4673,7 @@ dependencies = [ [[package]] name = "ide-workspace" -version = "0.1.32" +version = "0.1.33" dependencies = [ "blake3", "dunce", @@ -6144,7 +6144,7 @@ dependencies = [ [[package]] name = "openidea" -version = "0.1.32" +version = "0.1.33" dependencies = [ "blake3", "block2 0.6.2", diff --git a/Cargo.toml b/Cargo.toml index f8b7ed866..9da1d526f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "src-tauri"] [workspace.package] -version = "0.1.32" +version = "0.1.33" edition = "2024" rust-version = "1.96" license = "Apache-2.0" diff --git a/README.md b/README.md index 774889602..59b4f7c02 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,6 @@  ·  Benchmark  ·  - Build -  ·  Discord

@@ -47,24 +45,6 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on both sides. Both are measured from the outside, the same way: see [`perf/compare`](perf/compare) to run it on your machine and projects. -## Build - -```sh -pnpm install -pnpm tauri dev -``` - -Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/). - -Native notebook execution tests are opt-in and need two distinct Python environments -with `ipykernel` installed: - -```sh -OPENIDEA_TEST_PYTHON=/first/env/bin/python \ -OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \ -cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1 -``` - ## License [Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md). diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 7f360b1f1..7c2c17a37 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -58,6 +58,43 @@ pub struct Settings { pub deployment: DeploymentSettings, /// Build, Execution, Deployment › Run Targets. pub run_targets: Vec, + pub cmake: CMakeSettings, +} + +/// Absent profiles retain automatic discovery for existing projects. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CMakeSettings { + #[serde(skip_serializing_if = "Option::is_none")] + #[specta(optional)] + pub profiles: Option>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CMakeProfile { + pub name: String, + pub build_type: String, + pub generator: String, + pub cmake_args: Vec, + /// Empty uses cmake-build-. + pub build_dir: String, + pub build_args: Vec, + pub env: BTreeMap, +} + +impl Default for CMakeProfile { + fn default() -> Self { + Self { + name: "Debug".into(), + build_type: "Debug".into(), + generator: String::new(), + cmake_args: Vec::new(), + build_dir: String::new(), + build_args: Vec::new(), + env: BTreeMap::new(), + } + } } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -154,6 +191,7 @@ impl Default for Settings { ssh: SshSettings::default(), deployment: DeploymentSettings::default(), run_targets: Vec::new(), + cmake: CMakeSettings::default(), } } } @@ -765,6 +803,24 @@ impl Settings { mod tests { use super::*; + #[test] + fn cmake_profile_argument_arrays_round_trip() { + let mut settings = Settings::default(); + assert!(settings.cmake.profiles.is_none()); + assert!(CMakeProfile::default().cmake_args.is_empty()); + assert!(CMakeProfile::default().build_args.is_empty()); + let profile = CMakeProfile { + cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(), + build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(), + ..Default::default() + }; + settings.cmake.profiles = Some(vec![profile.clone()]); + let value = serde_json::to_value(&settings).unwrap(); + assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args)); + assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args)); + assert_eq!(serde_json::from_value::(value).unwrap(), settings); + } + #[test] fn ui_font_fallback_defaults_empty_and_round_trips() { let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap(); diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 21f21e0a4..8798e52ac 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -50,10 +50,11 @@ fn detected_and_saved( suppressed: &[String], ) -> Vec { let excluded_poms = crate::project::unlinked_maven_poms(root, settings); - let mut all: Vec = ide_run::detect_excluding(root, &excluded_poms) - .into_iter() - .filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id)) - .collect(); + let mut all: Vec = + ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref()) + .into_iter() + .filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id)) + .collect(); all.extend(saved.iter().cloned()); for config in all.iter_mut().filter(|c| c.kind != "gradle") { ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings); diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 9f6e2c5fe..13a1f5d5c 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -16,6 +16,14 @@ pub(crate) fn detect(root: &Path) -> Vec { } pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec { + detect_with_profiles(root, excluded_poms, None) +} + +pub(crate) fn detect_with_profiles( + root: &Path, + excluded_poms: &[PathBuf], + profiles: Option<&[ide_core::settings::CMakeProfile]>, +) -> Vec { let mut out = Vec::new(); detect_node(root, &mut out); detect_deno(root, &mut out); @@ -24,7 +32,7 @@ pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec) { +pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut Vec) { if !root.join("CMakeLists.txt").is_file() { return; } + if let Some(profiles) = profiles { + for (index, profile) in profiles.iter().enumerate() { + detect_profile(root, profile, index, profiles.len() > 1, out); + } + return; + } let mut builds = Vec::new(); if let Ok(entries) = fs::read_dir(root) { builds.extend(entries.flatten().filter_map(|e| { @@ -79,11 +86,69 @@ pub(super) fn detect(root: &Path, out: &mut Vec) { ); before(&mut test, &build_id); out.push(test); - targets(root, &build, &dir, &prefix, out); + targets(root, &build, &dir, &prefix, None, out); } } -fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec) { +fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, multiple: bool, out: &mut Vec) { + let dir = if profile.build_dir.trim().is_empty() { + format!("cmake-build-{}", profile.build_type.to_lowercase()) + } else { + profile.build_dir.clone() + }; + let build = root.join(&dir); + let prefix = format!("cmake:profile:{index}"); + let configure_id = format!("{prefix}:configure"); + let build_id = format!("{prefix}:build"); + let start = out.len(); + let mut configure = + config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &["-S", ".", "-B", &dir]); + if !profile.generator.trim().is_empty() { + configure.args.extend(["-G".into(), profile.generator.clone()]); + } + configure.args.push(format!("-DCMAKE_BUILD_TYPE={}", profile.build_type)); + configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into()); + configure.args.extend_from_slice(&profile.cmake_args); + configure.extras.params.insert("buildDir".into(), dir.clone()); + out.push(configure); + let mut all = config( + &build_id, + format!("CMake Build ({dir})"), + "cmake", + "cmake", + &["--build", &dir, "--config", &profile.build_type], + ); + all.args.extend_from_slice(&profile.build_args); + if !build.join("CMakeCache.txt").is_file() { + before(&mut all, &configure_id); + } + out.push(all); + let mut test = config( + format!("{prefix}:test"), + format!("CTest ({dir})"), + "cmake", + "ctest", + &["--test-dir", &dir, "-C", &profile.build_type, "--output-on-failure"], + ); + before(&mut test, &build_id); + out.push(test); + targets(root, &build, &dir, &prefix, Some(profile), out); + for config in &mut out[start..] { + config.env = profile.env.clone(); + if multiple { + config.name = format!("{} [{}]", config.name, profile.name); + } + } +} + +fn targets( + root: &Path, + build: &Path, + dir: &str, + prefix: &str, + profile: Option<&CMakeProfile>, + out: &mut Vec, +) { let reply = build.join(".cmake/api/v1/reply"); let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| { entries @@ -113,6 +178,9 @@ fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec crate::IdeResult<()> { && config.args.first().is_some_and(|a| a == "-S") && let Some(dir) = config.extras.params.get("buildDir") { - let query = cwd.join(dir).join(".cmake/api/v1/query/client-openidea"); + let build = cwd.join(dir); + let generator = config.args.windows(2).find(|a| a[0] == "-G").map(|a| a[1].as_str()); + if let Some(generator) = generator + && let Ok(cache) = fs::read_to_string(build.join("CMakeCache.txt")) + && let Some(cached) = cache.lines().find_map(|l| l.strip_prefix("CMAKE_GENERATOR:INTERNAL=")) + && cached != generator + { + fs::remove_file(build.join("CMakeCache.txt"))?; + let files = build.join("CMakeFiles"); + if files.is_dir() { + fs::remove_dir_all(files)?; + } + } + let query = build.join(".cmake/api/v1/query/client-openidea"); fs::create_dir_all(&query)?; fs::write(query.join("codemodel-v2"), "")?; } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profiles_select_file_api_configuration_and_apply_target_options() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap(); + let reply = root.path().join("output/.cmake/api/v1/reply"); + fs::create_dir_all(&reply).unwrap(); + let write = |name: &str, value: Value| fs::write(reply.join(name), value.to_string()).unwrap(); + write( + "index-1.json", + serde_json::json!({"objects":[{"kind":"codemodel","version":{"major":2},"jsonFile":"model.json"}]}), + ); + write( + "model.json", + serde_json::json!({"paths":{"source":root.path()},"configurations":[ + {"name":"Debug","targets":[{"jsonFile":"debug.json"}]}, + {"name":"Release","targets":[{"jsonFile":"release.json"}]} + ]}), + ); + for name in ["debug", "release"] { + write( + &format!("{name}.json"), + serde_json::json!({"name":"app","type":"EXECUTABLE","artifacts":[{"path":format!("{name}/app")}]}), + ); + } + let profile = CMakeProfile { + name: "Production".into(), + build_type: "Release".into(), + build_dir: "output".into(), + build_args: ["-j", "8", "--", "verbose=1"].map(String::from).into(), + env: [("FOO".into(), "bar".into())].into(), + ..Default::default() + }; + let mut configs = Vec::new(); + detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configs); + let run = configs.iter().find(|c| c.kind == "native").unwrap(); + assert!(run.command.ends_with("release/app")); + assert_eq!(run.env, profile.env); + assert_eq!(configs.iter().filter(|c| c.kind == "native").count(), 1); + let build = configs.iter().find(|c| c.id == run.extras.before_launch[0].target).unwrap(); + assert_eq!( + build.args, + ["--build", "output", "--target", "app", "--config", "Release", "-j", "8", "--", "verbose=1"] + ); + assert_eq!(build.env, profile.env); + assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure"); + fs::write(root.path().join("output/CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Ninja\n").unwrap(); + let mut configured = Vec::new(); + detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configured); + for id in ["cmake:profile:0:build", build.id.as_str()] { + let build = configured.iter().find(|c| c.id == id).unwrap(); + assert!(build.extras.before_launch.is_empty(), "{id} should use CMake's automatic regeneration"); + } + } + + #[test] + fn generator_change_clears_only_cmake_state_at_launch() { + let root = tempfile::tempdir().unwrap(); + let build = root.path().join("cmake-build-debug"); + fs::create_dir_all(build.join("CMakeFiles")).unwrap(); + fs::write(build.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap(); + fs::write(build.join("keep.txt"), "artifact").unwrap(); + fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap(); + let mut configs = Vec::new(); + detect(root.path(), Some(&[CMakeProfile { generator: "Ninja".into(), ..Default::default() }]), &mut configs); + assert!(build.join("CMakeCache.txt").exists()); + prepare(&configs[0], root.path()).unwrap(); + assert!(!build.join("CMakeCache.txt").exists()); + assert!(!build.join("CMakeFiles").exists()); + assert!(build.join("keep.txt").exists()); + assert!(build.join(".cmake/api/v1/query/client-openidea/codemodel-v2").exists()); + } +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index db94a4586..95bf00bd3 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -201,6 +201,15 @@ pub fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec, +) -> Vec { + detect::detect_with_profiles(root, excluded_poms, profiles) +} + /// Reads `/.openidea/run.toml`; a missing file yields no configs. pub fn load_user_configs(root: &Path) -> IdeResult> { config::load(root) diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 601f9af7f..578019334 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -652,3 +652,72 @@ fn go_default_build_output_is_ignored_by_version_control() { let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap(); assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}"); } + +#[test] +fn cmake_profiles_control_commands_names_and_environment() { + use ide_core::settings::CMakeProfile; + let dir = project(&[("CMakeLists.txt", "project(app)")]); + for generator in ["", "Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022"] { + let profile = CMakeProfile { + name: "Production".into(), + build_type: "Release".into(), + generator: generator.into(), + cmake_args: ["-DFEATURE=ON", "-DMESSAGE=two words", "-DQUOTE=\"hello world\"", ""].map(String::from).into(), + build_dir: "output dir".into(), + build_args: ["-j", "8", "--", "VERBOSE=1"].map(String::from).into(), + env: [("MY_ENV".into(), "yes".into())].into(), + }; + let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(std::slice::from_ref(&profile))); + let configure = find(&configs, "cmake:profile:0:configure"); + let mut expected = vec!["-S", ".", "-B", "output dir"]; + if !generator.is_empty() { + expected.extend(["-G", generator]); + } + expected.extend([ + "-DCMAKE_BUILD_TYPE=Release", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + "-DFEATURE=ON", + "-DMESSAGE=two words", + "-DQUOTE=\"hello world\"", + "", + ]); + assert_eq!(configure.args, expected); + assert_eq!(configure.name, "CMake Configure (output dir)"); + let build = find(&configs, "cmake:profile:0:build"); + assert_eq!(build.args, ["--build", "output dir", "--config", "Release", "-j", "8", "--", "VERBOSE=1"]); + assert_eq!(build.extras.before_launch[0].target, configure.id); + assert!(configs.iter().all(|c| c.env == profile.env)); + let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(&[CMakeProfile::default(), profile])); + assert!(find(&configs, "cmake:profile:0:build").name.ends_with("[Debug]")); + assert!(find(&configs, "cmake:profile:1:build").name.ends_with("[Production]")); + assert_eq!(find(&configs, "cmake:profile:0:build").args[1], "cmake-build-debug"); + } + assert_eq!(ide_run::detect_with_profiles(dir.path(), &[], None), detect(dir.path())); + assert!(ide_run::detect_with_profiles(dir.path(), &[], Some(&[])).is_empty()); +} + +#[test] +fn cmake_profile_build_configures_only_without_a_cache() { + use ide_core::settings::CMakeProfile; + let root = project(&[("CMakeLists.txt", "project(app)")]); + for dir in ["", "custom output"] { + let profile = CMakeProfile { build_dir: dir.into(), generator: "Ninja".into(), ..Default::default() }; + let build_dir = root.path().join(if dir.is_empty() { "cmake-build-debug" } else { dir }); + for cached in [false, true] { + if cached { + fs::create_dir_all(&build_dir).unwrap(); + // Generator changes are handled by an explicit Configure, not every Build. + fs::write(build_dir.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap(); + } + let configs = ide_run::detect_with_profiles(root.path(), &[], Some(std::slice::from_ref(&profile))); + let build = find(&configs, "cmake:profile:0:build"); + if cached { + assert!(build.extras.before_launch.is_empty()); + } else { + assert_eq!(build.extras.before_launch.len(), 1); + assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure"); + } + assert_eq!(find(&configs, "cmake:profile:0:test").extras.before_launch[0].target, build.id); + } + } +} diff --git a/package.json b/package.json index 9cf94c037..9fb5ec265 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openidea", "private": true, "description": "An open-source IDE inspired by IntelliJ IDEA", - "version": "0.1.32", + "version": "0.1.33", "license": "Apache-2.0", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9cbf6ee47..9a1c84fbf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenIDEA", - "version": "0.1.32", + "version": "0.1.33", "identifier": "ai.l0ng.openidea", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/bindings.ts b/src/bindings.ts index b65240187..645f8668a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1639,6 +1639,30 @@ export type ByteRange = { total: number, }; +export type CMakeProfile = { + name?: string, + buildType?: string, + generator?: string, + cmakeArgs?: string[], + /** Empty uses cmake-build-. */ + buildDir?: string, + buildArgs?: string[], + env?: { [key in string]: string }, +}; + +/** Absent profiles retain automatic discovery for existing projects. */ +export type CMakeSettings = CMakeSettings_Serialize | CMakeSettings_Deserialize; + +/** Absent profiles retain automatic discovery for existing projects. */ +export type CMakeSettings_Deserialize = { + profiles?: CMakeProfile[] | null, +}; + +/** Absent profiles retain automatic discovery for existing projects. */ +export type CMakeSettings_Serialize = { + profiles?: CMakeProfile[] | null, +}; + export type CallHierarchy = { items: HierarchyItem[], incomplete: ReferencesIncomplete | null, @@ -5635,6 +5659,7 @@ export type Settings_Deserialize = { deployment?: DeploymentSettings, /** Build, Execution, Deployment › Run Targets. */ runTargets?: RunTarget[], + cmake?: CMakeSettings_Deserialize, }; export type Settings_Serialize = { @@ -5690,6 +5715,7 @@ export type Settings_Serialize = { deployment: DeploymentSettings, /** Build, Execution, Deployment › Run Targets. */ runTargets: RunTarget[], + cmake: CMakeSettings_Serialize, }; export type Severity = "error" | "warning" | "info" | "hint"; diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 5cac309c2..a07662b1d 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -538,7 +538,14 @@ export function activate(): () => void { }), // Detected configs carry the selected toolchains (interpreter, JDK, Maven home and options). onIdeEvent("settingsChanged", (e) => { - if (e.key === "languages" || e.key.startsWith("languages.")) scheduleReload(); + if ( + !e.key || + e.key === "languages" || + e.key.startsWith("languages.") || + e.key === "cmake" || + e.key.startsWith("cmake.") + ) + scheduleReload(); }), ); diff --git a/src/features/settings/__tests__/cmake.test.ts b/src/features/settings/__tests__/cmake.test.ts new file mode 100644 index 000000000..bf5da3a79 --- /dev/null +++ b/src/features/settings/__tests__/cmake.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { validateSettings } from "../model/schema"; +import { DEFAULT_CMAKE_PROFILE } from "@/platform/cmakeSettings"; + +describe("CMake settings", () => { + it("preserves absent and explicitly empty profiles", () => { + expect(validateSettings({}).cmake).toEqual({}); + expect(validateSettings({ cmake: { profiles: [] } }).cmake.profiles).toEqual([]); + }); + it("preserves argument arrays and drops malformed argument values", () => { + const cmakeArgs = ["-DMESSAGE=two words", '-DQUOTE="hello"', ""]; + const buildArgs = ["-j", "8", "--", "LABEL=it's a build"]; + const profiles = validateSettings({ cmake: { profiles: [{ cmakeArgs, buildArgs }] } }).cmake.profiles; + expect(profiles?.[0]).toMatchObject({ cmakeArgs, buildArgs }); + expect( + validateSettings({ cmake: { profiles: [{ cmakeArgs: ["-DFOO=bar", 42, null] }] } }).cmake.profiles?.[0] + ?.cmakeArgs, + ).toEqual(["-DFOO=bar"]); + }); + it("retains custom types and generators while normalizing malformed fields", () => { + expect( + validateSettings({ + cmake: { + profiles: [ + { + name: "Custom", + buildType: "Coverage", + generator: "Custom generator", + buildArgs: 5, + env: { KEEP: "yes", DROP: 5 }, + }, + null, + ], + }, + }).cmake.profiles, + ).toEqual([ + { + ...DEFAULT_CMAKE_PROFILE, + name: "Custom", + buildType: "Coverage", + generator: "Custom generator", + env: { KEEP: "yes" }, + }, + ]); + }); +}); diff --git a/src/features/settings/__tests__/cmakeEditor.test.tsx b/src/features/settings/__tests__/cmakeEditor.test.tsx new file mode 100644 index 000000000..2d11a991b --- /dev/null +++ b/src/features/settings/__tests__/cmakeEditor.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it } from "vitest"; +import { DEFAULT_CMAKE_PROFILE } from "@/platform/cmakeSettings"; +import { parseArgs } from "@/platform/commandLine"; +import { PageContext } from "../components/context"; +import { validateSettings } from "../model/schema"; +import { useSettingsDialog } from "../model/staging"; +import { CMakeProfiles } from "../pages/cmake"; + +const savedDialog = useSettingsDialog.getState(); +afterEach(() => { + cleanup(); + useSettingsDialog.setState(savedDialog, true); +}); + +it.each([ + ["CMake arguments", "cmakeArgs"], + ["Build arguments", "buildArgs"], +] as const)("round-trips spaces, quotes and empty values in %s", async (label, key) => { + const args = ["two words", 'a "quote"', "it's", "", String.raw`C:\some path\file`]; + useSettingsDialog.setState({ + base: validateSettings({ cmake: { profiles: [{ ...DEFAULT_CMAKE_PROFILE, [key]: args }] } }), + staged: {}, + }); + const mount = () => + render( + + + , + ); + const view = mount(); + const input = screen.getByLabelText(label); + expect(parseArgs(input.value)).toEqual(args); + await userEvent.clear(input); + const text = String.raw`-DNAME="two words" "-DQUOTE=it's" ""`; + await userEvent.type(input, text); + expect(input).toHaveValue(text); + fireEvent.blur(input); + const expected = ["-DNAME=two words", "-DQUOTE=it's", ""]; + expect(parseArgs(input.value)).toEqual(expected); + expect(useSettingsDialog.getState().staged["project|cmake.profiles"]?.value).toEqual([ + { ...DEFAULT_CMAKE_PROFILE, [key]: expected }, + ]); + view.unmount(); + mount(); + expect(parseArgs(screen.getByLabelText(label).value)).toEqual(expected); +}); + +it("displays each profile's arguments when switching profiles", () => { + useSettingsDialog.setState({ + base: validateSettings({ + cmake: { + profiles: [ + { ...DEFAULT_CMAKE_PROFILE, name: "Debug" }, + { ...DEFAULT_CMAKE_PROFILE, name: "Release", cmakeArgs: ["-DMESSAGE=release build"] }, + ], + }, + }), + staged: {}, + }); + render( + + + , + ); + fireEvent.change(screen.getByLabelText("CMake arguments"), { target: { value: '"unfinished ' } }); + fireEvent.click(screen.getByRole("option", { name: "Release" })); + expect(parseArgs(screen.getByLabelText("CMake arguments").value)).toEqual([ + "-DMESSAGE=release build", + ]); +}); diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index dbdb5f024..a72f47525 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -84,6 +84,29 @@ describe("Settings dialog", () => { expect(settingsSet).toHaveBeenLastCalledWith("global", "editor.encodings.nativeToAscii", "false"); }); + it("edits CMake profiles in project settings and persists them on Apply", async () => { + useProject.setState({ project: { root: "/project" } as never }); + const dialog = await openDialog("build.cmake"); + expect(within(dialog).getByRole("option", { name: "Debug" })).toBeInTheDocument(); + fireEvent.click( + within(within(dialog).getByRole("toolbar", { name: "CMake profile actions" })).getByRole("button", { + name: "Add", + }), + ); + fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "Release" } }); + fireEvent.change(within(dialog).getByLabelText("Build type"), { target: { value: "Release" } }); + fireEvent.change(within(dialog).getByLabelText("Generator"), { target: { value: "Ninja" } }); + fireEvent.change(within(dialog).getByLabelText("Build arguments"), { target: { value: "-j 8" } }); + expect(settingsSet).not.toHaveBeenCalled(); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + const write = settingsSet.mock.calls.find((c) => c[1] === "cmake.profiles")!; + expect(write[0]).toBe("project"); + expect(JSON.parse(write[2])).toEqual([ + expect.objectContaining({ name: "Debug", buildType: "Debug" }), + expect.objectContaining({ name: "Release", buildType: "Release", generator: "Ninja", buildArgs: ["-j", "8"] }), + ]); + }); + it("finds Gradle JVM and applies project Gradle settings", async () => { useProject.setState({ project: { root: "/project" } as never }); const dialog = await openDialog("build.gradle"); diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index 8bf6e185e..d92540d3a 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -4,6 +4,7 @@ * so `validateSettings` never throws — anything unknown or malformed falls * back to the default (mirrors `ide-core::settings` defaults). */ +import { validateCMake } from "@/platform/cmakeSettings"; import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames"; import { validateFileTypeOverrides, validateFileTypes, type FileTypeAssociations } from "@/platform/fileTypes"; import type { LanguageServerSettings, Settings } from "@/platform/ipc"; @@ -82,6 +83,7 @@ export interface ValidSettings extends WorkflowSettings { /** System Settings › HTTP Proxy (global). */ proxy: ProxySettingsValid; languages: LanguagesValid; + cmake: ReturnType; projectStructure: ProjectStructureValid; inspections: InspectionSettingsValid; /** Editor › Natural Languages › Spelling. */ @@ -150,6 +152,7 @@ export const DEFAULT_SETTINGS: ValidSettings = { notifications: DEFAULT_NOTIFICATIONS, proxy: DEFAULT_PROXY, languages: DEFAULT_LANGUAGES, + cmake: {}, projectStructure: DEFAULT_PROJECT_STRUCTURE, inspections: DEFAULT_INSPECTIONS, spelling: { ideWords: [], projectWords: [], customDictionaries: [] }, @@ -254,6 +257,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings { notifications: validateNotifications(root.notifications), proxy: validateProxy(root.proxy), languages: validateLanguages(root.languages), + cmake: validateCMake(root.cmake), projectStructure: validateProjectStructure(root.projectStructure), inspections: validateInspections(root.inspections), spelling: validateSpelling(root.spelling), diff --git a/src/features/settings/pages/CMake.module.css b/src/features/settings/pages/CMake.module.css new file mode 100644 index 000000000..1196d4e8f --- /dev/null +++ b/src/features/settings/pages/CMake.module.css @@ -0,0 +1,4 @@ +.profile { + width: 100%; + text-align: left; +} diff --git a/src/features/settings/pages/cmake.tsx b/src/features/settings/pages/cmake.tsx new file mode 100644 index 000000000..6bdf008b0 --- /dev/null +++ b/src/features/settings/pages/cmake.tsx @@ -0,0 +1,151 @@ +import { useId, useState } from "react"; +import { DEFAULT_CMAKE_PROFILE, type CMakeProfileValid } from "@/platform/cmakeSettings"; +import { formatArgs, parseArgs } from "@/platform/commandLine"; +import { tx } from "@/platform/i18n"; +import { IconButton, Input, T } from "@/ui"; +import { Row, Section } from "../components/Form"; +import { useSetting } from "../components/context"; +import { TableEditor } from "./workflowControls"; +import styles from "./Workflow.module.css"; +import own from "./CMake.module.css"; + +function ArgumentsInput({ id, value, onChange }: { id: string; value: string[]; onChange: (args: string[]) => void }) { + const [text, setText] = useState(); + return ( + { + setText(e.target.value); + onChange(parseArgs(e.target.value)); + }} + onBlur={() => setText(undefined)} + /> + ); +} + +export function CMakeProfiles() { + const [saved, setProfiles] = useSetting("cmake.profiles"); + const profiles = saved ?? [DEFAULT_CMAKE_PROFILE]; + const [selected, setSelected] = useState(0); + const index = Math.min(selected, Math.max(0, profiles.length - 1)); + const current = profiles[index]; + const id = useId(); + const update = (patch: Partial) => + setProfiles(profiles.map((p, i) => (i === index ? { ...p, ...patch } : p))); + const fields = [ + ["name", "Name"], + ["buildType", "Build type"], + ["generator", "Generator"], + ["cmakeArgs", "CMake arguments"], + ["buildDir", "Build directory"], + ["buildArgs", "Build arguments"], + ] as const; + return ( +
+
+
+
+ { + setProfiles([ + ...profiles, + { ...DEFAULT_CMAKE_PROFILE, name: tx`CMake profile ${profiles.length + 1}`, env: {} }, + ]); + setSelected(profiles.length); + }} + /> + { + setProfiles(profiles.filter((_, i) => i !== index)); + setSelected(Math.max(0, index - 1)); + }} + /> +
+
+ {profiles.map((p, i) => ( + + ))} + {profiles.length === 0 && No CMake profiles. Click + to add one.} +
+
+
+ {current && ( +
+ {fields.map(([key, label]) => ( + + {key === "cmakeArgs" || key === "buildArgs" ? ( + update({ [key]: args })} + /> + ) : ( + update({ [key]: e.target.value })} + /> + )} + + ))} + + {["Debug", "Release", "RelWithDebInfo", "MinSizeRel"].map((v) => ( + + + {["Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022", "Xcode"].map((v) => ( + + + ({ name, value }))} + columns={[ + { key: "name", title: "Name", placeholder: "NAME" }, + { key: "value", title: "Value", placeholder: "value" }, + ]} + onChange={(rows) => update({ env: Object.fromEntries(rows.map((r) => [r.name ?? "", r.value ?? ""])) })} + empty="No variables" + aria-label="Environment variables" + /> + +
+ + Quote arguments containing spaces. Build arguments go to cmake --build; use -- before native build tool + options. + +
+
+ )} +
+ ); +} diff --git a/src/features/settings/pages/execution.tsx b/src/features/settings/pages/execution.tsx index 77ce59a02..3498655c9 100644 --- a/src/features/settings/pages/execution.tsx +++ b/src/features/settings/pages/execution.tsx @@ -3,6 +3,7 @@ * Stepping), Python Debugger and Run/Debug. Consumed by the run and debugger * features (and `ide-run` for encoding / termination timeout). */ +import { CMakeProfiles } from "./cmake"; import { WORKFLOW_LIMITS } from "@/platform/workflowSettings"; import type { SelectOption } from "@/ui"; import { GlobListEditor, NumberInput, Row } from "../components/Form"; @@ -90,6 +91,25 @@ function TooltipDelayRow() { } export const EXECUTION_PAGES: PageSpec[] = [ + { + id: "build.cmake", + title: "CMake", + group: BUILD_GROUP, + order: 357, + projectScope: true, + defaultScope: "project", + keywords: [ + "profile", + "generator", + "Ninja", + "Build type", + "CMake arguments", + "Build directory", + "Build arguments", + "Environment variables", + ], + component: CMakeProfiles, + }, { id: "build.gradle", projectScope: true, diff --git a/src/platform/cmakeSettings.ts b/src/platform/cmakeSettings.ts new file mode 100644 index 000000000..6c4cd5edf --- /dev/null +++ b/src/platform/cmakeSettings.ts @@ -0,0 +1,38 @@ +import type { CMakeProfile } from "@/bindings"; + +export type CMakeProfileValid = Required; + +export const DEFAULT_CMAKE_PROFILE: CMakeProfileValid = { + name: "Debug", + buildType: "Debug", + generator: "", + cmakeArgs: [], + buildDir: "", + buildArgs: [], + env: {}, +}; + +export function validateCMake(raw: unknown): { profiles?: CMakeProfileValid[] } { + const profiles = (raw as { profiles?: unknown } | undefined)?.profiles; + if (!Array.isArray(profiles)) return {}; + const string = (value: unknown, fallback = "") => (typeof value === "string" ? value : fallback); + const args = (value: unknown): string[] => + Array.isArray(value) ? value.filter((arg): arg is string => typeof arg === "string") : []; + return { + profiles: profiles + .filter((p) => p && typeof p === "object") + .map((p) => ({ + name: string(p.name, "Debug"), + buildType: string(p.buildType, "Debug"), + generator: string(p.generator), + cmakeArgs: args(p.cmakeArgs), + buildDir: string(p.buildDir), + buildArgs: args(p.buildArgs), + env: Object.fromEntries( + Object.entries(p.env && typeof p.env === "object" ? p.env : {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ), + })), + }; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5c81a726f..839f1066e 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5454,5 +5454,17 @@ "Cannot read Java formatter XML {path}: {error}": "无法读取 Java 格式化 XML {path}:{error}", "Java formatter configuration unavailable": "Java 格式化配置不可用", "Choose an Eclipse formatter XML file.": "请选择 Eclipse 格式化 XML 文件。", - "Not a formatter XML file: {path}": "不是格式化 XML 文件:{path}" + "Not a formatter XML file: {path}": "不是格式化 XML 文件:{path}", + "CMake profiles": "CMake 配置", + "CMake profile actions": "CMake 配置操作", + "No CMake profiles. Click + to add one.": "没有 CMake 配置。点击 + 添加。", + "Profile settings": "配置设置", + "Build type": "构建类型", + "Generator": "生成器", + "CMake arguments": "CMake 参数", + "Build directory": "构建目录", + "Build arguments": "构建参数", + "CMake default": "CMake 默认", + "Quote arguments containing spaces. Build arguments go to cmake --build; use -- before native build tool options.": "包含空格的参数请加引号。构建参数传给 cmake --build;原生构建工具参数前请加 --。", + "CMake profile {0}": "CMake 配置 {0}" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 7d74d60e9..3a418ebf5 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -80,6 +80,7 @@ const BUILT_AT_RUNTIME = new Set([]); /** Text that stays as written in every locale: names, code, formats, shortcuts. */ const UNTRANSLATED = new Set([ // Product names + "CMake", "Prettier", "IntelliJ IDEA", // Go environment variables and placeholder values