roslibrust_codegen 0.21.0

An library for generating rust type definitions from ROS IDL files
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
use crate::utils::{Package, RosVersion};
use crate::{bail, ArrayType, Error};
use crate::{ConstantInfo, FieldInfo, FieldType, RosLiteral};
use std::collections::HashMap;

mod action;
pub use action::{parse_ros_action_file, ParsedActionFile};
mod msg;
pub use msg::{parse_ros_message_file, ParsedMessageFile};
mod srv;
pub use srv::{parse_ros_service_file, ParsedServiceFile};

// Note: time and duration are primitives in ROS1, but not used in ROS2
// List of types which are "individual data fields" and not containers for other data
pub const ROS_PRIMITIVE_TYPE_LIST: [&str; 17] = [
    "bool", "int8", "uint8", "byte", "char", "int16", "uint16", "int32", "uint32", "int64",
    "uint64", "float32", "float64", "string", "wstring", "time", "duration",
];

lazy_static::lazy_static! {
    pub static ref ROS_TYPE_TO_RUST_TYPE_MAP: HashMap<&'static str, &'static str> = vec![
        ("bool", "bool"),
        ("int8", "i8"),
        ("uint8", "u8"),
        ("byte", "u8"),
        ("char", "u8"), // NOTE: a rust char != C++ char
        ("int16", "i16"),
        ("uint16", "u16"),
        ("int32", "i32"),
        ("uint32", "u32"),
        ("int64", "i64"),
        ("uint64", "u64"),
        ("float32", "f32"),
        ("float64", "f64"),
        ("string", "::std::string::String"),
        ("time", "::roslibrust::codegen::integral_types::Time"),
        ("duration", "::roslibrust::codegen::integral_types::Duration"),
    ].into_iter().collect();

    pub static ref ROS_2_TYPE_TO_RUST_TYPE_MAP: HashMap<&'static str, &'static str> = vec![
        ("bool", "bool"),
        ("int8", "i8"),
        ("uint8", "u8"),
        ("byte", "u8"),
        ("char", "u8"),
        ("int16", "i16"),
        ("uint16", "u16"),
        ("int32", "i32"),
        ("uint32", "u32"),
        ("int64", "i64"),
        ("uint64", "u64"),
        ("float32", "f32"),
        ("float64", "f64"),
        ("string", "::std::string::String"),
        ("builtin_interfaces/Time", "::roslibrust::codegen::integral_types::Time"),
        ("builtin_interfaces/Duration", "::roslibrust::codegen::integral_types::Duration"),
        // ("wstring", TODO),
    ].into_iter().collect();
}

pub fn is_intrinsic_type(version: RosVersion, ros_type: &str) -> bool {
    match version {
        // Treat time and duration as intrinsic in ROS1
        RosVersion::ROS1 => ROS_TYPE_TO_RUST_TYPE_MAP.contains_key(ros_type),
        // In ros 2 builtin_interfaces/Time and builtin_interfaces/Duration are not intrinsic
        RosVersion::ROS2 => ROS_PRIMITIVE_TYPE_LIST.contains(&ros_type),
    }
}

pub fn convert_ros_type_to_rust_type(version: RosVersion, ros_type: &str) -> Option<&'static str> {
    match version {
        RosVersion::ROS1 => ROS_TYPE_TO_RUST_TYPE_MAP.get(ros_type).copied(),
        RosVersion::ROS2 => ROS_2_TYPE_TO_RUST_TYPE_MAP.get(ros_type).copied(),
    }
}

fn parse_field(line: &str, pkg: &Package, msg_name: &str) -> Result<FieldInfo, Error> {
    let pkg_name = pkg.name.as_str();
    let (field_type, remainder) = split_type_from_line(line).ok_or(Error::new(format!(
        "Did not find field_type and field_name on line: {line} while parsing {pkg_name}/{msg_name}"
    )))?;
    let field_type = parse_type(field_type, pkg)?;
    let (field_name, default) = parse_field_name_and_default(remainder).ok_or(Error::new(
        format!("Did not find field_name on line: {line} while parsing {pkg_name}/{msg_name}"),
    ))?;

    let default = if matches!(pkg.version, Some(RosVersion::ROS2)) {
        default.map(RosLiteral::from)
    } else {
        None
    };

    Ok(FieldInfo {
        field_type,
        field_name: field_name.to_string(),
        default,
    })
}

