Coverage for src/c2puml/core/generator.py: 82%

488 statements  

« prev     ^ index     » next       coverage.py v7.10.4, created at 2025-08-20 03:53 +0000

1#!/usr/bin/env python3 

2""" 

3PlantUML Generator that creates proper PlantUML diagrams from C source and header files. 

4Follows the template format with strict separation of typedefs and clear relationship groupings. 

5""" 

6 

7import glob 

8import os 

9import re 

10from pathlib import Path 

11from typing import Dict, List, Optional 

12 

13from ..models import Field, FileModel, Function, ProjectModel 

14 

15# PlantUML generation constants 

16MAX_LINE_LENGTH = 120 

17TRUNCATION_LENGTH = 100 

18INDENT = " " 

19 

20# PlantUML styling colors 

21COLOR_SOURCE = "#LightBlue" 

22COLOR_HEADER = "#LightGreen" 

23COLOR_TYPEDEF = "#LightYellow" 

24 

25# UML prefixes 

26PREFIX_HEADER = "HEADER_" 

27PREFIX_TYPEDEF = "TYPEDEF_" 

28 

29 

30class Generator: 

31 """Generator that creates proper PlantUML files. 

32 

33 This class handles the complete PlantUML generation process, including: 

34 - Loading project models from JSON files 

35 - Building include trees for files 

36 - Generating UML IDs for all elements 

37 - Creating PlantUML classes for C files, headers, and typedefs 

38 - Generating relationships between elements 

39 - Writing output files to disk 

40 """ 

41 

42 # Configuration (set by main based on Config) 

43 max_function_signature_chars: int = 0 # 0 or less = unlimited 

44 hide_macro_values: bool = False # Hide macro values in generated PlantUML diagrams 

45 convert_empty_class_to_artifact: bool = False # Render empty headers as artifacts when enabled 

46 

47 def _clear_output_folder(self, output_dir: str) -> None: 

48 """Clear existing .puml and .png files from the output directory""" 

49 if not os.path.exists(output_dir): 

50 return 

51 

52 # Remove files with specified extensions in the output directory 

53 for ext in ("*.puml", "*.png", "*.html"): 

54 for file_path in glob.glob(os.path.join(output_dir, ext)): 

55 try: 

56 os.remove(file_path) 

57 except OSError: 

58 pass # Ignore errors if file can't be removed 

59 

60 def generate( 

61 self, model_file: str, output_dir: str = "./output" 

62 ) -> str: 

63 """Generate PlantUML files for all C files in the model""" 

64 # Load the model 

65 project_model = self._load_model(model_file) 

66 

67 # Create output directory 

68 os.makedirs(output_dir, exist_ok=True) 

69 

70 # Clear existing .puml and .png files from output directory 

71 self._clear_output_folder(output_dir) 

72 

73 # Generate a PlantUML file for each C file 

74 generated_files = [] 

75 

76 for filename, file_model in sorted(project_model.files.items()): 

77 # Only process C files (not headers) for diagram generation 

78 if file_model.name.endswith(".c"): 

79 # Generate PlantUML content 

80 # include_depth is handled by the transformer which processes 

81 # file-specific settings and stores them in include_relations 

82 puml_content = self.generate_diagram( 

83 file_model, project_model 

84 ) 

85 

86 # Create output filename 

87 basename = Path(file_model.name).stem 

88 output_file = os.path.join(output_dir, f"{basename}.puml") 

89 

90 # Write the file 

91 with open(output_file, "w", encoding="utf-8") as f: 

92 f.write(puml_content) 

93 

94 generated_files.append(output_file) 

95 

96 return output_dir 

97 

98 def generate_diagram( 

99 self, file_model: FileModel, project_model: ProjectModel 

100 ) -> str: 

101 """Generate a PlantUML diagram for a file following the template format""" 

102 basename = Path(file_model.name).stem 

103 # Capture placeholder headers for this diagram (if provided by transformer) 

104 self._placeholder_headers = set(getattr(file_model, "placeholder_headers", set())) 

105 include_tree = self._build_include_tree( 

106 file_model, project_model 

107 ) 

108 # Precompute header-declared names for visibility 

109 header_function_decl_names: set[str] = set() 

110 header_global_names: set[str] = set() 

111 for filename, fm in project_model.files.items(): 

112 if filename.endswith(".h"): 

113 for f in fm.functions: 

114 if f.is_declaration: 

115 header_function_decl_names.add(f.name) 

116 for g in fm.globals: 

117 header_global_names.add(g.name) 

118 

119 uml_ids = self._generate_uml_ids(include_tree, project_model) 

120 

121 lines = [f"@startuml {basename}", ""] 

122 

123 self._generate_all_file_classes( 

124 lines, 

125 include_tree, 

126 uml_ids, 

127 project_model, 

128 header_function_decl_names, 

129 header_global_names, 

130 ) 

131 self._generate_relationships(lines, include_tree, uml_ids, project_model) 

132 

133 lines.extend(["", "@enduml"]) 

134 return "\n".join(lines) 

135 

136 def _generate_all_file_classes( 

137 self, 

138 lines: List[str], 

139 include_tree: Dict[str, FileModel], 

140 uml_ids: Dict[str, str], 

141 project_model: ProjectModel, 

142 header_function_decl_names: set[str], 

143 header_global_names: set[str], 

144 ): 

