reveal-cli 0.2.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.
- plugins/c-header.yaml +89 -0
- plugins/gdscript.yaml +96 -0
- plugins/python.yaml +94 -0
- plugins/yaml.yaml +87 -0
- reveal/__init__.py +20 -0
- reveal/_archive_old_v0.1/analyzers/__init__.py +39 -0
- reveal/_archive_old_v0.1/analyzers/base.py +143 -0
- reveal/_archive_old_v0.1/analyzers/bash_analyzer.py +202 -0
- reveal/_archive_old_v0.1/analyzers/csharp_analyzer.py +167 -0
- reveal/_archive_old_v0.1/analyzers/gdscript_analyzer.py +201 -0
- reveal/_archive_old_v0.1/analyzers/go_analyzer.py +230 -0
- reveal/_archive_old_v0.1/analyzers/javascript_analyzer.py +272 -0
- reveal/_archive_old_v0.1/analyzers/json_analyzer.py +143 -0
- reveal/_archive_old_v0.1/analyzers/markdown_analyzer.py +116 -0
- reveal/_archive_old_v0.1/analyzers/php_analyzer.py +166 -0
- reveal/_archive_old_v0.1/analyzers/python_analyzer.py +118 -0
- reveal/_archive_old_v0.1/analyzers/rust_analyzer.py +202 -0
- reveal/_archive_old_v0.1/analyzers/sql_analyzer.py +534 -0
- reveal/_archive_old_v0.1/analyzers/text_analyzer.py +35 -0
- reveal/_archive_old_v0.1/analyzers/toml_analyzer.py +137 -0
- reveal/_archive_old_v0.1/analyzers/treesitter_base.py +276 -0
- reveal/_archive_old_v0.1/analyzers/yaml_analyzer.py +127 -0
- reveal/_archive_old_v0.1/breadcrumbs.py +147 -0
- reveal/_archive_old_v0.1/cli.py +445 -0
- reveal/_archive_old_v0.1/core.py +136 -0
- reveal/_archive_old_v0.1/detectors.py +29 -0
- reveal/_archive_old_v0.1/formatters.py +272 -0
- reveal/_archive_old_v0.1/grep_filter.py +85 -0
- reveal/_archive_old_v0.1/plugin_loader.py +178 -0
- reveal/_archive_old_v0.1/registry.py +286 -0
- reveal/analyzers/__init__.py +23 -0
- reveal/analyzers/go.py +13 -0
- reveal/analyzers/jupyter_analyzer.py +226 -0
- reveal/analyzers/markdown.py +79 -0
- reveal/analyzers/python.py +15 -0
- reveal/analyzers/rust.py +13 -0
- reveal/analyzers/yaml_json.py +110 -0
- reveal/base.py +193 -0
- reveal/main.py +216 -0
- reveal/tests/__init__.py +1 -0
- reveal/tests/test_json_yaml_line_numbers.py +238 -0
- reveal/tests/test_line_numbers.py +151 -0
- reveal/tests/test_toml_analyzer.py +220 -0
- reveal/tree_view.py +105 -0
- reveal/treesitter.py +282 -0
- reveal_cli-0.2.0.dist-info/METADATA +319 -0
- reveal_cli-0.2.0.dist-info/RECORD +51 -0
- reveal_cli-0.2.0.dist-info/WHEEL +5 -0
- reveal_cli-0.2.0.dist-info/entry_points.txt +2 -0
- reveal_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- reveal_cli-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Command-line interface for Progressive Reveal."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import argparse
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Dict, Any
|
|
7
|
+
|
|
8
|
+
from .core import FileSummary, create_file_summary
|
|
9
|
+
from .analyzers import TextAnalyzer # Fallback only
|
|
10
|
+
from .formatters import format_metadata, format_structure, format_preview, format_full_content
|
|
11
|
+
from .grep_filter import apply_grep_filter
|
|
12
|
+
from .registry import get_analyzer as get_analyzer_for_file
|
|
13
|
+
from .detectors import detect_file_type
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_file_location(file_arg: str) -> tuple[str, Optional[int], Optional[int]]:
|
|
18
|
+
"""
|
|
19
|
+
Parse file argument that may include line numbers.
|
|
20
|
+
|
|
21
|
+
Supports:
|
|
22
|
+
- file.sql → (file.sql, None, None)
|
|
23
|
+
- file.sql:32 → (file.sql, 32, 32)
|
|
24
|
+
- file.sql:10-50 → (file.sql, 10, 50)
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
file_arg: File argument from command line
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Tuple of (file_path, start_line, end_line)
|
|
31
|
+
"""
|
|
32
|
+
if ':' not in file_arg:
|
|
33
|
+
return file_arg, None, None
|
|
34
|
+
|
|
35
|
+
# Split on last colon (handles paths like C:\Users\file.txt on Windows)
|
|
36
|
+
file_path, line_spec = file_arg.rsplit(':', 1)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
if '-' in line_spec:
|
|
40
|
+
# Range: file.sql:10-50
|
|
41
|
+
start_str, end_str = line_spec.split('-', 1)
|
|
42
|
+
start_line = int(start_str) if start_str else 1
|
|
43
|
+
end_line = int(end_str) if end_str else None
|
|
44
|
+
return file_path, start_line, end_line
|
|
45
|
+
else:
|
|
46
|
+
# Single line: file.sql:32
|
|
47
|
+
line_num = int(line_spec)
|
|
48
|
+
return file_path, line_num, line_num
|
|
49
|
+
except ValueError:
|
|
50
|
+
# Not a valid line number, treat whole thing as file path
|
|
51
|
+
return file_arg, None, None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_analyzer(file_path: str, lines: List[str],
|
|
55
|
+
focus_start: Optional[int] = None,
|
|
56
|
+
focus_end: Optional[int] = None):
|
|
57
|
+
"""
|
|
58
|
+
Get appropriate analyzer for file.
|
|
59
|
+
|
|
60
|
+
Uses the plugin registry to find registered analyzers.
|
|
61
|
+
Falls back to TextAnalyzer if no specific analyzer is registered.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
file_path: Path to the file (used to determine extension)
|
|
65
|
+
lines: File content lines
|
|
66
|
+
focus_start: Optional start line for focused analysis
|
|
67
|
+
focus_end: Optional end line for focused analysis
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Analyzer instance
|
|
71
|
+
"""
|
|
72
|
+
# Get analyzer class from registry (automatically discovers plugins)
|
|
73
|
+
analyzer_class = get_analyzer_for_file(file_path)
|
|
74
|
+
|
|
75
|
+
# Fall back to text analyzer if no specific analyzer registered
|
|
76
|
+
if not analyzer_class:
|
|
77
|
+
analyzer_class = TextAnalyzer
|
|
78
|
+
|
|
79
|
+
return analyzer_class(
|
|
80
|
+
lines,
|
|
81
|
+
file_path=file_path,
|
|
82
|
+
focus_start=focus_start,
|
|
83
|
+
focus_end=focus_end
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def reveal_level_0(summary: FileSummary) -> List[str]:
|
|
88
|
+
"""Generate Level 0 (metadata) output."""
|
|
89
|
+
return format_metadata(summary)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def reveal_level_1(
|
|
93
|
+
summary: FileSummary,
|
|
94
|
+
grep_pattern: Optional[str] = None,
|
|
95
|
+
case_sensitive: bool = False,
|
|
96
|
+
focus_start: Optional[int] = None,
|
|
97
|
+
focus_end: Optional[int] = None
|
|
98
|
+
) -> List[str]:
|
|
99
|
+
"""Generate Level 1 (structure) output."""
|
|
100
|
+
analyzer = get_analyzer(
|
|
101
|
+
str(summary.path),
|
|
102
|
+
summary.lines,
|
|
103
|
+
focus_start=focus_start,
|
|
104
|
+
focus_end=focus_end
|
|
105
|
+
)
|
|
106
|
+
structure = analyzer.analyze_structure()
|
|
107
|
+
|
|
108
|
+
# Check if analyzer provides custom formatting
|
|
109
|
+
custom_lines = analyzer.format_structure(structure)
|
|
110
|
+
if custom_lines is not None:
|
|
111
|
+
# Use analyzer's custom formatter (pluggable!)
|
|
112
|
+
lines = custom_lines
|
|
113
|
+
else:
|
|
114
|
+
# Fall back to generic formatter
|
|
115
|
+
lines = format_structure(summary, structure, grep_pattern)
|
|
116
|
+
|
|
117
|
+
# Apply grep filter if specified
|
|
118
|
+
if grep_pattern:
|
|
119
|
+
from .grep_filter import filter_structure_output
|
|
120
|
+
lines = filter_structure_output(lines, grep_pattern, case_sensitive)
|
|
121
|
+
|
|
122
|
+
return lines
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def reveal_level_2(
|
|
126
|
+
summary: FileSummary,
|
|
127
|
+
grep_pattern: Optional[str] = None,
|
|
128
|
+
case_sensitive: bool = False,
|
|
129
|
+
context: int = 0,
|
|
130
|
+
focus_start: Optional[int] = None,
|
|
131
|
+
focus_end: Optional[int] = None
|
|
132
|
+
) -> List[str]:
|
|
133
|
+
"""Generate Level 2 (preview) output."""
|
|
134
|
+
analyzer = get_analyzer(
|
|
135
|
+
str(summary.path),
|
|
136
|
+
summary.lines,
|
|
137
|
+
focus_start=focus_start,
|
|
138
|
+
focus_end=focus_end
|
|
139
|
+
)
|
|
140
|
+
preview = analyzer.generate_preview()
|
|
141
|
+
|
|
142
|
+
# Apply grep filter if specified
|
|
143
|
+
if grep_pattern:
|
|
144
|
+
preview = apply_grep_filter(preview, grep_pattern, case_sensitive, context)
|
|
145
|
+
|
|
146
|
+
return format_preview(summary, preview, grep_pattern)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def reveal_level_3(
|
|
150
|
+
summary: FileSummary,
|
|
151
|
+
page_size: int = 120,
|
|
152
|
+
grep_pattern: Optional[str] = None,
|
|
153
|
+
case_sensitive: bool = False,
|
|
154
|
+
context: int = 0
|
|
155
|
+
) -> List[str]:
|
|
156
|
+
"""Generate Level 3 (full content) output."""
|
|
157
|
+
# Create line tuples
|
|
158
|
+
lines_with_numbers = [(i + 1, line) for i, line in enumerate(summary.lines)]
|
|
159
|
+
|
|
160
|
+
# Apply grep filter if specified
|
|
161
|
+
if grep_pattern:
|
|
162
|
+
lines_with_numbers = apply_grep_filter(
|
|
163
|
+
lines_with_numbers,
|
|
164
|
+
grep_pattern,
|
|
165
|
+
case_sensitive,
|
|
166
|
+
context
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Apply paging
|
|
170
|
+
total_lines = len(lines_with_numbers)
|
|
171
|
+
if total_lines <= page_size:
|
|
172
|
+
return format_full_content(summary, lines_with_numbers, grep_pattern, is_end=True)
|
|
173
|
+
|
|
174
|
+
# For simplicity, show first page
|
|
175
|
+
# In a real implementation, this would be interactive
|
|
176
|
+
page_lines = lines_with_numbers[:page_size]
|
|
177
|
+
return format_full_content(summary, page_lines, grep_pattern, is_end=False)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def analyze_directory_level_0(dir_path: Path) -> List[str]:
|
|
181
|
+
"""Analyze directory metadata (level 0)."""
|
|
182
|
+
lines = []
|
|
183
|
+
lines.append("=== DIRECTORY METADATA (Level 0) ===")
|
|
184
|
+
lines.append("")
|
|
185
|
+
|
|
186
|
+
# Count files and gather stats
|
|
187
|
+
all_files = []
|
|
188
|
+
total_size = 0
|
|
189
|
+
file_types = {}
|
|
190
|
+
|
|
191
|
+
for root, dirs, files in os.walk(dir_path):
|
|
192
|
+
for file in files:
|
|
193
|
+
file_path = Path(root) / file
|
|
194
|
+
try:
|
|
195
|
+
size = file_path.stat().st_size
|
|
196
|
+
total_size += size
|
|
197
|
+
file_type = detect_file_type(file_path)
|
|
198
|
+
file_types[file_type] = file_types.get(file_type, 0) + 1
|
|
199
|
+
all_files.append((file_path, size, file_type))
|
|
200
|
+
except:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
lines.append(f"Path: {dir_path.name}/")
|
|
204
|
+
lines.append(f"Total files: {len(all_files):,}")
|
|
205
|
+
lines.append(f"Total size: {total_size:,} bytes ({total_size / 1024:.1f} KB)")
|
|
206
|
+
lines.append("")
|
|
207
|
+
|
|
208
|
+
if file_types:
|
|
209
|
+
lines.append("File types:")
|
|
210
|
+
for ftype, count in sorted(file_types.items(), key=lambda x: x[1], reverse=True):
|
|
211
|
+
lines.append(f" {ftype:15} {count:4} files")
|
|
212
|
+
|
|
213
|
+
lines.append("")
|
|
214
|
+
lines.append("→ Next: --level 1 (file listing)")
|
|
215
|
+
lines.append(" Tip: Use --grep to filter by filename or type")
|
|
216
|
+
|
|
217
|
+
return lines
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def analyze_directory_level_1(dir_path: Path, grep_pattern: Optional[str] = None) -> List[str]:
|
|
221
|
+
"""Analyze directory structure (level 1) - list files with brief info."""
|
|
222
|
+
lines = []
|
|
223
|
+
lines.append("=== DIRECTORY STRUCTURE (Level 1) ===")
|
|
224
|
+
lines.append("")
|
|
225
|
+
|
|
226
|
+
# Gather files
|
|
227
|
+
files_by_type = {}
|
|
228
|
+
for root, dirs, files in os.walk(dir_path):
|
|
229
|
+
for file in files:
|
|
230
|
+
file_path = Path(root) / file
|
|
231
|
+
try:
|
|
232
|
+
file_type = detect_file_type(file_path)
|
|
233
|
+
rel_path = file_path.relative_to(dir_path)
|
|
234
|
+
size = file_path.stat().st_size
|
|
235
|
+
|
|
236
|
+
# Apply grep filter if specified
|
|
237
|
+
if grep_pattern and grep_pattern.lower() not in str(rel_path).lower():
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
if file_type not in files_by_type:
|
|
241
|
+
files_by_type[file_type] = []
|
|
242
|
+
files_by_type[file_type].append((rel_path, size))
|
|
243
|
+
except:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
# Format output by type
|
|
247
|
+
for file_type in sorted(files_by_type.keys()):
|
|
248
|
+
file_list = sorted(files_by_type[file_type])
|
|
249
|
+
lines.append(f"{file_type.title()} files ({len(file_list)}):")
|
|
250
|
+
for rel_path, size in file_list[:20]: # Limit to 20 per type
|
|
251
|
+
size_kb = size / 1024
|
|
252
|
+
lines.append(f" {str(rel_path):50} {size_kb:8.1f} KB")
|
|
253
|
+
if len(file_list) > 20:
|
|
254
|
+
lines.append(f" ... and {len(file_list) - 20} more")
|
|
255
|
+
lines.append("")
|
|
256
|
+
|
|
257
|
+
lines.append("→ Next: --level 2 (detailed summaries)")
|
|
258
|
+
lines.append(" Tip: Use 'reveal <filename>' to analyze individual files")
|
|
259
|
+
|
|
260
|
+
return lines
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def analyze_directory_level_2(dir_path: Path, grep_pattern: Optional[str] = None) -> List[str]:
|
|
264
|
+
"""Analyze directory with file summaries (level 2)."""
|
|
265
|
+
lines = []
|
|
266
|
+
lines.append("=== DIRECTORY DETAILS (Level 2) ===")
|
|
267
|
+
lines.append("")
|
|
268
|
+
|
|
269
|
+
files_analyzed = 0
|
|
270
|
+
for root, dirs, files in os.walk(dir_path):
|
|
271
|
+
for file in sorted(files)[:30]: # Limit to 30 files
|
|
272
|
+
file_path = Path(root) / file
|
|
273
|
+
try:
|
|
274
|
+
rel_path = file_path.relative_to(dir_path)
|
|
275
|
+
|
|
276
|
+
# Apply grep filter
|
|
277
|
+
if grep_pattern and grep_pattern.lower() not in str(rel_path).lower():
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
# Quick analysis
|
|
281
|
+
file_type = detect_file_type(file_path)
|
|
282
|
+
size = file_path.stat().st_size
|
|
283
|
+
|
|
284
|
+
# Try to get line count for text files
|
|
285
|
+
line_count = "?"
|
|
286
|
+
try:
|
|
287
|
+
if size < 1024 * 1024: # Only for files < 1MB
|
|
288
|
+
# Try multiple encodings for Windows compatibility
|
|
289
|
+
for encoding in ['utf-8-sig', 'utf-8', 'cp1252', 'iso-8859-1']:
|
|
290
|
+
try:
|
|
291
|
+
with open(file_path, 'r', encoding=encoding) as f:
|
|
292
|
+
line_count = str(sum(1 for _ in f))
|
|
293
|
+
break
|
|
294
|
+
except UnicodeDecodeError:
|
|
295
|
+
continue
|
|
296
|
+
except:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
lines.append(f"📄 {rel_path}")
|
|
300
|
+
lines.append(f" Type: {file_type}, Size: {size / 1024:.1f} KB, Lines: {line_count}")
|
|
301
|
+
lines.append("")
|
|
302
|
+
files_analyzed += 1
|
|
303
|
+
except:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
if files_analyzed == 0:
|
|
307
|
+
lines.append("No files found matching criteria.")
|
|
308
|
+
|
|
309
|
+
lines.append("→ Use 'reveal <specific-file>' for detailed analysis")
|
|
310
|
+
|
|
311
|
+
return lines
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def main():
|
|
315
|
+
"""Main CLI entry point."""
|
|
316
|
+
parser = argparse.ArgumentParser(
|
|
317
|
+
description='Progressive Reveal CLI - Explore files at different levels of detail',
|
|
318
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
319
|
+
epilog="""
|
|
320
|
+
Levels:
|
|
321
|
+
0 = metadata
|
|
322
|
+
1 = structural synopsis
|
|
323
|
+
2 = content preview
|
|
324
|
+
3 = full content (paged)
|
|
325
|
+
|
|
326
|
+
Examples:
|
|
327
|
+
reveal myfile.yaml
|
|
328
|
+
reveal myfile.json --level 1
|
|
329
|
+
reveal myfile.py --level 2 --grep "class"
|
|
330
|
+
reveal myfile.md --level 3 --page-size 50
|
|
331
|
+
reveal schema.sql:32 # Jump to line 32
|
|
332
|
+
reveal schema.sql:10-50 # Show lines 10-50
|
|
333
|
+
reveal code.py:125 --level 1 # What's at line 125?
|
|
334
|
+
"""
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
parser.add_argument('file', type=str, help='File to reveal (supports file:line or file:start-end)')
|
|
338
|
+
parser.add_argument('--level', '-l', type=int, default=0, choices=[0, 1, 2, 3],
|
|
339
|
+
help='Revelation level (default: 0)')
|
|
340
|
+
parser.add_argument('--grep', '-m', type=str, dest='grep_pattern',
|
|
341
|
+
help='Filter pattern (regex)')
|
|
342
|
+
parser.add_argument('--context', '-C', type=int, default=0,
|
|
343
|
+
help='Context lines around matches (default: 0)')
|
|
344
|
+
parser.add_argument('--grep-case-sensitive', action='store_true',
|
|
345
|
+
help='Use case-sensitive grep matching')
|
|
346
|
+
parser.add_argument('--page-size', type=int, default=120,
|
|
347
|
+
help='Page size for level 3 (default: 120)')
|
|
348
|
+
parser.add_argument('--force', action='store_true',
|
|
349
|
+
help='Force read of large or binary files')
|
|
350
|
+
|
|
351
|
+
args = parser.parse_args()
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
# Parse file location (may include :line or :start-end)
|
|
355
|
+
file_str, focus_start, focus_end = parse_file_location(args.file)
|
|
356
|
+
|
|
357
|
+
# Create file path
|
|
358
|
+
file_path = Path(file_str).resolve()
|
|
359
|
+
|
|
360
|
+
# Check if it's a directory
|
|
361
|
+
if file_path.is_dir():
|
|
362
|
+
# Handle directory analysis
|
|
363
|
+
if args.level == 0:
|
|
364
|
+
output = analyze_directory_level_0(file_path)
|
|
365
|
+
elif args.level == 1:
|
|
366
|
+
output = analyze_directory_level_1(file_path, args.grep_pattern)
|
|
367
|
+
elif args.level == 2:
|
|
368
|
+
output = analyze_directory_level_2(file_path, args.grep_pattern)
|
|
369
|
+
else: # level 3
|
|
370
|
+
print("Error: Level 3 (full content) not supported for directories", file=sys.stderr)
|
|
371
|
+
print("Hint: Use --level 2 for detailed file summaries", file=sys.stderr)
|
|
372
|
+
sys.exit(1)
|
|
373
|
+
|
|
374
|
+
# Print directory output
|
|
375
|
+
for line in output:
|
|
376
|
+
print(line)
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
# Regular file handling
|
|
380
|
+
# Create file summary
|
|
381
|
+
summary = create_file_summary(file_path, force=args.force)
|
|
382
|
+
|
|
383
|
+
# Handle errors
|
|
384
|
+
if summary.parse_error and summary.is_binary and not args.force:
|
|
385
|
+
print(f"Error: {summary.parse_error}", file=sys.stderr)
|
|
386
|
+
sys.exit(1)
|
|
387
|
+
|
|
388
|
+
# Generate output based on level
|
|
389
|
+
if args.level == 0:
|
|
390
|
+
output = reveal_level_0(summary)
|
|
391
|
+
elif args.level == 1:
|
|
392
|
+
if summary.parse_error and summary.is_binary:
|
|
393
|
+
print(f"Error: {summary.parse_error}", file=sys.stderr)
|
|
394
|
+
sys.exit(1)
|
|
395
|
+
output = reveal_level_1(
|
|
396
|
+
summary,
|
|
397
|
+
grep_pattern=args.grep_pattern,
|
|
398
|
+
case_sensitive=args.grep_case_sensitive,
|
|
399
|
+
focus_start=focus_start,
|
|
400
|
+
focus_end=focus_end
|
|
401
|
+
)
|
|
402
|
+
elif args.level == 2:
|
|
403
|
+
if summary.parse_error and summary.is_binary:
|
|
404
|
+
print(f"Error: {summary.parse_error}", file=sys.stderr)
|
|
405
|
+
sys.exit(1)
|
|
406
|
+
output = reveal_level_2(
|
|
407
|
+
summary,
|
|
408
|
+
grep_pattern=args.grep_pattern,
|
|
409
|
+
case_sensitive=args.grep_case_sensitive,
|
|
410
|
+
context=args.context,
|
|
411
|
+
focus_start=focus_start,
|
|
412
|
+
focus_end=focus_end
|
|
413
|
+
)
|
|
414
|
+
elif args.level == 3:
|
|
415
|
+
if summary.parse_error and summary.is_binary:
|
|
416
|
+
print(f"Error: {summary.parse_error}", file=sys.stderr)
|
|
417
|
+
sys.exit(1)
|
|
418
|
+
output = reveal_level_3(
|
|
419
|
+
summary,
|
|
420
|
+
page_size=args.page_size,
|
|
421
|
+
grep_pattern=args.grep_pattern,
|
|
422
|
+
case_sensitive=args.grep_case_sensitive,
|
|
423
|
+
context=args.context
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# Print output
|
|
427
|
+
for line in output:
|
|
428
|
+
print(line)
|
|
429
|
+
|
|
430
|
+
except FileNotFoundError as e:
|
|
431
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
432
|
+
sys.exit(1)
|
|
433
|
+
except ValueError as e:
|
|
434
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
435
|
+
sys.exit(1)
|
|
436
|
+
except KeyboardInterrupt:
|
|
437
|
+
print("\nInterrupted", file=sys.stderr)
|
|
438
|
+
sys.exit(130)
|
|
439
|
+
except Exception as e:
|
|
440
|
+
print(f"Unexpected error: {e}", file=sys.stderr)
|
|
441
|
+
sys.exit(1)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
if __name__ == '__main__':
|
|
445
|
+
main()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Core data models and utilities for Progressive Reveal CLI."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional, Dict, Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class FileSummary:
|
|
13
|
+
"""Internal data model for file analysis."""
|
|
14
|
+
|
|
15
|
+
path: Path
|
|
16
|
+
type: str
|
|
17
|
+
size: int
|
|
18
|
+
modified: datetime
|
|
19
|
+
linecount: int
|
|
20
|
+
sha256: str
|
|
21
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
structure: Dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
preview: str = ""
|
|
24
|
+
lines: List[str] = field(default_factory=list)
|
|
25
|
+
is_binary: bool = False
|
|
26
|
+
parse_error: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_file_safe(file_path: Path, max_bytes: int = 2 * 1024 * 1024, force: bool = False) -> tuple[bool, str, List[str]]:
|
|
30
|
+
"""
|
|
31
|
+
Safely read a file with size and binary checks.
|
|
32
|
+
|
|
33
|
+
Tries multiple encodings in order of likelihood:
|
|
34
|
+
1. UTF-8 (with BOM handling)
|
|
35
|
+
2. CP1252 (Windows Western European)
|
|
36
|
+
3. ISO-8859-1 (Latin-1, never fails but may produce garbage)
|
|
37
|
+
4. UTF-16 (Windows applications)
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
tuple: (success, error_message, lines)
|
|
41
|
+
"""
|
|
42
|
+
if not file_path.exists():
|
|
43
|
+
return False, f"File not found: {file_path}", []
|
|
44
|
+
|
|
45
|
+
file_size = file_path.stat().st_size
|
|
46
|
+
|
|
47
|
+
if file_size > max_bytes and not force:
|
|
48
|
+
return False, f"File too large ({file_size} bytes > {max_bytes} bytes). Use --force to override.", []
|
|
49
|
+
|
|
50
|
+
# Try multiple encodings in order of preference
|
|
51
|
+
encodings_to_try = [
|
|
52
|
+
'utf-8-sig', # UTF-8 with BOM handling
|
|
53
|
+
'utf-8', # UTF-8 without BOM
|
|
54
|
+
'cp1252', # Windows Western European
|
|
55
|
+
'iso-8859-1', # Latin-1 (fallback, accepts all bytes)
|
|
56
|
+
'utf-16', # Windows UTF-16 LE/BE
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
last_error = None
|
|
60
|
+
|
|
61
|
+
for encoding in encodings_to_try:
|
|
62
|
+
try:
|
|
63
|
+
with open(file_path, 'r', encoding=encoding) as f:
|
|
64
|
+
content = f.read()
|
|
65
|
+
lines = content.splitlines()
|
|
66
|
+
return True, "", lines
|
|
67
|
+
except UnicodeDecodeError as e:
|
|
68
|
+
last_error = e
|
|
69
|
+
continue
|
|
70
|
+
except Exception as e:
|
|
71
|
+
# Other errors (permissions, etc.) should be reported immediately
|
|
72
|
+
return False, f"Error reading file: {str(e)}", []
|
|
73
|
+
|
|
74
|
+
# If all encodings failed, it's likely a binary file
|
|
75
|
+
return False, "Binary file detected. Use --force for hexdump.", []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def compute_sha256(file_path: Path) -> str:
|
|
79
|
+
"""Compute SHA256 hash of a file."""
|
|
80
|
+
sha256_hash = hashlib.sha256()
|
|
81
|
+
try:
|
|
82
|
+
with open(file_path, "rb") as f:
|
|
83
|
+
for byte_block in iter(lambda: f.read(4096), b""):
|
|
84
|
+
sha256_hash.update(byte_block)
|
|
85
|
+
return sha256_hash.hexdigest()
|
|
86
|
+
except Exception:
|
|
87
|
+
return "ERROR"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def create_file_summary(file_path: Path, force: bool = False) -> FileSummary:
|
|
91
|
+
"""
|
|
92
|
+
Create a FileSummary object from a file path.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
file_path: Path to the file
|
|
96
|
+
force: Whether to force read large/binary files
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
FileSummary object
|
|
100
|
+
"""
|
|
101
|
+
from .detectors import detect_file_type
|
|
102
|
+
|
|
103
|
+
if not file_path.exists():
|
|
104
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
105
|
+
|
|
106
|
+
stat = file_path.stat()
|
|
107
|
+
modified = datetime.fromtimestamp(stat.st_mtime)
|
|
108
|
+
|
|
109
|
+
# Read file content
|
|
110
|
+
success, error, lines = read_file_safe(file_path, force=force)
|
|
111
|
+
|
|
112
|
+
if not success:
|
|
113
|
+
# Create minimal summary with error
|
|
114
|
+
return FileSummary(
|
|
115
|
+
path=file_path,
|
|
116
|
+
type="unknown",
|
|
117
|
+
size=stat.st_size,
|
|
118
|
+
modified=modified,
|
|
119
|
+
linecount=0,
|
|
120
|
+
sha256=compute_sha256(file_path),
|
|
121
|
+
is_binary="Binary file" in error,
|
|
122
|
+
parse_error=error,
|
|
123
|
+
lines=[]
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
file_type = detect_file_type(file_path)
|
|
127
|
+
|
|
128
|
+
return FileSummary(
|
|
129
|
+
path=file_path,
|
|
130
|
+
type=file_type,
|
|
131
|
+
size=stat.st_size,
|
|
132
|
+
modified=modified,
|
|
133
|
+
linecount=len(lines),
|
|
134
|
+
sha256=compute_sha256(file_path),
|
|
135
|
+
lines=lines
|
|
136
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""File type detection utilities."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
FILE_TYPE_MAP = {
|
|
7
|
+
'.yaml': 'yaml',
|
|
8
|
+
'.yml': 'yaml',
|
|
9
|
+
'.json': 'json',
|
|
10
|
+
'.ipynb': 'jupyter',
|
|
11
|
+
'.toml': 'toml',
|
|
12
|
+
'.md': 'markdown',
|
|
13
|
+
'.markdown': 'markdown',
|
|
14
|
+
'.py': 'python',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def detect_file_type(file_path: Path) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Detect file type based on extension.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
file_path: Path to the file
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
File type string (e.g., 'yaml', 'json', 'markdown', 'python', 'text')
|
|
27
|
+
"""
|
|
28
|
+
suffix = file_path.suffix.lower()
|
|
29
|
+
return FILE_TYPE_MAP.get(suffix, 'text')
|