repoglass 0.1.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.
- repoglass/__init__.py +19 -0
- repoglass/cli.py +582 -0
- repoglass/config/__init__.py +14 -0
- repoglass/config/load.py +92 -0
- repoglass/config/paths.py +88 -0
- repoglass/config/render.py +111 -0
- repoglass/config/schema.py +217 -0
- repoglass/corpus/__init__.py +0 -0
- repoglass/corpus/classify.py +84 -0
- repoglass/corpus/discovery.py +211 -0
- repoglass/corpus/extract.py +309 -0
- repoglass/corpus/languages.py +99 -0
- repoglass/corpus/queries/NOTICE.md +29 -0
- repoglass/corpus/queries/bash-tags.scm +8 -0
- repoglass/corpus/queries/c-refs.scm +5 -0
- repoglass/corpus/queries/c-tags.scm +9 -0
- repoglass/corpus/queries/cpp-refs.scm +10 -0
- repoglass/corpus/queries/cpp-tags.scm +15 -0
- repoglass/corpus/queries/csharp-tags.scm +26 -0
- repoglass/corpus/queries/elixir-tags.scm +54 -0
- repoglass/corpus/queries/go-tags.scm +42 -0
- repoglass/corpus/queries/haskell-refs.scm +8 -0
- repoglass/corpus/queries/haskell-tags.scm +22 -0
- repoglass/corpus/queries/java-tags.scm +20 -0
- repoglass/corpus/queries/javascript-tags.scm +88 -0
- repoglass/corpus/queries/kotlin-tags.scm +27 -0
- repoglass/corpus/queries/lua-tags.scm +34 -0
- repoglass/corpus/queries/manifest.json +71 -0
- repoglass/corpus/queries/markdown-tags.scm +2 -0
- repoglass/corpus/queries/php-refs.scm +7 -0
- repoglass/corpus/queries/php-tags.scm +26 -0
- repoglass/corpus/queries/python-tags.scm +31 -0
- repoglass/corpus/queries/ruby-tags.scm +64 -0
- repoglass/corpus/queries/rust-refs.scm +7 -0
- repoglass/corpus/queries/rust-tags.scm +60 -0
- repoglass/corpus/queries/scala-refs.scm +12 -0
- repoglass/corpus/queries/scala-tags.scm +20 -0
- repoglass/corpus/queries/swift-refs.scm +13 -0
- repoglass/corpus/queries/swift-tags.scm +51 -0
- repoglass/corpus/queries/typescript-refs.scm +11 -0
- repoglass/corpus/queries/typescript-tags.scm +41 -0
- repoglass/corpus/queries/zig-refs.scm +12 -0
- repoglass/corpus/queries/zig-tags.scm +10 -0
- repoglass/corpus/render.py +133 -0
- repoglass/corpus/windows.py +166 -0
- repoglass/embeddings/__init__.py +82 -0
- repoglass/embeddings/backends.py +371 -0
- repoglass/embeddings/policy.py +85 -0
- repoglass/index.py +463 -0
- repoglass/models.py +197 -0
- repoglass/search/__init__.py +0 -0
- repoglass/search/boosting.py +224 -0
- repoglass/search/fuse.py +93 -0
- repoglass/search/lexical.py +60 -0
- repoglass/search/penalties.py +101 -0
- repoglass/search/query_shape.py +31 -0
- repoglass/search/rank.py +60 -0
- repoglass/search/tokens.py +45 -0
- repoglass/search/vector.py +63 -0
- repoglass/sql/index.sql +99 -0
- repoglass/store.py +840 -0
- repoglass/text.py +16 -0
- repoglass-0.1.0.dist-info/METADATA +77 -0
- repoglass-0.1.0.dist-info/RECORD +69 -0
- repoglass-0.1.0.dist-info/WHEEL +5 -0
- repoglass-0.1.0.dist-info/entry_points.txt +3 -0
- repoglass-0.1.0.dist-info/licenses/LICENSE +21 -0
- repoglass-0.1.0.dist-info/licenses/src/repoglass/corpus/queries/NOTICE.md +29 -0
- repoglass-0.1.0.dist-info/top_level.txt +1 -0
repoglass/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .config import Paths, Settings
|
|
2
|
+
from .index import Index
|
|
3
|
+
from .models import Chunk, Hit, RefreshReport, SearchMode, Symbol
|
|
4
|
+
|
|
5
|
+
# The only copy. pyproject reads this attribute rather than restating it, so a
|
|
6
|
+
# release cannot ship a number that disagrees with what `rpg --version` prints.
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"__version__",
|
|
11
|
+
"Index",
|
|
12
|
+
"Settings",
|
|
13
|
+
"Paths",
|
|
14
|
+
"Symbol",
|
|
15
|
+
"Chunk",
|
|
16
|
+
"Hit",
|
|
17
|
+
"RefreshReport",
|
|
18
|
+
"SearchMode",
|
|
19
|
+
]
|
repoglass/cli.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
JSON on stdout by default, because the caller is usually a program.
|
|
4
|
+
`--text` is for reading with your eyes. Anything that is not the
|
|
5
|
+
answer -- progress, warnings, errors -- goes to stderr, so stdout stays
|
|
6
|
+
pipeable in both formats.
|
|
7
|
+
|
|
8
|
+
Exit codes:
|
|
9
|
+
0 it worked, including "no results"
|
|
10
|
+
1 something failed
|
|
11
|
+
2 the request was not answerable: unknown category, or a category
|
|
12
|
+
this index does not hold
|
|
13
|
+
|
|
14
|
+
Every retrieval setting comes from `load()`. There are no per-knob
|
|
15
|
+
flags: a flag default silently overrides the setting it shadows, so
|
|
16
|
+
what ran stops matching what was asked for, and a CLI is where that is
|
|
17
|
+
hardest to notice.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import shutil
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import replace
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from .config import Paths, as_toml, load
|
|
30
|
+
from .index import Index
|
|
31
|
+
from .models import Hit
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _version() -> str:
|
|
35
|
+
from . import __version__
|
|
36
|
+
|
|
37
|
+
return __version__
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _open(args) -> Index:
|
|
41
|
+
"""Open the index, announcing a first build on stderr.
|
|
42
|
+
|
|
43
|
+
A first `search` on a large repository indexes it, which can take
|
|
44
|
+
minutes. Silence there reads as a hang, and the notice cannot go to
|
|
45
|
+
stdout without corrupting the JSON.
|
|
46
|
+
"""
|
|
47
|
+
root = Path(args.repo).expanduser().resolve()
|
|
48
|
+
if not root.is_dir():
|
|
49
|
+
raise SystemExit(_fail(f"not a directory: {root}"))
|
|
50
|
+
try:
|
|
51
|
+
settings = load(repo_config=args.config) if args.config else None
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
# A config can outlive the settings it names -- `init` writes
|
|
54
|
+
# resolved values, so a key removed later leaves a file this
|
|
55
|
+
# tool refuses. That is a message, not a traceback.
|
|
56
|
+
raise SystemExit(_fail(f"{exc}\n"
|
|
57
|
+
" regenerate with: repoglass init --force", 2))
|
|
58
|
+
paths = Paths.for_root(root)
|
|
59
|
+
if not paths.db.exists():
|
|
60
|
+
print(f"repoglass: building the index for {root} "
|
|
61
|
+
f"(first run; this is not repeated)", file=sys.stderr)
|
|
62
|
+
return Index.open(root, settings, paths=paths)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fail(message: str, code: int = 1) -> int:
|
|
66
|
+
print(f"repoglass: {message}", file=sys.stderr)
|
|
67
|
+
return code
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _emit(payload: dict, args) -> None:
|
|
71
|
+
if not args.text:
|
|
72
|
+
json.dump(payload, sys.stdout, indent=None)
|
|
73
|
+
sys.stdout.write("\n")
|
|
74
|
+
else:
|
|
75
|
+
_emit_text(payload)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _emit_text(payload: dict) -> None:
|
|
79
|
+
"""The same payload as JSON, rendered for eyes.
|
|
80
|
+
|
|
81
|
+
Both formats render the one payload dict, so the two cannot drift
|
|
82
|
+
into disagreeing about what a result contains.
|
|
83
|
+
"""
|
|
84
|
+
for hit in payload.get("results", []):
|
|
85
|
+
head = f"{hit['path']}:{hit['start_line']}-{hit['end_line']}"
|
|
86
|
+
name = f" {hit['name']}" if hit.get("name") else ""
|
|
87
|
+
tiers = " ".join(f"{k}={v}" for k, v in hit.get("tiers", {}).items())
|
|
88
|
+
print(f"{head}{name}")
|
|
89
|
+
print(f" score={hit['score']}" + (f" [{tiers}]" if tiers else ""))
|
|
90
|
+
if hit.get("signature"):
|
|
91
|
+
print(f" {hit['signature']}")
|
|
92
|
+
if hit.get("code"):
|
|
93
|
+
print(hit["code"].rstrip())
|
|
94
|
+
print()
|
|
95
|
+
for sym in payload.get("symbols", []):
|
|
96
|
+
where = f" in {sym['enclosing']}" if sym.get("enclosing") else ""
|
|
97
|
+
print(f"{sym['path']}:{sym['start_line']} {sym['name']}"
|
|
98
|
+
f" ({sym['tag']}, {sym['content_type']}){where}")
|
|
99
|
+
if sym.get("signature"):
|
|
100
|
+
print(f" {sym['signature']}")
|
|
101
|
+
if "counts" in payload:
|
|
102
|
+
width = max((len(g) for g in payload["counts"]), default=0)
|
|
103
|
+
for group, n in payload["counts"].items():
|
|
104
|
+
print(f"{group:<{width}} {n}")
|
|
105
|
+
shown = (f"showing {payload['shown']} of " if
|
|
106
|
+
payload.get("shown", payload["groups"]) < payload["groups"]
|
|
107
|
+
else "")
|
|
108
|
+
print(f"# {shown}{payload['groups']} group(s),"
|
|
109
|
+
f" {payload['total']} symbol(s) by {payload['count_by']}")
|
|
110
|
+
if "count" in payload:
|
|
111
|
+
split = payload.get("by_content_type") or {}
|
|
112
|
+
detail = (": " + ", ".join(f"{k} {v}" for k, v in split.items())
|
|
113
|
+
if len(split) > 1 else "")
|
|
114
|
+
print(f"# {payload['count']} result(s){detail}")
|
|
115
|
+
if "status" in payload:
|
|
116
|
+
for key, value in payload["status"].items():
|
|
117
|
+
print(f"{key:<18}{value}")
|
|
118
|
+
if "cleared" in payload:
|
|
119
|
+
for line in payload["cleared"]:
|
|
120
|
+
print(line)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#: Tier -> the metric it reports. `score` is relative to the top hit
|
|
124
|
+
#: and so says nothing absolute; these do. What each one means and
|
|
125
|
+
#: which way it runs is in `search --help`, not in every response: it
|
|
126
|
+
#: is the same four lines every time and the caller is usually a
|
|
127
|
+
#: program that already knows.
|
|
128
|
+
_METRIC = {"lexical": "bm25", "vector": "cosine", "exact": "exact"}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _hit(h: Hit, *, code: str) -> dict:
|
|
132
|
+
out = {"path": h.path, "start_line": h.start_line,
|
|
133
|
+
"end_line": h.end_line, "name": h.name, "score": round(h.score, 4),
|
|
134
|
+
# Raw per-tier scores, keyed by the metric rather than the
|
|
135
|
+
# tier, because -7.86 and 0.327 are uninterpretable without
|
|
136
|
+
# knowing which is which and which way each runs.
|
|
137
|
+
"tiers": {_METRIC.get(name, name): round(v, 4)
|
|
138
|
+
for name, v in h.tiers}}
|
|
139
|
+
if code == "full":
|
|
140
|
+
out["code"] = h.code
|
|
141
|
+
elif code == "signature" and h.signature is not None:
|
|
142
|
+
# Still absent rather than empty on a chunk with no text to
|
|
143
|
+
# take a line from: an empty string would read as a header
|
|
144
|
+
# that happens to be blank.
|
|
145
|
+
out["signature"] = h.signature
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_search(args) -> int:
|
|
150
|
+
index = _open(args)
|
|
151
|
+
try:
|
|
152
|
+
query = " ".join(args.query)
|
|
153
|
+
hits = index.search(query, k=args.k, content=args.content,
|
|
154
|
+
lang=args.lang, include=args.include,
|
|
155
|
+
exclude=args.exclude)
|
|
156
|
+
except LookupError as exc:
|
|
157
|
+
return _fail(str(exc), 2)
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
return _fail(str(exc), 2)
|
|
160
|
+
if (bad := _check_lang(index, args.lang)) is not None:
|
|
161
|
+
return bad
|
|
162
|
+
results = [_hit(h, code=args.code) for h in hits]
|
|
163
|
+
_emit({"query": query, "count": len(results), "results": results}, args)
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _check_lang(index, lang: str | None) -> int | None:
|
|
168
|
+
"""Refuse a language this index does not hold.
|
|
169
|
+
|
|
170
|
+
Called only after a query came back empty, for two reasons. A typo
|
|
171
|
+
otherwise returns nothing and exits 0, reading as "no definitions
|
|
172
|
+
in that language" -- the confusion `_require_indexed` exists to
|
|
173
|
+
prevent for categories. And checking beforehand reads the file
|
|
174
|
+
table before the first refresh has populated it, so on a new index
|
|
175
|
+
every language looks absent.
|
|
176
|
+
|
|
177
|
+
The valid set is what this index holds, not every name grep_ast
|
|
178
|
+
knows, because that is the set that can return anything.
|
|
179
|
+
"""
|
|
180
|
+
if lang is None:
|
|
181
|
+
return None
|
|
182
|
+
have = {name for name, _ in index._store.top_languages(limit=1000)}
|
|
183
|
+
# Nothing indexed at all: the caller's language is not wrong, the
|
|
184
|
+
# index is merely empty.
|
|
185
|
+
if not have:
|
|
186
|
+
return None
|
|
187
|
+
missing = [x for x in ((lang,) if isinstance(lang, str) else lang)
|
|
188
|
+
if x not in have]
|
|
189
|
+
if not missing:
|
|
190
|
+
return None
|
|
191
|
+
lang = missing[0]
|
|
192
|
+
# A suggestion beats a list: the caller mistyped one word and
|
|
193
|
+
# should not have to scan six to find it. difflib is stdlib.
|
|
194
|
+
import difflib
|
|
195
|
+
|
|
196
|
+
near = difflib.get_close_matches(lang, sorted(have), n=1, cutoff=0.6)
|
|
197
|
+
hint = (f"did you mean {near[0]!r}?" if near
|
|
198
|
+
else f"this index has: {', '.join(sorted(have))}")
|
|
199
|
+
# Phrased as a bad argument, not a bad index. "no 'pyton' files in
|
|
200
|
+
# this index" reads as though something needs rebuilding; nothing
|
|
201
|
+
# does.
|
|
202
|
+
return _fail(f"--lang {lang!r} matches no indexed language, {hint}", 2)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def cmd_defs(args) -> int:
|
|
206
|
+
index = _open(args)
|
|
207
|
+
syms = index.definitions(args.name, lang=args.lang)
|
|
208
|
+
# After the query, so the refresh has run -- but not only when the
|
|
209
|
+
# result is empty: `-l python pyton` matches on python and would
|
|
210
|
+
# otherwise drop the typo silently.
|
|
211
|
+
if (bad := _check_lang(index, args.lang)) is not None:
|
|
212
|
+
return bad
|
|
213
|
+
rows = [_symbol(s) for s in syms]
|
|
214
|
+
_emit({"name": args.name, "count": len(rows),
|
|
215
|
+
"by_content_type": _by_content(rows, lambda r: r["content_type"]),
|
|
216
|
+
"symbols": rows}, args)
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def cmd_refs(args) -> int:
|
|
221
|
+
index = _open(args)
|
|
222
|
+
syms = index.references(args.name, lang=args.lang)
|
|
223
|
+
if (bad := _check_lang(index, args.lang)) is not None:
|
|
224
|
+
return bad
|
|
225
|
+
rows = [_symbol(s) for s in syms]
|
|
226
|
+
_emit({"name": args.name, "count": len(rows),
|
|
227
|
+
"by_content_type": _by_content(rows, lambda r: r["content_type"]),
|
|
228
|
+
"symbols": rows}, args)
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _by_content(items, key) -> dict[str, int]:
|
|
233
|
+
"""How many results fell in each category, most first.
|
|
234
|
+
|
|
235
|
+
A name can have far more references in tests than in production
|
|
236
|
+
code. The per-row category makes that knowable; this makes it
|
|
237
|
+
visible without the caller tallying it.
|
|
238
|
+
"""
|
|
239
|
+
import collections
|
|
240
|
+
|
|
241
|
+
counted = collections.Counter(key(i) for i in items)
|
|
242
|
+
return dict(counted.most_common())
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _symbol(s) -> dict:
|
|
246
|
+
row = {"name": s.name, "tag": s.tag, "path": s.path,
|
|
247
|
+
"start_line": s.start_line, "end_line": s.end_line,
|
|
248
|
+
"lang": s.lang, "content_type": s.content_type}
|
|
249
|
+
# Each is absent rather than null when it does not apply: a
|
|
250
|
+
# definition has no enclosing definition, a reference has no
|
|
251
|
+
# signature, and module scope has neither.
|
|
252
|
+
if s.enclosing is not None:
|
|
253
|
+
row["enclosing"] = s.enclosing
|
|
254
|
+
if s.signature is not None:
|
|
255
|
+
row["signature"] = s.signature
|
|
256
|
+
return row
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_symbols(args) -> int:
|
|
260
|
+
index = _open(args)
|
|
261
|
+
if args.count_by:
|
|
262
|
+
counts, groups, total = index.symbol_counts(
|
|
263
|
+
args.count_by, args.pattern, tag=args.tag, lang=args.lang,
|
|
264
|
+
content=args.content, include=args.include, exclude=args.exclude,
|
|
265
|
+
limit=args.limit)
|
|
266
|
+
if not counts and (bad := _check_lang(index, args.lang)) is not None:
|
|
267
|
+
return bad
|
|
268
|
+
_emit({"count_by": args.count_by, "groups": groups, "total": total,
|
|
269
|
+
"shown": len(counts), "counts": dict(counts)}, args)
|
|
270
|
+
return 0
|
|
271
|
+
syms = index.symbols(args.pattern, tag=args.tag, lang=args.lang,
|
|
272
|
+
content=args.content, include=args.include,
|
|
273
|
+
exclude=args.exclude, limit=args.limit)
|
|
274
|
+
if not syms and (bad := _check_lang(index, args.lang)) is not None:
|
|
275
|
+
return bad
|
|
276
|
+
rows = [_symbol(s) for s in syms]
|
|
277
|
+
_emit({"pattern": args.pattern, "count": len(rows),
|
|
278
|
+
"by_content_type": _by_content(rows, lambda r: r["content_type"]),
|
|
279
|
+
"symbols": rows}, args)
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cmd_index(args) -> int:
|
|
284
|
+
index = _open(args)
|
|
285
|
+
report = index.refresh(force=args.force)
|
|
286
|
+
_emit({"status": {"added": report.added, "changed": report.changed,
|
|
287
|
+
"deleted": report.deleted,
|
|
288
|
+
"seconds": round(report.elapsed_s, 2)}}, args)
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def cmd_status(args) -> int:
|
|
293
|
+
index = _open(args)
|
|
294
|
+
store = index._store
|
|
295
|
+
files, chunks, symbols = store.status_counts()
|
|
296
|
+
langs = store.top_languages(limit=8)
|
|
297
|
+
db = index._paths.db
|
|
298
|
+
size = sum(f.stat().st_size for f in db.parent.rglob("*") if f.is_file())
|
|
299
|
+
_emit({"status": {
|
|
300
|
+
"root": str(index._paths.root),
|
|
301
|
+
"index": str(db.parent),
|
|
302
|
+
"files": files, "chunks": chunks, "symbols": symbols,
|
|
303
|
+
"size_mb": round(size / 1e6, 1),
|
|
304
|
+
"languages": ", ".join(f"{name} {n}" for name, n in langs),
|
|
305
|
+
}}, args)
|
|
306
|
+
return 0
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def cmd_clear(args) -> int:
|
|
310
|
+
"""Remove this directory's index. `--all` removes every index.
|
|
311
|
+
|
|
312
|
+
Defaults to the one index the caller is standing in, named by
|
|
313
|
+
`--repo`: a destructive command should do the smallest thing its
|
|
314
|
+
name allows.
|
|
315
|
+
"""
|
|
316
|
+
paths = Paths.for_root(Path(args.repo).expanduser().resolve())
|
|
317
|
+
if args.config:
|
|
318
|
+
paths = replace(paths, data_dir=load(repo_config=args.config).data_dir)
|
|
319
|
+
if args.all:
|
|
320
|
+
root_dir = paths.home / "index"
|
|
321
|
+
targets = sorted(e for e in root_dir.iterdir() if e.is_dir()) \
|
|
322
|
+
if root_dir.is_dir() else []
|
|
323
|
+
else:
|
|
324
|
+
targets = [paths.data]
|
|
325
|
+
if not targets:
|
|
326
|
+
_emit({"cleared": ["nothing to clear"]}, args)
|
|
327
|
+
return 0
|
|
328
|
+
removed: list[str] = []
|
|
329
|
+
failed = False
|
|
330
|
+
for entry in targets:
|
|
331
|
+
if not entry.is_dir():
|
|
332
|
+
continue
|
|
333
|
+
size = sum(f.stat().st_size for f in entry.rglob("*") if f.is_file())
|
|
334
|
+
if args.dry_run:
|
|
335
|
+
removed.append(f"would remove {entry.name} {size / 1e6:.1f} MB")
|
|
336
|
+
continue
|
|
337
|
+
# Reported after the fact, not before. `ignore_errors=True` plus
|
|
338
|
+
# an optimistic message prints "removed" for a directory that is
|
|
339
|
+
# still there -- rmtree refuses a symlink, for one.
|
|
340
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
341
|
+
if entry.exists():
|
|
342
|
+
failed = True
|
|
343
|
+
removed.append(f"could NOT remove {entry}")
|
|
344
|
+
else:
|
|
345
|
+
removed.append(f"removed {entry.name} {size / 1e6:.1f} MB")
|
|
346
|
+
_emit({"cleared": removed or ["nothing matched"]}, args)
|
|
347
|
+
return 1 if failed else 0
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _resolved(args):
|
|
351
|
+
"""The settings this invocation would actually use."""
|
|
352
|
+
return load(repo_config=args.config) if args.config else load(
|
|
353
|
+
repo_config=Path(args.repo).expanduser().resolve() / "repoglass.toml")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def cmd_init(args) -> int:
|
|
357
|
+
"""Write a config file holding the resolved settings.
|
|
358
|
+
|
|
359
|
+
Resolved rather than commented-out, so the file shows what is
|
|
360
|
+
actually in force. The cost is that it then pins every setting: a
|
|
361
|
+
later change to a default will not reach a repository that has one
|
|
362
|
+
of these. The header says so, because nothing else will.
|
|
363
|
+
"""
|
|
364
|
+
target = Path(args.repo).expanduser().resolve() / "repoglass.toml"
|
|
365
|
+
if target.exists() and not args.force:
|
|
366
|
+
return _fail(f"{target} exists; pass --force to overwrite")
|
|
367
|
+
header = (
|
|
368
|
+
"# Written by `repoglass init`. Values are the ones resolved at\n"
|
|
369
|
+
"# the time of writing, so this file PINS them: a later change to\n"
|
|
370
|
+
"# a repoglass default will not reach this repository while the\n"
|
|
371
|
+
"# key is present. Delete any key you would rather have track the\n"
|
|
372
|
+
"# default, and regenerate with `repoglass init --force`.\n\n"
|
|
373
|
+
)
|
|
374
|
+
target.write_text(header + as_toml(_resolved(args), active=True))
|
|
375
|
+
_emit({"status": {"wrote": str(target)}}, args)
|
|
376
|
+
return 0
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def cmd_config(args) -> int:
|
|
380
|
+
"""Print the resolved settings. Writes nothing.
|
|
381
|
+
|
|
382
|
+
TOML rather than JSON, unlike every other command here: the whole
|
|
383
|
+
point is to show configuration in the form configuration takes, so
|
|
384
|
+
the output can be diffed against a real file or pasted into one.
|
|
385
|
+
"""
|
|
386
|
+
sys.stdout.write(as_toml(_resolved(args), active=True))
|
|
387
|
+
return 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
391
|
+
parser = argparse.ArgumentParser(
|
|
392
|
+
prog="repoglass",
|
|
393
|
+
description="Local code and prose index: symbols, lexical and "
|
|
394
|
+
"semantic retrieval.",
|
|
395
|
+
epilog=(
|
|
396
|
+
"every subcommand also takes:\n"
|
|
397
|
+
" -r, --repo PATH the directory to index and search"
|
|
398
|
+
" (default: .). Result\n"
|
|
399
|
+
" paths are relative to it, and its index is"
|
|
400
|
+
" keyed by the\n"
|
|
401
|
+
" resolved path, so two checkouts of one"
|
|
402
|
+
" project do not\n"
|
|
403
|
+
" share an index. Use it to query another"
|
|
404
|
+
" repository\n"
|
|
405
|
+
" without changing directory.\n"
|
|
406
|
+
" --config PATH a TOML file overriding the resolved"
|
|
407
|
+
" settings\n"
|
|
408
|
+
" --text human-readable output instead of json"
|
|
409
|
+
" (`config`\n"
|
|
410
|
+
" always emits TOML)\n"
|
|
411
|
+
"\nrun `repoglass <command> --help` for a command's own"
|
|
412
|
+
" options."),
|
|
413
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
414
|
+
parser.add_argument("-V", "--version", action="version",
|
|
415
|
+
version=_version())
|
|
416
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
417
|
+
|
|
418
|
+
def common(p: argparse.ArgumentParser) -> None:
|
|
419
|
+
# `--repo`, though it need not be a git repository: with
|
|
420
|
+
# `--include` taking path globs, a flag called `--path` meaning
|
|
421
|
+
# something else entirely is the worse confusion.
|
|
422
|
+
p.add_argument("-r", "--repo", default=".",
|
|
423
|
+
help="directory to index and search (default: .);"
|
|
424
|
+
" its index is keyed by the resolved path")
|
|
425
|
+
p.add_argument("--config", type=Path,
|
|
426
|
+
help="TOML overriding the resolved settings")
|
|
427
|
+
# A boolean, not --format json|text. Two formats do not need an
|
|
428
|
+
# enum, and `--format json` is noise when json is the default.
|
|
429
|
+
# If a third format ever lands, this becomes --format again.
|
|
430
|
+
p.add_argument("--text", action="store_true",
|
|
431
|
+
help="human-readable output instead of json")
|
|
432
|
+
|
|
433
|
+
s = sub.add_parser(
|
|
434
|
+
"search", help="search the index",
|
|
435
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
436
|
+
epilog=(
|
|
437
|
+
"output (JSON unless --text):\n"
|
|
438
|
+
" query the query as searched\n"
|
|
439
|
+
" results[] path, start_line, end_line, name, score, tiers,\n"
|
|
440
|
+
" and code unless --no-code\n"
|
|
441
|
+
" score the fused rank divided by the top hit. Always\n"
|
|
442
|
+
" 1.0 at rank 1, so it orders results but does\n"
|
|
443
|
+
" NOT measure how good any of them are.\n"
|
|
444
|
+
" tiers raw score from each tier that matched, keyed by\n"
|
|
445
|
+
" metric. A result matched by two tiers is better\n"
|
|
446
|
+
" corroborated than one matched by a single tier.\n"
|
|
447
|
+
" bm25 SQLite FTS5 bm25(); negative, and more\n"
|
|
448
|
+
" negative is better.\n"
|
|
449
|
+
" cosine cosine similarity in [-1, 1]; higher is\n"
|
|
450
|
+
" better.\n"
|
|
451
|
+
" exact 1.0 when the query literally matched a\n"
|
|
452
|
+
" symbol name.\n"
|
|
453
|
+
"\nno results is success (exit 0). Exit 2 means the request\n"
|
|
454
|
+
"could not be answered: an unknown category, or one this\n"
|
|
455
|
+
"index does not hold."))
|
|
456
|
+
# Several words, joined. Quoting works too, but `rpg search how are
|
|
457
|
+
# chunks bounded` failing on the unquoted form is a bad first
|
|
458
|
+
# experience and query is the only positional, so nothing is lost.
|
|
459
|
+
s.add_argument("query", nargs="+")
|
|
460
|
+
s.add_argument("-k", type=int, default=10, help="results (default: 10)")
|
|
461
|
+
s.add_argument("--content", nargs="+",
|
|
462
|
+
help="code, tests, docs, config, data, or all")
|
|
463
|
+
s.add_argument("-l", "--lang", nargs="+",
|
|
464
|
+
help="only results in these languages, e.g. python go")
|
|
465
|
+
s.add_argument("--include", nargs="+", metavar="GLOB",
|
|
466
|
+
help="only paths matching these globs, e.g. 'src/*'."
|
|
467
|
+
" * crosses / , so src/* is recursive")
|
|
468
|
+
# No short forms: -i is --ignore-case in grep and ripgrep, -e is
|
|
469
|
+
# --regexp in grep. GNU grep ships --include/--exclude without
|
|
470
|
+
# shorts for the same reason.
|
|
471
|
+
s.add_argument("--exclude", nargs="+", metavar="GLOB",
|
|
472
|
+
help="skip paths matching these globs; wins over"
|
|
473
|
+
" --include")
|
|
474
|
+
s.add_argument("--code", choices=("full", "signature", "none"),
|
|
475
|
+
default="full",
|
|
476
|
+
help="how much of each result to return: the whole"
|
|
477
|
+
" span, one line, or neither. that line is the"
|
|
478
|
+
" definition's header, or the span's first"
|
|
479
|
+
" non-blank line where it holds no definition")
|
|
480
|
+
# The shorter way to say the common case, and one value of --code
|
|
481
|
+
# rather than a second axis.
|
|
482
|
+
s.add_argument("--no-code", action="store_const", const="none",
|
|
483
|
+
dest="code", help="same as --code none")
|
|
484
|
+
common(s)
|
|
485
|
+
s.set_defaults(func=cmd_search)
|
|
486
|
+
|
|
487
|
+
d = sub.add_parser("defs", help="where a name is defined (all of them)")
|
|
488
|
+
d.add_argument("name")
|
|
489
|
+
# -l/--lang follows ast-grep, the closest tool in this space
|
|
490
|
+
# (tree-sitter, structural). ripgrep spells the adjacent idea
|
|
491
|
+
# -t/--type, but that is extension-based file typing, and -t is
|
|
492
|
+
# already --text here.
|
|
493
|
+
d.add_argument("-l", "--lang", nargs="+",
|
|
494
|
+
help="only definitions in these languages, e.g. python go")
|
|
495
|
+
common(d)
|
|
496
|
+
d.set_defaults(func=cmd_defs)
|
|
497
|
+
|
|
498
|
+
r = sub.add_parser("refs", help="where a name is used (all of them)")
|
|
499
|
+
r.add_argument("name")
|
|
500
|
+
r.add_argument("-l", "--lang", nargs="+",
|
|
501
|
+
help="only references in these languages, e.g. python go")
|
|
502
|
+
common(r)
|
|
503
|
+
r.set_defaults(func=cmd_refs)
|
|
504
|
+
|
|
505
|
+
y = sub.add_parser(
|
|
506
|
+
"symbols", help="list or count symbols, complete and unranked",
|
|
507
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
508
|
+
epilog=(
|
|
509
|
+
"the complement of `search`, which ranks and truncates.\n"
|
|
510
|
+
"use this to ask how many, which ones, or whether any.\n"
|
|
511
|
+
"\nexamples:\n"
|
|
512
|
+
" rpg symbols 'test_*' --tag def\n"
|
|
513
|
+
" rpg symbols --count-by lang\n"
|
|
514
|
+
" rpg symbols --tag ref --count-by name --limit 10\n"
|
|
515
|
+
" rpg symbols --count-by file --content tests\n"
|
|
516
|
+
"\nreads the symbol table, so a definition too small to be\n"
|
|
517
|
+
"chunked is listed here though no search can return it.\n"
|
|
518
|
+
"count 0 means the index holds none -- which an empty\n"
|
|
519
|
+
"ranked list does not say."))
|
|
520
|
+
y.add_argument("pattern", nargs="?",
|
|
521
|
+
help="glob on the symbol name, e.g. 'handle_*'."
|
|
522
|
+
" omit for all of them")
|
|
523
|
+
y.add_argument("--tag", choices=("def", "ref"),
|
|
524
|
+
help="definitions or references; omit for both")
|
|
525
|
+
y.add_argument("--count-by", dest="count_by",
|
|
526
|
+
choices=tuple(Index.COUNT_BY),
|
|
527
|
+
help="group and count instead of listing")
|
|
528
|
+
y.add_argument("--limit", type=int,
|
|
529
|
+
help="cap the rows, or the groups under --count-by")
|
|
530
|
+
y.add_argument("--content", nargs="+",
|
|
531
|
+
help="code, tests, docs, config, data, or all")
|
|
532
|
+
y.add_argument("-l", "--lang", nargs="+",
|
|
533
|
+
help="only symbols in these languages, e.g. python go")
|
|
534
|
+
y.add_argument("--include", nargs="+", metavar="GLOB",
|
|
535
|
+
help="only paths matching these globs")
|
|
536
|
+
y.add_argument("--exclude", nargs="+", metavar="GLOB",
|
|
537
|
+
help="skip paths matching these globs; wins over --include")
|
|
538
|
+
common(y)
|
|
539
|
+
y.set_defaults(func=cmd_symbols)
|
|
540
|
+
|
|
541
|
+
i = sub.add_parser("index", help="build or update the index")
|
|
542
|
+
i.add_argument("--force", action="store_true",
|
|
543
|
+
help="re-extract every file, not just changed ones")
|
|
544
|
+
common(i)
|
|
545
|
+
i.set_defaults(func=cmd_index)
|
|
546
|
+
|
|
547
|
+
st = sub.add_parser("status", help="what this index contains")
|
|
548
|
+
common(st)
|
|
549
|
+
st.set_defaults(func=cmd_status)
|
|
550
|
+
|
|
551
|
+
n = sub.add_parser("init", help="write repoglass.toml for this repo")
|
|
552
|
+
n.add_argument("--force", action="store_true",
|
|
553
|
+
help="overwrite an existing file")
|
|
554
|
+
common(n)
|
|
555
|
+
n.set_defaults(func=cmd_init)
|
|
556
|
+
|
|
557
|
+
g = sub.add_parser("config", help="print the resolved settings")
|
|
558
|
+
common(g)
|
|
559
|
+
g.set_defaults(func=cmd_config)
|
|
560
|
+
|
|
561
|
+
c = sub.add_parser("clear", help="remove index directories")
|
|
562
|
+
c.add_argument("--all", action="store_true",
|
|
563
|
+
help="every index under the repoglass home, not just"
|
|
564
|
+
" this one")
|
|
565
|
+
c.add_argument("--dry-run", action="store_true")
|
|
566
|
+
common(c)
|
|
567
|
+
c.set_defaults(func=cmd_clear)
|
|
568
|
+
return parser
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def main(argv: list[str] | None = None) -> int:
|
|
572
|
+
args = build_parser().parse_args(argv)
|
|
573
|
+
try:
|
|
574
|
+
return args.func(args)
|
|
575
|
+
except BrokenPipeError:
|
|
576
|
+
return 0 # `| head` closed the pipe
|
|
577
|
+
except KeyboardInterrupt:
|
|
578
|
+
return 130
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
if __name__ == "__main__":
|
|
582
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Configuration: paths, settings, and their resolution."""
|
|
2
|
+
|
|
3
|
+
from .paths import Paths
|
|
4
|
+
from .load import load
|
|
5
|
+
from .render import as_toml
|
|
6
|
+
from .schema import (DATA_DIR_NAME, HOME_ENV,
|
|
7
|
+
IGNORE_FILE_NAME, MAX_CHUNK_CHARS, MIN_CHUNK_CHARS,
|
|
8
|
+
NEVER_INDEX_LANGS, RRF_K, Settings, VECTOR_DTYPE,
|
|
9
|
+
categories_rev)
|
|
10
|
+
|
|
11
|
+
__all__ = ["Paths", "Settings", "load", "as_toml", "RRF_K", "VECTOR_DTYPE", "HOME_ENV",
|
|
12
|
+
"MIN_CHUNK_CHARS", "MAX_CHUNK_CHARS", "NEVER_INDEX_LANGS",
|
|
13
|
+
"DATA_DIR_NAME", "IGNORE_FILE_NAME",
|
|
14
|
+
"categories_rev"]
|