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

1#!/usr/bin/env python3 

2""" 

3LLM Code Lens - CLI Module 

4Handles command-line interface and coordination of analysis components. 

5""" 

6 

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 

23 

24console = Console() 

25 

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 [] 

30 

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}") 

41 

42 return patterns 

43 

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 = [] 

48 

49 path_str = str(path) 

50 

51 # First check gitignore patterns if parser is provided 

52 if gitignore_parser and gitignore_parser.should_ignore(path): 

53 return True 

54 

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', 

60 

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', 

66 

67 # Package lock files 

68 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 

69 'Gemfile.lock', 'composer.lock', 'composer.json', 

70 

71 # Config files 

72 'tsconfig.json', 'jsconfig.json', 

73 

74 # System files 

75 '.DS_Store', 

76 

77 # Log files 

78 '*.log', 'npm-debug.log', 'yarn-error.log', 

79 

80 # Temp/backup files 

81 '*.tmp', '*.bak', '*.swp', '*.swo', '*.orig', 

82 

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', 

89 

90 # Document build files 

91 '*.aux', '*.toc', '*.out', '*.dvi', '*.ps', '*.pdf', '*.lof', '*.lot', 

92 '*.fls', '*.fdb_latexmk', '*.synctex.gz', 

93 

94 # Source files that shouldn't normally be ignored 

95 '*.go', 

96 

97 # Project files 

98 '*.csproj', '*.user', '*.suo', '*.sln.docstates', '*.xcodeproj', '*.xcworkspace', 

99 

100 # CSS files 

101 '*.css.map', '*.min.css', 

102 

103 # R files 

104 '.Rhistory', '.RData', '*.Rout', 

105 

106 # Utility files 

107 'pnp.loader.mjs' 

108 } 

109 

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 

115 

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 

120 

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 

128 

129 return False 

130 

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 

141 

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. 

146 

147 Args: 

148 content (str): The content to split 

149 chunk_size (int): Target size for each chunk in tokens 

150 

151 Returns: 

152 List[str]: List of content chunks 

153 """ 

154 if not content: 

155 return [''] 

156 

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 = [] 

161 

162 for i in range(0, len(content), MAX_CHUNK_CHARS): 

163 rough_chunks.append(content[i:i + MAX_CHUNK_CHARS]) 

164 

165 encoder = tiktoken.get_encoding("cl100k_base") 

166 final_chunks = [] 

167 

168 # Process each rough chunk 

169 for rough_chunk in rough_chunks: 

170 tokens = encoder.encode(rough_chunk) 

171 

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) 

177 

178 return final_chunks 

179 

180 except Exception as e: 

181 # Fallback to line-based splitting 

182 return _split_by_lines(content, max_chunk_size=chunk_size) 

183 

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 [""] 

189 

190 lines = content.splitlines(keepends=True) # Keep line endings 

191 chunks = [] 

192 current_chunk = [] 

193 current_size = 0 

194 

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 

204 

205 if current_chunk: 

206 chunks.append(''.join(current_chunk)) 

207 

208 # Handle special case where we got no chunks 

209 if not chunks: 

210 return [content] # Return entire content as one chunk 

211 

212 return chunks 

213 

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 

226 

227 # Delete the directory 

228 shutil.rmtree(output_dir) 

229 

230 # Recreate the directory 

231 output_dir.mkdir(parents=True, exist_ok=True) 

232 

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) 

242 

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 [] 

247 

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") 

252 

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 

258 

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 

265 

266 if should_exclude: 

267 continue 

268 

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 

275 

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") 

281 

282 # Combine all content 

283 full_content = "\n".join(file_content) 

284 

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)}[/]") 

294 

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 

298 

299 summary_parts = [] 

300 

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'])}") 

311 

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']}") 

316 

317 return '\n'.join(summary_parts) 

318 

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 = {} 

323 

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 

328 

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 

333 

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 

343 

344 return '\n'.join(samples) 

345 

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 = [] 

349 

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) 

358 

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) 

367 

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) 

376 

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) 

381 

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)}[/]") 

389 

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) 

396 

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] 

404 

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) 

411 

412 # Update maintenance info 

413 maintenance = result.get('summary', {}).get('maintenance', {}) 

414 combined['summary']['maintenance']['todos'].extend(maintenance.get('todos', [])) 

415 

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) 

422 

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']) 

428 

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 } 

461 

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) 

478 

479 # Calculate final metrics 

480 total_items = (combined['summary']['project_stats']['total_files'] + 

481 combined['summary']['project_stats']['total_sql_objects']) 

482 

483 if total_items > 0: 

484 combined['summary']['project_stats']['avg_file_size'] = ( 

485 combined['summary']['project_stats']['lines_of_code'] / total_items 

486 ) 

487 

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 ) 

495 

496 return AnalysisResult(**combined) 

497 

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', [])) 

504 

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 

510 

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 

521 

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. 

525 

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 

530 

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 

538 

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 

548 

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. 

552 

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. 

554 

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. 

556 

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. 

558 

559In my next message, I'll tell you about a new request or question about this code. 

560""" 

