Skip to main content

robonix_codegen/codegen/
ros2_gen.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// ROS 2 colcon-package generator: capabilities/lib IDL -> a self-contained
3// colcon overlay. Robonix pins one canonical definition of every type it
4// uses; vendors build their ROS 2 nodes against this overlay (not the
5// distro's messages), so the wire types are always the Robonix ones.
6//
7// Emits, under `<out>/src/`, one ament_cmake interface package per ROS
8// package (chassis, geometry_msgs, ...) with package.xml + CMakeLists.txt +
9// its msg/srv. Cross-package refs use the `pkg/Type` convention, so
10// per-package granularity is the layout the IDL requires.
11//
12// .msg/.srv are copied raw (preserving constants like `uint8 CMD_INIT=0`
13// and comments, which the shared parser drops); normalize_idl only rewrites
14// `pkg/msg/Type` refs to rosidl's `pkg/Type` form and defuses `*/` in
15// comments.
16
17use anyhow::{Context, Result};
18use std::collections::{BTreeMap, BTreeSet};
19use std::fmt::Write as FmtWrite;
20use std::fs;
21use std::path::Path;
22
23use super::msg_parser::{MsgResolver, MsgTypeRef};
24
25const PKG_VERSION: &str = "0.1.0";
26const MAINTAINER: &str = "robonix";
27const MAINTAINER_EMAIL: &str = "robonix@syswonder.org";
28const LICENSE: &str = "MulanPSL-2.0";
29
30/// Make a raw `.msg`/`.srv` file safe for rosidl. Two rewrites, both on
31/// the code portion / comment portion respectively; constants, the
32/// `---` separator and blank lines pass through untouched.
33///
34/// First, rewrite `pkg/msg/Type` / `pkg/srv/Type` field refs to the
35/// `pkg/Type` form rosidl expects (code portion only — trailing
36/// comments may legitimately mention the 3-part spelling).
37///
38/// Second, defuse any literal `*/` in comments: rosidl_generator_c emits
39/// a message's description as a C `/* ... */` doc block, so a `*/` inside
40/// the text (e.g. `linear_*/angular_*`) closes the comment early and
41/// spills the rest as invalid C. Splitting it to `* /` keeps the prose
42/// readable without breaking generation.
43fn normalize_idl(src: &str) -> String {
44    let mut out = String::with_capacity(src.len());
45    for line in src.lines() {
46        match line.split_once('#') {
47            Some((code, comment)) => {
48                out.push_str(&code.replace("/msg/", "/").replace("/srv/", "/"));
49                out.push('#');
50                out.push_str(&comment.replace("*/", "* /"));
51            }
52            None => out.push_str(&line.replace("/msg/", "/").replace("/srv/", "/")),
53        }
54        out.push('\n');
55    }
56    out
57}
58
59fn package_xml(pkg: &str, deps: &BTreeSet<String>) -> String {
60    let mut s = String::new();
61    let _ = writeln!(s, "<?xml version=\"1.0\"?>");
62    // NB: no `--` in this comment — illegal inside XML comments.
63    let _ = writeln!(s, "<!-- @generated by robonix-codegen (lang ros2) -->");
64    let _ = writeln!(s, "<package format=\"3\">");
65    let _ = writeln!(s, "  <name>{pkg}</name>");
66    let _ = writeln!(s, "  <version>{PKG_VERSION}</version>");
67    let _ = writeln!(
68        s,
69        "  <description>Robonix canonical ROS 2 interfaces — {pkg} (generated).</description>"
70    );
71    let _ = writeln!(
72        s,
73        "  <maintainer email=\"{MAINTAINER_EMAIL}\">{MAINTAINER}</maintainer>"
74    );
75    let _ = writeln!(s, "  <license>{LICENSE}</license>");
76    let _ = writeln!(s);
77    let _ = writeln!(s, "  <buildtool_depend>ament_cmake</buildtool_depend>");
78    let _ = writeln!(
79        s,
80        "  <buildtool_depend>rosidl_default_generators</buildtool_depend>"
81    );
82    let _ = writeln!(s);
83    for d in deps {
84        let _ = writeln!(s, "  <depend>{d}</depend>");
85    }
86    if !deps.is_empty() {
87        let _ = writeln!(s);
88    }
89    let _ = writeln!(s, "  <exec_depend>rosidl_default_runtime</exec_depend>");
90    let _ = writeln!(
91        s,
92        "  <member_of_group>rosidl_interface_packages</member_of_group>"
93    );
94    let _ = writeln!(s);
95    let _ = writeln!(s, "  <export>");
96    let _ = writeln!(s, "    <build_type>ament_cmake</build_type>");
97    let _ = writeln!(s, "  </export>");
98    let _ = writeln!(s, "</package>");
99    s
100}
101
102fn cmakelists(pkg: &str, msgs: &[String], srvs: &[String], deps: &BTreeSet<String>) -> String {
103    let mut s = String::new();
104    let _ = writeln!(s, "# @generated by robonix-codegen --lang ros2");
105    let _ = writeln!(s, "cmake_minimum_required(VERSION 3.8)");
106    let _ = writeln!(s, "project({pkg})");
107    let _ = writeln!(s);
108    let _ = writeln!(s, "find_package(ament_cmake REQUIRED)");
109    let _ = writeln!(s, "find_package(rosidl_default_generators REQUIRED)");
110    for d in deps {
111        let _ = writeln!(s, "find_package({d} REQUIRED)");
112    }
113    let _ = writeln!(s);
114    let _ = writeln!(s, "rosidl_generate_interfaces(${{PROJECT_NAME}}");
115    for m in msgs {
116        let _ = writeln!(s, "  \"msg/{m}.msg\"");
117    }
118    for sv in srvs {
119        let _ = writeln!(s, "  \"srv/{sv}.srv\"");
120    }
121    if !deps.is_empty() {
122        let joined: Vec<&str> = deps.iter().map(|s| s.as_str()).collect();
123        let _ = writeln!(s, "  DEPENDENCIES {}", joined.join(" "));
124    }
125    let _ = writeln!(s, ")");
126    let _ = writeln!(s);
127    let _ = writeln!(s, "ament_package()");
128    s
129}
130
131/// Per-package emit plan: which msgs/srvs land in it and what it depends on.
132#[derive(Default)]
133struct PkgPlan {
134    msgs: BTreeSet<String>,
135    srvs: BTreeSet<String>,
136    deps: BTreeSet<String>,
137}
138
139pub fn generate(resolver: &MsgResolver, out_dir: &Path, verbose: bool) -> Result<()> {
140    // Bucket every resolved msg/srv into per-package plans, including the
141    // standard std_msgs / geometry_msgs / builtin_interfaces families.
142    // Robonix pins one canonical definition of every type it uses, so the
143    // overlay is self-contained: it carries its own std_msgs etc. rather
144    // than depending on the distro's (which may differ field-for-field).
145    let mut plans: BTreeMap<String, PkgPlan> = BTreeMap::new();
146    for ((pkg, name), spec) in &resolver.cache {
147        let plan = plans.entry(pkg.clone()).or_default();
148        plan.msgs.insert(name.clone());
149        for f in &spec.fields {
150            if let MsgTypeRef::Named { package, .. } = &f.type_ref
151                && package != pkg
152            {
153                plan.deps.insert(package.clone());
154            }
155        }
156    }
157    for ((pkg, name), srv) in &resolver.srv_cache {
158        let plan = plans.entry(pkg.clone()).or_default();
159        plan.srvs.insert(name.clone());
160        for f in srv.request.fields.iter().chain(srv.response.fields.iter()) {
161            if let MsgTypeRef::Named { package, .. } = &f.type_ref
162                && package != pkg
163            {
164                plan.deps.insert(package.clone());
165            }
166        }
167    }
168
169    // Emit.
170    let src_root = out_dir.join("src");
171    fs::create_dir_all(&src_root)?;
172    let mut n_pkgs = 0usize;
173    let mut n_msgs = 0usize;
174    let mut n_srvs = 0usize;
175    for (pkg, plan) in &plans {
176        let pkg_dir = src_root.join(pkg);
177        let msgs: Vec<String> = plan.msgs.iter().cloned().collect();
178        let srvs: Vec<String> = plan.srvs.iter().cloned().collect();
179
180        if !msgs.is_empty() {
181            fs::create_dir_all(pkg_dir.join("msg"))?;
182        }
183        for m in &msgs {
184            let Some(path) = resolver.find_msg_path(pkg, m) else {
185                if verbose {
186                    eprintln!("[robonix-codegen] ros2: missing .msg for {pkg}/{m}, skipping");
187                }
188                continue;
189            };
190            let raw =
191                fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
192            fs::write(
193                pkg_dir.join("msg").join(format!("{m}.msg")),
194                normalize_idl(&raw),
195            )?;
196            n_msgs += 1;
197        }
198
199        if !srvs.is_empty() {
200            fs::create_dir_all(pkg_dir.join("srv"))?;
201        }
202        for sv in &srvs {
203            let Some(path) = resolver.find_srv_path(pkg, sv) else {
204                if verbose {
205                    eprintln!("[robonix-codegen] ros2: missing .srv for {pkg}/{sv}, skipping");
206                }
207                continue;
208            };
209            let raw =
210                fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
211            fs::write(
212                pkg_dir.join("srv").join(format!("{sv}.srv")),
213                normalize_idl(&raw),
214            )?;
215            n_srvs += 1;
216        }
217
218        fs::write(pkg_dir.join("package.xml"), package_xml(pkg, &plan.deps))?;
219        fs::write(
220            pkg_dir.join("CMakeLists.txt"),
221            cmakelists(pkg, &msgs, &srvs, &plan.deps),
222        )?;
223        n_pkgs += 1;
224        if verbose {
225            eprintln!(
226                "[robonix-codegen] ros2: {pkg} ({} msg, {} srv, deps: {})",
227                msgs.len(),
228                srvs.len(),
229                plan.deps.iter().cloned().collect::<Vec<_>>().join(" ")
230            );
231        }
232    }
233
234    eprintln!(
235        "[robonix-codegen] ros2: {n_pkgs} packages, {n_msgs} msg, {n_srvs} srv -> {}/src",
236        out_dir.display()
237    );
238    Ok(())
239}