Coverage for src\llm_code_lens\cli.py: 9%
534 statements
« prev ^ index » next coverage.py v7.7.0, created at 2025-05-25 12:07 +0300
« prev ^ index » next coverage.py v7.7.0, created at 2025-05-25 12:07 +0300
1#!/usr/bin/env python3
2"""
3LLM Code Lens - CLI Module
4Handles command-line interface and coordination of analysis components.
5"""
7import click
8from pathlib import Path
9from typing import Dict, List, Union, Optional
10from rich.console import Console
11from .analyzer.base import ProjectAnalyzer, AnalysisResult
12from .analyzer.sql import SQLServerAnalyzer
13from .version import check_for_newer_version
14from .utils.gitignore import GitignoreParser # Added this line
15import tiktoken
16import traceback
17import os
18import json
19import shutil
20import webbrowser
21import subprocess
22import sys
24console = Console()
26def parse_ignore_file(ignore_file: Path) -> List[str]:
27 """Parse .llmclignore file and return list of patterns."""
28 if not ignore_file.exists():
29 return []
31 patterns = []
32 try:
33 with ignore_file.open() as f:
34 for line in f:
35 line = line.strip()
36 # Skip empty lines and comments
37 if line and not line.startswith('#'):
38 patterns.append(line)
39 except Exception as e:
40 print(f"Warning: Error reading {ignore_file}: {e}")
42 return patterns
44def should_ignore(path: Path, ignore_patterns: Optional[List[str]] = None, gitignore_parser: Optional['GitignoreParser'] = None) -> bool:
45 """Determine if a file or directory should be ignored based on patterns and gitignore."""
46 if ignore_patterns is None:
47 ignore_patterns = []
49 path_str = str(path)
51 # First check gitignore patterns if parser is provided
52 if gitignore_parser and gitignore_parser.should_ignore(path):
53 return True
55 # Then check default ignores and custom patterns (existing logic)
56 default_ignores = {
57 # Version control and cache directories
58 '.git', '__pycache__', '.pytest_cache', '.idea', '.vscode',
59 '.vscode-test', '.nyc_output', '.ipynb_checkpoints', '.tox',
61 # Language/framework specific directories
62 'node_modules', 'venv', 'env', 'dist', 'build', 'htmlcov',
63 '.next', 'next-env.d.ts', 'bin', 'obj', 'DerivedData',
64 'vendor', '.bundle', 'target', 'blib', 'pm_to_blib',
65 '.dart_tool', 'pkg', 'out', 'coverage',
67 # Package lock files
68 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
69 'Gemfile.lock', 'composer.lock', 'composer.json',
71 # Config files
72 'tsconfig.json', 'jsconfig.json',
74 # System files
75 '.DS_Store',
77 # Log files
78 '*.log', 'npm-debug.log', 'yarn-error.log',
80 # Temp/backup files
81 '*.tmp', '*.bak', '*.swp', '*.swo', '*.orig',
83 # Binary and compiled files
84 '*.exe', '*.dll', '*.so', '*.dylib', '*.a', '*.o', '*.obj',
85 '*.pdb', '*.idb', '*.ilk', '*.map', '*.ncb', '*.sdf', '*.opensdf',
86 '*.lib', '*.class', '*.jar', '*.war', '*.ear', '*.pyc', '*.pyo', '*.pyd',
87 '*.py[cod]', '*$py.class', '*.whl', '*.mexw64', '*.test', '*.out',
88 '*.rs.bk', '*.build',
90 # Document build files
91 '*.aux', '*.toc', '*.out', '*.dvi', '*.ps', '*.pdf', '*.lof', '*.lot',
92 '*.fls', '*.fdb_latexmk', '*.synctex.gz',
94 # Source files that shouldn't normally be ignored
95 '*.go',
97 # Project files
98 '*.csproj', '*.user', '*.suo', '*.sln.docstates', '*.xcodeproj', '*.xcworkspace',
100 # CSS files
101 '*.css.map', '*.min.css',
103 # R files
104 '.Rhistory', '.RData', '*.Rout',
106 # Utility files
107 'pnp.loader.mjs'
108 }
110 # Check if the path is a directory and should be ignored
111 if path.is_dir():
112 for pattern in default_ignores:
113 if pattern in path.name or any(pattern in part for part in path.parts):
114 return True
116 # Check default ignores
117 for pattern in default_ignores:
118 if pattern in path_str or any(pattern in part for part in path.parts):
119 return True
121 # Check custom ignore patterns
122 for pattern in ignore_patterns:
123 # Skip gitignore patterns (they're handled above)
124 if pattern.startswith('!') or '/' in pattern or '*' in pattern:
125 continue
126 if pattern in path_str or any(pattern in part for part in path.parts):
127 return True
129 return False
131def is_binary(file_path: Path) -> bool:
132 """Check if a file is binary."""
133 try:
134 with file_path.open('rb') as f:
135 for block in iter(lambda: f.read(1024), b''):
136 if b'\0' in block:
137 return True
138 except Exception:
139 return True
140 return False
142def split_content_by_tokens(content: str, chunk_size: int = 100000) -> List[str]:
143 """
144 Split content into chunks based on token count.
145 Handles large content safely by pre-chunking before tokenization.
147 Args:
148 content (str): The content to split
149 chunk_size (int): Target size for each chunk in tokens
151 Returns:
152 List[str]: List of content chunks
153 """
154 if not content:
155 return ['']
157 try:
158 # First do a rough pre-chunking by characters to avoid stack overflow
159 MAX_CHUNK_CHARS = 100000 # Adjust this based on your needs
160 rough_chunks = []
162 for i in range(0, len(content), MAX_CHUNK_CHARS):
163 rough_chunks.append(content[i:i + MAX_CHUNK_CHARS])
165 encoder = tiktoken.get_encoding("cl100k_base")
166 final_chunks = []
168 # Process each rough chunk
169 for rough_chunk in rough_chunks:
170 tokens = encoder.encode(rough_chunk)
172 # Split into smaller chunks based on token count
173 for i in range(0, len(tokens), chunk_size):
174 chunk_tokens = tokens[i:i + chunk_size]
175 chunk_content = encoder.decode(chunk_tokens)
176 final_chunks.append(chunk_content)
178 return final_chunks
180 except Exception as e:
181 # Fallback to line-based splitting
182 return _split_by_lines(content, max_chunk_size=chunk_size)
184def _split_by_lines(content: str, max_chunk_size: int = 100000) -> List[str]:
185 """Split content by lines with a maximum chunk size."""
186 # Handle empty content case first
187 if not content:
188 return [""]
190 lines = content.splitlines(keepends=True) # Keep line endings
191 chunks = []
192 current_chunk = []
193 current_size = 0
195 for line in lines:
196 line_size = len(line.encode('utf-8'))
197 if current_size + line_size > max_chunk_size and current_chunk:
198 chunks.append(''.join(current_chunk))
199 current_chunk = [line]
200 current_size = line_size
201 else:
202 current_chunk.append(line)
203 current_size += line_size
205 if current_chunk:
206 chunks.append(''.join(current_chunk))
208 # Handle special case where we got no chunks
209 if not chunks:
210 return [content] # Return entire content as one chunk
212 return chunks
214def delete_and_create_output_dir(output_dir: Path) -> None:
215 """Delete the output directory if it exists and recreate it."""
216 if output_dir.exists() and output_dir.is_dir():
217 # Preserve the menu state file if it exists
218 menu_state_file = output_dir / 'menu_state.json'
219 menu_state_data = None
220 if menu_state_file.exists():
221 try:
222 with open(menu_state_file, 'r') as f:
223 menu_state_data = f.read()
224 except Exception:
225 pass
227 # Delete the directory
228 shutil.rmtree(output_dir)
230 # Recreate the directory
231 output_dir.mkdir(parents=True, exist_ok=True)
233 # Restore the menu state file if we had one
234 if menu_state_data:
235 try:
236 with open(menu_state_file, 'w') as f:
237 f.write(menu_state_data)
238 except Exception:
239 pass
240 else:
241 output_dir.mkdir(parents=True, exist_ok=True)
243def export_full_content(path: Path, output_dir: Path, ignore_patterns: List[str], exclude_paths: List[Path] = None, include_samples: bool = True) -> None:
244 """Export full content of all files with optional sample snippets."""
245 file_content = []
246 exclude_paths = exclude_paths or []
248 # Add configuration summary at the top
249 config_summary = _generate_config_summary(path)
250 if config_summary:
251 file_content.append(f"\nPROJECT CONFIGURATION:\n{'='*80}\n{config_summary}\n")
253 # Export file system content
254 for file_path in path.rglob('*'):
255 # Skip if file should be ignored based on patterns
256 if should_ignore(file_path, ignore_patterns) or is_binary(file_path):
257 continue
259 # Skip if file is in excluded paths from interactive selection
260 should_exclude = False
261 for exclude_path in exclude_paths:
262 if str(file_path).startswith(str(exclude_path)):
263 should_exclude = True
264 break
266 if should_exclude:
267 continue
269 try:
270 content = file_path.read_text(encoding='utf-8')
271 file_content.append(f"\nFILE: {file_path}\n{'='*80}\n{content}\n")
272 except Exception as e:
273 console.print(f"[yellow]Warning: Error reading {file_path}: {str(e)}[/]")
274 continue
276 # Add sample content from representative files
277 if include_samples:
278 sample_content = _extract_sample_content(path, ignore_patterns, exclude_paths)
279 if sample_content:
280 file_content.append(f"\nSAMPLE CODE SNIPPETS:\n{'='*80}\n{sample_content}\n")
282 # Combine all content
283 full_content = "\n".join(file_content)
285 # Split and write content
286 chunks = split_content_by_tokens(full_content, chunk_size=100000)
287 for i, chunk in enumerate(chunks, 1):
288 output_file = output_dir / f'full_{i}.txt'
289 try:
290 output_file.write_text(chunk, encoding='utf-8')
291 console.print(f"[green]Created full content file: {output_file}[/]")
292 except Exception as e:
293 console.print(f"[yellow]Warning: Error writing {output_file}: {str(e)}[/]")
295def _generate_config_summary(path: Path) -> str:
296 """Generate a summary of project configuration."""
297 from .analyzer.config import analyze_package_json, analyze_tsconfig, extract_readme_summary
299 summary_parts = []
301 # Package.json summary
302 pkg_info = analyze_package_json(path / 'package.json')
303 if pkg_info and 'error' not in pkg_info:
304 summary_parts.append(f"Project: {pkg_info.get('name', 'Unknown')} v{pkg_info.get('version', '0.0.0')}")
305 if pkg_info.get('description'):
306 summary_parts.append(f"Description: {pkg_info['description']}")
307 if pkg_info.get('framework_indicators'):
308 summary_parts.append(f"Frameworks: {', '.join(pkg_info['framework_indicators'])}")
309 if pkg_info.get('scripts'):
310 summary_parts.append(f"Available scripts: {', '.join(pkg_info['scripts'])}")
312 # README summary
313 readme_info = extract_readme_summary(path)
314 if readme_info:
315 summary_parts.append(f"README Summary:\n{readme_info['summary']}")
317 return '\n'.join(summary_parts)
319def _extract_sample_content(path: Path, ignore_patterns: List[str], exclude_paths: List[Path]) -> str:
320 """Extract small samples from different file types."""
321 samples = []
322 sample_files = {}
324 # Collect representative files
325 for file_path in path.rglob('*'):
326 if should_ignore(file_path, ignore_patterns) or is_binary(file_path):
327 continue
329 ext = file_path.suffix.lower()
330 if ext in ['.js', '.jsx', '.ts', '.tsx', '.py', '.css', '.scss']:
331 if ext not in sample_files or len(str(file_path)) < len(str(sample_files[ext])):
332 sample_files[ext] = file_path
334 # Extract samples
335 for ext, file_path in sample_files.items():
336 try:
337 with open(file_path, 'r', encoding='utf-8') as f:
338 lines = f.readlines()[:20] # First 20 lines
339 content = ''.join(lines)
340 samples.append(f"Sample {ext} ({file_path.name}):\n{content}\n")
341 except Exception:
342 continue
344 return '\n'.join(samples)
346def export_sql_content(sql_results: dict, output_dir: Path) -> None:
347 """Export full content of SQL objects in separate token-limited files."""
348 file_content = []
350 # Process stored procedures
351 for proc in sql_results.get('stored_procedures', []):
352 content = f"""
353STORED PROCEDURE: [{proc['schema']}].[{proc['name']}]
354{'='*80}
355{proc['definition']}
356"""
357 file_content.append(content)
359 # Process views
360 for view in sql_results.get('views', []):
361 content = f"""
362VIEW: [{view['schema']}].[{view['name']}]
363{'='*80}
364{view['definition']}
365"""
366 file_content.append(content)
368 # Process functions
369 for func in sql_results.get('functions', []):
370 content = f"""
371FUNCTION: [{func['schema']}].[{func['name']}]
372{'='*80}
373{func['definition']}
374"""
375 file_content.append(content)
377 # Split and write content
378 if file_content:
379 full_content = "\n".join(file_content)
380 chunks = split_content_by_tokens(full_content, chunk_size=100000)
382 for i, chunk in enumerate(chunks, 1):
383 output_file = output_dir / f'sql_full_{i}.txt'
384 try:
385 output_file.write_text(chunk, encoding='utf-8')
386 console.print(f"[green]Created SQL content file: {output_file}[/]")
387 except Exception as e:
388 console.print(f"[yellow]Warning: Error writing {output_file}: {str(e)}[/]")
390def _combine_fs_results(combined: dict, result: dict) -> None:
391 """Combine file system analysis results."""
392 # Update project stats
393 stats = result.get('summary', {}).get('project_stats', {})
394 combined['summary']['project_stats']['total_files'] += stats.get('total_files', 0)
395 combined['summary']['project_stats']['lines_of_code'] += stats.get('lines_of_code', 0)
397 # Update code metrics
398 metrics = result.get('summary', {}).get('code_metrics', {})
399 for metric_type in ['functions', 'classes']:
400 if metric_type in metrics:
401 for key in ['count', 'with_docs', 'complex']:
402 if key in metrics[metric_type]:
403 combined['summary']['code_metrics'][metric_type][key] += metrics[metric_type][key]
405 # Update imports
406 if 'imports' in metrics:
407 combined['summary']['code_metrics']['imports']['count'] += metrics['imports'].get('count', 0)
408 unique_imports = metrics['imports'].get('unique', set())
409 if isinstance(unique_imports, (set, list)):
410 combined['summary']['code_metrics']['imports']['unique'].update(unique_imports)
412 # Update maintenance info
413 maintenance = result.get('summary', {}).get('maintenance', {})
414 combined['summary']['maintenance']['todos'].extend(maintenance.get('todos', []))
416 # Update structure info
417 structure = result.get('summary', {}).get('structure', {})
418 if 'directories' in structure:
419 dirs = structure['directories']
420 if isinstance(dirs, (set, list)):
421 combined['summary']['structure']['directories'].update(dirs)
423 # Update insights and files
424 if 'insights' in result:
425 combined['insights'].extend(result['insights'])
426 if 'files' in result:
427 combined['files'].update(result['files'])
429def _combine_results(results: List[Union[dict, AnalysisResult]]) -> AnalysisResult:
430 """Combine multiple analysis results into a single result."""
431 combined = {
432 'summary': {
433 'project_stats': {
434 'total_files': 0,
435 'total_sql_objects': 0,
436 'by_type': {},
437 'lines_of_code': 0,
438 'avg_file_size': 0
439 },
440 'code_metrics': {
441 'functions': {'count': 0, 'with_docs': 0, 'complex': 0},
442 'classes': {'count': 0, 'with_docs': 0},
443 'sql_objects': {'procedures': 0, 'views': 0, 'functions': 0},
444 'imports': {'count': 0, 'unique': set()}
445 },
446 'maintenance': {
447 'todos': [],
448 'comments_ratio': 0,
449 'doc_coverage': 0
450 },
451 'structure': {
452 'directories': set(),
453 'entry_points': [],
454 'core_files': [],
455 'sql_dependencies': []
456 }
457 },
458 'insights': [],
459 'files': {}
460 }
462 for result in results:
463 # Handle SQL results
464 if isinstance(result, dict) and ('stored_procedures' in result or 'views' in result):
465 _combine_sql_results(combined, result)
466 # Handle AnalysisResult objects
467 elif isinstance(result, AnalysisResult):
468 # Convert AnalysisResult to a simple dict for easier processing
469 result_dict = {
470 'summary': result.summary,
471 'insights': result.insights,
472 'files': result.files
473 }
474 _combine_fs_results(combined, result_dict)
475 # Handle plain dictionaries
476 else:
477 _combine_fs_results(combined, result)
479 # Calculate final metrics
480 total_items = (combined['summary']['project_stats']['total_files'] +
481 combined['summary']['project_stats']['total_sql_objects'])
483 if total_items > 0:
484 combined['summary']['project_stats']['avg_file_size'] = (
485 combined['summary']['project_stats']['lines_of_code'] / total_items
486 )
488 # Convert sets to lists for JSON serialization
489 combined['summary']['code_metrics']['imports']['unique'] = list(
490 combined['summary']['code_metrics']['imports']['unique']
491 )
492 combined['summary']['structure']['directories'] = list(
493 combined['summary']['structure']['directories']
494 )
496 return AnalysisResult(**combined)
498def _combine_sql_results(combined: dict, sql_result: dict) -> None:
499 """Combine SQL results with proper object counting."""
500 # Count objects
501 proc_count = len(sql_result.get('stored_procedures', []))
502 view_count = len(sql_result.get('views', []))
503 func_count = len(sql_result.get('functions', []))
505 # Update stats
506 combined['summary']['project_stats']['total_sql_objects'] += proc_count + view_count + func_count
507 combined['summary']['code_metrics']['sql_objects']['procedures'] += proc_count
508 combined['summary']['code_metrics']['sql_objects']['views'] += view_count
509 combined['summary']['code_metrics']['sql_objects']['functions'] += func_count
511 # Add objects to files
512 for proc in sql_result.get('stored_procedures', []):
513 key = f"stored_proc_{proc['name']}"
514 combined['files'][key] = proc
515 for view in sql_result.get('views', []):
516 key = f"view_{view['name']}"
517 combined['files'][key] = view
518 for func in sql_result.get('functions', []):
519 key = f"function_{func['name']}"
520 combined['files'][key] = func
522def open_in_llm_provider(provider: str, output_path: Path, debug: bool = False) -> bool:
523 """
524 Open the analysis results in a browser with the specified LLM provider.
526 Args:
527 provider: The LLM provider to use (claude, chatgpt, gemini, none, etc.)
528 output_path: Path to the output directory containing analysis files
529 debug: Enable debug output
531 Returns:
532 bool: True if successful, False otherwise
533 """
534 # Handle 'none' option - return success without opening anything
535 if provider and provider.lower() == 'none':
536 console.print("[green]Skipping browser opening as requested.[/]")
537 return True
539 try:
540 # Import pyperclip for clipboard operations
541 try:
542 import pyperclip
543 import urllib.parse
544 except ImportError:
545 console.print("[yellow]Error: The pyperclip package is required for LLM integration.[/]")
546 console.print("[yellow]Please install it with: pip install pyperclip[/]")
547 return False
549 # Define the system prompt
550 system_prompt = """You are an experienced developer and software architect.
551I'm sharing a codebase (or summary of a codebase) with you.
553Your task is to analyze this codebase and be able to convert any question or new feature request into very concrete, actionable, and detailed file-by-file instructions for my developer.
555IMPORTANT: All your instructions must be provided in a single, unformatted line for each file. Do not use multiple lines, bullet points, or any other formatting. My developer relies on this specific format to process your instructions correctly.
557Your instructions should specify exactly what needs to be done in which file and why, so the developer can implement them with a full understanding of the changes required. Do not skip any information - include all details, just format them as a continuous line of text for each file.
559In my next message, I'll tell you about a new request or question about this code.
560"""
562 # Prepare the complete message with files included
563 full_message = system_prompt + "\n\n"
565 # Add the analysis file
566 analysis_file = output_path / 'analysis.txt'
567 if analysis_file.exists():
568 full_message += f"# Code Analysis\n\n```\n{analysis_file.read_text(encoding='utf-8')}\n```\n\n"
570 # Check if full export is enabled by looking for full_*.txt files
571 full_files = list(output_path.glob('full_*.txt'))
573 # If full export is enabled, add the content of all full files
574 if full_files:
575 for file in sorted(full_files):
576 full_message += f"# {file.name}\n\n```\n{file.read_text(encoding='utf-8')}\n```\n\n"
578 # Add SQL content files if they exist
579 sql_files = list(output_path.glob('sql_full_*.txt'))
580 for file in sorted(sql_files):
581 full_message += f"# {file.name}\n\n```sql\n{file.read_text(encoding='utf-8')}\n```\n\n"
583 # Copy the full message to clipboard (for all providers as backup)
584 pyperclip.copy(full_message)
586 # Open the appropriate provider
587 if provider.lower() == 'claude':
588 # Open Claude in a new chat
589 webbrowser.open("https://claude.ai/new")
591 console.print("[green]Claude opened in browser.[/]")
592 console.print("[green]The complete analysis with all files has been copied to your clipboard.[/]")
593 console.print("[green]Just press Ctrl+V in Claude to paste everything at once![/]")
594 return True
596 elif provider.lower() == 'chatgpt':
597 # For ChatGPT, try to use the query parameter approach
598 try:
599 # Encode the message for URL
600 encoded_message = urllib.parse.quote(full_message)
602 # Check if the URL would be too long (most browsers have limits around 2000-8000 chars)
603 # We'll use a conservative limit of 2000 characters
604 if len(encoded_message) <= 2000:
605 # Use the query parameter approach
606 chatgpt_url = f"https://chatgpt.com/?q={encoded_message}"
607 webbrowser.open(chatgpt_url)
609 console.print("[green]ChatGPT opened in browser with content pre-loaded.[/]")
610 console.print("[green]The content has also been copied to your clipboard as a backup.[/]")
611 console.print("[green]If the content doesn't appear automatically, press Ctrl+V to paste it.[/]")
612 else:
613 # Fallback to regular URL if content is too large
614 webbrowser.open("https://chat.openai.com/")
616 console.print("[green]ChatGPT opened in browser.[/]")
617 console.print("[green]The content is too large for URL parameters (browser limitations).[/]")
618 console.print("[green]The complete analysis has been copied to your clipboard.[/]")
619 console.print("[green]Just press Ctrl+V in ChatGPT to paste everything at once![/]")
621 if debug:
622 console.print(f"[blue]URL parameter length: {len(encoded_message)} characters[/]")
623 if len(encoded_message) > 2000:
624 console.print("[blue]URL parameter too long, using clipboard only[/]")
626 return True
627 except Exception as e:
628 # Fallback to regular approach if URL encoding fails
629 if debug:
630 console.print(f"[yellow]Error with URL parameter approach: {str(e)}[/]")
631 console.print("[yellow]Falling back to clipboard approach[/]")
633 webbrowser.open("https://chat.openai.com/")
635 console.print("[green]ChatGPT opened in browser.[/]")
636 console.print("[green]The complete analysis with all files has been copied to your clipboard.[/]")
637 console.print("[green]Just press Ctrl+V in ChatGPT to paste everything at once![/]")
638 return True
640 elif provider.lower() == 'gemini':
641 # Open Gemini
642 webbrowser.open("https://gemini.google.com/")
644 console.print("[green]Gemini opened in browser.[/]")
645 console.print("[green]The complete analysis with all files has been copied to your clipboard.[/]")
646 console.print("[green]Just press Ctrl+V in Gemini to paste everything at once![/]")
647 return True
649 else:
650 console.print(f"[yellow]Unsupported LLM provider: {provider}[/]")
651 return False
653 except Exception as e:
654 console.print(f"[red]Error opening in LLM: {str(e)}[/]")
655 if debug:
656 console.print(traceback.format_exc())
657 return False
659@click.command()
660@click.argument('path', type=click.Path(exists=True), default='.')
661@click.option('--output', '-o', help='Output directory', default='.codelens')
662@click.option('--format', '-f', type=click.Choice(['txt', 'json']), default='txt')
663@click.option('--full', is_flag=True, help='Export full file/object contents in separate files')
664@click.option('--debug', is_flag=True, help='Enable debug output')
665@click.option('--sql-server', help='SQL Server connection string')
666@click.option('--sql-database', help='SQL Database to analyze')
667@click.option('--sql-config', help='Path to SQL configuration file')
668@click.option('--exclude', '-e', multiple=True, help='Patterns to exclude (can be used multiple times)')
669@click.option('--interactive', '-i', is_flag=True, help='Launch interactive selection menu before analysis', default=True, show_default=False)
670@click.option('--open-in-llm', help='Open results in LLM provider (claude, chatgpt, gemini, none)', default=None)
671@click.option('--respect-gitignore/--ignore-gitignore', default=True, help='Respect .gitignore file patterns (default: enabled)') # NEW OPTION
672def main(path: str, output: str, format: str, full: bool, debug: bool,
673 sql_server: str, sql_database: str, sql_config: str, exclude: tuple,
674 interactive: bool = True, open_in_llm: str = None, respect_gitignore: bool = True): # NEW PARAMETER
675 """
676 Main entry point for the CLI with gitignore support.
677 """
678 try:
679 # Convert to absolute paths
680 path = Path(path).resolve()
681 output_path = Path(output).resolve()
683 # Parse gitignore if enabled
684 gitignore_patterns = []
685 if respect_gitignore:
686 from .utils.gitignore import GitignoreParser # Import here to avoid circular imports
687 gitignore_parser = GitignoreParser(path)
688 gitignore_parser.load_gitignore()
689 gitignore_patterns = gitignore_parser.get_ignore_patterns()
691 if debug and gitignore_patterns:
692 console.print(f"[blue]Loaded {len(gitignore_patterns)} patterns from .gitignore[/]")
694 # Combine gitignore patterns with custom exclude patterns
695 all_ignore_patterns = list(exclude) + gitignore_patterns
697 # Update initial settings for menu
698 initial_settings = {
699 'format': format,
700 'full': full,
701 'debug': debug,
702 'sql_server': sql_server or '',
703 'sql_database': sql_database or '',
704 'sql_config': sql_config or '',
705 'exclude_patterns': all_ignore_patterns,
706 'open_in_llm': open_in_llm or '',
707 'respect_gitignore': respect_gitignore # NEW SETTING
708 }
710 # Launch interactive menu (default behavior)
711 try:
712 # Import here to avoid circular imports
713 from .menu import run_menu
714 console.print("[bold blue]🖥️ Launching interactive file selection menu...[/]")
715 settings = run_menu(Path(path), initial_settings)
717 # Check if user cancelled
718 if settings.get('cancelled', False):
719 console.print("[yellow]Operation cancelled by user[/]")
720 return 0
722 # Update paths based on user selection
723 path = settings.get('path', path)
724 include_paths = settings.get('include_paths', [])
725 exclude_paths = settings.get('exclude_paths', [])
727 # Update other settings from menu
728 format = settings.get('format', format)
729 full = settings.get('full', full)
730 debug = settings.get('debug', debug)
731 sql_server = settings.get('sql_server', sql_server)
732 sql_database = settings.get('sql_database', sql_database)
733 sql_config = settings.get('sql_config', sql_config)
734 exclude = settings.get('exclude', exclude)
735 open_in_llm = settings.get('open_in_llm', open_in_llm)
737 if debug:
738 console.print(f"[blue]Selected path: {path}[/]")
739 console.print(f"[blue]Included paths: {len(include_paths)}[/]")
740 console.print(f"[blue]Excluded paths: {len(exclude_paths)}[/]")
741 console.print(f"[blue]Output format: {format}[/]")
742 console.print(f"[blue]Full export: {full}[/]")
743 console.print(f"[blue]Debug mode: {debug}[/]")
744 console.print(f"[blue]SQL Server: {sql_server}[/]")
745 console.print(f"[blue]SQL Database: {sql_database}[/]")
746 console.print(f"[blue]Exclude patterns: {exclude}[/]")
747 except Exception as e:
748 console.print(f"[yellow]Warning: Interactive menu failed: {str(e)}[/]")
749 if debug:
750 console.print(traceback.format_exc())
751 console.print("[yellow]Continuing with default path selection...[/]")
753 # Ensure output directory exists
754 try:
755 delete_and_create_output_dir(output_path)
756 except Exception as e:
757 console.print(f"[red]Error creating output directory: {str(e)}[/]")
758 return 1
760 if debug:
761 console.print(f"[blue]Output directory: {output_path}[/]")
763 # Rest of the main function remains unchanged
764 results = []
766 # Load SQL configuration if provided
767 if sql_config:
768 try:
769 with open(sql_config) as f:
770 sql_settings = json.load(f)
771 sql_server = sql_settings.get('server')
772 sql_database = sql_settings.get('database')
774 # Set environment variables if provided in config
775 for key, value in sql_settings.get('env', {}).items():
776 os.environ[key] = value
777 except Exception as e:
778 console.print(f"[yellow]Warning: Error loading SQL config: {str(e)}[/]")
779 if debug:
780 console.print(traceback.format_exc())
782 # Run SQL analysis if requested
783 if sql_server or sql_database or os.getenv('MSSQL_SERVER'):
784 console.print("[bold blue]📊 Starting SQL Analysis...[/]")
785 try:
786 from .analyzer import SQLServerAnalyzer
787 analyzer = SQLServerAnalyzer()
789 try:
790 analyzer.connect(sql_server) # Will use env vars if not provided
791 if sql_database:
792 console.print(f"[blue]Analyzing database: {sql_database}[/]")
793 sql_result = analyzer.analyze_database(sql_database)
794 results.append(sql_result)
796 if full:
797 console.print("[blue]Exporting SQL content...[/]")
798 export_sql_content(sql_result, output_path)
799 else:
800 # Get all databases the user has access to
801 databases = analyzer.list_databases()
802 for db in databases:
803 console.print(f"[blue]Analyzing database: {db}[/]")
804 sql_result = analyzer.analyze_database(db)
805 results.append(sql_result)
807 if full:
808 console.print(f"[blue]Exporting SQL content for {db}...[/]")
809 export_sql_content(sql_result, output_path)
810 except Exception as e:
811 console.print(f"[yellow]Warning during SQL analysis: {str(e)}[/]")
812 if debug:
813 console.print(traceback.format_exc())
814 console.print("[yellow]SQL analysis will be skipped, but file analysis will continue.[/]")
816 except Exception as e:
817 console.print(f"[yellow]SQL Server analysis is not available: {str(e)}[/]")
818 console.print("[yellow]Install pyodbc and required ODBC drivers to enable this feature.[/]")
819 console.print("[yellow]Continuing with file analysis only.[/]")
821 # Check for newer version (non-blocking)
822 check_for_newer_version()
824 # Run file system analysis
825 console.print("[bold blue]📁 Starting File System Analysis...[/]")
826 analyzer = ProjectAnalyzer()
828 # Pass include/exclude paths to analyzer if they were set in interactive mode
829 if interactive and (include_paths or exclude_paths):
830 # Create a custom file collection function that respects include/exclude paths
831 def custom_collect_files(path: Path) -> List[Path]:
832 # Get all files that match the analyzer's supported extensions
833 files = []
834 for file_path in path.rglob('*'):
835 if (file_path.is_file() and
836 file_path.suffix.lower() in analyzer.analyzers):
837 files.append(file_path)
839 # Apply include/exclude filters
840 filtered_files = []
841 excluded_files = []
843 for file_path in files:
844 # Check if file should be included based on interactive selection
845 should_include = True
846 exclusion_reason = None
848 # If we have explicit include paths, file must be in one of them
849 if include_paths:
850 should_include = False
851 for include_path in include_paths:
852 if str(file_path).startswith(str(include_path)):
853 should_include = True
854 break
855 if not should_include:
856 exclusion_reason = "Not in include paths"
858 # Check if file is in exclude paths
859 for exclude_path in exclude_paths:
860 if str(file_path).startswith(str(exclude_path)):
861 should_include = False
862 exclusion_reason = f"Excluded by path: {exclude_path}"
863 break
865 if should_include:
866 filtered_files.append(file_path)
867 else:
868 excluded_files.append((str(file_path), exclusion_reason))
870 # Log verification information if debug mode is enabled
871 if debug:
872 console.print(f"[blue]File collection verification:[/]")
873 console.print(f"[blue]- Total files found: {len(files)}[/]")
874 console.print(f"[blue]- Files included: {len(filtered_files)}[/]")
875 console.print(f"[blue]- Files excluded: {len(excluded_files)}[/]")
877 # Log a sample of excluded files (up to 5) with reasons
878 if excluded_files and len(excluded_files) > 0:
879 console.print("[blue]Sample of excluded files:[/]")
880 for i, (file, reason) in enumerate(excluded_files[:5]):
881 console.print(f"[blue] {i+1}. {file} - {reason}[/]")
882 if len(excluded_files) > 5:
883 console.print(f"[blue] ... and {len(excluded_files) - 5} more[/]")
885 return filtered_files
887 # Replace the method
888 analyzer._collect_files = custom_collect_files
890 if debug:
891 console.print(f"[blue]Using custom file collection with filters[/]")
893 fs_results = analyzer.analyze(path)
894 results.append(fs_results)
896 # Combine results
897 combined_results = _combine_results(results)
899 if debug:
900 console.print("[blue]Analysis complete, writing results...[/]")
902 # Write results
903 result_file = output_path / f'analysis.{format}'
904 try:
905 # Ensure output directory exists
906 output_path.mkdir(parents=True, exist_ok=True)
908 content = combined_results.to_json() if format == 'json' else combined_results.to_text()
909 result_file.write_text(content, encoding='utf-8')
910 except Exception as e:
911 console.print(f"[red]Error writing results: {str(e)}[/]")
912 return 1
914 console.print(f"[bold green]✨ Analysis saved to {result_file}[/]")
916 # Handle full content export
917 if full:
918 console.print("[bold blue]📦 Exporting full contents...[/]")
919 try:
920 ignore_patterns = parse_ignore_file(Path('.llmclignore')) + list(exclude)
921 export_full_content(path, output_path, ignore_patterns, exclude_paths)
922 console.print("[bold green]✨ Full content export complete![/]")
923 except Exception as e:
924 console.print(f"[yellow]Warning during full export: {str(e)}[/]")
925 if debug:
926 console.print(traceback.format_exc())
928 # Open in LLM if requested and not 'none'
929 if open_in_llm and open_in_llm.lower() != 'none':
930 console.print(f"[bold blue]🌐 Opening results in {open_in_llm}...[/]")
931 if open_in_llm_provider(open_in_llm, output_path, debug):
932 console.print(f"[bold green]✨ Results opened in {open_in_llm}![/]")
933 else:
934 console.print(f"[yellow]Failed to open results in {open_in_llm}[/]")
936 # Friendly message to prompt users to give a star
937 console.print("\n [bold yellow] ⭐⭐⭐⭐⭐ If you like this tool, please consider giving it a star on GitHub![/]")
938 console.print("[bold blue]Visit: https://github.com/SikamikanikoBG/codelens.git[/]")
940 return 0
942 except Exception as e:
943 console.print("[bold red]Error occurred:[/]")
944 if debug:
945 console.print(traceback.format_exc())
946 else:
947 console.print(f"[bold red]Error: {str(e)}[/]")
948 return 1
950if __name__ == '__main__':
951 main()