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

496 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-14 18:23 +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 

14from .parse_utils import normalize_type_and_name_for_arrays 

15 

16# PlantUML generation constants 

17MAX_LINE_LENGTH = 120 

18TRUNCATION_LENGTH = 100 

19INDENT = " " 

20 

21# PlantUML styling colors 

22COLOR_SOURCE = "#LightBlue" 

23COLOR_HEADER = "#LightGreen" 

24COLOR_TYPEDEF = "#LightYellow" 

25 

26# UML prefixes 

27PREFIX_HEADER = "HEADER_" 

28PREFIX_TYPEDEF = "TYPEDEF_" 

29 

30 

31class Generator: 

32 """Generator that creates proper PlantUML files. 

33 

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

35 - Loading project models from JSON files 

36 - Building include trees for files 

37 - Generating UML IDs for all elements 

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

39 - Generating relationships between elements 

40 - Writing output files to disk 

41 """ 

42 

43 # Configuration (set by main based on Config) 

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

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

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

47 

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

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

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

51 return 

52 

53 # Remove files with specified extensions in the output directory 

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

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

56 try: 

57 os.remove(file_path) 

58 except OSError: 

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

60 

61 def generate( 

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

63 ) -> str: 

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

65 # Load the model 

66 project_model = self._load_model(model_file) 

67 

68 # Create output directory 

69 os.makedirs(output_dir, exist_ok=True) 

70 

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

72 self._clear_output_folder(output_dir) 

73 

74 # Generate a PlantUML file for each C file 

75 generated_files = [] 

76 

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

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

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

80 # Generate PlantUML content 

81 # include_depth is handled by the transformer which processes 

82 # file-specific settings and stores them in include_relations 

83 puml_content = self.generate_diagram( 

84 file_model, project_model 

85 ) 

86 

87 # Create output filename 

88 basename = Path(file_model.name).stem 

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

90 

91 # Write the file 

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

93 f.write(puml_content) 

94 

95 generated_files.append(output_file) 

96 

97 return output_dir 

98 

99 def generate_diagram( 

100 self, file_model: FileModel, project_model: ProjectModel 

101 ) -> str: 

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

103 basename = Path(file_model.name).stem 

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

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

106 include_tree = self._build_include_tree( 

107 file_model, project_model 

108 ) 

109 # Precompute header-declared names for visibility 

110 header_function_decl_names: set[str] = set() 

111 header_global_names: set[str] = set() 

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

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

114 for f in fm.functions: 

115 if f.is_declaration: 

116 header_function_decl_names.add(f.name) 

117 for g in fm.globals: 

118 header_global_names.add(g.name) 

119 

120 uml_ids = self._generate_uml_ids(include_tree, project_model) 

121 

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

123 

124 self._generate_all_file_classes( 

125 lines, 

126 include_tree, 

127 uml_ids, 

128 project_model, 

129 header_function_decl_names, 

130 header_global_names, 

131 ) 

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

133 

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

135 return "\n".join(lines) 

136 

137 def _generate_all_file_classes( 

138 self, 

139 lines: List[str], 

140 include_tree: Dict[str, FileModel], 

141 uml_ids: Dict[str, str], 

142 project_model: ProjectModel, 

143 header_function_decl_names: set[str], 

144 header_global_names: set[str], 

145 ): 

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

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

148 funcptr_alias_names: set[str] = set() 

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

150 # Skip placeholder headers entirely for content processing 

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

152 continue 

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

154 if self._is_function_pointer_type(alias_data.original_type): 

155 funcptr_alias_names.add(alias_name) 

156 

157 self._generate_file_classes_by_extension( 

158 lines, 

159 include_tree, 

160 uml_ids, 

161 project_model, 

162 header_function_decl_names, 

163 header_global_names, 

164 ".c", 

165 self._generate_c_file_class, 

166 ) 

167 self._generate_file_classes_by_extension( 

168 lines, 

169 include_tree, 

170 uml_ids, 

171 project_model, 

172 header_function_decl_names, 

173 header_global_names, 

174 ".h", 

175 self._generate_header_class, 

176 ) 

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

