# SPDX-License-Identifier: GPL-2.0 # # Copyright 2025 Canonical Ltd # """Output formatting and display functions for srcman. This module provides functions for displaying analysis results in various formats: - Statistics views (file-level and line-level) - Directory breakdowns (top-level and subdirectories) - Per-file summaries - Detailed line-by-line views - File listings (used/unused) - File copying operations """ import os import shutil import sys from collections import defaultdict # Import from tools directory sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from u_boot_pylib import terminal, tout # pylint: disable=wrong-import-position class DirStats: # pylint: disable=too-few-public-methods """Statistics for a directory. Attributes: total: Total number of files in directory used: Number of files used (compiled) unused: Number of files not used lines_total: Total lines of code in directory lines_used: Number of active lines (after preprocessing) files: List of file info dicts (for --show-files) """ def __init__(self): self.total = 0 self.used = 0 self.unused = 0 self.lines_total = 0 self.lines_used = 0 self.files = [] def count_lines(file_path): """Count lines in a file""" try: with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: return sum(1 for _ in f) except IOError: return 0 def klocs(lines): """Format line count in thousands, rounded to 1 decimal place. Args: lines (int): Line count (e.g., 3500) Returns: Formatted string in thousands (e.g., '3.5') """ kloc = round(lines / 1000, 1) return f'{kloc:.1f}' def percent(numerator, denominator): """Calculate percentage, handling division by zero. Args: numerator (int/float): The numerator denominator (int/float): The denominator Returns: float: Percentage (0-100), or 0 if denominator is 0 """ return 100 * numerator / denominator if denominator else 0 def print_heading(text, width=70, char='='): """Print a heading with separator lines. Args: text (str): Heading text to display (empty for separator only) width (int): Width of the separator line char (str): Character to use for separator """ print(char * width) if text: print(text) print(char * width) def show_file_detail(detail_file, file_results, srcdir): """Show detailed line-by-line analysis for a specific file. Args: detail_file (str): Path to the file to show details for (relative or absolute) file_results (dict): Dictionary mapping file paths to analysis results srcdir (str): Root directory of the source tree Returns: True on success, False on error """ detail_path = os.path.realpath(detail_file) if detail_path not in file_results: # Try relative to source root detail_path = os.path.realpath(os.path.join(srcdir, detail_file)) if detail_path in file_results: result = file_results[detail_path] rel_path = os.path.relpath(detail_path, srcdir) print_heading(f'DETAIL FOR: {rel_path}', width=70) print(f'Total lines: {result.total_lines:6}') pct_active = percent(result.active_lines, result.total_lines) pct_inactive = percent(result.inactive_lines, result.total_lines) print(f'Active lines: {result.active_lines:6} ({pct_active:.1f}%)') print(f'Inactive lines: {result.inactive_lines:6} ' + f'({pct_inactive:.1f}%)') print() # Show the file with status annotations with open(detail_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.readlines() col = terminal.Color() for line_num, line in enumerate(lines, 1): status = result.line_status.get(line_num, 'unknown') marker = '-' if status == 'inactive' else ' ' prefix = f'{marker} {line_num:4} | ' code = line.rstrip() if status == 'active': # Normal color for active code print(prefix + code) else: # Non-bright cyan for inactive code print(prefix + col.build(terminal.Color.CYAN, code, bright=False)) return True # File not found - caller handles errors return False def show_file_summary(file_results, srcdir): """Show per-file summary of line analysis. Args: file_results (dict): Dictionary mapping file paths to analysis results srcdir (str): Root directory of the source tree Returns: bool: True on success """ print_heading('PER-FILE SUMMARY', width=90) print(f"{'File':<50} {'Total':>8} {'Active':>8} " f"{'Inactive':>8} {'%Active':>8}") print('-' * 90) for source_file in sorted(file_results.keys()): result = file_results[source_file] rel_path = os.path.relpath(source_file, srcdir) if len(rel_path) > 47: rel_path = '...' + rel_path[-44:] pct_active = percent(result.active_lines, result.total_lines) print(f'{rel_path:<50} {result.total_lines:>8} ' f'{result.active_lines:>8} {result.inactive_lines:>8} ' f'{pct_active:>7.1f}%') return True def list_unused_files(skipped_sources, srcdir): """List unused source files. Args: skipped_sources (set of str): Set of unused source file paths (relative to srcdir) srcdir (str): Root directory of the source tree Returns: bool: True on success """ print(f'Unused source files ({len(skipped_sources)}):') for source_file in sorted(skipped_sources): try: rel_path = os.path.relpath(source_file, srcdir) except ValueError: rel_path = source_file print(f' {rel_path}') return True def list_used_files(used_sources, srcdir): """List used source files. Args: used_sources (set of str): Set of used source file paths (relative to srcdir) srcdir (str): Root directory of the source tree Returns: bool: True on success """ print(f'Used source files ({len(used_sources)}):') for source_file in sorted(used_sources): try: rel_path = os.path.relpath(source_file, srcdir) except ValueError: rel_path = source_file print(f' {rel_path}') return True def copy_used_files(used_sources, srcdir, dest_dir): """Copy used source files to a destination directory, preserving structure. Args: used_sources (set): Set of used source file paths (relative to srcdir) srcdir (str): Root directory of the source tree dest_dir (str): Destination directory for the copy Returns: True on success, False if errors occurred """ if os.path.exists(dest_dir): tout.error(f'Destination directory already exists: {dest_dir}') return False tout.progress(f'Copying {len(used_sources)} used source files to ' + f'{dest_dir}') copied_count = 0 error_count = 0 for source_file in sorted(used_sources): src_path = os.path.join(srcdir, source_file) dest_path = os.path.join(dest_dir, source_file) try: # Create parent directory if needed dest_parent = os.path.dirname(dest_path) os.makedirs(dest_parent, exist_ok=True) # Copy the file shutil.copy2(src_path, dest_path) copied_count += 1 except IOError as e: error_count += 1 tout.error(f'Error copying {source_file}: {e}') tout.progress(f'Copied {copied_count} files to {dest_dir}') if error_count: tout.error(f'Failed to copy {error_count} files') return False return True def collect_dir_stats(all_sources, used_sources, file_results, srcdir, by_subdirs, show_files): """Collect statistics organized by directory. Args: all_sources (set): Set of all source file paths used_sources (set): Set of used source file paths file_results (dict): Optional dict mapping file paths to line analysis results (or None) srcdir (str): Root directory of the source tree by_subdirs (bool): If True, use full subdirectory paths; otherwise top-level only show_files (bool): If True, collect individual file info within each directory Returns: dict: Directory statistics keyed by directory path """ dir_stats = defaultdict(DirStats) for source_file in all_sources: rel_path = os.path.relpath(source_file, srcdir) if by_subdirs: # Use the full directory path (not including the filename) dir_path = os.path.dirname(rel_path) if not dir_path: dir_path = '.' else: # Use only the top-level directory dir_path = (rel_path.split(os.sep)[0] if os.sep in rel_path else rel_path) line_count = count_lines(source_file) dir_stats[dir_path].total += 1 dir_stats[dir_path].lines_total += line_count if source_file in used_sources: dir_stats[dir_path].used += 1 # Use active line count if line-level analysis was performed # Normalize path to match file_results keys (absolute paths) abs_source = os.path.realpath(source_file) # Try to find the file in file_results result = None if file_results: if abs_source in file_results: result = file_results[abs_source] elif source_file in file_results: result = file_results[source_file] if result: active_lines = result.active_lines inactive_lines = result.inactive_lines dir_stats[dir_path].lines_used += active_lines # Store file info for --show-files (exclude .h files) if show_files and not rel_path.endswith('.h'): dir_stats[dir_path].files.append({ 'path': rel_path, 'total': line_count, 'active': active_lines, 'inactive': inactive_lines }) else: # File not found in results - count all lines tout.debug(f'File not in results (using full count): ' f'{rel_path}') dir_stats[dir_path].lines_used += line_count if show_files and not rel_path.endswith('.h'): dir_stats[dir_path].files.append({ 'path': rel_path, 'total': line_count, 'active': line_count, 'inactive': 0 }) else: dir_stats[dir_path].unused += 1 return dir_stats def print_dir_stats(dir_stats, file_results, by_subdirs, show_files, show_empty, use_kloc=False): """Print directory statistics table. Args: dir_stats (dict): Directory statistics keyed by directory path file_results (dict): Optional dict mapping file paths to line analysis results (or None) by_subdirs (bool): If True, show full subdirectory breakdown; otherwise top-level only show_files (bool): If True, show individual files within directories show_empty (bool): If True, show directories with 0 lines used use_kloc (bool): If True, show line counts in kLOC; otherwise show lines """ # Sort alphabetically by directory name sorted_dirs = sorted(dir_stats.items(), key=lambda x: x[0]) for dir_path, stats in sorted_dirs: # Skip subdirectories with 0 lines used unless --show-zero-lines is set if by_subdirs and not show_empty and stats.lines_used == 0: continue pct_used = percent(stats.used, stats.total) pct_code = percent(stats.lines_used, stats.lines_total) # Truncate long paths display_path = dir_path if len(display_path) > 37: display_path = '...' + display_path[-34:] # Format line counts based on use_kloc flag if use_kloc: lines_total_str = f'{klocs(stats.lines_total):>8}' lines_used_str = f'{klocs(stats.lines_used):>7}' else: lines_total_str = f'{stats.lines_total:>8}' lines_used_str = f'{stats.lines_used:>7}' print(f'{display_path:<40} {stats.total:>7} {stats.used:>7} ' f'{pct_used:>6.0f} {pct_code:>6.0f} ' f'{lines_total_str} {lines_used_str}') # Show individual files if requested if show_files and stats.files: # Sort files alphabetically by filename sorted_files = sorted(stats.files, key=lambda x: os.path.basename(x['path'])) for info in sorted_files: # Skip files with 0 active lines unless show_empty is set if not show_empty and info['active'] == 0: continue filename = os.path.basename(info['path']) if len(filename) > 35: filename = filename[:32] + '...' if file_results: # Show line-level details pct_active = percent(info['active'], info['total']) # Format line counts based on use_kloc flag if use_kloc: total_str = f'{klocs(info["total"]):>8}' active_str = f'{klocs(info["active"]):>7}' else: total_str = f'{info["total"]:>8}' active_str = f'{info["active"]:>7}' # Align with directory format: skip Files/Used columns, # show %code, then lines column, active in Used column print(f" {filename:<38} {'':>7} {'':>7} {'':>6} " f"{pct_active:>6.0f} {total_str} {active_str}") else: # Show file-level only print(f" {filename:<38} {info['total']:>7} lines") # Add blank line after file list print() def show_dir_breakdown(all_sources, used_sources, file_results, srcdir, by_subdirs, show_files, show_empty, use_kloc=False): """Show breakdown by directory (top-level or subdirectories). Args: all_sources (set): Set of all source file paths used_sources (set): Set of used source file paths file_results (dict): Optional dict mapping file paths to line analysis results (or None) srcdir (str): Root directory of the source tree by_subdirs (bool): If True, show full subdirectory breakdown; otherwise top-level only show_files (bool): If True, show individual files within each directory show_empty (bool): If True, show directories with 0 lines used use_kloc (bool): If True, show line counts in kLOC; otherwise show lines Returns: bool: True on success """ # Width of the main table (Directory + Total + Used columns) table_width = 87 print_heading('BREAKDOWN BY TOP-LEVEL DIRECTORY' if by_subdirs else '', width=table_width) # Column header changes based on use_kloc lines_header = 'kLOC' if use_kloc else 'Lines' print(f"{'Directory':<40} {'Files':>7} {'Used':>7} {'%Used':>6} " + f"{'%Code':>6} {lines_header:>8} {'Used':>7}") print('-' * table_width) # Collect directory statistics dir_stats = collect_dir_stats(all_sources, used_sources, file_results, srcdir, by_subdirs, show_files) # Print directory statistics print_dir_stats(dir_stats, file_results, by_subdirs, show_files, show_empty, use_kloc) print('-' * table_width) total_lines_all = sum(count_lines(f) for f in all_sources) # Calculate used lines: if we have file_results, use active_lines from there # Otherwise, count all lines in used files if file_results: total_lines_used = sum(r.active_lines for r in file_results.values()) else: total_lines_used = sum(count_lines(f) for f in used_sources) pct_files = percent(len(used_sources), len(all_sources)) pct_code = percent(total_lines_used, total_lines_all) # Format totals based on use_kloc flag if use_kloc: total_str = f'{klocs(total_lines_all):>8}' used_str = f'{klocs(total_lines_used):>7}' else: total_str = f'{total_lines_all:>8}' used_str = f'{total_lines_used:>7}' print(f"{'TOTAL':<40} {len(all_sources):>7} {len(used_sources):>7} " f"{pct_files:>6.0f} {pct_code:>6.0f} " f"{total_str} {used_str}") print_heading('', width=table_width) print() return True def generate_html_breakdown(all_sources, used_sources, file_results, srcdir, by_subdirs, show_files, show_empty, use_kloc, html_file, board=None, analysis_method=None): """Generate HTML output with colored directory breakdown. Args: all_sources (set): Set of all source file paths used_sources (set): Set of used source file paths file_results (dict): Optional dict mapping file paths to line analysis results (or None) srcdir (str): Root directory of the source tree by_subdirs (bool): If True, show full subdirectory breakdown show_files (bool): If True, show individual files within directories show_empty (bool): If True, show directories with 0 lines used use_kloc (bool): If True, show line counts in kLOC html_file (str): Path to output HTML file board (str): Board name (optional) analysis_method (str): Analysis method used ('unifdef', 'lsp', or 'dwarf') Returns: bool: True on success """ # Get git information import subprocess import datetime try: # Get short commit hash git_hash = subprocess.check_output( ['git', 'rev-parse', '--short', 'HEAD'], cwd=srcdir, stderr=subprocess.DEVNULL, text=True ).strip() except (subprocess.CalledProcessError, FileNotFoundError): git_hash = 'unknown' try: # Get commit date git_date = subprocess.check_output( ['git', 'log', '-1', '--format=%cd', '--date=short'], cwd=srcdir, stderr=subprocess.DEVNULL, text=True ).strip() except (subprocess.CalledProcessError, FileNotFoundError): git_date = datetime.date.today().strftime('%Y-%m-%d') # Collect directory statistics dir_stats = collect_dir_stats(all_sources, used_sources, file_results, srcdir, by_subdirs, show_files) # Calculate totals total_lines_all = sum(count_lines(f) for f in all_sources) if file_results: total_lines_used = sum(r.active_lines for r in file_results.values()) else: total_lines_used = sum(count_lines(f) for f in used_sources) # Generate HTML lines_header = 'kLOC' if use_kloc else 'Lines' board_name = board if board else 'unknown' html = f''' Code Analysis Report