145 """Generate all file classes (C files, headers, and typedefs)""" 

146 # Precompute names of function-pointer aliases to suppress duplicate struct classes 

147 funcptr_alias_names: set[str] = set() 

148 for _file_path, file_data in include_tree.items(): 

149 # Skip placeholder headers entirely for content processing 

150 if _file_path.endswith(".h") and _file_path in getattr(self, "_placeholder_headers", set()): 

151 continue 

152 for alias_name, alias_data in file_data.aliases.items(): 

153 if self._is_function_pointer_type(alias_data.original_type): 

154 funcptr_alias_names.add(alias_name) 

155 

156 self._generate_file_classes_by_extension( 

157 lines, 

158 include_tree, 

159 uml_ids, 

160 project_model, 

161 header_function_decl_names, 

162 header_global_names, 

163 ".c", 

164 self._generate_c_file_class, 

165 ) 

166 self._generate_file_classes_by_extension( 

167 lines, 

168 include_tree, 

169 uml_ids, 

170 project_model, 

171 header_function_decl_names, 

172 header_global_names, 

173 ".h", 

174 self._generate_header_class, 

175 ) 

176 self._generate_typedef_classes_for_all_files(lines, include_tree, uml_ids, funcptr_alias_names) 

177 

178 def _generate_file_classes_by_extension( 

179 self, 

180 lines: List[str], 

181 include_tree: Dict[str, FileModel], 

182 uml_ids: Dict[str, str], 

183 project_model: ProjectModel, 

184 header_function_decl_names: set[str], 

185 header_global_names: set[str], 

186 extension: str, 

187 generator_method, 

188 ): 

189 """Generate file classes for files with specific extension""" 

190 for file_path, file_data in sorted(include_tree.items()): 

191 if file_path.endswith(extension): 

192 generator_method( 

193 lines, 

194 file_data, 

195 uml_ids, 

196 project_model, 

197 header_function_decl_names, 

198 header_global_names, 

199 ) 

200 

201 def _generate_typedef_classes_for_all_files( 

202 self, 

203 lines: List[str], 

204 include_tree: Dict[str, FileModel], 

205 uml_ids: Dict[str, str], 

206 funcptr_alias_names: set[str], 

207 ): 

208 """Generate typedef classes for all files in include tree""" 

209 # No suppression in unit test mode: keep both generic and specific typedefs available 

210 suppressed_structs: set[str] = set() 

211 suppressed_unions: set[str] = set() 

212 

213 for file_path, file_data in sorted(include_tree.items()): 

214 # Skip typedef class generation for placeholder headers 

215 if file_path.endswith(".h") and file_path in getattr(self, "_placeholder_headers", set()): 

216 continue 

217 self._generate_typedef_classes( 

218 lines, 

219 file_data, 

220 uml_ids, 

221 suppressed_structs, 

222 suppressed_unions, 

223 funcptr_alias_names, 

224 ) 

225 lines.append("") 

226 

227 def _load_model(self, model_file: str) -> ProjectModel: 

228 """Load the project model from JSON file""" 

229 return ProjectModel.load(model_file) 

230 

231 def _build_include_tree( 

232 self, root_file: FileModel, project_model: ProjectModel 

233 ) -> Dict[str, FileModel]: 

234 """Build include tree starting from root file""" 

235 include_tree = {} 

236 

237 def find_file_key(file_name: str) -> str: 

238 """Find the correct key for a file in project_model.files using filename matching""" 

239 # First try exact match 

240 if file_name in project_model.files: 

241 return file_name 

242 

243 # Try matching by filename (filenames are guaranteed to be unique) 

244 filename = Path(file_name).name 

245 if filename in project_model.files: 

246 return filename 

247 

248 # If not found, return the filename (will be handled gracefully) 

249 return filename 

250 

251 # Start with the root file 

252 root_key = find_file_key(root_file.name) 

253 if root_key in project_model.files: 

254 include_tree[root_key] = project_model.files[root_key] 

255 

256 # If root file has include_relations, use only those files (flat processing) 

257 # This is the authoritative source built by the transformer (respecting include_depth and filters) 

258 if root_file.include_relations: 

259 # include_relations is already a flattened list of all headers needed 

260 included_files = set() 

261 for relation in root_file.include_relations: 

262 included_files.add(relation.included_file) 

263 

264 # Add all files mentioned in include_relations 

265 for included_file in included_files: 

266 file_key = find_file_key(included_file) 

267 if file_key in project_model.files: 

268 include_tree[file_key] = project_model.files[file_key] 

269 else: 

270 # Fall back: only direct includes (depth=1) when no include_relations exist 

271 visited = set() 

272 

273 def add_file_to_tree_once(file_name: str): 

274 if file_name in visited: 

275 return 

276 visited.add(file_name) 

277 file_key = find_file_key(file_name) 

278 if file_key in project_model.files: 

279 include_tree[file_key] = project_model.files[file_key] 

280 

281 # Start traversal from root (already added above) 

282 if root_key in project_model.files: 

283 root_file_model = project_model.files[root_key] 

