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 })}
+ />
+ )}
+
+ ))}
+
+
+
+ ({ 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