codegraph-py 1.0.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 (44) hide show
  1. codegraph/__init__.py +12 -0
  2. codegraph/__main__.py +6 -0
  3. codegraph/cli.py +911 -0
  4. codegraph/codegraph.py +618 -0
  5. codegraph/context/__init__.py +216 -0
  6. codegraph/db/__init__.py +11 -0
  7. codegraph/db/connection.py +163 -0
  8. codegraph/db/queries.py +752 -0
  9. codegraph/db/schema.sql +151 -0
  10. codegraph/directory.py +160 -0
  11. codegraph/errors.py +101 -0
  12. codegraph/extraction/__init__.py +956 -0
  13. codegraph/extraction/languages/__init__.py +64 -0
  14. codegraph/extraction/languages/base.py +132 -0
  15. codegraph/extraction/languages/c_cfg.py +53 -0
  16. codegraph/extraction/languages/cpp_cfg.py +60 -0
  17. codegraph/extraction/languages/dart_cfg.py +53 -0
  18. codegraph/extraction/languages/go_cfg.py +70 -0
  19. codegraph/extraction/languages/java_cfg.py +62 -0
  20. codegraph/extraction/languages/javascript_cfg.py +84 -0
  21. codegraph/extraction/languages/kotlin_cfg.py +52 -0
  22. codegraph/extraction/languages/lua_cfg.py +65 -0
  23. codegraph/extraction/languages/python_cfg.py +75 -0
  24. codegraph/extraction/languages/ruby_cfg.py +48 -0
  25. codegraph/extraction/languages/rust_cfg.py +68 -0
  26. codegraph/extraction/languages/scala_cfg.py +59 -0
  27. codegraph/extraction/languages/swift_cfg.py +64 -0
  28. codegraph/extraction/languages/typescript_cfg.py +89 -0
  29. codegraph/extraction/tree_sitter_extractor.py +689 -0
  30. codegraph/graph/__init__.py +685 -0
  31. codegraph/installer/__init__.py +6 -0
  32. codegraph/mcp/__init__.py +668 -0
  33. codegraph/project_config.py +191 -0
  34. codegraph/resolution/__init__.py +337 -0
  35. codegraph/search/__init__.py +653 -0
  36. codegraph/sync/__init__.py +204 -0
  37. codegraph/types.py +334 -0
  38. codegraph/ui/__init__.py +11 -0
  39. codegraph/utils.py +218 -0
  40. codegraph_py-1.0.0.dist-info/METADATA +238 -0
  41. codegraph_py-1.0.0.dist-info/RECORD +44 -0
  42. codegraph_py-1.0.0.dist-info/WHEEL +5 -0
  43. codegraph_py-1.0.0.dist-info/entry_points.txt +2 -0
  44. codegraph_py-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,653 @@
