rclnodejs 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1250 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Copyright (c) 2025, The Robot Web Tools Contributors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ """
18
+ IDL to ROS2 Interface Converter
19
+
20
+ This tool converts ROS2 .idl files to corresponding .msg/.srv/.action files.
21
+ It parses IDL syntax and generates proper ROS2 interface definitions.
22
+ """
23
+
24
+ import os
25
+ import sys
26
+ import re
27
+ import pathlib
28
+ import argparse
29
+ from typing import List, Dict, Optional
30
+ from dataclasses import dataclass
31
+ from enum import Enum
32
+
33
+
34
+ class IdlElementType(Enum):
35
+ MESSAGE = "message"
36
+ SERVICE = "service"
37
+ ACTION = "action"
38
+
39
+
40
+ @dataclass
41
+ class IdlField:
42
+ """Represents a field in an IDL structure"""
43
+ field_type: str
44
+ name: str
45
+ is_array: bool = False
46
+ array_size: Optional[int] = None
47
+ is_sequence: bool = False
48
+ is_bounded_string: bool = False
49
+ default_value: Optional[str] = None
50
+ comment: Optional[str] = None
51
+
52
+
53
+ @dataclass
54
+ class IdlConstant:
55
+ """Represents a constant definition in an IDL structure"""
56
+ name: str
57
+ const_type: str
58
+ value: str
59
+
60
+
61
+ @dataclass
62
+ class IdlStructure:
63
+ """Represents an IDL structure (message, service part, etc.)"""
64
+ name: str
65
+ fields: List[IdlField]
66
+ constants: List[IdlConstant]
67
+ comments: List[str]
68
+
69
+
70
+ @dataclass
71
+ class IdlInterface:
72
+ """Represents a complete IDL interface definition"""
73
+ name: str
74
+ interface_type: IdlElementType
75
+ package: str
76
+ structures: List[IdlStructure] # For messages: 1 structure, services: 2 (request/response), actions: 3 (goal/result/feedback)
77
+
78
+
79
+ class IdlParser:
80
+ """Parser for IDL files"""
81
+
82
+ # Type mapping from IDL to ROS2
83
+ TYPE_MAPPING = {
84
+ 'boolean': 'bool',
85
+ 'octet': 'byte', # IDL octet maps to ROS2 byte
86
+ 'char': 'char',
87
+ 'wchar': 'wchar',
88
+ 'int8': 'int8',
89
+ 'uint8': 'uint8',
90
+ 'int16': 'int16',
91
+ 'uint16': 'uint16',
92
+ 'int32': 'int32',
93
+ 'uint32': 'uint32',
94
+ 'int64': 'int64',
95
+ 'uint64': 'uint64',
96
+ 'float': 'float32',
97
+ 'double': 'float64',
98
+ 'long double': 'float64',
99
+ 'string': 'string',
100
+ 'wstring': 'wstring',
101
+ }
102
+
103
+ def __init__(self):
104
+ self.includes = []
105
+ self.current_package = ""
106
+ self.current_module = ""
107
+ self.typedefs = {} # Store typedef declarations
108
+
109
+ def _contains_key_annotations(self, content: str) -> bool:
110
+ """Check if IDL content contains @key annotations or references to keyed types (not supported in ROS2 .msg)"""
111
+ # Direct @key annotations
112
+ if '@key' in content:
113
+ return True
114
+
115
+ # Check for references to known keyed types
116
+ keyed_type_patterns = [
117
+ r'test_msgs::msg::KeyedString',
118
+ r'test_msgs::msg::KeyedLong',
119
+ r'KeyedString',
120
+ r'KeyedLong'
121
+ ]
122
+
123
+ import re
124
+ for pattern in keyed_type_patterns:
125
+ if re.search(pattern, content):
126
+ return True
127
+
128
+ return False
129
+
130
+ def parse_file(self, idl_file_path: str) -> List[IdlInterface]:
131
+ """Parse an IDL file and return list of interfaces"""
132
+ with open(idl_file_path, 'r') as f:
133
+ content = f.read()
134
+
135
+ return self.parse_content(content, idl_file_path)
136
+
137
+ def parse_content(self, content: str, file_path: str = "") -> List[IdlInterface]:
138
+ """Parse IDL content string"""
139
+ interfaces = []
140
+
141
+ # Check for unsupported features
142
+ if self._contains_key_annotations(content):
143
+ # Determine the specific reason for skipping
144
+ if '@key' in content:
145
+ reason = "contains @key annotations"
146
+ else:
147
+ reason = "references keyed types"
148
+ print(f"Warning: Skipping {file_path} - {reason} which are not supported in ROS2 .msg files")
149
+ return interfaces
150
+
151
+ # Extract modules and their contents BEFORE preprocessing (to preserve @verbatim)
152
+ modules = self._extract_modules(content)
153
+
154
+ for module_info in modules:
155
+ module_name = module_info['name']
156
+ module_content = module_info['content']
157
+
158
+ # Set current package for type mapping
159
+ self.current_package = module_name.split('::')[0]
160
+
161
+ # Parse typedefs FIRST from raw content (before preprocessing removes them)
162
+ self._parse_typedefs(module_content)
163
+
164
+ # Parse structures (to extract verbatim comments before preprocessing)
165
+ structures = self._parse_structures(module_content)
166
+
167
+ # Now preprocess the content to remove comments and normalize
168
+ clean_content = self._preprocess_content(module_content)
169
+
170
+ # Parse constants from nested modules from clean content
171
+ constants = self._parse_constants_from_modules(clean_content)
172
+
173
+ # Add constants to the main structure (if any structures exist)
174
+ if structures and constants:
175
+ # Add constants to the first structure (typically the main message structure)
176
+ structures[0].constants.extend(constants)
177
+
178
+ for struct in structures:
179
+ # Determine interface type based on naming convention or structure
180
+ interface_type = self._determine_interface_type(struct, file_path)
181
+
182
+ interface = IdlInterface(
183
+ name=struct.name,
184
+ interface_type=interface_type,
185
+ package=module_name,
186
+ structures=[struct]
187
+ )
188
+ interfaces.append(interface)
189
+
190
+ return interfaces
191
+
192
+ def _preprocess_content(self, content: str) -> str:
193
+ """Remove @verbatim blocks from the content with proper string handling."""
194
+ lines = content.split('\n')
195
+ result_lines = []
196
+
197
+ i = 0
198
+ while i < len(lines):
199
+ line = lines[i]
200
+ if '@verbatim' in line:
201
+ # Find opening parenthesis after @verbatim
202
+ verbatim_pos = line.find('@verbatim')
203
+ paren_pos = line.find('(', verbatim_pos)
204
+ if paren_pos == -1:
205
+ result_lines.append(line)
206
+ i += 1
207
+ continue
208
+
209
+ # Parse with string awareness
210
+ paren_count = 0
211
+ in_string = False
212
+ escape_next = False
213
+ start_part = line[:verbatim_pos]
214
+
215
+ # Process current line starting from opening parenthesis
216
+ j = paren_pos
217
+ while j < len(line):
218
+ char = line[j]
219
+
220
+ if escape_next:
221
+ escape_next = False
222
+ elif char == '\\':
223
+ escape_next = True
224
+ elif char == '"' and not escape_next:
225
+ in_string = not in_string
226
+ elif not in_string:
227
+ if char == '(':
228
+ paren_count += 1
229
+ elif char == ')':
230
+ paren_count -= 1
231
+ if paren_count == 0:
232
+ # Found end of verbatim block
233
+ result_lines.append(start_part + line[j+1:])
234
+ i += 1
235
+ break
236
+ j += 1
237
+ else:
238
+ # Verbatim block continues to next lines
239
+ i += 1
240
+ while i < len(lines) and paren_count > 0:
241
+ line = lines[i]
242
+ j = 0
243
+ while j < len(line):
244
+ char = line[j]
245
+
246
+ if escape_next:
247
+ escape_next = False
248
+ elif char == '\\':
249
+ escape_next = True
250
+ elif char == '"' and not escape_next:
251
+ in_string = not in_string
252
+ elif not in_string:
253
+ if char == '(':
254
+ paren_count += 1
255
+ elif char == ')':
256
+ paren_count -= 1
257
+ if paren_count == 0:
258
+ # Found end
259
+ result_lines.append(start_part + line[j+1:])
260
+ i += 1
261
+ break
262
+ j += 1
263
+ else:
264
+ i += 1
265
+ continue
266
+ break
267
+ else:
268
+ result_lines.append(line)
269
+ i += 1
270
+
271
+ return '\n'.join(result_lines)
272
+
273
+ def _extract_modules(self, content: str) -> List[Dict]:
274
+ """Extract module definitions from content"""
275
+ modules = []
276
+
277
+ # Find module blocks - improved pattern for better nested module handling
278
+ # This pattern will match modules even with complex nested structures
279
+ pos = 0
280
+ while True:
281
+ # Find next module declaration
282
+ module_match = re.search(r'module\s+(\w+)\s*\{', content[pos:])
283
+ if not module_match:
284
+ break
285
+
286
+ module_name = module_match.group(1)
287
+ start_pos = pos + module_match.end() - 1 # Position of opening brace
288
+
289
+ # Count braces to find the matching closing brace
290
+ brace_count = 1
291
+ current_pos = start_pos + 1
292
+
293
+ while current_pos < len(content) and brace_count > 0:
294
+ if content[current_pos] == '{':
295
+ brace_count += 1
296
+ elif content[current_pos] == '}':
297
+ brace_count -= 1
298
+ current_pos += 1
299
+
300
+ if brace_count == 0:
301
+ # Found the matching closing brace
302
+ module_content = content[start_pos + 1:current_pos - 1]
303
+
304
+ # Handle nested modules recursively
305
+ nested_modules = self._extract_modules(module_content)
306
+ if nested_modules:
307
+ for nested in nested_modules:
308
+ nested['name'] = f"{module_name}::{nested['name']}"
309
+ modules.append(nested)
310
+
311
+ # Only add the current module if it has content beyond just nested modules
312
+ # Check if there are any struct, enum, typedef, or const definitions directly in this module
313
+ lines = module_content.split('\n')
314
+ has_direct_definitions = False
315
+ in_nested_module = False
316
+ brace_level = 0
317
+
318
+ for line in lines:
319
+ line = line.strip()
320
+ if line.startswith('module ') and '{' in line:
321
+ in_nested_module = True
322
+ brace_level = 1
323
+ elif in_nested_module:
324
+ brace_level += line.count('{') - line.count('}')
325
+ if brace_level <= 0:
326
+ in_nested_module = False
327
+ elif not in_nested_module and re.search(r'^\s*(struct|enum|typedef|const)\s+\w+', line):
328
+ has_direct_definitions = True
329
+ break
330
+
331
+ if has_direct_definitions:
332
+ modules.append({
333
+ 'name': module_name,
334
+ 'content': module_content
335
+ })
336
+ else:
337
+ # No nested modules, always add this module
338
+ modules.append({
339
+ 'name': module_name,
340
+ 'content': module_content
341
+ })
342
+
343
+ pos = current_pos
344
+ else:
345
+ # Unmatched braces, skip this occurrence
346
+ pos = pos + module_match.end()
347
+
348
+ return modules
349
+
350
+ def _parse_typedefs(self, content: str):
351
+ """Parse typedef declarations from module content"""
352
+ # First pass: find simple typedefs like: typedef test_msgs::msg::Arrays test_msgs__msg__Arrays;
353
+ simple_typedef_pattern = r'typedef\s+([^;\s]+)\s+([^;\[\s]+)\s*;'
354
+ matches = re.finditer(simple_typedef_pattern, content, re.DOTALL)
355
+
356
+ for match in matches:
357
+ source_type = match.group(1).strip()
358
+ target_name = match.group(2).strip()
359
+
360
+ # Skip if this is actually an array typedef (contains [])
361
+ if '[' not in match.group(0):
362
+ # Map the source type and store the simple typedef
363
+ ros_source_type = self._map_type(source_type)
364
+ self.typedefs[target_name] = {
365
+ 'base_type': ros_source_type,
366
+ 'array_size': None
367
+ }
368
+
369
+ # Second pass: find array typedefs like: typedef double double__9[9];
370
+ array_typedef_pattern = r'typedef\s+([^[\s]+)\s+(\w+)\[(\d+)\]\s*;'
371
+ matches = re.finditer(array_typedef_pattern, content, re.DOTALL)
372
+
373
+ for match in matches:
374
+ base_type = match.group(1)
375
+ typedef_name = match.group(2)
376
+ array_size = int(match.group(3))
377
+
378
+ # Map the base type and store the typedef
379
+ ros_base_type = self._map_type(base_type)
380
+ self.typedefs[typedef_name] = {
381
+ 'base_type': ros_base_type,
382
+ 'array_size': array_size
383
+ }
384
+
385
+ def _parse_constants_from_modules(self, content: str) -> List[IdlConstant]:
386
+ """Parse constants from nested constant modules"""
387
+ constants = []
388
+
389
+ # Find constant modules like: module SomeConstants { const uint8 NAME = VALUE; };
390
+ const_module_pattern = r'module\s+(\w*[Cc]onstants?\w*)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}'
391
+ matches = re.finditer(const_module_pattern, content, re.DOTALL)
392
+
393
+ for match in matches:
394
+ module_name = match.group(1)
395
+ module_content = match.group(2)
396
+
397
+ # Find const declarations within the module
398
+ const_pattern = r'const\s+(\w+)\s+(\w+)\s*=\s*([^;]+);'
399
+ const_matches = re.finditer(const_pattern, module_content)
400
+
401
+ for const_match in const_matches:
402
+ const_type = const_match.group(1)
403
+ const_name = const_match.group(2)
404
+ const_value = const_match.group(3).strip()
405
+
406
+ # Map the type to ROS2 type
407
+ ros_type = self._map_type(const_type)
408
+
409
+ constant = IdlConstant(
410
+ name=const_name,
411
+ const_type=ros_type,
412
+ value=const_value
413
+ )
414
+ constants.append(constant)
415
+
416
+ return constants
417
+
418
+ def _parse_structures(self, content: str) -> List[IdlStructure]:
419
+ """Parse structure definitions from module content"""
420
+ structures = []
421
+
422
+ # Find struct definitions
423
+ struct_pattern = r'struct\s+(\w+)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}'
424
+ matches = re.finditer(struct_pattern, content, re.DOTALL)
425
+
426
+ for match in matches:
427
+ struct_name = match.group(1)
428
+ struct_content = match.group(2)
429
+
430
+ # Extract comments from @verbatim blocks before the struct
431
+ comments = self._extract_verbatim_comments(content, match.start())
432
+
433
+ # Parse fields from the struct content (need to preprocess it first)
434
+ clean_struct_content = self._preprocess_content(struct_content)
435
+ fields = self._parse_fields(clean_struct_content, struct_content, struct_name) # Pass both clean, original, and struct name
436
+
437
+ structure = IdlStructure(
438
+ name=struct_name,
439
+ fields=fields,
440
+ constants=[],
441
+ comments=comments
442
+ )
443
+ structures.append(structure)
444
+
445
+ return structures
446
+
447
+ def _extract_verbatim_comments(self, content: str, struct_start_pos: int) -> List[str]:
448
+ """Extract comments from @verbatim blocks immediately before a struct definition"""
449
+ comments = []
450
+
451
+ # Look backwards from struct position to find the most recent @verbatim block
452
+ content_before_struct = content[:struct_start_pos]
453
+
454
+ # Try to find the last @verbatim block before the struct
455
+ # Look for pattern: @verbatim (language="comment", text="...") struct
456
+
457
+ # First, try single-line @verbatim pattern
458
+ single_line_pattern = r'@verbatim\s*\(\s*language\s*=\s*"comment"\s*,\s*text\s*=\s*"([^"]+)"\s*\)\s*$'
459
+
460
+ # Split into lines and work backwards
461
+ lines = content_before_struct.split('\n')
462
+
463
+ for line in reversed(lines):
464
+ line_stripped = line.strip()
465
+
466
+ # Skip empty lines and braces
467
+ if not line_stripped or line_stripped in ['}', '};']:
468
+ continue
469
+
470
+ # If we hit non-verbatim content that's not empty/closing, stop looking
471
+ if not line_stripped.startswith('@verbatim') and line_stripped:
472
+ # Unless it's just whitespace or a closing brace, stop
473
+ if not (line_stripped == '}' or line_stripped == '};' or not line_stripped):
474
+ break
475
+
476
+ # Look for @verbatim
477
+ if '@verbatim' in line and 'language="comment"' in line:
478
+ # Try single-line match first
479
+ match = re.search(single_line_pattern, line)
480
+ if match:
481
+ comment_text = match.group(1).strip().replace('\\n', '\n')
482
+ return [comment_text]
483
+
484
+ # For multi-line, we need to look at the next line
485
+ # This handles cases like:
486
+ # @verbatim (language="comment", text=
487
+ # "The comment text")
488
+ break
489
+
490
+ # If single-line didn't work, try multi-line pattern
491
+ # Look for @verbatim blocks that span multiple lines
492
+ multi_line_pattern = r'@verbatim\s*\(\s*language\s*=\s*"comment"\s*,\s*text\s*=\s*"([^"]+)"\s*\)'
493
+
494
+ # Search in a reasonable window before the struct (last 500 characters)
495
+ search_window = content_before_struct[-500:] if len(content_before_struct) > 500 else content_before_struct
496
+
497
+ matches = list(re.finditer(multi_line_pattern, search_window, re.DOTALL))
498
+ if matches:
499
+ # Take the last match (closest to the struct)
500
+ last_match = matches[-1]
501
+ comment_text = last_match.group(1).strip().replace('\\n', '\n')
502
+ return [comment_text]
503
+
504
+ return comments
505
+
506
+ def _extract_inline_verbatim_comments(self, content: str) -> Dict[str, str]:
507
+ """Extract comments from @verbatim blocks that appear before field definitions"""
508
+ field_comments = {}
509
+ lines = content.split('\n')
510
+
511
+ i = 0
512
+ while i < len(lines):
513
+ line = lines[i].strip()
514
+ if '@verbatim' in line and 'language="comment"' in line:
515
+ # Extract the comment text - handle multi-line format
516
+ comment_text = ""
517
+
518
+ # Look for text on the same line first
519
+ text_match = re.search(r'text\s*=\s*"([^"]*)"', line)
520
+ if text_match:
521
+ comment_text = text_match.group(1)
522
+ else:
523
+ # Look for text in subsequent lines - handle concatenated strings
524
+ j = i + 1
525
+ in_text = True # Set to True since we found 'text=' in the @verbatim line
526
+ text_parts = []
527
+
528
+ while j < len(lines):
529
+ next_line = lines[j].strip()
530
+
531
+ if in_text:
532
+ # Handle different string concatenation patterns
533
+ if next_line.startswith('"') and next_line.endswith('" "'):
534
+ # Pattern: "text" "
535
+ text_content = next_line[1:-3] # Remove start quote and end quote+space+quote
536
+ if text_content:
537
+ text_parts.append(text_content)
538
+ elif next_line.startswith('"') and next_line.endswith('")'):
539
+ # Pattern: "text")
540
+ text_content = next_line[1:-2] # Remove start quote and end quote+paren
541
+ if text_content:
542
+ text_parts.append(text_content)
543
+ elif next_line == '"':
544
+ # Just a newline marker
545
+ text_parts.append('\n')
546
+ elif '"' in next_line:
547
+ # Extract all quoted content
548
+ quote_matches = re.findall(r'"([^"]*)"', next_line)
549
+ for quote_match in quote_matches:
550
+ if quote_match: # Skip empty strings unless they're newlines
551
+ text_parts.append(quote_match)
552
+
553
+ # Check if we've reached the end of the verbatim block
554
+ if ')' in next_line and in_text:
555
+ break
556
+ j += 1
557
+
558
+ comment_text = ''.join(text_parts)
559
+
560
+ # Convert \n to actual newlines and clean up
561
+ comment_text = comment_text.replace('\\n', '\n').strip()
562
+
563
+ # Find the next field definition
564
+ k = j + 1 # Start after the verbatim block ends
565
+ while k < len(lines):
566
+ field_line = lines[k].strip()
567
+ if field_line and not field_line.startswith('@') and not field_line.startswith('//') and not field_line.startswith('"'):
568
+ # Extract field name
569
+ field_match = re.search(r'\b(\w+)\s*;', field_line)
570
+ if field_match:
571
+ field_name = field_match.group(1)
572
+ if comment_text:
573
+ field_comments[field_name] = comment_text
574
+ break
575
+ k += 1
576
+ i += 1
577
+
578
+ return field_comments
579
+
580
+ def _parse_fields(self, struct_content: str, original_content: str = None, struct_name: str = None) -> List[IdlField]:
581
+ """Parse field definitions from struct content"""
582
+ fields = []
583
+
584
+ # Use original content for default value extraction if provided
585
+ content_for_defaults = original_content if original_content else struct_content
586
+
587
+ # First, extract default values from the original content
588
+ default_values = self._extract_default_values(content_for_defaults)
589
+
590
+ # Extract verbatim comments from within the struct
591
+ inline_comments = self._extract_inline_verbatim_comments(content_for_defaults)
592
+
593
+ # Remove @verbatim blocks and @default annotations from the clean content
594
+ cleaned_content = self._remove_verbatim_blocks(struct_content)
595
+
596
+ # Split by semicolon and process each field
597
+ field_lines = [line.strip() for line in cleaned_content.split(';') if line.strip()]
598
+
599
+ for field_line in field_lines:
600
+ field = self._parse_single_field(field_line, struct_name)
601
+ if field and field.name in default_values:
602
+ field.default_value = default_values[field.name]
603
+ # Add inline comment if available
604
+ if field and field.name in inline_comments:
605
+ field.comment = inline_comments[field.name]
606
+ if field:
607
+ fields.append(field)
608
+
609
+ return fields
610
+
611
+ def _extract_default_values(self, content: str) -> Dict[str, str]:
612
+ """Extract default values from @default annotations"""
613
+ default_values = {}
614
+ lines = content.split('\n')
615
+
616
+ i = 0
617
+ while i < len(lines):
618
+ line = lines[i].strip()
619
+ if '@default' in line:
620
+ # Use regex to extract the value from @default (value=...)
621
+ # Handle nested parentheses in the value
622
+ default_match = re.search(r'@default\s*\(\s*value\s*=\s*(.+)\)\s*$', line)
623
+ if default_match:
624
+ default_value = default_match.group(1).strip()
625
+
626
+ # Handle different value formats
627
+ if default_value.startswith('"') and default_value.endswith('"'):
628
+ # Quoted string - preserve quotes for string fields
629
+ inner_value = default_value[1:-1]
630
+ if inner_value.startswith('(') and inner_value.endswith(')'):
631
+ # It's a quoted tuple like "(False, True, False)"
632
+ inner_content = inner_value[1:-1]
633
+ # Replace Python boolean constants with ROS2 format
634
+ inner_content = inner_content.replace('False', 'false').replace('True', 'true')
635
+ # For string arrays, convert single quotes to double quotes
636
+ if "'" in inner_content:
637
+ inner_content = inner_content.replace("'", '"')
638
+ default_value = '[' + inner_content + ']'
639
+ else:
640
+ # For string fields, first unescape any escaped quotes
641
+ unescaped_value = inner_value.replace('\\"', '"').replace("\\'", "'")
642
+
643
+ # Now apply quoting logic based on content
644
+ if '"' in unescaped_value and "'" not in unescaped_value:
645
+ # Has double quotes only - use single quotes to wrap
646
+ default_value = "'" + unescaped_value + "'"
647
+ elif "'" in unescaped_value and '"' not in unescaped_value:
648
+ # Has single quotes only - use double quotes to wrap
649
+ default_value = '"' + unescaped_value + '"'
650
+ elif "'" in unescaped_value and '"' in unescaped_value:
651
+ # Has both - escape single quotes and use single quotes to wrap
652
+ escaped_value = unescaped_value.replace("'", "\\'")
653
+ default_value = "'" + escaped_value + "'"
654
+ else:
655
+ # No internal quotes - use double quotes
656
+ default_value = '"' + unescaped_value + '"'
657
+ elif default_value.startswith('(') and default_value.endswith(')'):
658
+ # Unquoted tuple format (a, b, c) to array format [a, b, c]
659
+ inner_content = default_value[1:-1]
660
+ inner_content = inner_content.replace('False', 'false').replace('True', 'true')
661
+ default_value = '[' + inner_content + ']'
662
+ else:
663
+ # Simple value - convert boolean constants and clean up decimals
664
+ if default_value == 'FALSE':
665
+ default_value = 'false'
666
+ elif default_value == 'TRUE':
667
+ default_value = 'true'
668
+ elif default_value.endswith('.0'):
669
+ # Convert 0.0 to 0, 1.0 to 1, etc.
670
+ try:
671
+ float_val = float(default_value)
672
+ if float_val.is_integer():
673
+ default_value = str(int(float_val))
674
+ except ValueError:
675
+ pass # Keep original value if conversion fails
676
+
677
+ # Look for the field definition in the next lines
678
+ j = i + 1
679
+ while j < len(lines):
680
+ next_line = lines[j].strip()
681
+ if next_line and not next_line.startswith('@') and not next_line.startswith('//'):
682
+ # Extract field name from this line
683
+ field_match = re.search(r'\b(\w+)\s*;', next_line)
684
+ if field_match:
685
+ field_name = field_match.group(1)
686
+ default_values[field_name] = default_value
687
+ break
688
+ j += 1
689
+ i += 1
690
+
691
+ return default_values
692
+
693
+ def _remove_verbatim_blocks(self, content: str) -> str:
694
+ """Remove @verbatim and @default blocks from content"""
695
+ lines = content.split('\n')
696
+ processed_lines = []
697
+ in_verbatim = False
698
+ paren_count = 0
699
+
700
+ for line in lines:
701
+ # Skip @verbatim blocks
702
+ if '@verbatim' in line and not in_verbatim:
703
+ in_verbatim = True
704
+ paren_count = line.count('(') - line.count(')')
705
+ continue
706
+ elif in_verbatim:
707
+ paren_count += line.count('(') - line.count(')')
708
+ if paren_count <= 0:
709
+ in_verbatim = False
710
+ continue
711
+
712
+ # Skip @default annotations completely
713
+ if '@default' in line:
714
+ continue
715
+
716
+ # Skip @unit annotations completely
717
+ if '@unit' in line:
718
+ continue
719
+
720
+ processed_lines.append(line)
721
+
722
+ return '\n'.join(processed_lines)
723
+
724
+ def _parse_single_field(self, field_line: str, struct_name: str = None) -> Optional[IdlField]:
725
+ """Parse a single field definition"""
726
+ field_line = field_line.strip()
727
+ if not field_line:
728
+ return None
729
+
730
+ # Handle sequence types: sequence<type> name or sequence<type, bound> name
731
+ sequence_match = re.match(r'sequence<([^,>]+)(?:,\s*(\d+))?>\s+(\w+)', field_line)
732
+ if sequence_match:
733
+ inner_type = sequence_match.group(1).strip()
734
+ bound = sequence_match.group(2)
735
+ field_name = sequence_match.group(3)
736
+
737
+ # Map the inner type with field name context
738
+ ros_type = self._map_type_with_context(inner_type, field_name, struct_name)
739
+
740
+ # Handle bounded sequence
741
+ if bound:
742
+ bound_value = int(bound)
743
+ return IdlField(
744
+ field_type=ros_type,
745
+ name=field_name,
746
+ is_sequence=True,
747
+ is_array=True,
748
+ array_size=bound_value # Store bound as array_size for bounded sequences
749
+ )
750
+ else:
751
+ return IdlField(
752
+ field_type=ros_type,
753
+ name=field_name,
754
+ is_sequence=True,
755
+ is_array=True
756
+ )
757
+
758
+ # Handle array types: type[size] name or type[] name
759
+ array_match = re.match(r'([^[\s]+)\s*\[([^\]]*)\]\s+(\w+)', field_line)
760
+ if array_match:
761
+ base_type = array_match.group(1)
762
+ array_size_str = array_match.group(2)
763
+ field_name = array_match.group(3)
764
+
765
+ ros_type = self._map_type_with_context(base_type, field_name, struct_name)
766
+ array_size = int(array_size_str) if array_size_str.isdigit() else None
767
+
768
+ return IdlField(
769
+ field_type=ros_type,
770
+ name=field_name,
771
+ is_array=True,
772
+ array_size=array_size
773
+ )
774
+
775
+ # Handle bounded strings: string<size> name
776
+ bounded_string_match = re.match(r'string<(\d+)>\s+(\w+)', field_line)
777
+ if bounded_string_match:
778
+ bound_size = int(bounded_string_match.group(1))
779
+ field_name = bounded_string_match.group(2)
780
+
781
+ return IdlField(
782
+ field_type='string',
783
+ name=field_name,
784
+ is_array=True, # Use is_array to indicate bounded
785
+ is_bounded_string=True,
786
+ array_size=bound_size
787
+ )
788
+
789
+ # Handle regular types: type name or type<params> name
790
+ regular_match = re.match(r'([^:\s]+(?:::[^:\s]+)*)\s+(\w+)', field_line)
791
+ if regular_match:
792
+ field_type = regular_match.group(1)
793
+ field_name = regular_match.group(2)
794
+
795
+ ros_type = self._map_type_with_context(field_type, field_name, struct_name)
796
+
797
+ # Check if this is a typedef array
798
+ if field_type in self.typedefs:
799
+ typedef_info = self.typedefs[field_type]
800
+ # Apply context mapping to the typedef base type
801
+ contextual_type = self._map_type_with_context(typedef_info['base_type'], field_name, struct_name)
802
+ return IdlField(
803
+ field_type=contextual_type,
804
+ name=field_name,
805
+ is_array=True,
806
+ array_size=typedef_info['array_size']
807
+ )
808
+
809
+ return IdlField(
810
+ field_type=ros_type,
811
+ name=field_name
812
+ )
813
+
814
+ return None
815
+
816
+ def _map_type(self, idl_type: str) -> str:
817
+ """Map IDL type to ROS2 type"""
818
+ # Check if it's a typedef first
819
+ if idl_type in self.typedefs:
820
+ typedef_info = self.typedefs[idl_type]
821
+ return typedef_info['base_type']
822
+
823
+ # Handle namespaced types (e.g., std_msgs::msg::Header)
824
+ if '::' in idl_type:
825
+ parts = idl_type.split('::')
826
+ if len(parts) >= 3:
827
+ # For types like package::msg::Type, check if it's in the same package context
828
+ package = parts[0]
829
+ msg_type = parts[-1]
830
+
831
+ # If it's the same package we're currently processing, just use the type name
832
+ if package == self.current_package or package == 'rmw_dds_common' or package == 'test_msgs':
833
+ return msg_type
834
+ else:
835
+ return f"{package}/{msg_type}"
836
+ else:
837
+ return idl_type.replace('::', '/')
838
+
839
+ # Handle basic types
840
+ return self.TYPE_MAPPING.get(idl_type, idl_type)
841
+
842
+ def _map_type_with_context(self, idl_type: str, field_name: str, struct_name: str = None) -> str:
843
+ """Map IDL type to ROS2 type with field name context"""
844
+ # Special case: uint8 with "char" in field name should map to char
845
+ if idl_type == 'uint8' and 'char' in field_name.lower():
846
+ return 'char'
847
+
848
+ # Special case: uint8 in Char struct should map to char
849
+ if idl_type == 'uint8' and struct_name and 'char' in struct_name.lower():
850
+ return 'char'
851
+
852
+ # Otherwise use regular mapping
853
+ return self._map_type(idl_type)
854
+
855
+ def _determine_interface_type(self, structure: IdlStructure, file_path: str) -> IdlElementType:
856
+ """Determine if structure represents a message, service, or action"""
857
+ file_name = os.path.basename(file_path).lower()
858
+ struct_name = structure.name.lower()
859
+
860
+ # Check for service patterns - be more specific to avoid false positives
861
+ if ('.srv' in file_path or
862
+ struct_name.endswith('_request') or struct_name.endswith('_response') or
863
+ struct_name == 'request' or struct_name == 'response'):
864
+ return IdlElementType.SERVICE
865
+ elif ('.action' in file_path or
866
+ struct_name.endswith('_goal') or struct_name.endswith('_result') or struct_name.endswith('_feedback') or
867
+ struct_name == 'goal' or struct_name == 'result' or struct_name == 'feedback'):
868
+ return IdlElementType.ACTION
869
+ else:
870
+ return IdlElementType.MESSAGE
871
+
872
+
873
+ class RosInterfaceGenerator:
874
+ """Generates ROS2 interface files from IDL interfaces"""
875
+
876
+ def __init__(self, output_dir: str = "ros_interfaces"):
877
+ self.output_dir = pathlib.Path(output_dir)
878
+ self.output_dir.mkdir(exist_ok=True)
879
+
880
+ # Create subdirectories
881
+ self.msg_dir = self.output_dir / "msg"
882
+ self.srv_dir = self.output_dir / "srv"
883
+ self.action_dir = self.output_dir / "action"
884
+
885
+ self.msg_dir.mkdir(exist_ok=True)
886
+ self.srv_dir.mkdir(exist_ok=True)
887
+ self.action_dir.mkdir(exist_ok=True)
888
+
889
+ # Store interfaces for service/action combining
890
+ self.service_parts = {}
891
+ self.action_parts = {}
892
+
893
+ def generate_interfaces(self, interfaces: List[IdlInterface]) -> List[str]:
894
+ """Generate ROS2 interface files from IDL interfaces"""
895
+ generated_files = []
896
+
897
+ # First pass: collect service and action parts
898
+ for interface in interfaces:
899
+ if interface.interface_type == IdlElementType.SERVICE:
900
+ self._collect_service_part(interface)
901
+ elif interface.interface_type == IdlElementType.ACTION:
902
+ self._collect_action_part(interface)
903
+
904
+ # Second pass: generate files
905
+ for interface in interfaces:
906
+ if interface.interface_type == IdlElementType.MESSAGE:
907
+ file_path = self._generate_message(interface)
908
+ if file_path:
909
+ generated_files.append(str(file_path))
910
+
911
+ # Generate combined service files
912
+ generated_files.extend(self._generate_service_files())
913
+
914
+ # Generate combined action files
915
+ generated_files.extend(self._generate_action_files())
916
+
917
+ return generated_files
918
+
919
+ def _collect_service_part(self, interface: IdlInterface):
920
+ """Collect service request/response parts"""
921
+ if interface.name.endswith('_Request'):
922
+ base_name = interface.name[:-8] # Remove '_Request'
923
+ if base_name not in self.service_parts:
924
+ self.service_parts[base_name] = {}
925
+ self.service_parts[base_name]['request'] = interface
926
+ elif interface.name.endswith('_Response'):
927
+ base_name = interface.name[:-9] # Remove '_Response'
928
+ if base_name not in self.service_parts:
929
+ self.service_parts[base_name] = {}
930
+ self.service_parts[base_name]['response'] = interface
931
+
932
+ def _collect_action_part(self, interface: IdlInterface):
933
+ """Collect action goal/result/feedback parts"""
934
+ name = interface.name
935
+ if name.endswith('_Goal'):
936
+ base_name = name[:-5] # Remove '_Goal'
937
+ if base_name not in self.action_parts:
938
+ self.action_parts[base_name] = {}
939
+ self.action_parts[base_name]['goal'] = interface
940
+ elif name.endswith('Goal'):
941
+ base_name = name[:-4] # Remove 'Goal'
942
+ if base_name not in self.action_parts:
943
+ self.action_parts[base_name] = {}
944
+ self.action_parts[base_name]['goal'] = interface
945
+ elif name.endswith('_Result'):
946
+ base_name = name[:-7] # Remove '_Result'
947
+ if base_name not in self.action_parts:
948
+ self.action_parts[base_name] = {}
949
+ self.action_parts[base_name]['result'] = interface
950
+ elif name.endswith('Result'):
951
+ base_name = name[:-6] # Remove 'Result'
952
+ if base_name not in self.action_parts:
953
+ self.action_parts[base_name] = {}
954
+ self.action_parts[base_name]['result'] = interface
955
+ elif name.endswith('_Feedback'):
956
+ base_name = name[:-9] # Remove '_Feedback'
957
+ if base_name not in self.action_parts:
958
+ self.action_parts[base_name] = {}
959
+ self.action_parts[base_name]['feedback'] = interface
960
+ elif name.endswith('Feedback'):
961
+ base_name = name[:-8] # Remove 'Feedback'
962
+ if base_name not in self.action_parts:
963
+ self.action_parts[base_name] = {}
964
+ self.action_parts[base_name]['feedback'] = interface
965
+
966
+ def _generate_service_files(self) -> List[str]:
967
+ """Generate .srv files from collected service parts"""
968
+ generated_files = []
969
+
970
+ for service_name, parts in self.service_parts.items():
971
+ if 'request' in parts and 'response' in parts:
972
+ file_path = self._generate_combined_service(service_name, parts['request'], parts['response'])
973
+ if file_path:
974
+ generated_files.append(str(file_path))
975
+
976
+ return generated_files
977
+
978
+ def _generate_action_files(self) -> List[str]:
979
+ """Generate .action files from collected action parts"""
980
+ generated_files = []
981
+
982
+ for action_name, parts in self.action_parts.items():
983
+ if all(key in parts for key in ['goal', 'result', 'feedback']):
984
+ file_path = self._generate_combined_action(action_name, parts)
985
+ if file_path:
986
+ generated_files.append(str(file_path))
987
+
988
+ return generated_files
989
+
990
+ def _generate_combined_service(self, service_name: str, request_interface: IdlInterface, response_interface: IdlInterface) -> Optional[pathlib.Path]:
991
+ """Generate a combined .srv file"""
992
+ lines = []
993
+
994
+ # Add request fields
995
+ if request_interface.structures:
996
+ structure = request_interface.structures[0]
997
+ # Add structure comments as comment for first field (if no field comment exists)
998
+ for i, field in enumerate(structure.fields):
999
+ # Add field comment if present, or structure comment for first field
1000
+ if field.comment:
1001
+ if lines: # Add blank line before field comment if not first
1002
+ lines.append("")
1003
+ comment_lines = field.comment.split('\n')
1004
+ for comment_line in comment_lines:
1005
+ # Unescape quotes in comments
1006
+ comment_line = comment_line.replace('\\"', '"')
1007
+ lines.append(f"# {comment_line}")
1008
+ elif i == 0 and structure.comments:
1009
+ # Use structure comment for first field if field has no comment
1010
+ for comment in structure.comments:
1011
+ comment_lines = comment.split('\n')
1012
+ for comment_line in comment_lines:
1013
+ lines.append(f"# {comment_line}")
1014
+ lines.append(self._format_field(field))
1015
+
1016
+ # Add separator
1017
+ lines.append("---")
1018
+
1019
+ # Add response fields
1020
+ if response_interface.structures:
1021
+ structure = response_interface.structures[0]
1022
+ # Add structure comments as comment for first field (if no field comment exists)
1023
+ for i, field in enumerate(structure.fields):
1024
+ # Add field comment if present, or structure comment for first field
1025
+ if field.comment:
1026
+ lines.append("") # Add blank line before field comment
1027
+ comment_lines = field.comment.split('\n')
1028
+ for comment_line in comment_lines:
1029
+ # Unescape quotes in comments
1030
+ comment_line = comment_line.replace('\\"', '"')
1031
+ lines.append(f"# {comment_line}")
1032
+ elif i == 0 and structure.comments:
1033
+ # Use structure comment for first field if field has no comment
1034
+ for comment in structure.comments:
1035
+ comment_lines = comment.split('\n')
1036
+ for comment_line in comment_lines:
1037
+ lines.append(f"# {comment_line}")
1038
+ lines.append(self._format_field(field))
1039
+
1040
+ content = "\n".join(lines)
1041
+ file_path = self.srv_dir / f"{service_name}.srv"
1042
+
1043
+ with open(file_path, 'w') as f:
1044
+ f.write(content)
1045
+
1046
+ print(f"Generated: {file_path}")
1047
+ return file_path
1048
+
1049
+ def _generate_combined_action(self, action_name: str, parts: Dict) -> Optional[pathlib.Path]:
1050
+ """Generate a combined .action file"""
1051
+ lines = []
1052
+
1053
+ # Add header comment
1054
+ lines.append(f"# {action_name}.action")
1055
+ lines.append("# Generated from IDL file")
1056
+ lines.append("")
1057
+
1058
+ # Add goal fields
1059
+ lines.append("# Goal")
1060
+ if parts['goal'].structures:
1061
+ for field in parts['goal'].structures[0].fields:
1062
+ lines.append(self._format_field(field))
1063
+
1064
+ lines.append("---")
1065
+
1066
+ # Add result fields
1067
+ lines.append("# Result")
1068
+ if parts['result'].structures:
1069
+ for field in parts['result'].structures[0].fields:
1070
+ lines.append(self._format_field(field))
1071
+
1072
+ lines.append("---")
1073
+
1074
+ # Add feedback fields
1075
+ lines.append("# Feedback")
1076
+ if parts['feedback'].structures:
1077
+ for field in parts['feedback'].structures[0].fields:
1078
+ lines.append(self._format_field(field))
1079
+
1080
+ content = "\n".join(lines)
1081
+ file_path = self.action_dir / f"{action_name}.action"
1082
+
1083
+ with open(file_path, 'w') as f:
1084
+ f.write(content)
1085
+
1086
+ print(f"Generated: {file_path}")
1087
+ return file_path
1088
+
1089
+ def _generate_message(self, interface: IdlInterface) -> Optional[pathlib.Path]:
1090
+ """Generate .msg file"""
1091
+ if not interface.structures:
1092
+ return None
1093
+
1094
+ structure = interface.structures[0]
1095
+ content = self._generate_message_content(structure, interface)
1096
+
1097
+ file_path = self.msg_dir / f"{interface.name}.msg"
1098
+ with open(file_path, 'w') as f:
1099
+ f.write(content)
1100
+
1101
+ print(f"Generated: {file_path}")
1102
+ return file_path
1103
+
1104
+ def _generate_message_content(self, structure: IdlStructure, interface: IdlInterface) -> str:
1105
+ """Generate the content of a .msg file"""
1106
+ lines = []
1107
+
1108
+ # Add verbatim comments first (if any)
1109
+ if structure.comments:
1110
+ for comment in structure.comments:
1111
+ lines.append(f"# {comment}")
1112
+
1113
+ # Process fields in their original order to preserve IDL field sequence
1114
+ for field in structure.fields:
1115
+ # Add field comment if present
1116
+ if field.comment:
1117
+ lines.append("") # Add blank line before field comment
1118
+ comment_lines = field.comment.split('\n')
1119
+ for comment_line in comment_lines:
1120
+ lines.append(f"# {comment_line}")
1121
+ line = self._format_field(field)
1122
+ lines.append(line)
1123
+
1124
+ # Add constants after fields (for ROS2 .msg format compatibility)
1125
+ for constant in structure.constants:
1126
+ line = self._format_constant_as_field(constant)
1127
+ lines.append(line)
1128
+
1129
+ return "\n".join(lines)
1130
+
1131
+ def _format_field(self, field: IdlField) -> str:
1132
+ """Format a field for ROS interface file"""
1133
+ field_type = field.field_type
1134
+
1135
+ # Handle arrays and bounded types
1136
+ if field.is_array:
1137
+ if field.is_sequence and field.array_size is not None:
1138
+ # Bounded sequence: Type[<=N]
1139
+ field_type += f"[<={field.array_size}]"
1140
+ elif field.is_sequence:
1141
+ # Unbounded sequence: Type[]
1142
+ field_type += "[]"
1143
+ elif field.is_bounded_string:
1144
+ # Bounded string: string<=N
1145
+ field_type = f"string<={field.array_size}"
1146
+ elif field.array_size is not None:
1147
+ # Fixed-size array: Type[N]
1148
+ field_type += f"[{field.array_size}]"
1149
+ else:
1150
+ # Dynamic array: Type[]
1151
+ field_type += "[]"
1152
+
1153
+ line = f"{field_type} {field.name}"
1154
+
1155
+ # Add default value if present
1156
+ if field.default_value:
1157
+ line += f" {field.default_value}"
1158
+
1159
+ return line
1160
+
1161
+ def _format_constant_as_field(self, constant: IdlConstant) -> str:
1162
+ """Format a constant as a field-like entry for compatibility with ROS2 .msg format"""
1163
+ return f"{constant.const_type} {constant.name}={constant.value}"
1164
+
1165
+ def _format_constant(self, constant: IdlConstant) -> str:
1166
+ """Format a constant for ROS interface file"""
1167
+ return f"{constant.const_type} {constant.name}={constant.value}"
1168
+
1169
+
1170
+ def main():
1171
+ """Main function"""
1172
+ parser = argparse.ArgumentParser(description="Convert ROS2 IDL files to interface files")
1173
+ parser.add_argument("idl_file", help="Path to the IDL file to convert")
1174
+ parser.add_argument("-o", "--output", default="ros_interfaces",
1175
+ help="Output directory for generated files")
1176
+ parser.add_argument("-r", "--root",
1177
+ help="Root path where the generated files will be located (default: current directory)")
1178
+ parser.add_argument("-p", "--package",
1179
+ help="Package name to use for generated files (overrides package from IDL)")
1180
+ parser.add_argument("-v", "--verbose", action="store_true",
1181
+ help="Enable verbose output")
1182
+
1183
+ args = parser.parse_args()
1184
+
1185
+ if not os.path.exists(args.idl_file):
1186
+ print(f"Error: IDL file '{args.idl_file}' not found")
1187
+ return 1
1188
+
1189
+ try:
1190
+ # Parse IDL file
1191
+ idl_parser = IdlParser()
1192
+ interfaces = idl_parser.parse_file(args.idl_file)
1193
+
1194
+ # Override package name if provided
1195
+ if args.package:
1196
+ for interface in interfaces:
1197
+ interface.package = args.package
1198
+
1199
+ if args.verbose:
1200
+ print(f"Parsed {len(interfaces)} interfaces from {args.idl_file}")
1201
+ for interface in interfaces:
1202
+ print(f" - {interface.name} ({interface.interface_type.value})")
1203
+ if args.package:
1204
+ print(f" Package: {interface.package} (overridden)")
1205
+ else:
1206
+ print(f" Package: {interface.package}")
1207
+
1208
+ # Determine output directory
1209
+ if args.root:
1210
+ output_dir = pathlib.Path(args.root) / args.output
1211
+ else:
1212
+ output_dir = pathlib.Path(args.output)
1213
+
1214
+ # Generate ROS interface files
1215
+ generator = RosInterfaceGenerator(str(output_dir))
1216
+ generated_files = generator.generate_interfaces(interfaces)
1217
+
1218
+ print(f"\nGenerated {len(generated_files)} files:")
1219
+ for file_path in generated_files:
1220
+ print(f" - {file_path}")
1221
+
1222
+ # Display generated file contents if verbose
1223
+ if args.verbose:
1224
+ print("\n" + "="*60)
1225
+ print("Generated file contents:")
1226
+ for file_path in generated_files:
1227
+ print(f"\n--- {pathlib.Path(file_path).name} ---")
1228
+ with open(file_path, 'r') as f:
1229
+ print(f.read())
1230
+
1231
+ return 0
1232
+
1233
+ except Exception as e:
1234
+ print(f"Error: {e}")
1235
+ if args.verbose:
1236
+ import traceback
1237
+ traceback.print_exc()
1238
+ return 1
1239
+
1240
+
1241
+ if __name__ == "__main__":
1242
+ if len(sys.argv) == 1:
1243
+ print("\nUsage: python idl_parser.py <idl_file> [options]")
1244
+ print("Options:")
1245
+ print(" -o, --output DIR Output directory name")
1246
+ print(" -r, --root PATH Root path for generated files")
1247
+ print(" -p, --package NAME Package name to use")
1248
+ print(" -v, --verbose Enable verbose output")
1249
+ else:
1250
+ exit(main())