Skip to main content

robonix_codegen/codegen/
msg_parser.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Shared ROS .msg / .srv file parser — used by proto_gen, mcp_python_gen,
3// and atlas's contract registry.
4
5use anyhow::{Context, Result, bail};
6use std::collections::{BTreeSet, HashMap};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use super::err::RIDLC_ERR_PREFIX;
11
12#[derive(Clone, Debug)]
13pub struct MsgField {
14    pub name: String,
15    pub type_ref: MsgTypeRef,
16    pub is_array: bool,
17    /// Fixed array size (e.g. 9 for `float32[9]`). None means unbounded (`T[]`).
18    pub array_size: Option<usize>,
19    /// Upper bound for `string<=N` / `wstring<=N` fields. None when not
20    /// declared. Preserved so MCP JSON Schema can emit `maxLength`.
21    pub string_max_len: Option<usize>,
22    /// Trailing-comment annotation from the .msg line, e.g.
23    /// `# in metres` or `# range [0, 1]`. Empty when there was no
24    /// comment. Standard ROS messages use these for units / value
25    /// ranges; we propagate them to MCP `description` because they
26    /// give an LLM real semantic hints about the field.
27    pub description: String,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum MsgTypeRef {
32    /// Carries the canonical ROS primitive identifier as a string. Use
33    /// `RosPrimitive::parse(...)` to recover the strongly-typed enum
34    /// when generating code; the string representation is the source
35    /// of truth for cross-generator consistency.
36    Primitive(String),
37    Named {
38        package: String,
39        name: String,
40    },
41}
42
43/// Strongly-typed enum of every ROS2 IDL primitive. **This is the
44/// single source of truth for how a primitive maps to wire formats
45/// across generators.** Both `proto_gen` and `mcp_python_gen` consume
46/// the methods on this enum so a new primitive only has to be added
47/// here once.
48///
49/// The catch-all silent fallbacks that used to live in each generator
50/// (`_ => "int"`, `_ => "bytes"`) are gone — every primitive must be
51/// listed explicitly. An unrecognised type now panics at codegen time
52/// instead of silently producing the wrong wire shape.
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
54pub enum RosPrimitive {
55    Bool,
56    Byte, // ROS2: signed 8-bit. NOT the same as uint8 (unsigned).
57    Char, // ROS2: unsigned 8-bit. NOT the same as int8.
58    Int8,
59    Uint8,
60    Int16,
61    Uint16,
62    Int32,
63    Uint32,
64    Int64,
65    Uint64,
66    Float32,
67    Float64,
68    String,
69    Wstring,
70}
71
72impl RosPrimitive {
73    pub fn parse(s: &str) -> Option<Self> {
74        Some(match s {
75            "bool" => Self::Bool,
76            "byte" => Self::Byte,
77            "char" => Self::Char,
78            "int8" => Self::Int8,
79            "uint8" => Self::Uint8,
80            "int16" => Self::Int16,
81            "uint16" => Self::Uint16,
82            "int32" => Self::Int32,
83            "uint32" => Self::Uint32,
84            "int64" => Self::Int64,
85            "uint64" => Self::Uint64,
86            "float32" => Self::Float32,
87            "float64" => Self::Float64,
88            "string" => Self::String,
89            "wstring" => Self::Wstring,
90            _ => return None,
91        })
92    }
93
94    pub fn as_ros_str(&self) -> &'static str {
95        match self {
96            Self::Bool => "bool",
97            Self::Byte => "byte",
98            Self::Char => "char",
99            Self::Int8 => "int8",
100            Self::Uint8 => "uint8",
101            Self::Int16 => "int16",
102            Self::Uint16 => "uint16",
103            Self::Int32 => "int32",
104            Self::Uint32 => "uint32",
105            Self::Int64 => "int64",
106            Self::Uint64 => "uint64",
107            Self::Float32 => "float32",
108            Self::Float64 => "float64",
109            Self::String => "string",
110            Self::Wstring => "wstring",
111        }
112    }
113
114    /// proto3 wire type for this primitive.
115    pub fn proto_type(&self) -> &'static str {
116        match self {
117            Self::Bool => "bool",
118            // ROS `byte` is signed 8-bit, ROS `char` is unsigned 8-bit;
119            // proto3 has no narrower-than-32-bit ints, so widen but
120            // preserve sign.
121            Self::Byte | Self::Int8 => "int32",
122            Self::Char | Self::Uint8 => "uint32",
123            Self::Int16 => "int32",
124            Self::Uint16 => "uint32",
125            Self::Int32 => "int32",
126            Self::Uint32 => "uint32",
127            Self::Int64 => "int64",
128            Self::Uint64 => "uint64",
129            Self::Float32 => "float",
130            Self::Float64 => "double",
131            Self::String | Self::Wstring => "string",
132        }
133    }
134
135    /// Python type name used in MCP dataclass annotations.
136    pub fn python_type(&self) -> &'static str {
137        match self {
138            Self::Bool => "bool",
139            Self::Float32 | Self::Float64 => "float",
140            Self::String | Self::Wstring => "str",
141            Self::Byte
142            | Self::Char
143            | Self::Int8
144            | Self::Uint8
145            | Self::Int16
146            | Self::Uint16
147            | Self::Int32
148            | Self::Uint32
149            | Self::Int64
150            | Self::Uint64 => "int",
151        }
152    }
153
154    /// Default literal in Python for a non-array field.
155    pub fn python_default(&self) -> &'static str {
156        match self {
157            Self::Bool => "False",
158            Self::Float32 | Self::Float64 => "0.0",
159            Self::String | Self::Wstring => "\"\"",
160            _ => "0",
161        }
162    }
163
164    /// Python cast invoked by `from_dict()` when validating an inbound
165    /// JSON value. Same as the type name for builtin casts.
166    pub fn python_cast(&self) -> &'static str {
167        self.python_type()
168    }
169
170    /// JSON Schema base type ("integer" / "number" / "boolean" / "string").
171    pub fn json_schema_type(&self) -> &'static str {
172        match self {
173            Self::Bool => "boolean",
174            Self::Float32 | Self::Float64 => "number",
175            Self::String | Self::Wstring => "string",
176            _ => "integer",
177        }
178    }
179
180    /// Inclusive range constraint for integer primitives (used by
181    /// `from_dict` validation and JSON Schema `minimum`/`maximum`).
182    /// Floats / strings / bool return None — JSON Schema's defaults
183    /// already cover them.
184    pub fn integer_range(&self) -> Option<(i128, i128)> {
185        Some(match self {
186            Self::Byte | Self::Int8 => (i8::MIN as i128, i8::MAX as i128),
187            Self::Char | Self::Uint8 => (0, u8::MAX as i128),
188            Self::Int16 => (i16::MIN as i128, i16::MAX as i128),
189            Self::Uint16 => (0, u16::MAX as i128),
190            Self::Int32 => (i32::MIN as i128, i32::MAX as i128),
191            Self::Uint32 => (0, u32::MAX as i128),
192            Self::Int64 => (i64::MIN as i128, i64::MAX as i128),
193            // uint64 max overflows i128's signed range only in the
194            // very upper bit; i128 holds it fine.
195            Self::Uint64 => (0, u64::MAX as i128),
196            _ => return None,
197        })
198    }
199
200    /// True if this primitive is the canonical "raw byte buffer" type.
201    /// Only `uint8` qualifies — `byte` is signed 8-bit and shouldn't
202    /// be conflated. Used by both proto and MCP gens to decide whether
203    /// a `T[]` field becomes a wire-level byte blob (`bytes` in proto,
204    /// base64-encoded `bytes` in MCP).
205    pub fn is_blob_element(&self) -> bool {
206        matches!(self, Self::Uint8)
207    }
208}
209
210#[derive(Clone, Debug)]
211pub struct MsgSpec {
212    pub package: String,
213    pub name: String,
214    pub fields: Vec<MsgField>,
215}
216
217#[derive(Clone, Debug)]
218pub struct SrvSpec {
219    pub package: String,
220    pub name: String,
221    pub request: MsgSpec,
222    pub response: MsgSpec,
223}
224
225#[derive(Clone, Debug, Default)]
226pub struct ResolveContext {
227    pub namespace: Option<String>,
228    pub interface_kind: Option<&'static str>,
229    pub interface_name: Option<String>,
230    pub field_name: Option<String>,
231}
232
233pub struct MsgResolver {
234    pub index: HashMap<(String, String), PathBuf>,
235    pub cache: HashMap<(String, String), MsgSpec>,
236    pub srv_index: HashMap<(String, String), PathBuf>,
237    pub srv_cache: HashMap<(String, String), SrvSpec>,
238    pub include_paths: Vec<PathBuf>,
239}
240
241impl MsgResolver {
242    pub fn new(include_paths: &[PathBuf]) -> Result<Self> {
243        let mut index = HashMap::new();
244        let mut srv_index = HashMap::new();
245        for root in include_paths {
246            index_msg_files(root, &mut index)?;
247            index_srv_files(root, &mut srv_index)?;
248        }
249        Ok(Self {
250            index,
251            cache: HashMap::new(),
252            srv_index,
253            srv_cache: HashMap::new(),
254            include_paths: include_paths.to_vec(),
255        })
256    }
257
258    pub fn resolve_ridl_type(&mut self, type_ref: &str, ctx: &ResolveContext) -> Result<()> {
259        if let Some((package, name)) = parse_ridl_type_ref(type_ref) {
260            self.resolve_named_type(&package, &name, Some((type_ref, ctx)))?;
261        }
262        Ok(())
263    }
264
265    pub fn resolve_named_type(
266        &mut self,
267        package: &str,
268        name: &str,
269        from: Option<(&str, &ResolveContext)>,
270    ) -> Result<()> {
271        let mut visiting: BTreeSet<(String, String)> = BTreeSet::new();
272        self.resolve_named_type_inner(package, name, from, &mut visiting)
273    }
274
275    /// Inner recursive resolver that detects cycles. A self-referential
276    /// or mutually-recursive .msg used to stack-overflow here; now we
277    /// short-circuit on revisit (the dependency closure is already
278    /// being satisfied by an outer frame, so the second visit can be a
279    /// no-op without losing the field list).
280    fn resolve_named_type_inner(
281        &mut self,
282        package: &str,
283        name: &str,
284        from: Option<(&str, &ResolveContext)>,
285        visiting: &mut BTreeSet<(String, String)>,
286    ) -> Result<()> {
287        let key = (package.to_string(), name.to_string());
288        if self.cache.contains_key(&key) {
289            return Ok(());
290        }
291        if !visiting.insert(key.clone()) {
292            // Cycle: we're already resolving this type higher up the
293            // call stack. Bail without re-parsing — the outer frame
294            // will populate the cache once it returns.
295            return Ok(());
296        }
297        let full_type = ros_msg_type_fmt(package, name);
298        let path = self
299            .find_msg_path(package, name)
300            .with_context(|| format_resolve_error(&full_type, from, &self.include_paths))?;
301        let spec = parse_msg_file(package, name, &path)?;
302        for field in &spec.fields {
303            if let MsgTypeRef::Named { package, name } = &field.type_ref {
304                let dep_ctx = ResolveContext {
305                    namespace: Some(spec.package.clone()),
306                    interface_kind: Some("msg"),
307                    interface_name: Some(spec.name.clone()),
308                    field_name: Some(field.name.clone()),
309                };
310                self.resolve_named_type_inner(
311                    package,
312                    name,
313                    Some((&ros_msg_type_fmt(package, name), &dep_ctx)),
314                    visiting,
315                )?;
316            }
317        }
318        visiting.remove(&key);
319        self.cache.insert(key, spec);
320        Ok(())
321    }
322
323    pub fn resolve_all_in_index(&mut self, verbose: bool, skip_count: &mut usize) -> Result<()> {
324        let keys: Vec<_> = self.index.keys().cloned().collect();
325        for (package, name) in keys {
326            if let Err(e) = self.resolve_named_type(&package, &name, None) {
327                if verbose {
328                    eprintln!(
329                        "[robonix-codegen] warning: skipping {}/{}: {:#}",
330                        package, name, e
331                    );
332                } else {
333                    *skip_count += 1;
334                }
335            }
336        }
337        Ok(())
338    }
339
340    pub fn find_msg_path(&self, package: &str, name: &str) -> Option<PathBuf> {
341        let candidates = package_aliases(package);
342        for candidate in candidates {
343            if let Some(path) = self.index.get(&(candidate.to_string(), name.to_string())) {
344                return Some(path.clone());
345            }
346        }
347        None
348    }
349
350    pub fn ordered_specs(&self) -> Vec<&MsgSpec> {
351        let mut keys: Vec<_> = self.cache.keys().cloned().collect();
352        keys.sort();
353        keys.iter()
354            .filter_map(|key| self.cache.get(key))
355            .collect::<Vec<_>>()
356    }
357
358    pub fn resolve_all_srv(&mut self, verbose: bool, skip_count: &mut usize) -> Result<()> {
359        let keys: Vec<_> = self.srv_index.keys().cloned().collect();
360        for (package, name) in keys {
361            if let Err(e) = self.resolve_srv(&package, &name) {
362                if verbose {
363                    eprintln!(
364                        "[robonix-codegen] warning: skipping srv {}/{}: {:#}",
365                        package, name, e
366                    );
367                } else {
368                    *skip_count += 1;
369                }
370            }
371        }
372        Ok(())
373    }
374
375    pub fn resolve_srv(&mut self, package: &str, name: &str) -> Result<()> {
376        let key = (package.to_string(), name.to_string());
377        if self.srv_cache.contains_key(&key) {
378            return Ok(());
379        }
380        let path = self.find_srv_path(package, name).with_context(|| {
381            format!("{RIDLC_ERR_PREFIX} .srv file not found: {package}/srv/{name}")
382        })?;
383        let spec = parse_srv_file(package, name, &path)?;
384        // Resolve all message types referenced in request and response
385        for field in spec
386            .request
387            .fields
388            .iter()
389            .chain(spec.response.fields.iter())
390        {
391            if let MsgTypeRef::Named {
392                package: pkg,
393                name: nm,
394            } = &field.type_ref
395            {
396                let dep_ctx = ResolveContext {
397                    namespace: Some(spec.package.clone()),
398                    interface_kind: Some("srv"),
399                    interface_name: Some(spec.name.clone()),
400                    field_name: Some(field.name.clone()),
401                };
402                self.resolve_named_type(pkg, nm, Some((&ros_msg_type_fmt(pkg, nm), &dep_ctx)))?;
403            }
404        }
405        self.srv_cache.insert(key, spec);
406        Ok(())
407    }
408
409    pub fn find_srv_path(&self, package: &str, name: &str) -> Option<PathBuf> {
410        let candidates = package_aliases(package);
411        for candidate in candidates {
412            if let Some(path) = self
413                .srv_index
414                .get(&(candidate.to_string(), name.to_string()))
415            {
416                return Some(path.clone());
417            }
418        }
419        None
420    }
421
422    pub fn ordered_srvs(&self) -> Vec<&SrvSpec> {
423        let mut keys: Vec<_> = self.srv_cache.keys().cloned().collect();
424        keys.sort();
425        keys.iter()
426            .filter_map(|key| self.srv_cache.get(key))
427            .collect::<Vec<_>>()
428    }
429
430    pub fn srv_spec(&self, package: &str, name: &str) -> Option<&SrvSpec> {
431        self.srv_cache.get(&(package.to_string(), name.to_string()))
432    }
433}
434
435pub fn index_msg_files(root: &Path, index: &mut HashMap<(String, String), PathBuf>) -> Result<()> {
436    if !root.exists() {
437        return Ok(());
438    }
439    for entry in fs::read_dir(root).with_context(|| {
440        format!(
441            "{RIDLC_ERR_PREFIX} failed to read include directory '{}' (check that path exists)",
442            root.display()
443        )
444    })? {
445        let entry = entry?;
446        let path = entry.path();
447        if path.is_dir() {
448            index_msg_files(&path, index)?;
449            continue;
450        }
451        if path.extension().and_then(|s| s.to_str()) != Some("msg") {
452            continue;
453        }
454        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
455            continue;
456        };
457        let Some(package) = infer_package_name(&path) else {
458            continue;
459        };
460        index.entry((package, stem.to_string())).or_insert(path);
461    }
462    Ok(())
463}
464
465pub fn index_srv_files(root: &Path, index: &mut HashMap<(String, String), PathBuf>) -> Result<()> {
466    if !root.exists() {
467        return Ok(());
468    }
469    for entry in fs::read_dir(root).with_context(|| {
470        format!(
471            "{RIDLC_ERR_PREFIX} failed to read include directory '{}' (check that path exists)",
472            root.display()
473        )
474    })? {
475        let entry = entry?;
476        let path = entry.path();
477        if path.is_dir() {
478            index_srv_files(&path, index)?;
479            continue;
480        }
481        if path.extension().and_then(|s| s.to_str()) != Some("srv") {
482            continue;
483        }
484        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
485            continue;
486        };
487        let Some(package) = infer_srv_package_name(&path) else {
488            continue;
489        };
490        index.entry((package, stem.to_string())).or_insert(path);
491    }
492    Ok(())
493}
494
495pub fn infer_srv_package_name(path: &Path) -> Option<String> {
496    let parent = path.parent()?;
497    let parent_name = parent.file_name()?.to_str()?;
498    if parent_name == "srv" {
499        return parent
500            .parent()?
501            .file_name()?
502            .to_str()
503            .map(|s| s.to_string());
504    }
505    Some(parent_name.to_string())
506}
507
508/// Strip legacy `# @robonix.grpc ...` lines from the top of `.srv` files.
509fn strip_leading_robonix_grpc_lines(src: &str) -> String {
510    let mut s = src.to_string();
511    loop {
512        let t = s.trim_start();
513        let first = t.lines().next().unwrap_or("").trim();
514        if first.starts_with("# @robonix.grpc") {
515            if let Some(pos) = s.find('\n') {
516                s = s[pos + 1..].to_string();
517                continue;
518            }
519            s.clear();
520            break;
521        }
522        break;
523    }
524    s
525}
526
527pub fn parse_srv_file(package: &str, name: &str, path: &Path) -> Result<SrvSpec> {
528    let src = fs::read_to_string(path).with_context(|| {
529        format!(
530            "{RIDLC_ERR_PREFIX} failed to read .srv file '{}'",
531            path.display()
532        )
533    })?;
534    let body = strip_leading_robonix_grpc_lines(&src);
535
536    // Split on a line that's exactly `---` after trimming. The
537    // previous `splitn(2, "---")` matched `---` as a substring, so a
538    // comment like `# --- separator` would split mid-comment and
539    // wreck the response section. Line-based split avoids that.
540    let mut request_lines: Vec<&str> = Vec::new();
541    let mut response_lines: Vec<&str> = Vec::new();
542    let mut in_response = false;
543    for line in body.lines() {
544        if !in_response && line.trim() == "---" {
545            in_response = true;
546            continue;
547        }
548        if in_response {
549            response_lines.push(line);
550        } else {
551            request_lines.push(line);
552        }
553    }
554    let request_src = request_lines.join("\n");
555    let response_src = response_lines.join("\n");
556
557    let request = parse_msg_section(package, &format!("{name}_Request"), &request_src)
558        .with_context(|| format!("{RIDLC_ERR_PREFIX} parsing request of '{}'", path.display()))?;
559    let response = parse_msg_section(package, &format!("{name}_Response"), &response_src)
560        .with_context(|| {
561            format!(
562                "{RIDLC_ERR_PREFIX} parsing response of '{}'",
563                path.display()
564            )
565        })?;
566
567    Ok(SrvSpec {
568        package: package.to_string(),
569        name: name.to_string(),
570        request,
571        response,
572    })
573}
574
575fn parse_msg_section(package: &str, name: &str, src: &str) -> Result<MsgSpec> {
576    let fields = parse_fields_from_lines(package, src, None)?;
577    Ok(MsgSpec {
578        package: package.to_string(),
579        name: name.to_string(),
580        fields,
581    })
582}
583
584/// Single field-parsing pass shared between `parse_msg_file` and
585/// `parse_msg_section` (for srv request / response bodies). Captures:
586///
587///   - constants (`int32 FOO = 42`) are skipped (current scope; could
588///     be promoted to first-class `MsgConstant` entries later)
589///   - trailing comments (`float64 x  # in metres`) → `field.description`
590///   - `string<=N` upper bounds → `field.string_max_len`
591///
592/// `path_hint` is used purely for error messages.
593fn parse_fields_from_lines(
594    package: &str,
595    src: &str,
596    path_hint: Option<&Path>,
597) -> Result<Vec<MsgField>> {
598    let mut fields = Vec::new();
599    for raw_line in src.lines() {
600        // Split off the trailing comment (if any). The comment text
601        // is preserved as `description` so MCP JSON Schema can show
602        // it to the LLM.
603        let (code, comment) = match raw_line.find('#') {
604            Some(idx) => (&raw_line[..idx], raw_line[idx + 1..].trim()),
605            None => (raw_line, ""),
606        };
607        let code = code.trim();
608        if code.is_empty() {
609            continue;
610        }
611        // ROS constants like `int32 FOO=42` use `=`. Skip; constant
612        // promotion to first-class entries is deferred (B2).
613        if code.contains('=') {
614            continue;
615        }
616        let mut parts = code.split_whitespace();
617        let Some(raw_type) = parts.next() else {
618            continue;
619        };
620        let Some(raw_name) = parts.next() else {
621            continue;
622        };
623        let (type_ref, is_array, array_size, string_max_len) =
624            parse_msg_field_type(package, raw_type).with_context(|| match path_hint {
625                Some(p) => format!(
626                    "{RIDLC_ERR_PREFIX} invalid field in '{}' at line with type '{}'",
627                    p.display(),
628                    raw_type
629                ),
630                None => format!(
631                    "{RIDLC_ERR_PREFIX} invalid field at line with type '{}'",
632                    raw_type
633                ),
634            })?;
635        fields.push(MsgField {
636            name: raw_name.to_string(),
637            type_ref,
638            is_array,
639            array_size,
640            string_max_len,
641            description: comment.to_string(),
642        });
643    }
644    Ok(fields)
645}
646
647pub fn infer_package_name(path: &Path) -> Option<String> {
648    let parent = path.parent()?;
649    let parent_name = parent.file_name()?.to_str()?;
650    if parent_name == "msg" {
651        return parent
652            .parent()?
653            .file_name()?
654            .to_str()
655            .map(|s| s.to_string());
656    }
657    Some(parent_name.to_string())
658}
659
660pub fn parse_msg_file(package: &str, name: &str, path: &Path) -> Result<MsgSpec> {
661    let src = fs::read_to_string(path).with_context(|| {
662        format!(
663            "{RIDLC_ERR_PREFIX} failed to read .msg file '{}'",
664            path.display()
665        )
666    })?;
667    let fields = parse_fields_from_lines(package, &src, Some(path))?;
668    Ok(MsgSpec {
669        package: package.to_string(),
670        name: name.to_string(),
671        fields,
672    })
673}
674
675/// Parse the type-side of one .msg field line. Returns
676/// `(type_ref, is_array, array_size, string_max_len)`.
677///
678/// `string_max_len` carries the `<=N` upper bound on `string<=N` /
679/// `wstring<=N` declarations (preserved into MCP JSON Schema as
680/// `maxLength`). It only applies to the string primitives.
681pub fn parse_msg_field_type(
682    current_package: &str,
683    raw_type: &str,
684) -> Result<(MsgTypeRef, bool, Option<usize>, Option<usize>)> {
685    // Capture string-bound `<=N` BEFORE stripping it, so we can
686    // surface it on MsgField. Only meaningful for string/wstring;
687    // other types ignore it.
688    let (without_bound, bound) = match raw_type.split_once("<=") {
689        Some((lhs, rhs)) => {
690            let n = rhs
691                .trim_matches(|c: char| c.is_whitespace() || c == ']' || c == '[')
692                .split(|c: char| !c.is_ascii_digit())
693                .next()
694                .unwrap_or("")
695                .parse::<usize>()
696                .ok();
697            (lhs.trim(), n)
698        }
699        None => (raw_type.trim(), None),
700    };
701    let normalized = without_bound;
702    let (base_type, is_array, array_size) = if let Some(idx) = normalized.find('[') {
703        let bracket = &normalized[idx..]; // e.g. "[]" or "[9]"
704        let size = bracket
705            .trim_matches(|c| c == '[' || c == ']')
706            .parse::<usize>()
707            .ok(); // None for "[]", Some(N) for "[N]"
708        (&normalized[..idx], true, size)
709    } else {
710        (normalized, false, None)
711    };
712    let base_type = base_type.trim();
713    if base_type.is_empty() {
714        bail!("{RIDLC_ERR_PREFIX} empty message field type in .msg file");
715    }
716    let string_max_len = match base_type {
717        "string" | "wstring" => bound,
718        _ => None,
719    };
720    if RosPrimitive::parse(base_type).is_some() {
721        return Ok((
722            MsgTypeRef::Primitive(base_type.to_string()),
723            is_array,
724            array_size,
725            string_max_len,
726        ));
727    }
728    // `pkg/msg/TypeName` (ROS fully-qualified message type)
729    let segs: Vec<&str> = base_type.split('/').collect();
730    if segs.len() == 3 && segs[1] == "msg" {
731        let pkg = segs[0].trim();
732        let nm = segs[2].trim();
733        if pkg.is_empty() || nm.is_empty() {
734            bail!(
735                "{RIDLC_ERR_PREFIX} invalid pkg/msg/Name reference '{}': empty package or name",
736                base_type
737            );
738        }
739        return Ok((
740            MsgTypeRef::Named {
741                package: pkg.to_string(),
742                name: nm.to_string(),
743            },
744            is_array,
745            array_size,
746            string_max_len,
747        ));
748    }
749    if let Some((package, name)) = base_type.split_once('/') {
750        let pkg = package.trim();
751        let nm = name.trim();
752        if pkg.is_empty() || nm.is_empty() {
753            bail!(
754                "{RIDLC_ERR_PREFIX} invalid pkg/Name reference '{}': empty package or name",
755                base_type
756            );
757        }
758        return Ok((
759            MsgTypeRef::Named {
760                package: pkg.to_string(),
761                name: nm.to_string(),
762            },
763            is_array,
764            array_size,
765            string_max_len,
766        ));
767    }
768    Ok((
769        MsgTypeRef::Named {
770            package: current_package.to_string(),
771            name: base_type.to_string(),
772        },
773        is_array,
774        array_size,
775        string_max_len,
776    ))
777}
778
779/// Back-compat alias around the canonical `RosPrimitive::parse`. Kept
780/// because external callers may still spell it this way.
781pub fn is_ros_primitive(raw: &str) -> bool {
782    RosPrimitive::parse(raw).is_some()
783}
784
785/// Parse a fully-qualified IDL type ref of the form `pkg/msg/Name` or
786/// `pkg/srv/Name`. Returns `(package, name)` — the middle segment is
787/// dropped after validation. Use `parse_qualified_type_ref` if the
788/// caller needs to know whether it was a msg or srv reference.
789///
790/// Trailing `[]` (array-ness in TOML refs) is stripped from the name.
791pub fn parse_ridl_type_ref(type_ref: &str) -> Option<(String, String)> {
792    parse_qualified_type_ref(type_ref).map(|(_, p, n)| (p, n))
793}
794
795/// Like `parse_ridl_type_ref` but also returns whether the middle was
796/// `msg` or `srv`. Returns `None` for any other middle segment or for
797/// shapes that aren't `pkg/<msg|srv>/Name`.
798pub fn parse_qualified_type_ref(type_ref: &str) -> Option<(IdlKind, String, String)> {
799    let trimmed = type_ref.trim();
800    let mut parts = trimmed.split('/');
801    let package = parts.next()?;
802    let middle = parts.next()?;
803    let mut name = parts.next()?.to_string();
804    if parts.next().is_some() {
805        return None;
806    }
807    let kind = match middle {
808        "msg" => IdlKind::Msg,
809        "srv" => IdlKind::Srv,
810        _ => return None,
811    };
812    if package.is_empty() || name.is_empty() {
813        return None;
814    }
815    if name.ends_with("[]") {
816        name.truncate(name.len().saturating_sub(2));
817    }
818    Some((kind, package.to_string(), name))
819}
820
821#[derive(Clone, Copy, Debug, PartialEq, Eq)]
822pub enum IdlKind {
823    Msg,
824    Srv,
825}
826
827pub fn ros_msg_type_fmt(package: &str, name: &str) -> String {
828    format!("{package}/msg/{name}")
829}
830
831pub fn package_aliases(package: &str) -> Vec<&str> {
832    match package {
833        "robonix_msgs" => vec!["robonix_msgs", "robonix_msg"],
834        _ => vec![package],
835    }
836}
837
838pub fn format_resolve_error(
839    full_type: &str,
840    from: Option<(&str, &ResolveContext)>,
841    include_paths: &[PathBuf],
842) -> String {
843    let mut msg = format!(
844        "{RIDLC_ERR_PREFIX} failed to resolve ROS message type '{}'",
845        full_type
846    );
847    if let Some((_type_ref, ctx)) = from {
848        let mut parts = Vec::new();
849        if let Some(ref ns) = ctx.namespace {
850            parts.push(format!("namespace '{ns}'"));
851        }
852        if let (Some(kind), Some(ref name)) = (ctx.interface_kind, ctx.interface_name.as_ref()) {
853            parts.push(format!("{kind} '{name}'"));
854        }
855        if let Some(ref f) = ctx.field_name {
856            parts.push(format!("field '{f}'"));
857        }
858        if !parts.is_empty() {
859            msg.push_str(&format!("\n  referenced from: {}", parts.join(", ")));
860        }
861    }
862    msg.push_str("\n  searched include paths:");
863    for p in include_paths {
864        msg.push_str(&format!("\n    - {}", p.display()));
865    }
866    msg.push_str("\n  hint: ensure the .msg file exists (e.g. <include>/common_interfaces/std_msgs/msg/Float64.msg)");
867    msg
868}