Skip to main content

nros_node/
ros_args.rs

1//! `--ros-args` parsing: the producer of the remap FALLBACK tier.
2//!
3//! [`crate::names::resolve_name_layered`] resolves a name through two tiers —
4//! the launch projection (authoritative) and a fallback. This module is the
5//! fallback's producer: it reads a process argument vector the way rcl does
6//! and yields the remap rules it carries.
7//!
8//! # Whose grammar
9//!
10//! rcl's, as documented in ROS 2 Humble's installed headers
11//! (`rcl/arguments.h`, `rcl_parse_arguments`; `rcl/remap.h`,
12//! `rcl_remap_topic_name`):
13//!
14//! * ROS arguments are scoped by `--ros-args` and end at `--` or at the end of
15//!   the vector. A vector may open several scopes. Everything outside a scope
16//!   is a non-ROS argument and is left alone.
17//! * `-r` / `--remap` is followed by ONE token, `[node:]from:=to`. A `node:`
18//!   prefix restricts the rule to the node of that NAME.
19//! * Rules are kept in argv order, and the first rule that matches a name wins
20//!   — `foo:=bar bar:=baz` sends `foo` to `bar`, never `baz`.
21//! * A `-r` followed by anything but a valid rule fails the parse.
22//! * `-p` / `--param` is followed by ONE token, `[node:]name:=value`, and
23//!   `--params-file` by a path. Both are parameter OVERRIDES (phase-482 W5,
24//!   RFC-0015 §9.4's POSIX row): reported through [`parse_ros_args_events`],
25//!   installed with `Executor::install_argv_params`, and applied when a node
26//!   declares the parameter. The file is the caller's to read — this module
27//!   has no file system — and [`parse_params_yaml`] reads its text.
28//!
29//! # What is REFUSED, and why each one
30//!
31//! Every flag this parser does not honour is a [`RosArgsError`], never a skip.
32//! Silently dropping a ROS argument is the defect `nros::init_with_args` was
33//! written to stop (a `-r chatter:=/other` that compiles and then publishes on
34//! `chatter`). rclcpp is no more lenient: `rclcpp::init` throws
35//! `UnknownROSArgsError` for any ROS argument rcl left unparsed.
36//!
37//! * `__node` / `__name` / `__ns` remaps — node IDENTITY. RFC-0046 makes the
38//!   launch the authority on a node's name and namespace, with the code's
39//!   default beneath it; an argv identity rung has not been ruled on.
40//! * `rostopic://` / `rosservice://` rules — kind-qualified remaps, which rcl
41//!   itself marks unfinished (`TODO(sloretz) add documentation about
42//!   rostopic:// when it is supported`).
43//! * `-e`/`--enclave`, `--log-level`, `--log-config-file`, the
44//!   `--{enable,disable}-*-logs` switches, and any other token inside a scope.
45//!
46//! # `no_std`, no allocation
47//!
48//! The parse borrows from the arguments it is given and reports each rule
49//! through a callback, so a board that reads its arguments from flash or a
50//! settings store (RFC-0015 §9.4's other rows) can drive it without an
51//! allocator.
52
53/// The `-r`/`--remap` rule one argument carried: `[node:]from:=to`, borrowed
54/// from the argument it was parsed out of.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct RemapArg<'a> {
57    /// `Some(name)` for a `name:from:=to` rule, which applies only to the node
58    /// of that name (rcl matches the node NAME, not its namespace). `None`
59    /// applies to every node in the process.
60    pub node: Option<&'a str>,
61    /// The name being remapped, as written (expanded at resolution time).
62    pub from: &'a str,
63    /// The replacement, as written (expanded at resolution time).
64    pub to: &'a str,
65}
66
67/// The `-p`/`--param` override one argument carried: `[node:]name:=value`,
68/// borrowed from the argument it was parsed out of. Also what
69/// [`parse_params_yaml`] reports for each value in a parameter file.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct ParamArg<'a> {
72    /// `Some(name)` applies only to the node of that NAME; `None` to every
73    /// node (a bare `-p`, or a parameter file's `/**` section).
74    pub node: Option<&'a str>,
75    /// The parameter name, dotted for a nested one (`gains.kp`).
76    pub name: &'a str,
77    /// The value as written. Typed when a node declares the parameter: by the
78    /// type of the default it declares, never guessed from the text alone.
79    pub value: &'a str,
80}
81
82/// One thing a `--ros-args` scope said, in argv order.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum RosArg<'a> {
85    /// `-r` / `--remap [node:]from:=to`.
86    Remap(RemapArg<'a>),
87    /// `-p` / `--param [node:]name:=value`.
88    Param(ParamArg<'a>),
89    /// `--params-file <path>`. The caller reads the file and hands its text to
90    /// [`parse_params_yaml`].
91    ParamsFile(&'a str),
92}
93
94/// Why an argument vector was refused. Each variant names the offending token.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum RosArgsError<'a> {
97    /// A recognised ROS flag nano-ros does not honour (see the module docs for
98    /// the reason each one is refused).
99    Unsupported {
100        /// The flag as written.
101        flag: &'a str,
102    },
103    /// A token inside a `--ros-args` scope that is not a ROS flag at all.
104    Unknown {
105        /// The token as written.
106        token: &'a str,
107    },
108    /// `-r`/`--remap` was the last argument.
109    MissingRule {
110        /// The flag as written.
111        flag: &'a str,
112    },
113    /// `-r`/`--remap` was followed by something that is not `[node:]from:=to`.
114    InvalidRule {
115        /// The rule as written.
116        rule: &'a str,
117    },
118    /// A node-identity remap (`__node`, `__name`, `__ns`, or any `__` rule).
119    IdentityRemap {
120        /// The rule as written.
121        rule: &'a str,
122    },
123    /// `-p`/`--param` or `--params-file` was the last argument.
124    MissingValue {
125        /// The flag as written.
126        flag: &'a str,
127    },
128    /// `-p`/`--param` was followed by something that is not `[node:]name:=value`.
129    InvalidParam {
130        /// The rule as written.
131        rule: &'a str,
132    },
133}
134
135impl core::fmt::Display for RosArgsError<'_> {
136    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
137        match self {
138            Self::Unsupported { flag } => write!(
139                f,
140                "`{flag}` is a ROS argument nano-ros does not honour here (enclaves and log \
141                 configuration have no counterpart; `-p` / `--params-file` are honoured only \
142                 where an executor can hold parameter overrides)"
143            ),
144            Self::Unknown { token } => {
145                write!(
146                    f,
147                    "`{token}` is not a ROS argument (rclcpp refuses it too, as UnknownROSArgsError)"
148                )
149            }
150            Self::MissingRule { flag } => {
151                write!(f, "`{flag}` must be followed by a rule `[node:]from:=to`")
152            }
153            Self::InvalidRule { rule } => {
154                write!(
155                    f,
156                    "`{rule}` is not a remap rule; expected `[node:]from:=to`"
157                )
158            }
159            Self::MissingValue { flag } => {
160                write!(f, "`{flag}` must be followed by a value")
161            }
162            Self::InvalidParam { rule } => write!(
163                f,
164                "`{rule}` is not a parameter override; expected `[node:]name:=value`"
165            ),
166            Self::IdentityRemap { rule } => write!(
167                f,
168                "`{rule}` remaps a node's identity; the launch file is the authority on a node's \
169                 name and namespace (RFC-0046), and an argv rung beneath it is not implemented"
170            ),
171        }
172    }
173}
174
175/// Flags rcl defines that this parser recognises and refuses. Kept as data so
176/// the refusal names the flag rather than calling it unknown.
177const UNSUPPORTED_FLAGS: &[&str] = &["-e", "--enclave", "--log-level", "--log-config-file"];
178
179/// rcl's log-switch flags are `--enable-<suffix>` / `--disable-<suffix>` over
180/// these three suffixes (`RCL_LOG_*_FLAG_SUFFIX`).
181const LOG_SWITCH_SUFFIXES: &[&str] = &["stdout-logs", "rosout-logs", "external-lib-logs"];
182
183fn is_log_switch(token: &str) -> bool {
184    let rest = token
185        .strip_prefix("--enable-")
186        .or_else(|| token.strip_prefix("--disable-"));
187    matches!(rest, Some(s) if LOG_SWITCH_SUFFIXES.contains(&s))
188}
189
190/// Parse one `[node:]from:=to` rule.
191pub fn parse_remap_rule(rule: &str) -> Result<RemapArg<'_>, RosArgsError<'_>> {
192    let invalid = RosArgsError::InvalidRule { rule };
193    let (lhs, to) = rule.split_once(":=").ok_or(invalid)?;
194    if lhs.contains("://") || to.contains("://") {
195        // `rostopic://` / `rosservice://` — rcl's own unfinished form.
196        return Err(RosArgsError::Unsupported { flag: rule });
197    }
198    let (node, from) = match lhs.split_once(':') {
199        Some((node, from)) => (Some(node), from),
200        None => (None, lhs),
201    };
202    if from.starts_with("__") {
203        return Err(RosArgsError::IdentityRemap { rule });
204    }
205    let bad = |s: &str| s.is_empty() || s.contains(':') || s.chars().any(char::is_whitespace);
206    if bad(from) || bad(to) {
207        return Err(invalid);
208    }
209    if let Some(n) = node
210        && (bad(n) || n.contains('/') || n.contains('~'))
211    {
212        return Err(invalid);
213    }
214    Ok(RemapArg { node, from, to })
215}
216
217/// Parse one `[node:]name:=value` override. An empty value is allowed (it is
218/// an empty string parameter); an empty or blank name is not.
219pub fn parse_param_rule(rule: &str) -> Result<ParamArg<'_>, RosArgsError<'_>> {
220    let invalid = RosArgsError::InvalidParam { rule };
221    let (lhs, value) = rule.split_once(":=").ok_or(invalid)?;
222    let (node, name) = match lhs.split_once(':') {
223        Some((node, name)) => (Some(node), name),
224        None => (None, lhs),
225    };
226    let bad = |s: &str| s.is_empty() || s.contains(':') || s.chars().any(char::is_whitespace);
227    if bad(name) {
228        return Err(invalid);
229    }
230    if let Some(n) = node
231        && (bad(n) || n.contains('/') || n.contains('~'))
232    {
233        return Err(invalid);
234    }
235    Ok(ParamArg { node, name, value })
236}
237
238/// Parse `args` the way `rcl_parse_arguments` does, reporting each remap rule
239/// in argv order through `on_remap`.
240///
241/// Returns the first refusal, naming its token; `on_remap` may have been
242/// called for rules before it, so a caller that must be all-or-nothing should
243/// collect and only commit on `Ok`. Arguments outside every `--ros-args` scope
244/// are ignored, so a vector with no `--ros-args` reports nothing and succeeds.
245///
246/// The remap-only parse: a `-p` / `--params-file` here is REFUSED as
247/// unsupported, because a caller that only installs remaps would otherwise
248/// drop it. A caller that can hold parameter overrides uses
249/// [`parse_ros_args_events`].
250pub fn parse_ros_args<'a, I, F>(args: I, mut on_remap: F) -> Result<(), RosArgsError<'a>>
251where
252    I: IntoIterator<Item = &'a str>,
253    F: FnMut(RemapArg<'a>),
254{
255    parse_impl(args, false, |a| {
256        if let RosArg::Remap(r) = a {
257            on_remap(r);
258        }
259    })
260}
261
262/// Parse `args` like [`parse_ros_args`], reporting remaps AND parameter
263/// overrides (`-p`, `--params-file`) in argv order — phase-482 W5.
264pub fn parse_ros_args_events<'a, I, F>(args: I, on_arg: F) -> Result<(), RosArgsError<'a>>
265where
266    I: IntoIterator<Item = &'a str>,
267    F: FnMut(RosArg<'a>),
268{
269    parse_impl(args, true, on_arg)
270}
271
272fn parse_impl<'a, I, F>(args: I, params: bool, mut on_arg: F) -> Result<(), RosArgsError<'a>>
273where
274    I: IntoIterator<Item = &'a str>,
275    F: FnMut(RosArg<'a>),
276{
277    let mut in_scope = false;
278    let mut it = args.into_iter();
279    while let Some(arg) = it.next() {
280        if arg == "--ros-args" {
281            in_scope = true;
282            continue;
283        }
284        if !in_scope {
285            continue;
286        }
287        match arg {
288            "--" => in_scope = false,
289            "-r" | "--remap" => {
290                let rule = it.next().ok_or(RosArgsError::MissingRule { flag: arg })?;
291                on_arg(RosArg::Remap(parse_remap_rule(rule)?));
292            }
293            "-p" | "--param" | "--params-file" if !params => {
294                return Err(RosArgsError::Unsupported { flag: arg });
295            }
296            "-p" | "--param" => {
297                let rule = it.next().ok_or(RosArgsError::MissingValue { flag: arg })?;
298                on_arg(RosArg::Param(parse_param_rule(rule)?));
299            }
300            "--params-file" => {
301                let path = it.next().ok_or(RosArgsError::MissingValue { flag: arg })?;
302                on_arg(RosArg::ParamsFile(path));
303            }
304            flag if UNSUPPORTED_FLAGS.contains(&flag) || is_log_switch(flag) => {
305                return Err(RosArgsError::Unsupported { flag });
306            }
307            token => return Err(RosArgsError::Unknown { token }),
308        }
309    }
310    Ok(())
311}
312
313/// Why a parameter file was refused: the 1-based line, the line as written,
314/// and the reason. Every construct the subset does not read is a refusal,
315/// never a skip — a value silently dropped from a file is the same "compiles
316/// and differs" the argv parse exists to stop.
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub struct ParamsYamlError<'t> {
319    /// 1-based line number.
320    pub line: usize,
321    /// The offending line, trimmed.
322    pub text: &'t str,
323    /// What is wrong with it.
324    pub reason: &'static str,
325}
326
327impl core::fmt::Display for ParamsYamlError<'_> {
328    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
329        write!(f, "line {}: {}: `{}`", self.line, self.reason, self.text)
330    }
331}
332
333/// The longest dotted name [`parse_params_yaml`] builds (`group.sub.name`).
334pub const MAX_PARAMS_FILE_NAME_LEN: usize = 128;
335const MAX_PARAMS_FILE_DEPTH: usize = 8;
336
337/// Read a ROS 2 parameter file's TEXT — the `--params-file` format — and
338/// report each value through `on_param`, in file order.
339///
340/// The subset rcl's files use and nothing more:
341///
342/// ```yaml
343/// /**:                     # every node; or `talker` / `/ns/talker`
344///   ros__parameters:
345///     rate_hz: 10
346///     gains:
347///       kp: 1.5            # reported as `gains.kp`
348///     frame: "base_link"
349/// ```
350///
351/// A node key matches by its last segment, the node NAME, as `-p node:` and
352/// `-r node:` do; its namespace is not compared. Comments, blank lines and
353/// `---` are skipped. Refused, each with its line: tabs used as indentation,
354/// sequences and flow collections (array parameters are not read from a file
355/// yet), block scalars, anchors, wildcards other than `/**`, and any value not
356/// under `<node>: ros__parameters:`.
357///
358/// `no_std` and allocation-free: the dotted name is built in a fixed buffer
359/// and lent to `on_param` for the call.
360pub fn parse_params_yaml<'t, F>(text: &'t str, mut on_param: F) -> Result<(), ParamsYamlError<'t>>
361where
362    F: for<'n> FnMut(ParamArg<'n>),
363{
364    let mut stack: heapless::Vec<(usize, &'t str), MAX_PARAMS_FILE_DEPTH> = heapless::Vec::new();
365    for (i, raw) in text.lines().enumerate() {
366        let err = |reason| ParamsYamlError {
367            line: i + 1,
368            text: raw.trim(),
369            reason,
370        };
371        let content = yaml_strip_comment(raw);
372        let body = content.trim();
373        if body.is_empty() || body == "---" || body == "..." {
374            continue;
375        }
376        let lead = &content[..content.len() - content.trim_start().len()];
377        if lead.contains('\t') {
378            return Err(err("a tab is not YAML indentation"));
379        }
380        let indent = lead.len();
381        if body == "-" || body.starts_with("- ") {
382            return Err(err("a sequence is not read from a parameter file"));
383        }
384        let (key, rest) =
385            yaml_split_key(body).ok_or_else(|| err("expected `key:` or `key: value`"))?;
386        let key = yaml_unquote(key.trim());
387        while let Some(&(ind, _)) = stack.last() {
388            if ind >= indent {
389                stack.pop();
390            } else {
391                break;
392            }
393        }
394        let rest = rest.trim();
395        if rest.is_empty() {
396            stack
397                .push((indent, key))
398                .map_err(|_| err("nested deeper than a parameter file needs"))?;
399            continue;
400        }
401        if stack.len() < 2 {
402            return Err(err(
403                "a value must sit under `<node>:` then `ros__parameters:`",
404            ));
405        }
406        if stack[1].1 != "ros__parameters" {
407            return Err(err("the level under a node key must be `ros__parameters`"));
408        }
409        if rest.starts_with(['[', '{', '|', '>', '&', '*', '!']) {
410            return Err(err(
411                "an array, inline map, block scalar, anchor or tag is not read from a parameter file",
412            ));
413        }
414        let node = yaml_node_selector(stack[0].1)
415            .ok_or_else(|| err("only the `/**` wildcard is supported"))?;
416        let mut name: heapless::String<MAX_PARAMS_FILE_NAME_LEN> = heapless::String::new();
417        for &(_, group) in &stack[2..] {
418            name.push_str(group)
419                .and_then(|_| name.push('.'))
420                .map_err(|_| err("parameter name longer than MAX_PARAMS_FILE_NAME_LEN"))?;
421        }
422        name.push_str(key)
423            .map_err(|_| err("parameter name longer than MAX_PARAMS_FILE_NAME_LEN"))?;
424        on_param(ParamArg {
425            node,
426            name: &name,
427            value: yaml_unquote(rest),
428        });
429    }
430    Ok(())
431}
432
433/// The line up to a `#` comment that is not inside quotes.
434fn yaml_strip_comment(line: &str) -> &str {
435    let mut quote = None;
436    let mut prev_space = true;
437    for (i, c) in line.char_indices() {
438        match (quote, c) {
439            (None, '"' | '\'') => quote = Some(c),
440            (Some(q), c) if c == q => quote = None,
441            (None, '#') if prev_space => return &line[..i],
442            _ => {}
443        }
444        prev_space = c.is_whitespace();
445    }
446    line
447}
448
449/// `key: rest` / `key:` — the first `:` outside quotes followed by a space or
450/// the end of the line.
451fn yaml_split_key(body: &str) -> Option<(&str, &str)> {
452    let mut quote = None;
453    let bytes = body.as_bytes();
454    for (i, c) in body.char_indices() {
455        match (quote, c) {
456            (None, '"' | '\'') => quote = Some(c),
457            (Some(q), c) if c == q => quote = None,
458            (None, ':') if i + 1 == bytes.len() || bytes[i + 1] == b' ' => {
459                return Some((&body[..i], &body[i + 1..]));
460            }
461            _ => {}
462        }
463    }
464    None
465}
466
467fn yaml_unquote(s: &str) -> &str {
468    let b = s.as_bytes();
469    if b.len() >= 2 && (b[0] == b'"' || b[0] == b'\'') && b[b.len() - 1] == b[0] {
470        &s[1..s.len() - 1]
471    } else {
472        s
473    }
474}
475
476/// `None` for every node (`/**`, `**`), `Some(name)` for a node key, or a
477/// refusal (`Err` as `None` of the outer option) for another wildcard.
478#[allow(clippy::option_option)]
479fn yaml_node_selector(key: &str) -> Option<Option<&str>> {
480    match key {
481        "/**" | "**" => Some(None),
482        k if k.contains('*') => None,
483        k => Some(Some(k.rsplit('/').next().unwrap_or(k))),
484    }
485}
486
487#[cfg(any(has_rmw, test))]
488/// Install parsed `-r` rules in `executor`'s remap table as its FALLBACK tier.
489///
490/// They then reach every entity the executor creates on every road —
491/// `Executor::resolve_entity_name_for` (C, C++), the Rust component sink
492/// (through [`argv_fallback`]) and `Executor::create_node`'s handle — and in
493/// each, a launch rule for the same name still wins.
494///
495/// All or nothing: on failure nothing is installed and the rule that did not
496/// fit is returned (a full table — `MAX_REMAPS` slots are shared with launch
497/// rules — or a name past its slot's length). Silently installing a prefix of
498/// the rules would be a partial version of the drop this exists to prevent.
499pub fn install_argv_remaps<'r, I>(
500    executor: &mut crate::executor::Executor<'_>,
501    rules: I,
502) -> Result<(), RemapArg<'r>>
503where
504    I: IntoIterator<Item = RemapArg<'r>>,
505    I::IntoIter: Clone,
506{
507    use crate::executor::spin::{RemapRule, RemapTier};
508    let rules = rules.into_iter();
509    let start = executor.remap_len;
510    for (i, r) in rules.clone().enumerate() {
511        let mut rule = RemapRule {
512            node_name: heapless::String::new(),
513            namespace: heapless::String::new(),
514            from: heapless::String::new(),
515            to: heapless::String::new(),
516            tier: RemapTier::Argv,
517        };
518        let fits = rule.node_name.push_str(r.node.unwrap_or("")).is_ok()
519            && rule.from.push_str(r.from).is_ok()
520            && rule.to.push_str(r.to).is_ok();
521        let slot = executor.remap_table.get_mut(start + i);
522        match (fits, slot) {
523            (true, Some(slot)) => *slot = Some(rule),
524            _ => {
525                for s in &mut executor.remap_table[start..start + i] {
526                    *s = None;
527                }
528                return Err(r);
529            }
530        }
531    }
532    executor.remap_len = start + rules.count();
533    Ok(())
534}
535
536#[cfg(any(has_rmw, test))]
537/// Why [`apply_ros_args`] refused an argument vector.
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub enum ApplyError<'a> {
540    /// The parse refused a token ([`parse_ros_args`]).
541    Refused(RosArgsError<'a>),
542    /// The rule did not fit: the remap table is full (its slots are shared
543    /// with launch rules), or a name is longer than its slot.
544    DoesNotFit(RemapArg<'a>),
545}
546
547#[cfg(any(has_rmw, test))]
548impl core::fmt::Display for ApplyError<'_> {
549    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
550        match self {
551            Self::Refused(e) => e.fmt(f),
552            Self::DoesNotFit(r) => write!(
553                f,
554                "remap `{}:={}` does not fit: the executor's remap table holds {} rules, \
555                 shared with the launch file's; `from`/`to` hold {} bytes, a `node:` \
556                 prefix 64",
557                r.from,
558                r.to,
559                crate::executor::spin::MAX_REMAPS,
560                crate::names::MAX_RESOLVED_NAME_LEN,
561            ),
562        }
563    }
564}
565
566#[cfg(any(has_rmw, test))]
567/// Parse `args` and, given an executor, install its `-r` rules as the
568/// FALLBACK tier — the whole of "honour `--ros-args`" for a caller with no
569/// allocator (the C++ ABI, `nros_cpp_install_argv_remaps`).
570///
571/// With `executor = None` it only VALIDATES, so a caller can refuse a bad
572/// vector before opening a session at all. All or nothing either way: on any
573/// error nothing is installed. Returns the number of rules installed (or that
574/// would be).
575pub fn apply_ros_args<'a, I>(
576    executor: Option<&mut crate::executor::Executor<'_>>,
577    args: I,
578) -> Result<usize, ApplyError<'a>>
579where
580    I: IntoIterator<Item = &'a str>,
581{
582    const N: usize = crate::executor::spin::MAX_REMAPS;
583    let mut rules: [Option<RemapArg<'a>>; N] = [None; N];
584    let mut n = 0usize;
585    let mut overflow = None;
586    parse_ros_args(args, |r| {
587        if n < N {
588            rules[n] = Some(r);
589            n += 1;
590        } else if overflow.is_none() {
591            overflow = Some(r);
592        }
593    })
594    .map_err(ApplyError::Refused)?;
595    if let Some(r) = overflow {
596        return Err(ApplyError::DoesNotFit(r));
597    }
598    if let Some(executor) = executor {
599        install_argv_remaps(executor, rules[..n].iter().flatten().copied())
600            .map_err(ApplyError::DoesNotFit)?;
601    }
602    Ok(n)
603}
604
605#[cfg(all(any(has_rmw, test), feature = "param-store", feature = "alloc"))]
606/// Why [`apply_ros_args_with_params`] refused an argument vector.
607#[derive(Debug, Clone)]
608pub enum ApplyParamsError<'a> {
609    /// The remap half refused it, exactly as [`apply_ros_args`] would.
610    Remaps(ApplyError<'a>),
611    /// `--params-file <path>` could not be read; the reason is the reader's.
612    Unreadable {
613        /// The path as written.
614        path: &'a str,
615        /// What the reader said.
616        why: alloc::string::String,
617    },
618    /// The file was read and its text refused by [`parse_params_yaml`].
619    BadFile {
620        /// The path as written.
621        path: &'a str,
622        /// `line N: reason: `text``.
623        why: alloc::string::String,
624    },
625    /// The parameter store could not be allocated (issue 1706).
626    NoStore,
627}
628
629#[cfg(all(any(has_rmw, test), feature = "param-store", feature = "alloc"))]
630impl core::fmt::Display for ApplyParamsError<'_> {
631    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
632        match self {
633            Self::Remaps(e) => e.fmt(f),
634            Self::Unreadable { path, why } => {
635                write!(f, "`--params-file {path}` cannot be read: {why}")
636            }
637            Self::BadFile { path, why } => write!(f, "`--params-file {path}`, {why}"),
638            Self::NoStore => f.write_str(
639                "the parameter store could not be allocated, so the parameter overrides have \
640                 nowhere to go",
641            ),
642        }
643    }
644}
645
646#[cfg(all(any(has_rmw, test), feature = "param-store", feature = "alloc"))]
647/// [`apply_ros_args`], plus the parameter half — phase-482 W5.
648///
649/// `-r` rules are installed as [`apply_ros_args`] installs them. `-p` overrides
650/// and every `--params-file` (read through `read_file`, which is the caller's:
651/// this crate has no file system) are installed with
652/// `Executor::install_argv_params`, in argv order, so a later value wins.
653///
654/// With `executor = None` it only VALIDATES — files are still read and parsed,
655/// so a bad file is refused before a session opens. All or nothing: on any
656/// error nothing is installed.
657pub fn apply_ros_args_with_params<'a, I, R>(
658    executor: Option<&mut crate::executor::Executor<'_>>,
659    args: I,
660    mut read_file: R,
661) -> Result<usize, ApplyParamsError<'a>>
662where
663    I: IntoIterator<Item = &'a str> + Clone,
664    R: FnMut(&str) -> Result<alloc::string::String, alloc::string::String>,
665{
666    use alloc::string::String;
667    // Pass 1: the remaps and the refusals, through the remap-only grammar's
668    // twin so a refused token reports exactly as before.
669    let mut params: alloc::vec::Vec<(Option<String>, String, String)> = alloc::vec::Vec::new();
670    let mut files: alloc::vec::Vec<&'a str> = alloc::vec::Vec::new();
671    let mut order: alloc::vec::Vec<Result<usize, usize>> = alloc::vec::Vec::new();
672    parse_ros_args_events(args.clone(), |a| match a {
673        RosArg::Param(p) => {
674            order.push(Ok(params.len()));
675            params.push((
676                p.node.map(String::from),
677                String::from(p.name),
678                String::from(p.value),
679            ));
680        }
681        RosArg::ParamsFile(path) => {
682            order.push(Err(files.len()));
683            files.push(path);
684        }
685        RosArg::Remap(_) => {}
686    })
687    .map_err(|e| ApplyParamsError::Remaps(ApplyError::Refused(e)))?;
688    // Read and parse every file before installing anything.
689    let mut file_params: alloc::vec::Vec<alloc::vec::Vec<(Option<String>, String, String)>> =
690        alloc::vec::Vec::new();
691    for &path in &files {
692        let text = read_file(path).map_err(|why| ApplyParamsError::Unreadable { path, why })?;
693        let mut got = alloc::vec::Vec::new();
694        parse_params_yaml(&text, |p| {
695            got.push((
696                p.node.map(String::from),
697                String::from(p.name),
698                String::from(p.value),
699            ))
700        })
701        .map_err(|e| ApplyParamsError::BadFile {
702            path,
703            why: alloc::format!("{e}"),
704        })?;
705        file_params.push(got);
706    }
707    // The remap half, minus the parameter flags it would refuse.
708    let remap_args = args.into_iter().scan(false, |skip, a| {
709        if core::mem::take(skip) {
710            return Some(None);
711        }
712        if matches!(a, "-p" | "--param" | "--params-file") {
713            *skip = true;
714            return Some(None);
715        }
716        Some(Some(a))
717    });
718    let remap_args: alloc::vec::Vec<&'a str> = remap_args.flatten().collect();
719    let Some(executor) = executor else {
720        let n =
721            apply_ros_args(None, remap_args.iter().copied()).map_err(ApplyParamsError::Remaps)?;
722        return Ok(n + params.len() + file_params.iter().map(alloc::vec::Vec::len).sum::<usize>());
723    };
724    // Commit in the order that keeps it all-or-nothing: make sure the store
725    // exists (the one way the parameter half can fail), then the remaps (which
726    // are all-or-nothing on their own), then the parameters, which can no
727    // longer fail.
728    executor
729        .install_argv_params(core::iter::empty())
730        .map_err(|_| ApplyParamsError::NoStore)?;
731    let n = apply_ros_args(Some(&mut *executor), remap_args.iter().copied())
732        .map_err(ApplyParamsError::Remaps)?;
733    let mut flat: alloc::vec::Vec<&(Option<String>, String, String)> = alloc::vec::Vec::new();
734    for slot in &order {
735        match *slot {
736            Ok(i) => flat.push(&params[i]),
737            Err(f) => flat.extend(file_params[f].iter()),
738        }
739    }
740    let m = executor
741        .install_argv_params(flat.iter().map(|(node, name, value)| ParamArg {
742            node: node.as_deref(),
743            name,
744            value,
745        }))
746        .map_err(|_| ApplyParamsError::NoStore)?;
747    Ok(n + m)
748}
749
750#[cfg(any(has_rmw, test))]
751/// The installed argv rules that apply to the node named `node_name` (in
752/// `namespace`), as a fallback tier for [`crate::names::resolve_name_layered`].
753///
754/// For a resolver that keeps its AUTHORITATIVE rules somewhere other than the
755/// executor's table — the Rust component sink holds its launch rules as a
756/// slice on `RuntimeCtx` — and so cannot use
757/// `Executor::resolve_entity_name_for` directly.
758pub fn argv_fallback<'e>(
759    executor: &'e crate::executor::Executor<'_>,
760    node_name: &'e str,
761    namespace: &'e str,
762) -> impl Iterator<Item = (&'e str, &'e str)> + 'e {
763    use crate::executor::spin::{RemapTier, tier_rules};
764    tier_rules(
765        &executor.remap_table[..executor.remap_len],
766        RemapTier::Argv,
767        node_name,
768        namespace,
769    )
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775
776    fn rules<'a>(args: &[&'a str]) -> Result<heapless::Vec<RemapArg<'a>, 8>, RosArgsError<'a>> {
777        let mut out = heapless::Vec::new();
778        parse_ros_args(args.iter().copied(), |r| out.push(r).unwrap())?;
779        Ok(out)
780    }
781
782    fn rule<'a>(node: Option<&'a str>, from: &'a str, to: &'a str) -> RemapArg<'a> {
783        RemapArg { node, from, to }
784    }
785
786    #[test]
787    fn no_scope_reports_nothing_and_leaves_user_arguments_alone() {
788        assert!(
789            rules(&["prog", "-r", "a:=b", "--verbose"])
790                .unwrap()
791                .is_empty()
792        );
793        assert!(rules(&[]).unwrap().is_empty());
794    }
795
796    #[test]
797    fn short_and_long_remap_flags_keep_argv_order() {
798        let got = rules(&[
799            "prog",
800            "--ros-args",
801            "-r",
802            "foo:=bar",
803            "--remap",
804            "bar:=baz",
805        ])
806        .unwrap();
807        assert_eq!(
808            got.as_slice(),
809            &[rule(None, "foo", "bar"), rule(None, "bar", "baz")]
810        );
811    }
812
813    #[test]
814    fn a_node_prefix_scopes_the_rule() {
815        let got = rules(&["--ros-args", "-r", "alice:foo:=~/bar"]).unwrap();
816        assert_eq!(got.as_slice(), &[rule(Some("alice"), "foo", "~/bar")]);
817    }
818
819    #[test]
820    fn double_dash_closes_the_scope_and_a_second_scope_reopens_it() {
821        let got = rules(&[
822            "--ros-args",
823            "-r",
824            "a:=b",
825            "--",
826            "-r",
827            "user:=arg",
828            "--ros-args",
829            "-r",
830            "c:=d",
831        ])
832        .unwrap();
833        assert_eq!(
834            got.as_slice(),
835            &[rule(None, "a", "b"), rule(None, "c", "d")]
836        );
837    }
838
839    #[test]
840    fn parameter_overrides_are_refused_by_name() {
841        for flag in ["-p", "--param", "--params-file"] {
842            assert_eq!(
843                rules(&["--ros-args", flag, "x:=1"]),
844                Err(RosArgsError::Unsupported { flag })
845            );
846        }
847    }
848
849    #[test]
850    fn enclave_and_log_flags_are_refused_by_name() {
851        for flag in [
852            "-e",
853            "--enclave",
854            "--log-level",
855            "--log-config-file",
856            "--disable-rosout-logs",
857        ] {
858            assert_eq!(
859                rules(&["--ros-args", flag]),
860                Err(RosArgsError::Unsupported { flag })
861            );
862        }
863    }
864
865    #[test]
866    fn an_unknown_token_inside_a_scope_is_refused_not_skipped() {
867        assert_eq!(
868            rules(&["--ros-args", "-r", "a:=b", "--frobnicate"]),
869            Err(RosArgsError::Unknown {
870                token: "--frobnicate"
871            })
872        );
873        // A look-alike of a real log switch is still unknown, not a switch.
874        assert_eq!(
875            rules(&["--ros-args", "--enable-everything"]),
876            Err(RosArgsError::Unknown {
877                token: "--enable-everything"
878            })
879        );
880    }
881
882    #[test]
883    fn a_remap_flag_needs_a_valid_rule_after_it() {
884        assert_eq!(
885            rules(&["--ros-args", "-r"]),
886            Err(RosArgsError::MissingRule { flag: "-r" })
887        );
888        for bad in [
889            "nocolon", ":=b", "a:=", "a b:=c", ":a:=b", "n/s:a:=b", "a:=b:c",
890        ] {
891            assert_eq!(
892                rules(&["--ros-args", "--remap", bad]),
893                Err(RosArgsError::InvalidRule { rule: bad }),
894                "{bad}"
895            );
896        }
897    }
898
899    #[test]
900    fn identity_remaps_are_refused() {
901        for r in ["__node:=x", "__name:=x", "__ns:=/x", "talker:__ns:=/x"] {
902            assert_eq!(
903                rules(&["--ros-args", "-r", r]),
904                Err(RosArgsError::IdentityRemap { rule: r })
905            );
906        }
907    }
908
909    // ---------------------------------------------------------------------
910    // The producer feeding the seam: real argv -> parse -> install -> resolve.
911    //
912    // `names.rs`'s precedence tests hand `resolve_name_layered` two arrays;
913    // these run the whole chain a hosted `init_with_args` caller gets, against
914    // the executor's own table and resolver.
915    // ---------------------------------------------------------------------
916
917    /// Same gate as `executor::node`'s tests: `Executor::from_session` over a
918    /// `MockSession` needs `std` and no linked backend.
919    #[cfg(all(feature = "std", not(feature = "rmw-cffi")))]
920    mod chain {
921        use super::super::*;
922        extern crate std;
923        use crate::{executor::Executor, mock::MockSession};
924
925        fn executor() -> Executor<'static> {
926            Executor::from_session(MockSession::new())
927        }
928
929        /// Parse `argv` and install what it carries, the way
930        /// `nros::Context::create_executor` does.
931        fn install_argv(exec: &mut Executor<'_>, argv: &[&'static str]) {
932            let mut parsed: std::vec::Vec<RemapArg<'static>> = std::vec::Vec::new();
933            parse_ros_args(argv.iter().copied(), |r| parsed.push(r)).expect("argv parses");
934            install_argv_remaps(exec, parsed.iter().copied()).expect("rules fit");
935        }
936
937        fn resolve(exec: &Executor<'_>, node: &str, source: &str) -> std::string::String {
938            std::string::String::from(
939                exec.resolve_entity_name_for(node, "/", source)
940                    .unwrap()
941                    .as_str(),
942            )
943        }
944
945        #[test]
946        fn a_launch_rule_wins_over_argv_even_when_argv_was_installed_first() {
947            // Declaration order argues for the WRONG winner here: the argv rule
948            // sits in the table first. A flat first-match over the table (the
949            // shape before tiers) hands it the name.
950            let mut exec = executor();
951            install_argv(
952                &mut exec,
953                &["prog", "--ros-args", "-r", "chatter:=/from_argv"],
954            );
955            exec.declare_remap("talker", "/", "chatter", "/from_launch")
956                .unwrap();
957            assert_eq!(resolve(&exec, "talker", "chatter"), "/from_launch");
958        }
959
960        #[test]
961        fn an_argv_rule_applies_where_the_launch_projected_none() {
962            let mut exec = executor();
963            install_argv(
964                &mut exec,
965                &["prog", "--ros-args", "-r", "chatter:=/from_argv"],
966            );
967            exec.declare_remap("talker", "/", "chatter", "/from_launch")
968                .unwrap();
969            // `listener` has no launch rule for `chatter`, so the fallback answers.
970            assert_eq!(resolve(&exec, "listener", "chatter"), "/from_argv");
971            // ...and a name no rule mentions is only expanded.
972            assert_eq!(resolve(&exec, "talker", "other"), "/other");
973        }
974
975        #[test]
976        fn a_node_prefixed_argv_rule_reaches_only_that_node() {
977            let mut exec = executor();
978            install_argv(
979                &mut exec,
980                &["--ros-args", "-r", "talker:chatter:=/only_talker"],
981            );
982            assert_eq!(resolve(&exec, "talker", "chatter"), "/only_talker");
983            assert_eq!(resolve(&exec, "listener", "chatter"), "/chatter");
984        }
985
986        #[test]
987        fn within_argv_the_first_rule_wins() {
988            // rcl: `foo:=bar bar:=baz` sends `foo` to `bar`, never `baz`.
989            let mut exec = executor();
990            install_argv(
991                &mut exec,
992                &["--ros-args", "-r", "foo:=bar", "-r", "bar:=baz"],
993            );
994            assert_eq!(resolve(&exec, "n", "foo"), "/bar");
995        }
996
997        #[test]
998        fn argv_fallback_yields_only_argv_rules_for_the_named_node() {
999            // What the Rust component sink reads as its fallback tier.
1000            let mut exec = executor();
1001            install_argv(
1002                &mut exec,
1003                &["--ros-args", "-r", "a:=/x", "-r", "other:b:=/y"],
1004            );
1005            exec.declare_remap("talker", "/", "a", "/launch").unwrap();
1006            let got: std::vec::Vec<_> = argv_fallback(&exec, "talker", "/").collect();
1007            assert_eq!(got, [("a", "/x")]);
1008        }
1009
1010        #[test]
1011        fn install_is_all_or_nothing() {
1012            let mut exec = executor();
1013            let free = exec.remap_table.len();
1014            for i in 0..free - 1 {
1015                let from = std::format!("/f{i}");
1016                exec.declare_remap("n", "/", &from, "/t").unwrap();
1017            }
1018            let before = exec.remap_len;
1019            let rules = [
1020                RemapArg {
1021                    node: None,
1022                    from: "fits",
1023                    to: "/a",
1024                },
1025                RemapArg {
1026                    node: None,
1027                    from: "does_not",
1028                    to: "/b",
1029                },
1030            ];
1031            assert_eq!(install_argv_remaps(&mut exec, rules), Err(rules[1]));
1032            assert_eq!(
1033                exec.remap_len, before,
1034                "a refused install leaves no rule behind"
1035            );
1036            assert_eq!(resolve(&exec, "n", "fits"), "/fits");
1037        }
1038    }
1039
1040    #[test]
1041    fn kind_qualified_remaps_are_refused() {
1042        let r = "rostopic://foo:=bar";
1043        assert_eq!(
1044            rules(&["--ros-args", "-r", r]),
1045            Err(RosArgsError::Unsupported { flag: r })
1046        );
1047    }
1048
1049    #[test]
1050    fn apply_validates_without_an_executor_and_installs_nothing() {
1051        let ok = [
1052            "prog",
1053            "--ros-args",
1054            "-r",
1055            "a:=b",
1056            "-r",
1057            "n:c:=d",
1058            "--",
1059            "x",
1060        ];
1061        assert_eq!(apply_ros_args(None, ok.iter().copied()), Ok(2));
1062        assert_eq!(
1063            apply_ros_args(None, ["prog", "plain"].iter().copied()),
1064            Ok(0)
1065        );
1066    }
1067
1068    #[test]
1069    fn apply_refuses_by_name_what_the_parse_refuses() {
1070        let bad = ["prog", "--ros-args", "-r", "a:=b", "-p", "x:=1"];
1071        assert_eq!(
1072            apply_ros_args(None, bad.iter().copied()),
1073            Err(ApplyError::Refused(RosArgsError::Unsupported {
1074                flag: "-p"
1075            }))
1076        );
1077    }
1078
1079    #[test]
1080    fn apply_refuses_more_rules_than_the_table_can_ever_hold() {
1081        const N: usize = crate::executor::spin::MAX_REMAPS;
1082        let mut args: heapless::Vec<&str, { 2 + 2 * (N + 1) }> = heapless::Vec::new();
1083        args.extend_from_slice(&["prog", "--ros-args"]).unwrap();
1084        for _ in 0..=N {
1085            args.extend_from_slice(&["-r", "a:=b"]).unwrap();
1086        }
1087        assert!(matches!(
1088            apply_ros_args(None, args.iter().copied()),
1089            Err(ApplyError::DoesNotFit(_))
1090        ));
1091    }
1092}
1093
1094// `alloc` for the YAML helper's owned rows; the lanes that build nros-node
1095// without it (the declared-QoS registration check) still compile its tests.
1096#[cfg(all(test, feature = "alloc"))]
1097mod param_tests {
1098    use super::*;
1099
1100    fn events<'a>(args: &[&'a str]) -> Result<heapless::Vec<RosArg<'a>, 8>, RosArgsError<'a>> {
1101        let mut out = heapless::Vec::new();
1102        parse_ros_args_events(args.iter().copied(), |a| out.push(a).unwrap())?;
1103        Ok(out)
1104    }
1105
1106    #[test]
1107    fn p_and_params_file_are_events_in_argv_order() {
1108        let got = events(&[
1109            "prog",
1110            "--ros-args",
1111            "-p",
1112            "a:=1",
1113            "--params-file",
1114            "x.yaml",
1115            "-r",
1116            "f:=t",
1117            "--param",
1118            "n:b:=two",
1119        ])
1120        .unwrap();
1121        assert_eq!(got.len(), 4);
1122        assert_eq!(
1123            got[0],
1124            RosArg::Param(ParamArg {
1125                node: None,
1126                name: "a",
1127                value: "1"
1128            })
1129        );
1130        assert_eq!(got[1], RosArg::ParamsFile("x.yaml"));
1131        assert!(matches!(got[2], RosArg::Remap(_)));
1132        assert_eq!(
1133            got[3],
1134            RosArg::Param(ParamArg {
1135                node: Some("n"),
1136                name: "b",
1137                value: "two"
1138            })
1139        );
1140    }
1141
1142    #[test]
1143    fn the_remap_only_parse_still_refuses_parameter_flags() {
1144        let r = parse_ros_args(["prog", "--ros-args", "-p", "a:=1"], |_| {});
1145        assert_eq!(r, Err(RosArgsError::Unsupported { flag: "-p" }));
1146        let r = parse_ros_args(["prog", "--ros-args", "--params-file", "x"], |_| {});
1147        assert_eq!(
1148            r,
1149            Err(RosArgsError::Unsupported {
1150                flag: "--params-file"
1151            })
1152        );
1153    }
1154
1155    #[test]
1156    fn malformed_overrides_are_refused_by_name() {
1157        assert_eq!(
1158            events(&["p", "--ros-args", "-p"]).unwrap_err(),
1159            RosArgsError::MissingValue { flag: "-p" }
1160        );
1161        assert_eq!(
1162            events(&["p", "--ros-args", "-p", "novalue"]).unwrap_err(),
1163            RosArgsError::InvalidParam { rule: "novalue" }
1164        );
1165        assert_eq!(
1166            events(&["p", "--ros-args", "--params-file"]).unwrap_err(),
1167            RosArgsError::MissingValue {
1168                flag: "--params-file"
1169            }
1170        );
1171        // An empty VALUE is an empty string, not a malformed rule.
1172        assert_eq!(
1173            events(&["p", "--ros-args", "-p", "label:="]).unwrap()[0],
1174            RosArg::Param(ParamArg {
1175                node: None,
1176                name: "label",
1177                value: ""
1178            })
1179        );
1180    }
1181
1182    fn yaml(
1183        text: &str,
1184    ) -> Result<
1185        alloc::vec::Vec<(
1186            Option<alloc::string::String>,
1187            alloc::string::String,
1188            alloc::string::String,
1189        )>,
1190        alloc::string::String,
1191    > {
1192        let mut out = alloc::vec::Vec::new();
1193        parse_params_yaml(text, |p| {
1194            out.push((
1195                p.node.map(alloc::string::String::from),
1196                alloc::string::String::from(p.name),
1197                alloc::string::String::from(p.value),
1198            ))
1199        })
1200        .map_err(|e| alloc::format!("{e}"))?;
1201        Ok(out)
1202    }
1203
1204    #[test]
1205    fn a_params_file_reports_dotted_names_per_node() {
1206        let got = yaml("/**:\n  ros__parameters:\n    a: 1  # c\n    g:\n      b: 'x # not a comment'\n/ns/talker:\n  ros__parameters:\n    c: true\n").unwrap();
1207        assert_eq!(got.len(), 3);
1208        assert_eq!(got[0], (None, "a".into(), "1".into()));
1209        assert_eq!(got[1], (None, "g.b".into(), "x # not a comment".into()));
1210        assert_eq!(got[2], (Some("talker".into()), "c".into(), "true".into()));
1211    }
1212
1213    #[test]
1214    fn what_the_subset_does_not_read_is_refused_with_its_line() {
1215        let e = yaml("/**:\n  ros__parameters:\n    xs: [1, 2]\n").unwrap_err();
1216        assert!(e.starts_with("line 3:"), "{e}");
1217        let e = yaml("/**:\n  params:\n    a: 1\n").unwrap_err();
1218        assert!(e.contains("ros__parameters"), "{e}");
1219        let e = yaml("/*/x:\n  ros__parameters:\n    a: 1\n").unwrap_err();
1220        assert!(e.contains("wildcard"), "{e}");
1221        let e = yaml("/**:\n\tros__parameters:\n").unwrap_err();
1222        assert!(e.contains("tab"), "{e}");
1223        let e = yaml("/**:\n  ros__parameters:\n    - a\n").unwrap_err();
1224        assert!(e.contains("sequence"), "{e}");
1225    }
1226}