284 for include in root_file_model.includes: 

285 clean_include = include.strip('<>"') 

286 add_file_to_tree_once(clean_include) 

287 

288 return include_tree 

289 

290 def _generate_uml_ids( 

291 self, include_tree: Dict[str, FileModel], project_model: ProjectModel 

292 ) -> Dict[str, str]: 

293 """Generate UML IDs for all elements in the include tree using filename-based keys""" 

294 uml_ids = {} 

295 

296 for filename, file_model in include_tree.items(): 

297 basename = Path(filename).stem.upper().replace("-", "_") 

298 file_key = Path(filename).name # Use just the filename as key 

299 

300 if filename.endswith(".c"): 

301 # C files: no prefix 

302 uml_ids[file_key] = basename 

303 elif filename.endswith(".h"): 

304 # H files: HEADER_ prefix 

305 uml_ids[file_key] = f"{PREFIX_HEADER}{basename}" 

306 

307 # For placeholder headers, only generate the file UML ID; skip typedef UML IDs 

308 if filename.endswith(".h") and Path(filename).name in getattr(self, "_placeholder_headers", set()): 

309 continue 

310 

311 # Generate typedef UML IDs 

312 for typedef_name in file_model.structs: 

313 uml_ids[f"typedef_{typedef_name}"] = ( 

314 f"{PREFIX_TYPEDEF}{typedef_name.upper()}" 

315 ) 

316 for typedef_name in file_model.enums: 

317 uml_ids[f"typedef_{typedef_name}"] = ( 

318 f"{PREFIX_TYPEDEF}{typedef_name.upper()}" 

319 ) 

320 for typedef_name in file_model.aliases: 

321 uml_ids[f"typedef_{typedef_name}"] = ( 

322 f"{PREFIX_TYPEDEF}{typedef_name.upper()}" 

323 ) 

324 for typedef_name in file_model.unions: 

325 uml_ids[f"typedef_{typedef_name}"] = ( 

326 f"{PREFIX_TYPEDEF}{typedef_name.upper()}" 

327 ) 

328 

329 return uml_ids 

330 

331 def _format_macro(self, macro: str, prefix: str = "") -> str: 

332 """Format a macro with the given prefix (+ for headers, - for source).""" 

333 import re 

334 

335 hide_values = getattr(self, "hide_macro_values", False) 

336 

337 # Regex for function-like macro (no space before '(') 

338 func_like_pattern = re.compile(r"#define\s+([A-Za-z_][A-Za-z0-9_]*\([^)]*\))") 

339 obj_like_pattern = re.compile(r"#define\s+([A-Za-z_][A-Za-z0-9_]*)") 

340 

341 match = func_like_pattern.search(macro) 

342 if match: 

343 # Function-like macros: only show name+params 

344 macro_name_with_params = match.group(1) 

345 return f"{INDENT}{prefix}#define {macro_name_with_params}" 

346 

347 match = obj_like_pattern.search(macro) 

348 if match: 

349 if hide_values: 

350 # Only name 

351 macro_name = match.group(1) 

352 return f"{INDENT}{prefix}#define {macro_name}" 

353 else: 

354 # Full definition 

355 clean_macro = macro.strip() 

356 if clean_macro.startswith("#define"): 

357 return f"{INDENT}{prefix}{clean_macro}" 

358 

359 # Fallback 

360 return f"{INDENT}{prefix}{macro}" 

361 

362 def _format_global_variable(self, global_var, prefix: str = "") -> str: 

363 """Format a global variable with the given prefix.""" 

364 return f"{INDENT}{prefix}{global_var.type} {global_var.name}" 

365 

366 def _format_function_signature(self, func, prefix: str = "") -> str: 

367 """Format a function signature with truncation if needed.""" 

368 params = self._format_function_parameters(func.parameters) 

369 param_str_full = ", ".join(params) 

370 

371 # Remove 'extern' and 'LOCAL_INLINE' keywords from return type for UML diagrams 

372 return_type = func.return_type.replace("extern ", "").replace("LOCAL_INLINE ", "").strip() 

373 

374 # Build full signature 

375 full_signature = f"{INDENT}{prefix}{return_type} {func.name}({param_str_full})" 

376 limit = getattr(self, "max_function_signature_chars", 0) 

377 if isinstance(limit, int) and limit > 0 and len(full_signature) > limit: 

378 # Try to truncate parameters by characters while preserving readability and appending ... 

379 head = f"{INDENT}{prefix}{return_type} {func.name}(" 

380 remaining = limit - len(head) - 1 # -1 for closing paren 

381 if remaining <= 0: 

382 return head + "...)" 

383 # fill with params until remaining would be exceeded 

384 out = [] 

385 consumed = 0 

386 for i, p in enumerate(params): 

387 add = (", " if i > 0 else "") + p 

388 if consumed + len(add) + 3 > remaining: # +3 for ellipsis when needed 

389 out.append(", ..." if i > 0 else "...") 

390 break 

391 out.append(add) 

392 consumed += len(add) 

393 param_str = "".join(out) 

394 return head + param_str + ")" 

395 return full_signature 

396 

397 def _format_function_parameters(self, parameters) -> List[str]: 

398 """Format function parameters into string list.""" 

