Skip to main content

ros2msg/msg/
message.rs

1/// Message specification parsing
2use std::collections::HashMap;
3use std::fs;
4use std::path::Path;
5
6#[cfg(feature = "serde")]
7use serde::{Deserialize, Serialize};
8
9use super::errors::{ParseError, ParseResult};
10use crate::msg::types::{AnnotationValue, Annotations, Constant, Field, Type};
11use crate::msg::validation::{
12    COMMENT_DELIMITER, CONSTANT_SEPARATOR, OPTIONAL_ANNOTATION, is_valid_message_name,
13    is_valid_package_name,
14};
15
16/// Message specification
17#[derive(Debug, Clone, PartialEq)]
18#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
19pub struct MessageSpecification {
20    /// Package name
21    pub pkg_name: String,
22    /// Message name
23    pub msg_name: String,
24    /// List of fields
25    pub fields: Vec<Field>,
26    /// List of constants
27    pub constants: Vec<Constant>,
28    /// Annotations for the message
29    pub annotations: Annotations,
30}
31
32impl MessageSpecification {
33    /// Create a new empty message specification
34    ///
35    /// # Errors
36    ///
37    /// Returns [`ParseError::InvalidResourceName`] if the package name or message name are invalid.
38    pub fn new(pkg_name: String, msg_name: String) -> ParseResult<Self> {
39        if !is_valid_package_name(&pkg_name) {
40            return Err(ParseError::InvalidResourceName {
41                name: pkg_name,
42                reason: "invalid package name pattern".to_string(),
43            });
44        }
45
46        if !is_valid_message_name(&msg_name) {
47            return Err(ParseError::InvalidResourceName {
48                name: msg_name,
49                reason: "invalid message name pattern".to_string(),
50            });
51        }
52
53        Ok(MessageSpecification {
54            pkg_name,
55            msg_name,
56            fields: Vec::new(),
57            constants: Vec::new(),
58            annotations: HashMap::new(),
59        })
60    }
61
62    /// Add a field to the message
63    pub fn add_field(&mut self, field: Field) {
64        self.fields.push(field);
65    }
66
67    /// Add a constant to the message
68    pub fn add_constant(&mut self, constant: Constant) {
69        self.constants.push(constant);
70    }
71
72    /// Get field by name
73    #[must_use]
74    pub fn get_field(&self, name: &str) -> Option<&Field> {
75        self.fields.iter().find(|f| f.name == name)
76    }
77
78    /// Get constant by name
79    #[must_use]
80    pub fn get_constant(&self, name: &str) -> Option<&Constant> {
81        self.constants.iter().find(|c| c.name == name)
82    }
83
84    /// Check if message has any fields
85    #[must_use]
86    pub fn has_fields(&self) -> bool {
87        !self.fields.is_empty()
88    }
89
90    /// Check if message has any constants
91    #[must_use]
92    pub fn has_constants(&self) -> bool {
93        !self.constants.is_empty()
94    }
95}
96
97impl std::fmt::Display for MessageSpecification {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        writeln!(f, "# {}/{}", self.pkg_name, self.msg_name)?;
100
101        // Write constants first
102        for constant in &self.constants {
103            writeln!(f, "{constant}")?;
104        }
105
106        if !self.constants.is_empty() && !self.fields.is_empty() {
107            writeln!(f)?; // Empty line between constants and fields
108        }
109
110        // Write fields
111        for field in &self.fields {
112            writeln!(f, "{field}")?;
113        }
114
115        Ok(())
116    }
117}
118
119/// Parse a message file
120///
121/// # Errors
122///
123/// Returns [`ParseError`] if the file cannot be read or the message format is invalid.
124pub fn parse_message_file<P: AsRef<Path>>(
125    pkg_name: &str,
126    interface_filename: P,
127) -> ParseResult<MessageSpecification> {
128    let path = interface_filename.as_ref();
129    let basename =
130        path.file_name()
131            .and_then(|n| n.to_str())
132            .ok_or_else(|| ParseError::InvalidField {
133                reason: "invalid filename".to_string(),
134            })?;
135
136    let msg_name = basename
137        .strip_suffix(".msg")
138        .unwrap_or(basename)
139        .to_string();
140
141    let content = fs::read_to_string(path)?;
142    parse_message_string(pkg_name, &msg_name, &content)
143}
144
145/// Parse a message from string content
146///
147/// # Errors
148///
149/// Returns [`ParseError`] if the message format is invalid.
150pub fn parse_message_string(
151    pkg_name: &str,
152    msg_name: &str,
153    message_string: &str,
154) -> ParseResult<MessageSpecification> {
155    let mut spec = MessageSpecification::new(pkg_name.to_string(), msg_name.to_string())?;
156
157    // Replace tabs with spaces for consistent parsing
158    let normalized_content = message_string.replace('\t', " ");
159
160    // Extract file-level comments and content
161    let (file_level_comments, content_lines) = extract_file_level_comments(&normalized_content);
162
163    // Set file-level comments as message annotations
164    if !file_level_comments.is_empty() {
165        spec.annotations.insert(
166            "comment".to_string(),
167            AnnotationValue::StringList(file_level_comments),
168        );
169    }
170
171    // Parse content lines
172    let mut current_comments = Vec::<String>::new();
173    let mut is_optional = false;
174
175    for (line_num, line) in content_lines.iter().enumerate() {
176        let line = line.trim_end();
177
178        // Skip empty lines
179        if line.trim().is_empty() {
180            continue;
181        }
182
183        // Handle comments
184        let (line_content, comment) = extract_line_comment(line);
185
186        if let Some(comment_text) = comment {
187            if line_content.trim().is_empty() {
188                // This is a comment-only line - collect for next element
189                current_comments.push(comment_text);
190                continue;
191            }
192            // Line has both content and comment, collect the comment
193            current_comments.push(comment_text);
194        }
195
196        let line_content = line_content.trim();
197        if line_content.is_empty() {
198            continue;
199        }
200
201        // Check for optional annotation
202        if line_content == OPTIONAL_ANNOTATION {
203            is_optional = true;
204            continue;
205        }
206
207        // Parse the line as field or constant
208        match parse_line_content(line_content, pkg_name, line_num + 1) {
209            Ok(LineContent::Field(mut field)) => {
210                // Add collected comments
211                if !current_comments.is_empty() {
212                    field.annotations.insert(
213                        "comment".to_string(),
214                        AnnotationValue::StringList(current_comments.clone()),
215                    );
216                    current_comments.clear();
217                }
218
219                // Add optional annotation if present
220                if is_optional {
221                    field
222                        .annotations
223                        .insert("optional".to_string(), AnnotationValue::Bool(true));
224                    is_optional = false;
225                }
226
227                spec.add_field(field);
228            }
229            Ok(LineContent::Constant(mut constant)) => {
230                // Add collected comments
231                if !current_comments.is_empty() {
232                    constant.annotations.insert(
233                        "comment".to_string(),
234                        AnnotationValue::StringList(current_comments.clone()),
235                    );
236                    current_comments.clear();
237                }
238
239                spec.add_constant(constant);
240            }
241            Err(e) => {
242                return Err(ParseError::LineParseError {
243                    line: line_num + 1,
244                    message: format!("Error parsing line '{line_content}': {e}"),
245                });
246            }
247        }
248    }
249
250    // Process comments for all elements
251    process_comments(&mut spec);
252
253    Ok(spec)
254}
255
256/// Content parsed from a line
257enum LineContent {
258    Field(Field),
259    Constant(Constant),
260}
261
262/// Extract file-level comments from the beginning of the message
263fn extract_file_level_comments(message_string: &str) -> (Vec<String>, Vec<String>) {
264    let lines: Vec<String> = message_string
265        .lines()
266        .map(std::string::ToString::to_string)
267        .collect();
268
269    let mut file_level_comments = Vec::new();
270    let mut first_content_index = 0;
271
272    // Extract comments at the very top, until we hit a blank line or non-comment content
273    for (i, line) in lines.iter().enumerate() {
274        let trimmed = line.trim();
275
276        if trimmed.is_empty() {
277            // Blank line marks end of file-level comments
278            first_content_index = i + 1;
279            break;
280        } else if trimmed.starts_with(COMMENT_DELIMITER) {
281            // This is a file-level comment
282            if let Some(comment_text) = trimmed.strip_prefix(COMMENT_DELIMITER) {
283                file_level_comments.push(comment_text.trim_start().to_string());
284            }
285        } else {
286            // First non-comment, non-blank line - no file-level comments if we haven't seen a blank
287            first_content_index = i;
288            break;
289        }
290    }
291
292    let content_lines = lines[first_content_index..].to_vec();
293
294    (file_level_comments, content_lines)
295}
296
297/// Extract comment from a line, returning (content, comment)
298fn extract_line_comment(line: &str) -> (String, Option<String>) {
299    if let Some(comment_index) = line.find(COMMENT_DELIMITER) {
300        let content = line[..comment_index].to_string();
301        let comment = line[comment_index + 1..].trim_start().to_string();
302        (content, Some(comment))
303    } else {
304        (line.to_string(), None)
305    }
306}
307
308/// Parse a single line of content (field or constant definition)
309fn parse_line_content(line: &str, pkg_name: &str, _line_num: usize) -> ParseResult<LineContent> {
310    // Check if this is a constant (contains '=' but not as part of '<=' array bounds)
311    if line.contains(CONSTANT_SEPARATOR) && !is_array_bound_syntax(line) {
312        parse_constant_line(line)
313    } else {
314        parse_field_line(line, pkg_name)
315    }
316}
317
318/// Check if line contains array bound syntax (<=) which should not be confused with constants
319fn is_array_bound_syntax(line: &str) -> bool {
320    // Check for array bounds in brackets
321    if line.contains("<=") && (line.contains('[') || line.contains(']')) {
322        return true;
323    }
324
325    // Check for string bounds (e.g., "string<=50")
326    if line.contains("<=") && (line.contains("string") || line.contains("wstring")) {
327        return true;
328    }
329
330    false
331}
332
333/// Parse a constant definition line
334fn parse_constant_line(line: &str) -> ParseResult<LineContent> {
335    let parts: Vec<&str> = line.splitn(2, CONSTANT_SEPARATOR).collect();
336    if parts.len() != 2 {
337        return Err(ParseError::InvalidConstant {
338            reason: "constant must have format: TYPE NAME=VALUE".to_string(),
339        });
340    }
341
342    let left_part = parts[0].trim();
343    let value_part = parts[1].trim();
344
345    // Parse type and name from left part
346    let type_name_parts: Vec<&str> = left_part.split_whitespace().collect();
347    if type_name_parts.len() != 2 {
348        return Err(ParseError::InvalidConstant {
349            reason: "constant must have format: TYPE NAME=VALUE".to_string(),
350        });
351    }
352
353    let type_name = type_name_parts[0];
354    let const_name = type_name_parts[1];
355
356    let constant = Constant::new(type_name, const_name, value_part)?;
357    Ok(LineContent::Constant(constant))
358}
359
360/// Parse a field definition line
361fn parse_field_line(line: &str, pkg_name: &str) -> ParseResult<LineContent> {
362    let parts: Vec<&str> = line.split_whitespace().collect();
363    if parts.len() < 2 {
364        return Err(ParseError::InvalidField {
365            reason: "field must have at least type and name".to_string(),
366        });
367    }
368
369    let type_string = parts[0];
370    let field_name = parts[1];
371
372    // Check for default value
373    let default_value = if parts.len() > 2 {
374        Some(parts[2..].join(" "))
375    } else {
376        None
377    };
378
379    let field_type = Type::new(type_string, Some(pkg_name))?;
380    let field = Field::new(field_type, field_name, default_value.as_deref())?;
381
382    Ok(LineContent::Field(field))
383}
384
385/// Process comments to extract special annotations like units
386fn process_comments(spec: &mut MessageSpecification) {
387    // Process message-level comments
388    process_element_comments(&mut spec.annotations);
389
390    // Process field comments
391    for field in &mut spec.fields {
392        process_element_comments(&mut field.annotations);
393    }
394
395    // Process constant comments
396    for constant in &mut spec.constants {
397        process_element_comments(&mut constant.annotations);
398    }
399}
400
401/// Process comments for a single element to extract special annotations
402fn process_element_comments(annotations: &mut Annotations) {
403    if let Some(AnnotationValue::StringList(comments)) = annotations.get("comment").cloned() {
404        // Look for unit annotations in brackets
405        let comment_text = comments.join("\n");
406
407        let mut processed_comments = if let Some(unit) = extract_unit_from_comment(&comment_text) {
408            annotations.insert("unit".to_string(), AnnotationValue::String(unit.clone()));
409
410            // Remove unit from comments
411            comments
412                .into_iter()
413                .map(|line| remove_unit_from_line(&line, &unit))
414                .collect()
415        } else {
416            comments
417        };
418
419        // Remove empty lines and update comments
420        processed_comments.retain(|line| !line.trim().is_empty());
421
422        if processed_comments.is_empty() {
423            annotations.remove("comment");
424        } else {
425            annotations.insert(
426                "comment".to_string(),
427                AnnotationValue::StringList(processed_comments),
428            );
429        }
430    }
431}
432
433/// Extract unit annotation from comment text
434fn extract_unit_from_comment(comment: &str) -> Option<String> {
435    // Look for [unit] pattern that doesn't contain commas
436    let re = regex::Regex::new(r"\[([^,\]]+)\]").ok()?;
437    let captures = re.captures(comment)?;
438    captures.get(1).map(|m| m.as_str().trim().to_string())
439}
440
441/// Remove unit annotation from a comment line
442fn remove_unit_from_line(line: &str, unit: &str) -> String {
443    let pattern = format!("[{unit}]");
444    line.replace(&pattern, "").trim().to_string()
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::msg::validation::PrimitiveValue;
451
452    #[test]
453    fn test_parse_simple_message() {
454        let content = r"
455# This is a test message
456int32 x
457int32 y
458string name
459";
460
461        let spec = parse_message_string("test_msgs", "TestMessage", content).unwrap();
462        assert_eq!(spec.pkg_name, "test_msgs");
463        assert_eq!(spec.msg_name, "TestMessage");
464        assert_eq!(spec.fields.len(), 3);
465        assert_eq!(spec.fields[0].name, "x");
466        assert_eq!(spec.fields[1].name, "y");
467        assert_eq!(spec.fields[2].name, "name");
468    }
469
470    #[test]
471    fn test_parse_message_with_constants() {
472        let content = r#"
473# Constants
474int32 MAX_VALUE=100
475string DEFAULT_NAME="test"
476
477# Fields
478int32 value
479string name
480"#;
481
482        let spec = parse_message_string("test_msgs", "TestMessage", content).unwrap();
483        assert_eq!(spec.constants.len(), 2);
484        assert_eq!(spec.fields.len(), 2);
485
486        let max_const = spec.get_constant("MAX_VALUE").unwrap();
487        assert_eq!(max_const.value, PrimitiveValue::Int32(100));
488    }
489
490    #[test]
491    fn test_parse_message_with_arrays() {
492        let content = r"
493int32[] dynamic_array
494int32[5] fixed_array
495int32[<=10] bounded_array
496";
497
498        let spec = parse_message_string("test_msgs", "TestMessage", content).unwrap();
499        assert_eq!(spec.fields.len(), 3);
500
501        assert!(spec.fields[0].field_type.is_dynamic_array());
502        assert_eq!(spec.fields[1].field_type.array_size, Some(5));
503        assert!(spec.fields[2].field_type.is_bounded_array());
504    }
505
506    #[test]
507    fn test_parse_message_with_comments() {
508        let content = r"
509# File level comment
510# Second line
511
512int32 x  # X coordinate
513int32 y  # Y coordinate
514";
515
516        let spec = parse_message_string("test_msgs", "TestMessage", content).unwrap();
517
518        // Should have file-level comments
519        if let Some(AnnotationValue::StringList(comments)) = spec.annotations.get("comment") {
520            assert!(comments.contains(&"File level comment".to_string()));
521        }
522
523        // Fields should have comments
524        assert!(spec.fields[0].annotations.contains_key("comment"));
525        assert!(spec.fields[1].annotations.contains_key("comment"));
526    }
527
528    #[test]
529    fn test_parse_message_with_optional_fields() {
530        let content = r"
531int32 required_field
532@optional
533int32 optional_field
534";
535
536        let spec = parse_message_string("test_msgs", "TestMessage", content).unwrap();
537        assert_eq!(spec.fields.len(), 2);
538
539        // First field should not be optional
540        assert!(!spec.fields[0].annotations.contains_key("optional"));
541
542        // Second field should be optional
543        if let Some(AnnotationValue::Bool(is_optional)) = spec.fields[1].annotations.get("optional")
544        {
545            assert!(is_optional);
546        }
547    }
548}