nros_node/names.rs
1//! ROS 2 name expansion + launch remap resolution (issue 0255 / phase-306 W3).
2//!
3//! The ONE resolution seam both entry-codegen twins funnel through: the Rust
4//! `ExecutorSink` (nros `node_runtime`) and the C-ABI registration paths
5//! (nros-c / nros-cpp via the executor-side remap table) call these functions
6//! so the two languages can never drift on name semantics.
7//!
8//! Scope: **basic name remapping only** — a rule matches when its expanded
9//! `from` equals the expanded source name (exact FQN comparison, no
10//! wildcards, no node-name prefixes). First matching rule wins.
11//!
12//! ## Two stores, one seam
13//!
14//! The rules themselves are held in two different places, and this module is
15//! the only thing both reach: the C and C++ roads store
16//! `executor::spin::RemapRule` in the executor's fixed table (filled by
17//! `Executor::declare_remap`, which the generated entry calls before the
18//! component configure), while the Rust road stores a bare
19//! `&[(&str, &str)]` on `nros_platform::RuntimeCtx` that `nros::main!` bakes
20//! per component and `ExecutorSink` threads to `create_entity`. Neither road
21//! reads the other's store. So anything about PRECEDENCE has to live here to
22//! be true of both — see [`resolve_name_layered`].
23
24/// Maximum bytes in a fully-qualified resolved entity name. Matches
25/// `nros::node_metadata::METADATA_STRING_CAPACITY` (the source-name bound
26/// entering the resolution seam).
27pub const MAX_RESOLVED_NAME_LEN: usize = 128;
28
29/// Owned storage for a resolved (fully-qualified) entity name.
30pub type ResolvedName = heapless::String<MAX_RESOLVED_NAME_LEN>;
31
32/// A node's fully-qualified name, joined from its two halves.
33///
34/// `/robot` + `talker` -> `/robot/talker`; the root namespace collapses, so
35/// `/` + `talker` is `/talker` and never `//talker`. The namespace may be given
36/// with or without a leading `/`, and empty means root — the same input rules
37/// [`expand_name`] already accepts, because this IS `expand_name`'s private-name
38/// branch under the name people look for.
39///
40/// # Why a named function for one call
41///
42/// The join is three lines and the tree already contained THREE of them —
43/// `Node::fully_qualified_name`, the inline build in
44/// `Executor::register_parameter_services`, and this branch — which is the
45/// "one shared helper, never a second spelling" rule pointing at itself. It is
46/// also not discoverable: nothing says that `expand_name("~", …)` is how you
47/// ask for a node's own name, so the fourth caller writes a fourth copy and
48/// gets the root case wrong. `//talker` is the failure this exists to stop.
49///
50/// The counterpart in ROS 2 is `rcl_node_get_fully_qualified_name` /
51/// `rclcpp::Node::get_fully_qualified_name`, but both answer only for the
52/// CALLER'S OWN node. Neither project has this one — the join for a node you
53/// DISCOVERED — because their graph APIs hand back two parallel arrays and
54/// leave correlating them to you. Ours delivers both halves in one visitor
55/// call, so the join is the only step left.
56#[allow(clippy::result_unit_err)] // as `expand_name`, which this is — no_std, no Error type
57pub fn fully_qualified_name(node_name: &str, namespace: &str) -> Result<ResolvedName, ()> {
58 expand_name("~", node_name, namespace)
59}
60
61/// Expand a source-level ROS name to its fully-qualified form (ROS 2 name
62/// expansion rules):
63///
64/// - `/absolute/name` → unchanged.
65/// - `~` / `~/rest` (private) → `/<ns>/<node>` / `/<ns>/<node>/rest`
66/// (`ns == "/"` collapses: `/<node>/rest`).
67/// - `relative/name` → `/<ns>/relative/name` (`ns == "/"` collapses:
68/// `/relative/name`).
69///
70/// `namespace` may be given with or without a leading `/`; empty means root.
71/// Errors on: empty `source`, a private name with an empty `node_name`, or a
72/// result exceeding [`MAX_RESOLVED_NAME_LEN`].
73#[allow(clippy::result_unit_err)] // matches the RuntimeCtx seam precedent — no_std, no Error type
74pub fn expand_name(source: &str, node_name: &str, namespace: &str) -> Result<ResolvedName, ()> {
75 if source.is_empty() {
76 return Err(());
77 }
78 let mut out = ResolvedName::new();
79 if source.starts_with('/') {
80 out.push_str(source)?;
81 return Ok(out);
82 }
83 push_namespace(&mut out, namespace)?;
84 if let Some(rest) = source.strip_prefix('~') {
85 if node_name.is_empty() {
86 return Err(());
87 }
88 out.push('/')?;
89 out.push_str(node_name)?;
90 let rest = rest.strip_prefix('/').unwrap_or(rest);
91 if !rest.is_empty() {
92 out.push('/')?;
93 out.push_str(rest)?;
94 }
95 } else {
96 out.push('/')?;
97 out.push_str(source)?;
98 }
99 Ok(out)
100}
101
102/// Append a normalized namespace: leading `/` guaranteed, trailing `/`
103/// stripped, root (`""` / `"/"`) appends nothing (the caller's `/` before the
104/// next segment is the only separator — the "ns=/ collapse").
105fn push_namespace(out: &mut ResolvedName, namespace: &str) -> Result<(), ()> {
106 let ns = namespace.trim_end_matches('/');
107 if ns.is_empty() {
108 return Ok(());
109 }
110 if !ns.starts_with('/') {
111 out.push('/')?;
112 }
113 out.push_str(ns)
114}
115
116/// The first rule in `rules` whose expanded `from` equals `expanded`, as its
117/// expanded `to`. A rule whose `from`/`to` fails to expand is skipped (never
118/// masks the name expansion itself).
119fn first_substitution<'a, I>(
120 expanded: &ResolvedName,
121 node_name: &str,
122 namespace: &str,
123 rules: I,
124) -> Option<ResolvedName>
125where
126 I: IntoIterator<Item = (&'a str, &'a str)>,
127{
128 for (from, to) in rules {
129 if let Ok(from_fq) = expand_name(from, node_name, namespace)
130 && &from_fq == expanded
131 && let Ok(to_fq) = expand_name(to, node_name, namespace)
132 {
133 return Some(to_fq);
134 }
135 }
136 None
137}
138
139/// Resolve a source-level entity name through launch remap rules: expand the
140/// source name AND each rule's `from` to fully-qualified form, compare exact,
141/// substitute the (also expanded) `to` of the first matching rule; no match →
142/// the expanded source name. A rule whose `from`/`to` fails to expand is
143/// skipped (never masks the name expansion itself).
144///
145/// This is the single-tier spelling: **everything passed here is
146/// AUTHORITATIVE**, and precedence among the rules is iterator order. When a
147/// caller has rules from more than one channel, it must say which channel
148/// outranks which — [`resolve_name_layered`] is that call, and it is the only
149/// place a precedence between channels may be expressed.
150#[allow(clippy::result_unit_err)]
151pub fn resolve_name<'a, I>(
152 source: &str,
153 node_name: &str,
154 namespace: &str,
155 remaps: I,
156) -> Result<ResolvedName, ()>
157where
158 I: IntoIterator<Item = (&'a str, &'a str)>,
159{
160 resolve_name_layered(
161 source,
162 node_name,
163 namespace,
164 remaps,
165 core::iter::empty::<(&str, &str)>(),
166 )
167}
168
169/// [`resolve_name`] with the rules split into two PROVENANCE TIERS: every
170/// `authoritative` rule is consulted before any `fallback` rule, whatever
171/// order either was declared in.
172///
173/// # Why a tier and not a field on the rule
174///
175/// A remap's provenance only ever decides one thing — which rule wins when two
176/// channels name the same entity — and the tree has no single rule TYPE to hang
177/// it on: the C and C++ roads store `executor::spin::RemapRule`, the Rust road
178/// stores a bare `&[(&str, &str)]` on `RuntimeCtx`. The one thing both roads
179/// share is this function, so the tier is an ARGUMENT here rather than a field
180/// there. That also makes the guarantee structural instead of documentary: a
181/// caller cannot promote a fallback rule by declaring it first, because the two
182/// tiers are different parameters and the loop below can never interleave them.
183///
184/// # The rule this encodes
185///
186/// **Launch is authoritative; anything else is a fallback for a name the launch
187/// projected no rule for.** That is RFC-0046's precedence for node identity
188/// (launch overrides the code-provided default) applied to the remap half of
189/// the same question, and it is rcl's own arrangement: `rcl_remap_topic_name`
190/// checks the node's LOCAL arguments (how a launch file or component container
191/// hands a node its rules) and only on no match the process's GLOBAL ones (the
192/// argv given to `rcl_init`) — Humble `rcl/remap.h`.
193///
194/// The producers: both roads pass their codegen-projected launch rules as
195/// `authoritative`; the fallback is the `--ros-args` parse
196/// ([`crate::ros_args`]), which `nros::init_with_args` carries on its
197/// `Context` into every executor that context creates.
198#[allow(clippy::result_unit_err)]
199pub fn resolve_name_layered<'a, 'b, A, F>(
200 source: &str,
201 node_name: &str,
202 namespace: &str,
203 authoritative: A,
204 fallback: F,
205) -> Result<ResolvedName, ()>
206where
207 A: IntoIterator<Item = (&'a str, &'a str)>,
208 F: IntoIterator<Item = (&'b str, &'b str)>,
209{
210 let expanded = expand_name(source, node_name, namespace)?;
211 Ok(
212 substitute_layered(&expanded, node_name, namespace, authoritative, fallback)
213 .unwrap_or(expanded),
214 )
215}
216
217fn substitute_layered<'a, 'b, A, F>(
218 expanded: &ResolvedName,
219 node_name: &str,
220 namespace: &str,
221 authoritative: A,
222 fallback: F,
223) -> Option<ResolvedName>
224where
225 A: IntoIterator<Item = (&'a str, &'a str)>,
226 F: IntoIterator<Item = (&'b str, &'b str)>,
227{
228 first_substitution(expanded, node_name, namespace, authoritative)
229 .or_else(|| first_substitution(expanded, node_name, namespace, fallback))
230}
231
232/// [`resolve_name_layered`]'s SUBSTITUTION, without its expansion: the name the
233/// first matching rule (authoritative tier first) sends `source` to, or `None`
234/// when no rule matches or `source` does not expand.
235///
236/// For a caller that has never expanded names and must not start: on `None` it
237/// passes `source` through exactly as before, so a node with no matching rule
238/// sees no change at all. `NodeHandle` is that caller.
239pub fn remap_layered<'a, 'b, A, F>(
240 source: &str,
241 node_name: &str,
242 namespace: &str,
243 authoritative: A,
244 fallback: F,
245) -> Option<ResolvedName>
246where
247 A: IntoIterator<Item = (&'a str, &'a str)>,
248 F: IntoIterator<Item = (&'b str, &'b str)>,
249{
250 let expanded = expand_name(source, node_name, namespace).ok()?;
251 substitute_layered(&expanded, node_name, namespace, authoritative, fallback)
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn absolute_passes_through() {
260 assert_eq!(
261 expand_name("/scan", "lidar", "/sensing").unwrap().as_str(),
262 "/scan"
263 );
264 }
265
266 #[test]
267 fn relative_expands_against_namespace() {
268 assert_eq!(
269 expand_name("scan", "lidar", "/sensing").unwrap().as_str(),
270 "/sensing/scan"
271 );
272 // Namespace without a leading slash normalizes.
273 assert_eq!(
274 expand_name("scan", "lidar", "sensing").unwrap().as_str(),
275 "/sensing/scan"
276 );
277 }
278
279 #[test]
280 fn relative_root_namespace_collapses() {
281 assert_eq!(expand_name("scan", "lidar", "/").unwrap().as_str(), "/scan");
282 assert_eq!(expand_name("scan", "lidar", "").unwrap().as_str(), "/scan");
283 }
284
285 #[test]
286 fn private_expands_against_node_fqn() {
287 assert_eq!(
288 expand_name("~/input/points", "filter", "/sensing")
289 .unwrap()
290 .as_str(),
291 "/sensing/filter/input/points"
292 );
293 // ns=/ collapse.
294 assert_eq!(
295 expand_name("~/status", "filter", "/").unwrap().as_str(),
296 "/filter/status"
297 );
298 // Bare `~` names the node itself.
299 assert_eq!(
300 expand_name("~", "filter", "/sensing").unwrap().as_str(),
301 "/sensing/filter"
302 );
303 }
304
305 #[test]
306 fn private_without_node_name_errors() {
307 assert!(expand_name("~/x", "", "/").is_err());
308 }
309
310 #[test]
311 fn empty_source_errors() {
312 assert!(expand_name("", "n", "/").is_err());
313 }
314
315 #[test]
316 fn oversized_result_errors() {
317 let long = "x".repeat(MAX_RESOLVED_NAME_LEN);
318 assert!(expand_name(&long, "n", "/ns").is_err());
319 }
320
321 #[test]
322 fn remap_matches_on_expanded_fqn() {
323 // `from` written relative, source written private — both expand to the
324 // same FQN, so the rule fires; `to` expands as well.
325 let remaps = [("filter/input/points", "/sensing/points_raw")];
326 let r = resolve_name("~/input/points", "filter", "/", remaps).unwrap();
327 assert_eq!(r.as_str(), "/sensing/points_raw");
328 }
329
330 #[test]
331 fn first_matching_rule_wins() {
332 let remaps = [("/a", "/first"), ("/a", "/second")];
333 assert_eq!(
334 resolve_name("/a", "n", "/", remaps).unwrap().as_str(),
335 "/first"
336 );
337 }
338
339 // ---------------------------------------------------------------------
340 // The precedence between remap CHANNELS (`resolve_name_layered`).
341 //
342 // `first_matching_rule_wins` above is precedence WITHIN one channel, and
343 // it is iterator order. These three pin the other axis: a fallback rule
344 // can never outrank an authoritative one, and declaration order cannot
345 // change that.
346 // ---------------------------------------------------------------------
347
348 #[test]
349 fn authoritative_rule_wins_over_a_fallback_for_the_same_name() {
350 // The mutation this refuses: concatenating the two channels into one
351 // flat iterator. Written that way the fallback rule is FIRST, so
352 // `first_matching_rule_wins` would hand it the name.
353 let launch = [("/a", "/from_launch")];
354 let argv = [("/a", "/from_argv")];
355 assert_eq!(
356 resolve_name_layered("/a", "n", "/", launch, argv)
357 .unwrap()
358 .as_str(),
359 "/from_launch"
360 );
361 // Same assertion with the flat concatenation spelled out, so the test
362 // says what it is protecting against rather than only what it wants.
363 assert_eq!(
364 resolve_name("/a", "n", "/", argv.into_iter().chain(launch))
365 .unwrap()
366 .as_str(),
367 "/from_argv"
368 );
369 }
370
371 #[test]
372 fn fallback_applies_where_the_authoritative_tier_has_no_rule() {
373 let launch = [("/b", "/from_launch")];
374 let argv = [("/a", "/from_argv")];
375 assert_eq!(
376 resolve_name_layered("/a", "n", "/", launch, argv)
377 .unwrap()
378 .as_str(),
379 "/from_argv"
380 );
381 }
382
383 #[test]
384 fn resolve_name_is_the_all_authoritative_case() {
385 let remaps = [("/a", "/one")];
386 assert_eq!(
387 resolve_name("/a", "n", "/", remaps).unwrap().as_str(),
388 resolve_name_layered("/a", "n", "/", remaps, core::iter::empty::<(&str, &str)>())
389 .unwrap()
390 .as_str(),
391 );
392 }
393
394 #[test]
395 fn no_match_returns_expansion() {
396 let remaps = [("/other", "/elsewhere")];
397 assert_eq!(
398 resolve_name("chatter", "n", "/ns", remaps)
399 .unwrap()
400 .as_str(),
401 "/ns/chatter"
402 );
403 }
404
405 #[test]
406 fn relative_to_expands_against_namespace() {
407 let remaps = [("chatter", "chatter_remapped")];
408 assert_eq!(
409 resolve_name("chatter", "n", "/ns", remaps)
410 .unwrap()
411 .as_str(),
412 "/ns/chatter_remapped"
413 );
414 }
415}