399 params = [] 

400 for p in parameters: 

401 if p.name == "..." and p.type == "...": 

402 params.append("...") 

403 continue 

404 

405 # Avoid duplicating the name for function pointer parameters if the type already contains it 

406 type_str = p.type.strip() 

407 name_str = p.name.strip() 

408 # Detect patterns like "( * name )" within the type 

409 try: 

410 contains_func_ptr = "( *" in type_str and ")" in type_str 

411 name_inside = None 

412 if contains_func_ptr: 

413 after = type_str.split("( *", 1)[1] 

414 name_inside = after.split(")", 1)[0].strip() 

415 if name_inside and name_str and name_str == name_inside: 

416 params.append(type_str) 

417 else: 

418 params.append(f"{type_str} {name_str}".strip()) 

419 except Exception: 

420 # Fallback if any unexpected formatting occurs 

421 params.append(f"{type_str} {name_str}".strip()) 

422 return params 

423 

424 # Truncation disabled to ensure complete signatures are rendered 

425 

426 def _add_macros_section( 

427 self, lines: List[str], file_model: FileModel, prefix: str = "" 

428 ): 

429 """Add macros section to lines with given prefix.""" 

430 if file_model.macros: 

431 lines.append(f"{INDENT}-- Macros --") 

432 for macro in sorted(file_model.macros): 

433 lines.append(self._format_macro(macro, prefix)) 

434 

435 def _add_globals_section( 

436 self, lines: List[str], file_model: FileModel, prefix: str = "" 

437 ): 

438 """Add global variables section to lines with given prefix.""" 

439 if file_model.globals: 

440 lines.append(f"{INDENT}-- Global Variables --") 

441 for global_var in sorted(file_model.globals, key=lambda x: x.name): 

442 lines.append(self._format_global_variable(global_var, prefix)) 

443 

444 def _add_functions_section( 

445 self, 

446 lines: List[str], 

447 file_model: FileModel, 

448 prefix: str = "", 

449 is_declaration_only: bool = False, 

450 ): 

451 """Add functions section to lines with given prefix and filter.""" 

452 if file_model.functions: 

453 lines.append(f"{INDENT}-- Functions --") 

454 for func in sorted(file_model.functions, key=lambda x: x.name): 

455 if is_declaration_only and (func.is_declaration or func.is_inline): 

456 lines.append(self._format_function_signature(func, prefix)) 

457 elif not is_declaration_only and not func.is_declaration: 

458 lines.append(self._format_function_signature(func, prefix)) 

459 

460 def _generate_c_file_class( 

461 self, 

462 lines: List[str], 

463 file_model: FileModel, 

464 uml_ids: Dict[str, str], 

465 project_model: ProjectModel, 

466 header_function_decl_names: set[str], 

467 header_global_names: set[str], 

468 ): 

469 """Generate class for C file using unified method with dynamic visibility""" 

470 self._generate_file_class_unified( 

471 lines=lines, 

472 file_model=file_model, 

473 uml_ids=uml_ids, 

474 header_function_decl_names=header_function_decl_names, 

475 header_global_names=header_global_names, 

476 class_type="source", 

477 color=COLOR_SOURCE, 

478 macro_prefix="- ", 

479 is_declaration_only=False, 

480 use_dynamic_visibility=True, 

481 ) 

482 

483 def _generate_header_class( 

484 self, 

485 lines: List[str], 

486 file_model: FileModel, 

487 uml_ids: Dict[str, str], 

488 project_model: ProjectModel, 

489 header_function_decl_names: set[str], 

490 header_global_names: set[str], 

491 ): 

492 """Generate class for header file using unified method with static '+' visibility""" 

493 self._generate_file_class_unified( 

494 lines=lines, 

495 file_model=file_model, 

496 uml_ids=uml_ids, 

497 header_function_decl_names=header_function_decl_names, 

498 header_global_names=header_global_names, 

499 class_type="header", 

500 color=COLOR_HEADER, 

501 macro_prefix="+ ", 

502 is_declaration_only=True, 

503 use_dynamic_visibility=False, 

504 ) 

505 

506 def _generate_file_class_unified( 

507 self, 

508 lines: List[str], 

509 file_model: FileModel, 

510 uml_ids: Dict[str, str], 

511 header_function_decl_names: set[str], 

512 header_global_names: set[str], 

513 class_type: str, 

514 color: str, 

515 macro_prefix: str, 

516 is_declaration_only: bool, 

517 use_dynamic_visibility: bool, 

518 ): 

519 """Generate class for a file; dynamic visibility for sources, static for headers.""" 

520 basename = Path(file_model.name).stem 

521 filename = Path(file_model.name).name 

522 uml_id = uml_ids.get(filename) 

523 

524 if not uml_id: 

525 return 

526 

527 lines.append(f'class "{basename}" as {uml_id} <<{class_type}>> {color}') 

528 lines.append("{") 

529 

530 # If this header is marked as placeholder for this diagram, render as empty class 

531 if class_type == "header" and Path(filename).name in getattr(self, "_placeholder_headers", set()): 

532 # When configured, render empty headers as artifact nodes instead of empty classes 

533 if getattr(self, "convert_empty_class_to_artifact", False): 

