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
« 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"""
7import glob
8import os
9import re
10from pathlib import Path
11from typing import Dict, List, Optional
13from ..models import Field, FileModel, Function, ProjectModel
15# PlantUML generation constants
16MAX_LINE_LENGTH = 120
17TRUNCATION_LENGTH = 100
18INDENT = " "
20# PlantUML styling colors
21COLOR_SOURCE = "#LightBlue"
22COLOR_HEADER = "#LightGreen"
23COLOR_TYPEDEF = "#LightYellow"
25# UML prefixes
26PREFIX_HEADER = "HEADER_"
27PREFIX_TYPEDEF = "TYPEDEF_"
30class Generator:
31 """Generator that creates proper PlantUML files.
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 """
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
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
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
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)
67 # Create output directory
68 os.makedirs(output_dir, exist_ok=True)
70 # Clear existing .puml and .png files from output directory
71 self._clear_output_folder(output_dir)
73 # Generate a PlantUML file for each C file
74 generated_files = []
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 )
86 # Create output filename
87 basename = Path(file_model.name).stem
88 output_file = os.path.join(output_dir, f"{basename}.puml")
90 # Write the file
91 with open(output_file, "w", encoding="utf-8") as f:
92 f.write(puml_content)
94 generated_files.append(output_file)
96 return output_dir
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)
119 uml_ids = self._generate_uml_ids(include_tree, project_model)
121 lines = [f"@startuml {basename}", ""]
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)
133 lines.extend(["", "@enduml"])
134 return "\n".join(lines)
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)
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)
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 )
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()
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("")
227 def _load_model(self, model_file: str) -> ProjectModel:
228 """Load the project model from JSON file"""
229 return ProjectModel.load(model_file)
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 = {}
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
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
248 # If not found, return the filename (will be handled gracefully)
249 return filename
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]
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)
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()
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]
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)
288 return include_tree
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 = {}
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
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}"
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
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 )
329 return uml_ids
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
335 hide_values = getattr(self, "hide_macro_values", False)
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_]*)")
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}"
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}"
359 # Fallback
360 return f"{INDENT}{prefix}{macro}"
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}"
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)
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()
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
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
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
424 # Truncation disabled to ensure complete signatures are rendered
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))
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))
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))
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 )
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 )
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)
524 if not uml_id:
525 return
527 lines.append(f'class "{basename}" as {uml_id} <<{class_type}>> {color}')
528 lines.append("{")
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
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 )
559 lines.append("}")
560 lines.append("")
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 --")
569 # Separate globals into public and private groups
570 public_globals = []
571 private_globals = []
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)
577 if prefix == "+ ":
578 public_globals.append(formatted_global)
579 else:
580 private_globals.append(formatted_global)
582 # Add public globals first
583 for global_line in public_globals:
584 lines.append(global_line)
586 # Add empty line between public and private if both exist
587 if public_globals and private_globals:
588 lines.append("")
590 # Add private globals
591 for global_line in private_globals:
592 lines.append(global_line)
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 --")
605 # Separate functions into public and private groups
606 public_functions = []
607 private_functions = []
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)
618 if prefix == "+ ":
619 public_functions.append(formatted_function)
620 else:
621 private_functions.append(formatted_function)
623 # Add public functions first
624 for function_line in public_functions:
625 lines.append(function_line)
627 # Add empty line between public and private if both exist
628 if public_functions and private_functions:
629 lines.append("")
631 # Add private functions
632 for function_line in private_functions:
633 lines.append(function_line)
635 # Removed O(N^2) header scans in favor of precomputed header visibility sets
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)
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("")
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("")
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("")
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>>"
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))
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("")
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}")
764 # Removed dead/unused alias handling helpers (_is_truncated_typedef, _handle_truncated_typedef, _handle_normal_alias)
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}"
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")
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)
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}")
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)
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")
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
831 file_uml_id = self._get_file_uml_id(file_name, uml_ids)
832 if not file_uml_id:
833 continue
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 )
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 )
863 lines.append("")
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"]
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
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("")
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)
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
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 )
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>>")
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 = []
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
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)
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)
985 if parent_id and child_id:
986 has_relationships = True
987 relationships_to_generate.append(f"{parent_id} *-- {child_id} : <<contains>>")
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)
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]
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]
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
1014 return None
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