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 — the executor's runtime table,
497/// `MAX_REMAPS` slots shared with hand-declared rules and none on bare metal —
498/// or a name past its slot's length). Silently installing a prefix of
499/// the rules would be a partial version of the drop this exists to prevent.
500pub fn install_argv_remaps<'r, I>(
501    executor: &mut crate::executor::Executor<'_>,
502    rules: I,
503) -> Result<(), RemapArg<'r>>
504where
505    I: IntoIterator<Item = RemapArg<'r>>,
506    I::IntoIter: Clone,
507{
508    use crate::executor::spin::{RemapRule, RemapTier};
509    let rules = rules.into_iter();
510    let start = executor.remap_len;
511    for (i, r) in rules.clone().enumerate() {
512        let mut rule = RemapRule {
513            node_name: heapless::String::new(),
514            namespace: heapless::String::new(),
515            from: heapless::String::new(),
516            to: heapless::String::new(),
517            tier: RemapTier::Argv,
518        };
519        let fits = rule.node_name.push_str(r.node.unwrap_or("")).is_ok()
520            && rule.from.push_str(r.from).is_ok()
521            && rule.to.push_str(r.to).is_ok();
522        let slot = executor.remap_table.get_mut(start + i);
523        match (fits, slot) {
524            (true, Some(slot)) => *slot = Some(rule),
525            _ => {
526                for s in &mut executor.remap_table[start..start + i] {
527                    *s = None;
528                }
529                return Err(r);
530            }
531        }
532    }
533    executor.remap_len = start + rules.count();
534    Ok(())
535}
536
537#[cfg(any(has_rmw, test))]
538/// Why [`apply_ros_args`] refused an argument vector.
539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
540pub enum ApplyError<'a> {
541    /// The parse refused a token ([`parse_ros_args`]).
542    Refused(RosArgsError<'a>),
543    /// The rule did not fit: the runtime remap table is full or absent (its
544    /// slots are shared with hand-declared rules), or a name is longer than
545    /// its slot.
546    DoesNotFit(RemapArg<'a>),
547}
548
549#[cfg(any(has_rmw, test))]
550impl core::fmt::Display for ApplyError<'_> {
551    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
552        match self {
553            Self::Refused(e) => e.fmt(f),
554            Self::DoesNotFit(r) => write!(
555                f,
556                "remap `{}:={}` does not fit: the executor's runtime remap table holds {} \
557                 rules (NROS_EXECUTOR_RUNTIME_REMAPS; 0 on bare metal, which has no argv), \
558                 shared with hand-declared rules; `from`/`to` hold {} bytes, a `node:` \
559                 prefix 64",
560                r.from,
561                r.to,
562                crate::executor::spin::MAX_REMAPS,
563                crate::names::MAX_RESOLVED_NAME_LEN,
564            ),
565        }
566    }
567}
568
569#[cfg(any(has_rmw, test))]
570/// Parse `args` and, given an executor, install its `-r` rules as the
571/// FALLBACK tier — the whole of "honour `--ros-args`" for a caller with no
572/// allocator (the C++ ABI, `nros_cpp_install_argv_remaps`).
573///
574/// With `executor = None` it only VALIDATES, so a caller can refuse a bad
575/// vector before opening a session at all. All or nothing either way: on any
576/// error nothing is installed. Returns the number of rules installed (or that
577/// would be).
578// `MAX_REMAPS` is 0 on bare metal (no runtime remap table, issue 1971): every
579// rule is then the overflow, refused by name.
580#[allow(clippy::absurd_extreme_comparisons)]
581pub fn apply_ros_args<'a, I>(
582    executor: Option<&mut crate::executor::Executor<'_>>,
583    args: I,
584) -> Result<usize, ApplyError<'a>>
585where
586    I: IntoIterator<Item = &'a str>,
587{
588    const N: usize = crate::executor::spin::MAX_REMAPS;
589    let mut rules: [Option<RemapArg<'a>>; N] = [None; N];
590    let mut n = 0usize;
591    let mut overflow = None;
592    parse_ros_args(args, |r| {
593        if n < N {
594            rules[n] = Some(r);
595            n += 1;
596        } else if overflow.is_none() {
597            overflow = Some(r);
598        }
599    })
600    .map_err(ApplyError::Refused)?;
601    if let Some(r) = overflow {
602        return Err(ApplyError::DoesNotFit(r));
603    }
604    if let Some(executor) = executor {
605        install_argv_remaps(executor, rules[..n].iter().flatten().copied())
606            .map_err(ApplyError::DoesNotFit)?;
607    }
608    Ok(n)
609}
610
611#[cfg(all(any(has_rmw, test), feature = "param-store", feature = "alloc"))]
612/// Why [`apply_ros_args_with_params`] refused an argument vector.
613#[derive(Debug, Clone)]
614pub enum ApplyParamsError<'a> {
615    /// The remap half refused it, exactly as [`apply_ros_args`] would.
616    Remaps(ApplyError<'a>),
617    /// `--params-file <path>` could not be read; the reason is the reader's.
618    Unreadable {
619        /// The path as written.
620        path: &'a str,
621        /// What the reader said.
622        why: alloc::string::String,
623    },
624    /// The file was read and its text refused by [`parse_params_yaml`].
625    BadFile {
626        /// The path as written.
627        path: &'a str,
628        /// `line N: reason: `text``.
629        why: alloc::string::String,
630    },
631    /// The parameter store could not be allocated (issue 1706).
632    NoStore,
633}
634
635#[cfg(all(any(has_rmw, test), feature = "param-store", feature = "alloc"))]
636impl core::fmt::Display for ApplyParamsError<'_> {
637    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
638        match self {
639            Self::Remaps(e) => e.fmt(f),
640            Self::Unreadable { path, why } => {
641                write!(f, "`--params-file {path}` cannot be read: {why}")
642            }
643            Self::BadFile { path, why } => write!(f, "`--params-file {path}`, {why}"),
644            Self::NoStore => f.write_str(
645                "the parameter store could not be allocated, so the parameter overrides have \
646                 nowhere to go",
647            ),
648        }
649    }
650}
651
652#[cfg(all(any(has_rmw, test), feature = "param-store", feature = "alloc"))]
653/// [`apply_ros_args`], plus the parameter half — phase-482 W5.
654///
655/// `-r` rules are installed as [`apply_ros_args`] installs them. `-p` overrides
656/// and every `--params-file` (read through `read_file`, which is the caller's:
657/// this crate has no file system) are installed with
658/// `Executor::install_argv_params`, in argv order, so a later value wins.
659///
660/// With `executor = None` it only VALIDATES — files are still read and parsed,
661/// so a bad file is refused before a session opens. All or nothing: on any
662/// error nothing is installed.
663pub fn apply_ros_args_with_params<'a, I, R>(
664    executor: Option<&mut crate::executor::Executor<'_>>,
665    args: I,
666    mut read_file: R,
667) -> Result<usize, ApplyParamsError<'a>>
668where
669    I: IntoIterator<Item = &'a str> + Clone,
670    R: FnMut(&str) -> Result<alloc::string::String, alloc::string::String>,
671{
672    use alloc::string::String;
673    // Pass 1: the remaps and the refusals, through the remap-only grammar's
674    // twin so a refused token reports exactly as before.
675    let mut params: alloc::vec::Vec<(Option<String>, String, String)> = alloc::vec::Vec::new();
676    let mut files: alloc::vec::Vec<&'a str> = alloc::vec::Vec::new();
677    let mut order: alloc::vec::Vec<Result<usize, usize>> = alloc::vec::Vec::new();
678    parse_ros_args_events(args.clone(), |a| match a {
679        RosArg::Param(p) => {
680            order.push(Ok(params.len()));
681            params.push((
682                p.node.map(String::from),
683                String::from(p.name),
684                String::from(p.value),
685            ));
686        }
687        RosArg::ParamsFile(path) => {
688            order.push(Err(files.len()));
689            files.push(path);
690        }
691        RosArg::Remap(_) => {}
692    })
693    .map_err(|e| ApplyParamsError::Remaps(ApplyError::Refused(e)))?;
694    // Read and parse every file before installing anything.
695    let mut file_params: alloc::vec::Vec<alloc::vec::Vec<(Option<String>, String, String)>> =
696        alloc::vec::Vec::new();
697    for &path in &files {
698        let text = read_file(path).map_err(|why| ApplyParamsError::Unreadable { path, why })?;
699        let mut got = alloc::vec::Vec::new();
700        parse_params_yaml(&text, |p| {
701            got.push((
702                p.node.map(String::from),
703                String::from(p.name),
704                String::from(p.value),
705            ))
706        })
707        .map_err(|e| ApplyParamsError::BadFile {
708            path,
709            why: alloc::format!("{e}"),
710        })?;
711        file_params.push(got);
712    }
713    // The remap half, minus the parameter flags it would refuse.
714    let remap_args = args.into_iter().scan(false, |skip, a| {
715        if core::mem::take(skip) {
716            return Some(None);
717        }
718        if matches!(a, "-p" | "--param" | "--params-file") {
719            *skip = true;
720            return Some(None);
721        }
722        Some(Some(a))
723    });
724    let remap_args: alloc::vec::Vec<&'a str> = remap_args.flatten().collect();
725    let Some(executor) = executor else {
726        let n =
727            apply_ros_args(None, remap_args.iter().copied()).map_err(ApplyParamsError::Remaps)?;
728        return Ok(n + params.len() + file_params.iter().map(alloc::vec::Vec::len).sum::<usize>());
729    };
730    // Commit in the order that keeps it all-or-nothing: make sure the store
731    // exists (the one way the parameter half can fail), then the remaps (which
732    // are all-or-nothing on their own), then the parameters, which can no
733    // longer fail.
734    executor
735        .install_argv_params(core::iter::empty())
736        .map_err(|_| ApplyParamsError::NoStore)?;
737    let n = apply_ros_args(Some(&mut *executor), remap_args.iter().copied())
738        .map_err(ApplyParamsError::Remaps)?;
739    let mut flat: alloc::vec::Vec<&(Option<String>, String, String)> = alloc::vec::Vec::new();
740    for slot in &order {
741        match *slot {
742            Ok(i) => flat.push(&params[i]),
743            Err(f) => flat.extend(file_params[f].iter()),
744        }
745    }
746    let m = executor
747        .install_argv_params(flat.iter().map(|(node, name, value)| ParamArg {
748            node: node.as_deref(),
749            name,
750            value,
751        }))
752        .map_err(|_| ApplyParamsError::NoStore)?;
753    Ok(n + m)
754}
755
756#[cfg(any(has_rmw, test))]
757/// The installed argv rules that apply to the node named `node_name` (in
758/// `namespace`), as a fallback tier for [`crate::names::resolve_name_layered`].
759///
760/// For a resolver that keeps its AUTHORITATIVE rules somewhere other than the
761/// executor's table — the Rust component sink holds its launch rules as a
762/// slice on `RuntimeCtx` — and so cannot use
763/// `Executor::resolve_entity_name_for` directly.
764pub fn argv_fallback<'e>(
765    executor: &'e crate::executor::Executor<'_>,
766    node_name: &'e str,
767    namespace: &'e str,
768) -> impl Iterator<Item = (&'e str, &'e str)> + 'e {
769    use crate::executor::spin::{RemapTier, tier_rules};
770    tier_rules(
771        &executor.remap_table[..executor.remap_len],
772        RemapTier::Argv,
773        node_name,
774        namespace,
775    )
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    fn rules<'a>(args: &[&'a str]) -> Result<heapless::Vec<RemapArg<'a>, 8>, RosArgsError<'a>> {
783        let mut out = heapless::Vec::new();
784        parse_ros_args(args.iter().copied(), |r| out.push(r).unwrap())?;
785        Ok(out)
786    }
787
788    fn rule<'a>(node: Option<&'a str>, from: &'a str, to: &'a str) -> RemapArg<'a> {
789        RemapArg { node, from, to }
790    }
791
792    #[test]
793    fn no_scope_reports_nothing_and_leaves_user_arguments_alone() {
794        assert!(
795            rules(&["prog", "-r", "a:=b", "--verbose"])
796                .unwrap()
797                .is_empty()
798        );
799        assert!(rules(&[]).unwrap().is_empty());
800    }
801
802    #[test]
803    fn short_and_long_remap_flags_keep_argv_order() {
804        let got = rules(&[
805            "prog",
806            "--ros-args",
807            "-r",
808            "foo:=bar",
809            "--remap",
810            "bar:=baz",
811        ])
812        .unwrap();
813        assert_eq!(
814            got.as_slice(),
815            &[rule(None, "foo", "bar"), rule(None, "bar", "baz")]
816        );
817    }
818
819    #[test]
820    fn a_node_prefix_scopes_the_rule() {
821        let got = rules(&["--ros-args", "-r", "alice:foo:=~/bar"]).unwrap();
822        assert_eq!(got.as_slice(), &[rule(Some("alice"), "foo", "~/bar")]);
823    }
824
825    #[test]
826    fn double_dash_closes_the_scope_and_a_second_scope_reopens_it() {
827        let got = rules(&[
828            "--ros-args",
829            "-r",
830            "a:=b",
831            "--",
832            "-r",
833            "user:=arg",
834            "--ros-args",
835            "-r",
836            "c:=d",
837        ])
838        .unwrap();
839        assert_eq!(
840            got.as_slice(),
841            &[rule(None, "a", "b"), rule(None, "c", "d")]
842        );
843    }
844
845    #[test]
846    fn parameter_overrides_are_refused_by_name() {
847        for flag in ["-p", "--param", "--params-file"] {
848            assert_eq!(
849                rules(&["--ros-args", flag, "x:=1"]),
850                Err(RosArgsError::Unsupported { flag })
851            );
852        }
853    }
854
855    #[test]
856    fn enclave_and_log_flags_are_refused_by_name() {
857        for flag in [
858            "-e",
859            "--enclave",
860            "--log-level",
861            "--log-config-file",
862            "--disable-rosout-logs",
863        ] {
864            assert_eq!(
865                rules(&["--ros-args", flag]),
866                Err(RosArgsError::Unsupported { flag })
867            );
868        }
869    }
870
871    #[test]
872    fn an_unknown_token_inside_a_scope_is_refused_not_skipped() {
873        assert_eq!(
874            rules(&["--ros-args", "-r", "a:=b", "--frobnicate"]),
875            Err(RosArgsError::Unknown {
876                token: "--frobnicate"
877            })
878        );
879        // A look-alike of a real log switch is still unknown, not a switch.
880        assert_eq!(
881            rules(&["--ros-args", "--enable-everything"]),
882            Err(RosArgsError::Unknown {
883                token: "--enable-everything"
884            })
885        );
886    }
887
888    #[test]
889    fn a_remap_flag_needs_a_valid_rule_after_it() {
890        assert_eq!(
891            rules(&["--ros-args", "-r"]),
892            Err(RosArgsError::MissingRule { flag: "-r" })
893        );
894        for bad in [
895            "nocolon", ":=b", "a:=", "a b:=c", ":a:=b", "n/s:a:=b", "a:=b:c",
896        ] {
897            assert_eq!(
898                rules(&["--ros-args", "--remap", bad]),
899                Err(RosArgsError::InvalidRule { rule: bad }),
900                "{bad}"
901            );
902        }
903    }
904
905    #[test]
906    fn identity_remaps_are_refused() {
907        for r in ["__node:=x", "__name:=x", "__ns:=/x", "talker:__ns:=/x"] {
908            assert_eq!(
909                rules(&["--ros-args", "-r", r]),
910                Err(RosArgsError::IdentityRemap { rule: r })
911            );
912        }
913    }
914
915    // ---------------------------------------------------------------------
916    // The producer feeding the seam: real argv -> parse -> install -> resolve.
917    //
918    // `names.rs`'s precedence tests hand `resolve_name_layered` two arrays;
919    // these run the whole chain a hosted `init_with_args` caller gets, against
920    // the executor's own table and resolver.
921    // ---------------------------------------------------------------------
922
923    /// Same gate as `executor::node`'s tests: `Executor::from_session` over a
924    /// `MockSession` needs `std` and no linked backend.
925    #[cfg(all(feature = "alloc", not(feature = "rmw-cffi")))]
926    mod chain {
927        use super::super::*;
928        extern crate std;
929        use crate::{executor::Executor, mock::MockSession};
930
931        fn executor() -> Executor<'static> {
932            Executor::from_session(MockSession::new())
933        }
934
935        /// Parse `argv` and install what it carries, the way
936        /// `nros::Context::create_executor` does.
937        fn install_argv(exec: &mut Executor<'_>, argv: &[&'static str]) {
938            let mut parsed: std::vec::Vec<RemapArg<'static>> = std::vec::Vec::new();
939            parse_ros_args(argv.iter().copied(), |r| parsed.push(r)).expect("argv parses");
940            install_argv_remaps(exec, parsed.iter().copied()).expect("rules fit");
941        }
942
943        fn resolve(exec: &Executor<'_>, node: &str, source: &str) -> std::string::String {
944            std::string::String::from(
945                exec.resolve_entity_name_for(node, "/", source)
946                    .unwrap()
947                    .as_str(),
948            )
949        }
950
951        #[test]
952        fn a_launch_rule_wins_over_argv_even_when_argv_was_installed_first() {
953            // Declaration order argues for the WRONG winner here: the argv rule
954            // sits in the table first. A flat first-match over the table (the
955            // shape before tiers) hands it the name.
956            let mut exec = executor();
957            install_argv(
958                &mut exec,
959                &["prog", "--ros-args", "-r", "chatter:=/from_argv"],
960            );
961            exec.declare_remap("talker", "/", "chatter", "/from_launch")
962                .unwrap();
963            assert_eq!(resolve(&exec, "talker", "chatter"), "/from_launch");
964        }
965
966        #[test]
967        fn an_argv_rule_applies_where_the_launch_projected_none() {
968            let mut exec = executor();
969            install_argv(
970                &mut exec,
971                &["prog", "--ros-args", "-r", "chatter:=/from_argv"],
972            );
973            exec.declare_remap("talker", "/", "chatter", "/from_launch")
974                .unwrap();
975            // `listener` has no launch rule for `chatter`, so the fallback answers.
976            assert_eq!(resolve(&exec, "listener", "chatter"), "/from_argv");
977            // ...and a name no rule mentions is only expanded.
978            assert_eq!(resolve(&exec, "talker", "other"), "/other");
979        }
980
981        // Issue 1971 -- the launch rules are the flash table now; the same
982        // layering holds against it.
983        static LAUNCH: [crate::executor::LaunchRemap; 1] = [crate::executor::LaunchRemap::new(
984            c"talker",
985            c"/",
986            c"chatter",
987            c"/from_flash",
988        )];
989
990        #[test]
991        fn a_flash_launch_rule_wins_over_argv() {
992            let mut exec = executor();
993            install_argv(
994                &mut exec,
995                &["prog", "--ros-args", "-r", "chatter:=/from_argv"],
996            );
997            exec.set_launch_remaps(&LAUNCH);
998            assert_eq!(resolve(&exec, "talker", "chatter"), "/from_flash");
999            // A node the flash table names no rule for still falls back.
1000            assert_eq!(resolve(&exec, "listener", "chatter"), "/from_argv");
1001        }
1002
1003        #[test]
1004        fn a_flash_launch_rule_takes_no_runtime_slot() {
1005            let mut exec = executor();
1006            exec.set_launch_remaps(&LAUNCH);
1007            assert_eq!(exec.remap_len, 0);
1008            assert_eq!(resolve(&exec, "talker", "chatter"), "/from_flash");
1009        }
1010
1011        #[test]
1012        fn a_node_prefixed_argv_rule_reaches_only_that_node() {
1013            let mut exec = executor();
1014            install_argv(
1015                &mut exec,
1016                &["--ros-args", "-r", "talker:chatter:=/only_talker"],
1017            );
1018            assert_eq!(resolve(&exec, "talker", "chatter"), "/only_talker");
1019            assert_eq!(resolve(&exec, "listener", "chatter"), "/chatter");
1020        }
1021
1022        #[test]
1023        fn within_argv_the_first_rule_wins() {
1024            // rcl: `foo:=bar bar:=baz` sends `foo` to `bar`, never `baz`.
1025            let mut exec = executor();
1026            install_argv(
1027                &mut exec,
1028                &["--ros-args", "-r", "foo:=bar", "-r", "bar:=baz"],
1029            );
1030            assert_eq!(resolve(&exec, "n", "foo"), "/bar");
1031        }
1032
1033        #[test]
1034        fn argv_fallback_yields_only_argv_rules_for_the_named_node() {
1035            // What the Rust component sink reads as its fallback tier.
1036            let mut exec = executor();
1037            install_argv(
1038                &mut exec,
1039                &["--ros-args", "-r", "a:=/x", "-r", "other:b:=/y"],
1040            );
1041            exec.declare_remap("talker", "/", "a", "/launch").unwrap();
1042            let got: std::vec::Vec<_> = argv_fallback(&exec, "talker", "/").collect();
1043            assert_eq!(got, [("a", "/x")]);
1044        }
1045
1046        #[test]
1047        fn install_is_all_or_nothing() {
1048            let mut exec = executor();
1049            let free = exec.remap_table.len();
1050            for i in 0..free - 1 {
1051                let from = std::format!("/f{i}");
1052                exec.declare_remap("n", "/", &from, "/t").unwrap();
1053            }
1054            let before = exec.remap_len;
1055            let rules = [
1056                RemapArg {
1057                    node: None,
1058                    from: "fits",
1059                    to: "/a",
1060                },
1061                RemapArg {
1062                    node: None,
1063                    from: "does_not",
1064                    to: "/b",
1065                },
1066            ];
1067            assert_eq!(install_argv_remaps(&mut exec, rules), Err(rules[1]));
1068            assert_eq!(
1069                exec.remap_len, before,
1070                "a refused install leaves no rule behind"
1071            );
1072            assert_eq!(resolve(&exec, "n", "fits"), "/fits");
1073        }
1074    }
1075
1076    #[test]
1077    fn kind_qualified_remaps_are_refused() {
1078        let r = "rostopic://foo:=bar";
1079        assert_eq!(
1080            rules(&["--ros-args", "-r", r]),
1081            Err(RosArgsError::Unsupported { flag: r })
1082        );
1083    }
1084
1085    #[test]
1086    fn apply_validates_without_an_executor_and_installs_nothing() {
1087        let ok = [
1088            "prog",
1089            "--ros-args",
1090            "-r",
1091            "a:=b",
1092            "-r",
1093            "n:c:=d",
1094            "--",
1095            "x",
1096        ];
1097        assert_eq!(apply_ros_args(None, ok.iter().copied()), Ok(2));
1098        assert_eq!(
1099            apply_ros_args(None, ["prog", "plain"].iter().copied()),
1100            Ok(0)
1101        );
1102    }
1103
1104    #[test]
1105    fn apply_refuses_by_name_what_the_parse_refuses() {
1106        let bad = ["prog", "--ros-args", "-r", "a:=b", "-p", "x:=1"];
1107        assert_eq!(
1108            apply_ros_args(None, bad.iter().copied()),
1109            Err(ApplyError::Refused(RosArgsError::Unsupported {
1110                flag: "-p"
1111            }))
1112        );
1113    }
1114
1115    #[test]
1116    fn apply_refuses_more_rules_than_the_table_can_ever_hold() {
1117        const N: usize = crate::executor::spin::MAX_REMAPS;
1118        let mut args: heapless::Vec<&str, { 2 + 2 * (N + 1) }> = heapless::Vec::new();
1119        args.extend_from_slice(&["prog", "--ros-args"]).unwrap();
1120        for _ in 0..=N {
1121            args.extend_from_slice(&["-r", "a:=b"]).unwrap();
1122        }
1123        assert!(matches!(
1124            apply_ros_args(None, args.iter().copied()),
1125            Err(ApplyError::DoesNotFit(_))
1126        ));
1127    }
1128}
1129
1130// `alloc` for the YAML helper's owned rows; the lanes that build nros-node
1131// without it (the declared-QoS registration check) still compile its tests.
1132#[cfg(all(test, feature = "alloc"))]
1133mod param_tests {
1134    use super::*;
1135
1136    fn events<'a>(args: &[&'a str]) -> Result<heapless::Vec<RosArg<'a>, 8>, RosArgsError<'a>> {
1137        let mut out = heapless::Vec::new();
1138        parse_ros_args_events(args.iter().copied(), |a| out.push(a).unwrap())?;
1139        Ok(out)
1140    }
1141
1142    #[test]
1143    fn p_and_params_file_are_events_in_argv_order() {
1144        let got = events(&[
1145            "prog",
1146            "--ros-args",
1147            "-p",
1148            "a:=1",
1149            "--params-file",
1150            "x.yaml",
1151            "-r",
1152            "f:=t",
1153            "--param",
1154            "n:b:=two",
1155        ])
1156        .unwrap();
1157        assert_eq!(got.len(), 4);
1158        assert_eq!(
1159            got[0],
1160            RosArg::Param(ParamArg {
1161                node: None,
1162                name: "a",
1163                value: "1"
1164            })
1165        );
1166        assert_eq!(got[1], RosArg::ParamsFile("x.yaml"));
1167        assert!(matches!(got[2], RosArg::Remap(_)));
1168        assert_eq!(
1169            got[3],
1170            RosArg::Param(ParamArg {
1171                node: Some("n"),
1172                name: "b",
1173                value: "two"
1174            })
1175        );
1176    }
1177
1178    #[test]
1179    fn the_remap_only_parse_still_refuses_parameter_flags() {
1180        let r = parse_ros_args(["prog", "--ros-args", "-p", "a:=1"], |_| {});
1181        assert_eq!(r, Err(RosArgsError::Unsupported { flag: "-p" }));
1182        let r = parse_ros_args(["prog", "--ros-args", "--params-file", "x"], |_| {});
1183        assert_eq!(
1184            r,
1185            Err(RosArgsError::Unsupported {
1186                flag: "--params-file"
1187            })
1188        );
1189    }
1190
1191    #[test]
1192    fn malformed_overrides_are_refused_by_name() {
1193        assert_eq!(
1194            events(&["p", "--ros-args", "-p"]).unwrap_err(),
1195            RosArgsError::MissingValue { flag: "-p" }
1196        );
1197        assert_eq!(
1198            events(&["p", "--ros-args", "-p", "novalue"]).unwrap_err(),
1199            RosArgsError::InvalidParam { rule: "novalue" }
1200        );
1201        assert_eq!(
1202            events(&["p", "--ros-args", "--params-file"]).unwrap_err(),
1203            RosArgsError::MissingValue {
1204                flag: "--params-file"
1205            }
1206        );
1207        // An empty VALUE is an empty string, not a malformed rule.
1208        assert_eq!(
1209            events(&["p", "--ros-args", "-p", "label:="]).unwrap()[0],
1210            RosArg::Param(ParamArg {
1211                node: None,
1212                name: "label",
1213                value: ""
1214            })
1215        );
1216    }
1217
1218    fn yaml(
1219        text: &str,
1220    ) -> Result<
1221        alloc::vec::Vec<(
1222            Option<alloc::string::String>,
1223            alloc::string::String,
1224            alloc::string::String,
1225        )>,
1226        alloc::string::String,
1227    > {
1228        let mut out = alloc::vec::Vec::new();
1229        parse_params_yaml(text, |p| {
1230            out.push((
1231                p.node.map(alloc::string::String::from),
1232                alloc::string::String::from(p.name),
1233                alloc::string::String::from(p.value),
1234            ))
1235        })
1236        .map_err(|e| alloc::format!("{e}"))?;
1237        Ok(out)
1238    }
1239
1240    #[test]
1241    fn a_params_file_reports_dotted_names_per_node() {
1242        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();
1243        assert_eq!(got.len(), 3);
1244        assert_eq!(got[0], (None, "a".into(), "1".into()));
1245        assert_eq!(got[1], (None, "g.b".into(), "x # not a comment".into()));
1246        assert_eq!(got[2], (Some("talker".into()), "c".into(), "true".into()));
1247    }
1248
1249    #[test]
1250    fn what_the_subset_does_not_read_is_refused_with_its_line() {
1251        let e = yaml("/**:\n  ros__parameters:\n    xs: [1, 2]\n").unwrap_err();
1252        assert!(e.starts_with("line 3:"), "{e}");
1253        let e = yaml("/**:\n  params:\n    a: 1\n").unwrap_err();
1254        assert!(e.contains("ros__parameters"), "{e}");
1255        let e = yaml("/*/x:\n  ros__parameters:\n    a: 1\n").unwrap_err();
1256        assert!(e.contains("wildcard"), "{e}");
1257        let e = yaml("/**:\n\tros__parameters:\n").unwrap_err();
1258        assert!(e.contains("tab"), "{e}");
1259        let e = yaml("/**:\n  ros__parameters:\n    - a\n").unwrap_err();
1260        assert!(e.contains("sequence"), "{e}");
1261    }
1262}