534 # Remove the opening brace and replace the class line with artifact syntax 

535 lines.pop() 

536 lines[-1] = f'() "{basename}" as {uml_id} <<{class_type}>> {color}' 

537 lines.append("") 

538 return 

539 lines.append("}") 

540 lines.append("") 

541 return 

542 

543 self._add_macros_section(lines, file_model, macro_prefix) 

544 if use_dynamic_visibility: 

545 # Use precomputed header visibility sets 

546 self._add_globals_section_with_visibility( 

547 lines, file_model, header_global_names 

548 ) 

549 self._add_functions_section_with_visibility( 

550 lines, file_model, header_function_decl_names, is_declaration_only 

551 ) 

552 else: 

553 # Static '+' visibility for headers 

554 self._add_globals_section(lines, file_model, "+ ") 

555 self._add_functions_section( 

556 lines, file_model, "+ ", is_declaration_only 

557 ) 

558 

559 lines.append("}") 

560 lines.append("") 

561 

562 def _add_globals_section_with_visibility( 

563 self, lines: List[str], file_model: FileModel, header_global_names: set[str] 

564 ): 

565 """Add global variables section with visibility based on header presence, grouped by visibility""" 

566 if file_model.globals: 

567 lines.append(f"{INDENT}-- Global Variables --") 

568 

569 # Separate globals into public and private groups 

570 public_globals = [] 

571 private_globals = [] 

572 

573 for global_var in sorted(file_model.globals, key=lambda x: x.name): 

574 prefix = "+ " if global_var.name in header_global_names else "- " 

575 formatted_global = self._format_global_variable(global_var, prefix) 

576 

577 if prefix == "+ ": 

578 public_globals.append(formatted_global) 

579 else: 

580 private_globals.append(formatted_global) 

581 

582 # Add public globals first 

583 for global_line in public_globals: 

584 lines.append(global_line) 

585 

586 # Add empty line between public and private if both exist 

587 if public_globals and private_globals: 

588 lines.append("") 

589 

590 # Add private globals 

591 for global_line in private_globals: 

592 lines.append(global_line) 

593 

594 def _add_functions_section_with_visibility( 

595 self, 

596 lines: List[str], 

597 file_model: FileModel, 

598 header_function_decl_names: set[str], 

599 is_declaration_only: bool = False, 

600 ): 

601 """Add functions section with visibility based on header presence, grouped by visibility""" 

602 if file_model.functions: 

603 lines.append(f"{INDENT}-- Functions --") 

604 

605 # Separate functions into public and private groups 

606 public_functions = [] 

607 private_functions = [] 

608 

609 for func in sorted(file_model.functions, key=lambda x: x.name): 

610 if is_declaration_only and (func.is_declaration or func.is_inline): 

611 prefix = "+ " 

612 formatted_function = self._format_function_signature(func, prefix) 

613 public_functions.append(formatted_function) 

614 elif not is_declaration_only and not func.is_declaration: 

615 prefix = "+ " if func.name in header_function_decl_names else "- " 

616 formatted_function = self._format_function_signature(func, prefix) 

617 

618 if prefix == "+ ": 

619 public_functions.append(formatted_function) 

620 else: 

621 private_functions.append(formatted_function) 

622 

623 # Add public functions first 

624 for function_line in public_functions: 

625 lines.append(function_line) 

626 

627 # Add empty line between public and private if both exist 

628 if public_functions and private_functions: 

629 lines.append("") 

630 

631 # Add private functions 

632 for function_line in private_functions: 

633 lines.append(function_line) 

634 

635 # Removed O(N^2) header scans in favor of precomputed header visibility sets 

636 

637 def _generate_typedef_classes( 

638 self, 

639 lines: List[str], 

640 file_data: FileModel, 

641 uml_ids: Dict[str, str], 

642 suppressed_structs: set[str], 

643 suppressed_unions: set[str], 

644 funcptr_alias_names: set[str], 

645 ): 

646 """Generate classes for typedefs""" 

647 self._generate_struct_classes(lines, file_data, uml_ids, suppressed_structs, funcptr_alias_names) 

648 self._generate_enum_classes(lines, file_data, uml_ids) 

649 self._generate_alias_classes(lines, file_data, uml_ids) 

650 self._generate_union_classes(lines, file_data, uml_ids, suppressed_unions) 

651 

652 def _generate_struct_classes( 

653 self, 

654 lines: List[str], 

655 file_model: FileModel, 

656 uml_ids: Dict[str, str], 

657 suppressed_structs: set[str], 

658 funcptr_alias_names: set[str], 

659 ): 

660 """Generate classes for struct typedefs""" 

661 for struct_name, struct_data in sorted(file_model.structs.items()): 

662 # Skip if suppressed due to duplicate suffix with a more specific name 

663 if struct_name in suppressed_structs: 

664 continue 

665 # Skip if there is a function-pointer alias with the same name to avoid duplicate typedef of result_generator_t 

666 if struct_name in funcptr_alias_names: 

667 continue 

668 uml_id = uml_ids.get(f"typedef_{struct_name}") 

669 if uml_id: 

670 lines.append( 

671 f'class "{struct_name}" as {uml_id} <<struct>> {COLOR_TYPEDEF}' 

672 ) 