fn parse_constant_field(line: &str, pkg: &Package) -> Result<ConstantInfo, Error> {
    let (constant_type, remainder) = split_type_from_line(line).ok_or(Error::new(format!(
        "Failed to find white space separator while parsing constant information on line {line} for package {pkg:?}"
    )))?;
    let equal = remainder.find('=').ok_or(
        Error::new(format!("Failed to find expected '=' while parsing constant information on line {line} for package {pkg:?}"))
    )?;
    let constant_type = parse_type(constant_type, pkg)?.field_type;
    let constant_name = remainder[..equal].trim().to_string();
    let constant_value = remainder[equal + 1..].trim().to_string();

    Ok(ConstantInfo {
        constant_type,
        constant_name,
        constant_value: constant_value.into(),
    })
}

fn split_type_from_line(line: &str) -> Option<(&str, &str)> {
    let type_end = line.find(char::is_whitespace)?;
    let remainder = line[type_end..].trim_start();
    if remainder.is_empty() {
        return None;
    }
    Some((&line[..type_end], remainder))
}

fn parse_field_name_and_default(remainder: &str) -> Option<(&str, Option<String>)> {
    match remainder.find(char::is_whitespace) {
        Some(name_end) => {
            let default = remainder[name_end..].trim();
            if default.is_empty() {
                Some((&remainder[..name_end], None))
            } else {
                Some((&remainder[..name_end], Some(default.to_owned())))
            }
        }
        None => Some((remainder, None)),
    }
}

/// Looks for # comment character and sub-slices for characters preceding it
/// Note: This should NOT be used for lines that contain string constants,
/// as # characters within string constant values are part of the value.
fn strip_comments(line: &str) -> &str {
    if let Some(token) = line.find('#') {
        return &line[..token];
    }
    line
}

/// Strips comments from a line, but respects string constant values.
/// For string constants, # characters within the value are NOT treated as comments per ROS spec.
fn strip_comments_respecting_string_constants(line: &str) -> &str {
    let trimmed = line.trim_start();

    if is_string_constant_line(trimmed) {
        return line;
    }

    // For everything else (fields, non-string constants, comments), strip normally
    strip_comments(line)
}

fn is_string_constant_line(line: &str) -> bool {
    let Some((type_name, remainder)) = split_type_from_line(line) else {
        return false;
    };
    (type_name == "string"
        || type_name == "wstring"
        || type_name.starts_with("string<=")
        || type_name.starts_with("wstring<="))
        && remainder.contains('=')
}

fn parse_field_type(
    type_str: &str,
    array_info: ArrayType,
    pkg: &Package,
) -> Result<FieldType, Error> {
    let items = type_str.split('/').collect::<Vec<&str>>();

    if items.len() == 1 {
        // If there is only one item (no package redirect)
        let pkg_version = pkg.version.unwrap_or(RosVersion::ROS1);

        let (field_type, string_capacity) = parse_bounded_string(items[0])?;

        Ok(FieldType {
            package_name: if is_intrinsic_type(pkg_version, &field_type) {
                // If it is a fundamental type, no package
                None
            } else {
                // Very special case for "Header"
                if type_str == "Header" {
                    Some("std_msgs".to_owned())
                } else {
                    // Otherwise it is referencing another message in the same package
                    Some(pkg.name.clone())
                }
            },
            source_package: pkg.name.clone(),
            field_type,
            array_info,
            string_capacity,
        })
    } else {
        // If there is more than one item there is a package redirect

        Ok(FieldType {
            package_name: Some(items[0].to_string()),
            source_package: pkg.name.clone(),
            field_type: items[1].to_string(),
            string_capacity: None,
            array_info,
        })
    }
}

