Files
u-boot/tools/codman/output.py
Simon Glass 34cdaeafdb codman: Move file line counts to kLOC column
File metrics do not align with directory columns, making it hard to
compare them visually. Align file output with directory columns by:
- Skipping the Files, Used, and %Used columns (leave empty)
- Showing percentage active in the %Code column
- Showing total lines (in kLOC) in the kLOC column
- Showing active lines (in kLOC) in the Used column

Co-developed-by: Claude <noreply@anthropic.com>
Signed-off-by: Simon Glass <simon.glass@canonical.com>
2025-12-08 17:09:59 -07:00

538 lines
19 KiB
Python

# 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):
"""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
"""
# 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:]
print(f'{display_path:<40} {stats.total:>7} {stats.used:>7} '
f'{pct_used:>6.0f} {pct_code:>6.0f} '
f'{klocs(stats.lines_total):>8} {klocs(stats.lines_used):>7}')
# 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'])
# Align with directory format: skip Files/Used columns,
# show %code, then lines in kLOC column, active in Used column
print(f" {filename:<38} {'':>7} {'':>7} {'':>6} "
f"{pct_active:>6.1f} {klocs(info['total']):>8} "
f"{klocs(info['active']):>7}")
else:
# Show file-level only
print(f" {filename:<38} {info['total']:>7} lines")
def show_dir_breakdown(all_sources, used_sources, file_results, srcdir,
by_subdirs, show_files, show_empty):
"""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
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)
print(f"{'Directory':<40} {'Files':>7} {'Used':>7} {'%Used':>6} " +
f"{'%Code':>6} {'kLOC':>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)
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)
print(f"{'TOTAL':<40} {len(all_sources):>7} {len(used_sources):>7} "
f"{pct_files:>6.0f} {pct_code:>6.0f} "
f"{klocs(total_lines_all):>8} {klocs(total_lines_used):>7}")
print_heading('', width=table_width)
print()
return True
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