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.
Files changed (51) hide show
  1. plugins/c-header.yaml +89 -0
  2. plugins/gdscript.yaml +96 -0
  3. plugins/python.yaml +94 -0
  4. plugins/yaml.yaml +87 -0
  5. reveal/__init__.py +20 -0
  6. reveal/_archive_old_v0.1/analyzers/__init__.py +39 -0
  7. reveal/_archive_old_v0.1/analyzers/base.py +143 -0
  8. reveal/_archive_old_v0.1/analyzers/bash_analyzer.py +202 -0
  9. reveal/_archive_old_v0.1/analyzers/csharp_analyzer.py +167 -0
  10. reveal/_archive_old_v0.1/analyzers/gdscript_analyzer.py +201 -0
  11. reveal/_archive_old_v0.1/analyzers/go_analyzer.py +230 -0
  12. reveal/_archive_old_v0.1/analyzers/javascript_analyzer.py +272 -0
  13. reveal/_archive_old_v0.1/analyzers/json_analyzer.py +143 -0
  14. reveal/_archive_old_v0.1/analyzers/markdown_analyzer.py +116 -0
  15. reveal/_archive_old_v0.1/analyzers/php_analyzer.py +166 -0
  16. reveal/_archive_old_v0.1/analyzers/python_analyzer.py +118 -0
  17. reveal/_archive_old_v0.1/analyzers/rust_analyzer.py +202 -0
  18. reveal/_archive_old_v0.1/analyzers/sql_analyzer.py +534 -0
  19. reveal/_archive_old_v0.1/analyzers/text_analyzer.py +35 -0
  20. reveal/_archive_old_v0.1/analyzers/toml_analyzer.py +137 -0
  21. reveal/_archive_old_v0.1/analyzers/treesitter_base.py +276 -0
  22. reveal/_archive_old_v0.1/analyzers/yaml_analyzer.py +127 -0
  23. reveal/_archive_old_v0.1/breadcrumbs.py +147 -0
  24. reveal/_archive_old_v0.1/cli.py +445 -0
  25. reveal/_archive_old_v0.1/core.py +136 -0
  26. reveal/_archive_old_v0.1/detectors.py +29 -0
  27. reveal/_archive_old_v0.1/formatters.py +272 -0
  28. reveal/_archive_old_v0.1/grep_filter.py +85 -0
  29. reveal/_archive_old_v0.1/plugin_loader.py +178 -0
  30. reveal/_archive_old_v0.1/registry.py +286 -0
  31. reveal/analyzers/__init__.py +23 -0
  32. reveal/analyzers/go.py +13 -0
  33. reveal/analyzers/jupyter_analyzer.py +226 -0
  34. reveal/analyzers/markdown.py +79 -0
  35. reveal/analyzers/python.py +15 -0
  36. reveal/analyzers/rust.py +13 -0
  37. reveal/analyzers/yaml_json.py +110 -0
  38. reveal/base.py +193 -0
  39. reveal/main.py +216 -0
  40. reveal/tests/__init__.py +1 -0
  41. reveal/tests/test_json_yaml_line_numbers.py +238 -0
  42. reveal/tests/test_line_numbers.py +151 -0
  43. reveal/tests/test_toml_analyzer.py +220 -0
  44. reveal/tree_view.py +105 -0
  45. reveal/treesitter.py +282 -0
  46. reveal_cli-0.2.0.dist-info/METADATA +319 -0
  47. reveal_cli-0.2.0.dist-info/RECORD +51 -0
  48. reveal_cli-0.2.0.dist-info/WHEEL +5 -0
  49. reveal_cli-0.2.0.dist-info/entry_points.txt +2 -0
  50. reveal_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  51. 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')