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