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.
- rag_your_code-0.4.1.dist-info/METADATA +237 -0
- rag_your_code-0.4.1.dist-info/RECORD +19 -0
- rag_your_code-0.4.1.dist-info/WHEEL +5 -0
- rag_your_code-0.4.1.dist-info/entry_points.txt +2 -0
- rag_your_code-0.4.1.dist-info/licenses/LICENSE +21 -0
- rag_your_code-0.4.1.dist-info/top_level.txt +1 -0
- ragyourcode/__init__.py +6 -0
- ragyourcode/agentic.py +55 -0
- ragyourcode/annotate.py +35 -0
- ragyourcode/cli.py +575 -0
- ragyourcode/config.py +572 -0
- ragyourcode/descriptions.py +248 -0
- ragyourcode/embeddings.py +60 -0
- ragyourcode/graph.py +198 -0
- ragyourcode/indexer.py +411 -0
- ragyourcode/models.py +86 -0
- ragyourcode/parser.py +485 -0
- ragyourcode/py.typed +0 -0
- ragyourcode/search.py +131 -0
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)
|