diff --git a/src/gdiffer/cli.py b/src/gdiffer/cli.py new file mode 100644 index 0000000..4a31433 --- /dev/null +++ b/src/gdiffer/cli.py @@ -0,0 +1,291 @@ +"""CLI interface for git diff explainer.""" + +import json +import sys +from typing import Optional + +import click + +from gdiffer import __version__ +from gdiffer.code_analyzer import CodeAnalyzer +from gdiffer.issue_detector import IssueDetector +from gdiffer.language_detector import LanguageDetector +from gdiffer.models import DiffAnalysis, DiffFile +from gdiffer.output import OutputFormatter, OutputFormat +from gdiffer.parser import parse_diff + + +def create_analysis(files: list[DiffFile], verbose: bool = False) -> DiffAnalysis: + analysis = DiffAnalysis() + language_detector = LanguageDetector() + code_analyzer = CodeAnalyzer() + issue_detector = IssueDetector() + + for file_obj in files: + analysis.files.append(file_obj) + + if file_obj.change_type == "add": + analysis.files_added += 1 + elif file_obj.change_type == "delete": + analysis.files_deleted += 1 + elif file_obj.change_type == "rename": + analysis.files_renamed += 1 + else: + analysis.files_modified += 1 + + lang = language_detector.detect(file_obj.filename) + if lang != "text": + analysis.language_breakdown[lang] = analysis.language_breakdown.get(lang, 0) + 1 + + for hunk in file_obj.hunks: + old_code = '\n'.join(hunk.old_lines_content) + new_code = '\n'.join(hunk.new_lines_content) + + summary = code_analyzer.summarize_change(old_code, new_code, lang) + + issues = issue_detector.detect_diff_issues(old_code, new_code, lang) + for issue in issues: + issue_dict = { + 'type': issue.type, + 'severity': issue.severity, + 'title': issue.title, + 'description': issue.description, + 'line': issue.line, + 'suggestion': issue.suggestion, + 'file': file_obj.filename, + } + analysis.all_issues.append(issue_dict) + + suggestions = issue_detector.suggest_improvements(new_code, lang) + analysis.all_suggestions.extend(suggestions) + + analysis.total_changes += hunk.new_lines + + analysis.total_files = len(files) + + return analysis + + +@click.group() +@click.version_option(version=__version__) +@click.option('--verbose', '-v', is_flag=True, help='Enable verbose output') +@click.option('--output', '-o', type=click.Choice(['terminal', 'json', 'plain']), + default='terminal', help='Output format') +@click.pass_context +def main(ctx: click.Context, verbose: bool, output: str): + ctx.ensure_object(dict) + ctx.obj['verbose'] = verbose + ctx.obj['output'] = output + + +@main.command() +@click.argument('diff_input', type=click.STRING, required=False) +@click.option('--file', '-f', type=click.Path(exists=True), help='Read diff from file') +@click.option('--stdin', '-s', is_flag=True, help='Read diff from stdin') +@click.pass_context +def explain(ctx: click.Context, diff_input: Optional[str], file: Optional[str], stdin: bool): + verbose = ctx.obj.get('verbose', False) + output_format = ctx.obj.get('output', 'terminal') + + diff_content = "" + + if stdin: + diff_content = sys.stdin.read() + elif file: + with open(file, 'r') as f: + diff_content = f.read() + elif diff_input: + diff_content = diff_input + else: + click.echo("No diff provided. Use --stdin, --file, or pass diff as argument.", err=True) + sys.exit(1) + + try: + files = parse_diff(diff_content) + + if not files: + click.echo("No valid diff files found. Please provide a valid git diff.", err=True) + sys.exit(1) + + analysis = create_analysis(files, verbose) + + if output_format == 'json': + result = format_analysis_json(analysis) + click.echo(result) + else: + formatter = OutputFormatter(OutputFormat(output_format)) + formatter.print_analysis(analysis) + + except Exception as e: + click.echo(f"Error analyzing diff: {e}", err=True) + if verbose: + import traceback + traceback.print_exc() + sys.exit(1) + + +@main.command() +@click.option('--file', '-f', type=click.Path(exists=True), help='Read diff from file') +@click.option('--stdin', '-s', is_flag=True, help='Read diff from stdin') +@click.pass_context +def issues(ctx: click.Context, file: Optional[str], stdin: bool): + diff_content = "" + + if stdin: + diff_content = sys.stdin.read() + elif file: + with open(file, 'r') as f: + diff_content = f.read() + else: + diff_content = sys.stdin.read() + + if not diff_content.strip(): + click.echo("No diff provided.", err=True) + sys.exit(1) + + try: + files = parse_diff(diff_content) + + issue_detector = IssueDetector() + all_issues = [] + + for file_obj in files: + for hunk in file_obj.hunks: + old_code = '\n'.join(hunk.old_lines_content) + new_code = '\n'.join(hunk.new_lines_content) + lang = LanguageDetector().detect(file_obj.filename) + + issues = issue_detector.detect_diff_issues(old_code, new_code, lang) + for issue in issues: + all_issues.append({ + 'file': file_obj.filename, + 'line': issue.line, + 'severity': issue.severity, + 'title': issue.title, + 'description': issue.description, + 'suggestion': issue.suggestion, + }) + + if all_issues: + severity_priority = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3} + all_issues.sort(key=lambda x: severity_priority.get(x.get('severity', ''), 4)) + + if ctx.obj.get('output') == 'json': + click.echo(json.dumps(all_issues, indent=2)) + else: + for issue in all_issues: + color = {'critical': 'red', 'high': 'orange3', 'medium': 'yellow', 'low': 'cyan'}.get( + issue['severity'], 'white' + ) + click.echo(f"[{color}][{issue['severity'].upper()}][/] {issue['title']}") + click.echo(f" File: {issue['file']}:{issue['line']}") + click.echo(f" {issue['description']}") + click.echo(f" Suggestion: {issue['suggestion']}") + click.echo() + else: + click.echo("No issues detected in the diff.") + + except Exception as e: + click.echo(f"Error analyzing issues: {e}", err=True) + sys.exit(1) + + +@main.command() +@click.option('--file', '-f', type=click.Path(exists=True), help='Read diff from file') +@click.option('--stdin', '-s', is_flag=True, help='Read diff from stdin') +@click.pass_context +def summarize(ctx: click.Context, file: Optional[str], stdin: bool): + diff_content = "" + + if stdin: + diff_content = sys.stdin.read() + elif file: + with open(file, 'r') as f: + diff_content = f.read() + else: + diff_content = sys.stdin.read() + + if not diff_content.strip(): + click.echo("No diff provided.", err=True) + sys.exit(1) + + try: + files = parse_diff(diff_content) + + if not files: + click.echo("No valid diff files found.") + sys.exit(1) + + analysis = create_analysis(files) + + click.echo(f"Files changed: {analysis.total_files}") + click.echo(f" Added: {analysis.files_added}") + click.echo(f" Deleted: {analysis.files_deleted}") + click.echo(f" Modified: {analysis.files_modified}") + click.echo(f" Renamed: {analysis.files_renamed}") + click.echo(f"Total changes: {analysis.total_changes}") + + if analysis.language_breakdown: + click.echo("\nLanguages:") + for lang, count in sorted(analysis.language_breakdown.items()): + click.echo(f" - {lang}: {count} files") + + if analysis.all_issues: + critical = sum(1 for i in analysis.all_issues if i.get('severity') == 'critical') + high = sum(1 for i in analysis.all_issues if i.get('severity') == 'high') + medium = sum(1 for i in analysis.all_issues if i.get('severity') == 'medium') + low = sum(1 for i in analysis.all_issues if i.get('severity') == 'low') + + click.echo(f"\nIssues found: {len(analysis.all_issues)}") + if critical: + click.echo(f" Critical: {critical}") + if high: + click.echo(f" High: {high}") + if medium: + click.echo(f" Medium: {medium}") + if low: + click.echo(f" Low: {low}") + + except Exception as e: + click.echo(f"Error summarizing diff: {e}", err=True) + sys.exit(1) + + +def format_analysis_json(analysis: DiffAnalysis) -> str: + result = { + 'summary': { + 'total_files': analysis.total_files, + 'files_added': analysis.files_added, + 'files_deleted': analysis.files_deleted, + 'files_modified': analysis.files_modified, + 'files_renamed': analysis.files_renamed, + 'total_changes': analysis.total_changes, + 'language_breakdown': analysis.language_breakdown, + }, + 'files': [], + 'issues': analysis.all_issues, + 'suggestions': analysis.all_suggestions, + } + + for file_obj in analysis.files: + file_data = { + 'filename': file_obj.filename, + 'change_type': file_obj.change_type, + 'language': file_obj.extension, + 'hunks': [], + } + + for hunk in file_obj.hunks: + hunk_data = { + 'old_start': hunk.old_start, + 'new_start': hunk.new_start, + 'changes': { + 'added': hunk.get_added_lines(), + 'removed': hunk.get_removed_lines(), + }, + } + file_data['hunks'].append(hunk_data) + + result['files'].append(file_data) + + return json.dumps(result, indent=2)