/// Specifically handles bounded string types, e.g. "string<=10"
/// Returns the field_type and the string_capacity if it is a bounded string
/// Otherwise returns the original type and None for the capacity
fn parse_bounded_string(type_str: &str) -> Result<(String, Option<usize>), Error> {
    if let Some(stripped) = type_str.strip_prefix("string<=") {
        let capacity = stripped.parse::<usize>().map_err(|err| {
            Error::new(format!(
                "Unable to parse capacity of bounded string: {type_str}: {err}"
            ))
        })?;
        Ok(("string".to_string(), Some(capacity)))
    } else {
        Ok((type_str.to_string(), None))
    }
}

/// Determines the type of a field
/// `type_str` -- Expects the part of the line containing all type information (up to the first space), e.g. "int32[3>=]"
/// `pkg` -- Reference to package this type is within, used for version information and determining relative types
fn parse_type(type_str: &str, pkg: &Package) -> Result<FieldType, Error> {
    // Handle array logic
    let open_bracket_idx = type_str.find('[');
    let close_bracket_idx = type_str.find(']');
    match (open_bracket_idx, close_bracket_idx) {
        (Some(o), Some(c)) => {
            // After having stripped array information, parse the remainder of the type
            let array_info = if c - o == 1 {
                // No size specified
                ArrayType::Unbounded
            } else {
                let fixed_size_str = &type_str[(o + 1)..c];
                let is_bounded;
                let offset;
                // Check if the first two characters are <=
                if fixed_size_str.starts_with("<=") {
                    is_bounded = true;
                    offset = 2;
                } else {
                    is_bounded = false;
                    offset = 0;
                }

                let fixed_size = fixed_size_str[offset..].parse::<usize>().map_err(|err| {
                    Error::new(format!(
                        "Unable to parse size of the array: {type_str}, defaulting to 0: {err}"
                    ))
                })?;
                if is_bounded {
                    ArrayType::Bounded(fixed_size)
                } else {
                    ArrayType::FixedLength(fixed_size)
                }
            };
            parse_field_type(&type_str[..o], array_info, pkg)
        }
        (None, None) => {
            // Not an array parse normally
            parse_field_type(type_str, ArrayType::NotArray, pkg)
        }
        _ => {
            bail!("Found malformed type: {type_str} in package {pkg:?}. Likely file is invalid.");
        }
    }
}

#[cfg(test)]
mod test {
    use crate::{
        parse::parse_type,
        utils::{Package, RosVersion},
        ArrayType,
    };

    // Simple test to just confirm fixed size logic is working correctly on the parse side
    #[test_log::test]
    fn parse_type_handles_fixed_size_correctly() {
        let line = "int32[9]";
        let pkg = Package {
            name: "test_pkg".to_string(),
            path: "./not_a_path".into(),
            version: Some(RosVersion::ROS1),
        };
        let parsed = parse_type(line, &pkg).unwrap();
        assert_eq!(parsed.array_info, ArrayType::FixedLength(9));
    }

    #[test_log::test]
    fn parse_type_handles_bounded_size_correctly() {
        let line = "int32[<=9]";
        let pkg = Package {
            name: "test_pkg".to_string(),
            path: "./not_a_path".into(),
            version: Some(RosVersion::ROS1),
        };
        let parsed = parse_type(line, &pkg).unwrap();
        assert_eq!(parsed.array_info, ArrayType::Bounded(9));
    }

    #[test_log::test]
    fn parse_type_handles_unbounded_size_correctly() {
        let line = "int32[]";
        let pkg = Package {
            name: "test_pkg".to_string(),
            path: "./not_a_path".into(),
            version: Some(RosVersion::ROS1),
        };
        let parsed = parse_type(line, &pkg).unwrap();
        assert_eq!(parsed.array_info, ArrayType::Unbounded);
    }

