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
« 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"""
7import glob
8import os
9import re
10from pathlib import Path
11from typing import Dict, List, Optional
13from ..models import Field, FileModel, Function, ProjectModel
14from .parse_utils import normalize_type_and_name_for_arrays
16# PlantUML generation constants
17MAX_LINE_LENGTH = 120
18TRUNCATION_LENGTH = 100
19INDENT = " "
21# PlantUML styling colors
22COLOR_SOURCE = "#LightBlue"
23COLOR_HEADER = "#LightGreen"
24COLOR_TYPEDEF = "#LightYellow"
26# UML prefixes
27PREFIX_HEADER = "HEADER_"
28PREFIX_TYPEDEF = "TYPEDEF_"
31class Generator:
32 """Generator that creates proper PlantUML files.
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 """
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
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
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
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)
68 # Create output directory
69 os.makedirs(output_dir, exist_ok=True)
71 # Clear existing .puml and .png files from output directory
72 self._clear_output_folder(output_dir)
74 # Generate a PlantUML file for each C file
75 generated_files = []
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 )
87 # Create output filename
88 basename = Path(file_model.name).stem
89 output_file = os.path.join(output_dir, f"{basename}.puml")
91 # Write the file
92 with open(output_file, "w", encoding="utf-8") as f:
93 f.write(puml_content)
95 generated_files.append(output_file)
97 return output_dir
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)
120 uml_ids = self._generate_uml_ids(include_tree, project_model)
122 lines = [f"@startuml {basename}", ""]
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)
134 lines.extend(["", "@enduml"])
135 return "\n".join(lines)
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)
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)
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 )
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()
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("")
228 def _load_model(self, model_file: str) -> ProjectModel:
229 """Load the project model from JSON file"""
230 return ProjectModel.load(model_file)
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 = {}
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
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
249 # If not found, return the filename (will be handled gracefully)
250 return filename
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]
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)
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()
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]
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)
289 return include_tree
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 = {}
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
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}"
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
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 )
330 return uml_ids
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
336 hide_values = getattr(self, "hide_macro_values", False)
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_]*)")
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}"
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}"
360 # Fallback
361 return f"{INDENT}{prefix}{macro}"
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}"
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)
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()
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
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
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
428 # Truncation disabled to ensure complete signatures are rendered
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))
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))
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
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))
467 if function_lines:
468 lines.append(f"{INDENT}-- Functions --")
469 lines.extend(function_lines)
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 )
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 )
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)
535 if not uml_id:
536 return
538 lines.append(f'class "{basename}" as {uml_id} <<{class_type}>> {color}')
539 lines.append("{")
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
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 )
570 lines.append("}")
571 lines.append("")
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 --")
580 # Separate globals into public and private groups
581 public_globals = []
582 private_globals = []
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)
588 if prefix == "+ ":
589 public_globals.append(formatted_global)
590 else:
591 private_globals.append(formatted_global)
593 # Add public globals first
594 for global_line in public_globals:
595 lines.append(global_line)
597 # Add empty line between public and private if both exist
598 if public_globals and private_globals:
599 lines.append("")
601 # Add private globals
602 for global_line in private_globals:
603 lines.append(global_line)
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
616 # Separate functions into public and private groups, collecting first
617 public_functions: List[str] = []
618 private_functions: List[str] = []
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)
629 if prefix == "+ ":
630 public_functions.append(formatted_function)
631 else:
632 private_functions.append(formatted_function)
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)
640 # Add empty line between public and private if both exist
641 if public_functions and private_functions:
642 lines.append("")
644 # Add private functions
645 for function_line in private_functions:
646 lines.append(function_line)
648 # Removed O(N^2) header scans in favor of precomputed header visibility sets
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)
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("")
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("")
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("")
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>>"
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))
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("")
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}")
777 # Removed dead/unused alias handling helpers (_is_truncated_typedef, _handle_truncated_typedef, _handle_normal_alias)
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}"
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")
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)
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}")
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)
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")
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
844 file_uml_id = self._get_file_uml_id(file_name, uml_ids)
845 if not file_uml_id:
846 continue
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 )
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 )
876 lines.append("")
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"]
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
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("")
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)
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
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 )
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>>")
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 = []
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
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)
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)
998 if parent_id and child_id:
999 has_relationships = True
1000 relationships_to_generate.append(f"{parent_id} *-- {child_id} : <<contains>>")
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)
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]
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]
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
1027 return None
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