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/config.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""Repository-scoped configuration.
|
|
2
|
+
|
|
3
|
+
Every value here was a module constant until 0.4.0, which meant adapting the
|
|
4
|
+
tool to a repository meant editing installed source. The settings table below
|
|
5
|
+
is the single place a value is named, defaulted, bounded and classified; the
|
|
6
|
+
loader, the validator, the fingerprint and the ``config`` subcommand all read
|
|
7
|
+
it rather than each carrying their own copy of the field list.
|
|
8
|
+
|
|
9
|
+
Resolution order is: an explicit argument (a CLI flag) beats the file, and the
|
|
10
|
+
file beats the built-in default. There is deliberately no environment-variable
|
|
11
|
+
layer -- an index is an artifact of a repository, not of a shell, and a value
|
|
12
|
+
that changes what gets indexed has to be visible to everyone who clones it.
|
|
13
|
+
|
|
14
|
+
The file lives at the repository root as ``rag-your-code.toml``, not inside
|
|
15
|
+
``.rag-your-code/``. That directory holds only generated artifacts and is
|
|
16
|
+
ignored by Git, so anything authored that lived there would be both
|
|
17
|
+
uncommittable and destroyed by the obvious way to clear the cache.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from .parser import EXTENSIONS
|
|
30
|
+
|
|
31
|
+
CONFIG_FILENAME = "rag-your-code.toml"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConfigError(ValueError):
|
|
35
|
+
"""A configuration file that cannot be applied as written.
|
|
36
|
+
|
|
37
|
+
Raised rather than warned about. A setting that is silently dropped is
|
|
38
|
+
indistinguishable, from the outside, from a setting that had no effect, so
|
|
39
|
+
the user has no way to tell a typo from a misunderstanding.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class Setting:
|
|
45
|
+
"""One configurable value.
|
|
46
|
+
|
|
47
|
+
``affects_build`` marks the settings that change *what is indexed* or *the
|
|
48
|
+
vector space the index lives in*. Only those enter the fingerprint: forcing
|
|
49
|
+
a full re-index because someone adjusted a default result limit would make
|
|
50
|
+
the fingerprint an obstacle rather than a safeguard.
|
|
51
|
+
|
|
52
|
+
``members``, where present, is the closed set a list value may draw from.
|
|
53
|
+
It exists for one case that used to fail in silence: a suffix the walker
|
|
54
|
+
accepts but the parser has no rules for is read, parsed to nothing, and
|
|
55
|
+
reported as a successful index of zero units.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
path: str
|
|
59
|
+
kind: str
|
|
60
|
+
default: Any
|
|
61
|
+
affects_build: bool = False
|
|
62
|
+
minimum: float | None = None
|
|
63
|
+
maximum: float | None = None
|
|
64
|
+
help: str = ""
|
|
65
|
+
members: frozenset[str] | None = field(default=None)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def table(self) -> str:
|
|
69
|
+
return self.path.split(".", 1)[0]
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def key(self) -> str:
|
|
73
|
+
return self.path.split(".", 1)[1]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Defaults reproduce the constants they replace exactly, so a repository with
|
|
77
|
+
# no configuration file indexes and searches byte-identically to 0.3.0.
|
|
78
|
+
SETTINGS: tuple[Setting, ...] = (
|
|
79
|
+
Setting(
|
|
80
|
+
"index.ignore",
|
|
81
|
+
"str_list",
|
|
82
|
+
(".git", ".hg", ".svn", ".venv", "venv", "node_modules", "dist", "build", "__pycache__", ".rag-your-code"),
|
|
83
|
+
affects_build=True,
|
|
84
|
+
help="directory names never descended into",
|
|
85
|
+
),
|
|
86
|
+
# Derived from the parser's own dispatch table rather than restated. The
|
|
87
|
+
# two lists agreed by coincidence and nothing enforced it; a suffix present
|
|
88
|
+
# here and absent there is walked, read, parsed to nothing, and reported as
|
|
89
|
+
# a clean index. Naming one list makes that state unreachable.
|
|
90
|
+
Setting(
|
|
91
|
+
"index.suffixes",
|
|
92
|
+
"str_list",
|
|
93
|
+
tuple(sorted(EXTENSIONS)),
|
|
94
|
+
affects_build=True,
|
|
95
|
+
members=frozenset(EXTENSIONS),
|
|
96
|
+
help="file suffixes treated as source; only suffixes the parser can read",
|
|
97
|
+
),
|
|
98
|
+
Setting(
|
|
99
|
+
"index.max_file_bytes",
|
|
100
|
+
"int",
|
|
101
|
+
5 * 1024 * 1024,
|
|
102
|
+
affects_build=True,
|
|
103
|
+
minimum=1024,
|
|
104
|
+
maximum=1024 * 1024 * 1024,
|
|
105
|
+
help="files larger than this are skipped",
|
|
106
|
+
),
|
|
107
|
+
Setting(
|
|
108
|
+
"embedding.dimensions",
|
|
109
|
+
"int",
|
|
110
|
+
384,
|
|
111
|
+
affects_build=True,
|
|
112
|
+
minimum=32,
|
|
113
|
+
maximum=4096,
|
|
114
|
+
help="feature-hash vector width; changing it invalidates every vector",
|
|
115
|
+
),
|
|
116
|
+
Setting(
|
|
117
|
+
"search.vector_weight",
|
|
118
|
+
"float",
|
|
119
|
+
0.15,
|
|
120
|
+
minimum=0.0,
|
|
121
|
+
maximum=1.0,
|
|
122
|
+
help="how much cosine similarity contributes beside lexical overlap",
|
|
123
|
+
),
|
|
124
|
+
Setting("search.limit", "int", 8, minimum=1, maximum=100, help="default result count"),
|
|
125
|
+
Setting("search.max_chars", "int", 12000, minimum=0, maximum=100000, help="default context budget"),
|
|
126
|
+
Setting(
|
|
127
|
+
"agent.max_open_bytes",
|
|
128
|
+
"int",
|
|
129
|
+
5 * 1024 * 1024,
|
|
130
|
+
minimum=1024,
|
|
131
|
+
maximum=1024 * 1024 * 1024,
|
|
132
|
+
help="largest file the agent `open` action will read",
|
|
133
|
+
),
|
|
134
|
+
Setting(
|
|
135
|
+
"agent.max_open_chars",
|
|
136
|
+
"int",
|
|
137
|
+
100000,
|
|
138
|
+
minimum=1000,
|
|
139
|
+
maximum=10_000_000,
|
|
140
|
+
help="largest source payload one `open` response may carry",
|
|
141
|
+
),
|
|
142
|
+
Setting(
|
|
143
|
+
"describe.languages",
|
|
144
|
+
"str_list",
|
|
145
|
+
("en", "zh"),
|
|
146
|
+
help="languages an agent is asked to write unit descriptions in",
|
|
147
|
+
),
|
|
148
|
+
Setting("describe.batch", "int", 20, minimum=1, maximum=200, help="units per describe_pending response"),
|
|
149
|
+
# 600 was picked before any real corpus existed. Measured against the 119
|
|
150
|
+
# descriptions written for this repository's own src/ tree, the median is
|
|
151
|
+
# 349 characters but the 90th percentile is 662 -- so a 600 cap rejects
|
|
152
|
+
# roughly one good-faith description in eight, and rejects them at the
|
|
153
|
+
# complex units retrieval most needs help with. Nothing is truncated to
|
|
154
|
+
# fit, so a cap set inside the normal range silently leaves those units
|
|
155
|
+
# undescribed. 1000 covers the whole observed range.
|
|
156
|
+
Setting("describe.max_chars", "int", 1000, minimum=40, maximum=4000, help="cap on one stored description"),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
BY_PATH: dict[str, Setting] = {setting.path: setting for setting in SETTINGS}
|
|
160
|
+
TABLES: tuple[str, ...] = tuple(dict.fromkeys(setting.table for setting in SETTINGS))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _coerce(setting: Setting, value: Any) -> Any:
|
|
164
|
+
"""Validate one value against its setting, or raise ConfigError.
|
|
165
|
+
|
|
166
|
+
``bool`` is rejected for the numeric kinds on purpose: it is a subclass of
|
|
167
|
+
``int`` in Python, so ``max_file_bytes = true`` would otherwise be accepted
|
|
168
|
+
and silently mean one byte.
|
|
169
|
+
"""
|
|
170
|
+
if setting.kind == "str_list":
|
|
171
|
+
if not isinstance(value, (list, tuple)) or any(not isinstance(item, str) for item in value):
|
|
172
|
+
raise ConfigError(f"{setting.path} must be a list of strings")
|
|
173
|
+
items = tuple(dict.fromkeys(value))
|
|
174
|
+
if setting.members is not None:
|
|
175
|
+
unknown = [item for item in items if item not in setting.members]
|
|
176
|
+
if unknown:
|
|
177
|
+
raise ConfigError(
|
|
178
|
+
f"{setting.path}: {', '.join(unknown)} has no parser rules, so files matching it "
|
|
179
|
+
f"would be read and yield nothing. Supported: {', '.join(sorted(setting.members))}. "
|
|
180
|
+
f"Adding a language means adding a rule table entry in parser.py."
|
|
181
|
+
)
|
|
182
|
+
return items
|
|
183
|
+
if setting.kind == "int":
|
|
184
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
185
|
+
raise ConfigError(f"{setting.path} must be an integer")
|
|
186
|
+
number: float = value
|
|
187
|
+
elif setting.kind == "float":
|
|
188
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
189
|
+
raise ConfigError(f"{setting.path} must be a number")
|
|
190
|
+
if value != value or value in (float("inf"), float("-inf")):
|
|
191
|
+
raise ConfigError(f"{setting.path} must be finite")
|
|
192
|
+
number = float(value)
|
|
193
|
+
else: # pragma: no cover - the table above defines every kind in use
|
|
194
|
+
raise ConfigError(f"{setting.path} has an unknown kind {setting.kind!r}")
|
|
195
|
+
if setting.minimum is not None and number < setting.minimum:
|
|
196
|
+
raise ConfigError(f"{setting.path} must be at least {setting.minimum}")
|
|
197
|
+
if setting.maximum is not None and number > setting.maximum:
|
|
198
|
+
raise ConfigError(f"{setting.path} must be at most {setting.maximum}")
|
|
199
|
+
return int(number) if setting.kind == "int" else number
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True, slots=True)
|
|
203
|
+
class Config:
|
|
204
|
+
"""Resolved settings, addressed by their dotted path."""
|
|
205
|
+
|
|
206
|
+
values: dict[str, Any]
|
|
207
|
+
source: Path | None = None
|
|
208
|
+
|
|
209
|
+
def __getitem__(self, path: str) -> Any:
|
|
210
|
+
return self.values[path]
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def build_fingerprint(self) -> str:
|
|
214
|
+
"""Digest of the settings that determine what an index contains.
|
|
215
|
+
|
|
216
|
+
An index built with different suffixes, ignores, size cap or vector
|
|
217
|
+
width is not a stale index -- it is an index of something else. The
|
|
218
|
+
dimension case in particular fails silently otherwise: ``search`` skips
|
|
219
|
+
the cosine term when the widths disagree, so the only symptom of
|
|
220
|
+
searching new-width queries against old vectors is quietly worse
|
|
221
|
+
ranking.
|
|
222
|
+
"""
|
|
223
|
+
material = {setting.path: self.values[setting.path] for setting in SETTINGS if setting.affects_build}
|
|
224
|
+
canonical = json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=list)
|
|
225
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
226
|
+
|
|
227
|
+
def with_overrides(self, **overrides: Any) -> "Config":
|
|
228
|
+
"""Apply explicit arguments on top, ignoring the ones left unset.
|
|
229
|
+
|
|
230
|
+
Argparse gives ``None`` for a flag the caller did not pass, and ``None``
|
|
231
|
+
has to mean "no opinion" rather than "set this to nothing", or every
|
|
232
|
+
flag would need a sentinel default duplicating the table above.
|
|
233
|
+
"""
|
|
234
|
+
merged = dict(self.values)
|
|
235
|
+
for name, value in overrides.items():
|
|
236
|
+
if value is None:
|
|
237
|
+
continue
|
|
238
|
+
path = name.replace("__", ".")
|
|
239
|
+
setting = BY_PATH.get(path)
|
|
240
|
+
if setting is None:
|
|
241
|
+
raise ConfigError(f"unknown setting {path}")
|
|
242
|
+
merged[path] = _coerce(setting, value)
|
|
243
|
+
return Config(merged, self.source)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def defaults() -> Config:
|
|
247
|
+
return Config({setting.path: setting.default for setting in SETTINGS})
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def config_path(root: Path) -> Path:
|
|
251
|
+
return root / CONFIG_FILENAME
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def load(root: Path) -> Config:
|
|
255
|
+
"""Read ``rag-your-code.toml`` from a repository root, if it has one."""
|
|
256
|
+
path = config_path(root)
|
|
257
|
+
if not path.is_file():
|
|
258
|
+
return defaults()
|
|
259
|
+
try:
|
|
260
|
+
text = path.read_text(encoding="utf-8")
|
|
261
|
+
except OSError as exc:
|
|
262
|
+
raise ConfigError(f"{path.name} is unreadable: {exc}") from exc
|
|
263
|
+
return from_text(text, source=path)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def from_text(text: str, source: Path | None = None) -> Config:
|
|
267
|
+
parsed = parse_toml(text)
|
|
268
|
+
values: dict[str, Any] = {setting.path: setting.default for setting in SETTINGS}
|
|
269
|
+
for table, entries in parsed.items():
|
|
270
|
+
if table not in TABLES:
|
|
271
|
+
raise ConfigError(f"unknown section [{table}]; known sections are {', '.join(TABLES)}")
|
|
272
|
+
if not isinstance(entries, dict):
|
|
273
|
+
raise ConfigError(f"[{table}] must be a table")
|
|
274
|
+
for key, value in entries.items():
|
|
275
|
+
setting = BY_PATH.get(f"{table}.{key}")
|
|
276
|
+
if setting is None:
|
|
277
|
+
known = ", ".join(item.key for item in SETTINGS if item.table == table)
|
|
278
|
+
raise ConfigError(f"unknown setting {table}.{key}; [{table}] accepts {known}")
|
|
279
|
+
values[setting.path] = _coerce(setting, value)
|
|
280
|
+
return Config(values, source)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# TOML reading
|
|
285
|
+
#
|
|
286
|
+
# `tomllib` is standard library from 3.11. On 3.10 the alternative was adding
|
|
287
|
+
# `tomli` as a runtime dependency, which would falsify the claim README and
|
|
288
|
+
# CONTRIBUTING both make about this package having none. The reader below
|
|
289
|
+
# covers the subset the settings table can express -- tables, strings,
|
|
290
|
+
# integers, floats, booleans, and arrays of those -- and rejects everything
|
|
291
|
+
# else with a line number rather than guessing at it. `tests/test_config.py`
|
|
292
|
+
# checks it against `tomllib` on every version that ships one, so the two
|
|
293
|
+
# cannot drift apart silently.
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "f": "\f", "b": "\b", '"': '"', "\\": "\\"}
|
|
297
|
+
_NON_FINITE = {
|
|
298
|
+
"inf": float("inf"), "+inf": float("inf"), "-inf": float("-inf"),
|
|
299
|
+
"nan": float("nan"), "+nan": float("nan"), "-nan": float("nan"),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def parse_toml(text: str) -> dict[str, Any]:
|
|
304
|
+
if sys.version_info >= (3, 11):
|
|
305
|
+
import tomllib
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
return tomllib.loads(text)
|
|
309
|
+
except tomllib.TOMLDecodeError as exc:
|
|
310
|
+
raise ConfigError(f"{CONFIG_FILENAME} is not valid TOML: {exc}") from exc
|
|
311
|
+
return parse_toml_subset(text)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _strip_comment(line: str) -> str:
|
|
315
|
+
"""Remove a trailing comment, respecting quotes.
|
|
316
|
+
|
|
317
|
+
Splitting on the first ``#`` would corrupt any value containing one, and
|
|
318
|
+
directory names and file suffixes are exactly the kind of value that might.
|
|
319
|
+
"""
|
|
320
|
+
quote = ""
|
|
321
|
+
for index, character in enumerate(line):
|
|
322
|
+
if quote:
|
|
323
|
+
if character == quote:
|
|
324
|
+
quote = ""
|
|
325
|
+
elif character in "\"'":
|
|
326
|
+
quote = character
|
|
327
|
+
elif character == "#":
|
|
328
|
+
return line[:index]
|
|
329
|
+
return line
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _parse_string(token: str, line_number: int) -> str:
|
|
333
|
+
body = token[1:-1]
|
|
334
|
+
if token[0] == "'":
|
|
335
|
+
return body # A TOML literal string performs no escape processing.
|
|
336
|
+
out: list[str] = []
|
|
337
|
+
index = 0
|
|
338
|
+
while index < len(body):
|
|
339
|
+
character = body[index]
|
|
340
|
+
if character != "\\":
|
|
341
|
+
out.append(character)
|
|
342
|
+
index += 1
|
|
343
|
+
continue
|
|
344
|
+
index += 1
|
|
345
|
+
if index >= len(body):
|
|
346
|
+
raise ConfigError(f"line {line_number}: string ends in an escape")
|
|
347
|
+
code = body[index]
|
|
348
|
+
if code in _ESCAPES:
|
|
349
|
+
out.append(_ESCAPES[code])
|
|
350
|
+
index += 1
|
|
351
|
+
elif code in "uU":
|
|
352
|
+
width = 4 if code == "u" else 8
|
|
353
|
+
digits = body[index + 1 : index + 1 + width]
|
|
354
|
+
if len(digits) != width:
|
|
355
|
+
raise ConfigError(f"line {line_number}: truncated unicode escape")
|
|
356
|
+
try:
|
|
357
|
+
out.append(chr(int(digits, 16)))
|
|
358
|
+
except ValueError as exc:
|
|
359
|
+
raise ConfigError(f"line {line_number}: invalid unicode escape") from exc
|
|
360
|
+
index += 1 + width
|
|
361
|
+
else:
|
|
362
|
+
raise ConfigError(f"line {line_number}: unsupported escape \\{code}")
|
|
363
|
+
return "".join(out)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _parse_scalar(raw: str, line_number: int) -> Any:
|
|
367
|
+
token = raw.strip()
|
|
368
|
+
if not token:
|
|
369
|
+
raise ConfigError(f"line {line_number}: missing value")
|
|
370
|
+
if token[0] in "\"'":
|
|
371
|
+
if len(token) < 2 or token[-1] != token[0]:
|
|
372
|
+
raise ConfigError(f"line {line_number}: unterminated string")
|
|
373
|
+
return _parse_string(token, line_number)
|
|
374
|
+
if token in ("true", "false"):
|
|
375
|
+
return token == "true"
|
|
376
|
+
# TOML floats include the non-finite literals. Omitting them made this
|
|
377
|
+
# reader refuse `nan` as unparseable while `tomllib` accepted it and let
|
|
378
|
+
# the range check reject it, so the two disagreed about the grammar and
|
|
379
|
+
# gave different errors for the same file -- caught only by the 3.10 leg
|
|
380
|
+
# of CI, which is the whole reason that leg exists.
|
|
381
|
+
if token in _NON_FINITE:
|
|
382
|
+
return _NON_FINITE[token]
|
|
383
|
+
cleaned = token.replace("_", "")
|
|
384
|
+
try:
|
|
385
|
+
if any(character in cleaned for character in ".eE") and not cleaned.lower().startswith("0x"):
|
|
386
|
+
return float(cleaned)
|
|
387
|
+
return int(cleaned, 0)
|
|
388
|
+
except ValueError as exc:
|
|
389
|
+
# Everything TOML allows that this reader does not -- dates, times,
|
|
390
|
+
# multi-line strings, inline tables -- lands here. Reporting the line
|
|
391
|
+
# and what is supported beats a stack trace from int().
|
|
392
|
+
raise ConfigError(
|
|
393
|
+
f"line {line_number}: {token!r} is not a value this reader supports. It accepts "
|
|
394
|
+
f"strings, integers, floats, booleans and arrays of those; Python 3.11 and later "
|
|
395
|
+
f"read the full TOML grammar."
|
|
396
|
+
) from exc
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _split_array(body: str, line_number: int) -> list[str]:
|
|
400
|
+
items: list[str] = []
|
|
401
|
+
current: list[str] = []
|
|
402
|
+
quote = ""
|
|
403
|
+
depth = 0
|
|
404
|
+
for character in body:
|
|
405
|
+
if quote:
|
|
406
|
+
current.append(character)
|
|
407
|
+
if character == quote:
|
|
408
|
+
quote = ""
|
|
409
|
+
continue
|
|
410
|
+
if character in "\"'":
|
|
411
|
+
quote = character
|
|
412
|
+
current.append(character)
|
|
413
|
+
elif character == "[":
|
|
414
|
+
depth += 1
|
|
415
|
+
current.append(character)
|
|
416
|
+
elif character == "]":
|
|
417
|
+
depth -= 1
|
|
418
|
+
current.append(character)
|
|
419
|
+
elif character == "," and depth == 0:
|
|
420
|
+
items.append("".join(current))
|
|
421
|
+
current = []
|
|
422
|
+
else:
|
|
423
|
+
current.append(character)
|
|
424
|
+
if quote or depth:
|
|
425
|
+
raise ConfigError(f"line {line_number}: unterminated array")
|
|
426
|
+
items.append("".join(current))
|
|
427
|
+
return [item for item in items if item.strip()]
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def parse_toml_subset(text: str) -> dict[str, Any]:
|
|
431
|
+
"""The 3.10 reader. Exported so the differential test can drive it directly."""
|
|
432
|
+
result: dict[str, Any] = {}
|
|
433
|
+
table: dict[str, Any] = result
|
|
434
|
+
pending: list[str] = []
|
|
435
|
+
pending_key = ""
|
|
436
|
+
pending_start = 0
|
|
437
|
+
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
|
438
|
+
line = _strip_comment(raw_line).strip()
|
|
439
|
+
if pending:
|
|
440
|
+
pending.append(line)
|
|
441
|
+
if "]" in line:
|
|
442
|
+
joined = " ".join(pending)
|
|
443
|
+
body = joined[joined.index("[") + 1 : joined.rindex("]")]
|
|
444
|
+
table[pending_key] = [_parse_scalar(item, pending_start) for item in _split_array(body, pending_start)]
|
|
445
|
+
pending = []
|
|
446
|
+
continue
|
|
447
|
+
if not line:
|
|
448
|
+
continue
|
|
449
|
+
if line.startswith("["):
|
|
450
|
+
if not line.endswith("]"):
|
|
451
|
+
raise ConfigError(f"line {line_number}: unterminated table header")
|
|
452
|
+
name = line[1:-1].strip()
|
|
453
|
+
if not name or name.startswith("["):
|
|
454
|
+
raise ConfigError(f"line {line_number}: this reader supports only simple [table] headers")
|
|
455
|
+
node: dict[str, Any] = result
|
|
456
|
+
for part in name.split("."):
|
|
457
|
+
child = node.setdefault(part.strip().strip('"').strip("'"), {})
|
|
458
|
+
if not isinstance(child, dict):
|
|
459
|
+
raise ConfigError(f"line {line_number}: {name} is already a value")
|
|
460
|
+
node = child
|
|
461
|
+
table = node
|
|
462
|
+
continue
|
|
463
|
+
if "=" not in line:
|
|
464
|
+
raise ConfigError(f"line {line_number}: expected key = value")
|
|
465
|
+
key, _, value = line.partition("=")
|
|
466
|
+
key = key.strip().strip('"').strip("'")
|
|
467
|
+
value = value.strip()
|
|
468
|
+
if not key:
|
|
469
|
+
raise ConfigError(f"line {line_number}: empty key")
|
|
470
|
+
if value.startswith("{"):
|
|
471
|
+
raise ConfigError(f"line {line_number}: inline tables are not supported by this reader")
|
|
472
|
+
if value.startswith("["):
|
|
473
|
+
if "]" in value:
|
|
474
|
+
body = value[1 : value.rindex("]")]
|
|
475
|
+
table[key] = [_parse_scalar(item, line_number) for item in _split_array(body, line_number)]
|
|
476
|
+
else:
|
|
477
|
+
pending, pending_key, pending_start = [value], key, line_number
|
|
478
|
+
continue
|
|
479
|
+
table[key] = _parse_scalar(value, line_number)
|
|
480
|
+
if pending:
|
|
481
|
+
raise ConfigError(f"line {pending_start}: unterminated array")
|
|
482
|
+
return result
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def render_template() -> str:
|
|
486
|
+
"""A commented file listing every setting at its default.
|
|
487
|
+
|
|
488
|
+
Written by ``rag-your-code config init``. Everything is commented out, so
|
|
489
|
+
the file documents the surface without changing behaviour by existing.
|
|
490
|
+
"""
|
|
491
|
+
lines = [
|
|
492
|
+
"# rag-your-code configuration.",
|
|
493
|
+
"# Every value below is the built-in default; uncomment one to change it.",
|
|
494
|
+
"#",
|
|
495
|
+
"# [index] and [embedding] settings determine what the index contains, so",
|
|
496
|
+
"# changing one forces a full rebuild on the next run. The rest take effect",
|
|
497
|
+
"# immediately and never invalidate an index.",
|
|
498
|
+
]
|
|
499
|
+
for table in TABLES:
|
|
500
|
+
lines.append("")
|
|
501
|
+
lines.append(f"[{table}]")
|
|
502
|
+
for setting in SETTINGS:
|
|
503
|
+
if setting.table != table:
|
|
504
|
+
continue
|
|
505
|
+
if setting.help:
|
|
506
|
+
lines.append(f"# {setting.help}")
|
|
507
|
+
lines.append(f"# {setting.key} = {render_value(setting.default)}")
|
|
508
|
+
return "\n".join(lines) + "\n"
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def render_value(value: Any) -> str:
|
|
512
|
+
if isinstance(value, (tuple, list)):
|
|
513
|
+
return "[" + ", ".join(json.dumps(item, ensure_ascii=False) for item in value) + "]"
|
|
514
|
+
if isinstance(value, bool):
|
|
515
|
+
return "true" if value else "false"
|
|
516
|
+
if isinstance(value, str):
|
|
517
|
+
return json.dumps(value, ensure_ascii=False)
|
|
518
|
+
return repr(value)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def parse_literal(setting: Setting, raw: str) -> Any:
|
|
522
|
+
"""Read one command-line value the way the file would read it.
|
|
523
|
+
|
|
524
|
+
Routed through the same reader rather than through ``int``/``float``/
|
|
525
|
+
``split(',')`` so that ``config set`` and an edited file cannot disagree
|
|
526
|
+
about what a value means, and so the same error text explains both.
|
|
527
|
+
"""
|
|
528
|
+
parsed = parse_toml(f"{setting.key} = {raw.strip()}")
|
|
529
|
+
return _coerce(setting, parsed[setting.key])
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def update_file(path: Path, dotted: str, value: Any) -> None:
|
|
533
|
+
"""Set one key in place, preserving comments, order and unrelated tables.
|
|
534
|
+
|
|
535
|
+
A round trip through a parser and a serializer would be shorter and would
|
|
536
|
+
silently delete every comment in the file, including the explanations
|
|
537
|
+
``config init`` writes. So this walks lines: it replaces the key's existing
|
|
538
|
+
assignment where there is one, uncomments the template line where there is
|
|
539
|
+
one, and otherwise inserts under the right table header.
|
|
540
|
+
"""
|
|
541
|
+
setting = BY_PATH[dotted]
|
|
542
|
+
rendered = f"{setting.key} = {render_value(value)}"
|
|
543
|
+
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else render_template().splitlines()
|
|
544
|
+
table = ""
|
|
545
|
+
active: int | None = None
|
|
546
|
+
commented: int | None = None
|
|
547
|
+
table_end: int | None = None
|
|
548
|
+
for number, line in enumerate(lines):
|
|
549
|
+
stripped = line.strip()
|
|
550
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
551
|
+
table = stripped[1:-1].strip()
|
|
552
|
+
continue
|
|
553
|
+
if table != setting.table:
|
|
554
|
+
continue
|
|
555
|
+
table_end = number
|
|
556
|
+
body = stripped.lstrip("#").strip()
|
|
557
|
+
if not body.startswith(f"{setting.key} ") and not body.startswith(f"{setting.key}="):
|
|
558
|
+
continue
|
|
559
|
+
if stripped.startswith("#"):
|
|
560
|
+
commented = number if commented is None else commented
|
|
561
|
+
else:
|
|
562
|
+
active = number
|
|
563
|
+
break
|
|
564
|
+
if active is not None:
|
|
565
|
+
lines[active] = rendered
|
|
566
|
+
elif commented is not None:
|
|
567
|
+
lines[commented] = rendered
|
|
568
|
+
elif table_end is not None:
|
|
569
|
+
lines.insert(table_end + 1, rendered)
|
|
570
|
+
else:
|
|
571
|
+
lines.extend(["", f"[{setting.table}]", rendered])
|
|
572
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|