    #[test_log::test]
    fn parse_constant_with_hash_in_value() {
        use crate::parse::parse_constant_field;

        let pkg = Package {
            name: "test_pkg".to_string(),
            path: "./not_a_path".into(),
            version: Some(RosVersion::ROS1),
        };

        // Test parsing a constant with # in the value
        let line = "string HASH_IN_VALUE=foo # bar";
        let constant = parse_constant_field(line, &pkg).unwrap();

        assert_eq!(constant.constant_name, "HASH_IN_VALUE");
        assert_eq!(constant.constant_type, "string");
        // This should be "foo # bar" according to ROS spec
        assert_eq!(constant.constant_value.inner, "foo # bar");
    }

    #[test_log::test]
    fn parse_bounded_string_constant_with_hash_in_value() {
        use crate::parse::parse_ros_message_file;
        use std::path::Path;

        let pkg = Package {
            name: "test_pkg".to_string(),
            path: "./not_a_path".into(),
            version: Some(RosVersion::ROS2),
        };

        let msg_content = r#"string<=32 HASH_IN_VALUE='foo # bar'
string data
"#;

        let parsed =
            parse_ros_message_file(msg_content, "TestMsg", &pkg, Path::new("test.msg")).unwrap();

        assert_eq!(parsed.constants[0].constant_type, "string");
        assert_eq!(parsed.constants[0].constant_value.inner, "'foo # bar'");
    }

    #[test_log::test]
    fn parse_message_with_hash_in_string_constant() {
        use crate::parse::parse_ros_message_file;
        use std::path::Path;

        let pkg = Package {
            name: "test_pkg".to_string(),
            path: "./not_a_path".into(),
            version: Some(RosVersion::ROS1),
        };

        let msg_content = r#"# Test message
string HASH_IN_VALUE=foo # bar
string NO_SPACES=test#value
string HASH_AT_START=#start
string HASH_AT_END=end#
string data
"#;

        let parsed =
            parse_ros_message_file(msg_content, "TestMsg", &pkg, Path::new("test.msg")).unwrap();

        assert_eq!(parsed.constants.len(), 4);
        assert_eq!(parsed.fields.len(), 1);

        // Check all constants have correct values with # characters
        assert_eq!(parsed.constants[0].constant_name, "HASH_IN_VALUE");
        assert_eq!(parsed.constants[0].constant_value.inner, "foo # bar");

        assert_eq!(parsed.constants[1].constant_name, "NO_SPACES");
        assert_eq!(parsed.constants[1].constant_value.inner, "test#value");

        assert_eq!(parsed.constants[2].constant_name, "HASH_AT_START");
        assert_eq!(parsed.constants[2].constant_value.inner, "#start");

        assert_eq!(parsed.constants[3].constant_name, "HASH_AT_END");
        assert_eq!(parsed.constants[3].constant_value.inner, "end#");

        // Check field is parsed correctly
        assert_eq!(parsed.fields[0].field_name, "data");
    }

    #[test_log::test]
    fn parse_non_string_constants_with_comments() {
        use crate::parse::parse_ros_message_file;
        use std::path::Path;

        let pkg = Package {
            name: "test_pkg".to_string(),
            path: "./not_a_path".into(),
            version: Some(RosVersion::ROS1),
        };

        // Test that non-string constants still have comments stripped correctly
        let msg_content = r#"# Test message
int32 INT_CONST=42 # this is a comment
float32 FLOAT_CONST=3.14 # another comment
bool BOOL_CONST=true # yet another comment
"#;

        let parsed =
            parse_ros_message_file(msg_content, "TestMsg", &pkg, Path::new("test.msg")).unwrap();

        assert_eq!(parsed.constants.len(), 3);

        // Non-string constants should have comments stripped
        assert_eq!(parsed.constants[0].constant_name, "INT_CONST");
        assert_eq!(parsed.constants[0].constant_value.inner, "42");

        assert_eq!(parsed.constants[1].constant_name, "FLOAT_CONST");
        assert_eq!(parsed.constants[1].constant_value.inner, "3.14");

        assert_eq!(parsed.constants[2].constant_name, "BOOL_CONST");
        assert_eq!(parsed.constants[2].constant_value.inner, "true");
    }
}