178 

179 def _generate_file_classes_by_extension( 

180 self, 

181 lines: List[str], 

182 include_tree: Dict[str, FileModel], 

183 uml_ids: Dict[str, str], 

184 project_model: ProjectModel, 

185 header_function_decl_names: set[str], 

186 header_global_names: set[str], 

187 extension: str, 

188 generator_method, 

189 ): 

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

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

192 if file_path.endswith(extension): 

193 generator_method( 

194 lines, 

195 file_data, 

196 uml_ids, 

197 project_model, 

198 header_function_decl_names, 

199 header_global_names, 

200 ) 

201 

202 def _generate_typedef_classes_for_all_files( 

203 self, 

204 lines: List[str], 

205 include_tree: Dict[str, FileModel], 

206 uml_ids: Dict[str, str], 

207 funcptr_alias_names: set[str], 

208 ): 

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

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

211 suppressed_structs: set[str] = set() 

212 suppressed_unions: set[str] = set() 

213 

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

215 # Skip typedef class generation for placeholder headers 

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

217 continue 

218 self._generate_typedef_classes( 

219 lines, 

220 file_data, 

221 uml_ids, 

222 suppressed_structs, 

223 suppressed_unions, 

224 funcptr_alias_names, 

225 ) 

226 lines.append("") 

227 

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

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

230 return ProjectModel.load(model_file) 

231 

232 def _build_include_tree( 

233 self, root_file: FileModel, project_model: ProjectModel 

234 ) -> Dict[str, FileModel]: 

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

236 include_tree = {} 

237 

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

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

240 # First try exact match 

241 if file_name in project_model.files: 

242 return file_name 

243 

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

245 filename = Path(file_name).name 

246 if filename in project_model.files: 

247 return filename 

248 

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

250 return filename 

251 

252 # Start with the root file 

253 root_key = find_file_key(root_file.name) 

254 if root_key in project_model.files: 

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

256 

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

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

259 if root_file.include_relations: 

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

261 included_files = set() 

262 for relation in root_file.include_relations: 

263 included_files.add(relation.included_file) 

264 

265 # Add all files mentioned in include_relations 

266 for included_file in included_files: 

267 file_key = find_file_key(included_file) 

268 if file_key in project_model.files: 

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

270 else: 

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

272 visited = set() 

273 

274 def add_file_to_tree_once(file_name: str): 

275 if file_name in visited: 

276 return 

277 visited.add(file_name) 

278 file_key = find_file_key(file_name) 

279 if file_key in project_model.files: 

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

281 

282 # Start traversal from root (already added above) 

283 if root_key in project_model.files: 

284 root_file_model = project_model.files[root_key] 

285 for include in root_file_model.includes: 

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

287 add_file_to_tree_once(clean_include) 

288 

289 return include_tree 

290 

291 def _generate_uml_ids( 

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

293 ) -> Dict[str, str]: 

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

295 uml_ids = {} 

296 

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

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

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

300 

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

302 # C files: no prefix 

303 uml_ids[file_key] = basename 

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

305 # H files: HEADER_ prefix 

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

307 

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

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

310 continue 

311 

312 # Generate typedef UML IDs 

313 for typedef_name in file_model.structs: 

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

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

316 ) 

317 for typedef_name in file_model.enums: 

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

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

320 ) 

321 for typedef_name in file_model.aliases: 

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

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

324 ) 

325 for typedef_name in file_model.unions: 

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

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

328 ) 

329 

330 return uml_ids 

331 

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

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

334 import re 

335 

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

337 

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

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

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

341 

342 match = func_like_pattern.search(macro) 

343 if match: 

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

345 macro_name_with_params = match.group(1) 

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

347 

348 match = obj_like_pattern.search(macro) 

349 if match: 

350 if hide_values: 

351 # Only name 

352 macro_name = match.group(1) 

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

354 else: 

355 # Full definition 

356 clean_macro = macro.strip() 

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

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

359 

360 # Fallback 

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

