From 467dffdd2495219baad94f9b53f49222c22d0b2b Mon Sep 17 00:00:00 2001 From: Esteve Fernandez Date: Sun, 14 Jun 2026 16:46:28 +0200 Subject: [PATCH] feat: add selective interface package inclusion Signed-off-by: Esteve Fernandez --- Cargo.toml | 16 +++++ README.md | 32 ++++++++- build.rs | 202 +++++++++++++++++++++++++++++++++-------------------- 3 files changed, 172 insertions(+), 78 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e6ca41a..6e15b08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,5 +25,21 @@ cargo_toml = "0.22" ament_rs = "0.3" [features] +default = ["include_all"] +include_all = [] +action_msgs = [] +builtin_interfaces = [] +rcl_interfaces = [] +rosgraph_msgs = [] +unique_identifier_msgs = [] +example_interfaces = [] +test_msgs = [] +rclrs_core = [ + "action_msgs", + "builtin_interfaces", + "rcl_interfaces", + "rosgraph_msgs", + "unique_identifier_msgs", +] serde = ["dep:serde", "dep:serde-big-array", "rosidl_runtime_rs/serde"] use_ros_shim = ["rosidl_runtime_rs/use_ros_shim"] diff --git a/README.md b/README.md index 72ea29e..818f145 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,43 @@ use ros_env::shape_msgs::msg::Plane; ## Details Any Rust crate found in the `AMENT_PREFIX_PATH` environment variable, that has opted in, will be `include!()`d. +By default, `ros-env` keeps compatibility with existing users by including every discovered opt-in generated interface package: + +```toml +ros-env = "0.2" +``` + +Cargo feature selection is additive across the workspace: if any dependency enables a feature, it is unified for the final build. + +For selective inclusion, disable default features and opt into the package features you need. `rclrs_core` is a feature alias for the common core interface set used by `rclrs` (`action_msgs`, `builtin_interfaces`, `rcl_interfaces`, `rosgraph_msgs`, and `unique_identifier_msgs`): + +```toml +ros-env = { version = "0.2", default-features = false, features = ["rclrs_core"] } +``` + +If your crate or tests need extra generated interfaces, add them explicitly: + +```toml +ros-env = { version = "0.2", default-features = false, features = ["rclrs_core", "example_interfaces", "test_msgs"] } +``` + To opt in, the crate must have the following metadata present in the Cargo.toml ```toml [package.metadata.ros-env] include = true ``` +The selectable package feature list is fixed to: `action_msgs`, `builtin_interfaces`, `rcl_interfaces`, `rosgraph_msgs`, `unique_identifier_msgs`, `example_interfaces`, and `test_msgs`. + +Packages discovered in AMENT that are re-exported may depend on other generated packages via `*` Cargo dependencies. Those generated dependencies are included automatically when present, but non-generated dependencies remain normal Cargo dependencies and are not re-exported here. + +`use_ros_shim` forwards to `rosidl_runtime_rs/use_ros_shim` and lets selective builds skip selected packages that are missing from `AMENT_PREFIX_PATH`. It does not synthesize ROS interface modules. Crates that need no-ROS docs/builds should provide their own stubs (for example `rclrs/src/vendor.rs`). Without the shim, selective mode still requires the selected packages to exist and be opt-in. + By default, crates generated from `rosidl_generator_rs` opt in. ## Limitations - The [include!()](https://doc.rust-lang.org/std/macro.include.html) macro is literal text inclusion. As such, depending on the number of generated crates found in `AMENT_PREFIX_PATH`, the build times for this crate can be long. -- The dependencies of the included crates are not included. You cannot dynamically alter cargo dependencies through - anything other than features, and features need to be explicitly declared and enabled. As such, this crate must have - all expected dependencies itself (hence why this crate has a `serde` dependency for example). +- Non-generated Cargo dependencies of included crates are not added dynamically. Cargo dependencies can only be changed + through explicitly declared features, so this crate must declare expected non-generated dependencies itself (hence why + this crate has a `serde` dependency for example). diff --git a/build.rs b/build.rs index ee815e5..5ce8f8d 100644 --- a/build.rs +++ b/build.rs @@ -39,6 +39,18 @@ fn star_deps_to_use(manifest: &Manifest) -> String { .collect() } +fn feature_enabled(name: &str) -> bool { + env::var_os(format!( + "CARGO_FEATURE_{}", + name.to_ascii_uppercase().replace('-', "_") + )) + .is_some() +} + +fn use_ros_shim() -> bool { + feature_enabled("use_ros_shim") +} + fn crate_name_from_ament_package_dir(package_dir: &Path) -> &str { package_dir .parent() @@ -55,15 +67,11 @@ fn try_rustfmt(path: &Path) { .status() { Ok(status) if status.success() => {} - Ok(status) => { - println!("cargo:warning=rustfmt exited with status: {status}"); - } - Err(err) => { - println!( - "cargo:warning=failed to run rustfmt for {}: {err}", - path.display() - ); - } + Ok(status) => println!("cargo:warning=rustfmt exited with status: {status}"), + Err(err) => println!( + "cargo:warning=failed to run rustfmt for {}: {err}", + path.display() + ), } } @@ -72,7 +80,7 @@ fn main() { let ament_prefix_paths = get_search_paths().unwrap_or_default(); - // Re-export any generated interface crates that we find. AMENT_PREFIX_PATH + // Find any generated interface crates that we may re-export. AMENT_PREFIX_PATH // can contain overlays and underlays that provide the same package, so keep // the first provider according to the search path order. let mut discovered_packages = HashSet::new(); @@ -120,56 +128,111 @@ fn main() { }) .collect(); - // Include dependencies of exported packages too. Some distro packages export - // generated crates whose metadata is incomplete, but their generated Rust code - // still imports dependency packages through the ros-env crate root. - let mut included_packages: HashSet = export_candidates - .iter() - .filter(|path| is_marked_for_inclusion(path)) - .filter_map(|path| { - path.parent() - .map(crate_name_from_ament_package_dir) - .map(str::to_owned) - }) - .collect(); - let mut pending_packages: VecDeque = included_packages.iter().cloned().collect(); - - while let Some(package) = pending_packages.pop_front() { - let Some(dependencies) = dependencies_by_package.get(&package) else { - continue; - }; - - for dependency in dependencies { - if candidate_by_package.contains_key(dependency) - && included_packages.insert(dependency.clone()) - { - pending_packages.push_back(dependency.clone()); - } - } - } - - loop { - let invalid_packages: Vec = included_packages + let include_all = feature_enabled("include_all"); + let shim = use_ros_shim(); + let mut included_packages: HashSet = if include_all { + export_candidates .iter() - .filter(|package| { - dependencies_by_package - .get(*package) - .map(|dependencies| { - dependencies - .iter() - .any(|dependency| !included_packages.contains(dependency)) - }) - .unwrap_or(false) + .filter(|path| is_marked_for_inclusion(path)) + .filter_map(|path| { + path.parent() + .map(crate_name_from_ament_package_dir) + .map(str::to_owned) }) - .cloned() + .collect() + } else { + let selected_packages = [ + "action_msgs", + "builtin_interfaces", + "rcl_interfaces", + "rosgraph_msgs", + "unique_identifier_msgs", + "example_interfaces", + "test_msgs", + ]; + + let mut selected: HashSet = selected_packages + .iter() + .filter(|name| feature_enabled(name)) + .map(|name| (*name).to_owned()) .collect(); - if invalid_packages.is_empty() { - break; + for package in &selected { + if let Some(path) = candidate_by_package.get(package) { + if !is_marked_for_inclusion(path) { + panic!( + "selected package `{package}` is present but not opt-in for ros-env inclusion" + ); + } + } else if !shim { + panic!( + "selected package `{package}` not found in AMENT_PREFIX_PATH or not a generated interface package" + ); + } + } + + // Include dependencies of exported packages too. Some distro packages export + // generated crates whose metadata is incomplete, but their generated Rust code + // still imports dependency packages through the ros-env crate root. + let mut pending_packages: VecDeque = selected.iter().cloned().collect(); + while let Some(package) = pending_packages.pop_front() { + let Some(dependencies) = dependencies_by_package.get(&package) else { + continue; + }; + for dependency in dependencies { + if !candidate_by_package.contains_key(dependency) { + if shim { + continue; + } + panic!("selected package `{package}` depends on missing generated package `{dependency}`"); + } + if selected.insert(dependency.clone()) { + pending_packages.push_back(dependency.clone()); + } + } + } + selected + }; + + if include_all { + // Include dependencies of exported packages too. Some distro packages export + // generated crates whose metadata is incomplete, but their generated Rust code + // still imports dependency packages through the ros-env crate root. + let mut pending_packages: VecDeque = included_packages.iter().cloned().collect(); + while let Some(package) = pending_packages.pop_front() { + let Some(dependencies) = dependencies_by_package.get(&package) else { + continue; + }; + for dependency in dependencies { + if candidate_by_package.contains_key(dependency) + && included_packages.insert(dependency.clone()) + { + pending_packages.push_back(dependency.clone()); + } + } } - for package in invalid_packages { - included_packages.remove(&package); + loop { + let invalid_packages: Vec = included_packages + .iter() + .filter(|package| { + dependencies_by_package + .get(*package) + .map(|dependencies| { + dependencies + .iter() + .any(|dependency| !included_packages.contains(dependency)) + }) + .unwrap_or(false) + }) + .cloned() + .collect(); + if invalid_packages.is_empty() { + break; + } + for package in invalid_packages { + included_packages.remove(&package); + } } } @@ -186,10 +249,11 @@ fn main() { // Make sure the script re-runs if any of the sources we want to include change. for cargo_toml in &export_crate_tomls { println!("cargo:rerun-if-changed={}", cargo_toml.display()); - if let Some(package_dir) = cargo_toml.parent() { - let src_dir = package_dir.join("src"); - println!("cargo:rerun-if-changed={}", src_dir.display()); + println!( + "cargo:rerun-if-changed={}", + package_dir.join("src").display() + ); } } @@ -223,23 +287,12 @@ fn main() { // so that the generated code can be imported like `ros_env::std_msgs::msgs::Bool` .filter_map(|e| { let path = std::path::absolute(e.path()).expect("Failed to get absolute path for idiomatic module"); - path.file_stem() - .and_then(|stem| stem.to_str()) - .map(|stem| { - let idiomatic_path = path.to_string_lossy().replace('\\', "/"); - - let parent = path - .parent() - .expect("Failed to create rmw path"); - - let rmw_path = parent - .join(stem) - .join("rmw.rs") - .to_string_lossy() - .replace('\\', "/"); - - format!("pub mod {stem} {{ {dependencies} include!(\"{idiomatic_path}\"); pub mod rmw {{ {dependencies} include!(\"{rmw_path}\"); }} }}") - }) + path.file_stem().and_then(|stem| stem.to_str()).map(|stem| { + let idiomatic_path = path.to_string_lossy().replace('\\', "/"); + let parent = path.parent().expect("Failed to create rmw path"); + let rmw_path = parent.join(stem).join("rmw.rs").to_string_lossy().replace('\\', "/"); + format!("pub mod {stem} {{ {dependencies} include!(\"{idiomatic_path}\"); pub mod rmw {{ {dependencies} include!(\"{rmw_path}\"); }} }}") + }) }) .collect(); @@ -250,6 +303,5 @@ fn main() { let out_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set")).join("interfaces.rs"); fs::write(&out_path, content).expect("Failed to write interfaces.rs"); - try_rustfmt(&out_path); }