673 lines.append("{") 

674 for field in struct_data.fields: 

675 self._generate_field_with_nested_structs(lines, field, " + ") 

676 lines.append("}") 

677 lines.append("") 

678 

679 def _generate_enum_classes( 

680 self, lines: List[str], file_model: FileModel, uml_ids: Dict[str, str] 

681 ): 

682 """Generate classes for enum typedefs""" 

683 # Preserve original declaration order by iterating without sorting 

684 for enum_name, enum_data in file_model.enums.items(): 

685 uml_id = uml_ids.get(f"typedef_{enum_name}") 

686 if uml_id: 

687 lines.append( 

688 f'class "{enum_name}" as {uml_id} <<enumeration>> {COLOR_TYPEDEF}' 

689 ) 

690 lines.append("{") 

691 # Preserve source order: do not sort enum values 

692 for value in enum_data.values: 

693 if value.value: 

694 lines.append(f" {value.name} = {value.value}") 

695 else: 

696 lines.append(f" {value.name}") 

697 lines.append("}") 

698 lines.append("") 

699 

700 def _generate_alias_classes( 

701 self, lines: List[str], file_model: FileModel, uml_ids: Dict[str, str] 

702 ): 

703 """Generate classes for alias typedefs (simple typedefs)""" 

704 for alias_name, alias_data in sorted(file_model.aliases.items()): 

705 uml_id = uml_ids.get(f"typedef_{alias_name}") 

706 if uml_id: 

707 # Determine stereotype based on whether this is a function pointer typedef 

708 stereotype = self._get_alias_stereotype(alias_data) 

709 lines.append( 

710 f'class "{alias_name}" as {uml_id} {stereotype} {COLOR_TYPEDEF}' 

711 ) 

712 lines.append("{") 

713 self._process_alias_content(lines, alias_data) 

714 lines.append("}") 

715 lines.append("") 

716 

717 def _get_alias_stereotype(self, alias_data) -> str: 

718 """Determine the appropriate stereotype for an alias typedef""" 

719 original_type = alias_data.original_type.strip() 

720 if self._is_function_pointer_type(original_type): 

721 return "<<function pointer>>" 

722 return "<<typedef>>" 

723 

724 def _is_function_pointer_type(self, type_str: str) -> bool: 

725 """Heuristically detect C function pointer type patterns with optional whitespace. 

726 Examples: int (*name)(...), int ( * name ) ( ... ), int (*(*name)(...))(...)  

727 """ 

728 pattern = re.compile(r"\(\s*\*\s*\w+\s*\)\s*\(") 

729 if pattern.search(type_str): 

730 return True 

731 # Also detect nested function pointer returns: (*(*name)(...))( 

732 pattern_nested = re.compile(r"\(\s*\*\s*\(\s*\*\s*\w+\s*\)\s*\)\s*\(") 

733 return bool(pattern_nested.search(type_str)) 

734 

735 def _generate_union_classes( 

736 self, 

737 lines: List[str], 

738 file_model: FileModel, 

739 uml_ids: Dict[str, str], 

740 suppressed_unions: set[str], 

741 ): 

742 """Generate classes for union typedefs""" 

743 for union_name, union_data in sorted(file_model.unions.items()): 

744 uml_id = uml_ids.get(f"typedef_{union_name}") 

745 if uml_id: 

746 lines.append( 

747 f'class "{union_name}" as {uml_id} <<union>> {COLOR_TYPEDEF}' 

748 ) 

749 lines.append("{") 

750 for field in union_data.fields: 

751 self._generate_field_with_nested_structs(lines, field, " + ") 

752 lines.append("}") 

753 lines.append("") 

754 

755 def _process_alias_content(self, lines: List[str], alias_data): 

756 """Process the content of an alias typedef with proper formatting""" 

757 # For aliases, show "alias of {original_type}" format 

758 # Handle multi-line types properly by cleaning up newlines and extra whitespace 

759 original_type = alias_data.original_type.replace('\n', ' ').strip() 

760 # Normalize multiple spaces to single spaces 

761 original_type = ' '.join(original_type.split()) 

762 lines.append(f" alias of {original_type}") 

763 

764 # Removed dead/unused alias handling helpers (_is_truncated_typedef, _handle_truncated_typedef, _handle_normal_alias) 

765 

766 def _generate_field_with_nested_structs( 

767 self, lines: List[str], field, base_indent: str 

768 ): 

769 """Generate field with proper handling of nested structures""" 

770 field_text = f"{field.type} {field.name}" 

771 

772 # Check if this is a nested struct field with newlines 

773 if field.type.startswith("struct {") and "\n" in field.type: 

774 # Parse the nested struct content and flatten it 

775 struct_parts = field.type.split("\n") 

776 

777 # For nested structs, flatten them to avoid PlantUML parsing issues 

778 # Format as: + struct { field_type field_name } 

779 nested_content = [] 

780 for part in struct_parts[1:]: 

781 part = part.strip() 

782 if part and part != "}": 

783 nested_content.append(part) 

784 

785 if nested_content: 

786 # Create a flattened representation 

787 content_str = "; ".join(nested_content) 

788 lines.append(f"{base_indent}struct {{ {content_str} }} {field.name}") 