362 

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

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

365 # In some parsed cases, array brackets may end up attached to the name. 

366 # Normalize by moving any trailing [dim] groups from the name into the type. 

367 t, n = normalize_type_and_name_for_arrays(global_var.type, global_var.name) 

368 return f"{INDENT}{prefix}{t} {n}" 

369 

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

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

372 params = self._format_function_parameters(func.parameters) 

373 param_str_full = ", ".join(params) 

374 

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

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

377 

378 # Build full signature 

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

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

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

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

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

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

385 if remaining <= 0: 

386 return head + "...)" 

387 # fill with params until remaining would be exceeded 

388 out = [] 

389 consumed = 0 

390 for i, p in enumerate(params): 

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

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

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

394 break 

395 out.append(add) 

396 consumed += len(add) 

397 param_str = "".join(out) 

398 return head + param_str + ")" 

399 return full_signature 

400 

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

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

403 params = [] 

404 for p in parameters: 

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

406 params.append("...") 

407 continue 

408 

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

410 type_str = p.type.strip() 

411 name_str = p.name.strip() 

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

413 try: 

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

415 name_inside = None 

416 if contains_func_ptr: 

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

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

419 if name_inside and name_str and name_str == name_inside: 

420 params.append(type_str) 

421 else: 

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

423 except Exception: 

424 # Fallback if any unexpected formatting occurs 

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

426 return params 

427 

428 # Truncation disabled to ensure complete signatures are rendered 

429 

430 def _add_macros_section( 

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

432 ): 

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

434 if file_model.macros: 

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

436 for macro in sorted(file_model.macros): 

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

438 

439 def _add_globals_section( 

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

441 ): 

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

443 if file_model.globals: 

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

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

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

447 

448 def _add_functions_section( 

449 self, 

450 lines: List[str], 

451 file_model: FileModel, 

452 prefix: str = "", 

453 is_declaration_only: bool = False, 

454 ): 

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

456 if not file_model.functions: 

457 return 

458 

459 # Collect matching function lines first to avoid emitting an empty header 

460 function_lines: List[str] = [] 

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

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

463 function_lines.append(self._format_function_signature(func, prefix)) 

464 elif not is_declaration_only and not func.is_declaration: 

465 function_lines.append(self._format_function_signature(func, prefix)) 

466 

467 if function_lines: 

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

469 lines.extend(function_lines) 

470 

471 def _generate_c_file_class( 

472 self, 

473 lines: List[str], 

474 file_model: FileModel, 

475 uml_ids: Dict[str, str], 

476 project_model: ProjectModel, 

477 header_function_decl_names: set[str], 

478 header_global_names: set[str], 

479 ): 

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

481 self._generate_file_class_unified( 

482 lines=lines, 

483 file_model=file_model, 

484 uml_ids=uml_ids, 

485 header_function_decl_names=header_function_decl_names, 

486 header_global_names=header_global_names, 

487 class_type="source", 

488 color=COLOR_SOURCE, 

489 macro_prefix="- ", 

490 is_declaration_only=False, 

491 use_dynamic_visibility=True, 

492 ) 

493 

494 def _generate_header_class( 

495 self, 

496 lines: List[str], 

497 file_model: FileModel, 

498 uml_ids: Dict[str, str], 

499 project_model: ProjectModel, 

500 header_function_decl_names: set[str], 

501 header_global_names: set[str], 

502 ): 

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

504 self._generate_file_class_unified( 

505 lines=lines, 

506 file_model=file_model, 

507 uml_ids=uml_ids, 

508 header_function_decl_names=header_function_decl_names, 

509 header_global_names=header_global_names, 

510 class_type="header", 

511 color=COLOR_HEADER, 

512 macro_prefix="+ ", 

513 is_declaration_only=True, 

514 use_dynamic_visibility=False, 

515 ) 

516 

