Initial commit: rustytokens — native codebase indexer + MCP server

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
endergebnisandCopilot committed 2026-09-04 02:43:00 +02:00
commit 354132e887
43 files changed
+3783

No files matched your search

+1
View File
@@ -0,0 +1 @@
/target
Generated
+591
View File
@@ -0,0 +1,591 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bstr"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
dependencies = [
"memchr",
"serde_core",
]
[[package]]
name = "cc"
version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "globset"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "ignore"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libsqlite3-sys"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rusqlite"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustytokens"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"ignore",
"rayon",
"regex",
"rusqlite",
"serde",
"serde_json",
"xxhash-rust",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "smallvec"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "xxhash-rust"
version = "0.8.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "rustytokens"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4", features = ["derive"] }
rusqlite = { version = "0.31", features = ["bundled"] }
ignore = "0.4"
rayon = "1"
xxhash-rust = { version = "0.8", features = ["xxh3"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
regex = "1"
[dev-dependencies]
serde_json = "1"
[profile.release]
lto = true
strip = true
+172
View File
@@ -0,0 +1,172 @@
# rustytokens
A native Rust CLI + MCP server that indexes a codebase with
[`universal-ctags`](https://github.com/universal-ctags/ctags) and lets an AI
coding agent retrieve **symbols and functions** instead of whole files — saving
context tokens.
Language coverage (via ctags): Rust, Java, Python, C/C++, TypeScript,
JavaScript, HTML. HTML import edges are extracted directly (not via ctags), and
`<link>`/`<script>`/`<img>` references to local `.css`/`.js`/image files are
tracked as imports. CSS files are discovered and import-tracked, but ctags has
no CSS parser — `search symbols` returns nothing for `.css` files.
`rustytokens` is an independent, clean-room implementation. It does not share
code with any AGPL-licensed project.
## Prerequisites
- Rust (MSRV: current stable; built and tested on nightly)
- `universal-ctags` on `PATH` (`ctags --version` must report Universal Ctags)
```bash
# Debian/Ubuntu
sudo apt install universal-ctags
# macOS
brew install universal-ctags
```
No system SQLite needed — `rusqlite` bundles SQLite with FTS5.
## Build
```bash
cargo build --release
# binary at target/release/rustytokens
```
## Commands
| Command | Purpose |
|---|---|
| `index create <path>` | Index a project from scratch |
| `index update <path>` | Re-index only changed files (content-hash diff) |
| `search symbols <query>` | Symbol search by name (exact > prefix > full-text) |
| `search text <query>` | Full-text search over file contents |
| `inspect symbol <id>` | Source of a single symbol |
| `inspect outline <file>` | Symbol outline of a file |
| `inspect file <file> [--lines a-b]` | Read a file or line range |
| `inspect tree [--depth n]` | Indexed file tree |
| `inspect blast-radius <file> [--depth n]` | Files transitively affected by a change |
| `find importers <file>` | Who imports this file |
| `find dead` | Files nobody imports (entry points excluded) |
| `inspect cycles` | Circular imports |
| `serve` | MCP server over stdio |
| `stats` | Index statistics |
Global options: `--project <dir>` (default `.`), `--format json|table`
(default `json`), `--compact`.
## Examples
```bash
# Index a project
rustytokens index create ./myproject
# Find the `authenticate` function
rustytokens --project ./myproject search symbols authenticate
# Get only that function's source (id from the search result)
rustytokens --project ./myproject inspect symbol 42
# Which files import auth.rs?
rustytokens --project ./myproject find importers src/auth.rs
# What's dead / circular?
rustytokens --project ./myproject find dead
rustytokens --project ./myproject inspect cycles
# Human-readable output
rustytokens --project ./myproject --format table search symbols authenticate
```
## MCP integration
Register the binary as a stdio MCP server (e.g. in Claude Code / Finir
`mcp.json`):
```json
{
"mcpServers": {
"rustytokens": {
"command": "/path/to/rustytokens",
"args": ["--project", "/path/to/your/project", "serve"]
}
}
}
```
Exposed tools: `index_create`, `index_update`, `search_symbols`,
`search_text`, `inspect_symbol`, `inspect_outline`, `inspect_file`,
`inspect_tree`, `find_importers`, `find_dead`, `inspect_cycles`,
`inspect_blast_radius`, `stats`.
## Why it saves tokens
Instead of sending a whole file, the agent fetches only the symbol it needs.
Measured on `rustytokens`' own `src/ops.rs` (396 lines, ~11.7 KB):
- Whole file: 396 lines, 11,675 chars (~2,900 tokens)
- `inspect symbol` for `build_tree`: 14 lines, 437 chars (~110 tokens)
That single retrieval is ~96% smaller than reading the file. Across a large
codebase and a long session, per-symbol retrieval keeps context focused on the
code actually being edited.
## Performance
Measured on a Xeon E5-2667 v3 @ 3.2 GHz (16 cores), `cargo build --release`,
`universal-ctags` on `PATH`. Wall time, CPU time and peak RSS via
`/usr/bin/time -v`.
| Operation | Repo (files) | Wall | CPU | Peak RSS |
|---|---|---|---|---|
| `index create` | nlohmann/json (497) | 1.61 s | 0.98 s | 22 MB |
| `index create` | gson (264) | 0.92 s | 0.46 s | 13 MB |
| `index create` (warm) | nlohmann/json (497) | 1.55 s | — | — |
| `index update` (no-op) | nlohmann/json (497) | 0.02 s | — | — |
| `search symbols` | — | 8.7 ms | — | 7.8 MB |
| `find dead` | — | 4.4 ms | — | — |
| `inspect cycles` | — | 3.7 ms | — | — |
Notes:
- `index update` is ~50× faster than a full rebuild when nothing changed: it
hashes every file in parallel (rayon) and re-indexes only changed ones, while
`index create` drops and rebuilds the whole database. A warm `index create`
(≈1.55 s) shows that hashing dominates the cost.
- Hardware cycle counters were not available (`perf` tools missing on this
kernel), so cycle figures are estimates: CPU time × 3.2 GHz nominal.
## Development
```bash
cargo fmt
cargo clippy
cargo test --release # unit (in-memory SQLite) + CLI integration tests
```
Test fixtures live in `tests/fixtures/` (own small Rust/Python/TS repos).
## Licensing
`rustytokens` is an independent, clean-room implementation — no code is taken
from any AGPL-licensed project, so the project's own source is yours to license
however you choose (MIT, proprietary, commercial, closed-source). Decide and
set it once: add a `license` field to `Cargo.toml` and, if you ship the source,
drop in a `LICENSE` file.
Dependencies are all permissive (MIT / Apache-2.0 / BSL-1.0 / Unlicense):
`clap`, `rusqlite`, `ignore`, `rayon`, `xxhash-rust`, `serde`, `serde_json`,
`anyhow`, `regex`. None is copyleft — commercial use and closed-source
distribution are fine. The only obligation is to ship each crate's copyright
notice and license text (e.g. a `THIRD_PARTY_NOTICES.md`).
`universal-ctags` is GPL-licensed, but `rustytokens` invokes it only as an
external program over `PATH` (subprocess + stdio, no linking, no bundling). That
keeps it a separate program and does not impose GPL on `rustytokens` itself. Do
not bundle the `ctags` binary into a distributed product — that is what would
trigger GPL obligations for that binary. Keep it a user-installed prerequisite.
Naming: `rustytokens` is distinct from `toktoken`. If this goes commercial,
avoid "toktoken-compatible" or similar wording in marketing.
+107
View File
@@ -0,0 +1,107 @@
//! clap definitions for all subcommands.
use std::path::PathBuf;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "rustytokens",
version,
about = "Native codebase indexer + MCP server"
)]
pub struct Cli {
/// Project root for index-backed commands (defaults to current directory)
#[arg(short, long, global = true, default_value = ".")]
pub project: PathBuf,
/// Output format: json or table
#[arg(long, global = true, default_value = "json")]
pub format: String,
/// Compact JSON output
#[arg(long, global = true)]
pub compact: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Build or refresh the index
Index {
#[command(subcommand)]
sub: IndexCommand,
},
/// Search symbols or file contents
Search {
#[command(subcommand)]
sub: SearchCommand,
},
/// Inspect symbols, files, outlines, the tree, or cycles
Inspect {
#[command(subcommand)]
sub: InspectCommand,
},
/// Find importers or dead code
Find {
#[command(subcommand)]
sub: FindCommand,
},
/// Start the MCP server over stdio
Serve,
/// Show index statistics
Stats,
}
#[derive(Subcommand)]
pub enum IndexCommand {
/// Index a project from scratch
Create { path: PathBuf },
/// Re-index only changed files
Update { path: PathBuf },
}
#[derive(Subcommand)]
pub enum SearchCommand {
/// Search symbols by name
Symbols { query: String },
/// Full-text search over file contents
Text { query: String },
}
#[derive(Subcommand)]
pub enum InspectCommand {
/// Source code of a single symbol by id
Symbol { id: i64 },
/// Symbol outline of a file
Outline { file: String },
/// Read a file or a line range (e.g. --lines 10-40)
File {
file: String,
#[arg(long)]
lines: Option<String>,
},
/// Indexed file tree
Tree {
#[arg(long)]
depth: Option<usize>,
},
/// Files transitively affected by a change
BlastRadius {
file: String,
#[arg(long)]
depth: Option<usize>,
},
/// Find circular imports
Cycles,
}
#[derive(Subcommand)]
pub enum FindCommand {
/// Who imports this file
Importers { file: String },
/// Files with no importers
Dead,
}
+81
View File
@@ -0,0 +1,81 @@
//! Project configuration: paths, cache dir, ignore patterns, language mapping.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
/// Directory names skipped during discovery (own list).
pub const SKIP_DIRS: &[&str] = &[
".git",
".hg",
".svn",
"node_modules",
"target",
"vendor",
"venv",
".venv",
"__pycache__",
"dist",
"build",
".idea",
".vscode",
".mypy_cache",
".pytest_cache",
".next",
".nuxt",
"bower_components",
"coverage",
"out",
];
/// Map a file extension to a language label, or `None` if unsupported.
pub fn language_for(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?;
Some(match ext {
"rs" => "rust",
"java" => "java",
"py" => "python",
"c" | "h" => "c",
"cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => "cpp",
"ts" | "tsx" | "mts" | "cts" => "typescript",
"js" | "jsx" | "mjs" | "cjs" => "javascript",
"html" | "htm" => "html",
"css" => "css",
_ => return None,
})
}
/// Base cache directory for index databases.
pub fn cache_dir() -> PathBuf {
let base = std::env::var("XDG_CACHE_HOME")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".cache"))
})
.unwrap_or_else(|| PathBuf::from("."));
base.join("rustytokens")
}
/// Deterministic short key for a project path (used to name the cache DB).
fn project_key(path: &str) -> String {
let mut h = DefaultHasher::new();
path.hash(&mut h);
format!("{:016x}", h.finish())
}
/// Path of the SQLite index DB for a project root.
pub fn index_db_path(project: &Path) -> PathBuf {
let canon = project
.canonicalize()
.unwrap_or_else(|_| project.to_path_buf());
let key = project_key(&canon.to_string_lossy());
let name = project
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "project".to_string());
cache_dir().join(format!("{name}-{key}.db"))
}
+65
View File
@@ -0,0 +1,65 @@
//! Thin subprocess wrapper around universal-ctags (no linking, own structs).
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use anyhow::{Context, Result, anyhow};
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Tag {
pub name: String,
pub path: String,
#[serde(default)]
pub line: Option<u64>,
#[serde(default)]
pub end: Option<u64>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub signature: Option<String>,
}
/// Run ctags over `files`, returning parsed JSON tags.
pub fn run(files: &[&Path]) -> Result<Vec<Tag>> {
if files.is_empty() {
return Ok(Vec::new());
}
let mut child = Command::new("ctags")
.args(["--output-format=json", "--fields=+nKeSt", "-L", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to start ctags (is universal-ctags installed?)")?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow!("ctags stdin unavailable"))?;
for f in files {
writeln!(stdin, "{}", f.to_string_lossy())?;
}
}
let output = child.wait_with_output().context("ctags failed")?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("ctags exited with {}: {}", output.status, err));
}
let mut tags = Vec::new();
for line in output.stdout.split(|b| *b == b'\n') {
if line.is_empty() {
continue;
}
if let Ok(t) = serde_json::from_slice::<Tag>(line) {
tags.push(t);
}
}
Ok(tags)
}
+59
View File
@@ -0,0 +1,59 @@
//! Filesystem walk with smart filtering, producing the list of source files to index.
use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::config::{SKIP_DIRS, language_for};
#[derive(Debug, Clone)]
pub struct SourceFile {
/// Absolute path on disk.
pub path: PathBuf,
/// Path relative to the project root (forward slashes).
pub rel: String,
pub language: &'static str,
}
pub fn discover(project: &Path) -> Result<Vec<SourceFile>> {
let project_abs = project
.canonicalize()
.unwrap_or_else(|_| project.to_path_buf());
let mut builder = ignore::WalkBuilder::new(&project_abs);
builder.standard_filters(true);
builder.require_git(false);
builder.filter_entry(|entry| {
if entry.file_type().is_some_and(|t| t.is_dir()) {
let name = entry.file_name().to_string_lossy();
!SKIP_DIRS.iter().any(|d| *d == name)
} else {
true
}
});
let mut out = Vec::new();
for entry in builder.build() {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if entry.file_type().is_some_and(|t| t.is_dir()) {
continue;
}
let Some(language) = language_for(path) else {
continue;
};
let rel = path
.strip_prefix(&project_abs)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| path.to_string_lossy().replace('\\', "/"));
out.push(SourceFile {
path: path.to_path_buf(),
rel,
language,
});
}
out.sort_by(|a, b| a.rel.cmp(&b.rel));
Ok(out)
}
+177
View File
@@ -0,0 +1,177 @@
//! Import graph: cycles (Tarjan SCC), dead files, blast radius (BFS).
use std::collections::{HashMap, VecDeque};
use crate::index::store::ImportRow;
pub struct Graph {
adj: HashMap<i64, Vec<i64>>, // from_file_id -> to_file_id
rev: HashMap<i64, Vec<i64>>, // to_file_id -> from_file_id
}
impl Graph {
pub fn from_imports(imports: &[ImportRow]) -> Self {
let mut adj: HashMap<i64, Vec<i64>> = HashMap::new();
let mut rev: HashMap<i64, Vec<i64>> = HashMap::new();
for imp in imports {
adj.entry(imp.from_file_id)
.or_default()
.push(imp.to_file_id);
rev.entry(imp.to_file_id)
.or_default()
.push(imp.from_file_id);
}
Graph { adj, rev }
}
/// Strongly-connected components with more than one node (or a self-loop).
pub fn cycles(&self) -> Vec<Vec<i64>> {
let mut sccs = tarjan_scc(&self.adj);
sccs.retain(|scc| {
scc.len() > 1
|| (scc.len() == 1
&& self
.adj
.get(&scc[0])
.map(|v| v.contains(&scc[0]))
.unwrap_or(false))
});
for scc in &mut sccs {
scc.sort_unstable();
}
sccs
}
/// Files with no incoming import edges (nobody imports them).
pub fn dead_nodes(&self, all_file_ids: &[i64]) -> Vec<i64> {
let mut dead: Vec<i64> = all_file_ids
.iter()
.copied()
.filter(|id| self.rev.get(id).map(|v| v.is_empty()).unwrap_or(true))
.collect();
dead.sort_unstable();
dead
}
/// BFS over the reverse graph: which files (transitively) import `start`.
pub fn blast_radius(&self, start: i64, max_depth: usize) -> Vec<(i64, usize)> {
let mut out = Vec::new();
let mut seen = HashMap::new();
seen.insert(start, 0usize);
let mut queue = VecDeque::from([(start, 0usize)]);
while let Some((node, depth)) = queue.pop_front() {
if depth >= max_depth {
continue;
}
if let Some(parents) = self.rev.get(&node) {
for &p in parents {
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(p) {
e.insert(depth + 1);
queue.push_back((p, depth + 1));
}
}
}
}
for (id, d) in seen {
if id != start {
out.push((id, d));
}
}
out.sort_by_key(|(_, d)| *d);
out
}
}
/// Iterative Tarjan SCC. Returns strongly-connected components.
fn tarjan_scc(adj: &HashMap<i64, Vec<i64>>) -> Vec<Vec<i64>> {
let mut index_counter = 0usize;
let mut indices: HashMap<i64, usize> = HashMap::new();
let mut lowlinks: HashMap<i64, usize> = HashMap::new();
let mut stack: Vec<i64> = Vec::new();
let mut on_stack: HashMap<i64, bool> = HashMap::new();
let mut sccs: Vec<Vec<i64>> = Vec::new();
let nodes: Vec<i64> = {
let mut set = std::collections::HashSet::new();
for (k, v) in adj {
set.insert(*k);
for t in v {
set.insert(*t);
}
}
let mut v: Vec<i64> = set.into_iter().collect();
v.sort_unstable();
v
};
#[allow(clippy::too_many_arguments)]
fn strongconnect(
v: i64,
adj: &HashMap<i64, Vec<i64>>,
index_counter: &mut usize,
indices: &mut HashMap<i64, usize>,
lowlinks: &mut HashMap<i64, usize>,
stack: &mut Vec<i64>,
on_stack: &mut HashMap<i64, bool>,
sccs: &mut Vec<Vec<i64>>,
) {
indices.insert(v, *index_counter);
lowlinks.insert(v, *index_counter);
*index_counter += 1;
stack.push(v);
on_stack.insert(v, true);
if let Some(neighbors) = adj.get(&v) {
for &w in neighbors {
if !indices.contains_key(&w) {
strongconnect(
w,
adj,
index_counter,
indices,
lowlinks,
stack,
on_stack,
sccs,
);
let lw = lowlinks[&w];
let lv = lowlinks[&v];
lowlinks.insert(v, lv.min(lw));
} else if on_stack.get(&w).copied().unwrap_or(false) {
let iw = indices[&w];
let lv = lowlinks[&v];
lowlinks.insert(v, lv.min(iw));
}
}
}
if lowlinks[&v] == indices[&v] {
let mut component = Vec::new();
loop {
let w = stack.pop().unwrap();
on_stack.insert(w, false);
component.push(w);
if w == v {
break;
}
}
sccs.push(component);
}
}
for &n in &nodes {
if !indices.contains_key(&n) {
strongconnect(
n,
adj,
&mut index_counter,
&mut indices,
&mut lowlinks,
&mut stack,
&mut on_stack,
&mut sccs,
);
}
}
sccs
}
+23
View File
@@ -0,0 +1,23 @@
//! Content hashing for change detection.
use std::fs::File;
use std::io::Read;
use std::path::Path;
use anyhow::Result;
pub fn hash_file(path: &Path) -> Result<String> {
let mut f = File::open(path)?;
let mut buf = Vec::new();
f.read_to_end(&mut buf)?;
Ok(format!("{:016x}", xxhash_rust::xxh3::xxh3_64(&buf)))
}
pub fn mtime_secs(path: &Path) -> i64 {
std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
+398
View File
@@ -0,0 +1,398 @@
//! Import extraction (per-language) + best-effort resolution to file ids.
use std::collections::HashMap;
use std::sync::OnceLock;
use regex::Regex;
use crate::discovery::SourceFile;
#[derive(Debug, Clone)]
pub struct Import {
pub target: String,
pub import_type: String,
/// Directory levels a relative import ascends (Python `from ..x`).
pub levels: usize,
}
/// Extract import targets from a source file (own heuristic parsers).
pub fn extract(f: &SourceFile) -> Vec<Import> {
let content = std::fs::read_to_string(&f.path).unwrap_or_default();
match f.language {
"python" => extract_python(&content),
"rust" => extract_rust(&content),
"typescript" | "javascript" => extract_ts(&content),
"c" | "cpp" => extract_c(&content),
"java" => extract_java(&content),
"html" => extract_html(&content),
_ => Vec::new(),
}
}
fn extract_python(content: &str) -> Vec<Import> {
let mut out = Vec::new();
for line in content.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("import ") {
for part in rest.split(',') {
let p = part.trim();
if !p.is_empty() {
out.push(Import {
target: p.to_string(),
import_type: "import".into(),
levels: 0,
});
}
}
} else if let Some(rest) = l.strip_prefix("from ") {
// `from [..]<mod> import names` — leading dots = parent levels.
let module = rest.split_whitespace().next().unwrap_or("");
let dots = module.bytes().take_while(|b| *b == b'.').count();
let name = &module[dots..];
let levels = dots.saturating_sub(1);
if name.is_empty() {
// `from . import x` -> `x` lives `levels` dirs up.
let after = rest.split_once("import ").map(|(_, s)| s).unwrap_or("");
for n in after.split(',') {
let n = n.trim().trim_end_matches('\\').trim();
if !n.is_empty() {
out.push(Import {
target: n.to_string(),
import_type: "from".into(),
levels,
});
}
}
} else {
out.push(Import {
target: name.to_string(),
import_type: "from".into(),
levels,
});
}
}
}
out
}
fn extract_rust(content: &str) -> Vec<Import> {
let mut out = Vec::new();
for line in content.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("use ") {
let rest = rest
.trim_end_matches(';')
.split('{')
.next()
.unwrap_or(rest)
.trim();
let path = rest
.trim_start_matches("crate::")
.trim_start_matches("self::")
.trim_start_matches("super::")
.trim_start_matches("::");
let mut segs: Vec<&str> = path.split("::").filter(|s| !s.is_empty()).collect();
if segs.is_empty() || matches!(segs[0], "std" | "core" | "alloc") {
continue;
}
// A trailing CamelCase segment is a type/symbol, not a module path.
if let Some(last) = segs.last()
&& last.chars().next().is_some_and(char::is_uppercase)
{
segs.pop();
}
if let Some(first) = segs.first() {
out.push(Import {
target: first.to_string(),
import_type: "use".into(),
levels: 0,
});
}
if segs.len() > 1 {
out.push(Import {
target: segs.join("/"),
import_type: "use".into(),
levels: 0,
});
}
} else {
let mod_name = strip_mod_decl(l);
if let Some(name) = mod_name {
out.push(Import {
target: name,
import_type: "mod".into(),
levels: 0,
});
}
}
}
out
}
/// Extract the module name from a `mod` declaration, handling visibility
/// modifiers such as `pub`, `pub(crate)`, `pub(super)`, and attributes.
fn strip_mod_decl(l: &str) -> Option<String> {
let mut rest = l;
// Strip leading attributes like `#[cfg(...)]`.
while rest.starts_with("#[") {
let end = rest.find("]\n").or_else(|| rest.find(']'))? + 1;
rest = rest[end..].trim_start();
}
if let Some(r) = rest.strip_prefix("pub ") {
rest = r;
} else if let Some(r) = rest.strip_prefix("pub(") {
let end = r.find(") ")? + 2;
rest = r[end..].trim_start();
}
let name = rest
.strip_prefix("mod ")?
.trim()
.trim_end_matches(';')
.trim();
if name.is_empty() || name.contains(' ') {
None
} else {
Some(name.to_string())
}
}
fn ts_import_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r#"(?:import|export)\s+(?:[^'"]*?from\s+)?['"]([^'"]+)['"]"#).unwrap()
})
}
fn ts_require_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r#"require\(\s*['"]([^'"]+)['"]\s*\)"#).unwrap())
}
fn extract_ts(content: &str) -> Vec<Import> {
let mut out = Vec::new();
for cap in ts_import_re().captures_iter(content) {
let target = cap[1].to_string();
let t = target.trim_start_matches("./");
out.push(Import {
target: t.to_string(),
import_type: "import".into(),
levels: 0,
});
}
for cap in ts_require_re().captures_iter(content) {
let target = cap[1].to_string();
let t = target.trim_start_matches("./");
out.push(Import {
target: t.to_string(),
import_type: "require".into(),
levels: 0,
});
}
out
}
fn extract_c(content: &str) -> Vec<Import> {
let mut out = Vec::new();
for line in content.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("#include") {
let inner = rest
.trim()
.trim_start_matches('"')
.trim_start_matches('<')
.split(['"', '>'])
.next()
.unwrap_or("");
if !inner.is_empty() {
out.push(Import {
target: inner.to_string(),
import_type: "include".into(),
levels: 0,
});
}
}
}
out
}
fn extract_java(content: &str) -> Vec<Import> {
let mut out = Vec::new();
for line in content.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("import ") {
let rest = rest.trim_end_matches(';').trim_start_matches("static ");
let rest = rest.split_whitespace().next().unwrap_or(rest);
if !rest.is_empty() {
out.push(Import {
target: rest.to_string(),
import_type: "import".into(),
levels: 0,
});
}
}
}
out
}
fn html_attr_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r#"(?:src|href)\s*=\s*["']([^"']+)["']"#).unwrap())
}
/// `<script src="...">` / `<link href="...">` as relative imports. ctags' HTML
/// parser is weak, so symbol coverage here is best-effort.
fn extract_html(content: &str) -> Vec<Import> {
html_attr_re()
.captures_iter(content)
.filter_map(|cap| {
let target = cap[1].trim();
// Skip absolute URLs, data URIs, and fragments.
if target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with("//")
|| target.starts_with("data:")
|| target.starts_with('#')
{
return None;
}
let t = target.trim_start_matches("./");
if t.is_empty() {
return None;
}
Some(Import {
target: t.to_string(),
import_type: "asset".into(),
levels: 0,
})
})
.collect()
}
/// Best-effort mapping of an import target string to a file id.
pub struct Resolver {
by_path: HashMap<String, i64>,
by_name: HashMap<String, i64>,
by_stem: HashMap<String, i64>,
}
/// Normalize an import target string (strip prefixes like `./`, `crate::`).
fn clean(target: &str) -> Option<String> {
let t = target.trim();
if t.is_empty() {
return None;
}
let t = t.trim_start_matches("./");
let t = t
.trim_start_matches("crate::")
.trim_start_matches("self::")
.trim_start_matches("super::")
.trim_start_matches("::");
if t.is_empty() {
return None;
}
Some(t.to_string())
}
impl Resolver {
pub fn build(files: Vec<(String, i64)>) -> Self {
let mut by_path = HashMap::new();
let mut by_name = HashMap::new();
let mut by_stem = HashMap::new();
for (rel, id) in files {
by_path.insert(rel.clone(), id);
let name = rel.rsplit('/').next().unwrap_or(&rel).to_string();
by_name.entry(name.clone()).or_insert(id);
let stem = name
.rsplit_once('.')
.map(|(s, _)| s.to_string())
.unwrap_or(name);
by_stem.entry(stem).or_insert(id);
}
Resolver {
by_path,
by_name,
by_stem,
}
}
/// Resolve an import relative to the importing file's directory first,
/// ascending `levels` parent dirs for relative imports.
pub fn resolve_with_context(&self, target: &str, dir: &str, levels: usize) -> Option<i64> {
let t = clean(target)?;
let mut base = dir;
for _ in 0..levels {
base = base.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
}
let base = if base.is_empty() {
String::new()
} else {
format!("{base}/")
};
self.resolve_relative(&base, &t)
.or_else(|| self.resolve_clean(&t))
}
fn resolve_clean(&self, t: &str) -> Option<i64> {
if let Some(id) = self.by_path.get(t) {
return Some(*id);
}
let slashed = t.replace('.', "/");
if slashed != t {
if let Some(id) = self.by_path.get(&slashed) {
return Some(*id);
}
if let Some(id) = self.try_module(&slashed) {
return Some(id);
}
}
if let Some(id) = self.try_module(t) {
return Some(id);
}
if let Some(id) = self.by_stem.get(t) {
return Some(*id);
}
let last = t.rsplit('/').next().unwrap_or(t);
if let Some(id) = self.by_stem.get(last) {
return Some(*id);
}
// Java dotted packages (`demo.Util`) — fall back to the final segment.
let last_dot = last.rsplit('.').next().unwrap_or(last);
if last_dot != last
&& let Some(id) = self.by_stem.get(last_dot)
{
return Some(*id);
}
self.by_name.get(last).copied()
}
fn resolve_relative(&self, base: &str, t: &str) -> Option<i64> {
if let Some(id) = self.by_path.get(&format!("{base}{t}")) {
return Some(*id);
}
for ext in ["rs", "py", "ts", "tsx", "js", "cpp", "c", "h", "java"] {
if let Some(id) = self.by_path.get(&format!("{base}{t}.{ext}")) {
return Some(*id);
}
}
for entry in ["mod.rs", "__init__.py", "index.ts", "index.js", "index.tsx"] {
if let Some(id) = self.by_path.get(&format!("{base}{t}/{entry}")) {
return Some(*id);
}
}
None
}
fn try_module(&self, base: &str) -> Option<i64> {
for ext in ["rs", "py", "ts", "tsx", "js", "cpp", "c", "h", "java"] {
if let Some(id) = self.by_path.get(&format!("{base}.{ext}")) {
return Some(*id);
}
}
for entry in ["mod.rs", "__init__.py", "index.ts", "index.js", "index.tsx"] {
if let Some(id) = self.by_path.get(&format!("{base}/{entry}")) {
return Some(*id);
}
}
None
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod hashing;
pub mod imports;
pub mod pipeline;
pub mod schema;
pub mod store;
+209
View File
@@ -0,0 +1,209 @@
//! Index pipeline: hash, run ctags, extract imports, write to SQLite in one transaction.
use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use rayon::prelude::*;
use rusqlite::params;
use crate::ctags;
use crate::discovery::{self, SourceFile};
use crate::index::{hashing, imports, store};
#[derive(Debug, serde::Serialize)]
pub struct IndexStats {
pub files: i64,
pub symbols: i64,
pub imports: i64,
}
/// (Re)index a project. `recreate` drops any existing index first.
pub fn run(project: &Path, db_path: &Path, recreate: bool) -> Result<IndexStats> {
if recreate && db_path.exists() {
std::fs::remove_file(db_path).context("remove old index")?;
}
let conn = store::open(db_path)?;
let files = discovery::discover(project)?;
let existing: HashMap<String, (i64, String)> = store::all_files(&conn)?
.into_iter()
.map(|f| (f.path.clone(), (f.id, f.content_hash.clone())))
.collect();
// Hash all files in parallel.
let hashed: Vec<(String, String, i64, &'static str)> = files
.par_iter()
.map(|f| {
let h = hashing::hash_file(&f.path).unwrap_or_else(|_| "0000000000000000".to_string());
let m = hashing::mtime_secs(&f.path);
(f.rel.clone(), h, m, f.language)
})
.collect();
let by_rel: HashMap<&str, &SourceFile> = files.iter().map(|f| (f.rel.as_str(), f)).collect();
let abs_to_rel: HashMap<String, String> = files
.iter()
.map(|f| (f.path.to_string_lossy().into_owned(), f.rel.clone()))
.collect();
let changed: Vec<&SourceFile> = hashed
.iter()
.filter(|(rel, h, _, _)| existing.get(rel).map(|(_, oh)| oh != h).unwrap_or(true))
.filter_map(|(rel, ..)| by_rel.get(rel.as_str()).copied())
.collect();
let deleted_ids: Vec<i64> = existing
.iter()
.filter(|(rel, _)| !by_rel.contains_key(rel.as_str()))
.map(|(_, (id, _))| *id)
.collect();
conn.execute_batch("BEGIN;")?;
let mut file_ids: HashMap<String, i64> = HashMap::new();
for (rel, h, m, lang) in &hashed {
let id = store::upsert_file(&conn, rel, h, lang, *m)?;
file_ids.insert(rel.clone(), id);
}
for id in &deleted_ids {
store::delete_file(&conn, *id)?;
conn.execute("DELETE FROM content_fts WHERE rowid = ?1", [id])?;
}
// Run ctags once over changed files.
let changed_paths: Vec<&Path> = changed.iter().map(|f| f.path.as_path()).collect();
let tags = ctags::run(&changed_paths)?;
let mut tags_by_rel: HashMap<String, Vec<ctags::Tag>> = HashMap::new();
for t in tags {
if let Some(rel) = abs_to_rel.get(&t.path) {
tags_by_rel.entry(rel.clone()).or_default().push(t);
}
}
let resolver =
imports::Resolver::build(file_ids.iter().map(|(k, v)| (k.clone(), *v)).collect());
for f in &changed {
let fid = file_ids[&f.rel];
let content = std::fs::read_to_string(&f.path).unwrap_or_default();
let tags = tags_by_rel.get(&f.rel).cloned().unwrap_or_default();
store::replace_symbols(&conn, fid, &tags_to_symbols(&tags, &content))?;
store::clear_imports_for_file(&conn, fid)?;
let dir = f
.rel
.rsplit_once('/')
.map(|(d, _)| d)
.unwrap_or("")
.to_string();
for imp in imports::extract(f) {
if let Some(to) = resolver.resolve_with_context(&imp.target, &dir, imp.levels)
&& to != fid
{
store::insert_import(&conn, fid, to, &imp.import_type)?;
}
}
conn.execute("DELETE FROM content_fts WHERE rowid = ?1", [fid])?;
conn.execute(
"INSERT INTO content_fts(rowid, path, content) VALUES (?1, ?2, ?3)",
params![fid, f.rel, content],
)?;
}
conn.execute_batch("COMMIT;")?;
let (files_n, symbols_n, imports_n) = store::stats(&conn)?;
Ok(IndexStats {
files: files_n,
symbols: symbols_n,
imports: imports_n,
})
}
fn tags_to_symbols(tags: &[ctags::Tag], content: &str) -> Vec<store::NewSymbol> {
let lines: Vec<&str> = content.lines().collect();
tags.iter()
.filter_map(|t| {
let line = t.line? as i64;
let end = t.end.map(|e| e as i64).unwrap_or(line);
let kind = t.kind.clone().unwrap_or_default();
let end = extend_block_end(&lines, line, end, &kind);
let name = t.name.clone();
let qualified_name = match &t.scope {
Some(s) if !name.starts_with(&format!("{s}.")) => format!("{s}.{name}"),
_ => name.clone(),
};
Some(store::NewSymbol {
name,
qualified_name,
kind,
signature: t.signature.clone(),
line_start: line,
line_end: end,
})
})
.collect()
}
/// ctags omits a body span for some kinds (e.g. Rust `fn`), leaving `end ==
/// start` even for a multi-line block. Extend brace-based blocks to their
/// closing brace so stored spans are correct for every reader.
fn extend_block_end(lines: &[&str], start: i64, end: i64, kind: &str) -> i64 {
if !is_block_kind(kind) || end > start {
return end;
}
// Semicolon-terminated declarations (`mod foo;`, `struct Foo;`) have no body.
if let Some(line) = lines.get((start - 1) as usize)
&& line.trim_end().ends_with(';')
{
return end;
}
let mut depth = 0i64;
let mut opened = false;
let mut out = end;
// ponytail: naive char brace count; braces in string/comment literals skew
// depth. Swap for a tokenizer only if real spans come back wrong.
for (i, line) in lines.iter().enumerate() {
let n = (i as i64) + 1;
if n < start {
continue;
}
for c in line.chars() {
match c {
'{' => {
depth += 1;
opened = true;
}
'}' => depth -= 1,
_ => {}
}
}
if opened {
out = n;
}
if opened && depth <= 0 {
break;
}
}
out
}
fn is_block_kind(kind: &str) -> bool {
matches!(
kind,
"function"
| "method"
| "struct"
| "class"
| "interface"
| "enum"
| "trait"
| "impl"
| "implementation"
| "module"
| "namespace"
| "union"
)
}
+71
View File
@@ -0,0 +1,71 @@
//! SQLite schema + minimal migration (schema_version in metadata).
use anyhow::Result;
use rusqlite::Connection;
const SCHEMA_VERSION: i64 = 1;
const SCHEMA: &str = r#"
CREATE TABLE files (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
content_hash TEXT NOT NULL,
language TEXT NOT NULL,
mtime INTEGER NOT NULL
);
CREATE TABLE symbols (
id INTEGER PRIMARY KEY,
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
name TEXT NOT NULL,
qualified_name TEXT NOT NULL,
kind TEXT NOT NULL,
signature TEXT,
line_start INTEGER NOT NULL,
line_end INTEGER NOT NULL
);
CREATE INDEX idx_symbols_file ON symbols(file_id);
CREATE INDEX idx_symbols_name ON symbols(name);
CREATE TABLE imports (
id INTEGER PRIMARY KEY,
from_file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
to_file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
import_type TEXT NOT NULL
);
CREATE INDEX idx_imports_from ON imports(from_file_id);
CREATE INDEX idx_imports_to ON imports(to_file_id);
CREATE VIRTUAL TABLE symbols_fts USING fts5(
name, qualified_name, kind, signature,
content='symbols', content_rowid='id'
);
CREATE TRIGGER symbols_ai AFTER INSERT ON symbols BEGIN
INSERT INTO symbols_fts(rowid, name, qualified_name, kind, signature)
VALUES (new.id, new.name, new.qualified_name, new.kind, new.signature);
END;
CREATE TRIGGER symbols_ad AFTER DELETE ON symbols BEGIN
INSERT INTO symbols_fts(symbols_fts, rowid, name, qualified_name, kind, signature)
VALUES ('delete', old.id, old.name, old.qualified_name, old.kind, old.signature);
END;
CREATE VIRTUAL TABLE content_fts USING fts5(path UNINDEXED, content);
"#;
/// Create schema if missing, or migrate to the current version.
pub fn init(conn: &mut Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
)?;
let version: i64 = conn
.query_row(
"SELECT COALESCE(CAST(value AS INTEGER), 0) FROM metadata WHERE key='schema_version'",
[],
|r| r.get(0),
)
.unwrap_or(0);
if version < SCHEMA_VERSION {
conn.execute_batch(SCHEMA)?;
conn.execute(
"INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
)?;
}
Ok(())
}
+311
View File
@@ -0,0 +1,311 @@
//! CRUD on files/symbols/imports.
use std::path::Path;
use anyhow::Result;
use rusqlite::{Connection, OptionalExtension, params};
use crate::index::schema;
#[derive(Debug, Clone)]
pub struct FileRow {
pub id: i64,
pub path: String,
pub content_hash: String,
pub language: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SymbolRow {
pub id: i64,
pub file_id: i64,
pub name: String,
pub qualified_name: String,
pub kind: String,
pub signature: Option<String>,
pub line_start: i64,
pub line_end: i64,
}
#[derive(Debug, Clone)]
pub struct NewSymbol {
pub name: String,
pub qualified_name: String,
pub kind: String,
pub signature: Option<String>,
pub line_start: i64,
pub line_end: i64,
}
#[derive(Debug, Clone)]
pub struct ImportRow {
pub from_file_id: i64,
pub to_file_id: i64,
}
/// Open (creating if needed) the index DB and apply schema.
pub fn open(path: &Path) -> Result<Connection> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut conn = Connection::open(path)?;
conn.execute_batch(
"PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=NORMAL;",
)?;
schema::init(&mut conn)?;
Ok(conn)
}
pub fn all_files(conn: &Connection) -> Result<Vec<FileRow>> {
let mut stmt = conn.prepare("SELECT id, path, content_hash, language FROM files")?;
let rows = stmt.query_map([], |r| {
Ok(FileRow {
id: r.get(0)?,
path: r.get(1)?,
content_hash: r.get(2)?,
language: r.get(3)?,
})
})?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
pub fn upsert_file(
conn: &Connection,
path: &str,
hash: &str,
language: &str,
mtime: i64,
) -> Result<i64> {
conn.execute(
"INSERT INTO files (path, content_hash, language, mtime) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(path) DO UPDATE SET content_hash=?2, language=?3, mtime=?4",
params![path, hash, language, mtime],
)?;
// last_insert_rowid() is unreliable after an upsert that updates; query it.
Ok(conn.query_row("SELECT id FROM files WHERE path = ?1", [path], |r| r.get(0))?)
}
pub fn delete_file(conn: &Connection, id: i64) -> Result<()> {
conn.execute("DELETE FROM files WHERE id = ?1", [id])?;
Ok(())
}
pub fn file_by_id(conn: &Connection, id: i64) -> Result<Option<FileRow>> {
conn.query_row(
"SELECT id, path, content_hash, language FROM files WHERE id = ?1",
[id],
|r| {
Ok(FileRow {
id: r.get(0)?,
path: r.get(1)?,
content_hash: r.get(2)?,
language: r.get(3)?,
})
},
)
.optional()
.map_err(Into::into)
}
pub fn file_id_by_path(conn: &Connection, path: &str) -> Result<Option<i64>> {
Ok(conn
.query_row("SELECT id FROM files WHERE path = ?1", [path], |r| r.get(0))
.optional()?)
}
pub fn replace_symbols(conn: &Connection, file_id: i64, symbols: &[NewSymbol]) -> Result<()> {
conn.execute("DELETE FROM symbols WHERE file_id = ?1", [file_id])?;
let mut stmt = conn.prepare(
"INSERT INTO symbols (file_id, name, qualified_name, kind, signature, line_start, line_end)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
for s in symbols {
stmt.execute(params![
file_id,
s.name,
s.qualified_name,
s.kind,
s.signature,
s.line_start,
s.line_end
])?;
}
Ok(())
}
pub fn clear_imports_for_file(conn: &Connection, file_id: i64) -> Result<()> {
conn.execute("DELETE FROM imports WHERE from_file_id = ?1", [file_id])?;
Ok(())
}
pub fn insert_import(conn: &Connection, from: i64, to: i64, import_type: &str) -> Result<()> {
conn.execute(
"INSERT INTO imports (from_file_id, to_file_id, import_type) VALUES (?1, ?2, ?3)",
params![from, to, import_type],
)?;
Ok(())
}
pub fn all_imports(conn: &Connection) -> Result<Vec<ImportRow>> {
let mut stmt = conn.prepare("SELECT from_file_id, to_file_id FROM imports")?;
let rows = stmt.query_map([], |r| {
Ok(ImportRow {
from_file_id: r.get(0)?,
to_file_id: r.get(1)?,
})
})?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
pub fn symbol_by_id(conn: &Connection, id: i64) -> Result<Option<SymbolRow>> {
conn.query_row(
"SELECT id, file_id, name, qualified_name, kind, signature, line_start, line_end
FROM symbols WHERE id = ?1",
[id],
|r| {
Ok(SymbolRow {
id: r.get(0)?,
file_id: r.get(1)?,
name: r.get(2)?,
qualified_name: r.get(3)?,
kind: r.get(4)?,
signature: r.get(5)?,
line_start: r.get(6)?,
line_end: r.get(7)?,
})
},
)
.optional()
.map_err(Into::into)
}
pub fn symbols_for_file(conn: &Connection, file_id: i64) -> Result<Vec<SymbolRow>> {
let mut stmt = conn.prepare(
"SELECT id, file_id, name, qualified_name, kind, signature, line_start, line_end
FROM symbols WHERE file_id = ?1 ORDER BY line_start",
)?;
let rows = stmt.query_map([file_id], |r| {
Ok(SymbolRow {
id: r.get(0)?,
file_id: r.get(1)?,
name: r.get(2)?,
qualified_name: r.get(3)?,
kind: r.get(4)?,
signature: r.get(5)?,
line_start: r.get(6)?,
line_end: r.get(7)?,
})
})?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
pub fn importers_of(conn: &Connection, file_id: i64) -> Result<Vec<i64>> {
let mut stmt = conn.prepare("SELECT from_file_id FROM imports WHERE to_file_id = ?1")?;
let rows = stmt.query_map([file_id], |r| r.get(0))?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
/// Returns (files, symbols, imports) counts.
pub fn stats(conn: &Connection) -> Result<(i64, i64, i64)> {
let files: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))?;
let symbols: i64 = conn.query_row("SELECT COUNT(*) FROM symbols", [], |r| r.get(0))?;
let imports: i64 = conn.query_row("SELECT COUNT(*) FROM imports", [], |r| r.get(0))?;
Ok((files, symbols, imports))
}
pub fn language_counts(conn: &Connection) -> Result<Vec<(String, i64)>> {
let mut stmt =
conn.prepare("SELECT language, COUNT(*) FROM files GROUP BY language ORDER BY 2 DESC")?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
#[cfg(test)]
mod tests {
use super::*;
fn mem() -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
schema::init(&mut conn).unwrap();
conn
}
#[test]
fn file_insert_query_and_upsert() {
let conn = mem();
let id1 = upsert_file(&conn, "src/lib.rs", "hash1", "rust", 100).unwrap();
let id2 = upsert_file(&conn, "src/lib.rs", "hash2", "rust", 200).unwrap();
assert_eq!(id1, id2, "upsert reuses the same row");
let row = file_by_id(&conn, id1).unwrap().unwrap();
assert_eq!(row.content_hash, "hash2");
assert_eq!(row.language, "rust");
assert_eq!(all_files(&conn).unwrap().len(), 1);
}
#[test]
fn symbols_replace_and_query() {
let conn = mem();
let fid = upsert_file(&conn, "src/lib.rs", "h", "rust", 0).unwrap();
replace_symbols(
&conn,
fid,
&[
NewSymbol {
name: "add".into(),
qualified_name: "add".into(),
kind: "function".into(),
signature: Some("(a: i32, b: i32) -> i32".into()),
line_start: 3,
line_end: 5,
},
NewSymbol {
name: "AuthService".into(),
qualified_name: "AuthService".into(),
kind: "struct".into(),
signature: None,
line_start: 7,
line_end: 7,
},
],
)
.unwrap();
let syms = symbols_for_file(&conn, fid).unwrap();
assert_eq!(syms.len(), 2);
assert_eq!(syms[0].name, "add");
assert_eq!(
symbol_by_id(&conn, syms[1].id).unwrap().unwrap().kind,
"struct"
);
// Replacing drops old symbols and re-inserts.
replace_symbols(
&conn,
fid,
&[NewSymbol {
name: "only".into(),
qualified_name: "only".into(),
kind: "function".into(),
signature: None,
line_start: 1,
line_end: 1,
}],
)
.unwrap();
assert_eq!(symbols_for_file(&conn, fid).unwrap().len(), 1);
}
#[test]
fn imports_roundtrip_and_importers() {
let conn = mem();
let a = upsert_file(&conn, "a.ts", "h", "typescript", 0).unwrap();
let b = upsert_file(&conn, "b.ts", "h", "typescript", 0).unwrap();
insert_import(&conn, a, b, "import").unwrap();
let imports = all_imports(&conn).unwrap();
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].from_file_id, a);
assert_eq!(importers_of(&conn, b).unwrap(), vec![a]);
assert_eq!(stats(&conn).unwrap(), (2, 0, 1));
}
}
+81
View File
@@ -0,0 +1,81 @@
mod cli;
mod config;
mod ctags;
mod discovery;
mod graph;
mod index;
mod mcp;
mod ops;
mod output;
mod search;
use anyhow::Result;
use clap::Parser;
use serde_json::Value;
use cli::{Cli, Command, FindCommand, IndexCommand, InspectCommand, SearchCommand};
fn main() {
if let Err(e) = run() {
if let Some(io) = e.downcast_ref::<std::io::Error>()
&& io.kind() == std::io::ErrorKind::BrokenPipe
{
std::process::exit(0);
}
eprintln!("error: {e:#}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
match &cli.command {
Command::Index { sub } => match sub {
IndexCommand::Create { path } => emit(&ops::index_create(path)?, &cli)?,
IndexCommand::Update { path } => emit(&ops::index_update(path)?, &cli)?,
},
Command::Search { sub } => match sub {
SearchCommand::Symbols { query } => {
emit(&ops::search_symbols(&cli.project, query)?, &cli)?
}
SearchCommand::Text { query } => emit(&ops::search_text(&cli.project, query)?, &cli)?,
},
Command::Inspect { sub } => match sub {
InspectCommand::Symbol { id } => emit(&ops::inspect_symbol(&cli.project, *id)?, &cli)?,
InspectCommand::Outline { file } => {
emit(&ops::inspect_outline(&cli.project, file)?, &cli)?
}
InspectCommand::File { file, lines } => emit(
&ops::inspect_file(&cli.project, file, lines.as_deref())?,
&cli,
)?,
InspectCommand::Tree { depth } => {
emit(&ops::inspect_tree(&cli.project, *depth)?, &cli)?
}
InspectCommand::BlastRadius { file, depth } => emit(
&ops::inspect_blast_radius(&cli.project, file, *depth)?,
&cli,
)?,
InspectCommand::Cycles => emit(&ops::inspect_cycles(&cli.project)?, &cli)?,
},
Command::Find { sub } => match sub {
FindCommand::Importers { file } => {
emit(&ops::find_importers(&cli.project, file)?, &cli)?
}
FindCommand::Dead => emit(&ops::find_dead(&cli.project)?, &cli)?,
},
Command::Serve => mcp::serve(&cli.project)?,
Command::Stats => emit(&ops::stats(&cli.project)?, &cli)?,
}
Ok(())
}
fn emit(value: &Value, cli: &Cli) -> Result<()> {
match cli.format.as_str() {
"table" => {
output::print_table(value);
Ok(())
}
_ => output::print_json(value, cli.compact),
}
}
+254
View File
@@ -0,0 +1,254 @@
//! Minimal MCP server: JSON-RPC over stdio, dispatching to the same ops as the CLI.
use std::io::{BufRead, Write};
use std::path::Path;
use anyhow::Result;
use serde_json::{Value, json};
const PROTOCOL_VERSION: &str = "2024-11-05";
pub fn serve(project: &Path) -> Result<()> {
eprintln!("rustytokens MCP server listening on stdio");
let stdin = std::io::stdin();
let stdout = std::io::stdout();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.trim().is_empty() {
continue;
}
let msg: Value = match serde_json::from_str(&line) {
Ok(m) => m,
Err(_) => continue,
};
if let Some(resp) = handle(&msg, project) {
let mut out = stdout.lock();
serde_json::to_writer(&mut out, &resp)?;
out.write_all(b"\n")?;
out.flush()?;
}
}
Ok(())
}
fn handle(msg: &Value, project: &Path) -> Option<Value> {
let method = msg.get("method")?.as_str()?;
let id = msg.get("id").cloned();
// Notifications have no id -> no response.
let id = id.as_ref()?;
match method {
"initialize" => Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": { "tools": {} },
"serverInfo": { "name": "rustytokens", "version": env!("CARGO_PKG_VERSION") }
}
})),
"ping" => Some(json!({ "jsonrpc": "2.0", "id": id, "result": {} })),
"tools/list" => Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": { "tools": tools() }
})),
"tools/call" => {
let params = msg.get("params").cloned().unwrap_or_else(|| json!({}));
let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": call_tool(name, &args, project)
}))
}
_ => Some(json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32601, "message": format!("method not found: {method}") }
})),
}
}
fn call_tool(name: &str, args: &Value, project: &Path) -> Value {
let result: Result<Value> = dispatch(name, args, project);
match result {
Ok(v) => json!({
"content": [{ "type": "text", "text": serde_json::to_string(&v).unwrap_or_default() }]
}),
Err(e) => json!({
"content": [{ "type": "text", "text": format!("error: {e:#}") }],
"isError": true
}),
}
}
fn dispatch(name: &str, args: &Value, project: &Path) -> Result<Value> {
use crate::ops;
let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("");
match name {
"index_create" => ops::index_create(Path::new(s("path"))),
"index_update" => ops::index_update(Path::new(s("path"))),
"search_symbols" => ops::search_symbols(project, s("query")),
"search_text" => ops::search_text(project, s("query")),
"inspect_symbol" => ops::inspect_symbol(
project,
args.get("id").and_then(|v| v.as_i64()).unwrap_or(0),
),
"inspect_outline" => ops::inspect_outline(project, s("file")),
"inspect_file" => ops::inspect_file(
project,
s("file"),
args.get("lines").and_then(|v| v.as_str()),
),
"inspect_tree" => ops::inspect_tree(
project,
args.get("depth")
.and_then(|v| v.as_u64())
.map(|d| d as usize),
),
"find_importers" => ops::find_importers(project, s("file")),
"find_dead" => ops::find_dead(project),
"inspect_cycles" => ops::inspect_cycles(project),
"inspect_blast_radius" => ops::inspect_blast_radius(
project,
s("file"),
args.get("depth")
.and_then(|v| v.as_u64())
.map(|d| d as usize),
),
"stats" => ops::stats(project),
_ => anyhow::bail!("unknown tool: {name}"),
}
}
fn tools() -> Value {
json!([
tool(
"index_create",
"Index a project from scratch",
json!({
"type": "object",
"properties": { "path": { "type": "string", "description": "Project directory to index" } },
"required": ["path"]
})
),
tool(
"index_update",
"Re-index only changed files",
json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
})
),
tool(
"search_symbols",
"Search symbols by name",
json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
})
),
tool(
"search_text",
"Full-text search over file contents",
json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
})
),
tool(
"inspect_symbol",
"Get the source code of a single symbol by id",
json!({
"type": "object",
"properties": { "id": { "type": "integer" } },
"required": ["id"]
})
),
tool(
"inspect_outline",
"Get the symbol outline of a file",
json!({
"type": "object",
"properties": { "file": { "type": "string" } },
"required": ["file"]
})
),
tool(
"inspect_file",
"Read a file or a line range",
json!({
"type": "object",
"properties": {
"file": { "type": "string" },
"lines": { "type": "string", "description": "Optional range, e.g. 10-40" }
},
"required": ["file"]
})
),
tool(
"inspect_tree",
"Show the indexed file tree",
json!({
"type": "object",
"properties": { "depth": { "type": "integer" } }
})
),
tool(
"find_importers",
"List files that import the given file",
json!({
"type": "object",
"properties": { "file": { "type": "string" } },
"required": ["file"]
})
),
tool(
"find_dead",
"List files with no importers",
json!({ "type": "object" })
),
tool(
"inspect_cycles",
"Find circular imports",
json!({ "type": "object" })
),
tool(
"inspect_blast_radius",
"Files transitively affected by a change",
json!({
"type": "object",
"properties": {
"file": { "type": "string" },
"depth": { "type": "integer" }
},
"required": ["file"]
})
),
tool(
"stats",
"Show index statistics",
json!({ "type": "object" })
),
])
}
fn tool(name: &str, description: &str, input_schema: Value) -> Value {
json!({
"name": name,
"description": description,
"inputSchema": input_schema
})
}
+341
View File
@@ -0,0 +1,341 @@
//! High-level operations shared by the CLI and the MCP server.
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result, bail};
use rusqlite::Connection;
use serde::Serialize;
use serde_json::Value;
use crate::config;
use crate::graph::Graph;
use crate::index::{pipeline, store};
use crate::search;
// ---- index ----
pub fn index_create(project: &Path) -> Result<Value> {
let db = config::index_db_path(project);
let stats = pipeline::run(project, &db, true)?;
Ok(serde_json::to_value(stats)?)
}
pub fn index_update(project: &Path) -> Result<Value> {
let db = config::index_db_path(project);
let stats = pipeline::run(project, &db, false)?;
Ok(serde_json::to_value(stats)?)
}
// ---- search ----
pub fn search_symbols(project: &Path, query: &str) -> Result<Value> {
let conn = open_read(project)?;
Ok(serde_json::to_value(search::search_symbols(&conn, query)?)?)
}
pub fn search_text(project: &Path, query: &str) -> Result<Value> {
let conn = open_read(project)?;
Ok(serde_json::to_value(search::search_text(&conn, query)?)?)
}
// ---- inspect ----
pub fn inspect_symbol(project: &Path, id: i64) -> Result<Value> {
let conn = open_read(project)?;
let sym = store::symbol_by_id(&conn, id)?.with_context(|| format!("no symbol with id {id}"))?;
let file = store::file_by_id(&conn, sym.file_id)?
.with_context(|| format!("no file with id {}", sym.file_id))?;
let source = read_lines(project, &file.path, sym.line_start, sym.line_end)?;
Ok(serde_json::json!({
"id": sym.id,
"name": sym.name,
"qualified_name": sym.qualified_name,
"kind": sym.kind,
"signature": sym.signature,
"file": file.path,
"line_start": sym.line_start,
"line_end": sym.line_end,
"source": source,
}))
}
pub fn inspect_outline(project: &Path, file: &str) -> Result<Value> {
let conn = open_read(project)?;
let fid = file_id(&conn, file)?;
let symbols = store::symbols_for_file(&conn, fid)?;
Ok(serde_json::to_value(symbols)?)
}
pub fn inspect_file(project: &Path, file: &str, lines: Option<&str>) -> Result<Value> {
let conn = open_read(project)?;
let fid = file_id(&conn, file)?;
let frow = store::file_by_id(&conn, fid)?.unwrap();
let (start, end) = parse_range(lines);
let all = read_all_lines(project, &frow.path)?;
let total = all.len() as i64;
let (s, e) = (start.unwrap_or(1), end.unwrap_or(total));
let slice = all
.iter()
.enumerate()
.filter(|(i, _)| {
let n = (*i as i64) + 1;
n >= s && n <= e
})
.map(|(_, l)| l.clone())
.collect::<Vec<_>>()
.join("\n");
Ok(serde_json::json!({
"file": frow.path,
"language": frow.language,
"line_start": s,
"line_end": e.min(total),
"total_lines": total,
"content": slice,
}))
}
pub fn inspect_tree(project: &Path, depth: Option<usize>) -> Result<Value> {
let conn = open_read(project)?;
let files = store::all_files(&conn)?;
let paths: Vec<String> = files.into_iter().map(|f| f.path).collect();
let root_name = project
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "project".to_string());
let tree = build_tree(&root_name, &paths, depth);
Ok(serde_json::to_value(tree)?)
}
// ---- find / graph ----
pub fn find_importers(project: &Path, file: &str) -> Result<Value> {
let conn = open_read(project)?;
let fid = file_id(&conn, file)?;
let importers = store::importers_of(&conn, fid)?;
let paths = importers
.into_iter()
.map(|id| store::file_by_id(&conn, id).map(|f| f.map(|f| f.path)))
.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect::<Vec<_>>();
Ok(serde_json::to_value(paths)?)
}
pub fn find_dead(project: &Path) -> Result<Value> {
let conn = open_read(project)?;
let files = store::all_files(&conn)?;
let ids: Vec<i64> = files.iter().map(|f| f.id).collect();
let imports = store::all_imports(&conn)?;
let graph = Graph::from_imports(&imports);
let dead = graph.dead_nodes(&ids);
let out: Vec<Value> = dead
.into_iter()
.filter_map(|id| {
let f = files.iter().find(|f| f.id == id)?;
if is_entry_point(&f.path, &f.language) {
return None;
}
Some(serde_json::json!({"id": f.id, "path": f.path, "language": f.language}))
})
.collect();
Ok(serde_json::json!({"count": out.len(), "files": out}))
}
/// Entry-point files are legitimately never imported by anything else.
fn is_entry_point(path: &str, language: &str) -> bool {
let name = path.rsplit('/').next().unwrap_or(path);
let stem = name.split('.').next().unwrap_or(name);
if matches!(stem, "main" | "lib") {
return true;
}
// Cargo compiles every file in these dirs as its own binary/crate.
if language == "rust" {
let top = path.split('/').next().unwrap_or(path);
if matches!(top, "tests" | "benches" | "examples") || path.starts_with("src/bin/") {
return true;
}
}
false
}
pub fn inspect_cycles(project: &Path) -> Result<Value> {
let conn = open_read(project)?;
let files = store::all_files(&conn)?;
let imports = store::all_imports(&conn)?;
let graph = Graph::from_imports(&imports);
let cycles = graph.cycles();
let path_of = |id: i64| {
files
.iter()
.find(|f| f.id == id)
.map(|f| f.path.clone())
.unwrap_or_else(|| format!("<id {id}>"))
};
let out: Vec<Vec<String>> = cycles
.into_iter()
.map(|c| c.into_iter().map(&path_of).collect())
.collect();
Ok(serde_json::json!({"count": out.len(), "cycles": out}))
}
pub fn inspect_blast_radius(project: &Path, file: &str, depth: Option<usize>) -> Result<Value> {
let conn = open_read(project)?;
let fid = file_id(&conn, file)?;
let files = store::all_files(&conn)?;
let imports = store::all_imports(&conn)?;
let graph = Graph::from_imports(&imports);
let max_depth = depth.unwrap_or(3);
let hits = graph.blast_radius(fid, max_depth);
let out: Vec<Value> = hits
.into_iter()
.filter_map(|(id, d)| {
files
.iter()
.find(|f| f.id == id)
.map(|f| serde_json::json!({"id": f.id, "path": f.path, "depth": d}))
})
.collect();
Ok(serde_json::json!({"file": file, "count": out.len(), "affected": out}))
}
// ---- stats ----
pub fn stats(project: &Path) -> Result<Value> {
let conn = open_read(project)?;
let (files, symbols, imports) = store::stats(&conn)?;
let langs = store::language_counts(&conn)?;
Ok(serde_json::json!({
"files": files,
"symbols": symbols,
"imports": imports,
"languages": langs,
}))
}
// ---- helpers ----
fn open_read(project: &Path) -> Result<Connection> {
let db = config::index_db_path(project);
if !db.exists() {
bail!(
"no index found for {}. Run `rustytokens index create <path>` first.",
project.display()
);
}
store::open(&db)
}
fn file_id(conn: &Connection, path: &str) -> Result<i64> {
store::file_id_by_path(conn, path)?.with_context(|| format!("file not indexed: {path}"))
}
fn read_lines(project: &Path, rel: &str, start: i64, end: i64) -> Result<String> {
let all = read_all_lines(project, rel)?;
Ok(all
.iter()
.enumerate()
.filter(|(i, _)| {
let n = (*i as i64) + 1;
n >= start && n <= end
})
.map(|(_, l)| l.clone())
.collect::<Vec<_>>()
.join("\n"))
}
fn read_all_lines(project: &Path, rel: &str) -> Result<Vec<String>> {
let path = project.join(rel);
let content = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read {}", path.display()))?;
Ok(content.lines().map(|l| l.to_string()).collect())
}
fn parse_range(lines: Option<&str>) -> (Option<i64>, Option<i64>) {
let Some(s) = lines else { return (None, None) };
let parts: Vec<&str> = s.split('-').collect();
let start = parts.first().and_then(|p| p.trim().parse::<i64>().ok());
let end = parts.get(1).and_then(|p| p.trim().parse::<i64>().ok());
(start, end)
}
// ---- tree ----
#[derive(Debug, Serialize)]
struct TreeNode {
name: String,
path: String,
#[serde(rename = "type")]
kind: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
children: Vec<TreeNode>,
}
#[derive(Default)]
struct RawNode {
dirs: BTreeMap<String, RawNode>,
files: BTreeMap<String, String>,
}
fn build_tree(root_name: &str, paths: &[String], depth: Option<usize>) -> TreeNode {
let mut root = RawNode::default();
for p in paths {
let segs: Vec<&str> = p.split('/').collect();
insert_raw(&mut root, &segs, p);
}
let children = raw_to_nodes(&root, "", depth);
TreeNode {
name: root_name.to_string(),
path: String::new(),
kind: "dir".to_string(),
children,
}
}
fn insert_raw(node: &mut RawNode, segs: &[&str], full: &str) {
match segs {
[head] => {
node.files
.entry((*head).to_string())
.or_insert_with(|| full.to_string());
}
[head, rest @ ..] => {
let child = node.dirs.entry((*head).to_string()).or_default();
insert_raw(child, rest, full);
}
[] => {}
}
}
fn raw_to_nodes(node: &RawNode, base: &str, depth: Option<usize>) -> Vec<TreeNode> {
let mut out = Vec::new();
for (name, full) in &node.files {
out.push(TreeNode {
name: name.clone(),
path: full.clone(),
kind: "file".to_string(),
children: Vec::new(),
});
}
for (name, child) in &node.dirs {
let path = if base.is_empty() {
name.clone()
} else {
format!("{base}/{name}")
};
let expand = depth.map(|d| d > 0).unwrap_or(true);
let children = if expand {
raw_to_nodes(child, &path, depth.map(|d| d.saturating_sub(1)))
} else {
Vec::new()
};
out.push(TreeNode {
name: name.clone(),
path,
kind: "dir".to_string(),
children,
});
}
out
}
+100
View File
@@ -0,0 +1,100 @@
//! Output formatting: JSON (default) or a minimal table.
use anyhow::Result;
use serde::Serialize;
use serde_json::Value;
use std::io::Write;
pub fn print_json<T: Serialize>(value: &T, compact: bool) -> Result<()> {
let mut buf = Vec::new();
if compact {
serde_json::to_writer(&mut buf, value)?;
} else {
serde_json::to_writer_pretty(&mut buf, value)?;
}
let mut out = std::io::stdout().lock();
out.write_all(&buf)?;
out.write_all(b"\n")?;
Ok(())
}
fn putline(s: &str) {
use std::io::Write;
let _ = writeln!(std::io::stdout().lock(), "{s}");
}
/// Minimal table renderer for array-of-objects and plain objects.
pub fn print_table(value: &Value) {
match value {
Value::Array(items) if !items.is_empty() && items.iter().all(|i| i.is_object()) => {
let cols = collect_columns(items);
let mut rows = vec![cols.clone()];
for item in items {
let obj = item.as_object().unwrap();
rows.push(cols.iter().map(|c| cell_string(obj.get(c))).collect());
}
print_grid(&rows);
}
Value::Object(obj) => {
let mut rows = vec![vec!["key".to_string(), "value".to_string()]];
for (k, v) in obj {
rows.push(vec![k.clone(), cell_string(Some(v))]);
}
print_grid(&rows);
}
Value::Array(items) => {
for i in items {
putline(&cell_string(Some(i)));
}
}
other => putline(&cell_string(Some(other))),
}
}
fn collect_columns(items: &[Value]) -> Vec<String> {
let mut cols: Vec<String> = Vec::new();
for item in items {
if let Some(obj) = item.as_object() {
for k in obj.keys() {
if !cols.contains(k) {
cols.push(k.clone());
}
}
}
}
cols
}
fn cell_string(v: Option<&Value>) -> String {
match v {
None | Some(Value::Null) => String::new(),
Some(Value::String(s)) => s.clone(),
Some(other) => serde_json::to_string(other).unwrap_or_default(),
}
}
fn print_grid(rows: &[Vec<String>]) {
let n = rows.iter().map(|r| r.len()).max().unwrap_or(0);
let mut widths = vec![0usize; n];
for r in rows {
for (i, c) in r.iter().enumerate() {
widths[i] = widths[i].max(c.chars().count());
}
}
for (ri, r) in rows.iter().enumerate() {
let mut line = String::new();
for (i, w) in widths.iter().enumerate() {
let c = r.get(i).cloned().unwrap_or_default();
line.push_str(&c);
if i + 1 < n {
line.push_str(&" ".repeat(w.saturating_sub(c.chars().count())));
line.push_str(" ");
}
}
putline(line.trim_end());
if ri == 0 {
let sep: Vec<String> = widths.iter().map(|w| "-".repeat(*w)).collect();
putline(sep.join(" ").trim_end());
}
}
}
+130
View File
@@ -0,0 +1,130 @@
//! FTS5-backed search with a simple own ranking (exact > prefix > fulltext).
use std::collections::HashSet;
use anyhow::Result;
use rusqlite::{Connection, params};
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct SymbolHit {
pub id: i64,
pub name: String,
pub qualified_name: String,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
pub file: String,
pub line_start: i64,
pub line_end: i64,
pub match_type: String,
}
#[derive(Debug, Serialize)]
pub struct TextHit {
pub file: String,
pub snippet: String,
}
const SYMBOL_COLS: &str =
"s.id, s.name, s.qualified_name, s.kind, s.signature, f.path, s.line_start, s.line_end";
pub fn search_symbols(conn: &Connection, query: &str) -> Result<Vec<SymbolHit>> {
let q = query.trim();
let mut seen: HashSet<i64> = HashSet::new();
let mut out: Vec<SymbolHit> = Vec::new();
if q.is_empty() {
return Ok(out);
}
// 1) exact name (case-insensitive)
{
let mut stmt = conn.prepare(&format!(
"SELECT {SYMBOL_COLS} FROM symbols s JOIN files f ON f.id = s.file_id \
WHERE s.name = ?1 COLLATE NOCASE ORDER BY s.id LIMIT 200"
))?;
let rows = stmt.query_map(params![q], |r| row_to_hit(r, "exact"))?;
push_hits(&mut out, &mut seen, rows)?;
}
// 2) prefix
{
let pat = format!("{}%", q);
let mut stmt = conn.prepare(&format!(
"SELECT {SYMBOL_COLS} FROM symbols s JOIN files f ON f.id = s.file_id \
WHERE s.name LIKE ?1 COLLATE NOCASE ORDER BY s.id LIMIT 200"
))?;
let rows = stmt.query_map(params![pat], |r| row_to_hit(r, "prefix"))?;
push_hits(&mut out, &mut seen, rows)?;
}
// 3) fulltext over name/qualified_name/kind/signature
{
let phrase = fts_phrase(q);
let mut stmt = conn.prepare(&format!(
"SELECT {SYMBOL_COLS} FROM symbols_fts \
JOIN symbols s ON s.id = symbols_fts.rowid \
JOIN files f ON f.id = s.file_id \
WHERE symbols_fts MATCH ?1 ORDER BY rank LIMIT 200"
))?;
let rows = stmt.query_map(params![phrase], |r| row_to_hit(r, "fulltext"))?;
push_hits(&mut out, &mut seen, rows)?;
}
Ok(out)
}
pub fn search_text(conn: &Connection, query: &str) -> Result<Vec<TextHit>> {
let q = query.trim();
if q.is_empty() {
return Ok(Vec::new());
}
let phrase = fts_phrase(q);
let mut stmt = conn.prepare(
"SELECT path, snippet(content_fts, 1, '«', '»', '…', 24) \
FROM content_fts WHERE content_fts MATCH ?1 ORDER BY rank LIMIT 100",
)?;
let rows = stmt.query_map(params![phrase], |r| {
Ok(TextHit {
file: r.get(0)?,
snippet: r.get(1)?,
})
})?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
fn row_to_hit(
r: &rusqlite::Row<'_>,
match_type: &str,
) -> std::result::Result<SymbolHit, rusqlite::Error> {
Ok(SymbolHit {
id: r.get(0)?,
name: r.get(1)?,
qualified_name: r.get(2)?,
kind: r.get(3)?,
signature: r.get(4)?,
file: r.get(5)?,
line_start: r.get(6)?,
line_end: r.get(7)?,
match_type: match_type.to_string(),
})
}
fn push_hits(
out: &mut Vec<SymbolHit>,
seen: &mut HashSet<i64>,
rows: impl Iterator<Item = std::result::Result<SymbolHit, rusqlite::Error>>,
) -> Result<()> {
for r in rows {
let hit = r?;
if seen.insert(hit.id) {
out.push(hit);
}
}
Ok(())
}
/// Wrap a user query as a safe FTS5 phrase.
fn fts_phrase(q: &str) -> String {
format!("\"{}\"", q.replace('"', "\"\""))
}
+480
View File
@@ -0,0 +1,480 @@
//! End-to-end CLI tests against small, self-created fixture repos.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use serde_json::Value;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_rustytokens")
}
/// Shared temp cache dir so tests never touch the real ~/.cache/rustytokens.
fn cache_dir() -> &'static Path {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let d = std::env::temp_dir().join(format!("rustytokens-test-cache-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
})
}
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
fn copy_dir(src: &Path, dst: &Path) {
std::fs::create_dir_all(dst).unwrap();
for entry in std::fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let to = dst.join(entry.file_name());
if entry.path().is_dir() {
copy_dir(&entry.path(), &to);
} else {
std::fs::copy(entry.path(), &to).unwrap();
}
}
}
/// Run the binary with the fixture's cache dir, returning parsed stdout JSON.
fn run_json(args: &[&str]) -> Value {
let out = Command::new(bin())
.args(args)
.env("XDG_CACHE_HOME", cache_dir())
.output()
.expect("run rustytokens");
assert!(
out.status.success(),
"command failed: {:?}\nstderr: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("stdout is valid JSON")
}
#[test]
fn rust_index_search_and_dead() {
let fx = fixture("rustdemo");
run_json(&["index", "create", fx.to_str().unwrap()]);
let hits = run_json(&[
"--project",
fx.to_str().unwrap(),
"search",
"symbols",
"add",
]);
let hit = hits
.as_array()
.unwrap()
.iter()
.find(|v| v["name"] == "add")
.expect("search finds `add`");
assert_eq!(hit["kind"], "function");
// inspect symbol by id returns the exact source.
let id = hit["id"].as_i64().unwrap();
let sym = run_json(&[
"--project",
fx.to_str().unwrap(),
"inspect",
"symbol",
&id.to_string(),
]);
assert!(
sym["source"].as_str().unwrap().contains("a + b"),
"source contains function body"
);
// dead.rs is flagged; entry points lib.rs/main.rs are not.
let dead = run_json(&["--project", fx.to_str().unwrap(), "find", "dead"]);
let paths: Vec<&str> = dead["files"]
.as_array()
.unwrap()
.iter()
.map(|f| f["path"].as_str().unwrap())
.collect();
assert!(paths.contains(&"src/dead.rs"), "dead.rs flagged");
assert!(!paths.contains(&"src/lib.rs"), "lib.rs is an entry point");
assert!(!paths.contains(&"src/main.rs"), "main.rs is an entry point");
}
#[test]
fn inspect_blast_radius_finds_affected_files() {
let src = fixture("rustdemo");
let tmp = cache_dir().join("blast-rustdemo");
let _ = std::fs::remove_dir_all(&tmp);
copy_dir(&src, &tmp);
run_json(&["index", "create", tmp.to_str().unwrap()]);
let blast = run_json(&[
"--project",
tmp.to_str().unwrap(),
"inspect",
"blast-radius",
"src/util.rs",
]);
assert_eq!(blast["count"], 1);
assert_eq!(blast["file"], "src/util.rs");
let affected: Vec<&str> = blast["affected"]
.as_array()
.unwrap()
.iter()
.map(|f| f["path"].as_str().unwrap())
.collect();
assert_eq!(affected, ["src/lib.rs"], "lib.rs imports util.rs");
}
#[test]
fn python_import_resolution_and_dead() {
let fx = fixture("pydemo");
run_json(&["index", "create", fx.to_str().unwrap()]);
let importers = run_json(&[
"--project",
fx.to_str().unwrap(),
"find",
"importers",
"util.py",
]);
let names: Vec<&str> = importers
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(names.contains(&"main.py"), "main.py imports util.py");
let dead = run_json(&["--project", fx.to_str().unwrap(), "find", "dead"]);
let paths: Vec<&str> = dead["files"]
.as_array()
.unwrap()
.iter()
.map(|f| f["path"].as_str().unwrap())
.collect();
assert!(paths.contains(&"dead.py"), "dead.py flagged");
assert!(!paths.contains(&"util.py"), "util.py is imported");
}
#[test]
fn typescript_cycle_detection() {
let fx = fixture("tsdemo");
run_json(&["index", "create", fx.to_str().unwrap()]);
let cycles = run_json(&["--project", fx.to_str().unwrap(), "inspect", "cycles"]);
assert_eq!(cycles["count"], 1, "one cycle");
let cycle = &cycles["cycles"][0];
let files: Vec<&str> = cycle
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(
files.contains(&"a.ts") && files.contains(&"b.ts"),
"a↔b cycle"
);
}
#[test]
fn java_symbols_and_import_resolution() {
let fx = fixture("javademo");
run_json(&["index", "create", fx.to_str().unwrap()]);
let hits = run_json(&[
"--project",
fx.to_str().unwrap(),
"search",
"symbols",
"helper",
]);
let hit = hits
.as_array()
.unwrap()
.iter()
.find(|v| v["name"] == "helper")
.expect("search finds `helper`");
assert_eq!(hit["kind"], "method");
// ctags reports Java constructors as `method` with a full `end` span, so
// the multi-line `Main` constructor must carry line_end > line_start.
let ctor = run_json(&[
"--project",
fx.to_str().unwrap(),
"search",
"symbols",
"Main",
]);
let ctor_hit = ctor
.as_array()
.unwrap()
.iter()
.find(|v| v["name"] == "Main" && v["kind"] == "method")
.expect("search finds `Main` constructor");
assert!(
ctor_hit["line_end"].as_i64().unwrap() > ctor_hit["line_start"].as_i64().unwrap(),
"multi-line constructor has an extended span"
);
let importers = run_json(&[
"--project",
fx.to_str().unwrap(),
"find",
"importers",
"Util.java",
]);
let names: Vec<&str> = importers
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(names.contains(&"Main.java"), "Main.java imports Util.java");
}
#[test]
fn c_and_cpp_symbols_and_imports() {
let cdemo = fixture("cdemo");
run_json(&["index", "create", cdemo.to_str().unwrap()]);
let hits = run_json(&[
"--project",
cdemo.to_str().unwrap(),
"search",
"symbols",
"helper",
]);
assert!(
hits.as_array()
.unwrap()
.iter()
.any(|v| v["name"] == "helper"),
"c demo indexes `helper`"
);
let importers = run_json(&[
"--project",
cdemo.to_str().unwrap(),
"find",
"importers",
"util.h",
]);
let names: Vec<&str> = importers
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(names.contains(&"main.c") && names.contains(&"util.c"));
let cppdemo = fixture("cppdemo");
run_json(&["index", "create", cppdemo.to_str().unwrap()]);
let hits = run_json(&[
"--project",
cppdemo.to_str().unwrap(),
"search",
"symbols",
"add",
]);
assert!(
hits.as_array().unwrap().iter().any(|v| v["name"] == "add"),
"cpp demo indexes `add`"
);
// ctags reports C++ constructors/destructors as `function` with an `end`
// span; the multi-line `Math` ctor must carry line_end > line_start.
let ctor = run_json(&[
"--project",
cppdemo.to_str().unwrap(),
"search",
"symbols",
"Math",
]);
let ctor_hit = ctor
.as_array()
.unwrap()
.iter()
.find(|v| v["name"] == "Math" && v["kind"] == "function")
.expect("search finds `Math` constructor");
assert_eq!(ctor_hit["kind"], "function");
assert!(
ctor_hit["line_end"].as_i64().unwrap() > ctor_hit["line_start"].as_i64().unwrap(),
"multi-line constructor has an extended span"
);
// Destructor is also `function` with a ctags-owned `end` span (name `~Math`).
let dtor_hit = ctor
.as_array()
.unwrap()
.iter()
.find(|v| v["name"] == "~Math" && v["kind"] == "function")
.expect("search finds `~Math` destructor");
assert!(
dtor_hit["line_end"].as_i64().unwrap() > dtor_hit["line_start"].as_i64().unwrap(),
"multi-line destructor has an extended span"
);
let importers = run_json(&[
"--project",
cppdemo.to_str().unwrap(),
"find",
"importers",
"math.hpp",
]);
let names: Vec<&str> = importers
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(
names.contains(&"main.cpp") && names.contains(&"math.cpp"),
"both cpp files include math.hpp"
);
}
#[test]
fn html_assets_are_discovered_and_imported() {
let fx = fixture("htmldemo");
run_json(&["index", "create", fx.to_str().unwrap()]);
let importers = run_json(&[
"--project",
fx.to_str().unwrap(),
"find",
"importers",
"app.js",
]);
let names: Vec<&str> = importers
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(
names.contains(&"index.html"),
"index.html imports app.js via <script src>"
);
let css = run_json(&[
"--project",
fx.to_str().unwrap(),
"find",
"importers",
"style.css",
]);
let names: Vec<&str> = css
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(
names.contains(&"index.html"),
"index.html imports style.css via <link href>"
);
}
#[test]
fn mcp_serve_handles_tool_call() {
let src = fixture("rustdemo");
let tmp = cache_dir().join("mcp-rustdemo");
let _ = std::fs::remove_dir_all(&tmp);
copy_dir(&src, &tmp);
run_json(&["index", "create", tmp.to_str().unwrap()]);
let mut child = Command::new(bin())
.args(["--project", tmp.to_str().unwrap(), "serve"])
.env("XDG_CACHE_HOME", cache_dir())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn serve");
{
let mut stdin = child.stdin.take().unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize"}}"#).unwrap();
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"search_symbols","arguments":{{"query":"add"}}}}}}"#
)
.unwrap();
} // drop stdin -> closes the pipe, server exits
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "serve exits cleanly");
let responses: Vec<Value> = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
let init = responses
.iter()
.find(|r| r["id"] == 1)
.expect("initialize response");
assert_eq!(init["result"]["serverInfo"]["name"], "rustytokens");
let call = responses
.iter()
.find(|r| r["id"] == 2)
.expect("tools/call response");
let text = call["result"]["content"][0]["text"].as_str().unwrap();
let hits: Value = serde_json::from_str(text).unwrap();
assert!(
hits.as_array().unwrap().iter().any(|h| h["name"] == "add"),
"search_symbols over MCP finds `add`"
);
}
#[test]
fn index_update_reindexes_changed_files() {
let src = fixture("javademo");
let tmp = cache_dir().join("update-javademo");
let _ = std::fs::remove_dir_all(&tmp);
copy_dir(&src, &tmp);
run_json(&["index", "create", tmp.to_str().unwrap()]);
let before = run_json(&[
"--project",
tmp.to_str().unwrap(),
"search",
"symbols",
"brandNew",
]);
assert!(
before.as_array().unwrap().is_empty(),
"new symbol absent before update"
);
let lib = tmp.join("BrandNew.java");
std::fs::write(
&lib,
"package demo;\n\npublic class BrandNew {\n public static int brandNew() { return 7; }\n}\n",
)
.unwrap();
run_json(&["index", "update", tmp.to_str().unwrap()]);
let after = run_json(&[
"--project",
tmp.to_str().unwrap(),
"search",
"symbols",
"brandNew",
]);
assert!(
after
.as_array()
.unwrap()
.iter()
.any(|h| h["name"] == "brandNew"),
"index update picks up the new symbol"
);
}
+1
View File
@@ -0,0 +1 @@
int c_dead(void) { return 0; }
+5
View File
@@ -0,0 +1,5 @@
#include "util.h"
int main(void) {
return helper();
}
+3
View File
@@ -0,0 +1,3 @@
#include "util.h"
int helper(void) { return 0; }
+1
View File
@@ -0,0 +1 @@
int helper(void);
+5
View File
@@ -0,0 +1,5 @@
#include "math.hpp"
int main() {
return Math::add(1, 2);
}
+13
View File
@@ -0,0 +1,13 @@
#include "math.hpp"
Math::Math(int scale) : scale_(scale) {
scale_ = scale;
}
Math::~Math() {
scale_ = 0;
}
int Math::add(int a, int b) { return a + b; }
int Math::scaled(int v) const { return v * scale_; }
+11
View File
@@ -0,0 +1,11 @@
#pragma once
class Math {
public:
Math(int scale);
~Math();
static int add(int a, int b);
int scaled(int v) const;
private:
int scale_;
};
+1
View File
@@ -0,0 +1 @@
console.log("hi");
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
<script src="app.js"></script>
</head>
<body>
<h1>Hello</h1>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
body { color: red; }
+5
View File
@@ -0,0 +1,5 @@
package demo;
public class Dead {
public static void unused() {}
}
+15
View File
@@ -0,0 +1,15 @@
package demo;
import demo.Util;
public class Main {
private final int seed;
public Main(int seed) {
this.seed = seed;
}
public static void main(String[] args) {
System.out.println(Util.helper());
}
}
+5
View File
@@ -0,0 +1,5 @@
package demo;
public class Util {
public static int helper() { return 42; }
}
+2
View File
@@ -0,0 +1,2 @@
def py_dead():
return 0
+5
View File
@@ -0,0 +1,5 @@
from util import helper
def run():
return helper()
+2
View File
@@ -0,0 +1,2 @@
def helper():
return 1
+1
View File
@@ -0,0 +1 @@
pub fn totally_dead() {}
+5
View File
@@ -0,0 +1,5 @@
pub mod util;
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
+5
View File
@@ -0,0 +1,5 @@
use rustdemo::add;
fn main() {
println!("{}", add(2, 3));
}
+3
View File
@@ -0,0 +1,3 @@
pub fn helper() -> String {
"ok".to_string()
}
+3
View File
@@ -0,0 +1,3 @@
import { b } from "./b";
export const a = 1;
+3
View File
@@ -0,0 +1,3 @@
import { a } from "./a";
export const b = a;