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.
- codegraph/__init__.py +12 -0
- codegraph/__main__.py +6 -0
- codegraph/cli.py +911 -0
- codegraph/codegraph.py +618 -0
- codegraph/context/__init__.py +216 -0
- codegraph/db/__init__.py +11 -0
- codegraph/db/connection.py +163 -0
- codegraph/db/queries.py +752 -0
- codegraph/db/schema.sql +151 -0
- codegraph/directory.py +160 -0
- codegraph/errors.py +101 -0
- codegraph/extraction/__init__.py +956 -0
- codegraph/extraction/languages/__init__.py +64 -0
- codegraph/extraction/languages/base.py +132 -0
- codegraph/extraction/languages/c_cfg.py +53 -0
- codegraph/extraction/languages/cpp_cfg.py +60 -0
- codegraph/extraction/languages/dart_cfg.py +53 -0
- codegraph/extraction/languages/go_cfg.py +70 -0
- codegraph/extraction/languages/java_cfg.py +62 -0
- codegraph/extraction/languages/javascript_cfg.py +84 -0
- codegraph/extraction/languages/kotlin_cfg.py +52 -0
- codegraph/extraction/languages/lua_cfg.py +65 -0
- codegraph/extraction/languages/python_cfg.py +75 -0
- codegraph/extraction/languages/ruby_cfg.py +48 -0
- codegraph/extraction/languages/rust_cfg.py +68 -0
- codegraph/extraction/languages/scala_cfg.py +59 -0
- codegraph/extraction/languages/swift_cfg.py +64 -0
- codegraph/extraction/languages/typescript_cfg.py +89 -0
- codegraph/extraction/tree_sitter_extractor.py +689 -0
- codegraph/graph/__init__.py +685 -0
- codegraph/installer/__init__.py +6 -0
- codegraph/mcp/__init__.py +668 -0
- codegraph/project_config.py +191 -0
- codegraph/resolution/__init__.py +337 -0
- codegraph/search/__init__.py +653 -0
- codegraph/sync/__init__.py +204 -0
- codegraph/types.py +334 -0
- codegraph/ui/__init__.py +11 -0
- codegraph/utils.py +218 -0
- codegraph_py-1.0.0.dist-info/METADATA +238 -0
- codegraph_py-1.0.0.dist-info/RECORD +44 -0
- codegraph_py-1.0.0.dist-info/WHEEL +5 -0
- codegraph_py-1.0.0.dist-info/entry_points.txt +2 -0
- 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)
|