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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My only concern with this approach is that users may think that they can selectively include any msg package, when in reality they can only include the ones defined in our feature set.

Perhaps we should lean on 2 features, include_all and manual (or some other name) and then if the later feature is selected we read a predefined metadata field and only include those deps?

ros-env = { 
  version = "0.2", 
  default-features = false, 
  features = ["manual"] 
}

# Okay, this is close in name to the other metadata we look for. 
# Maybe not `includes` but you get the point
[package.metadata.ros-env]
includes = [
  "action_msgs", 
  "builtin_interfaces", 
  "rcl_interfaces", 
  "my_msg"
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, metadata approach won't work as downstream crates won't be able to read the metadata from consumer crates :/


```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).
202 changes: 127 additions & 75 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
),
}
}

Expand All @@ -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();
Expand Down Expand Up @@ -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<String> = 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<String> = 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<String> = included_packages
let include_all = feature_enabled("include_all");
let shim = use_ros_shim();
let mut included_packages: HashSet<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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);
}
}
}

Expand All @@ -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()
);
}
}

Expand Down Expand Up @@ -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();

Expand All @@ -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);
}