789 else: 

790 lines.append(f"{base_indent}struct {{ }} {field.name}") 

791 # Fallback: if a garbled anonymous pattern is detected, render as placeholder 

792 elif re.search(r"}\s+\w+;\s*struct\s*{", field.type): 

793 struct_type = "struct" if "struct" in field.type else ("union" if "union" in field.type else "struct") 

794 lines.append(f"{base_indent}{struct_type} {{ ... }} {field.name}") 

795 else: 

796 # Handle regular multi-line field types 

797 field_lines = field_text.split("\n") 

798 for i, line in enumerate(field_lines): 

799 if i == 0: 

800 lines.append(f"{base_indent}{line}") 

801 else: 

802 lines.append(f"{line}") 

803 

804 def _generate_relationships( 

805 self, 

806 lines: List[str], 

807 include_tree: Dict[str, FileModel], 

808 uml_ids: Dict[str, str], 

809 project_model: ProjectModel, 

810 ): 

811 """Generate relationships between elements""" 

812 self._generate_include_relationships(lines, include_tree, uml_ids) 

813 self._generate_declaration_relationships(lines, include_tree, uml_ids, project_model) 

814 self._generate_uses_relationships(lines, include_tree, uml_ids, project_model) 

815 self._generate_anonymous_relationships(lines, project_model, uml_ids) 

816 

817 def _generate_include_relationships( 

818 self, 

819 lines: List[str], 

820 include_tree: Dict[str, FileModel], 

821 uml_ids: Dict[str, str], 

822 ): 

823 """Generate include relationships using include_relations from .c files, with fallback to includes""" 

824 lines.append("' Include relationships") 

825 

826 # Only process .c files - never use .h files for include relationships 

827 for file_name, file_model in sorted(include_tree.items()): 

828 if not file_name.endswith(".c"): 

829 continue # Skip .h files - they should not contribute include relationships 

830 

831 file_uml_id = self._get_file_uml_id(file_name, uml_ids) 

832 if not file_uml_id: 

833 continue 

834 

835 # Prefer include_relations if available (from transformation) 

836 if file_model.include_relations: 

837 # Use include_relations for precise control based on include_depth and include_filters 

838 for relation in sorted( 

839 file_model.include_relations, 

840 key=lambda r: (r.source_file, r.included_file), 

841 ): 

842 source_uml_id = self._get_file_uml_id(relation.source_file, uml_ids) 

843 included_uml_id = self._get_file_uml_id( 

844 relation.included_file, uml_ids 

845 ) 

846 

847 if source_uml_id and included_uml_id: 

848 lines.append( 

849 f"{source_uml_id} --> {included_uml_id} : <<include>>" 

850 ) 

851 else: 

852 # Fall back to using includes field for .c files only (backward compatibility) 

853 # This happens when no transformation was applied (parsing only) 

854 for include in sorted(file_model.includes): 

855 clean_include = include.strip('<>"') 

856 include_filename = Path(clean_include).name 

857 include_uml_id = uml_ids.get(include_filename) 

858 if include_uml_id: 

859 lines.append( 

860 f"{file_uml_id} --> {include_uml_id} : <<include>>" 

861 ) 

862 

863 lines.append("") 

864 

865 def _generate_declaration_relationships( 

866 self, 

867 lines: List[str], 

868 include_tree: Dict[str, FileModel], 

869 uml_ids: Dict[str, str], 

870 project_model: ProjectModel, 

871 ): 

872 """Generate declaration relationships between files and typedefs""" 

873 lines.append("' Declaration relationships") 

874 typedef_collections_names = ["structs", "enums", "aliases", "unions"] 

875 

876 for file_name, file_model in sorted(include_tree.items()): 

877 # Suppress declaration relationships from placeholder headers 

878 if file_name.endswith(".h") and Path(file_name).name in getattr(self, "_placeholder_headers", set()): 

879 continue 

880 file_uml_id = self._get_file_uml_id(file_name, uml_ids) 

881 if file_uml_id: 

882 for collection_name in typedef_collections_names: 

883 typedef_collection = getattr(file_model, collection_name) 

884 for typedef_name in sorted(typedef_collection.keys()): 

885 # Skip anonymous structures - they should not have declares relationships from files 

886 if self._is_anonymous_structure_in_project(typedef_name, project_model): 

887 continue 

888 

889 typedef_uml_id = uml_ids.get(f"typedef_{typedef_name}") 

890 if typedef_uml_id: 

891 lines.append( 

892 f"{file_uml_id} ..> {typedef_uml_id} : <<declares>>" 

893 ) 

894 lines.append("") 

895 

896 def _get_file_uml_id( 

897 self, file_name: str, uml_ids: Dict[str, str] 

898 ) -> Optional[str]: 

899 """Get UML ID for a file""" 

900 file_key = Path(file_name).name 

901 return uml_ids.get(file_key) 

902 

903 def _is_anonymous_structure_in_project(self, typedef_name: str, project_model: ProjectModel) -> bool: 

904 """Check if a typedef is an anonymous structure using the provided project model""" 

905 for file_model in project_model.files.values(): 

906 if file_model.anonymous_relationships: 

907 for parent_name, children in file_model.anonymous_relationships.items(): 