517 def _generate_file_class_unified( 

518 self, 

519 lines: List[str], 

520 file_model: FileModel, 

521 uml_ids: Dict[str, str], 

522 header_function_decl_names: set[str], 

523 header_global_names: set[str], 

524 class_type: str, 

525 color: str, 

526 macro_prefix: str, 

527 is_declaration_only: bool, 

528 use_dynamic_visibility: bool, 

529 ): 

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

531 basename = Path(file_model.name).stem 

532 filename = Path(file_model.name).name 

533 uml_id = uml_ids.get(filename) 

534 

535 if not uml_id: 

536 return 

537 

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

539 lines.append("{") 

540 

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

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

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

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

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

546 lines.pop() 

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

548 lines.append("") 

549 return 

550 lines.append("}") 

551 lines.append("") 

552 return 

553 

554 self._add_macros_section(lines, file_model, macro_prefix) 

555 if use_dynamic_visibility: 

556 # Use precomputed header visibility sets 

557 self._add_globals_section_with_visibility( 

558 lines, file_model, header_global_names 

559 ) 

560 self._add_functions_section_with_visibility( 

561 lines, file_model, header_function_decl_names, is_declaration_only 

562 ) 

563 else: 

564 # Static '+' visibility for headers 

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

566 self._add_functions_section( 

567 lines, file_model, "+ ", is_declaration_only 

568 ) 

569 

570 lines.append("}") 

571 lines.append("") 

572 

573 def _add_globals_section_with_visibility( 

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

575 ): 

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

577 if file_model.globals: 

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

579 

580 # Separate globals into public and private groups 

581 public_globals = [] 

582 private_globals = [] 

583 

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

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

586 formatted_global = self._format_global_variable(global_var, prefix) 

587 

588 if prefix == "+ ": 

589 public_globals.append(formatted_global) 

590 else: 

591 private_globals.append(formatted_global) 

592 

593 # Add public globals first 

594 for global_line in public_globals: 

595 lines.append(global_line) 

596 

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

598 if public_globals and private_globals: 

599 lines.append("") 

600 

601 # Add private globals 

602 for global_line in private_globals: 

603 lines.append(global_line) 

604 

605 def _add_functions_section_with_visibility( 

606 self, 

607 lines: List[str], 

608 file_model: FileModel, 

609 header_function_decl_names: set[str], 

610 is_declaration_only: bool = False, 

611 ): 

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

613 if not file_model.functions: 

614 return 

615 

616 # Separate functions into public and private groups, collecting first 

617 public_functions: List[str] = [] 

618 private_functions: List[str] = [] 

619 

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

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

622 prefix = "+ " 

623 formatted_function = self._format_function_signature(func, prefix) 

624 public_functions.append(formatted_function) 

625 elif not is_declaration_only and not func.is_declaration: 

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

627 formatted_function = self._format_function_signature(func, prefix) 

628 

629 if prefix == "+ ": 

630 public_functions.append(formatted_function) 

631 else: 

632 private_functions.append(formatted_function) 

633 

634 if public_functions or private_functions: 

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

636 # Add public functions first 

637 for function_line in public_functions: 

638 lines.append(function_line) 

639 

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

641 if public_functions and private_functions: 

642 lines.append("") 

643 

644 # Add private functions 

645 for function_line in private_functions: 

646 lines.append(function_line) 

647 

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

649 

650 def _generate_typedef_classes( 

651 self, 

652 lines: List[str], 

653 file_data: FileModel, 

654 uml_ids: Dict[str, str], 

655 suppressed_structs: set[str], 

656 suppressed_unions: set[str], 

657 funcptr_alias_names: set[str], 

658 ): 

659 """Generate classes for typedefs""" 

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

661 self._generate_enum_classes(lines, file_data, uml_ids) 

662 self._generate_alias_classes(lines, file_data, uml_ids) 

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

664 

665 def _generate_struct_classes( 

666 self, 

667 lines: List[str], 

668 file_model: FileModel, 

669 uml_ids: Dict[str, str], 

670 suppressed_structs: set[str], 

671 funcptr_alias_names: set[str], 

672 ): 

673 """Generate classes for struct typedefs""" 

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

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

676 if struct_name in suppressed_structs: 

