reveal-cli 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
reveal/base.py ADDED
@@ -0,0 +1,267 @@
1
+ """Base analyzer class for reveal - clean, simple design."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional, Dict, Any, List
6
+ import hashlib
7
+
8
+
9
+ class FileAnalyzer:
10
+ """Base class for all file analyzers.
11
+
12
+ Provides automatic functionality:
13
+ - File reading with encoding detection
14
+ - Metadata extraction
15
+ - Line number formatting
16
+ - Source extraction helpers
17
+
18
+ Subclasses only need to implement:
19
+ - get_structure(): Return dict of file elements
20
+ - extract_element(type, name): Extract specific element (optional)
21
+ """
22
+
23
+ def __init__(self, path: str):
24
+ self.path = Path(path)
25
+ self.lines = self._read_file()
26
+ self.content = '\n'.join(self.lines)
27
+
28
+ def _read_file(self) -> List[str]:
29
+ """Read file with automatic encoding detection."""
30
+ encodings = ['utf-8', 'utf-8-sig', 'latin-1', 'cp1252']
31
+
32
+ for encoding in encodings:
33
+ try:
34
+ with open(self.path, 'r', encoding=encoding) as f:
35
+ return f.read().splitlines()
36
+ except (UnicodeDecodeError, LookupError):
37
+ continue
38
+
39
+ # Last resort: read as binary and decode with errors='replace'
40
+ with open(self.path, 'rb') as f:
41
+ content = f.read().decode('utf-8', errors='replace')
42
+ return content.splitlines()
43
+
44
+ def get_metadata(self) -> Dict[str, Any]:
45
+ """Return file metadata.
46
+
47
+ Automatic - works for all file types.
48
+ """
49
+ stat = os.stat(self.path)
50
+
51
+ return {
52
+ 'path': str(self.path),
53
+ 'name': self.path.name,
54
+ 'size': stat.st_size,
55
+ 'size_human': self._format_size(stat.st_size),
56
+ 'lines': len(self.lines),
57
+ 'encoding': self._detect_encoding(),
58
+ }
59
+
60
+ def get_structure(self) -> Dict[str, List[Dict[str, Any]]]:
61
+ """Return file structure (imports, functions, classes, etc.).
62
+
63
+ Override in subclasses for custom extraction.
64
+ Default: Returns empty structure.
65
+ """
66
+ return {}
67
+
68
+ def extract_element(self, element_type: str, name: str) -> Optional[Dict[str, Any]]:
69
+ """Extract a specific element from the file.
70
+
71
+ Args:
72
+ element_type: Type of element ('function', 'class', 'section', etc.)
73
+ name: Name of the element
74
+
75
+ Returns:
76
+ Dict with 'line_start', 'line_end', 'source', etc. or None
77
+
78
+ Override in subclasses for semantic extraction.
79
+ Default: Falls back to grep-based search.
80
+ """
81
+ # Default: simple grep-based extraction
82
+ return self._grep_extract(name)
83
+
84
+ def _grep_extract(self, name: str) -> Optional[Dict[str, Any]]:
85
+ """Fallback: Extract by grepping for name."""
86
+ for i, line in enumerate(self.lines, 1):
87
+ if name in line:
88
+ # Found it - extract this line and a few after
89
+ line_start = i
90
+ line_end = min(i + 20, len(self.lines)) # Up to 20 lines
91
+
92
+ return {
93
+ 'name': name,
94
+ 'line_start': line_start,
95
+ 'line_end': line_end,
96
+ 'source': '\n'.join(self.lines[line_start-1:line_end]),
97
+ }
98
+ return None
99
+
100
+ def format_with_lines(self, source: str, start_line: int) -> str:
101
+ """Format source code with line numbers.
102
+
103
+ Args:
104
+ source: Source code to format
105
+ start_line: Starting line number
106
+
107
+ Returns:
108
+ Formatted string with line numbers
109
+ """
110
+ lines = source.split('\n')
111
+ result = []
112
+
113
+ for i, line in enumerate(lines):
114
+ line_num = start_line + i
115
+ result.append(f" {line_num:4d} {line}")
116
+
117
+ return '\n'.join(result)
118
+
119
+ def _format_size(self, size: int) -> str:
120
+ """Format file size in human-readable form."""
121
+ for unit in ['B', 'KB', 'MB', 'GB']:
122
+ if size < 1024.0:
123
+ return f"{size:.1f} {unit}"
124
+ size /= 1024.0
125
+ return f"{size:.1f} TB"
126
+
127
+ def _detect_encoding(self) -> str:
128
+ """Detect file encoding."""
129
+ # Simple heuristic for now
130
+ try:
131
+ self.content.encode('ascii')
132
+ return 'ASCII'
133
+ except UnicodeEncodeError:
134
+ return 'UTF-8'
135
+
136
+ def get_directory_entry(self) -> Dict[str, Any]:
137
+ """Return info for directory listing.
138
+
139
+ Automatic - works for all file types.
140
+ """
141
+ meta = self.get_metadata()
142
+ file_type = self.__class__.__name__.replace('Analyzer', '')
143
+
144
+ return {
145
+ 'path': str(self.path),
146
+ 'name': self.path.name,
147
+ 'size': meta['size_human'],
148
+ 'lines': meta['lines'],
149
+ 'type': file_type,
150
+ }
151
+
152
+
153
+ # Registry for file type analyzers
154
+ _ANALYZER_REGISTRY: Dict[str, type] = {}
155
+
156
+
157
+ def register(*extensions, name: str = '', icon: str = 'šŸ“„'):
158
+ """Decorator to register an analyzer for file extensions.
159
+
160
+ Usage:
161
+ @register('.py', name='Python', icon='šŸ')
162
+ class PythonAnalyzer(FileAnalyzer):
163
+ ...
164
+
165
+ Args:
166
+ extensions: File extensions to register (e.g., '.py', '.rs')
167
+ name: Display name for this file type
168
+ icon: Emoji icon for this file type
169
+ """
170
+ def decorator(cls):
171
+ for ext in extensions:
172
+ _ANALYZER_REGISTRY[ext.lower()] = cls
173
+
174
+ # Store metadata on class
175
+ cls.type_name = name or cls.__name__.replace('Analyzer', '')
176
+ cls.icon = icon
177
+
178
+ return cls
179
+
180
+ return decorator
181
+
182
+
183
+ def get_analyzer(path: str) -> Optional[type]:
184
+ """Get analyzer class for a file path.
185
+
186
+ Args:
187
+ path: File path
188
+
189
+ Returns:
190
+ Analyzer class or None if not found
191
+ """
192
+ file_path = Path(path)
193
+ ext = file_path.suffix.lower()
194
+
195
+ # If we have an extension, use it
196
+ if ext and ext in _ANALYZER_REGISTRY:
197
+ return _ANALYZER_REGISTRY.get(ext)
198
+
199
+ # No extension or not found - check special filenames (Dockerfile, Makefile)
200
+ filename = file_path.name.lower()
201
+ if filename in _ANALYZER_REGISTRY:
202
+ return _ANALYZER_REGISTRY.get(filename)
203
+
204
+ # Still no match - check shebang for extensionless scripts
205
+ if not ext or ext not in _ANALYZER_REGISTRY:
206
+ shebang_ext = _detect_shebang(path)
207
+ if shebang_ext:
208
+ return _ANALYZER_REGISTRY.get(shebang_ext)
209
+
210
+ return None
211
+
212
+
213
+ def _detect_shebang(path: str) -> Optional[str]:
214
+ """Detect file type from shebang line.
215
+
216
+ Args:
217
+ path: File path
218
+
219
+ Returns:
220
+ Extension to use (e.g., '.py', '.sh') or None
221
+ """
222
+ try:
223
+ with open(path, 'rb') as f:
224
+ first_line = f.readline()
225
+
226
+ # Decode with error handling
227
+ try:
228
+ shebang = first_line.decode('utf-8', errors='ignore').strip()
229
+ except:
230
+ return None
231
+
232
+ if not shebang.startswith('#!'):
233
+ return None
234
+
235
+ # Map shebangs to extensions
236
+ shebang_lower = shebang.lower()
237
+
238
+ # Python
239
+ if 'python' in shebang_lower:
240
+ return '.py'
241
+
242
+ # Shell scripts (bash, sh, zsh)
243
+ if any(shell in shebang_lower for shell in ['bash', '/sh', 'zsh']):
244
+ return '.sh'
245
+
246
+ return None
247
+
248
+ except (IOError, OSError):
249
+ return None
250
+
251
+
252
+ def get_all_analyzers() -> Dict[str, Dict[str, Any]]:
253
+ """Get all registered analyzers with metadata.
254
+
255
+ Returns:
256
+ Dict mapping extension to analyzer metadata
257
+ e.g., {'.py': {'name': 'Python', 'icon': 'šŸ', 'class': PythonAnalyzer}}
258
+ """
259
+ result = {}
260
+ for ext, cls in _ANALYZER_REGISTRY.items():
261
+ result[ext] = {
262
+ 'extension': ext,
263
+ 'name': getattr(cls, 'type_name', cls.__name__.replace('Analyzer', '')),
264
+ 'icon': getattr(cls, 'icon', 'šŸ“„'),
265
+ 'class': cls,
266
+ }
267
+ return result
reveal/main.py ADDED
@@ -0,0 +1,355 @@
1
+ """Clean, simple CLI for reveal."""
2
+
3
+ import sys
4
+ import os
5
+ import argparse
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ from datetime import datetime, timedelta
9
+ from .base import get_analyzer, get_all_analyzers, FileAnalyzer
10
+ from .tree_view import show_directory_tree
11
+ from . import __version__
12
+
13
+
14
+ def check_for_updates():
15
+ """Check PyPI for newer version (once per day, non-blocking).
16
+
17
+ - Checks at most once per day (cached in ~/.config/reveal/last_update_check)
18
+ - 1-second timeout (doesn't slow down CLI)
19
+ - Fails silently (no errors shown to user)
20
+ - Opt-out: Set REVEAL_NO_UPDATE_CHECK=1 environment variable
21
+ """
22
+ # Opt-out check
23
+ if os.environ.get('REVEAL_NO_UPDATE_CHECK'):
24
+ return
25
+
26
+ try:
27
+ # Setup cache directory
28
+ cache_dir = Path.home() / '.config' / 'reveal'
29
+ cache_dir.mkdir(parents=True, exist_ok=True)
30
+ cache_file = cache_dir / 'last_update_check'
31
+
32
+ # Check if we should update (once per day)
33
+ if cache_file.exists():
34
+ last_check_str = cache_file.read_text().strip()
35
+ try:
36
+ last_check = datetime.fromisoformat(last_check_str)
37
+ if datetime.now() - last_check < timedelta(days=1):
38
+ return # Checked recently, skip
39
+ except (ValueError, OSError):
40
+ pass # Invalid cache, continue with check
41
+
42
+ # Check PyPI (using urllib to avoid new dependencies)
43
+ import urllib.request
44
+ import json
45
+
46
+ req = urllib.request.Request(
47
+ 'https://pypi.org/pypi/reveal-cli/json',
48
+ headers={'User-Agent': f'reveal-cli/{__version__}'}
49
+ )
50
+
51
+ with urllib.request.urlopen(req, timeout=1) as response:
52
+ data = json.loads(response.read().decode('utf-8'))
53
+ latest_version = data['info']['version']
54
+
55
+ # Update cache file
56
+ cache_file.write_text(datetime.now().isoformat())
57
+
58
+ # Compare versions (simple string comparison works for semver)
59
+ if latest_version != __version__:
60
+ # Parse versions for proper comparison
61
+ def parse_version(v):
62
+ return tuple(map(int, v.split('.')))
63
+
64
+ try:
65
+ if parse_version(latest_version) > parse_version(__version__):
66
+ print(f"āš ļø Update available: reveal {latest_version} (you have {__version__})")
67
+ print(f"šŸ’” Update: pip install --upgrade reveal-cli\n")
68
+ except (ValueError, AttributeError):
69
+ pass # Version comparison failed, ignore
70
+
71
+ except Exception:
72
+ # Fail silently - don't interrupt user's workflow
73
+ pass
74
+
75
+
76
+ def main():
77
+ """Main CLI entry point."""
78
+ # Fix Windows console encoding for emoji/unicode support
79
+ if sys.platform == 'win32':
80
+ # Set environment variable for subprocess compatibility
81
+ os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
82
+ # Reconfigure stdout/stderr to use UTF-8 with error handling
83
+ if hasattr(sys.stdout, 'reconfigure'):
84
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
85
+ if hasattr(sys.stderr, 'reconfigure'):
86
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
87
+
88
+ _main_impl()
89
+
90
+
91
+ def _main_impl():
92
+ """Main CLI entry point."""
93
+ parser = argparse.ArgumentParser(
94
+ description='Reveal: Explore code semantically - The simplest way to understand code',
95
+ formatter_class=argparse.RawDescriptionHelpFormatter,
96
+ epilog='''
97
+ Examples:
98
+ # Directory exploration
99
+ reveal src/ # Show directory tree
100
+ reveal src/ --depth=5 # Deeper tree view
101
+
102
+ # File structure
103
+ reveal app.py # Show structure (imports, functions, classes)
104
+ reveal app.py --meta # Show metadata (size, lines, encoding)
105
+ reveal player.gd # GDScript support (Godot engine)
106
+
107
+ # Element extraction
108
+ reveal app.py load_config # Extract specific function
109
+ reveal app.py Database # Extract class definition
110
+
111
+ # Output formats
112
+ reveal app.py --format=json # JSON output for scripting
113
+ reveal app.py --format=grep # Grep-compatible format
114
+
115
+ # Discovery
116
+ reveal --list-supported # Show all supported file types
117
+ reveal --version # Show version
118
+
119
+ Perfect filename:line integration - works with vim, git, grep, sed, awk!
120
+ '''
121
+ )
122
+
123
+ parser.add_argument('path', nargs='?', help='File or directory to reveal')
124
+ parser.add_argument('element', nargs='?', help='Element to extract (function, class, etc.)')
125
+
126
+ # Optional flags
127
+ parser.add_argument('--version', action='version', version=f'reveal {__version__}')
128
+ parser.add_argument('--list-supported', '-l', action='store_true',
129
+ help='List all supported file types')
130
+ parser.add_argument('--meta', action='store_true', help='Show metadata only')
131
+ parser.add_argument('--format', choices=['text', 'json', 'grep'], default='text',
132
+ help='Output format (text, json, grep)')
133
+ parser.add_argument('--depth', type=int, default=3, help='Directory tree depth (default: 3)')
134
+
135
+ args = parser.parse_args()
136
+
137
+ # Check for updates (once per day, non-blocking, opt-out available)
138
+ check_for_updates()
139
+
140
+ # Handle --list-supported
141
+ if args.list_supported:
142
+ list_supported_types()
143
+ sys.exit(0)
144
+
145
+ # Path is required if not using --list-supported
146
+ if not args.path:
147
+ parser.print_help()
148
+ sys.exit(1)
149
+
150
+ # Check if path exists
151
+ path = Path(args.path)
152
+ if not path.exists():
153
+ print(f"Error: {args.path} not found", file=sys.stderr)
154
+ sys.exit(1)
155
+
156
+ # Route based on path type
157
+ if path.is_dir():
158
+ # Directory → show tree
159
+ output = show_directory_tree(str(path), depth=args.depth)
160
+ print(output)
161
+
162
+ elif path.is_file():
163
+ # File → show structure or extract element
164
+ handle_file(str(path), args.element, args.meta, args.format)
165
+
166
+ else:
167
+ print(f"Error: {args.path} is neither file nor directory", file=sys.stderr)
168
+ sys.exit(1)
169
+
170
+
171
+ def list_supported_types():
172
+ """List all supported file types."""
173
+ analyzers = get_all_analyzers()
174
+
175
+ if not analyzers:
176
+ print("No file types registered")
177
+ return
178
+
179
+ print(f"šŸ“‹ Reveal v{__version__} - Supported File Types\n")
180
+
181
+ # Sort by name for nice display
182
+ sorted_analyzers = sorted(analyzers.items(), key=lambda x: x[1]['name'])
183
+
184
+ for ext, info in sorted_analyzers:
185
+ icon = info['icon']
186
+ name = info['name']
187
+ print(f" {icon} {name:15s} ({ext})")
188
+
189
+ print(f"\n✨ Total: {len(analyzers)} file types supported")
190
+ print(f"\nšŸ’” Use 'reveal <file>' to explore any supported file")
191
+ print(f"šŸ’” Use 'reveal --help' for usage examples")
192
+
193
+
194
+ def handle_file(path: str, element: Optional[str], show_meta: bool, output_format: str):
195
+ """Handle file analysis.
196
+
197
+ Args:
198
+ path: File path
199
+ element: Optional element to extract
200
+ show_meta: Whether to show metadata only
201
+ output_format: Output format ('text', 'json', 'grep')
202
+ """
203
+ # Get analyzer
204
+ analyzer_class = get_analyzer(path)
205
+ if not analyzer_class:
206
+ ext = Path(path).suffix or '(no extension)'
207
+ print(f"Error: No analyzer found for {path} ({ext})", file=sys.stderr)
208
+ print(f"\nšŸ’” Hint: File type '{ext}' is not supported yet", file=sys.stderr)
209
+ print(f"šŸ’” Run 'reveal --list-supported' to see all supported file types", file=sys.stderr)
210
+ print(f"šŸ’” Visit https://github.com/scottsen/reveal to request new file types", file=sys.stderr)
211
+ sys.exit(1)
212
+
213
+ analyzer = analyzer_class(path)
214
+
215
+ # Show metadata only?
216
+ if show_meta:
217
+ show_metadata(analyzer, output_format)
218
+ return
219
+
220
+ # Extract specific element?
221
+ if element:
222
+ extract_element(analyzer, element, output_format)
223
+ return
224
+
225
+ # Default: show structure
226
+ show_structure(analyzer, output_format)
227
+
228
+
229
+ def show_metadata(analyzer: FileAnalyzer, output_format: str):
230
+ """Show file metadata."""
231
+ meta = analyzer.get_metadata()
232
+
233
+ if output_format == 'json':
234
+ import json
235
+ print(json.dumps(meta, indent=2))
236
+ else:
237
+ print(f"šŸ“„ {meta['name']}\n")
238
+ print(f"Path: {meta['path']}")
239
+ print(f"Size: {meta['size_human']}")
240
+ print(f"Lines: {meta['lines']}")
241
+ print(f"Encoding: {meta['encoding']}")
242
+ print(f"\n→ reveal {meta['path']}")
243
+
244
+
245
+ def show_structure(analyzer: FileAnalyzer, output_format: str):
246
+ """Show file structure."""
247
+ structure = analyzer.get_structure()
248
+ path = analyzer.path
249
+
250
+ if output_format == 'json':
251
+ import json
252
+ print(json.dumps(structure, indent=2))
253
+ return
254
+
255
+ if not structure:
256
+ print(f"šŸ“„ {path.name}\n")
257
+ print("No structure available for this file type")
258
+ return
259
+
260
+ print(f"šŸ“„ {path.name}\n")
261
+
262
+ # Show each category
263
+ for category, items in structure.items():
264
+ if not items:
265
+ continue
266
+
267
+ # Format category name (e.g., 'functions' → 'Functions')
268
+ category_name = category.capitalize()
269
+ print(f"{category_name} ({len(items)}):")
270
+
271
+ for item in items:
272
+ line = item.get('line', '?')
273
+ name = item.get('name', '')
274
+ signature = item.get('signature', '')
275
+ content = item.get('content', '')
276
+
277
+ # Format based on what's available
278
+ if signature and name:
279
+ # Function with signature
280
+ if output_format == 'grep':
281
+ print(f"{path}:{line}:{name}{signature}")
282
+ else:
283
+ print(f" {path}:{line:<6} {name}{signature}")
284
+ elif name:
285
+ # Just name (class, struct, etc.)
286
+ if output_format == 'grep':
287
+ print(f"{path}:{line}:{name}")
288
+ else:
289
+ print(f" {path}:{line:<6} {name}")
290
+ elif content:
291
+ # Just content (import, etc.)
292
+ if output_format == 'grep':
293
+ print(f"{path}:{line}:{content}")
294
+ else:
295
+ print(f" {path}:{line:<6} {content}")
296
+
297
+ print() # Blank line between categories
298
+
299
+ # Navigation hints
300
+ if output_format == 'text':
301
+ print(f"→ reveal {path} <element>")
302
+ print(f"→ vim {path}:<line>")
303
+
304
+
305
+ def extract_element(analyzer: FileAnalyzer, element: str, output_format: str):
306
+ """Extract a specific element.
307
+
308
+ Args:
309
+ analyzer: File analyzer
310
+ element: Element name to extract
311
+ output_format: Output format
312
+ """
313
+ # Try common element types
314
+ for element_type in ['function', 'class', 'struct', 'section']:
315
+ result = analyzer.extract_element(element_type, element)
316
+ if result:
317
+ break
318
+ else:
319
+ # Not found
320
+ print(f"Error: Element '{element}' not found in {analyzer.path}", file=sys.stderr)
321
+ sys.exit(1)
322
+
323
+ # Format output
324
+ if output_format == 'json':
325
+ import json
326
+ print(json.dumps(result, indent=2))
327
+ return
328
+
329
+ path = analyzer.path
330
+ line_start = result.get('line_start', 1)
331
+ line_end = result.get('line_end', line_start)
332
+ source = result.get('source', '')
333
+ name = result.get('name', element)
334
+
335
+ # Header
336
+ print(f"{path}:{line_start}-{line_end} | {name}\n")
337
+
338
+ # Source with line numbers
339
+ if output_format == 'grep':
340
+ # Grep format: filename:linenum:content
341
+ for i, line in enumerate(source.split('\n')):
342
+ line_num = line_start + i
343
+ print(f"{path}:{line_num}:{line}")
344
+ else:
345
+ # Human-readable format
346
+ formatted = analyzer.format_with_lines(source, line_start)
347
+ print(formatted)
348
+
349
+ # Navigation hints
350
+ print(f"\n→ vim {path}:{line_start}")
351
+ print(f"→ reveal {path}")
352
+
353
+
354
+ if __name__ == '__main__':
355
+ main()
@@ -0,0 +1 @@
1
+ """Test suite for reveal."""