1
+ """
2
+ CodeGraph Search Module
3
+
4
+ Provides full-text search, fuzzy matching, and natural language symbol lookup.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import Dict, List, Optional, Tuple
12
+
13
+ from codegraph.db.connection import DatabaseConnection
14
+ from codegraph.types import Node, SearchOptions, SearchResult, SegmentMatch
15
+
16
+
17
+ # =============================================================================
18
+ # Identifier Segmentation
19
+ # =============================================================================
20
+
21
+ # Regex patterns for identifier splitting
22
+ CAMEL_CASE_PATTERN = re.compile(r'(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])')
23
+ SNAKE_CASE_PATTERN = re.compile(r'_+|[-]+')
24
+ PASCAL_CASE_PATTERN = re.compile(r'(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])')
25
+
26
+
27
+ def split_identifier_segments(identifier: str) -> List[str]:
28
+ """
29
+ Split an identifier into meaningful segments.
30
+
31
+ Handles:
32
+ - camelCase: "myFunctionName" → ["my", "function", "name"]
33
+ - snake_case: "my_function_name" → ["my", "function", "name"]
34
+ - PascalCase: "MyClassName" → ["my", "class", "name"]
35
+ - kebab-case: "my-function-name" → ["my", "function", "name"]
36
+
37
+ Args:
38
+ identifier: The identifier to split (e.g., "getUserById")
39
+
40
+ Returns:
41
+ List of lowercase segments (e.g., ["get", "user", "by", "id"])
42
+ """
43
+ if not identifier:
44
+ return []
45
+
46
+ # First split by snake_case/kebab-case separators
47
+ segments = SNAKE_CASE_PATTERN.split(identifier)
48
+
49
+ result = []
50
+ for segment in segments:
51
+ if not segment:
52
+ continue
53
+ # Then split camelCase within each part
54
+ camel_parts = CAMEL_CASE_PATTERN.split(segment)
55
+ for part in camel_parts:
56
+ if part:
57
+ result.append(part.lower())
58
+
59
+ return result if result else [identifier.lower()]
60
+
61
+
62
+ # =============================================================================
63
+ # Prose Extraction
64
+ # =============================================================================
65
+
66
+ # Common words to exclude from prose matching
67
+ COMMON_WORDS = {
68
+ 'a', 'an', 'the', 'and', 'or', 'but', 'is', 'are', 'was', 'were',
69
+ 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did',
70
+ 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'shall',
71
+ 'can', 'need', 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by',
72
+ 'from', 'as', 'into', 'through', 'during', 'before', 'after',
73
+ 'above', 'below', 'between', 'under', 'again', 'further', 'then',
74
+ 'once', 'get', 'set', 'add', 'remove', 'update', 'delete', 'create',
75
+ 'make', 'new', 'this', 'that', 'these', 'those', 'it', 'its',
76
+ 'function', 'method', 'class', 'interface', 'type', 'var', 'let',
77
+ 'const', 'return', 'value', 'data', 'item', 'list', 'obj', 'name',
78
+ 'id', 'by', 'from', 'all', 'any', 'each', 'every', 'some', 'many',
79
+ 'few', 'most', 'other', 'such', 'no', 'nor', 'not', 'only', 'same',
80
+ 'so', 'than', 'too', 'very', 'just', 'also', 'now', 'here', 'there',
81
+ }
82
+
83
+ # Words that map to common programming concepts
84
+ CONCEPT_ALIASES: Dict[str, List[str]] = {
85
+ 'fetch': ['get', 'load', 'retrieve', 'read', 'fetch', 'request'],
86
+ 'fetching': ['get', 'load', 'retrieve', 'read', 'fetch', 'request'],
87
+ 'create': ['add', 'insert', 'new', 'create', 'make', 'build'],
88
+ 'update': ['edit', 'modify', 'change', 'update', 'set'],
89
+ 'delete': ['remove', 'drop', 'delete', 'clear', 'erase'],
90
+ 'save': ['store', 'persist', 'save', 'write', 'commit'],
91
+ 'find': ['search', 'query', 'lookup', 'find', 'locate', 'get'],
92
+ 'handler': ['callback', 'handler', 'listener', 'on'],
93
+ 'listener': ['callback', 'handler', 'listener', 'on'],
94
+ 'config': ['configuration', 'settings', 'options', 'config'],
95
+ 'init': ['initialize', 'setup', 'init', 'bootstrap', 'start'],
96
+ 'utils': ['utilities', 'helpers', 'tools', 'utils'],
97
+ 'req': ['request', 'req'],
98
+ 'resp': ['response', 'resp'],
99
+ 'err': ['error', 'err', 'exception'],
100
+ 'elem': ['element', 'elem'],
101
+ 'msg': ['message', 'msg'],
102
+ 'num': ['number', 'num', 'count'],
103
+ 'str': ['string', 'str'],
104
+ 'bool': ['boolean', 'bool'],
105
+ 'fn': ['function', 'func', 'fn'],
106
+ }
107
+
108
+
109
+ def extract_prose_candidates(query: str) -> List[str]:
110
+ """
111
+ Extract candidate words from natural language query for prose matching.
112
+
113
+ Extracts meaningful words, normalizes them, and expands with aliases
114
+ for common programming concepts.
115
+
116
+ Args:
117
+ query: Natural language query (e.g., "find user by id")
118
+
119
+ Returns:
120
+ List of normalized candidate words (e.g., ["find", "user", "id"])
121
+ """
122
+ # Tokenize: split on whitespace and punctuation, keep alphanumerics
123
+ words = re.findall(r'[a-zA-Z0-9]+', query.lower())
124
+
125
+ # Filter out common words and very short words
126
+ candidates = []
127
+ seen = set()
128
+
129
+ for word in words:
130
+ if word in COMMON_WORDS or len(word) < 2:
131
+ continue
132
+ if word in seen:
133
+ continue
134
+ seen.add(word)
135
+ candidates.append(word)
136
+
137
+ # Add aliases for common programming concepts
138
+ if word in CONCEPT_ALIASES:
139
+ for alias in CONCEPT_ALIASES[word]:
140
+ if alias not in seen:
141
+ seen.add(alias)
142
+ candidates.append(alias)
143
+
144
+ return candidates
145
+
146
+
147
+ # =============================================================================
148
+ # Query Parsing
149
+ # =============================================================================
150
+
151
+ # Field query pattern: kind:, lang:, path:
152
+ FIELD_PATTERN = re.compile(r'^(kind|lang|path):(.+)$', re.IGNORECASE)
153
+
154
+
155
+ @dataclass
156
+ class ParsedQuery:
157
+ """A parsed search query with field filters and remaining text."""
158
+ text: str
159
+ kinds: List[str]
160
+ languages: List[str]
161
+ paths: List[str]
162
+
163
+
164
+ def parse_query(query: str) -> ParsedQuery:
165
+ """
166
+ Parse a search query, extracting field filters.
167
+
168
+ Supported fields:
169
+ - kind: Filter by node kind (e.g., "kind:function")
170
+ - lang: Filter by language (e.g., "lang:typescript")
171
+ - path: Filter by file path pattern (e.g., "path:src/utils")
172
+
173
+ Examples:
174
+ >>> parse_query("get user data")
175
+ ParsedQuery(text='get user data', kinds=[], languages=[], paths=[])
176
+
177
+ >>> parse_query("kind:class lang:typescript path:src")
178
+ ParsedQuery(text='', kinds=['class'], languages=['typescript'], paths=['src'])
179
+
180
+ >>> parse_query("find method kind:function lang:python")
181
+ ParsedQuery(text='find method', kinds=['function'], languages=['python'], paths=[])
182
+
183
+ Args:
184
+ query: The raw query string
185
+
186
+ Returns:
187
+ ParsedQuery with extracted fields and remaining text
188
+ """
189
+ kinds: List[str] = []
190
+ languages: List[str] = []
191
+ paths: List[str] = []
192
+ remaining_parts: List[str] = []
193
+
194
+ # Split on quoted strings to preserve them
195
+ tokens = re.findall(r'(?:[^\s"]+|"[^"]*")+', query)
196
+
197
+ for token in tokens:
198
+ # Handle quoted strings
199
+ if token.startswith('"') and token.endswith('"'):
200
+ remaining_parts.append(token[1:-1])
201
+ continue
202
+
203
+ # Check for field prefix
204
+ match = FIELD_PATTERN.match(token)
205
+ if match:
206
+ field_name = match.group(1).lower()
207
+ field_value = match.group(2).strip()
208
+
209
+ if field_name == 'kind':
210
+ kinds.append(field_value.lower())
211
+ elif field_name == 'lang':
212
+ languages.append(field_value.lower())
213
+ elif field_name == 'path':
214
+ paths.append(field_value.lower())
215
+ else:
216
+ remaining_parts.append(token)
217
+
218
+ return ParsedQuery(
219
+ text=' '.join(remaining_parts),
220
+ kinds=kinds,
221
+ languages=languages,
222
+ paths=paths,
223
+ )
224
+
225
+
226
+ # =============================================================================
227
+ # Fuzzy Searcher
228
+ # =============================================================================
229
+
230
+ class FuzzySearcher:
231
+ """
232
+ Fuzzy search for code symbols using FTS5 with LIKE fallback.
233
+
234
+ Uses SQLite FTS5 for fast full-text search when available,
235
+ falls back to LIKE queries for pattern matching.
236
+ """
237
+
238
+ def __init__(self, db_path: Optional[str] = None):
239
+ """
240
+ Initialize the fuzzy searcher.
241
+
242
+ Args:
243
+ db_path: Optional path to SQLite database
244
+ """
245
+ self.db_path = db_path
246
+ self._fts_available: Optional[bool] = None
247
+
248
+ def _check_fts_available(self) -> bool:
249
+ """Check if FTS5 is available."""
250
+ if self._fts_available is not None:
251
+ return self._fts_available
252
+
253
+ try:
254
+ db = DatabaseConnection.open(self.db_path)
255
+ cursor = db.get_db().cursor()
256
+ cursor.execute(
257
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'"
258
+ )
259
+ self._fts_available = cursor.fetchone() is not None
260
+ db.close()
261
+ return self._fts_available
262
+ except Exception:
263
+ self._fts_available = False
264
+ return False
265
+
266
+ def _node_from_row(self, row: tuple) -> Node:
267
+ """Convert a database row to a Node object."""
268
+ return Node(
269
+ id=row[0],
270
+ kind=row[1],
271
+ name=row[2],
272
+ qualified_name=row[3],
273
+ file_path=row[4],
274
+ language=row[5],
275
+ start_line=row[6],
276
+ end_line=row[7],
277
+ start_column=row[8],
278
+ end_column=row[9],
279
+ docstring=row[10],
280
+ signature=row[11],
281
+ visibility=row[12],
282
+ is_exported=bool(row[13]),
283
+ is_async=bool(row[14]),
284
+ is_static=bool(row[15]),
285
+ is_abstract=bool(row[16]),
286
+ decorators=row[17],
287
+ type_parameters=row[18],
288
+ return_type=row[19],
289
+ updated_at=row[20],
290
+ )
291
+
292
+ def search(
293
+ self,
294
+ query: str,
295
+ options: Optional[SearchOptions] = None,
296
+ ) -> List[SearchResult]:
297
+ """
298
+ Search for symbols matching the query.
299
+
300
+ Args:
301
+ query: Search query (supports field filters like kind:, lang:)
302
+ options: Search options (kinds, languages, limit, etc.)
303
+
304
+ Returns:
305
+ List of SearchResult objects sorted by relevance
306
+ """
307
+ options = options or SearchOptions()
308
+
309
+ # Parse the query for field filters
310
+ parsed = parse_query(query)
311
+
312
+ # Combine parsed filters with options
313
+ kinds = parsed.kinds or (options.kinds or [])
314
+ languages = parsed.languages or (options.languages or [])
315
+ paths = parsed.paths or []
316
+ text = parsed.text
317
+
318
+ results: List[SearchResult] = []
319
+
320
+ if self._check_fts_available() and text:
321
+ results = self._fts_search(text, kinds, languages, paths, options)
322
+ else:
323
+ results = self._like_search(text, kinds, languages, paths, options)
324
+
325
+ # Apply include/exclude patterns
326
+ if options.include_patterns or options.exclude_patterns:
327
+ results = self._filter_by_patterns(results, options)
328
+
329
+ # Apply limit and offset
330
+ return results[options.offset : options.offset + options.limit]
331
+
332
+ def _fts_search(
333
+ self,
334
+ text: str,
335
+ kinds: List[str],
336
+ languages: List[str],
337
+ paths: List[str],
338
+ options: SearchOptions,
339
+ ) -> List[SearchResult]:
340
+ """Full-text search using FTS5."""
341
+ db = DatabaseConnection.open(self.db_path)
342
+ cursor = db.get_db().cursor()
343
+
344
+ # Build FTS query with prefix matching
345
+ fts_query = self._build_fts_query(text)
346
+
347
+ # Build WHERE clause for filters
348
+ where_clauses = []
349
+ params: List = []
350
+
351
+ if kinds:
352
+ placeholders = ','.join('?' * len(kinds))
353
+ where_clauses.append(f"n.kind IN ({placeholders})")
354
+ params.extend(kinds)
355
+
356
+ if languages:
357
+ placeholders = ','.join('?' * len(languages))
358
+ where_clauses.append(f"n.language IN ({placeholders})")
359
+ params.extend(languages)
360
+
361
+ if paths:
362
+ path_conditions = []
363
+ for p in paths:
364
+ path_conditions.append("n.file_path LIKE ?")
365
+ params.append(f"%{p}%")
366
+ where_clauses.append(f"({' OR '.join(path_conditions)})")
367
+
368
+ where_str = " AND ".join(where_clauses) if where_clauses else "1=1"
369
+
370
+ sql = f"""
371
+ SELECT
372
+ n.id, n.kind, n.name, n.qualified_name, n.file_path, n.language,
373
+ n.start_line, n.end_line, n.start_column, n.end_column,
374
+ n.docstring, n.signature, n.visibility,
375
+ n.is_exported, n.is_async, n.is_static, n.is_abstract,
376
+ n.decorators, n.type_parameters, n.return_type, n.updated_at,
377
+ bm25(nodes_fts) as rank
378
+ FROM nodes_fts fts
379
+ JOIN nodes n ON fts.id = n.id
380
+ WHERE nodes_fts MATCH ?
381
+ AND {where_str}
382
+ ORDER BY rank
383
+ LIMIT ?
384
+ """
385
+ params.extend([fts_query, options.limit + options.offset])
386
+
387
+ try:
388
+ cursor.execute(sql, params)
389
+ rows = cursor.fetchall()
390
+ except Exception:
391
+ # FTS query failed, fall back to LIKE
392
+ db.close()
393
+ return self._like_search(text, kinds, languages, paths, options)
394
+
395
+ db.close()
396
+
397
+ results = []
398
+ for row in rows:
399
+ node = self._node_from_row(row[:-1])
400
+ # BM25 returns negative values, convert to positive score
401
+ score = abs(row[-1]) if row[-1] else 0.5
402
+ results.append(SearchResult(node=node, score=score))
403
+
404
+ return results
405
+
406
+ def _like_search(
407
+ self,
408
+ text: str,
409
+ kinds: List[str],
410
+ languages: List[str],
411
+ paths: List[str],
412
+ options: SearchOptions,
413
+ ) -> List[SearchResult]:
414
+ """Fallback search using LIKE queries."""
415
+ db = DatabaseConnection.open(self.db_path)
416
+ cursor = db.get_db().cursor()
417
+
418
+ # Build LIKE pattern
419
+ like_pattern = f"%{text}%"
420
+
421
+ # Build WHERE clause
422
+ where_clauses = [
423
+ "(n.name LIKE ? OR n.qualified_name LIKE ? OR n.docstring LIKE ?)"
424
+ ]
425
+ params: List = [like_pattern, like_pattern, like_pattern]
426
+
427
+ if kinds:
428
+ placeholders = ','.join('?' * len(kinds))
429
+ where_clauses.append(f"n.kind IN ({placeholders})")
430
+ params.extend(kinds)
431
+
432
+ if languages:
433
+ placeholders = ','.join('?' * len(languages))
434
+ where_clauses.append(f"n.language IN ({placeholders})")
435
+ params.extend(languages)
436
+
437
+ if paths:
438
+ path_conditions = []
439
+ for p in paths:
440
+ path_conditions.append("n.file_path LIKE ?")
441
+ params.append(f"%{p}%")
442
+ where_clauses.append(f"({' OR '.join(path_conditions)})")
443
+
444
+ where_str = " AND ".join(where_clauses)
445
+
446
+ sql = f"""
447
+ SELECT
448
+ n.id, n.kind, n.name, n.qualified_name, n.file_path, n.language,
449
+ n.start_line, n.end_line, n.start_column, n.end_column,
450
+ n.docstring, n.signature, n.visibility,
451
+ n.is_exported, n.is_async, n.is_static, n.is_abstract,
452
+ n.decorators, n.type_parameters, n.return_type, n.updated_at
453
+ FROM nodes n
454
+ WHERE {where_str}
455
+ LIMIT ?
456
+ """
457
+ params.append(options.limit + options.offset)
458
+
459
+ cursor.execute(sql, params)
460
+ rows = cursor.fetchall()
461
+ db.close()
462
+
463
+ results = []
464
+ for row in rows:
465
+ node = self._node_from_row(row)
466
+ # LIKE searches get a base score, FTS gets actual BM25
467
+ score = 0.5
468
+ results.append(SearchResult(node=node, score=score))
469
+
470
+ return results
471
+
472
+ def _build_fts_query(self, text: str) -> str:
473
+ """Build an FTS5 query with prefix matching."""
474
+ # Add prefix matching for each term
475
+ terms = text.split()
476
+ if not terms:
477
+ return '""'
478
+
479
+ # Use OR between terms for broader matching
480
+ fts_terms = []
481
+ for term in terms:
482
+ # Add wildcard for prefix matching
483
+ fts_terms.append(f'"{term}"*')
484
+
485
+ return ' OR '.join(fts_terms)
486
+
487
+ def _filter_by_patterns(
488
+ self,
489
+ results: List[SearchResult],
490
+ options: SearchOptions,
491
+ ) -> List[SearchResult]:
492
+ """Filter results by include/exclude patterns."""
493
+ filtered = []
494
+
495
+ for result in results:
496
+ file_path = result.node.file_path
497
+
498
+ # Check exclude patterns first
499
+ if options.exclude_patterns:
500
+ excluded = False
501
+ for pattern in options.exclude_patterns:
502
+ if self._match_pattern(file_path, pattern):
503
+ excluded = True
504
+ break
505
+ if excluded:
506
+ continue
507
+
508
+ # Check include patterns (if specified)
509
+ if options.include_patterns:
510
+ included = False
511
+ for pattern in options.include_patterns:
512
+ if self._match_pattern(file_path, pattern):
513
+ included = True
514
+ break
515
+ if not included:
516
+ continue
517
+
518
+ filtered.append(result)
519
+
520
+ return filtered
521
+
522
+ def _match_pattern(self, text: str, pattern: str) -> bool:
523
+ """Simple glob-style pattern matching."""
524
+ # Convert glob to regex
525
+ regex_pattern = pattern.replace('.', r'\.').replace('*', '.*').replace('?', '.')
526
+ return bool(re.match(f'^{regex_pattern}$', text, re.IGNORECASE))
527
+
528
+ def search_by_segments(
529
+ self,
530
+ query: str,
531
+ options: Optional[SearchOptions] = None,
532
+ ) -> List[SegmentMatch]:
533
+ """
534
+ Search for symbols matching query segments.
535
+
536
+ Matches prose words from the query against pre-computed name segments
537
+ in the name_segment_vocab table.
538
+
539
+ Args:
540
+ query: Natural language query
541
+ options: Search options
542
+
543
+ Returns:
544
+ List of SegmentMatch objects
545
+ """
546
+ options = options or SearchOptions()
547
+
548
+ # Extract candidate words from query
549
+ candidates = extract_prose_candidates(query)
550
+ if not candidates:
551
+ return []
552
+
553
+ db = DatabaseConnection.open(self.db_path)
554
+ cursor = db.get_db().cursor()
555
+
556
+ matches: Dict[str, SegmentMatch] = {}
557
+
558
+ for word in candidates:
559
+ # Find nodes that have this segment in their name
560
+ sql = """
561
+ SELECT n.name, n.kind, n.file_path, n.start_line
562
+ FROM nodes n
563
+ JOIN name_segment_vocab seg ON n.name = seg.name
564
+ WHERE seg.segment = ?
565
+ """
566
+ params = [word.lower()]
567
+
568
+ if options.kinds:
569
+ placeholders = ','.join('?' * len(options.kinds))
570
+ sql += f" AND n.kind IN ({placeholders})"
571
+ params.extend(options.kinds)
572
+
573
+ sql += f" LIMIT {options.limit}"
574
+
575
+ try:
576
+ cursor.execute(sql, params)
577
+ rows = cursor.fetchall()
578
+
579
+ for row in rows:
580
+ key = f"{row[0]}:{row[2]}"
581
+ if key not in matches:
582
+ matches[key] = SegmentMatch(
583
+ name=row[0],
584
+ kind=row[1],
585
+ file_path=row[2],
586
+ start_line=row[3],
587
+ matched_words=[],
588
+ )
589
+ if word not in matches[key].matched_words:
590
+ matches[key].matched_words.append(word)
591
+ except Exception:
592
+ # Table might not exist, return empty
593
+ pass
594
+
595
+ db.close()
596
+
597
+ # Sort by number of matched words
598
+ result = list(matches.values())
599
+ result.sort(key=lambda m: len(m.matched_words), reverse=True)
600
+
601
+ return result[: options.limit]
602
+
603
+
604
+ # =============================================================================
605
+ # Convenience Functions
606
+ # =============================================================================
607
+
608
+ def search(
609
+ query: str,
610
+ kinds: Optional[List[str]] = None,
611
+ languages: Optional[List[str]] = None,
612
+ limit: int = 20,
613
+ ) -> List[SearchResult]:
614
+ """
615
+ Convenience function for simple searches.
616
+
617
+ Args:
618
+ query: Search query
619
+ kinds: Optional list of node kinds to filter
620
+ languages: Optional list of languages to filter
621
+ limit: Maximum results to return
622
+
623
+ Returns:
624
+ List of SearchResult objects
625
+ """
626
+ options = SearchOptions(
627
+ kinds=kinds,
628
+ languages=languages,
629
+ limit=limit,
630
+ )
631
+ searcher = FuzzySearcher()
632
+ return searcher.search(query, options)
633
+
634
+
635
+ def search_prose(
636
+ query: str,
637
+ kinds: Optional[List[str]] = None,
638
+ limit: int = 20,
639
+ ) -> List[SegmentMatch]:
640
+ """
641
+ Convenience function for prose-based segment search.
642
+
643
+ Args:
644
+ query: Natural language query
645
+ kinds: Optional list of node kinds to filter
646
+ limit: Maximum results to return
647
+
648
+ Returns:
649
+ List of SegmentMatch objects
650
+ """
651
+ options = SearchOptions(kinds=kinds, limit=limit)
652
+ searcher = FuzzySearcher()
653
+ return searcher.search_by_segments(query, options)