677 continue 

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

679 if struct_name in funcptr_alias_names: 

680 continue 

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

682 if uml_id: 

683 lines.append( 

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

685 ) 

686 lines.append("{") 

687 for field in struct_data.fields: 

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

689 lines.append("}") 

690 lines.append("") 

691 

692 def _generate_enum_classes( 

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

694 ): 

695 """Generate classes for enum typedefs""" 

696 # Preserve original declaration order by iterating without sorting 

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

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

699 if uml_id: 

700 lines.append( 

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

702 ) 

703 lines.append("{") 

704 # Preserve source order: do not sort enum values 

705 for value in enum_data.values: 

706 if value.value: 

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

708 else: 

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

710 lines.append("}") 

711 lines.append("") 

712 

713 def _generate_alias_classes( 

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

715 ): 

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

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

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

719 if uml_id: 

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

721 stereotype = self._get_alias_stereotype(alias_data) 

722 lines.append( 

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

724 ) 

725 lines.append("{") 

726 self._process_alias_content(lines, alias_data) 

727 lines.append("}") 

728 lines.append("") 

729 

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

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

732 original_type = alias_data.original_type.strip() 

733 if self._is_function_pointer_type(original_type): 

734 return "<<function pointer>>" 

735 return "<<typedef>>" 

736 

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

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

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

