From a6c55741941df6f159ae4eb9eb6fd7ac34d083eb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:33:59 +0800 Subject: [PATCH] feat(build): Build Project for CMake projects (#1093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build › Build Project and Rebuild Project reported "no supported build system" in a CMake project. They now configure the build directory when needed and run cmake --build (--clean-first for Rebuild), with the directory and options of the first CMake profile, else a configured directory or build. GCC / Clang errors and warnings are reported at their source positions. Fixes #873 --- crates/ide-build/src/compile/mod.rs | 117 +++++++++++++++++++++++--- crates/ide-build/src/compile/parse.rs | 32 +++++++ crates/ide-build/src/kotlin_bridge.rs | 2 +- crates/ide-host/src/ops/build.rs | 10 ++- src/bindings.ts | 2 +- 5 files changed, 146 insertions(+), 17 deletions(-) diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 58aa7241..603d5b3f 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -10,6 +10,7 @@ pub use fingerprint::{LaunchFingerprint, launch_fingerprint}; use crate::process::{self, Stream}; use crate::{BuildModel, maven}; +use ide_core::settings::CMakeProfile; use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity}; use parse::{ColUnit, Format, OutputParser, RawMessage}; use serde::{Deserialize, Serialize}; @@ -41,6 +42,8 @@ pub enum BuildSystem { Go, #[serde(rename = "typescript")] TypeScript, + #[serde(rename = "cmake")] + CMake, } /// One command of a build. @@ -152,6 +155,8 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option(root: &'a Path, file: &'a Path) -> impl Iterator) -> IdeResult { +/// `cmake`: the CMake profiles of the settings (the first one is built), if any. +pub fn plan( + root: &Path, + model: &BuildModel, + kind: BuildKind, + file: Option<&Path>, + cmake: Option<&[CMakeProfile]>, +) -> IdeResult { let project_name = root.file_name().map_or_else(|| "project".to_owned(), |n| n.to_string_lossy().into_owned()); let module_dir = match file { Some(f) => { @@ -192,7 +204,9 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path let system = detect_dir(&dir, model, root) .or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None }) .or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven)) - .ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?; + .ok_or_else(|| { + IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found") + })?; let module = module_dir.as_deref().filter(|d| *d != root); let title = match module { Some(d) => format!( @@ -209,6 +223,7 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path // Go builds the file's package (a module holds many). BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind), BuildSystem::TypeScript => ts_steps(&dir, kind), + BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)), }; Ok(BuildPlan { system, title, steps }) } @@ -302,6 +317,50 @@ fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec { } } +/// CLion's Build Project for a CMake project: configures the build directory when it has no +/// `CMakeCache.txt` yet, then `cmake --build` (`--clean-first` to rebuild). The directory and +/// options come from the CMake profile; without one, a configured directory of the project +/// (the first by name), else `build`, as the generated run configurations use. +fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> Vec { + let build_dir = match profile { + Some(p) if !p.build_dir.trim().is_empty() => p.build_dir.trim().to_owned(), + Some(p) => format!("cmake-build-{}", p.build_type.to_lowercase()), + None => { + let mut configured: Vec = std::fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.path().join("CMakeCache.txt").is_file()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + configured.sort(); + configured.into_iter().next().unwrap_or_else(|| "build".into()) + } + }; + let build_type = profile.map_or("Debug", |p| p.build_type.as_str()); + let env = profile.map(|p| p.env.clone()).unwrap_or_default(); + let mut steps = Vec::new(); + if !dir.join(&build_dir).join("CMakeCache.txt").is_file() { + let mut configure = BuildStep::new("cmake", &["-S", ".", "-B", &build_dir], dir, Format::Gcc); + if let Some(p) = profile.filter(|p| !p.generator.trim().is_empty()) { + configure.args.extend(["-G".into(), p.generator.clone()]); + } + configure.args.push(format!("-DCMAKE_BUILD_TYPE={build_type}")); + configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into()); + configure.args.extend(profile.map(|p| p.cmake_args.clone()).unwrap_or_default()); + configure.env = env.clone(); + steps.push(configure); + } + let mut build = BuildStep::new("cmake", &["--build", &build_dir, "--config", build_type], dir, Format::Gcc); + if kind == BuildKind::Rebuild { + build.args.push("--clean-first".into()); + } + build.args.extend(profile.map(|p| p.build_args.clone()).unwrap_or_default()); + build.env = env; + steps.push(build); + steps +} + fn go_steps(dir: &Path, kind: BuildKind) -> Vec { let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir); let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) }; @@ -687,7 +746,7 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> Id } else { BuildKind::Build }; - let mut plan = plan(root, model, kind, Some(module))?; + let mut plan = plan(root, model, kind, Some(module), None)?; if plan.system == BuildSystem::Maven && !test { for step in &mut plan.steps { if let Some(goal) = step.args.last_mut() { @@ -796,12 +855,12 @@ mod tests { write(&root, "core/pom.xml", "core"); write(&root, "core/src/main/java/A.java", "class A {}"); let model = crate::load(&root, &tmp.path().join("cache"), None); - let p = plan(&root, &model, BuildKind::Build, None).unwrap(); + let p = plan(&root, &model, BuildKind::Build, None, None).unwrap(); assert_eq!(p.system, BuildSystem::Maven); assert_eq!(p.title, "demo"); assert_eq!(p.steps.len(), 1); assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "test-compile"]); - let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java"))).unwrap(); + let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap(); assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "-pl", "core", "-am", "clean", "test-compile"]); assert_eq!(p.steps[0].cwd, root); assert_eq!(p.title, "demo: build module 'core'"); @@ -810,24 +869,24 @@ mod tests { write(&g, "settings.gradle", "include 'app'"); write(&g, "app/build.gradle", "apply plugin: 'java'"); let model = crate::load(&g, &tmp.path().join("cache2"), None); - let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java"))).unwrap(); + let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java")), None).unwrap(); assert_eq!(p.system, BuildSystem::Gradle); assert_eq!(p.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]); assert_eq!( - plan(&g, &model, BuildKind::Artifacts, None).unwrap().steps[0].args, + plan(&g, &model, BuildKind::Artifacts, None, None).unwrap().steps[0].args, ["--console=plain", "assemble"] ); let c = tmp.path().join("c"); write(&c, "Cargo.toml", "[package]\nname='c'"); - let p = plan(&c, &BuildModel::default(), BuildKind::Rebuild, None).unwrap(); + let p = plan(&c, &BuildModel::default(), BuildKind::Rebuild, None, None).unwrap(); assert_eq!(p.system, BuildSystem::Cargo); assert_eq!(p.steps.len(), 2); assert_eq!(p.steps[0].args[0], "clean"); let go = tmp.path().join("go"); write(&go, "go.mod", "module x"); - let p = plan(&go, &BuildModel::default(), BuildKind::Build, Some(&go.join("pkg/a/a.go"))).unwrap(); + let p = plan(&go, &BuildModel::default(), BuildKind::Build, Some(&go.join("pkg/a/a.go")), None).unwrap(); assert_eq!(p.steps[0].args, ["build", "./pkg/a/..."]); assert_eq!(p.steps[0].cwd, go); @@ -835,19 +894,51 @@ mod tests { write(&ts, "package.json", r#"{"scripts":{"build":"vite build"}}"#); write(&ts, "tsconfig.json", "{}"); write(&ts, "pnpm-lock.yaml", ""); - let p = plan(&ts, &BuildModel::default(), BuildKind::Build, None).unwrap(); + let p = plan(&ts, &BuildModel::default(), BuildKind::Build, None, None).unwrap(); assert_eq!(p.steps[0].args, ["--noEmit", "--pretty", "false", "-p", "."]); - let p = plan(&ts, &BuildModel::default(), BuildKind::Artifacts, None).unwrap(); + let p = plan(&ts, &BuildModel::default(), BuildKind::Artifacts, None, None).unwrap(); assert_eq!( (p.steps[0].program.as_str(), p.steps[0].args.as_slice()), ("pnpm", &["run".to_owned(), "build".to_owned()][..]) ); + let cm = tmp.path().join("cm"); + write(&cm, "CMakeLists.txt", "project(app)"); + let args = |p: &BuildPlan| p.steps.iter().map(|s| s.args.join(" ")).collect::>(); + let p = plan(&cm, &BuildModel::default(), BuildKind::Build, None, None).unwrap(); + assert_eq!(p.system, BuildSystem::CMake); + assert_eq!( + args(&p), + [ + "-S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + "--build build --config Debug" + ], + "an unconfigured project is configured first" + ); + write(&cm, "cmake-build-release/CMakeCache.txt", ""); + let p = plan(&cm, &BuildModel::default(), BuildKind::Rebuild, None, None).unwrap(); + assert_eq!( + args(&p), + ["--build cmake-build-release --config Debug --clean-first"], + "a configured directory is reused" + ); + let profile = CMakeProfile { + build_type: "Release".into(), + generator: "Ninja".into(), + build_args: vec!["-j".into(), "8".into()], + ..CMakeProfile::default() + }; + let p = + plan(&cm, &BuildModel::default(), BuildKind::Build, None, Some(std::slice::from_ref(&profile))).unwrap(); + assert_eq!(args(&p), ["--build cmake-build-release --config Release -j 8"]); + let p = plan(&cm, &BuildModel::default(), BuildKind::Build, None, Some(&[CMakeProfile::default()])).unwrap(); + assert_eq!(p.steps[0].args[..4], ["-S", ".", "-B", "cmake-build-debug"]); + let siblings = tmp.path().join("siblings"); write(&siblings, "core/pom.xml", "core"); write(&siblings, "system/pom.xml", "system"); let model = crate::load(&siblings, &tmp.path().join("cache3"), None); - let p = plan(&siblings, &model, BuildKind::Build, None).unwrap(); + let p = plan(&siblings, &model, BuildKind::Build, None, None).unwrap(); assert_eq!(p.system, BuildSystem::Maven); assert_eq!( p.steps.iter().map(|s| s.cwd.clone()).collect::>(), @@ -856,7 +947,7 @@ mod tests { let none = tmp.path().join("none"); std::fs::create_dir_all(&none).unwrap(); - assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err()); + assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None, None).is_err()); } #[test] diff --git a/crates/ide-build/src/compile/parse.rs b/crates/ide-build/src/compile/parse.rs index e7e935cf..ffd97ff5 100644 --- a/crates/ide-build/src/compile/parse.rs +++ b/crates/ide-build/src/compile/parse.rs @@ -22,6 +22,8 @@ pub(crate) enum Format { Tsc, /// `go build`: `./a.go:12:5: msg`. Go, + /// GCC / Clang (through `cmake --build`): `src/a.cpp:12:5: error: msg`. + Gcc, } /// What a printed column counts. @@ -296,6 +298,17 @@ fn go_line(line: &str) -> Option { (!msg.is_empty()).then(|| RawMessage::new(Severity::Error, msg, ColUnit::Byte).at(path, ln, col)) } +/// A GCC / Clang diagnostic line; notes and the context lines after it are left as output. +fn gcc_line(line: &str) -> Option { + let (path, ln, col, rest) = path_line_col(line)?; + let rest = rest.strip_prefix(':')?.trim_start(); + let (severity, msg) = + [("fatal error:", Severity::Error), ("error:", Severity::Error), ("warning:", Severity::Warning)] + .into_iter() + .find_map(|(prefix, severity)| rest.strip_prefix(prefix).map(|m| (severity, m.trim())))?; + (!msg.is_empty()).then(|| RawMessage::new(severity, msg, ColUnit::Byte).at(path, ln, col)) +} + /// rustc's JSON diagnostic (`reason: compiler-message`) → message and its rendered text. fn cargo_json(v: &Value) -> Option<(Option, String)> { if v.get("reason")?.as_str()? != "compiler-message" { @@ -410,6 +423,7 @@ impl OutputParser { Format::Gradle => javac_header(&line).or_else(|| kotlin_line(&line)), Format::Tsc => tsc_line(&line), Format::Go => go_line(&line), + Format::Gcc => gcc_line(&line), Format::Cargo => None, }; if let Some(msg) = parsed { @@ -563,6 +577,24 @@ mod tests { assert_eq!(out[2].text, "Cannot find name 'foo'."); } + #[test] + fn gcc_errors_and_warnings() { + let out = parse( + Format::Gcc, + "[1/2] Building CXX object CMakeFiles/app.dir/main.cpp.o\n\ + /p/src/main.cpp:12:5: error: use of undeclared identifier 'foo'\n\ + \x20 12 | foo();\n\ + /p/src/util.h:3:10: warning: unused variable 'x' [-Wunused-variable]\n\ + /p/src/main.cpp:4:10: fatal error: 'missing.h' file not found\n\ + /p/src/main.cpp:2:1: note: in file included from here\n", + ); + assert_eq!(out.len(), 3, "{out:?}"); + assert_eq!(out[0].severity, Severity::Error); + assert_eq!(out[0].text, "use of undeclared identifier 'foo'"); + assert_eq!(out[1].severity, Severity::Warning); + assert_eq!(out[2].text, "'missing.h' file not found"); + } + #[test] fn go_errors() { let out = parse( diff --git a/crates/ide-build/src/kotlin_bridge.rs b/crates/ide-build/src/kotlin_bridge.rs index 150170ef..e5ea5f3a 100644 --- a/crates/ide-build/src/kotlin_bridge.rs +++ b/crates/ide-build/src/kotlin_bridge.rs @@ -81,7 +81,7 @@ pub fn build( cancel: &CancellationToken, ) -> IdeResult { std::fs::create_dir_all(generation)?; - let mut plan = compile::plan(root, model, BuildKind::Build, None)?; + let mut plan = compile::plan(root, model, BuildKind::Build, None, None)?; if plan.system != compile::BuildSystem::Maven { return Err(IdeError::invalid("Kotlin bridge requires a Maven build root")); } diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 95417fcd..d024fd0c 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -301,7 +301,7 @@ pub async fn build_run( let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective(); let member_cache = cache.join(crate::project::project_key(member)); let model = with_profiles(load(member, &member_cache, &settings), &member_cache); - let plan = compile::plan(member, &model, kind, file.as_deref())?; + let plan = compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?; match &mut combined { Some(combined) => combined.steps.extend(plan.steps), None => combined = Some(plan), @@ -311,7 +311,13 @@ pub async fn build_run( plan.title = workspace.name; plan } else { - compile::plan(&root, &with_profiles(load(&root, &cache, &settings), &cache), kind, file.as_deref())? + compile::plan( + &root, + &with_profiles(load(&root, &cache, &settings), &cache), + kind, + file.as_deref(), + settings.cmake.profiles.as_deref(), + )? }; let result = compile::run(&root, &plan, &token, &|e| { let _ = on_event.send(e); diff --git a/src/bindings.ts b/src/bindings.ts index 493c3be1..b5669afe 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1759,7 +1759,7 @@ export type BuildResult = { messages: BuildMessage[], }; -export type BuildSystem = "maven" | "gradle" | "cargo" | "go" | "typescript"; +export type BuildSystem = "maven" | "gradle" | "cargo" | "go" | "typescript" | "cmake"; export type BuildTool = "maven" | "gradle";