rag-your-code 0.4.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.
ragyourcode/parser.py ADDED
@@ -0,0 +1,485 @@
1
+ """Code parsers. Python is AST-precise; other languages use a line scanner.
2
+
3
+ The non-Python path is three separated layers:
4
+
5
+ Layer 1 line scanner one match attempt per line; the line number IS the
6
+ loop index, so it cannot drift
7
+ Layer 2 rule table per-language declaration patterns, each anchored
8
+ inside a single line
9
+ Layer 3 span closer brace balance, or Ruby's `end`, or the next
10
+ declaration
11
+
12
+ The separation is what fixes the defects, not the individual patterns. One
13
+ whole-file regex previously did all three jobs at once, and its coupling
14
+ produced catastrophic backtracking (a 530-byte file took 12.6 s), an `[^;]*`
15
+ that swallowed every declaration up to the last `)` in a file, and a leading
16
+ `\\s*` that started matches on preceding blank lines so reported line numbers
17
+ and signatures were wrong. A pattern that cannot see a second line cannot
18
+ consume one.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import ast
24
+ import re
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ from .annotate import describe_python
29
+ from .models import CodeUnit
30
+
31
+ EXTENSIONS = {
32
+ ".py": "python", ".pyi": "python", ".js": "javascript", ".jsx": "javascript",
33
+ ".ts": "typescript", ".tsx": "typescript", ".go": "go", ".rs": "rust",
34
+ ".java": "java", ".c": "c", ".h": "c", ".cpp": "cpp", ".cc": "cpp",
35
+ ".php": "php", ".rb": "ruby", ".swift": "swift", ".kt": "kotlin", ".kts": "kotlin",
36
+ ".cs": "csharp", ".scala": "scala", ".sh": "shell", ".bash": "shell",
37
+ }
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class Declaration:
42
+ """One declaration shape for one language family."""
43
+
44
+ kind: str
45
+ pattern: re.Pattern[str]
46
+
47
+
48
+ # Horizontal whitespace only, never ``\s``, which also matches a newline. A
49
+ # leading ``\s*`` is exactly what let the previous parser begin a match on a
50
+ # preceding blank line and report the wrong start_line for 9 declarations in 10.
51
+ IND = r"[^\S\r\n]*"
52
+ SP = r"[^\S\r\n]+"
53
+ ID = r"[A-Za-z_][A-Za-z0-9_]*"
54
+ DOLLAR_ID = r"[A-Za-z_$][A-Za-z0-9_$]*"
55
+ TYPES = "class|interface|struct|trait|enum|protocol|object|module|record|union"
56
+ # Words that open a statement, not a declaration. Without this the bare
57
+ # `TYPE name(args)` shape shared by C, C++, Java and C# also matches `if (x) {`.
58
+ CONTROL = (
59
+ r"(?!(?:if|for|while|switch|catch|return|else|do|new|throw|sizeof|typedef|using"
60
+ r"|namespace|case|default|delete|goto|await|yield|assert|echo|match|when|guard"
61
+ r"|repeat|defer|select|import|package|export|from|try|finally|with|in|is|as)\b)"
62
+ )
63
+ # A declaration line ends where its parameters end, its body opens, or its
64
+ # parameters continue onto the next line -- `constructor(` and
65
+ # `public async Task<string> RenderAsync(` break immediately after the open
66
+ # paren, so end-of-line alone has to count. A trailing `;` still cannot match:
67
+ # the `[^;]*` ahead of this cannot cross one, which is how a same-line prototype
68
+ # or a call statement stays out. Prototypes whose `;` lands on a later line are
69
+ # rejected by the span closer instead.
70
+ TAIL = r"(?:\{|\(|\)|,|:|=>|->|=)?[^\S\r\n]*$"
71
+
72
+ RULES: dict[str, tuple[Declaration, ...]] = {}
73
+
74
+
75
+ def _rule(kind: str, body: str) -> Declaration:
76
+ return Declaration(kind, re.compile(body))
77
+
78
+
79
+ def _register(languages: tuple[str, ...], rules: tuple[Declaration, ...]) -> None:
80
+ for language in languages:
81
+ RULES[language] = rules
82
+
83
+
84
+ _register(("javascript", "typescript"), (
85
+ _rule("class", rf"^{IND}(?:export{SP})?(?:default{SP})?(?:abstract{SP})?(?:{TYPES}){SP}(?P<name>{ID})\b"),
86
+ # `function NAME(` anywhere on the line, so a named function expression such
87
+ # as `return function acquire(task) {` is found. An anonymous `function()`
88
+ # carries no identifier and cannot match.
89
+ _rule("function", rf"\bfunction{IND}\*?{IND}(?P<name>{ID}){IND}\("),
90
+ # A binding is a unit only when its right-hand side is *syntactically* a
91
+ # function literal (SPEC.md). `= makeLimiter(4)` is a reference and `= {` is
92
+ # a value, so an arrow or the `function` keyword must appear on the line.
93
+ _rule("function", rf"^{IND}(?:export{SP})?(?:const|let|var){SP}(?P<name>{DOLLAR_ID})[^\S\r\n]*(?::[^=]*)?={IND}(?:async{SP})?function\b"),
94
+ _rule("function", rf"^{IND}(?:export{SP})?(?:const|let|var){SP}(?P<name>{DOLLAR_ID})[^\S\r\n]*(?::[^=]*)?=[^=]*=>"),
95
+ # Class-body and object-literal shorthand carry no `function` keyword at all.
96
+ _rule("method", rf"^{IND}(?:(?:public|private|protected|readonly|static|abstract|override|declare|async|get|set){SP})*{CONTROL}(?P<name>{DOLLAR_ID}){IND}(?:<[^;{{]*>)?{IND}\([^;{{]*(?:\{{|{TAIL})"),
97
+ ))
98
+
99
+ _register(("go",), (
100
+ _rule("class", rf"^{IND}type{SP}(?P<name>{ID}){SP}(?:{TYPES})\b"),
101
+ _rule("method", rf"^{IND}func{IND}\([^)]*\){IND}(?P<name>{ID}){IND}[\(\[]"),
102
+ _rule("function", rf"^{IND}func{SP}(?P<name>{ID}){IND}[\(\[]"),
103
+ _rule("function", rf"^{IND}var{SP}(?P<name>{ID}){IND}={IND}func\b"),
104
+ ))
105
+
106
+ _register(("rust",), (
107
+ _rule("class", rf"^{IND}(?:pub(?:\([^)]*\))?{SP})?(?:{TYPES}){SP}(?P<name>{ID})\b"),
108
+ _rule("function", rf"^{IND}(?:pub(?:\([^)]*\))?{SP})?(?:const{SP})?(?:async{SP})?(?:unsafe{SP})?fn{SP}(?P<name>{ID})\b"),
109
+ ))
110
+
111
+ _JVM = (
112
+ # `case` is deliberately absent: as a modifier it also lets `case Some(m) =>`
113
+ # match the bare TYPE-name-args method shape below. Scala's `case class` is
114
+ # handled by the class rule, which spells `case` out.
115
+ r"(?:public|private|protected|internal|static|final|abstract|override|open|sealed"
116
+ r"|suspend|async|virtual|inline|operator|infix|tailrec|external|partial|readonly"
117
+ r"|lateinit|data|value|implicit|synchronized|native|transient|volatile|strictfp)"
118
+ )
119
+ _register(("java", "csharp", "kotlin", "scala"), (
120
+ _rule("class", rf"^{IND}(?:case{SP})?(?:{_JVM}{SP})*(?:{TYPES}){SP}(?P<name>{ID})\b"),
121
+ # Kotlin extension functions carry a receiver: `fun String.toDocumentId(...)`.
122
+ _rule("function", rf"^{IND}(?:{_JVM}{SP})*fun{SP}{ID}\.(?P<name>{ID}){IND}\("),
123
+ _rule("method", rf"^{IND}(?:{_JVM}{SP})*(?:fun|def){SP}(?:<[^>]*>{IND})?(?P<name>{ID}){IND}[\(\[:=]"),
124
+ _rule("function", rf"^{IND}(?:{_JVM}{SP})*(?:fun|def){SP}(?P<name>{ID})\b"),
125
+ # `<[^{(]*>` rather than `<[^>]*>`: a Java generic method leads with
126
+ # `public static <T extends Comparable<T>> List<T> sortedCopy(`, whose type
127
+ # parameter list nests, and stopping at the first `>` loses the name. The
128
+ # class is bounded by `{` and `(` so it still cannot leave the declaration.
129
+ _rule("method", rf"^{IND}(?:{_JVM}{SP})+(?:<[^{{(]*>{IND})?(?:[\w.<>\[\],?]+{SP})?{CONTROL}(?P<name>{ID}){IND}\([^;{{]*(?:\{{|{TAIL})"),
130
+ ))
131
+
132
+ _C = r"(?:static|inline|extern|const|constexpr|virtual|explicit|friend|public|private|protected|unsigned|signed|struct|enum|union|register|volatile)"
133
+ _register(("c", "cpp"), (
134
+ _rule("class", rf"^{IND}(?:typedef{SP})?(?:{TYPES}){SP}(?P<name>{ID}){IND}(?:final{IND})?(?::[^;]*)?{IND}\{{"),
135
+ # A destructor's identifier includes the tilde, which is what keeps it
136
+ # distinct from the constructor of the same class.
137
+ _rule("method", rf"^{IND}(?P<name>~{ID}){IND}\("),
138
+ _rule("method", rf"^{IND}(?:{_C}{SP})*(?:[\w:<>,]+[\s*&]+)?{ID}::(?P<name>~?{ID}){IND}\([^;{{]*(?:\{{|{TAIL})"),
139
+ _rule("function", rf"^{IND}(?:{_C}{SP})*(?:[\w:<>,]+{IND}[*&]?{SP})+[*&]*{CONTROL}(?P<name>{ID}){IND}\([^;{{]*(?:\{{|{TAIL})"),
140
+ _rule("method", rf"^{IND}(?:{_C}{SP})+{CONTROL}(?P<name>{ID}){IND}\([^;{{]*(?:\{{|{TAIL})"),
141
+ ))
142
+
143
+ _register(("ruby",), (
144
+ _rule("class", rf"^{IND}(?:class|module){SP}(?P<name>[A-Z][A-Za-z0-9_]*)"),
145
+ _rule("method", rf"^{IND}def{SP}(?:self\.)?(?P<name>[A-Za-z_][A-Za-z0-9_]*[?!=]?)"),
146
+ _rule("function", rf"^{IND}(?P<name>{ID}){IND}={IND}(?:->|lambda|proc|Proc\.new)"),
147
+ ))
148
+
149
+ _PHP = r"(?:public|private|protected|static|final|abstract|readonly)"
150
+ _register(("php",), (
151
+ _rule("class", rf"^{IND}(?:{_PHP}{SP})*(?:{TYPES}){SP}(?P<name>{ID})\b"),
152
+ _rule("method", rf"^{IND}(?:{_PHP}{SP})+function{SP}&?{IND}(?P<name>{ID}){IND}\("),
153
+ _rule("function", rf"^{IND}function{SP}&?{IND}(?P<name>{ID}){IND}\("),
154
+ _rule("function", rf"^{IND}\$(?P<name>{ID}){IND}={IND}(?:static{SP})?(?:fn|function)\b"),
155
+ ))
156
+
157
+ _SWIFT = r"(?:public|private|internal|fileprivate|open|static|class|final|override|mutating|nonmutating|required|convenience|indirect|dynamic|lazy|weak|unowned)"
158
+ _register(("swift",), (
159
+ _rule("class", rf"^{IND}(?:{_SWIFT}{SP})*(?:class|struct|enum|protocol|extension|actor){SP}(?P<name>{ID})\b"),
160
+ _rule("method", rf"^{IND}(?:{_SWIFT}{SP})*func{SP}(?P<name>{ID}){IND}[<\(]"),
161
+ _rule("method", rf"^{IND}(?:{_SWIFT}{SP})*(?P<name>init)[\?!]?{IND}\("),
162
+ # A computed property owns a body; a stored one does not (SPEC.md).
163
+ _rule("method", rf"^{IND}(?:{_SWIFT}{SP})*var{SP}(?P<name>{ID}){IND}:[^=]*\{{[^\S\r\n]*$"),
164
+ ))
165
+
166
+ _register(("shell",), (
167
+ _rule("function", rf"^{IND}function{SP}(?P<name>[\w.-]+){IND}(?:\(\){IND})?\{{?"),
168
+ _rule("function", rf"^{IND}(?P<name>[\w.-]+){IND}\(\){IND}\{{?[^\S\r\n]*$"),
169
+ ))
170
+
171
+ # Lines whose first non-space characters mark them as prose, not code.
172
+ COMMENT_PREFIXES: dict[str, tuple[str, ...]] = {
173
+ "ruby": ("#",),
174
+ "shell": ("#",),
175
+ "php": ("//", "#", "*", "/*", "*/"),
176
+ }
177
+ _DEFAULT_COMMENTS = ("//", "*", "/*", "*/")
178
+ # Every language not listed closes a body with braces.
179
+ BLOCK_STYLE: dict[str, str] = {"ruby": "end"}
180
+
181
+
182
+ def _line_offsets(source: str) -> list[int]:
183
+ """Character offsets of each line start, counting only newlines Python counts.
184
+
185
+ ``str.splitlines`` also breaks on \\x0b, \\x0c, \\x1c-\\x1e, \\x85, U+2028 and
186
+ U+2029, none of which ``ast`` treats as a line break. A single form feed
187
+ inside a string literal therefore shifted every later offset, truncating one
188
+ unit mid-literal and reducing the next to its ``def`` line with no body.
189
+ ``read_text`` has already normalised \\r\\n and \\r to \\n by this point, so
190
+ splitting on \\n is exactly the tokenizer's line model.
191
+ """
192
+ offsets = [0]
193
+ position = 0
194
+ for line in source.split("\n"):
195
+ position += len(line) + 1
196
+ offsets.append(position)
197
+ return offsets
198
+
199
+
200
+ def _snippet(source: str, start: int, end: int) -> str:
201
+ return source[start:end].strip()
202
+
203
+
204
+ # Which characters open a string literal, per language. Rust is the exception:
205
+ # `'` there introduces a lifetime far more often than a character literal, and
206
+ # treating `&'static str {` as an unterminated string swallowed the brace that
207
+ # proves the function has a body.
208
+ QUOTES: dict[str, str] = {"rust": '"'}
209
+ _DEFAULT_QUOTES = "\"'`"
210
+
211
+
212
+ def _strip_literals(line: str, quotes: str = _DEFAULT_QUOTES) -> str:
213
+ """Blank out string literals and trailing line comments before counting braces.
214
+
215
+ A character scanner rather than a regex on purpose: an alternation like
216
+ ``(?:[^"\\\\]|\\\\.)*`` backtracks on an unterminated quote, and reintroducing
217
+ that here would undo the reason this module was rewritten.
218
+
219
+ ``quotes`` is language-supplied. Assuming ``'`` always opens a string read
220
+ Rust's ``&'static str {`` as an unterminated literal and blanked out the very
221
+ brace that proves the function has a body, so every lifetime-annotated method
222
+ was dropped as if it were a trait signature.
223
+ """
224
+ out: list[str] = []
225
+ quote = ""
226
+ index = 0
227
+ length = len(line)
228
+ while index < length:
229
+ char = line[index]
230
+ if quote:
231
+ if char == "\\":
232
+ index += 2
233
+ continue
234
+ if char == quote:
235
+ quote = ""
236
+ out.append(" ")
237
+ elif char in quotes:
238
+ quote = char
239
+ out.append(" ")
240
+ elif char == "/" and index + 1 < length and line[index + 1] == "/":
241
+ break
242
+ elif char == "#" and not out[-1:] == ["$"]:
243
+ break
244
+ else:
245
+ out.append(char)
246
+ index += 1
247
+ return "".join(out)
248
+
249
+
250
+ def _brace_depths(lines: list[str], quotes: str) -> list[int]:
251
+ """Running brace depth after each line, literals and comments removed."""
252
+ depths: list[int] = []
253
+ depth = 0
254
+ for line in lines:
255
+ clean = _strip_literals(line, quotes)
256
+ depth += clean.count("{") - clean.count("}")
257
+ depths.append(depth)
258
+ return depths
259
+
260
+
261
+ def _terminates_before_body(lines: list[str], start: int, quotes: str) -> bool:
262
+ """Whether the declaration at ``start`` reaches a `;` before any `{`.
263
+
264
+ This is the single question behind both "is it a unit at all" and "how far
265
+ does it reach". A C prototype, a Rust trait method signature, a PHP
266
+ interface method, a Swift protocol requirement and a Rust unit struct
267
+ (``pub struct LineChunker;``) all terminate without opening anything -- and
268
+ the terminator often lands on a later line than the name, which no
269
+ single-line pattern can see.
270
+ """
271
+ if _strip_literals(lines[start], quotes).rstrip().endswith(("=", "=>")):
272
+ return False # an expression body: Kotlin `... : String =`, JS `... =>`
273
+ for index in range(start, min(len(lines), start + 12)):
274
+ clean = _strip_literals(lines[index], quotes)
275
+ brace, semi = clean.find("{"), clean.find(";")
276
+ if brace != -1 and (semi == -1 or brace < semi):
277
+ return False
278
+ if semi != -1:
279
+ return True
280
+ if index > start and (not clean.strip() or clean.lstrip().startswith("}")):
281
+ # The declaration ended without opening anything. Swift protocol
282
+ # requirements need this: they carry no `;`, so without a bound the
283
+ # scan would reach the next declaration's brace and claim it.
284
+ return True
285
+ return True
286
+
287
+
288
+ def _opens_a_body(lines: list[str], start: int, quotes: str) -> bool:
289
+ """SPEC.md: a unit is a named declaration that owns a body span."""
290
+ return not _terminates_before_body(lines, start, quotes)
291
+
292
+
293
+ def _close_brace_span(lines: list[str], depths: list[int], start: int, limit: int, quotes: str) -> int:
294
+ """Return the 0-based last line of a brace-delimited body starting at ``start``."""
295
+ if _terminates_before_body(lines, start, quotes):
296
+ # `pub struct LineChunker;` owns exactly its own line. Without this the
297
+ # scan ran on to the next `{` in the file -- the following `impl` block --
298
+ # and swallowed its methods into the unit struct's source.
299
+ return start
300
+ opened = next(
301
+ (index for index in range(start, min(len(lines), start + 12)) if "{" in _strip_literals(lines[index], quotes)),
302
+ None,
303
+ )
304
+ if opened is None:
305
+ return limit
306
+ clean = _strip_literals(lines[opened], quotes)
307
+ outer = depths[opened] - clean.count("{") + clean.count("}")
308
+ for index in range(opened, len(lines)):
309
+ if depths[index] <= outer:
310
+ return index
311
+ return len(lines) - 1
312
+
313
+
314
+ # A line opening with one of these continues the previous statement -- a C++
315
+ # constructor initialiser list (`: slots_(slots), running_(false) {}`) reads
316
+ # exactly like `name(args) {` to a pattern that only sees one line.
317
+ CONTINUATION_PREFIXES = (":", ",", "?", ")", "]", "}", "&&", "||", "|", "+", ".", "=>", "->")
318
+
319
+
320
+ _HEREDOC_RE = re.compile(r"<<-?[^\S\r\n]*(?P<quote>[\"']?)(?P<word>[A-Za-z_][A-Za-z0-9_]*)(?P=quote)")
321
+
322
+
323
+ def _close_end_span(lines: list[str], start: int, limit: int) -> int:
324
+ """Ruby: the matching ``end`` at the declaration's own indentation."""
325
+ indent = len(lines[start]) - len(lines[start].lstrip())
326
+ if re.search(r";[^\S\r\n]*end[^\S\r\n]*$", lines[start]):
327
+ return start
328
+ for index in range(start + 1, len(lines)):
329
+ line = lines[index]
330
+ if not line.strip():
331
+ continue
332
+ if len(line) - len(line.lstrip()) == indent and line.strip() in {"end", "end;"}:
333
+ return index
334
+ return limit
335
+
336
+
337
+ def _generic_units(path: Path, source: str, relative: str, language: str) -> list[CodeUnit]:
338
+ del path # the relative path is what identifies a unit
339
+ lines = source.split("\n")
340
+ if lines and lines[-1] == "":
341
+ lines.pop()
342
+ rules = RULES.get(language, ())
343
+ comments = COMMENT_PREFIXES.get(language, _DEFAULT_COMMENTS)
344
+ style = BLOCK_STYLE.get(language, "brace")
345
+ quotes = QUOTES.get(language, _DEFAULT_QUOTES)
346
+
347
+ hits: list[tuple[int, str, str]] = []
348
+ heredoc = ""
349
+ for index, line in enumerate(lines):
350
+ stripped = line.strip()
351
+ if heredoc:
352
+ # Text inside a heredoc is data, not code. Without this a shell
353
+ # script that documents a function inside `<<EOF ... EOF` gets that
354
+ # documentation indexed as a declaration.
355
+ if stripped == heredoc:
356
+ heredoc = ""
357
+ continue
358
+ if language == "shell":
359
+ opened = _HEREDOC_RE.search(line)
360
+ if opened:
361
+ heredoc = opened.group("word")
362
+ if not stripped or stripped.startswith(comments) or stripped.startswith(CONTINUATION_PREFIXES):
363
+ continue
364
+ for rule in rules:
365
+ match = rule.pattern.search(line)
366
+ if match:
367
+ if rule.kind != "class" and style == "brace" and not _opens_a_body(lines, index, quotes):
368
+ # Only brace languages can express a bodyless declaration this
369
+ # way. Ruby closes with `end` and its `def` always owns a body,
370
+ # so the brace scan would reject every method in the file.
371
+ break
372
+ hits.append((index, rule.kind, match.group("name")))
373
+ break
374
+
375
+ depths = _brace_depths(lines, quotes) if style == "brace" else []
376
+ offsets = _line_offsets(source)
377
+ units: list[CodeUnit] = []
378
+ for serial, (index, kind, name) in enumerate(hits, 1):
379
+ fallback = hits[serial][0] - 1 if serial < len(hits) else len(lines) - 1
380
+ if style == "end":
381
+ last = _close_end_span(lines, index, fallback)
382
+ else:
383
+ last = _close_brace_span(lines, depths, index, fallback, quotes)
384
+ last = max(index, min(last, len(lines) - 1))
385
+ signature = lines[index].strip()[:500]
386
+ description = (
387
+ f"This {language} {kind} {_humanize_name(name)}. "
388
+ f"Declared as: {signature}"
389
+ )
390
+ units.append(
391
+ CodeUnit(
392
+ f"{relative}:{index + 1}:{name}", relative, language, kind, name, name,
393
+ signature, index + 1, last + 1,
394
+ _snippet(source, offsets[index], offsets[last + 1]),
395
+ description, serial,
396
+ )
397
+ )
398
+ return units
399
+
400
+
401
+ def _python_units(path: Path, source: str, relative: str, diagnostics: list[dict] | None = None) -> list[CodeUnit]:
402
+ try:
403
+ tree = ast.parse(source, filename=str(path))
404
+ except SyntaxError as exc:
405
+ if diagnostics is not None:
406
+ diagnostics.append({"path": relative, "code": "syntax_error", "line": exc.lineno, "message": exc.msg})
407
+ return []
408
+ offsets = _line_offsets(source)
409
+ units: list[CodeUnit] = []
410
+ serial = 0
411
+ module_imports = sorted(
412
+ {
413
+ item.module or (item.names[0].name if item.names else "")
414
+ for item in tree.body
415
+ if isinstance(item, ast.ImportFrom)
416
+ }
417
+ | {alias.name for item in tree.body if isinstance(item, ast.Import) for alias in item.names}
418
+ - {""}
419
+ )
420
+
421
+ def visit(node: ast.AST, parent: str | None = None) -> None:
422
+ nonlocal serial
423
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
424
+ serial += 1
425
+ name = node.name
426
+ qualified = f"{parent}.{name}" if parent else name
427
+ start_line = node.lineno
428
+ end_line = getattr(node, "end_lineno", node.lineno)
429
+ calls = sorted(
430
+ {
431
+ call.func.id
432
+ if isinstance(call.func, ast.Name)
433
+ else ast.unparse(call.func)
434
+ for call in ast.walk(node)
435
+ if isinstance(call, ast.Call) and isinstance(call.func, (ast.Name, ast.Attribute))
436
+ }
437
+ )
438
+ imports = sorted(
439
+ set(module_imports)
440
+ | {
441
+ item.module or (item.names[0].name if item.names else "")
442
+ for item in ast.walk(node)
443
+ if isinstance(item, ast.ImportFrom)
444
+ }
445
+ | {alias.name for item in ast.walk(node) if isinstance(item, ast.Import) for alias in item.names}
446
+ )
447
+ imports = sorted(set(imports) - {""})
448
+ rendered = ast.unparse(node).splitlines()
449
+ signature = next(
450
+ (line.strip() for line in rendered if line.lstrip().startswith(("def ", "async def ", "class "))),
451
+ rendered[0].strip(),
452
+ )[:500]
453
+ description = describe_python(node, source, calls, imports)
454
+ unit_id = f"{relative}:{start_line}:{qualified}"
455
+ units.append(CodeUnit(unit_id, relative, "python", "class" if isinstance(node, ast.ClassDef) else "function", name, qualified, signature, start_line, end_line, _snippet(source, offsets[start_line - 1], offsets[end_line]), description, serial, parent, calls, imports))
456
+ for child in node.body:
457
+ visit(child, qualified)
458
+ return
459
+ for child in ast.iter_child_nodes(node):
460
+ visit(child, parent)
461
+
462
+ visit(tree)
463
+ return units
464
+
465
+
466
+ def _humanize_name(name: str) -> str:
467
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name).replace("_", " ").lower()
468
+
469
+
470
+ def parse_file(path: Path, root: Path, diagnostics: list[dict] | None = None) -> list[CodeUnit]:
471
+ language = EXTENSIONS.get(path.suffix.lower())
472
+ if not language:
473
+ return []
474
+ relative = path.relative_to(root).as_posix()
475
+ try:
476
+ source = path.read_text(encoding="utf-8")
477
+ except UnicodeDecodeError as exc:
478
+ if diagnostics is not None:
479
+ diagnostics.append({"path": relative, "code": "decode_error", "line": None, "message": str(exc)})
480
+ return []
481
+ except OSError as exc:
482
+ if diagnostics is not None:
483
+ diagnostics.append({"path": relative, "code": "read_error", "line": None, "message": str(exc)})
484
+ return []
485
+ return _python_units(path, source, relative, diagnostics) if language == "python" else _generic_units(path, source, relative, language)
ragyourcode/py.typed ADDED
File without changes
ragyourcode/search.py ADDED
@@ -0,0 +1,131 @@
1
+ """Hybrid lexical/vector retrieval for agent context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ from bisect import bisect_left
7
+ from collections import Counter, defaultdict
8
+ from dataclasses import dataclass
9
+
10
+ from .config import BY_PATH
11
+ from .embeddings import DEFAULT_DIMENSIONS, embed, tokenize
12
+ from .models import CodeUnit, SearchResult
13
+
14
+ # Named here rather than repeated as a literal so `search.vector_weight` in
15
+ # rag-your-code.toml and the default a direct caller gets cannot drift apart.
16
+ DEFAULT_VECTOR_WEIGHT: float = BY_PATH["search.vector_weight"].default
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class SearchIndex:
21
+ """In-memory inverted index reused across queries.
22
+
23
+ Building this once avoids re-tokenizing every code unit. Matched terms are
24
+ read straight out of ``postings``; an earlier version also cached a
25
+ per-unit frozenset of every token, which cost the largest share of the
26
+ index's resident memory while holding nothing ``postings`` did not already
27
+ have. The same structure can later be backed by SQLite/ANN storage.
28
+ """
29
+
30
+ units: dict[str, CodeUnit]
31
+ postings: dict[str, tuple[str, ...]]
32
+
33
+
34
+ def build_search_index(units: list[CodeUnit]) -> SearchIndex:
35
+ postings: dict[str, set[str]] = defaultdict(set)
36
+ for unit in units:
37
+ for term in set(tokenize(unit.searchable_text)):
38
+ postings[term].add(unit.id)
39
+ return SearchIndex({unit.id: unit for unit in units}, {term: tuple(sorted(ids)) for term, ids in postings.items()})
40
+
41
+
42
+ def _in_posting(posting: tuple[str, ...], unit_id: str) -> bool:
43
+ """Membership test over a posting list, which build_search_index keeps sorted."""
44
+ position = bisect_left(posting, unit_id)
45
+ return position < len(posting) and posting[position] == unit_id
46
+
47
+
48
+ def search(
49
+ units: list[CodeUnit],
50
+ query: str,
51
+ limit: int = 8,
52
+ search_index: SearchIndex | None = None,
53
+ vector_weight: float = DEFAULT_VECTOR_WEIGHT,
54
+ ) -> list[SearchResult]:
55
+ query_tokens = set(tokenize(query))
56
+ if limit <= 0 or not query_tokens:
57
+ return []
58
+ query_vector = embed(query, len(units[0].vector) if units and units[0].vector else DEFAULT_DIMENSIONS)
59
+ query_features = [(index, value) for index, value in enumerate(query_vector) if value]
60
+ search_index = search_index or build_search_index(units)
61
+ postings = [(token, search_index.postings.get(token, ())) for token in query_tokens]
62
+
63
+ # Matched terms come straight from the posting lists. Walking postings costs
64
+ # O(sum of posting lengths) of dict work, where scoring each candidate by
65
+ # intersecting a cached per-unit token set cost a frozenset operation per
66
+ # candidate -- and every lexically matching unit now gets a score.
67
+ matched_counts: Counter[str] = Counter()
68
+ for _, posting in postings:
69
+ matched_counts.update(posting)
70
+
71
+ # A term present in a tenth of the corpus (``function``, ``return``) is not
72
+ # evidence of relevance, and its posting list is effectively the whole index;
73
+ # computing a 384-dimension dot product for everything it reaches is what
74
+ # this threshold exists to avoid. It selects which candidates additionally
75
+ # receive a VECTOR score. It must not decide which candidates are scored at
76
+ # all -- doing that silently dropped units matching MORE query terms and
77
+ # under-filled ``limit`` (116 units, `--limit 8`, one result returned).
78
+ selective_threshold = max(64, min(2048, len(units) // 10))
79
+ vector_ids: set[str] = set()
80
+ for _, posting in postings:
81
+ if 0 < len(posting) <= selective_threshold:
82
+ vector_ids.update(posting)
83
+ if matched_counts and not vector_ids and len(matched_counts) <= selective_threshold:
84
+ vector_ids = set(matched_counts)
85
+
86
+ # With no lexical overlap anywhere, fall back to pure cosine so a genuine
87
+ # paraphrase still retrieves something.
88
+ candidate_ids = matched_counts.keys() if matched_counts else search_index.units.keys()
89
+ scored: list[tuple[float, str]] = []
90
+ for unit_id in candidate_ids:
91
+ unit = search_index.units[unit_id]
92
+ lexical = matched_counts.get(unit_id, 0) / len(query_tokens)
93
+ vector_score = (
94
+ sum(value * unit.vector[index] for index, value in query_features)
95
+ if (unit_id in vector_ids or not matched_counts) and len(unit.vector) == len(query_vector)
96
+ else 0.0
97
+ )
98
+ # Exact symbols and domain terms are high-confidence evidence. Keep
99
+ # lexical overlap dominant so a noisy feature-hash vector cannot push an
100
+ # exact match below an unrelated semantic neighbor; use the vector score
101
+ # to rank paraphrases and break lexical ties.
102
+ score = lexical + vector_weight * max(0.0, vector_score)
103
+ if lexical or score > 0:
104
+ scored.append((score, unit_id))
105
+ # Materialise only the winners. Building a SearchResult for every lexical
106
+ # match and then sorting all of them cost more than the scoring itself once
107
+ # recall became complete: at 10k units that alone was most of a 10x query
108
+ # regression. nsmallest keeps the exact previous ordering -- highest score
109
+ # first, ties broken by ascending unit id -- at O(n log limit).
110
+ winners = heapq.nsmallest(limit, scored, key=lambda item: (-item[0], item[1]))
111
+ # Which terms matched is only needed for the handful actually returned, and
112
+ # postings are stored sorted, so a binary search beats carrying a per-unit
113
+ # term list through the scoring loop for every candidate in the corpus.
114
+ return [
115
+ SearchResult(search_index.units[unit_id], score, sorted(token for token, posting in postings if _in_posting(posting, unit_id)))
116
+ for score, unit_id in winners
117
+ ]
118
+
119
+
120
+ def context(results: list[SearchResult], max_chars: int = 12000) -> str:
121
+ blocks: list[str] = []
122
+ used = 0
123
+ for result in results:
124
+ unit = result.unit
125
+ evidence = "\nEvidence: " + " | ".join(result.evidence) if result.evidence else ""
126
+ block = f"[{unit.id}] score={result.score:.3f}{evidence}\n{unit.description}\n```{unit.language}\n{unit.source}\n```"
127
+ if used + len(block) > max_chars:
128
+ break
129
+ blocks.append(block)
130
+ used += len(block)
131
+ return "\n\n".join(blocks)