740 """ 

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

742 if pattern.search(type_str): 

743 return True 

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

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

746 return bool(pattern_nested.search(type_str)) 

747 

748 def _generate_union_classes( 

749 self, 

750 lines: List[str], 

751 file_model: FileModel, 

752 uml_ids: Dict[str, str], 

753 suppressed_unions: set[str], 

754 ): 

755 """Generate classes for union typedefs""" 

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

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

758 if uml_id: 

759 lines.append( 

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

761 ) 

762 lines.append("{") 

763 for field in union_data.fields: 

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

765 lines.append("}") 

766 lines.append("") 

767 

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

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

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

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

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

773 # Normalize multiple spaces to single spaces 

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

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

776 

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

778 

779 def _generate_field_with_nested_structs( 

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

781 ): 

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

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

784 

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

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

787 # Parse the nested struct content and flatten it 

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

789 

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

791 # Format as: + struct { field_type field_name } 

792 nested_content = [] 

793 for part in struct_parts[1:]: 

794 part = part.strip() 

795 if part and part != "}": 

796 nested_content.append(part) 

797 

798 if nested_content: 

799 # Create a flattened representation 

800 content_str = "; ".join(nested_content) 

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

802 else: 

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

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

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

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

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

808 else: 

809 # Handle regular multi-line field types 

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

811 for i, line in enumerate(field_lines): 

812 if i == 0: 

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

814 else: 

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

816 

817 def _generate_relationships( 

818 self, 

819 lines: List[str], 

820 include_tree: Dict[str, FileModel], 

821 uml_ids: Dict[str, str], 

822 project_model: ProjectModel, 

823 ): 

824 """Generate relationships between elements""" 

825 self._generate_include_relationships(lines, include_tree, uml_ids) 

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

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

828 self._generate_anonymous_relationships(lines, project_model, uml_ids) 

829 

830 def _generate_include_relationships( 

831 self, 

832 lines: List[str], 

833 include_tree: Dict[str, FileModel], 

834 uml_ids: Dict[str, str], 

835 ): 

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

837 lines.append("' Include relationships") 

838 

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

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

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

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

843 

844 file_uml_id = self._get_file_uml_id(file_name, uml_ids) 

845 if not file_uml_id: 

846 continue 

847 

848 # Prefer include_relations if available (from transformation) 

849 if file_model.include_relations: 

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

851 for relation in sorted( 

852 file_model.include_relations, 

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

854 ): 

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

856 included_uml_id = self._get_file_uml_id( 

857 relation.included_file, uml_ids 

858 ) 

859 

860 if source_uml_id and included_uml_id: 

861 lines.append( 

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

863 ) 

864 else: 

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

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

867 for include in sorted(file_model.includes): 

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

869 include_filename = Path(clean_include).name 

870 include_uml_id = uml_ids.get(include_filename) 

871 if include_uml_id: 

872 lines.append( 

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

874 ) 

875 

876 lines.append("") 

877 

878 def _generate_declaration_relationships( 

879 self, 

880 lines: List[str], 

881 include_tree: Dict[str, FileModel], 

882 uml_ids: Dict[str, str], 

883 project_model: ProjectModel, 

884 ): 

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

886 lines.append("' Declaration relationships") 

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

888 

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

890 # Suppress declaration relationships from placeholder headers 

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

892 continue 

893 file_uml_id = self._get_file_uml_id(file_name, uml_ids) 

894 if file_uml_id: 

895 for collection_name in typedef_collections_names: 

896 typedef_collection = getattr(file_model, collection_name) 

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

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

899 if self._is_anonymous_structure_in_project(typedef_name, project_model): 

900 continue 

901 

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

903 if typedef_uml_id: 

904 lines.append( 

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

906 ) 

907 lines.append("") 

908 

909 def _get_file_uml_id( 

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

911 ) -> Optional[str]: 

912 """Get UML ID for a file""" 

913 file_key = Path(file_name).name 

914 return uml_ids.get(file_key) 

915 

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

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

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

919 if file_model.anonymous_relationships: 

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

921 if typedef_name in children: 

922 return True 

923 return False 

924 

925 def _generate_uses_relationships( 

926 self, 

927 lines: List[str], 

928 include_tree: Dict[str, FileModel], 

929 uml_ids: Dict[str, str], 

930 project_model: ProjectModel, 

931 ): 

932 """Generate uses relationships between typedefs""" 

933 lines.append("' Uses relationships") 

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

935 # Struct uses relationships 

936 self._add_typedef_uses_relationships( 

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

938 ) 

939 # Alias uses relationships 

940 self._add_typedef_uses_relationships( 

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

942 ) 

943 # Union uses relationships 

944 self._add_typedef_uses_relationships( 

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

946 ) 

947 

948 def _add_typedef_uses_relationships( 

949 self, 

950 lines: List[str], 

951 typedef_collection: Dict, 

952 uml_ids: Dict[str, str], 

953 typedef_type: str, 

954 project_model: ProjectModel, 

955 ): 

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

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

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

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

960 continue 

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

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

963 for used_type in sorted(typedef_data.uses): 

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

965 if used_uml_id: 

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

967 is_parent_anonymous = typedef_name.startswith("__anonymous_") 

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

969 continue 

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

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

972 continue 

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

974 

975 def _generate_anonymous_relationships( 

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

977 ): 

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

979 # First, check if there are any anonymous relationships 

980 has_relationships = False 

981 relationships_to_generate = [] 

982 

983 # Process all files in the project model 

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

985 if not file_model.anonymous_relationships: 

986 continue 

987 

988 # Generate relationships for each parent-child pair 

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

990 parent_id = self._get_anonymous_uml_id(parent_name, uml_ids) 

991 

992 for child_name in children: 

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

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

995 continue 

996 child_id = self._get_anonymous_uml_id(child_name, uml_ids) 

997 

998 if parent_id and child_id: 

999 has_relationships = True 

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

1001 

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

1003 if has_relationships: 

1004 lines.append("") 

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

1006 for relationship in relationships_to_generate: 

1007 lines.append(relationship) 

1008 

1009 

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

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

1012 # Try direct key 

1013 if entity_name in uml_ids: 

1014 return uml_ids[entity_name] 

1015 

1016 # Try exact typedef key 

1017 typedef_exact = f"typedef_{entity_name}" 

1018 if typedef_exact in uml_ids: 

1019 return uml_ids[typedef_exact] 

1020 

1021 # Case-insensitive match for typedef keys 

1022 entity_lower = entity_name.lower() 

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

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

1025 return value 

1026 

1027 return None 

1028 

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

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

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

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

1033 if not rels: 

1034 continue 

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

1036 return True 

1037 return False