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 ADDED
@@ -0,0 +1,7 @@
1
+ """File Scanner MCP - Beautiful file structure scanner with tree formatting."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .server import main
6
+
7
+ __all__ = ["main"]
@@ -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