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.
- lscope-0.1.0.dist-info/METADATA +321 -0
- lscope-0.1.0.dist-info/RECORD +6 -0
- lscope-0.1.0.dist-info/WHEEL +5 -0
- lscope-0.1.0.dist-info/entry_points.txt +2 -0
- lscope-0.1.0.dist-info/top_level.txt +1 -0
- main.py +1052 -0
|
@@ -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 @@
|
|
|
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())
|