908 if typedef_name in children: 

909 return True 

910 return False 

911 

912 def _generate_uses_relationships( 

913 self, 

914 lines: List[str], 

915 include_tree: Dict[str, FileModel], 

916 uml_ids: Dict[str, str], 

917 project_model: ProjectModel, 

918 ): 

919 """Generate uses relationships between typedefs""" 

920 lines.append("' Uses relationships") 

921 for file_name, file_model in sorted(include_tree.items()): 

922 # Struct uses relationships 

923 self._add_typedef_uses_relationships( 

924 lines, file_model.structs, uml_ids, "struct", project_model 

925 ) 

926 # Alias uses relationships 

927 self._add_typedef_uses_relationships( 

928 lines, file_model.aliases, uml_ids, "alias", project_model 

929 ) 

930 # Union uses relationships 

931 self._add_typedef_uses_relationships( 

932 lines, file_model.unions, uml_ids, "union", project_model 

933 ) 

934 

935 def _add_typedef_uses_relationships( 

936 self, 

937 lines: List[str], 

938 typedef_collection: Dict, 

939 uml_ids: Dict[str, str], 

940 typedef_type: str, 

941 project_model: ProjectModel, 

942 ): 

943 """Add uses relationships for a specific typedef collection""" 

944 for typedef_name, typedef_data in sorted(typedef_collection.items()): 

945 # Skip emitting uses from anonymous parents to reduce duplication/noise in diagrams 

946 if isinstance(typedef_name, str) and typedef_name.startswith("__anonymous_"): 

947 continue 

948 typedef_uml_id = uml_ids.get(f"typedef_{typedef_name}") 

949 if typedef_uml_id and hasattr(typedef_data, "uses"): 

950 for used_type in sorted(typedef_data.uses): 

951 used_uml_id = uml_ids.get(f"typedef_{used_type}") 

952 if used_uml_id: 

953 # Allow uses when the parent itself is anonymous; otherwise skip anonymous children (handled via composition) 

954 is_parent_anonymous = typedef_name.startswith("__anonymous_") 

955 if self._is_anonymous_structure_in_project(used_type, project_model) and not is_parent_anonymous: 

956 continue 

957 # If there is a composition for this pair, do not add a duplicate uses relation 

958 if self._is_anonymous_composition_pair(typedef_name, used_type, project_model): 

959 continue 

960 lines.append(f"{typedef_uml_id} ..> {used_uml_id} : <<uses>>") 

961 

962 def _generate_anonymous_relationships( 

963 self, lines: List[str], project_model: ProjectModel, uml_ids: Dict[str, str] 

964 ): 

965 """Generate composition relationships for anonymous structures.""" 

966 # First, check if there are any anonymous relationships 

967 has_relationships = False 

968 relationships_to_generate = [] 

969 

970 # Process all files in the project model 

971 for file_name, file_model in project_model.files.items(): 

972 if not file_model.anonymous_relationships: 

973 continue 

974 

975 # Generate relationships for each parent-child pair 

976 for parent_name, children in file_model.anonymous_relationships.items(): 

977 parent_id = self._get_anonymous_uml_id(parent_name, uml_ids) 

978 

979 for child_name in children: 

980 # Skip only pure generic placeholders as children (allow suffixed ones) 

981 if child_name in ("__anonymous_struct__", "__anonymous_union__"): 

982 continue 

983 child_id = self._get_anonymous_uml_id(child_name, uml_ids) 

984 

985 if parent_id and child_id: 

986 has_relationships = True 

987 relationships_to_generate.append(f"{parent_id} *-- {child_id} : <<contains>>") 

988 

989 # Only add the section header and relationships if we have any 

990 if has_relationships: 

991 lines.append("") 

992 lines.append("' Anonymous structure relationships (composition)") 

993 for relationship in relationships_to_generate: 

994 lines.append(relationship) 

995 

996 

997 def _get_anonymous_uml_id(self, entity_name: str, uml_ids: Dict[str, str]) -> Optional[str]: 

998 """Get UML ID for an anonymous structure entity using typedef-based keys with case-insensitive fallback.""" 

999 # Try direct key 

1000 if entity_name in uml_ids: 

1001 return uml_ids[entity_name] 

1002 

1003 # Try exact typedef key 

1004 typedef_exact = f"typedef_{entity_name}" 

1005 if typedef_exact in uml_ids: 

1006 return uml_ids[typedef_exact] 

1007 

1008 # Case-insensitive match for typedef keys 

1009 entity_lower = entity_name.lower() 

1010 for key, value in uml_ids.items(): 

1011 if key.startswith("typedef_") and key[len("typedef_"):].lower() == entity_lower: 

1012 return value 

1013 

1014 return None 

1015 

1016 def _is_anonymous_composition_pair(self, parent_name: str, child_name: str, project_model: ProjectModel) -> bool: 

1017 """Return True if a given parent->child anonymous composition exists in the project model.""" 

1018 for file_model in project_model.files.values(): 

1019 rels = getattr(file_model, "anonymous_relationships", None) 

1020 if not rels: 

1021 continue 

1022 if parent_name in rels and child_name in rels[parent_name]: 

1023 return True 

1024 return False