scantool 0.9.1__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.
- scantool/__init__.py +7 -0
- scantool/directory_formatter.py +364 -0
- scantool/formatter.py +153 -0
- scantool/gitignore.py +145 -0
- scantool/glob_expander.py +48 -0
- scantool/scanner.py +236 -0
- scantool/scanners/__init__.py +94 -0
- scantool/scanners/_template.py +340 -0
- scantool/scanners/base.py +175 -0
- scantool/scanners/c_cpp_scanner.py +605 -0
- scantool/scanners/csharp_scanner.py +633 -0
- scantool/scanners/go_scanner.py +339 -0
- scantool/scanners/image_scanner.py +241 -0
- scantool/scanners/java_scanner.py +499 -0
- scantool/scanners/markdown_scanner.py +369 -0
- scantool/scanners/php_scanner.py +539 -0
- scantool/scanners/python_scanner.py +329 -0
- scantool/scanners/ruby_scanner.py +339 -0
- scantool/scanners/rust_scanner.py +481 -0
- scantool/scanners/text_scanner.py +101 -0
- scantool/scanners/typescript_scanner.py +505 -0
- scantool/server.py +386 -0
- scantool-0.9.1.dist-info/METADATA +536 -0
- scantool-0.9.1.dist-info/RECORD +27 -0
- scantool-0.9.1.dist-info/WHEEL +4 -0
- scantool-0.9.1.dist-info/entry_points.txt +3 -0
- scantool-0.9.1.dist-info/licenses/LICENSE +21 -0
scantool/__init__.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Hierarchical directory tree formatter with integrated code structures."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from .scanners import StructureNode
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DirectoryFormatter:
|
|
11
|
+
"""Formats directory scans as hierarchical trees with code structures."""
|
|
12
|
+
|
|
13
|
+
# Tree drawing characters
|
|
14
|
+
BRANCH = "├─"
|
|
15
|
+
LAST_BRANCH = "└─"
|
|
16
|
+
VERTICAL = "│ "
|
|
17
|
+
SPACE = " "
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def _format_relative_time(iso_timestamp: str) -> str:
|
|
21
|
+
"""Format timestamp as relative time (e.g., '2 mins ago', '3 days ago')."""
|
|
22
|
+
try:
|
|
23
|
+
# Parse ISO timestamp
|
|
24
|
+
modified_time = datetime.fromisoformat(iso_timestamp)
|
|
25
|
+
now = datetime.now()
|
|
26
|
+
diff = now - modified_time
|
|
27
|
+
|
|
28
|
+
# Calculate time difference
|
|
29
|
+
seconds = diff.total_seconds()
|
|
30
|
+
if seconds < 60:
|
|
31
|
+
return "just now"
|
|
32
|
+
elif seconds < 3600: # < 1 hour
|
|
33
|
+
mins = int(seconds / 60)
|
|
34
|
+
return f"{mins} min{'s' if mins != 1 else ''} ago"
|
|
35
|
+
elif seconds < 86400: # < 1 day
|
|
36
|
+
hours = int(seconds / 3600)
|
|
37
|
+
return f"{hours} hour{'s' if hours != 1 else ''} ago"
|
|
38
|
+
elif seconds < 604800: # < 1 week
|
|
39
|
+
days = int(seconds / 86400)
|
|
40
|
+
return f"{days} day{'s' if days != 1 else ''} ago"
|
|
41
|
+
elif seconds < 2592000: # < 30 days
|
|
42
|
+
weeks = int(seconds / 604800)
|
|
43
|
+
return f"{weeks} week{'s' if weeks != 1 else ''} ago"
|
|
44
|
+
elif seconds < 31536000: # < 1 year
|
|
45
|
+
months = int(seconds / 2592000)
|
|
46
|
+
return f"{months} month{'s' if months != 1 else ''} ago"
|
|
47
|
+
else:
|
|
48
|
+
years = int(seconds / 31536000)
|
|
49
|
+
return f"{years} year{'s' if years != 1 else ''} ago"
|
|
50
|
+
except Exception:
|
|
51
|
+
# If parsing fails, return empty string
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
def __init__(self, show_signatures: bool = True, show_decorators: bool = True,
|
|
55
|
+
show_docstrings: bool = True, show_complexity: bool = False,
|
|
56
|
+
include_structures: bool = True, flatten_structures: bool = False):
|
|
57
|
+
"""
|
|
58
|
+
Initialize directory formatter with display options.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
flatten_structures: Show only top-level structures (classes/functions)
|
|
62
|
+
without nested children (methods). Reduces output by ~50%.
|
|
63
|
+
"""
|
|
64
|
+
self.show_signatures = show_signatures
|
|
65
|
+
self.show_decorators = show_decorators
|
|
66
|
+
self.show_docstrings = show_docstrings
|
|
67
|
+
self.show_complexity = show_complexity
|
|
68
|
+
self.include_structures = include_structures
|
|
69
|
+
self.flatten_structures = flatten_structures
|
|
70
|
+
|
|
71
|
+
def format(self, base_dir: str, file_structures: dict[str, list[StructureNode]]) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Format directory scan as hierarchical tree.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
base_dir: Base directory path
|
|
77
|
+
file_structures: Dict mapping file paths to their structure nodes
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Formatted hierarchical tree string
|
|
81
|
+
"""
|
|
82
|
+
base_path = Path(base_dir).resolve()
|
|
83
|
+
|
|
84
|
+
# Build directory tree
|
|
85
|
+
tree = self._build_tree(base_path, file_structures)
|
|
86
|
+
|
|
87
|
+
# Format as text
|
|
88
|
+
lines = [f"{base_path.name}/ {self._format_stats(tree)}"]
|
|
89
|
+
lines.extend(self._format_tree_node(tree, ""))
|
|
90
|
+
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
def _build_tree(self, base_path: Path, file_structures: dict[str, list[StructureNode]]) -> dict:
|
|
94
|
+
"""Build hierarchical directory tree structure."""
|
|
95
|
+
tree = {
|
|
96
|
+
"type": "directory",
|
|
97
|
+
"name": base_path.name,
|
|
98
|
+
"path": base_path,
|
|
99
|
+
"children": {},
|
|
100
|
+
"files": {},
|
|
101
|
+
"stats": {"files": 0, "classes": 0, "functions": 0, "methods": 0}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for file_path_str, structures in file_structures.items():
|
|
105
|
+
if not structures:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
file_path = Path(file_path_str).resolve()
|
|
109
|
+
|
|
110
|
+
# Get relative path from base
|
|
111
|
+
try:
|
|
112
|
+
rel_path = file_path.relative_to(base_path)
|
|
113
|
+
except ValueError:
|
|
114
|
+
# File is outside base_path, skip it
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# Navigate/create directory structure
|
|
118
|
+
current = tree
|
|
119
|
+
parts = list(rel_path.parts[:-1]) # All but filename
|
|
120
|
+
|
|
121
|
+
for part in parts:
|
|
122
|
+
if part not in current["children"]:
|
|
123
|
+
current["children"][part] = {
|
|
124
|
+
"type": "directory",
|
|
125
|
+
"name": part,
|
|
126
|
+
"children": {},
|
|
127
|
+
"files": {},
|
|
128
|
+
"stats": {"files": 0, "classes": 0, "functions": 0, "methods": 0}
|
|
129
|
+
}
|
|
130
|
+
current = current["children"][part]
|
|
131
|
+
|
|
132
|
+
# Add file to current directory
|
|
133
|
+
filename = rel_path.parts[-1]
|
|
134
|
+
current["files"][filename] = {
|
|
135
|
+
"type": "file",
|
|
136
|
+
"name": filename,
|
|
137
|
+
"path": file_path,
|
|
138
|
+
"structures": structures
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Update stats recursively up the tree
|
|
142
|
+
self._update_stats(tree, structures)
|
|
143
|
+
|
|
144
|
+
return tree
|
|
145
|
+
|
|
146
|
+
def _update_stats(self, node: dict, structures: list[StructureNode]):
|
|
147
|
+
"""Update statistics for a node and count structures."""
|
|
148
|
+
node["stats"]["files"] += 1
|
|
149
|
+
|
|
150
|
+
def count_structures(structs):
|
|
151
|
+
for s in structs:
|
|
152
|
+
if s.type == "class":
|
|
153
|
+
node["stats"]["classes"] += 1
|
|
154
|
+
elif s.type == "function":
|
|
155
|
+
node["stats"]["functions"] += 1
|
|
156
|
+
elif s.type == "method":
|
|
157
|
+
node["stats"]["methods"] += 1
|
|
158
|
+
|
|
159
|
+
if hasattr(s, "children") and s.children:
|
|
160
|
+
count_structures(s.children)
|
|
161
|
+
|
|
162
|
+
count_structures(structures)
|
|
163
|
+
|
|
164
|
+
def _format_stats(self, node: dict) -> str:
|
|
165
|
+
"""Format directory statistics."""
|
|
166
|
+
stats = node["stats"]
|
|
167
|
+
parts = []
|
|
168
|
+
|
|
169
|
+
if stats["files"] > 0:
|
|
170
|
+
parts.append(f"{stats['files']} file{'s' if stats['files'] != 1 else ''}")
|
|
171
|
+
if stats["classes"] > 0:
|
|
172
|
+
parts.append(f"{stats['classes']} class{'es' if stats['classes'] != 1 else ''}")
|
|
173
|
+
if stats["functions"] > 0:
|
|
174
|
+
parts.append(f"{stats['functions']} function{'s' if stats['functions'] != 1 else ''}")
|
|
175
|
+
if stats["methods"] > 0:
|
|
176
|
+
parts.append(f"{stats['methods']} method{'s' if stats['methods'] != 1 else ''}")
|
|
177
|
+
|
|
178
|
+
return f"({', '.join(parts)})" if parts else ""
|
|
179
|
+
|
|
180
|
+
def _format_tree_node(self, node: dict, prefix: str) -> list[str]:
|
|
181
|
+
"""Recursively format a tree node and its children."""
|
|
182
|
+
lines = []
|
|
183
|
+
|
|
184
|
+
# Get sorted children and files
|
|
185
|
+
dirs = sorted(node["children"].items())
|
|
186
|
+
files = sorted(node["files"].items())
|
|
187
|
+
|
|
188
|
+
all_items = [(name, child, True) for name, child in dirs] + \
|
|
189
|
+
[(name, child, False) for name, child in files]
|
|
190
|
+
|
|
191
|
+
for i, (name, child, is_dir) in enumerate(all_items):
|
|
192
|
+
is_last = i == len(all_items) - 1
|
|
193
|
+
connector = self.LAST_BRANCH if is_last else self.BRANCH
|
|
194
|
+
|
|
195
|
+
if is_dir:
|
|
196
|
+
# Directory
|
|
197
|
+
stats_str = self._format_stats(child)
|
|
198
|
+
lines.append(f"{prefix}{connector} {name}/ {stats_str}")
|
|
199
|
+
|
|
200
|
+
# Recurse into directory
|
|
201
|
+
child_prefix = prefix + (self.SPACE if is_last else self.VERTICAL)
|
|
202
|
+
lines.extend(self._format_tree_node(child, child_prefix))
|
|
203
|
+
|
|
204
|
+
else:
|
|
205
|
+
# File
|
|
206
|
+
structures = child["structures"]
|
|
207
|
+
|
|
208
|
+
# Check if this is an unsupported file (only has file-info with unsupported flag)
|
|
209
|
+
is_unsupported = (
|
|
210
|
+
len(structures) == 1
|
|
211
|
+
and structures[0].type == "file-info"
|
|
212
|
+
and hasattr(structures[0], "file_metadata")
|
|
213
|
+
and structures[0].file_metadata
|
|
214
|
+
and structures[0].file_metadata.get("unsupported", False)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if is_unsupported:
|
|
218
|
+
# Unsupported file - show with metadata (no extension needed, it's in the filename)
|
|
219
|
+
metadata = structures[0].file_metadata
|
|
220
|
+
size = metadata.get("size_formatted", "")
|
|
221
|
+
|
|
222
|
+
# Format modified time as relative (e.g., "2 mins ago")
|
|
223
|
+
modified_iso = metadata.get("modified", "")
|
|
224
|
+
modified_relative = self._format_relative_time(modified_iso) if modified_iso else ""
|
|
225
|
+
|
|
226
|
+
# Build metadata string: size, relative time
|
|
227
|
+
meta_parts = [size, modified_relative]
|
|
228
|
+
meta_str = ", ".join(p for p in meta_parts if p)
|
|
229
|
+
lines.append(f"{prefix}{connector} {name} [{meta_str}]")
|
|
230
|
+
else:
|
|
231
|
+
# Supported file - show structures with metadata
|
|
232
|
+
min_line = min(s.start_line for s in self._flatten(structures)) if structures else 1
|
|
233
|
+
max_line = max(s.end_line for s in self._flatten(structures)) if structures else 1
|
|
234
|
+
|
|
235
|
+
# Extract metadata from file-info node if present
|
|
236
|
+
file_metadata = None
|
|
237
|
+
if structures and structures[0].type == "file-info":
|
|
238
|
+
file_metadata = structures[0].file_metadata
|
|
239
|
+
|
|
240
|
+
# Format metadata (size and modified time)
|
|
241
|
+
metadata_str = ""
|
|
242
|
+
if file_metadata:
|
|
243
|
+
size = file_metadata.get("size_formatted", "")
|
|
244
|
+
modified_iso = file_metadata.get("modified", "")
|
|
245
|
+
modified_relative = self._format_relative_time(modified_iso) if modified_iso else ""
|
|
246
|
+
|
|
247
|
+
meta_parts = [size, modified_relative]
|
|
248
|
+
metadata_str = " [" + ", ".join(p for p in meta_parts if p) + "]"
|
|
249
|
+
|
|
250
|
+
# Format file line
|
|
251
|
+
if self.include_structures and self.flatten_structures:
|
|
252
|
+
# Ultra-compact mode: show structures inline
|
|
253
|
+
display_structures = self._flatten_top_level(structures)
|
|
254
|
+
if display_structures:
|
|
255
|
+
# Get just the names of classes and functions
|
|
256
|
+
names = [s.name for s in display_structures]
|
|
257
|
+
if len(names) > 5:
|
|
258
|
+
# Truncate if too many
|
|
259
|
+
structure_list = ", ".join(names[:5]) + f", ... ({len(names)} total)"
|
|
260
|
+
else:
|
|
261
|
+
structure_list = ", ".join(names)
|
|
262
|
+
lines.append(f"{prefix}{connector} {name} ({min_line}-{max_line}){metadata_str} - {structure_list}")
|
|
263
|
+
else:
|
|
264
|
+
lines.append(f"{prefix}{connector} {name} ({min_line}-{max_line}){metadata_str}")
|
|
265
|
+
elif self.include_structures:
|
|
266
|
+
# Normal mode: show structures in tree below file
|
|
267
|
+
lines.append(f"{prefix}{connector} {name} ({min_line}-{max_line}){metadata_str}")
|
|
268
|
+
child_prefix = prefix + (self.SPACE if is_last else self.VERTICAL)
|
|
269
|
+
lines.extend(self._format_structures(structures, child_prefix))
|
|
270
|
+
else:
|
|
271
|
+
# No structures mode
|
|
272
|
+
lines.append(f"{prefix}{connector} {name} ({min_line}-{max_line}){metadata_str}")
|
|
273
|
+
|
|
274
|
+
return lines
|
|
275
|
+
|
|
276
|
+
def _format_structures(self, structures: list[StructureNode], prefix: str) -> list[str]:
|
|
277
|
+
"""Format structure nodes with indentation."""
|
|
278
|
+
lines = []
|
|
279
|
+
|
|
280
|
+
for i, node in enumerate(structures):
|
|
281
|
+
is_last = i == len(structures) - 1
|
|
282
|
+
lines.extend(self._format_structure_node(node, prefix, is_last))
|
|
283
|
+
|
|
284
|
+
return lines
|
|
285
|
+
|
|
286
|
+
def _format_structure_node(self, node: StructureNode, prefix: str, is_last: bool) -> list[str]:
|
|
287
|
+
"""Format a single structure node."""
|
|
288
|
+
lines = []
|
|
289
|
+
connector = self.LAST_BRANCH if is_last else self.BRANCH
|
|
290
|
+
|
|
291
|
+
# Build main line
|
|
292
|
+
parts = [f"{prefix}{connector} {node.type}: {node.name}"]
|
|
293
|
+
|
|
294
|
+
# Add signature if available
|
|
295
|
+
if self.show_signatures and hasattr(node, "signature") and node.signature:
|
|
296
|
+
parts.append(node.signature)
|
|
297
|
+
|
|
298
|
+
# Add line numbers
|
|
299
|
+
parts.append(f"({node.start_line}-{node.end_line})")
|
|
300
|
+
|
|
301
|
+
# Add modifiers if present
|
|
302
|
+
if hasattr(node, "modifiers") and node.modifiers:
|
|
303
|
+
modifiers_str = " ".join(node.modifiers)
|
|
304
|
+
parts.append(f"[{modifiers_str}]")
|
|
305
|
+
|
|
306
|
+
lines.append(" ".join(parts))
|
|
307
|
+
|
|
308
|
+
# Add decorators on separate lines
|
|
309
|
+
if self.show_decorators and hasattr(node, "decorators") and node.decorators:
|
|
310
|
+
decorator_prefix = prefix + (self.SPACE if is_last else self.VERTICAL) + " "
|
|
311
|
+
for decorator in node.decorators:
|
|
312
|
+
lines.append(f"{decorator_prefix}{decorator}")
|
|
313
|
+
|
|
314
|
+
# Add docstring on separate line
|
|
315
|
+
if self.show_docstrings and hasattr(node, "docstring") and node.docstring:
|
|
316
|
+
docstring_prefix = prefix + (self.SPACE if is_last else self.VERTICAL) + " "
|
|
317
|
+
lines.append(f'{docstring_prefix}"{node.docstring}"')
|
|
318
|
+
|
|
319
|
+
# Format children
|
|
320
|
+
if hasattr(node, "children") and node.children:
|
|
321
|
+
child_prefix = prefix + (self.SPACE if is_last else self.VERTICAL)
|
|
322
|
+
for j, child in enumerate(node.children):
|
|
323
|
+
is_last_child = j == len(node.children) - 1
|
|
324
|
+
lines.extend(self._format_structure_node(child, child_prefix, is_last_child))
|
|
325
|
+
|
|
326
|
+
return lines
|
|
327
|
+
|
|
328
|
+
def _flatten(self, structures: list[StructureNode]) -> list[StructureNode]:
|
|
329
|
+
"""Flatten structure tree to get all nodes."""
|
|
330
|
+
result = []
|
|
331
|
+
for node in structures:
|
|
332
|
+
result.append(node)
|
|
333
|
+
if hasattr(node, "children") and node.children:
|
|
334
|
+
result.extend(self._flatten(node.children))
|
|
335
|
+
return result
|
|
336
|
+
|
|
337
|
+
def _flatten_top_level(self, structures: list[StructureNode]) -> list[StructureNode]:
|
|
338
|
+
"""
|
|
339
|
+
Create shallow copies of structures without children.
|
|
340
|
+
|
|
341
|
+
Returns only top-level classes and functions, stripping nested methods.
|
|
342
|
+
Reduces output by ~50% while maintaining overview.
|
|
343
|
+
"""
|
|
344
|
+
flattened = []
|
|
345
|
+
for node in structures:
|
|
346
|
+
# Skip non-code structures (file-info, imports)
|
|
347
|
+
if node.type in ('file-info', 'imports'):
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
# Create shallow copy with no children
|
|
351
|
+
shallow = StructureNode(
|
|
352
|
+
type=node.type,
|
|
353
|
+
name=node.name,
|
|
354
|
+
start_line=node.start_line,
|
|
355
|
+
end_line=node.end_line,
|
|
356
|
+
signature=None, # Strip signatures for compactness
|
|
357
|
+
decorators=[], # Strip decorators
|
|
358
|
+
docstring=None, # Strip docstrings
|
|
359
|
+
complexity=None,
|
|
360
|
+
modifiers=[],
|
|
361
|
+
children=[] # No children - flattened!
|
|
362
|
+
)
|
|
363
|
+
flattened.append(shallow)
|
|
364
|
+
return flattened
|
scantool/formatter.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Pretty tree formatter for file structure with rich metadata display."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from .scanners import StructureNode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TreeFormatter:
|
|
9
|
+
"""Formats structure nodes as a pretty tree with metadata."""
|
|
10
|
+
|
|
11
|
+
# Tree drawing characters
|
|
12
|
+
BRANCH = "├─"
|
|
13
|
+
LAST_BRANCH = "└─"
|
|
14
|
+
VERTICAL = "│ "
|
|
15
|
+
SPACE = " "
|
|
16
|
+
|
|
17
|
+
def __init__(self, show_signatures: bool = True, show_decorators: bool = True,
|
|
18
|
+
show_docstrings: bool = True, show_complexity: bool = False):
|
|
19
|
+
"""
|
|
20
|
+
Initialize formatter with display options.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
show_signatures: Display function signatures
|
|
24
|
+
show_decorators: Display decorators
|
|
25
|
+
show_docstrings: Display first line of docstrings
|
|
26
|
+
show_complexity: Display complexity metrics
|
|
27
|
+
"""
|
|
28
|
+
self.show_signatures = show_signatures
|
|
29
|
+
self.show_decorators = show_decorators
|
|
30
|
+
self.show_docstrings = show_docstrings
|
|
31
|
+
self.show_complexity = show_complexity
|
|
32
|
+
|
|
33
|
+
def format(self, file_path: str, structures: list[StructureNode]) -> str:
|
|
34
|
+
"""Format the structure as a pretty tree."""
|
|
35
|
+
if not structures:
|
|
36
|
+
return f"{Path(file_path).name} (empty file)"
|
|
37
|
+
|
|
38
|
+
# Get file line range (excluding metadata nodes with line 0)
|
|
39
|
+
content_nodes = [s for s in self._flatten(structures) if s.start_line > 0 or s.end_line > 0]
|
|
40
|
+
if content_nodes:
|
|
41
|
+
min_line = min(s.start_line for s in content_nodes)
|
|
42
|
+
max_line = max(s.end_line for s in content_nodes)
|
|
43
|
+
lines = [f"{Path(file_path).name} ({min_line}-{max_line})"]
|
|
44
|
+
else:
|
|
45
|
+
lines = [f"{Path(file_path).name}"]
|
|
46
|
+
|
|
47
|
+
for i, node in enumerate(structures):
|
|
48
|
+
is_last = i == len(structures) - 1
|
|
49
|
+
lines.extend(self._format_node(node, "", is_last))
|
|
50
|
+
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
|
|
53
|
+
def _format_node(self, node: StructureNode, prefix: str, is_last: bool) -> list[str]:
|
|
54
|
+
"""Format a single node and its children with metadata."""
|
|
55
|
+
lines = []
|
|
56
|
+
|
|
57
|
+
# Current node connector
|
|
58
|
+
connector = self.LAST_BRANCH if is_last else self.BRANCH
|
|
59
|
+
|
|
60
|
+
# Special formatting for file-info nodes
|
|
61
|
+
if node.type == "file-info" and node.file_metadata:
|
|
62
|
+
meta = node.file_metadata
|
|
63
|
+
|
|
64
|
+
# Format timestamp as readable datetime
|
|
65
|
+
modified_iso = meta.get('modified', '')
|
|
66
|
+
if modified_iso:
|
|
67
|
+
try:
|
|
68
|
+
dt = datetime.fromisoformat(modified_iso)
|
|
69
|
+
# Format as: 2025-10-17 14:30
|
|
70
|
+
modified_str = dt.strftime('%Y-%m-%d %H:%M')
|
|
71
|
+
except Exception:
|
|
72
|
+
# Fallback to just date if parsing fails
|
|
73
|
+
modified_str = modified_iso.split('T')[0]
|
|
74
|
+
else:
|
|
75
|
+
modified_str = ""
|
|
76
|
+
|
|
77
|
+
parts = [
|
|
78
|
+
f"{prefix}{connector} {node.type}:",
|
|
79
|
+
meta['size_formatted'],
|
|
80
|
+
f"modified: {modified_str}" if modified_str else ""
|
|
81
|
+
]
|
|
82
|
+
lines.append(" ".join(p for p in parts if p))
|
|
83
|
+
return lines
|
|
84
|
+
|
|
85
|
+
# Build the main node line
|
|
86
|
+
parts = [f"{prefix}{connector} {node.type}: {node.name}"]
|
|
87
|
+
|
|
88
|
+
# Add signature if available
|
|
89
|
+
if self.show_signatures and node.signature:
|
|
90
|
+
parts.append(node.signature)
|
|
91
|
+
|
|
92
|
+
# Add line numbers (skip for file-info nodes with line 0)
|
|
93
|
+
if node.start_line > 0 or node.end_line > 0:
|
|
94
|
+
parts.append(f"({node.start_line}-{node.end_line})")
|
|
95
|
+
|
|
96
|
+
# Add modifiers if present
|
|
97
|
+
if node.modifiers:
|
|
98
|
+
modifiers_str = " ".join(node.modifiers)
|
|
99
|
+
parts.append(f"[{modifiers_str}]")
|
|
100
|
+
|
|
101
|
+
# Add complexity indicator if enabled
|
|
102
|
+
if self.show_complexity and node.complexity:
|
|
103
|
+
complexity_str = self._format_complexity(node.complexity)
|
|
104
|
+
if complexity_str:
|
|
105
|
+
parts.append(complexity_str)
|
|
106
|
+
|
|
107
|
+
lines.append(" ".join(parts))
|
|
108
|
+
|
|
109
|
+
# Add decorators on separate lines (indented)
|
|
110
|
+
if self.show_decorators and node.decorators:
|
|
111
|
+
decorator_prefix = prefix + (self.SPACE if is_last else self.VERTICAL) + " "
|
|
112
|
+
for decorator in node.decorators:
|
|
113
|
+
lines.append(f"{decorator_prefix}{decorator}")
|
|
114
|
+
|
|
115
|
+
# Add docstring on separate line (indented)
|
|
116
|
+
if self.show_docstrings and node.docstring:
|
|
117
|
+
docstring_prefix = prefix + (self.SPACE if is_last else self.VERTICAL) + " "
|
|
118
|
+
lines.append(f'{docstring_prefix}"{node.docstring}"')
|
|
119
|
+
|
|
120
|
+
# Format children
|
|
121
|
+
if node.children:
|
|
122
|
+
# New prefix for children
|
|
123
|
+
child_prefix = prefix + (self.SPACE if is_last else self.VERTICAL)
|
|
124
|
+
|
|
125
|
+
for i, child in enumerate(node.children):
|
|
126
|
+
is_last_child = i == len(node.children) - 1
|
|
127
|
+
lines.extend(self._format_node(child, child_prefix, is_last_child))
|
|
128
|
+
|
|
129
|
+
return lines
|
|
130
|
+
|
|
131
|
+
def _format_complexity(self, complexity: dict) -> str:
|
|
132
|
+
"""Format complexity metrics as a compact string."""
|
|
133
|
+
parts = []
|
|
134
|
+
|
|
135
|
+
if complexity.get("lines", 0) > 100:
|
|
136
|
+
parts.append(f"📏{complexity['lines']}")
|
|
137
|
+
|
|
138
|
+
if complexity.get("max_depth", 0) > 5:
|
|
139
|
+
parts.append(f"🔄{complexity['max_depth']}")
|
|
140
|
+
|
|
141
|
+
if complexity.get("branches", 0) > 10:
|
|
142
|
+
parts.append(f"🌿{complexity['branches']}")
|
|
143
|
+
|
|
144
|
+
return " ".join(parts) if parts else ""
|
|
145
|
+
|
|
146
|
+
def _flatten(self, structures: list[StructureNode]) -> list[StructureNode]:
|
|
147
|
+
"""Flatten structure tree to get all nodes."""
|
|
148
|
+
result = []
|
|
149
|
+
for node in structures:
|
|
150
|
+
result.append(node)
|
|
151
|
+
if node.children:
|
|
152
|
+
result.extend(self._flatten(node.children))
|
|
153
|
+
return result
|
scantool/gitignore.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Gitignore parsing and path matching utilities."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GitignoreParser:
|
|
9
|
+
"""Parse and match paths against gitignore patterns."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, patterns: list[str]):
|
|
12
|
+
"""
|
|
13
|
+
Initialize gitignore parser with patterns.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
patterns: List of gitignore pattern strings
|
|
17
|
+
"""
|
|
18
|
+
self.patterns = []
|
|
19
|
+
for pattern in patterns:
|
|
20
|
+
pattern = pattern.strip()
|
|
21
|
+
# Skip empty lines and comments
|
|
22
|
+
if not pattern or pattern.startswith('#'):
|
|
23
|
+
continue
|
|
24
|
+
self.patterns.append(self._compile_pattern(pattern))
|
|
25
|
+
|
|
26
|
+
def _compile_pattern(self, pattern: str) -> tuple[re.Pattern, bool]:
|
|
27
|
+
"""
|
|
28
|
+
Compile a gitignore pattern to regex.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Tuple of (compiled_regex, is_negation)
|
|
32
|
+
"""
|
|
33
|
+
is_negation = pattern.startswith('!')
|
|
34
|
+
if is_negation:
|
|
35
|
+
pattern = pattern[1:]
|
|
36
|
+
|
|
37
|
+
# Directory-only pattern
|
|
38
|
+
if pattern.endswith('/'):
|
|
39
|
+
pattern = pattern[:-1]
|
|
40
|
+
is_dir_only = True
|
|
41
|
+
else:
|
|
42
|
+
is_dir_only = False
|
|
43
|
+
|
|
44
|
+
# Anchored pattern (starts with /)
|
|
45
|
+
if pattern.startswith('/'):
|
|
46
|
+
pattern = pattern[1:]
|
|
47
|
+
anchored = True
|
|
48
|
+
else:
|
|
49
|
+
anchored = False
|
|
50
|
+
|
|
51
|
+
# Convert gitignore glob to regex
|
|
52
|
+
regex_parts = []
|
|
53
|
+
i = 0
|
|
54
|
+
while i < len(pattern):
|
|
55
|
+
char = pattern[i]
|
|
56
|
+
if char == '*':
|
|
57
|
+
if i + 1 < len(pattern) and pattern[i + 1] == '*':
|
|
58
|
+
# ** matches any number of directories
|
|
59
|
+
regex_parts.append('.*')
|
|
60
|
+
i += 2
|
|
61
|
+
# Skip following /
|
|
62
|
+
if i < len(pattern) and pattern[i] == '/':
|
|
63
|
+
i += 1
|
|
64
|
+
continue
|
|
65
|
+
else:
|
|
66
|
+
# * matches anything except /
|
|
67
|
+
regex_parts.append('[^/]*')
|
|
68
|
+
elif char == '?':
|
|
69
|
+
regex_parts.append('[^/]')
|
|
70
|
+
elif char == '[':
|
|
71
|
+
# Character class
|
|
72
|
+
j = i + 1
|
|
73
|
+
while j < len(pattern) and pattern[j] != ']':
|
|
74
|
+
j += 1
|
|
75
|
+
if j < len(pattern):
|
|
76
|
+
regex_parts.append(pattern[i:j + 1])
|
|
77
|
+
i = j
|
|
78
|
+
else:
|
|
79
|
+
regex_parts.append(re.escape(char))
|
|
80
|
+
else:
|
|
81
|
+
regex_parts.append(re.escape(char))
|
|
82
|
+
i += 1
|
|
83
|
+
|
|
84
|
+
regex_str = ''.join(regex_parts)
|
|
85
|
+
|
|
86
|
+
# Build final pattern
|
|
87
|
+
# Pattern should match:
|
|
88
|
+
# 1. The exact name (.venv matches .venv)
|
|
89
|
+
# 2. The name as a directory (.venv matches .venv/)
|
|
90
|
+
# 3. Anything under it (.venv matches .venv/foo/bar.py)
|
|
91
|
+
|
|
92
|
+
if anchored:
|
|
93
|
+
# Must match from start
|
|
94
|
+
# Matches: exact name, or name followed by / and anything
|
|
95
|
+
final_pattern = f'^{regex_str}(?:/.*)?$'
|
|
96
|
+
else:
|
|
97
|
+
# Can match anywhere in path
|
|
98
|
+
# Matches at start or after /, then exact name or name/ with anything
|
|
99
|
+
final_pattern = f'(?:^|/){regex_str}(?:/.*)?$'
|
|
100
|
+
|
|
101
|
+
return (re.compile(final_pattern), is_negation)
|
|
102
|
+
|
|
103
|
+
def matches(self, path: str, is_dir: bool = False) -> bool:
|
|
104
|
+
"""
|
|
105
|
+
Check if path matches any pattern.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
path: Relative path to check
|
|
109
|
+
is_dir: Whether the path is a directory
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
True if path should be ignored
|
|
113
|
+
"""
|
|
114
|
+
# Normalize path (remove leading ./ if present)
|
|
115
|
+
if path.startswith('./'):
|
|
116
|
+
path = path[2:]
|
|
117
|
+
|
|
118
|
+
ignored = False
|
|
119
|
+
for regex, is_negation in self.patterns:
|
|
120
|
+
if regex.search(path):
|
|
121
|
+
ignored = not is_negation
|
|
122
|
+
|
|
123
|
+
return ignored
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_gitignore(directory: Path) -> Optional[GitignoreParser]:
|
|
127
|
+
"""
|
|
128
|
+
Load .gitignore file from directory.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
directory: Directory to search for .gitignore
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
GitignoreParser or None if no .gitignore found
|
|
135
|
+
"""
|
|
136
|
+
gitignore_path = directory / '.gitignore'
|
|
137
|
+
if not gitignore_path.exists():
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
with open(gitignore_path, 'r', encoding='utf-8') as f:
|
|
142
|
+
patterns = f.readlines()
|
|
143
|
+
return GitignoreParser(patterns)
|
|
144
|
+
except Exception:
|
|
145
|
+
return None
|