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.
Files changed (69) hide show
  1. repoglass/__init__.py +19 -0
  2. repoglass/cli.py +582 -0
  3. repoglass/config/__init__.py +14 -0
  4. repoglass/config/load.py +92 -0
  5. repoglass/config/paths.py +88 -0
  6. repoglass/config/render.py +111 -0
  7. repoglass/config/schema.py +217 -0
  8. repoglass/corpus/__init__.py +0 -0
  9. repoglass/corpus/classify.py +84 -0
  10. repoglass/corpus/discovery.py +211 -0
  11. repoglass/corpus/extract.py +309 -0
  12. repoglass/corpus/languages.py +99 -0
  13. repoglass/corpus/queries/NOTICE.md +29 -0
  14. repoglass/corpus/queries/bash-tags.scm +8 -0
  15. repoglass/corpus/queries/c-refs.scm +5 -0
  16. repoglass/corpus/queries/c-tags.scm +9 -0
  17. repoglass/corpus/queries/cpp-refs.scm +10 -0
  18. repoglass/corpus/queries/cpp-tags.scm +15 -0
  19. repoglass/corpus/queries/csharp-tags.scm +26 -0
  20. repoglass/corpus/queries/elixir-tags.scm +54 -0
  21. repoglass/corpus/queries/go-tags.scm +42 -0
  22. repoglass/corpus/queries/haskell-refs.scm +8 -0
  23. repoglass/corpus/queries/haskell-tags.scm +22 -0
  24. repoglass/corpus/queries/java-tags.scm +20 -0
  25. repoglass/corpus/queries/javascript-tags.scm +88 -0
  26. repoglass/corpus/queries/kotlin-tags.scm +27 -0
  27. repoglass/corpus/queries/lua-tags.scm +34 -0
  28. repoglass/corpus/queries/manifest.json +71 -0
  29. repoglass/corpus/queries/markdown-tags.scm +2 -0
  30. repoglass/corpus/queries/php-refs.scm +7 -0
  31. repoglass/corpus/queries/php-tags.scm +26 -0
  32. repoglass/corpus/queries/python-tags.scm +31 -0
  33. repoglass/corpus/queries/ruby-tags.scm +64 -0
  34. repoglass/corpus/queries/rust-refs.scm +7 -0
  35. repoglass/corpus/queries/rust-tags.scm +60 -0
  36. repoglass/corpus/queries/scala-refs.scm +12 -0
  37. repoglass/corpus/queries/scala-tags.scm +20 -0
  38. repoglass/corpus/queries/swift-refs.scm +13 -0
  39. repoglass/corpus/queries/swift-tags.scm +51 -0
  40. repoglass/corpus/queries/typescript-refs.scm +11 -0
  41. repoglass/corpus/queries/typescript-tags.scm +41 -0
  42. repoglass/corpus/queries/zig-refs.scm +12 -0
  43. repoglass/corpus/queries/zig-tags.scm +10 -0
  44. repoglass/corpus/render.py +133 -0
  45. repoglass/corpus/windows.py +166 -0
  46. repoglass/embeddings/__init__.py +82 -0
  47. repoglass/embeddings/backends.py +371 -0
  48. repoglass/embeddings/policy.py +85 -0
  49. repoglass/index.py +463 -0
  50. repoglass/models.py +197 -0
  51. repoglass/search/__init__.py +0 -0
  52. repoglass/search/boosting.py +224 -0
  53. repoglass/search/fuse.py +93 -0
  54. repoglass/search/lexical.py +60 -0
  55. repoglass/search/penalties.py +101 -0
  56. repoglass/search/query_shape.py +31 -0
  57. repoglass/search/rank.py +60 -0
  58. repoglass/search/tokens.py +45 -0
  59. repoglass/search/vector.py +63 -0
  60. repoglass/sql/index.sql +99 -0
  61. repoglass/store.py +840 -0
  62. repoglass/text.py +16 -0
  63. repoglass-0.1.0.dist-info/METADATA +77 -0
  64. repoglass-0.1.0.dist-info/RECORD +69 -0
  65. repoglass-0.1.0.dist-info/WHEEL +5 -0
  66. repoglass-0.1.0.dist-info/entry_points.txt +3 -0
  67. repoglass-0.1.0.dist-info/licenses/LICENSE +21 -0
  68. repoglass-0.1.0.dist-info/licenses/src/repoglass/corpus/queries/NOTICE.md +29 -0
  69. 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"]