Skip to main content

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}