561 

562 # Prepare the complete message with files included 

563 full_message = system_prompt + "\n\n" 

564 

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" 

569 

570 # Check if full export is enabled by looking for full_*.txt files 

571 full_files = list(output_path.glob('full_*.txt')) 

572 

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" 

577 

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" 

582 

583 # Copy the full message to clipboard (for all providers as backup) 

584 pyperclip.copy(full_message) 

585 

586 # Open the appropriate provider 

587 if provider.lower() == 'claude': 

588 # Open Claude in a new chat 

589 webbrowser.open("https://claude.ai/new") 

590 

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 

595 

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) 

601 

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) 

608 

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/") 

615 

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![/]") 

620 

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[/]") 

625 

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[/]") 

632 

633 webbrowser.open("https://chat.openai.com/") 

634 

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 

639 

640 elif provider.lower() == 'gemini': 

641 # Open Gemini 

642 webbrowser.open("https://gemini.google.com/") 

643 

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 

648 

649 else: 

650 console.print(f"[yellow]Unsupported LLM provider: {provider}[/]") 

651 return False 

652 

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 

658 

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() 

682 

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() 

690 

691 if debug and gitignore_patterns: 

692 console.print(f"[blue]Loaded {len(gitignore_patterns)} patterns from .gitignore[/]") 

693 

694 # Combine gitignore patterns with custom exclude patterns 

695 all_ignore_patterns = list(exclude) + gitignore_patterns 

696 

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 } 

709 

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) 

716 

717 # Check if user cancelled 

718 if settings.get('cancelled', False): 

719 console.print("[yellow]Operation cancelled by user[/]") 

720 return 0 

721 

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', []) 

726 

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) 

736 

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...[/]") 

752 

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 

759 

760 if debug: 

761 console.print(f"[blue]Output directory: {output_path}[/]") 

762 

763 # Rest of the main function remains unchanged 

764 results = [] 

765 

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') 

773 

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()) 

781 

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() 

788 

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) 

795 

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) 

806 

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.[/]") 

815 

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.[/]") 

820 

821 # Check for newer version (non-blocking) 

822 check_for_newer_version() 

823 

824 # Run file system analysis 

825 console.print("[bold blue]📁 Starting File System Analysis...[/]") 

826 analyzer = ProjectAnalyzer() 

827 

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) 

838 

839 # Apply include/exclude filters 

840 filtered_files = [] 

841 excluded_files = [] 

842 

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 

847 

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" 

857 

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 

864 

865 if should_include: 

866 filtered_files.append(file_path) 

867 else: 

868 excluded_files.append((str(file_path), exclusion_reason)) 

869 

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)}[/]") 

876 

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[/]") 

884 

885 return filtered_files 

886 

887 # Replace the method 

888 analyzer._collect_files = custom_collect_files 

889 

890 if debug: 

891 console.print(f"[blue]Using custom file collection with filters[/]") 

892 

893 fs_results = analyzer.analyze(path) 

894 results.append(fs_results) 

895 

896 # Combine results 

897 combined_results = _combine_results(results) 

898 

899 if debug: 

900 console.print("[blue]Analysis complete, writing results...[/]") 

901 

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) 

907 

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 

913 

914 console.print(f"[bold green]✨ Analysis saved to {result_file}[/]") 

915 

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()) 

927 

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}[/]") 

935 

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[/]") 

939 

940 return 0 

941 

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 

949 

950if __name__ == '__main__': 

951 main()