lscope 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.
@@ -0,0 +1,321 @@
1
+ Metadata-Version: 2.4
2
+ Name: lscope
3
+ Version: 0.1.0
4
+ Summary: ast-grep using ladybug
5
+ Requires-Python: >=3.14
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: icebug>=12.9
8
+ Requires-Dist: ladybug>=0.17.1
9
+ Requires-Dist: polars>=1.41.2
10
+ Requires-Dist: pyarrow>=24.0.0
11
+ Requires-Dist: tqdm>=4.68.3
12
+ Requires-Dist: tree-sitter>=0.25.2
13
+ Requires-Dist: tree-sitter-c-sharp>=0.23.1
14
+ Requires-Dist: tree-sitter-cpp>=0.23.4
15
+ Requires-Dist: tree-sitter-go>=0.23.4
16
+ Requires-Dist: tree-sitter-java>=0.23.5
17
+ Requires-Dist: tree-sitter-javascript>=0.23.1
18
+ Requires-Dist: tree-sitter-kotlin>=1.1.0
19
+ Requires-Dist: tree-sitter-python>=0.25.0
20
+ Requires-Dist: tree-sitter-rust>=0.24.2
21
+ Requires-Dist: tree-sitter-swift>=0.0.1
22
+ Requires-Dist: tree-sitter-typescript>=0.23.2
23
+
24
+ # lscope — semantic code graph (via tree-sitter) on Ladybug
25
+
26
+ lscope parses source files with tree-sitter, extracts a semantic graph
27
+ (files, classes, functions, methods, calls), and stores it in a
28
+ [Ladybug](https://github.com/thatguyfrombb/ladybug) database. It then
29
+ supports lightweight code-intelligence queries: find functions by name
30
+ pattern, or find callers of a given function.
31
+
32
+ ---
33
+
34
+ ## Pipeline phases
35
+
36
+ When invoked with `--index`, the tool runs through several phases.
37
+ Some are parallel, some are single-threaded. The diagram below shows
38
+ the data flow and concurrency model:
39
+
40
+ ```
41
+ ┌─────────────────────────────────────────────────────────────────┐
42
+ │ 1. File Discovery (single-threaded) │
43
+ │ Walk targets, match extensions, collect file list │
44
+ └─────────────────────────────────────────────────────────────────┘
45
+
46
+
47
+ ┌─────────────────────────────────────────────────────────────────┐
48
+ │ 2. Analysis / Parsing (parallel — ThreadPoolExecutor) │
49
+ │ Each file is read + tree-sitter parsed in a worker thread. │
50
+ │ Every thread has its own thread-local LanguageRegistry │
51
+ │ (parser instances). │
52
+ │ Progress: tqdm "Analyzing" bar across all files. │
53
+ └─────────────────────────────────────────────────────────────────┘
54
+
55
+
56
+ ┌─────────────────────────────────────────────────────────────────┐
57
+ │ 3. Node Ingestion (parallel when workers > 1) │
58
+ │ Analyses are split round-robin into worker chunks. │
59
+ │ Each chunk runs in its own thread, opening its own │
60
+ │ ladybug Connection with enable_multi_writes=True. │
61
+ │ Nodes are bulk-inserted via UNWIND + MERGE per label │
62
+ │ in a single transaction per chunk. │
63
+ │ Progress: tqdm "Ingesting" bar across chunks. │
64
+ └─────────────────────────────────────────────────────────────────┘
65
+
66
+
67
+ ┌─────────────────────────────────────────────────────────────────┐
68
+ │ 4. Definition Edges (single-threaded) │
69
+ │ CONTAINS / DEFINES / HAS_METHOD edges inserted via │
70
+ │ COPY CodeRelation (DDL — cannot run concurrently with │
71
+ │ active write transactions from step 3). │
72
+ │ Runs on the main connection after all workers finish. │
73
+ └─────────────────────────────────────────────────────────────────┘
74
+
75
+
76
+ ┌─────────────────────────────────────────────────────────────────┐
77
+ │ 5. Call Resolution & Edges (single-threaded) │
78
+ │ Build a global name index across all analyzed files. │
79
+ │ Resolve each call expression to a declaration by name, │
80
+ │ preferring same-file matches. Bulk-insert CALLS edges │
81
+ │ via COPY CodeRelation on the main connection. │
82
+ └─────────────────────────────────────────────────────────────────┘
83
+ ```
84
+
85
+ When running a **query** (`--find-functions` or `--find-callers`), the
86
+ pipeline is trivial: a single read-only connection is opened and the
87
+ query executes on the main thread.
88
+
89
+ ---
90
+
91
+ ### Phase details
92
+
93
+ #### 1. File discovery — single-threaded
94
+
95
+ `iter_source_files()` walks the given paths (files or directories).
96
+ Directories are recursed with `os.walk`, skipping common ignorable
97
+ directories (`.git`, `__pycache__`, `node_modules`, `.venv`, …).
98
+ Files are matched against the known extension map from the installed
99
+ tree-sitter grammars. If `--language` is given, only files of that
100
+ language are kept.
101
+
102
+ This runs entirely in the main thread — it is I/O-bound on `os.walk`
103
+ and has no heavy computation.
104
+
105
+ #### 2. Analysis / parsing — parallel (ThreadPoolExecutor)
106
+
107
+ Each source file is read from disk and parsed by a tree-sitter `Parser`.
108
+ The parser is obtained from a **thread-local** `LanguageRegistry`
109
+ (`_worker_state.registry`) so each thread creates its own parser
110
+ instances — tree-sitter parsers are not thread-safe for concurrent
111
+ use from multiple threads.
112
+
113
+ The `analyze_path()` function:
114
+ 1. Determines the language from the file extension.
115
+ 2. Reads the full file into a string.
116
+ 3. Parses it with the appropriate tree-sitter grammar.
117
+ 4. Walks the CST to extract:
118
+ - **Containers** — classes, structs, interfaces, traits, impl blocks.
119
+ - **Functions / methods** — with their owning container.
120
+ - **Call expressions** — recording caller, callee name, and source location.
121
+
122
+ No database calls happen in this phase. All extracted data is returned
123
+ as plain dicts for the next phase.
124
+
125
+ #### 3. Node ingestion — parallel (ThreadPoolExecutor, multi-write DB)
126
+
127
+ `ingest_analyses_parallel()` splits the list of analyses into
128
+ `workers` chunks (round-robin). Each chunk is processed by a worker
129
+ thread that:
130
+
131
+ 1. Opens its own `ladybug.Connection` to the shared database (which was
132
+ opened with `enable_multi_writes=True`).
133
+ 2. Calls `_collect_file_data()` to organise extracted symbols by label.
134
+ 3. Bulk-inserts all nodes for that chunk:
135
+ - **Files** — `UNWIND … MERGE (f:File …)`.
136
+ - **Symbols** — `UNWIND … MERGE (n:{label} …)` per label
137
+ (Function, Method, Class, Struct, …).
138
+ - All wrapped in a single `BEGIN TRANSACTION … COMMIT` per chunk.
139
+ 4. Returns definition edges for deferred insertion.
140
+
141
+ **Why parallel?** Node insertion is the bulk of the write work and
142
+ Ladybug's multi-write mode permits concurrent write transactions from
143
+ different connections. This gives a significant speedup for large
144
+ codebases.
145
+
146
+ **Why not parallel for everything?** Step 4 (definition edges) and
147
+ step 5 (call edges) use `COPY CodeRelation`, which is a DDL statement
148
+ and cannot run concurrently with other write transactions.
149
+
150
+ #### 4. Definition edges — single-threaded
151
+
152
+ After all worker threads finish, the main thread inserts edges that
153
+ represent:
154
+
155
+ - **CONTAINS** — file contains a function/class/etc.
156
+ - **DEFINES** — a file defines a top-level symbol.
157
+ - **HAS_METHOD** — a container (class/struct/trait/impl) has a method.
158
+
159
+ Edges are grouped by `(source_label, target_label)`, written to a
160
+ temporary Parquet file per group, and bulk-loaded with
161
+ `COPY CodeRelation FROM '…' (FROM='…', TO='…')`.
162
+
163
+ This runs single-threaded because `COPY` on the `CodeRelation`
164
+ relationship table group is a DDL-level operation that requires
165
+ exclusive write access.
166
+
167
+ #### 5. Call resolution & edges — single-threaded
168
+
169
+ `ingest_calls()` builds a global dictionary mapping each declared
170
+ function/method name to its symbol dict(s), then iterates over every
171
+ call expression from every file:
172
+
173
+ 1. Looks up the callee name in the name index.
174
+ 2. Prefers a declaration in the **same file** (for local disambiguation).
175
+ 3. Falls back to the first candidate if there is no same-file match.
176
+ 4. Assigns `confidence = 1.0` for unique matches, `0.7` when multiple
177
+ candidates exist (ambiguous name).
178
+ 5. Bulk-inserts all `CALLS` edges via `_ingest_chunk_edges()` — again
179
+ using the DDL `COPY` path.
180
+
181
+ This must be single-threaded because:
182
+ - The name index is a global data structure built across **all** files.
183
+ - `COPY CodeRelation` is DDL and cannot run concurrently.
184
+ - Call resolution is lightweight (dictionary lookups) so parallelism
185
+ would add overhead without benefit.
186
+
187
+ #### Schema setup — single-threaded
188
+
189
+ When the database is empty, `ensure_schema()` runs the schema file
190
+ (`schema.cypher`) to create all node tables (`File`, `Function`,
191
+ `Method`, `Class`, `Struct`, `Interface`, `Trait`, `Impl`, …) and
192
+ the relationship table group `CodeRelation` with all permitted
193
+ `FROM→TO` pairs.
194
+
195
+ This runs once at the start of `--index`, before any other phases.
196
+
197
+ ---
198
+
199
+ ## Concurrency model summary
200
+
201
+ | Phase | Threads | DB mode | Notes |
202
+ |---|---|---|---|
203
+ | File discovery | 1 (main) | — | I/O walk, no DB |
204
+ | Analysis / parsing | `--workers` (ThreadPoolExecutor) | — | Thread-local parsers, no DB |
205
+ | Node ingestion | `--workers` (ThreadPoolExecutor) | `enable_multi_writes=true` | Each worker opens its own `Connection` |
206
+ | Definition edges | 1 (main) | Regular | `COPY CodeRelation` is DDL |
207
+ | Call resolution + edges | 1 (main) | Regular | Single-pass name index, then DDL COPY |
208
+ | Queries | 1 (main) | Read-only | Single `MATCH` queries |
209
+
210
+ `--workers` defaults to `min(32, cpu_count() + 4)`. It controls both
211
+ the analysis threads (phase 2) and the ingest threads (phase 3). When
212
+ `--workers=1`, phases 2 and 3 also run single-threaded (the
213
+ `ThreadPoolExecutor` is bypassed and the tqdm progress shows chunks
214
+ instead of per-file).
215
+
216
+ The entire tool is **single-process** — it uses Python threads only,
217
+ never `multiprocessing`. Threads are appropriate because tree-sitter
218
+ parsing is CPU-bound C extension work (the GIL is released) and the
219
+ ladybug client library is also I/O-bound on IPC with the database
220
+ process.
221
+
222
+ ---
223
+
224
+ ## Usage
225
+
226
+ ### Indexing
227
+
228
+ ```
229
+ uv run python3 main.py --index [PATH ...] [--language LANG] [--workers N] [--db DB] [--schema SCHEMA]
230
+ ```
231
+
232
+ - `PATH` — one or more files or directories (default: current directory).
233
+ - `--language` / `-l` — restrict to a single language (default: all installed).
234
+ - `--workers` — thread count for analysis and node ingestion (default: CPU-based).
235
+ - `--db` / `-d` — Ladybug database file (default: `test.db`).
236
+ - `--schema` / `-s` — schema `.cypher` file (default: `schema.cypher` alongside `main.py`).
237
+
238
+ Example:
239
+
240
+ ```
241
+ $ uv run python3 main.py --index icebug-format --language python --workers 4
242
+ Created schema from .../lscope/schema.cypher
243
+ Analyzing: 100%|████████████| 9/9 [00:00<00:00, 10.12 file/s]
244
+ Ingesting: 100%|████████████| 4/4 [00:00<00:00, 8.54 chunk/s]
245
+
246
+ Ingested 9 file(s), 90 semantic node(s), and 149 resolved call(s) into test.db using 4 analysis thread(s)
247
+ python: 9 file(s)
248
+
249
+ $ du -sh test.db
250
+ 832K test.db
251
+ ```
252
+
253
+ ### Searching
254
+
255
+ ```
256
+ uv run python3 main.py --find-functions REGEX [--db DB]
257
+ uv run python3 main.py --find-callers NAME [--db DB]
258
+ ```
259
+
260
+ Example:
261
+
262
+ ```
263
+ $ uv run python3 main.py --find-functions 'main'
264
+ Functions matching /main/:
265
+ shape: (4, 5)
266
+ ┌──────┬──────────┬──────────────────────┬────────────┬──────────┐
267
+ │ name ┆ kind ┆ file_path ┆ start_line ┆ end_line │
268
+ ╞══════╪══════════╪══════════════════════╪════════════╪══════════╡
269
+ │ main ┆ Function ┆ .../verify_edges.py ┆ 88 ┆ 114 │
270
+ │ ... ┆ ... ┆ ... ┆ ... ┆ ... │
271
+ └──────┴──────────┴──────────────────────┴────────────┴──────────┘
272
+ ```
273
+
274
+ ```
275
+ $ uv run python3 main.py --find-callers 'format_gb'
276
+ Callers of 'format_gb':
277
+ shape: (2, 6)
278
+ ┌──────────────────────┬─────────────┬───────────┬───────────┬────────────┬──────────────────┐
279
+ │ caller ┆ caller_kind ┆ file_path ┆ callee ┆ confidence ┆ reason │
280
+ ╞══════════════════════╪═════════════╪═══════════╪═══════════╪════════════╪══════════════════╡
281
+ │ default_memory_limit ┆ Function ┆ ... ┆ format_gb ┆ 1.0 ┆ call at ...:129 │
282
+ │ parse_memory_limit ┆ Function ┆ ... ┆ format_gb ┆ 1.0 ┆ call at ...:139 │
283
+ └──────────────────────┴─────────────┴───────────┴───────────┴────────────┴──────────────────┘
284
+ ```
285
+
286
+ ### Schema only
287
+
288
+ ```
289
+ uv run python3 main.py --schema-only --schema SCHEMA --db DB
290
+ ```
291
+
292
+ Applies the schema file to the database without indexing any files.
293
+ Useful for preparing an empty database ahead of time.
294
+
295
+ ---
296
+
297
+ ## Supported languages
298
+
299
+ Python, JavaScript, TypeScript, Java, C#, C++, Go, Rust, Swift, and
300
+ Kotlin — whenever the corresponding tree-sitter grammar package is
301
+ installed.
302
+
303
+ ---
304
+
305
+ ## Schema
306
+
307
+ The full schema is defined in [`schema.cypher`](./schema.cypher). It
308
+ defines node tables for:
309
+
310
+ - **File**, **Folder** — filesystem hierarchy.
311
+ - **Function**, **Method** — callable declarations.
312
+ - **Class**, **Struct**, **Interface**, **Trait**, **Impl** — type/container
313
+ declarations.
314
+ - **Enum**, **Property**, **CodeElement** — additional code entities.
315
+ - **Route**, **Tool** — framework-level metadata (routes, external tools).
316
+ - **Community**, **Process** — higher-level grouping nodes (for future
317
+ analysis passes).
318
+
319
+ All relationships are stored in a single `CodeRelation` relationship
320
+ table group, with a `type` property indicating the relationship kind
321
+ (e.g. `CALLS`, `DEFINES`, `HAS_METHOD`, `CONTAINS`, `IMPORTS`).
@@ -0,0 +1,6 @@
1
+ main.py,sha256=l27aKJLrR5mdlfDBA2kJHAjv0mUQalbDNo9lNmE-HkU,35560
2
+ lscope-0.1.0.dist-info/METADATA,sha256=BIRRkbRJj1vXmUCFzNf_DARoh48Fku_O-btg2kYNGvQ,15730
3
+ lscope-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
4
+ lscope-0.1.0.dist-info/entry_points.txt,sha256=TCuS_2W57v-6dh5d9bAJ1FelWKzPS02hsclLS28He0s,37
5
+ lscope-0.1.0.dist-info/top_level.txt,sha256=ZAMgPdWghn6xTRBO6Kc3ML1y3ZrZLnjZlqbboKXc_AE,5
6
+ lscope-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lscope = main:main
@@ -0,0 +1 @@
1
+ main
main.py ADDED
@@ -0,0 +1,1052 @@
1
+ """
2
+ lscope — extract a semantic code graph (via tree-sitter) into a Ladybug DB
3
+ and run lightweight code-intelligence queries (find functions / callers).
4
+
5
+ A single invocation does exactly one of these jobs:
6
+
7
+ * **index** — parse one file, several files, or a whole project tree and store
8
+ the resulting semantic graph in a Ladybug database.
9
+ * **query** — run a find-functions or find-callers search against an existing
10
+ database.
11
+
12
+ These two jobs are mutually exclusive: you either write to the DB or you read
13
+ from it, never both in one invocation. `argparse` is tightened so that
14
+ non-sensical flag combinations are rejected up-front with a clear message.
15
+ """
16
+
17
+ import argparse
18
+ import os
19
+ import sys
20
+ import threading
21
+ import uuid
22
+ from collections.abc import Iterable, Iterator
23
+ from concurrent.futures import ThreadPoolExecutor
24
+ from tqdm import tqdm
25
+
26
+ import ladybug
27
+ import pyarrow as pa
28
+ from tree_sitter import Language, Parser
29
+
30
+
31
+ # --------------------------------------------------------------------------- #
32
+ # Language registry
33
+ # --------------------------------------------------------------------------- #
34
+ # A language is supported when its `tree_sitter_<lang>` package is importable.
35
+ # Each entry maps the public name -> (import path, callable returning the
36
+ # language capsule, file extensions). Extensions are lower-cased and compared
37
+ # without the leading dot.
38
+ _LANGUAGE_SPECS = {
39
+ "rust": ("tree_sitter_rust", "language", ("rs",)),
40
+ "python": ("tree_sitter_python", "language", ("py", "pyi")),
41
+ "javascript": (
42
+ "tree_sitter_javascript",
43
+ "language",
44
+ ("js", "jsx", "mjs", "cjs"),
45
+ ),
46
+ "typescript": (
47
+ "tree_sitter_typescript",
48
+ "language_typescript",
49
+ ("ts", "tsx", "mts", "cts"),
50
+ ),
51
+ "java": ("tree_sitter_java", "language", ("java",)),
52
+ "csharp": ("tree_sitter_c_sharp", "language", ("cs",)),
53
+ "cpp": (
54
+ "tree_sitter_cpp",
55
+ "language",
56
+ ("cc", "cpp", "cxx", "h", "hh", "hpp", "hxx"),
57
+ ),
58
+ "go": ("tree_sitter_go", "language", ("go",)),
59
+ "swift": ("tree_sitter_swift", "language", ("swift",)),
60
+ "kotlin": ("tree_sitter_kotlin", "language", ("kt", "kts")),
61
+ }
62
+
63
+
64
+ def supported_languages() -> list[str]:
65
+ """Names of languages whose tree-sitter grammar is installed."""
66
+ import importlib
67
+
68
+ out = []
69
+ for name, (module_name, _func, _exts) in _LANGUAGE_SPECS.items():
70
+ try:
71
+ importlib.import_module(module_name)
72
+ except ImportError:
73
+ continue
74
+ out.append(name)
75
+ return out
76
+
77
+
78
+ class LanguageRegistry:
79
+ """Lazily build tree-sitter ``Parser`` objects per language on demand."""
80
+
81
+ def __init__(self) -> None:
82
+ import importlib
83
+
84
+ self._parsers: dict[str, Parser] = {}
85
+ self._ext_map: dict[str, str] = {}
86
+ for name, (module_name, func, exts) in _LANGUAGE_SPECS.items():
87
+ try:
88
+ mod = importlib.import_module(module_name)
89
+ except ImportError:
90
+ continue
91
+ lang = Language(getattr(mod, func)())
92
+ parser = Parser()
93
+ parser.language = lang
94
+ self._parsers[name] = parser
95
+ for ext in exts:
96
+ self._ext_map[ext.lower().lstrip(".")] = name
97
+
98
+ @property
99
+ def languages(self) -> list[str]:
100
+ return sorted(self._parsers)
101
+
102
+ def language_for_path(self, path: str) -> str | None:
103
+ ext = os.path.splitext(path)[1].lstrip(".").lower()
104
+ return self._ext_map.get(ext)
105
+
106
+ def parser_for(self, language: str) -> Parser:
107
+ try:
108
+ return self._parsers[language]
109
+ except KeyError:
110
+ available = ", ".join(self.languages) or "<none installed>"
111
+ raise SystemExit(
112
+ f"Unsupported language {language!r}. "
113
+ f"Installed grammars: {available}."
114
+ )
115
+
116
+ def parser_for_path(self, path: str) -> Parser | None:
117
+ lang = self.language_for_path(path)
118
+ return self._parsers[lang] if lang else None
119
+
120
+
121
+ _worker_state = threading.local()
122
+
123
+
124
+ def _worker_registry() -> LanguageRegistry:
125
+ """Return a parser registry owned by the current analysis thread."""
126
+ registry = getattr(_worker_state, "registry", None)
127
+ if registry is None:
128
+ registry = LanguageRegistry()
129
+ _worker_state.registry = registry
130
+ return registry
131
+
132
+
133
+ # --------------------------------------------------------------------------- #
134
+ # File discovery
135
+ # --------------------------------------------------------------------------- #
136
+ # Directory subtrees that are never interesting source trees to index.
137
+ _DEFAULT_IGNORE_DIRS = {
138
+ ".git",
139
+ ".hg",
140
+ ".svn",
141
+ ".venv",
142
+ "venv",
143
+ "env",
144
+ "__pycache__",
145
+ ".mypy_cache",
146
+ ".pytest_cache",
147
+ ".ruff_cache",
148
+ "node_modules",
149
+ ".pixi",
150
+ ".tox",
151
+ "dist",
152
+ "build",
153
+ ".eggs",
154
+ ".cache",
155
+ }
156
+
157
+
158
+ def iter_source_files(
159
+ targets: Iterable[str],
160
+ *,
161
+ language: str | None,
162
+ registry: LanguageRegistry,
163
+ ignore_dirs: set[str] = _DEFAULT_IGNORE_DIRS,
164
+ ) -> Iterator[str]:
165
+ """
166
+ Yield absolute paths of source files to index.
167
+
168
+ ``targets`` may be individual files or directories. Directories are
169
+ walked recursively, skipping ``ignore_dirs`` and files whose extension is
170
+ not mapped to a known language (unless ``language`` is given, in which case
171
+ only that language's extensions match).
172
+
173
+ Non-existent paths raise SystemExit.
174
+ """
175
+ seen: set[str] = set()
176
+ for target in targets:
177
+ if not os.path.exists(target):
178
+ raise SystemExit(f"Path not found: {target}")
179
+ target = os.path.abspath(target)
180
+ if os.path.isfile(target):
181
+ # An explicit file is always indexed, but if --language was given
182
+ # it must match: indexing a .rs file under --language python is a
183
+ # non-sensical request.
184
+ file_lang = registry.language_for_path(target)
185
+ if file_lang is None:
186
+ known = ", ".join(sorted(registry._ext_map)) or "<none>"
187
+ raise SystemExit(
188
+ f"Cannot determine language for {target} "
189
+ f"(unknown extension). Known extensions: {known}."
190
+ )
191
+ if language is not None and file_lang != language:
192
+ raise SystemExit(
193
+ f"File {target} is {file_lang!r} but --language is "
194
+ f"{language!r}."
195
+ )
196
+ files = [target]
197
+ else:
198
+ files = _walk_dir(target, language, registry, ignore_dirs)
199
+
200
+ for f in files:
201
+ if f in seen:
202
+ continue
203
+ seen.add(f)
204
+ yield f
205
+
206
+
207
+ def _walk_dir(
208
+ root: str,
209
+ language: str | None,
210
+ registry: LanguageRegistry,
211
+ ignore_dirs: set[str],
212
+ ) -> Iterator[str]:
213
+ for dirpath, dirnames, filenames in os.walk(root):
214
+ # prune ignored dirs in place so os.walk doesn't descend into them
215
+ dirnames[:] = [d for d in dirnames if d not in ignore_dirs]
216
+ for fname in sorted(filenames):
217
+ full = os.path.join(dirpath, fname)
218
+ ext = os.path.splitext(fname)[1].lstrip(".").lower()
219
+ lang = registry._ext_map.get(ext) # noqa: SLF001
220
+ if lang is None:
221
+ continue
222
+ if language is not None and lang != language:
223
+ continue
224
+ yield full
225
+
226
+
227
+ # --------------------------------------------------------------------------- #
228
+ # Schema / DB helpers
229
+ # --------------------------------------------------------------------------- #
230
+ _REQUIRED_SCHEMA_TABLES = {"File", "Function", "Method", "Class", "CodeRelation"}
231
+ _REQUIRED_REL_CONNECTIONS = (
232
+ ("Function", "Class"),
233
+ ("Function", "Interface"),
234
+ ("Function", "Struct"),
235
+ ("Function", "Trait"),
236
+ ("Function", "Impl"),
237
+ ("Method", "Class"),
238
+ ("Method", "Interface"),
239
+ ("Method", "Struct"),
240
+ ("Method", "Trait"),
241
+ ("Method", "Impl"),
242
+ ("Class", "Struct"),
243
+ ("Class", "Impl"),
244
+ ("Class", "Function"),
245
+ ("Interface", "Class"),
246
+ ("Interface", "Struct"),
247
+ ("Interface", "Trait"),
248
+ ("Interface", "Impl"),
249
+ ("Struct", "Class"),
250
+ ("Struct", "Struct"),
251
+ ("Struct", "Impl"),
252
+ ("Trait", "Class"),
253
+ ("Trait", "Struct"),
254
+ ("Trait", "Interface"),
255
+ ("Trait", "Impl"),
256
+ ("Impl", "Interface"),
257
+ ("Impl", "Impl"),
258
+ )
259
+
260
+
261
+ def _default_schema_path() -> str:
262
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.cypher")
263
+
264
+
265
+ def _load_schema_text(schema_path: str | None) -> str | None:
266
+ """Read schema.cypher and normalise the unsupported hash-index directive."""
267
+ if not schema_path:
268
+ return None
269
+ if not os.path.exists(schema_path):
270
+ raise SystemExit(f"Schema file not found: {schema_path}")
271
+ with open(schema_path, "r", encoding="utf8") as fh:
272
+ text = fh.read()
273
+ # Some schema files use the unsupported `CALL disable_default_hash_index=...`.
274
+ # The supported equivalent is `CALL enable_default_hash_index = false`.
275
+ lines = []
276
+ for line in text.splitlines():
277
+ if "disable_default_hash_index" in line:
278
+ lines.append("CALL enable_default_hash_index = false;")
279
+ else:
280
+ lines.append(line)
281
+ return "\n".join(lines)
282
+
283
+
284
+ def apply_schema(conn, schema_text: str | None) -> None:
285
+ if schema_text is None:
286
+ return
287
+ try:
288
+ conn.execute(schema_text)
289
+ except Exception as e: # noqa: BLE001
290
+ # tolerate "table already exists" when re-indexing into an existing DB
291
+ print("Warning: executing schema raised an error; continuing. Error:", e)
292
+
293
+
294
+ def ensure_schema(conn, schema_path: str | None = None) -> None:
295
+ """Create the semantic schema when the database has no graph tables."""
296
+ tables = conn.execute("CALL show_tables() RETURN name").get_as_pl()
297
+ existing = set(tables["name"].to_list()) if tables.height else set()
298
+ if _REQUIRED_SCHEMA_TABLES <= existing:
299
+ for source, target in _REQUIRED_REL_CONNECTIONS:
300
+ if source in existing and target in existing:
301
+ conn.execute(
302
+ "ALTER TABLE CodeRelation ADD IF NOT EXISTS "
303
+ f"FROM {source} TO {target}"
304
+ )
305
+ return
306
+ if existing:
307
+ missing = ", ".join(sorted(_REQUIRED_SCHEMA_TABLES - existing))
308
+ raise SystemExit(
309
+ "Database contains a partial or incompatible schema; "
310
+ f"missing required tables: {missing}."
311
+ )
312
+
313
+ selected_path = schema_path or _default_schema_path()
314
+ schema_text = _load_schema_text(selected_path)
315
+ assert schema_text is not None
316
+ conn.execute(schema_text)
317
+ print(f"Created schema from {selected_path}")
318
+
319
+
320
+ # --------------------------------------------------------------------------- #
321
+ # Semantic ingestion
322
+ # --------------------------------------------------------------------------- #
323
+ _CONTAINER_KINDS = {
324
+ "class_definition": "Class",
325
+ "class_declaration": "Class",
326
+ "class_specifier": "Class",
327
+ "object_declaration": "Class",
328
+ "struct_declaration": "Struct",
329
+ "struct_item": "Struct",
330
+ "protocol_declaration": "Interface",
331
+ "interface_declaration": "Interface",
332
+ "trait_item": "Trait",
333
+ "impl_item": "Impl",
334
+ }
335
+ _FUNCTION_KINDS = {
336
+ "function_definition",
337
+ "function_item",
338
+ "function_declaration",
339
+ "local_function_statement",
340
+ "method_definition",
341
+ "method_declaration",
342
+ "constructor_declaration",
343
+ "init_declaration",
344
+ }
345
+ _CALL_KINDS = {
346
+ "call",
347
+ "call_expression",
348
+ "function_call",
349
+ "invocation_expression",
350
+ "method_invocation",
351
+ "method_call",
352
+ "macro_invocation",
353
+ }
354
+ _METHOD_KINDS = {
355
+ "constructor_declaration",
356
+ "init_declaration",
357
+ "local_function_statement",
358
+ "method_declaration",
359
+ "method_definition",
360
+ }
361
+
362
+
363
+ def _node_text(node, source_bytes: bytes) -> str:
364
+ return source_bytes[node.start_byte : node.end_byte].decode("utf8")
365
+
366
+
367
+ def _field_text(node, field: str, source_bytes: bytes) -> str | None:
368
+ child = node.child_by_field_name(field)
369
+ return _node_text(child, source_bytes) if child is not None else None
370
+
371
+
372
+ def _declaration_name(node, source_bytes: bytes) -> str | None:
373
+ """Extract names, following C/C++ nested declarators when necessary."""
374
+ name = _field_text(node, "name", source_bytes)
375
+ if name:
376
+ return name
377
+ declarator = node.child_by_field_name("declarator")
378
+ while declarator is not None:
379
+ nested = declarator.child_by_field_name("declarator")
380
+ if nested is None:
381
+ text = _node_text(declarator, source_bytes)
382
+ return text.split("::")[-1]
383
+ declarator = nested
384
+ return None
385
+
386
+
387
+ def _called_name(node, source_bytes: bytes) -> str | None:
388
+ """Return the terminal name of a Python/Rust call expression."""
389
+ callee = (
390
+ node.child_by_field_name("function")
391
+ or node.child_by_field_name("name")
392
+ or node.child_by_field_name("macro")
393
+ )
394
+ if callee is None and node.named_child_count:
395
+ callee = node.named_children[0]
396
+ if callee is None:
397
+ return None
398
+
399
+ for field in ("attribute", "field", "name"):
400
+ terminal = callee.child_by_field_name(field)
401
+ if terminal is not None:
402
+ return _node_text(terminal, source_bytes).rstrip("!")
403
+ return _node_text(callee, source_bytes).split("::")[-1].rstrip("!")
404
+
405
+
406
+ def analyze_file(path: str, language: str, source: str, parser: Parser) -> dict:
407
+ """Extract semantic declarations and calls from one source file."""
408
+ source_bytes = source.encode()
409
+ tree = parser.parse(source_bytes)
410
+ file_id = f"file:{path}"
411
+ symbols: list[dict] = []
412
+ calls: list[dict] = []
413
+
414
+ def visit(node, owner: dict, type_owner: dict | None = None) -> None:
415
+ next_owner = owner
416
+ next_type = type_owner
417
+
418
+ if node.type in _CONTAINER_KINDS:
419
+ label = _CONTAINER_KINDS[node.type]
420
+ if node.type == "class_specifier" and node.children:
421
+ label = "Struct" if node.children[0].type == "struct" else "Class"
422
+ name = (
423
+ _field_text(node, "type", source_bytes)
424
+ if label == "Impl"
425
+ else _declaration_name(node, source_bytes)
426
+ )
427
+ if name:
428
+ symbol = {
429
+ "id": f"{label.lower()}:{path}#{node.start_byte}",
430
+ "label": label,
431
+ "name": f"impl {name}" if label == "Impl" else name,
432
+ "qualified_name": name,
433
+ "file_path": path,
434
+ "language": language,
435
+ "start_line": node.start_point.row + 1,
436
+ "end_line": node.end_point.row + 1,
437
+ "owner": owner,
438
+ "relation": "DEFINES",
439
+ }
440
+ symbols.append(symbol)
441
+ next_owner = symbol
442
+ next_type = symbol
443
+
444
+ elif node.type in _FUNCTION_KINDS:
445
+ name = _declaration_name(node, source_bytes)
446
+ if name:
447
+ is_method = type_owner is not None or node.type in _METHOD_KINDS
448
+ if node.child_by_field_name("receiver") is not None:
449
+ is_method = True
450
+ label = "Method" if is_method else "Function"
451
+ symbol = {
452
+ "id": f"{label.lower()}:{path}#{node.start_byte}",
453
+ "label": label,
454
+ "name": name,
455
+ "qualified_name": (
456
+ f"{type_owner['qualified_name']}.{name}"
457
+ if type_owner is not None
458
+ else name
459
+ ),
460
+ "file_path": path,
461
+ "language": language,
462
+ "start_line": node.start_point.row + 1,
463
+ "end_line": node.end_point.row + 1,
464
+ "owner": type_owner or owner,
465
+ "relation": "HAS_METHOD" if type_owner else "DEFINES",
466
+ }
467
+ symbols.append(symbol)
468
+ next_owner = symbol
469
+ next_type = None
470
+
471
+ if node.type in _CALL_KINDS:
472
+ name = _called_name(node, source_bytes)
473
+ if name:
474
+ calls.append(
475
+ {
476
+ "caller": owner,
477
+ "callee_name": name,
478
+ "file_path": path,
479
+ "line": node.start_point.row + 1,
480
+ }
481
+ )
482
+
483
+ for child in node.named_children:
484
+ visit(child, next_owner, next_type)
485
+
486
+ file_owner = {"id": file_id, "label": "File"}
487
+ visit(tree.root_node, file_owner)
488
+ return {
489
+ "file": {
490
+ "id": file_id,
491
+ "name": os.path.basename(path),
492
+ "path": path,
493
+ "language": language,
494
+ },
495
+ "symbols": symbols,
496
+ "calls": calls,
497
+ }
498
+
499
+
500
+ def analyze_path(path: str) -> dict:
501
+ """Read and analyze a file using a parser local to the worker thread."""
502
+ registry = _worker_registry()
503
+ language = registry.language_for_path(path)
504
+ if language is None:
505
+ raise ValueError(f"Cannot determine language for {path}")
506
+ with open(path, "r", encoding="utf8") as fh:
507
+ source = fh.read()
508
+ return analyze_file(path, language, source, registry.parser_for(language))
509
+
510
+
511
+ def _collect_file_data(
512
+ analysis: dict,
513
+ ) -> tuple[
514
+ dict,
515
+ dict[str, list[dict]],
516
+ int,
517
+ list[tuple[dict, dict, str, float, str, int]],
518
+ ]:
519
+ """
520
+ Extract node data and edges from one analysis without touching the DB.
521
+
522
+ Returns ``(file_dict, symbols_by_label, node_count, edges)``.
523
+ """
524
+ file = analysis["file"]
525
+ file_ref = {"id": file["id"], "label": "File"}
526
+
527
+ by_label: dict[str, list[dict]] = {}
528
+ for symbol in analysis["symbols"]:
529
+ by_label.setdefault(symbol["label"], []).append(
530
+ {
531
+ "id": symbol["id"],
532
+ "name": symbol["name"],
533
+ "qualified_name": symbol["qualified_name"],
534
+ "file_path": symbol["file_path"],
535
+ "language": symbol["language"],
536
+ "start_line": symbol["start_line"],
537
+ "end_line": symbol["end_line"],
538
+ }
539
+ )
540
+
541
+ edges: list[tuple[dict, dict, str, float, str, int]] = [
542
+ (
543
+ symbol.get("owner") or file_ref,
544
+ symbol,
545
+ symbol["relation"],
546
+ 1.0,
547
+ "",
548
+ 0,
549
+ )
550
+ for symbol in analysis["symbols"]
551
+ ]
552
+ return file, by_label, 1 + len(analysis["symbols"]), edges
553
+
554
+
555
+ def ingest_calls(conn, analyses: list[dict]) -> int:
556
+ """Resolve calls by name, preferring a declaration in the same file.
557
+
558
+ Resolution stays a single global pass; only the writes are batched into one
559
+ ``CREATE`` per ``(caller_label, target_label)`` pair via ``_ingest_chunk_edges``.
560
+ """
561
+ by_name: dict[str, list[dict]] = {}
562
+ for analysis in analyses:
563
+ for symbol in analysis["symbols"]:
564
+ if symbol["label"] in {"Function", "Method"}:
565
+ by_name.setdefault(symbol["name"], []).append(symbol)
566
+
567
+ edges: list[tuple[dict, dict, str, float, str, int]] = []
568
+ for analysis in analyses:
569
+ for call in analysis["calls"]:
570
+ candidates = by_name.get(call["callee_name"], [])
571
+ if not candidates:
572
+ continue
573
+ target = next(
574
+ (s for s in candidates if s["file_path"] == call["file_path"]),
575
+ candidates[0],
576
+ )
577
+ edges.append(
578
+ (
579
+ call["caller"],
580
+ target,
581
+ "CALLS",
582
+ 1.0 if len(candidates) == 1 else 0.7,
583
+ f"call at {call['file_path']}:{call['line']}",
584
+ 0,
585
+ )
586
+ )
587
+ _ingest_chunk_edges(conn, edges)
588
+ return len(edges)
589
+
590
+
591
+ def _ingest_chunk_edges(
592
+ conn,
593
+ all_edges: list[tuple[dict, dict, str, float, str, int]],
594
+ ) -> None:
595
+ """Bulk-insert all edges collected from a chunk of files.
596
+
597
+ Edges are grouped by ``(source.label, target.label)``; each group
598
+ is registered as a temporary Arrow memory table and loaded with
599
+ ``COPY FROM`` — one bulk operation per label pair for the
600
+ entire chunk instead of one per file, and without writing to disk.
601
+ """
602
+ if not all_edges:
603
+ return
604
+
605
+ by_pair: dict[tuple[str, str], list[dict]] = {}
606
+ for source, target, rel_type, confidence, reason, step in all_edges:
607
+ key = (source["label"], target["label"])
608
+ by_pair.setdefault(key, []).append(
609
+ {
610
+ "src": source["id"],
611
+ "dst": target["id"],
612
+ "type": rel_type,
613
+ "confidence": confidence,
614
+ "reason": reason,
615
+ "step": step,
616
+ }
617
+ )
618
+
619
+ for (src_label, dst_label), rows in by_pair.items():
620
+ table = pa.table(
621
+ {
622
+ "from": [r["src"] for r in rows],
623
+ "to": [r["dst"] for r in rows],
624
+ "type": [r["type"] for r in rows],
625
+ "confidence": [r["confidence"] for r in rows],
626
+ "reason": [r["reason"] for r in rows],
627
+ "step": [r["step"] for r in rows],
628
+ }
629
+ )
630
+ tmp_name = f"__lscope_{uuid.uuid4().hex}"
631
+ conn.create_arrow_table(tmp_name, table)
632
+ conn.execute(
633
+ f"COPY CodeRelation FROM ("
634
+ f"MATCH (s:{tmp_name}) "
635
+ f"RETURN s.from, s.to, s.type, s.confidence, s.reason, s.step"
636
+ f") "
637
+ f"(FROM='{src_label}', TO='{dst_label}')"
638
+ )
639
+ conn.drop_arrow_table(tmp_name)
640
+
641
+
642
+ def _ingest_chunk_nodes(
643
+ conn,
644
+ all_files: list[dict],
645
+ all_by_label: dict[str, list[dict]],
646
+ ) -> None:
647
+ """Bulk-insert every node (files + symbols) for a whole chunk.
648
+
649
+ One ``UNWIND`` + ``MERGE`` per label across every file in the
650
+ chunk, wrapped in a single transaction — instead of one
651
+ transaction + one query per label **per file**.
652
+ """
653
+ conn.execute("BEGIN TRANSACTION")
654
+ if all_files:
655
+ conn.execute(
656
+ "UNWIND $rows AS row "
657
+ "MERGE (f:File {id: row.id}) "
658
+ "SET f.name = row.name, f.path = row.path, "
659
+ "f.filePath = row.path, f.language = row.language",
660
+ {"rows": all_files},
661
+ )
662
+ for label, rows in all_by_label.items():
663
+ conn.execute(
664
+ f"UNWIND $rows AS row "
665
+ f"MERGE (n:{label} {{id: row.id}}) "
666
+ "SET n.name = row.name, "
667
+ "n.qualifiedName = row.qualified_name, "
668
+ "n.filePath = row.file_path, "
669
+ "n.language = row.language, "
670
+ "n.startLine = row.start_line, "
671
+ "n.endLine = row.end_line",
672
+ {"rows": rows},
673
+ )
674
+ conn.execute("COMMIT")
675
+
676
+
677
+ def ingest_analyses_parallel(
678
+ db, analyses: list[dict], workers: int
679
+ ) -> tuple[
680
+ int,
681
+ list[tuple[dict, dict, str, float, str, int]],
682
+ ]:
683
+ """
684
+ Ingest nodes for many files in parallel.
685
+
686
+ Each worker thread owns its own ``Connection`` to the shared ``Database``
687
+ (opened with ``enable_multi_writes=True`` so concurrent write transactions
688
+ are permitted). Analyses are split round-robin into ``workers`` chunks so
689
+ per-file node batches stay independent and need no cross-thread
690
+ coordination.
691
+
692
+ Returns ``(node_count, all_definition_edges)``. The caller must
693
+ insert the returned edges **sequentially** after all workers finish
694
+ (``COPY CodeRelation`` is a DDL operation that cannot run concurrently
695
+ with active write transactions from other threads).
696
+ """
697
+ if not analyses:
698
+ return 0, []
699
+ workers = max(1, min(workers, len(analyses)))
700
+ total = 0
701
+ all_edges: list[tuple[dict, dict, str, float, str, int]] = []
702
+
703
+ def _run(chunk: list[dict]) -> tuple[int, list]:
704
+ """Collect node data from every file in *chunk*, bulk-insert
705
+ all nodes, return (count, edges) for deferred insertion."""
706
+ conn = ladybug.Connection(db)
707
+ try:
708
+ all_files: list[dict] = []
709
+ all_by_label: dict[str, list[dict]] = {}
710
+ chunk_edges: list[
711
+ tuple[dict, dict, str, float, str, int]
712
+ ] = []
713
+ chunk_total = 0
714
+ for a in chunk:
715
+ file_ref, by_label, n, edges = _collect_file_data(a)
716
+ all_files.append(file_ref)
717
+ chunk_total += n
718
+ chunk_edges.extend(edges)
719
+ for label, rows in by_label.items():
720
+ all_by_label.setdefault(label, []).extend(rows)
721
+ _ingest_chunk_nodes(conn, all_files, all_by_label)
722
+ return chunk_total, chunk_edges
723
+ finally:
724
+ conn.close()
725
+
726
+ chunks: list[list[dict]] = [[] for _ in range(workers)]
727
+ for i, analysis in enumerate(analyses):
728
+ chunks[i % workers].append(analysis)
729
+
730
+ if workers == 1:
731
+ for c in tqdm(chunks, desc="Ingesting", unit="chunk"):
732
+ n, edges = _run(c)
733
+ total += n
734
+ all_edges.extend(edges)
735
+ # Single-threaded — safe to insert edges right away
736
+ return total, all_edges
737
+
738
+ with ThreadPoolExecutor(
739
+ max_workers=workers, thread_name_prefix="lscope-ingest"
740
+ ) as executor:
741
+ for n, edges in tqdm(
742
+ executor.map(_run, chunks),
743
+ total=len(chunks),
744
+ desc="Ingesting",
745
+ unit="chunk",
746
+ ):
747
+ total += n
748
+ all_edges.extend(edges)
749
+ return total, all_edges
750
+
751
+
752
+ # --------------------------------------------------------------------------- #
753
+ # Queries
754
+ # --------------------------------------------------------------------------- #
755
+ def _run_query(conn, query: str, params: dict | None = None):
756
+ qr = conn.execute(query, params or {})
757
+ return qr.get_as_pl()
758
+
759
+
760
+ def find_functions(conn, pattern: str, languages: list[str] | None = None):
761
+ """Return semantic Function and Method nodes matching ``pattern``."""
762
+ del languages # reserved for future per-language filtering
763
+ frames = []
764
+ for label in ("Function", "Method"):
765
+ frames.append(
766
+ _run_query(
767
+ conn,
768
+ f"""
769
+ MATCH (fn:{label})
770
+ WHERE fn.name =~ $pattern
771
+ RETURN fn.name AS name,
772
+ '{label}' AS kind,
773
+ fn.filePath AS file_path,
774
+ fn.startLine AS start_line,
775
+ fn.endLine AS end_line
776
+ """,
777
+ {"pattern": pattern},
778
+ )
779
+ )
780
+ return pl.concat(frames, how="vertical")
781
+
782
+
783
+ def find_callers(conn, func_name: str):
784
+ """Return semantic nodes with a CALLS edge to a named function or method."""
785
+ frames = []
786
+ for caller_label in ("File", "Function", "Method"):
787
+ for target_label in ("Function", "Method"):
788
+ frames.append(
789
+ _run_query(
790
+ conn,
791
+ f"""
792
+ MATCH (caller:{caller_label})-[r:CodeRelation]->(
793
+ target:{target_label}
794
+ )
795
+ WHERE r.type = 'CALLS' AND target.name = $func_name
796
+ RETURN caller.name AS caller,
797
+ '{caller_label}' AS caller_kind,
798
+ caller.filePath AS file_path,
799
+ target.name AS callee,
800
+ r.confidence AS confidence,
801
+ r.reason AS reason
802
+ """,
803
+ {"func_name": func_name},
804
+ )
805
+ )
806
+ return pl.concat(frames, how="vertical")
807
+
808
+
809
+ # --------------------------------------------------------------------------- #
810
+ # Argparse
811
+ # --------------------------------------------------------------------------- #
812
+ def _build_parser() -> argparse.ArgumentParser:
813
+ installed = supported_languages()
814
+ p = argparse.ArgumentParser(
815
+ prog="lscope",
816
+ description=(
817
+ "Index a semantic code graph into a Ladybug DB and run code queries. "
818
+ "Exactly one of --index / --find-functions / --find-callers is "
819
+ "required."
820
+ ),
821
+ )
822
+ p.add_argument(
823
+ "--db",
824
+ "-d",
825
+ default="test.db",
826
+ help="Ladybug database file (default: %(default)s)",
827
+ )
828
+ p.add_argument(
829
+ "--schema",
830
+ "-s",
831
+ default=None,
832
+ help="Schema file used to initialize an empty database",
833
+ )
834
+
835
+ index_grp = p.add_argument_group("indexing")
836
+ index_grp.add_argument(
837
+ "--index",
838
+ "--ingest",
839
+ dest="index_targets",
840
+ nargs="*",
841
+ metavar="PATH",
842
+ default=None,
843
+ help=(
844
+ "Index one or more files or directories. "
845
+ "Use without a value to index the current directory."
846
+ ),
847
+ )
848
+ index_grp.add_argument(
849
+ "--language",
850
+ "-l",
851
+ choices=installed or ["rust", "python"],
852
+ default=None,
853
+ help="Restrict indexing to a single language (default: all installed).",
854
+ )
855
+ index_grp.add_argument(
856
+ "--workers",
857
+ type=int,
858
+ default=min(32, (os.cpu_count() or 1) + 4),
859
+ help=(
860
+ "Threads for both file analysis and DB ingestion "
861
+ "(default: %(default)s)."
862
+ ),
863
+ )
864
+
865
+ query_grp = p.add_argument_group("queries")
866
+ query_grp.add_argument(
867
+ "--find-functions",
868
+ dest="find_functions",
869
+ default=None,
870
+ metavar="REGEX",
871
+ help="Regex (Cypher =~) to find function/method names",
872
+ )
873
+ query_grp.add_argument(
874
+ "--find-callers",
875
+ dest="find_callers",
876
+ default=None,
877
+ metavar="NAME",
878
+ help="Function name to find call sites for (exact match on identifier text)",
879
+ )
880
+ p.add_argument(
881
+ "--schema-only",
882
+ action="store_true",
883
+ help="Apply the schema file to the DB and exit (no index, no query).",
884
+ )
885
+ return p
886
+
887
+
888
+ def _resolve_actions(args: argparse.Namespace) -> str:
889
+ """
890
+ Decide which job this invocation runs. Returns one of:
891
+ 'index', 'find-functions', 'find-callers', 'schema-only'.
892
+
893
+ Raises SystemExit for empty / conflicting combinations.
894
+ """
895
+ requested = []
896
+ if args.index_targets is not None:
897
+ requested.append("index")
898
+ if args.find_functions is not None:
899
+ requested.append("find-functions")
900
+ if args.find_callers is not None:
901
+ requested.append("find-callers")
902
+ if args.schema_only:
903
+ requested.append("schema-only")
904
+
905
+ if len(requested) == 0:
906
+ raise SystemExit(
907
+ "Nothing to do. Specify exactly one of "
908
+ "--index, --find-functions, --find-callers, or --schema-only."
909
+ )
910
+ if len(requested) > 1:
911
+ raise SystemExit(
912
+ "Conflicting flags: at most one of "
913
+ "--index, --find-functions, --find-callers, --schema-only "
914
+ "may be given (got: " + ", ".join(requested) + ")."
915
+ )
916
+ return requested[0]
917
+
918
+
919
+ # --------------------------------------------------------------------------- #
920
+ # Command runners
921
+ # --------------------------------------------------------------------------- #
922
+ def _open_db(db_path: str, *, read_only: bool = False, multi_writes: bool = False):
923
+ db = ladybug.Database(db_path, read_only=read_only, enable_multi_writes=multi_writes)
924
+ conn = ladybug.Connection(db)
925
+ return db, conn
926
+
927
+
928
+ def run_index(args: argparse.Namespace) -> int:
929
+ targets = args.index_targets or ["."]
930
+ registry = LanguageRegistry()
931
+ if not registry.languages:
932
+ raise SystemExit(
933
+ "No tree-sitter grammar is installed. "
934
+ "Install one of: " + ", ".join(_LANGUAGE_SPECS) + "."
935
+ )
936
+
937
+ files = list(
938
+ iter_source_files(
939
+ targets, language=args.language, registry=registry
940
+ )
941
+ )
942
+ if not files:
943
+ where = "the given paths" if args.language is None else f"{args.language} files"
944
+ print(f"No indexable source files found in {where}.")
945
+ return 0
946
+ if args.workers < 1:
947
+ raise SystemExit("--workers must be at least 1.")
948
+
949
+ db, conn = _open_db(args.db, multi_writes=True)
950
+ try:
951
+ ensure_schema(conn, args.schema)
952
+ worker_count = min(args.workers, len(files))
953
+ with ThreadPoolExecutor(
954
+ max_workers=worker_count,
955
+ thread_name_prefix="lscope-analyze",
956
+ ) as executor:
957
+ analyses = list(
958
+ tqdm(
959
+ executor.map(analyze_path, files),
960
+ total=len(files),
961
+ desc="Analyzing",
962
+ unit="file",
963
+ )
964
+ )
965
+
966
+ per_lang: dict[str, int] = {}
967
+ for i, analysis in enumerate(analyses, 1):
968
+ path = analysis["file"]["path"]
969
+ lang = analysis["file"]["language"]
970
+ per_lang[lang] = per_lang.get(lang, 0) + 1
971
+
972
+ # Nodes: parallelized across worker threads, each with its own
973
+ # connection to the shared multi-write database.
974
+ total_nodes, def_edges = ingest_analyses_parallel(
975
+ db, analyses, worker_count
976
+ )
977
+ # Definition edges (COPY CodeRelation = DDL) must run sequentially,
978
+ # not concurrently with active write transactions from workers.
979
+ _ingest_chunk_edges(conn, def_edges)
980
+ # Calls need every node already written and a global name index, so they
981
+ # stay single-threaded on the main connection.
982
+ call_count = ingest_calls(conn, analyses)
983
+ print(
984
+ f"\nIngested {len(files)} file(s), {total_nodes} semantic node(s), "
985
+ f"and {call_count} resolved call(s) into {args.db} "
986
+ f"using {worker_count} analysis thread(s)"
987
+ )
988
+ for lang, count in sorted(per_lang.items()):
989
+ print(f" {lang}: {count} file(s)")
990
+ finally:
991
+ conn.close()
992
+ db.close()
993
+ return 0
994
+
995
+
996
+ def run_find_functions(args: argparse.Namespace) -> int:
997
+ db, conn = _open_db(args.db, read_only=True)
998
+ try:
999
+ df = find_functions(conn, args.find_functions)
1000
+ print(f"Functions matching /{args.find_functions}/:")
1001
+ print(df)
1002
+ finally:
1003
+ conn.close()
1004
+ db.close()
1005
+ return 0
1006
+
1007
+
1008
+ def run_find_callers(args: argparse.Namespace) -> int:
1009
+ db, conn = _open_db(args.db, read_only=True)
1010
+ try:
1011
+ df = find_callers(conn, args.find_callers)
1012
+ print(f"Callers of {args.find_callers!r}:")
1013
+ print(df)
1014
+ finally:
1015
+ conn.close()
1016
+ db.close()
1017
+ return 0
1018
+
1019
+
1020
+ def run_schema_only(args: argparse.Namespace) -> int:
1021
+ if args.schema is None:
1022
+ raise SystemExit("--schema-only requires --schema/-s.")
1023
+ schema_text = _load_schema_text(args.schema)
1024
+ db, conn = _open_db(args.db)
1025
+ try:
1026
+ apply_schema(conn, schema_text)
1027
+ print(f"Applied schema from {args.schema} to {args.db}")
1028
+ finally:
1029
+ conn.close()
1030
+ db.close()
1031
+ return 0
1032
+
1033
+
1034
+ def main(argv: list[str] | None = None) -> int:
1035
+ parser = _build_parser()
1036
+ args = parser.parse_args(argv)
1037
+ action = _resolve_actions(args)
1038
+
1039
+ if action == "index":
1040
+ return run_index(args)
1041
+ if action == "find-functions":
1042
+ return run_find_functions(args)
1043
+ if action == "find-callers":
1044
+ return run_find_callers(args)
1045
+ if action == "schema-only":
1046
+ return run_schema_only(args)
1047
+ parser.error(f"Unhandled action {action!r}")
1048
+ return 2 # unreachable
1049
+
1050
+
1051
+ if __name__ == "__main__":
1052
+ sys.exit(main())