Code Analysis Report

Board: {board_name} Commit: {git_hash} Date: {git_date} Analysis: {analysis_method or 'unknown'}
Total Files: {len(all_sources)}
Used Files: {len(used_sources)}
Usage: {percent(len(used_sources), len(all_sources)):.0f}%
Total Lines: {total_lines_all:,}
Used Lines: {total_lines_used:,}
Code Usage: {percent(total_lines_used, total_lines_all):.0f}%
''' # Build hierarchical structure - only show top-level directories initially # Group all directories by their top-level component top_level_groups = {} sorted_dirs = sorted(dir_stats.items(), key=lambda x: x[0]) for dir_path, stats in sorted_dirs: # Skip directories with 0 lines used unless show_empty is set if not show_empty and stats.lines_used == 0: continue # Get top-level directory name parts = dir_path.split('/') top_level = parts[0] if top_level not in top_level_groups: top_level_groups[top_level] = [] top_level_groups[top_level].append((dir_path, stats)) # Generate HTML for hierarchical structure dir_counter = [0] # Use list to allow modification in nested function def render_directory(dir_path, stats, parent_id=None, indent_level=0): """Render a directory row and its children.""" nonlocal html dir_id = f'dir-{dir_counter[0]}' dir_counter[0] += 1 pct_used = percent(stats.used, stats.total) pct_code = percent(stats.lines_used, stats.lines_total) pct_code_class = 'pct-high' if pct_code >= 75 else ('pct-med' if pct_code >= 50 else 'pct-low') if use_kloc: lines_total_str = f'{klocs(stats.lines_total)}' lines_used_str = f'{klocs(stats.lines_used)}' else: lines_total_str = f'{stats.lines_total:,}' lines_used_str = f'{stats.lines_used:,}' # Add indentation to directory name indent = '  ' * indent_level display_name = f'{indent}{dir_path}' if indent_level > 0 else dir_path # Start collapsed collapsed_class = ' collapsed' hidden_class = ' hidden' if parent_id else '' parent_attr = f' data-parent-dir="{parent_id}"' if parent_id else '' html += f''' ''' return dir_id # Render top-level directories and their hierarchies for top_level in sorted(top_level_groups.keys()): subdirs_list = top_level_groups[top_level] # Aggregate stats for top-level directory from collections import namedtuple DirStats = namedtuple('DirStats', ['total', 'used', 'unused', 'lines_total', 'lines_used', 'files']) total_files = sum(s.total for _, s in subdirs_list) used_files = sum(s.used for _, s in subdirs_list) total_lines = sum(s.lines_total for _, s in subdirs_list) used_lines = sum(s.lines_used for _, s in subdirs_list) top_stats = DirStats(total=total_files, used=used_files, unused=0, lines_total=total_lines, lines_used=used_lines, files=[]) # Render top-level directory with aggregated stats top_dir_id = render_directory(top_level, top_stats, None, 0) # Render all subdirectories under this top-level directory for subdir_path, subdir_stats in sorted(subdirs_list): subdir_id = render_directory(subdir_path, subdir_stats, top_dir_id, 1) # Render files for this subdirectory if show_files and subdir_stats.files: sorted_files = sorted(subdir_stats.files, key=lambda x: os.path.basename(x['path'])) for info in sorted_files: if not show_empty and info['active'] == 0: continue filename = os.path.basename(info['path']) if file_results: pct_active = percent(info['active'], info['total']) pct_active_class = ('pct-high' if pct_active >= 75 else ('pct-med' if pct_active >= 50 else 'pct-low')) if use_kloc: total_str = f'{klocs(info["total"])}' active_str = f'{klocs(info["active"])}' else: total_str = f'{info["total"]:,}' active_str = f'{info["active"]:,}' html += f''' ''' # Add total row pct_files = percent(len(used_sources), len(all_sources)) pct_code_total = percent(total_lines_used, total_lines_all) if use_kloc: total_str = f'{klocs(total_lines_all)}' used_str = f'{klocs(total_lines_used)}' else: total_str = f'{total_lines_all:,}' used_str = f'{total_lines_used:,}' html += f'''
Directory Files Used %Used %Code {lines_header} Used
{display_name} {stats.total} {stats.used} {pct_used:.0f} {pct_code:.0f} {lines_total_str} {lines_used_str}
TOTAL {len(all_sources)} {len(used_sources)} {pct_files:.0f} {pct_code_total:.0f} {total_str} {used_str}
''' # Write HTML to file try: with open(html_file, 'w', encoding='utf-8') as f: f.write(html) tout.info(f'HTML report written to: {html_file}') return True except IOError as e: tout.error(f'Failed to write HTML file: {e}') return False def show_statistics(all_sources, used_sources, skipped_sources, file_results, srcdir, top_n): """Show overall statistics about source file usage. Args: all_sources (set of str): Set of all source file paths used_sources (set of str): Set of used source file paths skipped_sources (set of str): Set of unused source file paths file_results (dict): Optional dict mapping file paths to line analysis results srcdir (str): Root directory of the source tree top_n (int): Number of top files with most inactive code to show Returns: bool: True on success """ # Calculate line counts - use file_results (DWARF/unifdef) if available if file_results: # Use active lines from analysis results used_lines = sum(r.active_lines for r in file_results.values()) else: # Fall back to counting all lines in used files used_lines = sum(count_lines(f) for f in used_sources) unused_lines = sum(count_lines(f) for f in skipped_sources) total_lines = used_lines + unused_lines print_heading('FILE-LEVEL STATISTICS', width=70) print(f'Total source files: {len(all_sources):6}') used_pct = percent(len(used_sources), len(all_sources)) print(f'Used source files: {len(used_sources):6} ({used_pct:.1f}%)') unused_pct = percent(len(skipped_sources), len(all_sources)) print(f'Unused source files: {len(skipped_sources):6} ' + f'({unused_pct:.1f}%)') print() print(f'Total lines of code: {total_lines:6}') used_lines_pct = percent(used_lines, total_lines) print(f'Used lines of code: {used_lines:6} ({used_lines_pct:.1f}%)') unused_lines_pct = percent(unused_lines, total_lines) print(f'Unused lines of code: {unused_lines:6} ' + f'({unused_lines_pct:.1f}%)') print_heading('', width=70) # If line-level analysis was performed, show those stats too if file_results: print() total_lines_analysed = sum(r.total_lines for r in file_results.values()) active_lines = sum(r.active_lines for r in file_results.values()) inactive_lines = sum(r.inactive_lines for r in file_results.values()) print_heading('LINE-LEVEL STATISTICS (within compiled files)', width=70) print(f'Files analysed: {len(file_results):6}') print(f'Total lines in used files:{total_lines_analysed:6}') active_pct = percent(active_lines, total_lines_analysed) print(f'Active lines: {active_lines:6} ' + f'({active_pct:.1f}%)') inactive_pct = percent(inactive_lines, total_lines_analysed) print(f'Inactive lines: {inactive_lines:6} ' + f'({inactive_pct:.1f}%)') print_heading('', width=70) print() # Show top files with most inactive code files_by_inactive = sorted( file_results.items(), key=lambda x: x[1].inactive_lines, reverse=True ) print(f'TOP {top_n} FILES WITH MOST INACTIVE CODE:') print('-' * 70) for source_file, result in files_by_inactive[:top_n]: rel_path = os.path.relpath(source_file, srcdir) pct_inactive = percent(result.inactive_lines, result.total_lines) print(f' {result.inactive_lines:5} inactive lines ' + f'({pct_inactive:4.1f}%) - {rel_path}') return True