diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 0c28b15..0000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "tree-sitter-java"] - path = tree-sitter-java - url = https://github.com/tree-sitter/tree-sitter-java -[submodule "tree-sitter-groovy"] - path = tree-sitter-groovy - url = https://github.com/murtaza64/tree-sitter-groovy.git diff --git a/Cargo.lock b/Cargo.lock index 5fa659e..938c437 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # 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.4" @@ -11,6 +23,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -70,6 +88,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "async-trait" version = "0.1.89" @@ -104,38 +128,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e84ce723ab67259cfeb9877c6a639ee9eb7a27b28123abd71db7f0d5d0cc9d86" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a442ece363113bd4bd4c8b18977a7798dd4d3c3383f34fb61936960e8f4ad8" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" -version = "0.8.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ + "async-trait", "axum-core", "base64", "bytes", - "form_urlencoded", "futures-util", "http", "http-body", @@ -148,14 +150,15 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "serde_core", + "rustversion", + "serde", "serde_json", "serde_path_to_error", "serde_urlencoded", "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tower 0.5.3", "tower-layer", "tower-service", @@ -164,17 +167,19 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.6" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ + "async-trait", "bytes", - "futures-core", + "futures-util", "http", "http-body", "http-body-util", "mime", "pin-project-lite", + "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -233,6 +238,12 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.0" @@ -251,24 +262,12 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.43" @@ -323,31 +322,12 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "convert_case" version = "0.10.0" @@ -357,26 +337,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -613,12 +573,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dyn-clone" version = "1.0.20" @@ -631,15 +585,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -700,12 +645,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -821,10 +760,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -834,11 +771,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasip2", - "wasm-bindgen", ] [[package]] @@ -854,30 +789,16 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", + "serde", +] [[package]] name = "hashbrown" @@ -955,7 +876,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -965,23 +885,6 @@ dependencies = [ "pin-utils", "smallvec", "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", ] [[package]] @@ -990,24 +893,14 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64", "bytes", - "futures-channel", "futures-core", - "futures-util", "http", "http-body", "hyper", - "ipnet", - "libc", - "percent-encoding", "pin-project-lite", - "socket2", - "system-configuration", "tokio", "tower-service", - "tracing", - "windows-registry", ] [[package]] @@ -1166,6 +1059,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1188,22 +1083,6 @@ dependencies = [ "libc", ] -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1225,28 +1104,6 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - [[package]] name = "jobserver" version = "0.1.34" @@ -1287,6 +1144,17 @@ dependencies = [ "libc", ] +[[package]] +name = "lasso" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e14eda50a3494b3bf7b9ce51c52434a761e383d7238ce1dd5dcec2fbc13e9fb" +dependencies = [ + "dashmap 6.1.0", + "hashbrown 0.14.5", + "serde", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1342,12 +1210,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lsp-types" version = "0.94.1" @@ -1372,9 +1234,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.8.4" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" @@ -1401,42 +1263,153 @@ dependencies = [ ] [[package]] -name = "naviscope" -version = "0.2.1" +name = "naviscope-api" +version = "0.5.0" +dependencies = [ + "async-trait", + "lasso", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "naviscope-cli" +version = "0.5.0" dependencies = [ - "axum", - "cc", "clap", - "dashmap 6.1.0", "dirs", - "futures", + "indexmap", + "naviscope-api", + "naviscope-lsp", + "naviscope-mcp", + "naviscope-runtime", + "nu-ansi-term", + "petgraph", + "reedline", + "serde", + "serde_json", + "shlex", + "tabled", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "naviscope-core" +version = "0.5.0" +dependencies = [ + "async-trait", "ignore", + "lasso", "log", + "lsp-types", + "naviscope-api", "notify", - "nu-ansi-term", + "once_cell", "petgraph", "rayon", - "reedline", "regex", - "reqwest", - "rmcp", "rmp-serde", "schemars", "serde", "serde_json", - "shlex", - "tabled", "thiserror 2.0.17", "tokio", - "tokio-tungstenite", "tokio-util", - "tower-lsp", "tracing", "tracing-appender", "tracing-subscriber", "tree-sitter", + "tree-sitter-java", + "url", "walkdir", "xxhash-rust", + "zstd", +] + +[[package]] +name = "naviscope-gradle" +version = "0.5.0" +dependencies = [ + "lasso", + "naviscope-api", + "naviscope-core", + "once_cell", + "petgraph", + "rmp-serde", + "serde", + "serde_json", + "thiserror 2.0.17", + "tree-sitter", + "tree-sitter-groovy", +] + +[[package]] +name = "naviscope-java" +version = "0.5.0" +dependencies = [ + "lasso", + "lsp-types", + "naviscope-api", + "naviscope-core", + "petgraph", + "rmp-serde", + "serde", + "serde_json", + "thiserror 2.0.17", + "tree-sitter", + "tree-sitter-java", +] + +[[package]] +name = "naviscope-lsp" +version = "0.5.0" +dependencies = [ + "dashmap 6.1.0", + "naviscope-api", + "naviscope-mcp", + "petgraph", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower-lsp", + "tracing", + "tree-sitter", +] + +[[package]] +name = "naviscope-mcp" +version = "0.5.0" +dependencies = [ + "anyhow", + "axum", + "futures", + "naviscope-api", + "rmcp", + "schemars", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite 0.28.0", + "tokio-util", + "tower-lsp", + "tracing", + "xxhash-rust", +] + +[[package]] +name = "naviscope-runtime" +version = "0.5.0" +dependencies = [ + "naviscope-api", + "naviscope-core", + "naviscope-gradle", + "naviscope-java", + "tracing", ] [[package]] @@ -1499,12 +1472,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "option-ext" version = "0.2.0" @@ -1602,6 +1569,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1658,84 +1631,49 @@ dependencies = [ ] [[package]] -name = "quinn" -version = "0.11.9" +name = "quote" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.17", - "tokio", - "tracing", - "web-time", + "proc-macro2", ] [[package]] -name = "quinn-proto" -version = "0.11.13" +name = "r-efi" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.17", - "tinyvec", - "tracing", - "web-time", -] +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "quinn-udp" -version = "0.5.14" +name = "rand" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "cfg_aliases", "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", + "rand_chacha 0.3.1", + "rand_core 0.6.4", ] [[package]] -name = "quote" -version = "1.0.43" +name = "rand" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "proc-macro2", + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.2" +name = "rand_chacha" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ - "rand_chacha", - "rand_core", + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1745,7 +1683,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1864,63 +1811,8 @@ dependencies = [ [[package]] name = "regex-syntax" version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "reqwest" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower 0.5.3", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rmcp" @@ -1976,12 +1868,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustc_version" version = "0.4.1" @@ -2004,81 +1890,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" -dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -2100,15 +1911,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "schemars" version = "1.2.0" @@ -2141,29 +1943,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.27" @@ -2384,12 +2163,6 @@ dependencies = [ "syn", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.114" @@ -2406,9 +2179,6 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] [[package]] name = "synstructure" @@ -2421,27 +2191,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tabled" version = "0.20.0" @@ -2565,21 +2314,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.49.0" @@ -2590,6 +2324,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -2607,13 +2342,15 @@ dependencies = [ ] [[package]] -name = "tokio-rustls" -version = "0.26.4" +name = "tokio-tungstenite" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ - "rustls", + "futures-util", + "log", "tokio", + "tungstenite 0.24.0", ] [[package]] @@ -2625,7 +2362,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.28.0", ] [[package]] @@ -2671,24 +2408,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags 2.10.0", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower 0.5.3", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-layer" version = "0.3.3" @@ -2823,6 +2542,26 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-groovy" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a20016017f0865ba902ca50354f92429de5de8df994e64ab7fae087a13c40ed" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-language" version = "0.1.6" @@ -2830,10 +2569,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae62f7eae5eb549c71b76658648b72cc6111f2d87d24a1e31fa907f4943e3ce" [[package]] -name = "try-lock" -version = "0.2.5" +name = "tungstenite" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] [[package]] name = "tungstenite" @@ -2846,7 +2597,7 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.9.2", "sha1", "thiserror 2.0.17", "utf-8", @@ -2882,12 +2633,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.8" @@ -2950,15 +2695,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2987,20 +2723,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.108" @@ -3033,48 +2755,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi" version = "0.3.9" @@ -3147,17 +2827,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -3176,15 +2845,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3212,21 +2872,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -3260,12 +2905,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3278,12 +2917,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3296,12 +2929,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3326,12 +2953,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3344,12 +2965,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3362,12 +2977,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3380,12 +2989,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3480,12 +3083,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - [[package]] name = "zerotrie" version = "0.2.3" @@ -3524,3 +3121,31 @@ name = "zmij" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index eb500db..ca4c637 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,25 @@ -[package] -name = "naviscope" -version = "0.2.1" -edition = "2024" +[workspace] +resolver = "2" +members = [ + "crates/core", + "crates/lang-java", + "crates/lang-gradle", + "crates/cli", + "crates/lsp", + "crates/mcp", + "crates/api", + "crates/runtime", +] + +[workspace.dependencies] +naviscope-core = { path = "crates/core" } +naviscope-java = { path = "crates/lang-java" } +naviscope-gradle = { path = "crates/lang-gradle" } +naviscope-lsp = { path = "crates/lsp" } +naviscope-mcp = { path = "crates/mcp" } +naviscope-api = { path = "crates/api" } +naviscope-runtime = { path = "crates/runtime" } -[dependencies] petgraph = { version = "0.8", features = ["serde-1"] } tree-sitter = "0.26" serde = { version = "1.0", features = ["derive"] } @@ -11,31 +27,39 @@ serde_json = "1.0" thiserror = "2.0" walkdir = "2.5" log = "0.4" -clap = { version = "4.5", features = ["derive"] } ignore = "0.4.25" rayon = "1.11.0" notify = "8.2.0" xxhash-rust = { version = "0.8.15", features = ["xxh3"] } regex = "1.11.1" -tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros", "sync", "time"] } +tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "net", "signal"] } schemars = "1.2.0" -rmcp = { version = "0.13.0", features = ["macros", "server", "transport-io"] } -tower-lsp = "0.20" -futures = "0.3" -dashmap = "6.1.0" -axum = { version = "0.8.8", features = ["ws"] } -reqwest = { version = "0.13.1", features = ["stream"] } -tokio-util = { version = "0.7.18", features = ["io"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } tracing-appender = "0.2.4" -tokio-tungstenite = "0.28.0" +rmp-serde = "1.3.1" +once_cell = "1.20" +tower-lsp = "0.20" +lsp-types = "0.94" +cc = "1.2" +dashmap = "6.1.0" +tokio-util = "0.7.18" +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } reedline = "0.45.0" dirs = "6.0.0" nu-ansi-term = "0.50.3" shlex = "1.3.0" tabled = "0.20.0" -rmp-serde = "1.3.1" +indexmap = { version = "2.1.0", features = ["serde"] } +axum = { version = "0.7.5", features = ["ws"] } +futures = "0.3.31" +tokio-tungstenite = "0.28.0" +rmcp = { version = "0.13.0", features = ["macros", "server", "transport-io"] } +lasso = { version = "0.7", features = ["serialize", "multi-threaded"] } +zstd = "0.13" +async-trait = "0.1" +url = "2.5.8" +tree-sitter-java = "0.23.5" +tree-sitter-groovy = "0.1.2" -[build-dependencies] -cc = "1.2" diff --git a/README.md b/README.md index ef2b55b..a120b88 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Unified Code Knowledge Graph Engine for AI Agents & Developers** -Naviscope bridges the gap between AI and IDEs. It builds a comprehensive, graph-based representation of your codebase (connecting micro-level semantics like calls and inheritance with macro-level structures) that powers both **LLM agents** (via MCP) and **code editors** (via LSP). +Naviscope bridges the gap between AI and IDEs. It builds a comprehensive, graph-based representation of your codebase (connecting micro-level semantics like type relationships and inheritance with macro-level structures) that powers both **LLM agents** (via MCP) and **code editors** (via LSP). Unlike traditional tools that maintain separate indexes for different purposes, Naviscope provides a **single, unified knowledge graph**, ensuring that what AI agents see is exactly what developers navigate. @@ -20,10 +20,11 @@ Unlike traditional tools that maintain separate indexes for different purposes, ### 🤖 For AI Agents (MCP Support) Naviscope implements the [Model Context Protocol](https://modelcontextprotocol.io/), giving LLMs "X-ray vision" into your code structure. +- **`get_guide`**: Call this first! Get a comprehensive guide on how to use Naviscope tools. - **`ls`**: Hierarchical exploration of packages, modules, and fields. -- **`grep`**: Precise symbol search (find "Class definitions", not just string matches). -- **`inspect`**: Retrieve definition, source code, and metadata for any symbol. -- **`deps`**: Analyze incoming/outgoing dependencies and call graphs. +- **`find`**: Precise symbol search (find "Class definitions", not just string matches). +- **`cat`**: Retrieve definition, source code, and metadata for any symbol. +- **`deps`**: Analyze incoming/outgoing dependencies and relationships (inheritance, type usage, etc.). ### 👨‍💻 For Developers (LSP Support) A lightweight, lightning-fast alternative to standard language servers (like JDTLS). @@ -38,61 +39,79 @@ A lightweight, lightning-fast alternative to standard language servers (like JDT ```mermaid graph TD %% Styles - classDef layer fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,rx:5,ry:5 - classDef component fill:#fff,stroke:#333,stroke-width:1px - classDef storage fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,rx:5,ry:5 - - subgraph Access [Interfaces Layer] - direction LR - CLI[CLI Shell]:::component - MCP["MCP Server
(AI Agents)"]:::component - LSP["LSP Server
(Editors)"]:::component + classDef interface fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,rx:5,ry:5 + classDef runtime fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,rx:5,ry:5 + classDef language fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,rx:5,ry:5 + classDef core fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,rx:5,ry:5 + classDef api fill:#fafafa,stroke:#616161,stroke-width:2px,rx:5,ry:5 + + subgraph Interfaces [Interface Layer] + CLI["naviscope-cli
(Shell & Main)"]:::interface + LSP["naviscope-lsp
(LSP Server)"]:::interface + MCP["naviscope-mcp
(MCP Server)"]:::interface end - subgraph Service [Query & Analysis Layer] - direction LR - Query[Query Engine]:::component - Search[Semantic Search]:::component - Deps[Dependency Analysis]:::component + subgraph Orchestration [Runtime Layer] + Runtime["naviscope-runtime
(Engine Orchestrator)"]:::runtime end - subgraph Core [Core Knowledge Graph] - direction LR - Graph["Unified Graph
(petgraph)"]:::component - Index["Symbol Index"]:::component + subgraph Strategies [Language Layer] + Java["naviscope-java
(Java Analysis)"]:::language + Gradle["naviscope-gradle
(Gradle Analysis)"]:::language end - subgraph Ingestion [Ingestion Layer] - direction LR - Scanner[File Scanner]:::component - Parser["Parsers
(Tree-sitter)"]:::component - Resolver["Symbol Resolver
(Java/Gradle)"]:::component + subgraph Engine [Core Layer] + Core["naviscope-core
(Graph, Index & IO)"]:::core end - subgraph Infra [Infrastructure] - direction LR - Store[("Persistence")]:::storage - Watch[File Watcher]:::component + subgraph Foundation [API Layer] + API["naviscope-api
(Common Traits & Models)"]:::api end - %% Connections - CLI --> Query - MCP --> Query - LSP --> Query + %% Crate Dependencies + CLI --> LSP + CLI --> MCP + CLI --> Runtime + CLI --> API - Query --> Graph - Search --> Graph - Deps --> Graph + LSP --> MCP + LSP --> API - Scanner --> Parser - Parser --> Resolver - Resolver --> Graph + MCP --> API - Graph -.-> Store - Watch -.-> Scanner + Runtime --> Java + Runtime --> Gradle + Runtime --> Core + Runtime --> API + + Java --> Core + Java --> API + + Gradle --> Core + Gradle --> API + + Core --> API ``` -Naviscope is built on a **layered architecture** that separates ingestion, core graph logic, and external interfaces. The core is a language-agnostic graph structure populated by language-specific strategies (currently Java/Gradle via Tree-sitter), exposing a unified query engine to both AI agents and developer tools. +Naviscope is built on a **layered crate architecture** that separates concerns across multiple Rust crates: + +- **Interface Layer** (`naviscope-cli`, `naviscope-lsp`, `naviscope-mcp`): Entry points for different use cases (CLI shell, LSP for editors, MCP for AI agents). +- **Runtime Layer** (`naviscope-runtime`): Orchestrates the engine assembly, registering language plugins and providing a unified factory. +- **Language Layer** (`naviscope-java`, `naviscope-gradle`): Language-specific analysis plugins that parse and resolve symbols. +- **Core Layer** (`naviscope-core`): The heart of the system - graph storage, indexing, file scanning, and persistence. +- **API Layer** (`naviscope-api`): Common traits and models shared across all crates, ensuring a consistent interface. + +The core is a language-agnostic graph structure populated by language-specific strategies (currently Java/Gradle via Tree-sitter), exposing a unified query engine to both AI agents and developer tools. + +### 🔍 Reference Discovery Strategy + +Naviscope uses a **two-phase reference discovery** approach for optimal performance: + +1. **Meso-level (Coarse Filtering)**: Uses an inverted `reference_index` (token → files) to quickly identify candidate files that likely contain references to a symbol. This index is built during parsing by extracting all identifier tokens from source files. + +2. **Micro-level (Precise Analysis)**: For each candidate file, uses Tree-sitter to parse and verify actual symbol occurrences, ensuring accurate reference locations. + +This hybrid approach combines the speed of inverted indexing with the precision of syntax-aware parsing, enabling fast reference discovery even in large codebases. ## 🚀 Quick Start @@ -109,7 +128,7 @@ cd naviscope git submodule update --init --recursive # 2. Install the Naviscope CLI -cargo install --path . +cargo install --path crates/cli # 3. (Optional) Build the VS Code Extension cd editors/vscode @@ -124,6 +143,10 @@ npm run package - `naviscope index `: Build a persistent index for a project. - `naviscope shell [PATH]`: Start an interactive shell to query the graph. - `naviscope watch `: Start a background service to keep the index updated. +- `naviscope clear [PATH]`: Clear built indices (or all indices if path omitted). +- `naviscope mcp`: Start the MCP server. +- `naviscope lsp`: Start the LSP server. + #### Configure in Cursor (for AI Agents) 1. Open **Cursor Settings** (Cmd + Shift + J) -> **Features** -> **MCP**. 2. Click **+ Add New MCP Server**. @@ -136,30 +159,55 @@ npm run package - **VS Code**: Install the extension built in step 3. - **Other Clients**: Point your LSP client to run `naviscope lsp`. -## 🛠️ Query DSL Examples +## 🛠️ Query DSL (Interactive Shell) -Whether using the CLI shell or MCP tools, the query logic is consistent: +The `naviscope shell` provides a Unix-like experience for exploring the Code Knowledge Graph: ```bash -# Find all classes named 'UserService' -grep "UserService" --kind class +# Change current context to a package or class +cd "com.example.service" + +# List members in current context +ls -# List contents of a package -ls "com.example.service" +# List with detailed information +ls -l + +# Find all classes named 'UserService' +find "UserService" --kind class # Inspect full details of a symbol (source code, metadata) -cat "com.example.service.UserService" +cat "UserService" + +# Find who references current symbol? +deps --rev -# Who calls 'login'? (Incoming dependencies / Reverse lookups) -deps --rev "com.example.auth.AuthService.login" +# Print current FQN context +pwd + +# Clear screen +clear ``` +## 🔗 Graph Relationships + +Naviscope tracks the following relationship types in the knowledge graph: + +- **Structural**: `Contains` (package → class, class → method, etc.) +- **Inheritance**: `InheritsFrom`, `Implements` +- **Type Usage**: `TypedAs` (field/variable → type) +- **Annotations**: `DecoratedBy` (class/method → annotation) +- **Build System**: `UsesDependency` (project → dependency) + +Reference discovery (method calls, instantiations) is handled efficiently through the `reference_index` + Tree-sitter two-phase approach, avoiding the need to store explicit call edges for every reference. + ## 📈 Roadmap - [x] **Core**: Graph Storage (`petgraph`), Parallel Indexing, Real-time Updates (`notify`). - [x] **Languages**: Java & Gradle (Tree-sitter driven). - [x] **Interfaces**: CLI Shell, MCP Server, LSP Server. - [x] **Editors**: VS Code Extension. +- [x] **Reference Discovery**: Two-phase approach (reference_index + Tree-sitter). - [ ] **Upcoming**: Maven Support, Python/Rust Language Strategies. ## 📄 License diff --git a/build.rs b/build.rs deleted file mode 100644 index 3c76002..0000000 --- a/build.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::path::Path; - -fn main() { - let grammars = vec![ - ("java", "tree-sitter-java"), - ("groovy", "tree-sitter-groovy"), - ]; - - for (language, dir) in grammars { - let lang_dir = Path::new(dir); - let parser_path = lang_dir.join("src").join("parser.c"); - let scanner_path = lang_dir.join("src").join("scanner.c"); - - println!("cargo:rerun-if-changed={}", parser_path.to_str().unwrap()); - println!("cargo:rerun-if-changed={}", scanner_path.to_str().unwrap()); - - let mut build = cc::Build::new(); - build.file(&parser_path).include(lang_dir.join("src")); - - if scanner_path.exists() { - build.file(&scanner_path); - } - - build.compile(format!("tree-sitter-{}", language).as_str()); - } -} diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml new file mode 100644 index 0000000..097d610 --- /dev/null +++ b/crates/api/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "naviscope-api" +version = "0.5.0" +edition = "2024" + +[dependencies] +async-trait = { workspace = true } +serde = { workspace = true } +schemars = { workspace = true } +lasso = { workspace = true } +thiserror = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/api/src/graph.rs b/crates/api/src/graph.rs new file mode 100644 index 0000000..424bbf2 --- /dev/null +++ b/crates/api/src/graph.rs @@ -0,0 +1,25 @@ +pub use crate::models::graph::{GraphQuery, QueryResult}; +use async_trait::async_trait; + +#[derive(Debug, thiserror::Error)] +pub enum GraphError { + #[error("Internal error: {0}")] + Internal(String), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct GraphStats { + pub node_count: usize, + pub edge_count: usize, +} + +#[async_trait] +pub trait GraphService: Send + Sync { + async fn query(&self, query: &GraphQuery) -> Result; + async fn get_stats(&self) -> Result; + + /// Get a fully hydrated display node by its FQN. + async fn get_node_display(&self, fqn: &str) -> Result>; +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs new file mode 100644 index 0000000..eb36c62 --- /dev/null +++ b/crates/api/src/lib.rs @@ -0,0 +1,25 @@ +pub mod graph; +pub mod lifecycle; +pub mod models; +pub mod navigation; +pub mod semantic; + +// Re-export commonly used types +pub use graph::GraphService; +pub use lifecycle::EngineLifecycle; +pub use models::*; +pub use navigation::NavigationService; +pub use semantic::{CallHierarchyAnalyzer, ReferenceAnalyzer, SymbolInfoProvider, SymbolNavigator}; + +/// Composite trait representing the full Naviscope Engine API. +/// This allows clients to depend on a single trait instead of multiple individual ones. +pub trait NaviscopeEngine: + GraphService + + NavigationService + + SymbolNavigator + + ReferenceAnalyzer + + CallHierarchyAnalyzer + + SymbolInfoProvider + + EngineLifecycle +{ +} diff --git a/crates/api/src/lifecycle.rs b/crates/api/src/lifecycle.rs new file mode 100644 index 0000000..344798e --- /dev/null +++ b/crates/api/src/lifecycle.rs @@ -0,0 +1,30 @@ +use async_trait::async_trait; + +#[derive(Debug, thiserror::Error)] +pub enum EngineError { + #[error("Internal error: {0}")] + Internal(String), +} + +pub type EngineResult = std::result::Result; + +#[async_trait] +pub trait EngineLifecycle: Send + Sync { + /// Rebuild the index from scratch + async fn rebuild(&self) -> EngineResult<()>; + + /// Load the index from disk + async fn load(&self) -> EngineResult; + + /// Save the index to disk + async fn save(&self) -> EngineResult<()>; + + /// Refresh the index (find new files, etc.) + async fn refresh(&self) -> EngineResult<()>; + + /// Watch for filesystem changes + async fn watch(&self) -> EngineResult<()>; + + /// Clear the index for the current project + async fn clear_index(&self) -> EngineResult<()>; +} diff --git a/crates/api/src/models/graph.rs b/crates/api/src/models/graph.rs new file mode 100644 index 0000000..bf926fa --- /dev/null +++ b/crates/api/src/models/graph.rs @@ -0,0 +1,289 @@ +use super::language::Language; +use super::symbol::Range; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum NodeKind { + Package, + Module, + Class, + Interface, + Enum, + Annotation, + Method, + Constructor, + Field, + Variable, + // Build Specific + Project, + Dependency, + Task, + Plugin, + // Extension + Custom(#[schemars(with = "String")] String), +} + +impl From<&str> for NodeKind { + fn from(s: &str) -> Self { + match s.to_lowercase().as_str() { + "package" => NodeKind::Package, + "module" => NodeKind::Module, + "class" => NodeKind::Class, + "interface" => NodeKind::Interface, + "enum" => NodeKind::Enum, + "annotation" => NodeKind::Annotation, + "method" => NodeKind::Method, + "constructor" => NodeKind::Constructor, + "field" => NodeKind::Field, + "variable" => NodeKind::Variable, + "project" => NodeKind::Project, + "dependency" => NodeKind::Dependency, + "task" => NodeKind::Task, + "plugin" => NodeKind::Plugin, + _ => NodeKind::Custom(s.to_string()), + } + } +} + +impl ToString for NodeKind { + fn to_string(&self) -> String { + match self { + NodeKind::Package => "package".to_string(), + NodeKind::Module => "module".to_string(), + NodeKind::Class => "class".to_string(), + NodeKind::Interface => "interface".to_string(), + NodeKind::Enum => "enum".to_string(), + NodeKind::Annotation => "annotation".to_string(), + NodeKind::Method => "method".to_string(), + NodeKind::Constructor => "constructor".to_string(), + NodeKind::Field => "field".to_string(), + NodeKind::Variable => "variable".to_string(), + NodeKind::Project => "project".to_string(), + NodeKind::Dependency => "dependency".to_string(), + NodeKind::Task => "task".to_string(), + NodeKind::Plugin => "plugin".to_string(), + NodeKind::Custom(s) => s.to_string(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +pub enum EdgeType { + // Structural relationships + Contains, + // Inheritance/Implementation + InheritsFrom, + Implements, + // Usage/Reference + TypedAs, + DecoratedBy, + // Build system relationships + UsesDependency, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +pub struct GraphEdge { + pub edge_type: EdgeType, +} + +impl GraphEdge { + pub fn new(edge_type: EdgeType) -> Self { + Self { edge_type } + } +} + +use super::symbol::Symbol; +use lasso::Reader; +use std::any::Any; +use std::fmt::Debug; + +/// Trait for language-specific metadata. +pub trait NodeMetadata: Send + Sync + Debug { + /// Cast to Any for downcasting to concrete types. + fn as_any(&self) -> &dyn Any; +} + +/// Default empty metadata implementation. +#[derive(Debug, Clone)] +pub struct EmptyMetadata; + +impl NodeMetadata for EmptyMetadata { + fn as_any(&self) -> &dyn Any { + self + } +} + +#[derive(Debug, Clone)] +pub struct GraphNode { + /// Unique Identifier (Symbol) + pub id: Symbol, + /// Short display name (Symbol) + pub name: Symbol, + /// Abstract categorization + pub kind: NodeKind, + /// Language identifier (Symbol) + pub lang: Symbol, + /// Physical Location + pub location: Option, + /// Extension metadata + pub metadata: Arc, +} + +impl Default for GraphNode { + fn default() -> Self { + Self { + id: Symbol(lasso::Spur::default()), + name: Symbol(lasso::Spur::default()), + kind: NodeKind::Custom("unknown".to_string()), + lang: Symbol(lasso::Spur::default()), + location: None, + metadata: Arc::new(EmptyMetadata), + } + } +} + +impl GraphNode { + pub fn language<'a>(&self, rodeo: &'a dyn Reader) -> Language { + Language::new(rodeo.resolve(&self.lang.0).to_string()) + } + + pub fn fqn<'a>(&self, rodeo: &'a dyn Reader) -> &'a str { + rodeo.resolve(&self.id.0) + } + + pub fn name<'a>(&self, rodeo: &'a dyn Reader) -> &'a str { + rodeo.resolve(&self.name.0) + } + + pub fn kind(&self) -> NodeKind { + self.kind.clone() + } + + pub fn path<'a>(&self, rodeo: &'a impl Reader) -> Option<&'a str> { + self.location.as_ref().map(|l| rodeo.resolve(&l.path.0)) + } + + pub fn range(&self) -> Option<&Range> { + self.location.as_ref().map(|l| &l.range) + } + + pub fn name_range(&self) -> Option<&Range> { + self.location + .as_ref() + .and_then(|l| l.selection_range.as_ref()) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] +pub struct DisplaySymbolLocation { + pub path: String, + pub range: Range, + #[serde(default)] + pub selection_range: Option, +} + +impl DisplaySymbolLocation { + pub fn to_internal(&self, rodeo: &mut lasso::Rodeo) -> super::symbol::InternedLocation { + super::symbol::InternedLocation { + path: Symbol(rodeo.get_or_intern(&self.path)), + range: self.range, + selection_range: self.selection_range, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] +pub struct DisplayGraphNode { + pub id: String, + pub name: String, + pub kind: NodeKind, + pub lang: String, + pub location: Option, + + // Rendering fields + pub detail: Option, + pub signature: Option, + #[serde(default)] + pub modifiers: Vec, + + // Hierarchy support + pub children: Option>, +} + +impl DisplayGraphNode { + pub fn to_internal(&self, rodeo: &mut lasso::Rodeo) -> GraphNode { + GraphNode { + id: Symbol(rodeo.get_or_intern(&self.id)), + name: Symbol(rodeo.get_or_intern(&self.name)), + kind: self.kind.clone(), + lang: Symbol(rodeo.get_or_intern(&self.lang)), + location: self.location.as_ref().map(|l| l.to_internal(rodeo)), + metadata: Arc::new(EmptyMetadata), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] +#[serde(tag = "command", rename_all = "snake_case")] +pub enum GraphQuery { + /// List members or structure (Rich Listing) + Ls { + /// Target node FQN, defaults to project modules if null + fqn: Option, + #[serde(default)] + kind: Vec, + #[serde(default)] + modifiers: Vec, + }, + + /// Search for symbols + Find { + pattern: String, + #[serde(default)] + kind: Vec, + #[serde(default = "default_limit")] + limit: usize, + }, + + /// Inspect node details (Source & Metadata) + Cat { fqn: String }, + + /// Find dependencies (outgoing) or dependents (incoming) + Deps { + fqn: String, + /// If true, find incoming dependencies (who depends on me). + /// If false (default), find outgoing dependencies (who do I depend on). + #[serde(default)] + rev: bool, + #[serde(default)] + edge_types: Vec, + }, +} + +fn default_limit() -> usize { + 20 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryResultEdge { + #[serde(with = "super::util::serde_arc_str")] + pub from: Arc, + #[serde(with = "super::util::serde_arc_str")] + pub to: Arc, + pub data: GraphEdge, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct QueryResult { + pub nodes: Vec, + pub edges: Vec, +} + +impl QueryResult { + pub fn new(nodes: Vec, edges: Vec) -> Self { + Self { nodes, edges } + } +} diff --git a/crates/api/src/models/language.rs b/crates/api/src/models/language.rs new file mode 100644 index 0000000..0055e18 --- /dev/null +++ b/crates/api/src/models/language.rs @@ -0,0 +1,109 @@ +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::fmt; + +/// Programming language types +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Language(Cow<'static, str>); + +impl Language { + pub const JAVA: Language = Language(Cow::Borrowed("java")); + pub const KOTLIN: Language = Language(Cow::Borrowed("kotlin")); + pub const RUST: Language = Language(Cow::Borrowed("rust")); + pub const JAVASCRIPT: Language = Language(Cow::Borrowed("javascript")); + pub const TYPESCRIPT: Language = Language(Cow::Borrowed("typescript")); + pub const PYTHON: Language = Language(Cow::Borrowed("python")); + pub const GO: Language = Language(Cow::Borrowed("go")); + pub const BUILDFILE: Language = Language(Cow::Borrowed("buildfile")); + pub const UNKNOWN: Language = Language(Cow::Borrowed("unknown")); + + pub fn new(name: impl Into>) -> Self { + Self(name.into()) + } + + /// Map a file extension to a Language. + /// This is the central logic for language detection from extensions. + pub fn from_extension(ext: &str) -> Option { + match ext.to_lowercase().as_str() { + "java" => Some(Self::JAVA), + "kt" | "kts" => Some(Self::KOTLIN), + "rs" => Some(Self::RUST), + "js" => Some(Self::JAVASCRIPT), + "ts" => Some(Self::TYPESCRIPT), + "py" => Some(Self::PYTHON), + "go" => Some(Self::GO), + "gradle" | "gradle.kts" => Some(Self::new("gradle")), + "pom.xml" => Some(Self::new("maven")), + ext => Some(Self::new(ext.to_string())), + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Language { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From<&str> for Language { + fn from(s: &str) -> Self { + Self::new(s.to_string()) + } +} + +impl From for Language { + fn from(s: String) -> Self { + Self::new(s) + } +} + +impl AsRef for Language { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +/// Build tool types +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct BuildTool(Cow<'static, str>); + +impl BuildTool { + pub const GRADLE: BuildTool = BuildTool(Cow::Borrowed("gradle")); + pub const MAVEN: BuildTool = BuildTool(Cow::Borrowed("maven")); + pub const CARGO: BuildTool = BuildTool(Cow::Borrowed("cargo")); + pub const NPM: BuildTool = BuildTool(Cow::Borrowed("npm")); + pub const POETRY: BuildTool = BuildTool(Cow::Borrowed("poetry")); + pub const BAZEL: BuildTool = BuildTool(Cow::Borrowed("bazel")); + + pub fn new(name: impl Into>) -> Self { + Self(name.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for BuildTool { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From<&str> for BuildTool { + fn from(s: &str) -> Self { + Self::new(s.to_string()) + } +} + +impl AsRef for BuildTool { + fn as_ref(&self) -> &str { + self.as_str() + } +} diff --git a/crates/api/src/models/mod.rs b/crates/api/src/models/mod.rs new file mode 100644 index 0000000..e37c9e1 --- /dev/null +++ b/crates/api/src/models/mod.rs @@ -0,0 +1,8 @@ +pub mod graph; +pub mod language; +pub mod symbol; +pub mod util; + +pub use graph::*; +pub use language::*; +pub use symbol::*; diff --git a/crates/api/src/models/symbol.rs b/crates/api/src/models/symbol.rs new file mode 100644 index 0000000..fb834bd --- /dev/null +++ b/crates/api/src/models/symbol.rs @@ -0,0 +1,183 @@ +use super::graph::{DisplayGraphNode, NodeKind}; +use super::language::Language; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::Arc; +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Symbol(pub lasso::Spur); + +impl JsonSchema for Symbol { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Symbol") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + u32::json_schema(generator) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)] +pub struct Range { + pub start_line: usize, + pub start_col: usize, + pub end_line: usize, + pub end_col: usize, +} + +impl Range { + pub fn contains(&self, line: usize, col: usize) -> bool { + if line < self.start_line || line > self.end_line { + return false; + } + if line == self.start_line && col < self.start_col { + return false; + } + if line == self.end_line && col > self.end_col { + return false; + } + true + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SymbolIntent { + Type, + Method, + Field, + Variable, + Unknown, +} + +pub fn matches_intent(node_kind: &NodeKind, intent: SymbolIntent) -> bool { + match intent { + SymbolIntent::Type => matches!( + node_kind, + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation + ), + SymbolIntent::Method => matches!(node_kind, NodeKind::Method | NodeKind::Constructor), + SymbolIntent::Field => matches!(node_kind, NodeKind::Field), + SymbolIntent::Variable => false, + SymbolIntent::Unknown => true, + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SymbolResolution { + Local(Range, Option), // Range of declaration, and optional type name + Precise(String, SymbolIntent), + Global(String), +} + +// --- New Core API Types --- + +#[derive(Debug, Clone)] +pub struct PositionContext { + pub uri: String, + pub line: u32, + pub char: u32, + pub content: Option, +} + +#[derive(Debug, Clone)] +pub struct SymbolQuery { + pub resolution: SymbolResolution, + pub language: Language, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SymbolLocation { + #[serde(with = "super::util::serde_arc_path")] + #[schemars(with = "String")] + pub path: Arc, + pub range: Range, + /// Range of the identifier/name (for precise navigation) + #[serde(default)] + pub selection_range: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct InternedLocation { + pub path: Symbol, + pub range: Range, + #[serde(default)] + pub selection_range: Option, +} + +impl InternedLocation { + pub fn to_display(&self, rodeo: &dyn lasso::Reader) -> super::graph::DisplaySymbolLocation { + super::graph::DisplaySymbolLocation { + path: rodeo.resolve(&self.path.0).to_string(), + range: self.range, + selection_range: self.selection_range, + } + } +} + +#[derive(Debug, Clone)] +pub struct ReferenceQuery { + pub resolution: SymbolResolution, + pub language: Language, + pub include_declaration: bool, +} + +#[derive(Debug, Clone)] +pub struct CallHierarchyIncomingCall { + pub from: DisplayGraphNode, + pub from_ranges: Vec, +} + +#[derive(Debug, Clone)] +pub struct CallHierarchyOutgoingCall { + pub to: DisplayGraphNode, + pub from_ranges: Vec, +} + +// --- Type System --- + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash, JsonSchema)] +pub enum TypeRef { + /// Unresolved or primitive type name (e.g., "int", "void", "List") + Raw(String), + + /// Resolved reference to a Type definition node (FQN) + Id(String), + + /// Generic instantiation (e.g., List) + Generic { + base: Box, + args: Vec, + }, + + /// Array type (e.g., String[]) + Array { + element: Box, + dimensions: usize, + }, + + /// Wildcard type (e.g., ? extends Number) + Wildcard { + bound: Option>, + is_upper_bound: bool, // true: extends, false: super + }, + + Unknown, +} + +impl TypeRef { + /// Helper to create a Raw type + pub fn raw(s: impl Into) -> Self { + TypeRef::Raw(s.into()) + } + + /// Helper to create an Id type + pub fn id(s: impl Into) -> Self { + TypeRef::Id(s.into()) + } +} + +impl Default for TypeRef { + fn default() -> Self { + TypeRef::Unknown + } +} diff --git a/crates/api/src/models/util.rs b/crates/api/src/models/util.rs new file mode 100644 index 0000000..e8f8862 --- /dev/null +++ b/crates/api/src/models/util.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Deserializer, Serializer}; +use std::path::Path; +use std::sync::Arc; + +pub mod serde_arc_str { + use super::*; + + pub fn serialize(arc: &Arc, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(arc) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Arc::from(s.as_str())) + } +} + +pub mod serde_arc_path { + use super::*; + + pub fn serialize(arc: &Arc, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&arc.to_string_lossy()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Arc::from(Path::new(&s))) + } +} diff --git a/crates/api/src/navigation.rs b/crates/api/src/navigation.rs new file mode 100644 index 0000000..2781720 --- /dev/null +++ b/crates/api/src/navigation.rs @@ -0,0 +1,60 @@ +use async_trait::async_trait; + +/// Result of resolving a user-provided path to a node FQN. +/// This is specific to CLI-style navigation with fuzzy matching and relative paths. +#[derive(Debug, Clone)] +pub enum ResolveResult { + /// Exactly one node found + Found(String), + /// Multiple nodes match the query (ambiguous) + Ambiguous(Vec), + /// No node found + NotFound, +} + +/// Navigation service for CLI-style path resolution. +/// +/// This trait provides fuzzy matching and relative path resolution for interactive shells. +/// For structured graph queries (get children, get parent, etc.), use `GraphService` instead. +/// +/// # Design Rationale +/// - `resolve_path()` supports CLI-specific features like: +/// - Fuzzy matching: "MyClass" finds "com.example.MyClass" +/// - Relative paths: "../sibling" from current context +/// - Special paths: "/", "root" +/// - These features don't fit well into structured `GraphQuery` patterns +/// - Other graph traversal operations should use `GraphService::query()` +#[async_trait] +pub trait NavigationService: Send + Sync { + /// Resolve a user-provided path to a concrete FQN. + /// + /// Supports multiple resolution strategies: + /// - **Absolute FQN**: "com.example.MyClass" → exact match + /// - **Relative path**: "../sibling" from current context + /// - **Fuzzy name**: "MyClass" → searches for matching nodes + /// - **Special paths**: "/" or "root" → project root + /// + /// # Arguments + /// * `target` - The path to resolve (e.g., "MyClass", "../sibling", "/root/package") + /// * `current_context` - Optional current FQN for relative path resolution + /// + /// # Returns + /// * `ResolveResult::Found(fqn)` - Exactly one match found + /// * `ResolveResult::Ambiguous(fqns)` - Multiple matches found (user needs to disambiguate) + /// * `ResolveResult::NotFound` - No matches found + /// + /// # Example + /// ```ignore + /// // Fuzzy match + /// service.resolve_path("MyClass", None).await + /// // => Found("com.example.MyClass") + /// + /// // Relative path + /// service.resolve_path("../OtherClass", Some("com.example.MyClass")).await + /// // => Found("com.example.OtherClass") + /// ``` + async fn resolve_path(&self, target: &str, current_context: Option<&str>) -> ResolveResult; + + /// Get completion candidates for a prefix. + async fn get_completion_candidates(&self, prefix: &str) -> Vec; +} diff --git a/crates/api/src/semantic.rs b/crates/api/src/semantic.rs new file mode 100644 index 0000000..560fc70 --- /dev/null +++ b/crates/api/src/semantic.rs @@ -0,0 +1,93 @@ +use crate::models::{ + CallHierarchyIncomingCall, CallHierarchyOutgoingCall, DisplayGraphNode, PositionContext, + ReferenceQuery, SymbolLocation, SymbolQuery, SymbolResolution, +}; +use async_trait::async_trait; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum SemanticError { + #[error("Symbol not found: {0}")] + SymbolNotFound(String), + #[error("Internal error: {0}")] + Internal(String), + #[error("Language not supported: {0}")] + UnsupportedLanguage(String), +} + +pub type SemanticResult = std::result::Result; + +// ============================================================================ +// Core Semantic Traits - Balanced Granularity +// ============================================================================ + +/// Symbol navigation: resolve, go-to-definition, go-to-type, go-to-implementation. +/// This is the most fundamental semantic capability for code navigation. +#[async_trait] +pub trait SymbolNavigator: Send + Sync { + /// Resolve the symbol at a specific position in the source code. + async fn resolve_symbol_at( + &self, + ctx: &PositionContext, + ) -> SemanticResult>; + + /// Find all definition locations for a given symbol query. + async fn find_definitions(&self, query: &SymbolQuery) -> SemanticResult>; + + /// Find type definition locations (e.g., the class definition of a variable's type). + async fn find_type_definitions( + &self, + query: &SymbolQuery, + ) -> SemanticResult>; + + /// Find all implementation locations (e.g., classes implementing an interface). + async fn find_implementations( + &self, + query: &SymbolQuery, + ) -> SemanticResult>; + + /// Find occurrences of a symbol for document highlighting. + async fn find_highlights( + &self, + ctx: &PositionContext, + ) -> SemanticResult>; +} + +/// Reference analysis: find all usages of a symbol. +#[async_trait] +pub trait ReferenceAnalyzer: Send + Sync { + /// Find all reference locations for a given reference query. + async fn find_references(&self, query: &ReferenceQuery) -> SemanticResult>; +} + +/// Call hierarchy analysis: incoming and outgoing calls. +#[async_trait] +pub trait CallHierarchyAnalyzer: Send + Sync { + /// Find all incoming calls (callers) to the specified function/method. + async fn find_incoming_calls( + &self, + fqn: &str, + ) -> SemanticResult>; + + /// Find all outgoing calls (callees) from the specified function/method. + async fn find_outgoing_calls( + &self, + fqn: &str, + ) -> SemanticResult>; +} + +/// Symbol metadata provider: detailed information about symbols. +#[async_trait] +pub trait SymbolInfoProvider: Send + Sync { + /// Get detailed information about a symbol by its FQN. + async fn get_symbol_info(&self, fqn: &str) -> SemanticResult>; + + /// Get all symbols defined in a specific document. + async fn get_document_symbols(&self, uri: &str) -> SemanticResult>; + + /// Get the language of a specific document. + async fn get_language_for_document( + &self, + uri: &str, + ) -> SemanticResult>; +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..b110111 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "naviscope-cli" +version = "0.5.0" +edition = "2024" + +[[bin]] +name = "naviscope" +path = "src/main.rs" + +[dependencies] +naviscope-lsp = { workspace = true } +naviscope-mcp = { workspace = true } +naviscope-runtime = { workspace = true } +clap = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +reedline = { workspace = true } +dirs = { workspace = true } +nu-ansi-term = { workspace = true } +shlex = { workspace = true } +tabled = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +indexmap = { workspace = true } +petgraph = { workspace = true } +naviscope-api = { workspace = true } diff --git a/crates/cli/src/clear.rs b/crates/cli/src/clear.rs new file mode 100644 index 0000000..56dab2b --- /dev/null +++ b/crates/cli/src/clear.rs @@ -0,0 +1,17 @@ +use std::path::PathBuf; +use tracing::info; + +pub async fn run(path: Option) -> Result<(), Box> { + if let Some(path) = path { + let engine = naviscope_runtime::build_default_engine(path.clone()); + info!("Clearing index for project at: {}...", path.display()); + engine.clear_index().await?; + info!("Project index cleared."); + } else { + // For clearing ALL indices, we use the runtime utility. + info!("Clearing all indices..."); + naviscope_runtime::clear_all_indices()?; + info!("All indices cleared."); + } + Ok(()) +} diff --git a/crates/cli/src/index.rs b/crates/cli/src/index.rs new file mode 100644 index 0000000..c3081d1 --- /dev/null +++ b/crates/cli/src/index.rs @@ -0,0 +1,31 @@ +use std::path::PathBuf; +use tracing::info; + +pub async fn run(path: PathBuf) -> Result<(), Box> { + let engine = naviscope_runtime::build_default_engine(path.clone()); + + info!("Indexing project at: {}...", path.display()); + + // Run async build + engine.rebuild().await?; + + let stats = engine.get_stats().await?; + + info!("Indexing complete!"); + info!("Nodes: {}", stats.node_count); + info!("Edges: {}", stats.edge_count); + + info!("Sample nodes:"); + let query = naviscope_api::models::GraphQuery::Ls { + fqn: None, + kind: vec![], + modifiers: vec![], + }; + if let Ok(res) = engine.query(&query).await { + for node in res.nodes.iter().take(10) { + info!(" - {}", node.id); + } + } + + Ok(()) +} diff --git a/src/cli/mod.rs b/crates/cli/src/lib.rs similarity index 63% rename from src/cli/mod.rs rename to crates/cli/src/lib.rs index ef12626..78aec5e 100644 --- a/src/cli/mod.rs +++ b/crates/cli/src/lib.rs @@ -1,7 +1,7 @@ +mod clear; mod index; mod shell; mod watch; -mod clear; use clap::{Parser, Subcommand}; use std::path::PathBuf; @@ -23,40 +23,40 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { /// Index a project directory into a Code Knowledge Graph - #[command(long_about = "Analyzes the project structure and source code to build a persistent index. \ - By default, the index is stored in ~/.naviscope/indices/.")] + #[command( + long_about = "Analyzes the project structure and source code to build a persistent index. \ + By default, the index is stored in ~/.naviscope/indices/." + )] Index { /// Path to the project root directory to index #[arg(value_name = "PROJECT_PATH")] path: PathBuf, - - /// Save a human-readable JSON version for debugging purposes - #[arg(long)] - debug: bool, }, /// Start an interactive shell to query the code knowledge graph - #[command(long_about = "Starts an interactive shell where you can execute structured queries \ - against the index using both JSON DSL and shorthand commands.")] + #[command( + long_about = "Starts an interactive shell where you can execute structured queries \ + against the index using both JSON DSL and shorthand commands." + )] Shell { /// Path to the project root (used to locate the default index). Defaults to current directory. #[arg(value_name = "PROJECT_PATH")] path: Option, }, /// Watch for file changes and update the index automatically - #[command(long_about = "Starts a file watcher that monitors the project directory for changes. \ - When a change is detected, the index is automatically updated.")] + #[command( + long_about = "Starts a file watcher that monitors the project directory for changes. \ + When a change is detected, the index is automatically updated." + )] Watch { /// Path to the project root directory to watch #[arg(value_name = "PROJECT_PATH")] path: PathBuf, - - /// Save a human-readable JSON version for debugging purposes - #[arg(long)] - debug: bool, }, /// Clear built indices - #[command(long_about = "Removes built index files. If a path is provided, only that project's index \ - is removed. Otherwise, all indices are cleared.")] + #[command( + long_about = "Removes built index files. If a path is provided, only that project's index \ + is removed. Otherwise, all indices are cleared." + )] Clear { /// Path to the project root directory to clear (optional) #[arg(value_name = "PROJECT_PATH")] @@ -81,30 +81,27 @@ pub fn run() -> Result<(), Box> { Commands::Mcp { .. } => "mcp", _ => "cli", }; - let _guard = naviscope::logging::init_logging(component); + let _guard = naviscope_runtime::init_logging(component); + + let rt = tokio::runtime::Runtime::new()?; match cli.command { - Commands::Index { - path, - debug, - } => index::run(path, debug), - Commands::Shell { path } => shell::run(path), - Commands::Watch { path, debug } => watch::run(path, debug), - Commands::Clear { path } => clear::run(path), + Commands::Index { path } => rt.block_on(index::run(path)), + Commands::Shell { path } => rt.block_on(shell::run(path)), + Commands::Watch { path } => rt.block_on(watch::run(path)), + Commands::Clear { path } => rt.block_on(clear::run(path)), Commands::Mcp { path } => { - let rt = tokio::runtime::Runtime::new()?; - let project_path = path.clone().unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); - + let project_path = path + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + // Connect to LSP via proxy mode (waits for LSP if not started) - rt.block_on(async { - naviscope::mcp::proxy::run_mcp_proxy(&project_path).await - })?; + rt.block_on(async { naviscope_mcp::proxy::run_mcp_proxy(&project_path).await })?; Ok(()) } Commands::Lsp => { - let rt = tokio::runtime::Runtime::new()?; rt.block_on(async { - naviscope::lsp::run_server().await + naviscope_lsp::run_server(naviscope_runtime::build_default_engine).await })?; Ok(()) } diff --git a/src/main.rs b/crates/cli/src/main.rs similarity index 69% rename from src/main.rs rename to crates/cli/src/main.rs index 32ad922..0dacad7 100644 --- a/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,5 +1,3 @@ -mod cli; - fn main() -> Result<(), Box> { - cli::run() + naviscope_cli::run() } diff --git a/crates/cli/src/shell/command.rs b/crates/cli/src/shell/command.rs new file mode 100644 index 0000000..f0f9e8c --- /dev/null +++ b/crates/cli/src/shell/command.rs @@ -0,0 +1,305 @@ +use super::view::{ShellNodeView, ShellNodeViewShort, get_kind_weight}; +use clap::{Parser, ValueEnum}; +use naviscope_api::models::{EdgeType, GraphQuery, NodeKind, QueryResult}; +use shlex; +use tabled::{Table, settings::Style}; + +/// Default limit for search results +const DEFAULT_SEARCH_LIMIT: usize = 20; + +#[derive(Clone, Debug, ValueEnum)] +#[clap(rename_all = "lowercase")] +pub enum CliNodeKind { + Class, + Interface, + Enum, + Annotation, + Method, + Constructor, + Field, + Package, + Project, + Module, + Dependency, + Task, + Plugin, + Other, +} + +impl From for NodeKind { + fn from(kind: CliNodeKind) -> Self { + match kind { + CliNodeKind::Class => NodeKind::Class, + CliNodeKind::Interface => NodeKind::Interface, + CliNodeKind::Enum => NodeKind::Enum, + CliNodeKind::Annotation => NodeKind::Annotation, + CliNodeKind::Method => NodeKind::Method, + CliNodeKind::Constructor => NodeKind::Constructor, + CliNodeKind::Field => NodeKind::Field, + CliNodeKind::Package => NodeKind::Package, + CliNodeKind::Project => NodeKind::Project, + CliNodeKind::Module => NodeKind::Module, + CliNodeKind::Dependency => NodeKind::Dependency, + CliNodeKind::Task => NodeKind::Task, + CliNodeKind::Plugin => NodeKind::Plugin, + CliNodeKind::Other => NodeKind::Custom("other".to_string()), + } + } +} + +#[derive(Clone, Debug, ValueEnum)] +pub enum CliEdgeType { + Contains, + InheritsFrom, + Implements, + TypedAs, + DecoratedBy, + UsesDependency, +} + +impl From for EdgeType { + fn from(kind: CliEdgeType) -> Self { + match kind { + CliEdgeType::Contains => EdgeType::Contains, + CliEdgeType::InheritsFrom => EdgeType::InheritsFrom, + CliEdgeType::Implements => EdgeType::Implements, + CliEdgeType::TypedAs => EdgeType::TypedAs, + CliEdgeType::DecoratedBy => EdgeType::DecoratedBy, + CliEdgeType::UsesDependency => EdgeType::UsesDependency, + } + } +} + +/// Helper struct for Clap parsing within the shell +#[derive(Parser, Clone)] +#[command(no_binary_name = true)] +pub enum ShellCommand { + /// List members or structure + Ls { + /// Target node FQN (optional, defaults to current node) + fqn: Option, + /// Filter by node kind (e.g. class, interface, method) + #[arg(long, value_delimiter = ',')] + kind: Vec, + /// Filter by modifiers (e.g. public, static) + #[arg(long, value_delimiter = ',')] + modifiers: Vec, + /// Use long listing format + #[arg(short, long)] + long: bool, + }, + /// Change current node context (internal shell command) + Cd { + /// Target path + path: String, + }, + /// Print current node context + Pwd, + /// Clear the screen + Clear, + /// Search for symbols + Find { + /// Pattern to search for + pattern: String, + /// Filter by node kind + #[arg(long, value_delimiter = ',')] + kind: Vec, + /// Limit number of results + #[arg(long, default_value_t = DEFAULT_SEARCH_LIMIT)] + limit: usize, + }, + /// Inspect node details + Cat { + /// Target node FQN (optional, defaults to current node) or member name + target: String, + }, + /// Find dependencies + Deps { + /// Target node FQN (optional, defaults to current node) + fqn: Option, + /// If set, find incoming dependencies (who depends on me) + #[arg(long)] + rev: bool, + /// Filter by edge types (e.g. TypedAs, InheritsFrom) + #[arg(long, value_delimiter = ',')] + edge_types: Vec, + }, +} + +use clap::error::ErrorKind; + +impl ShellCommand { + /// Automatically generates the list of available command names from the enum. + /// This eliminates the need to manually maintain a hardcoded command list. + pub fn command_names() -> Vec { + use clap::CommandFactory; + let cmd = Self::command(); + let mut names = vec!["help".to_string(), "exit".to_string(), "quit".to_string()]; + names.extend(cmd.get_subcommands().map(|s| s.get_name().to_string())); + names + } +} + +pub fn parse_shell_command( + input: &str, +) -> Result, Box> { + // Use shlex to split arguments while respecting quotes + let args = shlex::split(input).ok_or("Invalid quoting")?; + + // Parse using Clap + match ShellCommand::try_parse_from(args) { + Ok(c) => Ok(Some(c)), + Err(e) => { + // Handle help/version display without returning an error + if e.kind() == ErrorKind::DisplayHelp || e.kind() == ErrorKind::DisplayVersion { + println!("{}", e); + return Ok(None); + } + Err(Box::new(e)) + } + } +} + +impl ShellCommand { + pub fn to_graph_query( + &self, + current_node: &Option, + ) -> Result> { + match self { + ShellCommand::Ls { + fqn, + kind, + modifiers, + .. + } => { + let target_fqn = fqn.clone().or_else(|| current_node.clone()); + Ok(GraphQuery::Ls { + fqn: target_fqn, + kind: kind.iter().map(|k| k.clone().into()).collect(), + modifiers: modifiers.clone(), + }) + } + ShellCommand::Find { + pattern, + kind, + limit, + } => Ok(GraphQuery::Find { + pattern: pattern.clone(), + kind: kind.iter().map(|k| k.clone().into()).collect(), + limit: *limit, + }), + ShellCommand::Cat { target } => Ok(GraphQuery::Cat { + fqn: target.clone(), + }), + ShellCommand::Deps { + fqn, + rev, + edge_types, + } => { + let target_fqn = fqn + .clone() + .or_else(|| current_node.clone()) + .ok_or("No FQN provided and no current context")?; + Ok(GraphQuery::Deps { + fqn: target_fqn, + rev: *rev, + edge_types: edge_types.iter().map(|e| e.clone().into()).collect(), + }) + } + ShellCommand::Cd { .. } | ShellCommand::Pwd | ShellCommand::Clear => { + Err("Internal shell command should be handled by ReplServer".into()) + } + } + } + + pub fn render( + &self, + result: QueryResult, + _context: &super::context::ShellContext, + ) -> Result> { + if result.nodes.is_empty() { + return Ok("NO RECORDS FOUND".to_string()); + } + + match self { + ShellCommand::Ls { long: false, .. } => { + let mut views: Vec = result + .nodes + .iter() + .map(|node| ShellNodeViewShort { + kind: node.kind.to_string(), + name: if is_container(node.kind.clone()) { + format!("{}/", node.name) + } else { + node.name.to_string() + }, + }) + .collect(); + + views.sort_by(|a, b| { + let wa = get_kind_weight(&a.kind); + let wb = get_kind_weight(&b.kind); + if wa != wb { + wa.cmp(&wb) + } else { + a.name.cmp(&b.name) + } + }); + + Ok(Table::new(&views).with(Style::psql()).to_string()) + } + ShellCommand::Cat { .. } if result.nodes.len() == 1 => { + Ok(serde_json::to_string_pretty(&result.nodes[0])?) + } + _ => { + // Default detailed table view for Find, Deps, and Ls -l + let mut views: Vec = result + .nodes + .iter() + .map(|node| { + let relation = result + .edges + .iter() + .filter(|e| e.to.as_ref() == node.id || e.from.as_ref() == node.id) + .map(|e| format!("{:?}", e.data.edge_type)) + .collect::>() + .join(", "); + + ShellNodeView::from_node( + node, + if relation.is_empty() { + None + } else { + Some(relation) + }, + ) + }) + .collect(); + + views.sort_by(|a, b| { + let wa = get_kind_weight(&a.kind); + let wb = get_kind_weight(&b.kind); + if wa != wb { + wa.cmp(&wb) + } else { + a.name.cmp(&b.name) + } + }); + + Ok(Table::new(&views).with(Style::psql()).to_string()) + } + } + } +} + +fn is_container(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Class + | NodeKind::Interface + | NodeKind::Enum + | NodeKind::Annotation + | NodeKind::Module + | NodeKind::Package + ) +} diff --git a/crates/cli/src/shell/completer.rs b/crates/cli/src/shell/completer.rs new file mode 100644 index 0000000..fe520c6 --- /dev/null +++ b/crates/cli/src/shell/completer.rs @@ -0,0 +1,152 @@ +use super::context::ShellContext; +use naviscope_api::models::GraphQuery; +use reedline::{Completer, Suggestion}; + +pub struct NaviscopeCompleter<'a> { + pub commands: Vec, + pub context: ShellContext, + pub _marker: std::marker::PhantomData<&'a ()>, +} + +impl<'a> NaviscopeCompleter<'a> { + pub fn new(commands: Vec, context: ShellContext) -> Self { + Self { + commands, + context, + _marker: std::marker::PhantomData, + } + } +} + +impl<'a> Completer for NaviscopeCompleter<'a> { + fn complete(&mut self, line: &str, pos: usize) -> Vec { + let trimmed = line.trim_start(); + + // 1. Command completion (at start of line) + if !trimmed.contains(' ') { + return self + .commands + .iter() + .filter(|cmd| cmd.starts_with(trimmed)) + .map(|cmd| Suggestion { + value: cmd.clone(), + description: None, + style: None, + extra: None, + span: reedline::Span { + start: pos - trimmed.len(), + end: pos, + }, + append_whitespace: true, + match_indices: None, + }) + .collect(); + } + + // 2. Argument completion (for cd, ls, cat, deps) + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() >= 1 { + let cmd = parts[0]; + if matches!(cmd, "cd" | "ls" | "cat" | "deps") { + // Determine the partial FQN being typed + let last_word = if line.ends_with(' ') { + "" + } else { + parts.last().unwrap_or(&"") + }; + let span_start = pos - last_word.len(); + + // Get current context + let parent_fqn = self.context.current_fqn(); + let mut suggestions = Vec::new(); + + if last_word.contains('.') + || last_word.contains("::") + || (parent_fqn.is_none() && !last_word.is_empty()) + { + // Find potential FQNs starting with last_word from the API NavigationService + use naviscope_api::navigation::NavigationService; + let nav_service: &dyn NavigationService = self.context.engine.as_ref(); + + let matches = if tokio::runtime::Handle::try_current().is_ok() { + tokio::task::block_in_place(|| { + self.context + .rt_handle + .block_on(nav_service.get_completion_candidates(last_word)) + }) + } else { + self.context + .rt_handle + .block_on(nav_service.get_completion_candidates(last_word)) + }; + + for fqn in matches { + suggestions.push(Suggestion { + value: fqn, + description: None, + style: None, + extra: None, + span: reedline::Span { + start: span_start, + end: pos, + }, + append_whitespace: true, + match_indices: None, + }); + } + } + + // Case B: Relative completion from current context (or root) + let query = GraphQuery::Ls { + fqn: parent_fqn.clone(), + kind: vec![], + modifiers: vec![], + }; + + if let Ok(result) = self.context.execute_query(&query) { + for node in result.nodes { + let name = &node.name; + if name.starts_with(last_word) { + // De-duplicate if already added by Case A + if suggestions.iter().any(|s| s.value == *name) { + continue; + } + + suggestions.push(Suggestion { + value: name.to_string(), + description: Some(node.kind.to_string()), + style: None, + extra: None, + span: reedline::Span { + start: span_start, + end: pos, + }, + append_whitespace: true, + match_indices: None, + }); + } + } + } + + // Sort suggestions: Relative first (shorter names that aren't FQNs usually) + // Then by length + suggestions.sort_by(|a, b| { + let a_is_fqn = a.value.contains('.') || a.value.contains("::"); + let b_is_fqn = b.value.contains('.') || b.value.contains("::"); + if a_is_fqn != b_is_fqn { + a_is_fqn.cmp(&b_is_fqn) // Non-FQN first + } else { + a.value.len().cmp(&b.value.len()) + } + }); + + // Final limit to total suggestions to keep UI clean + suggestions.truncate(50); + + return suggestions; + } + } + + vec![] + } +} diff --git a/crates/cli/src/shell/context.rs b/crates/cli/src/shell/context.rs new file mode 100644 index 0000000..1e9d52a --- /dev/null +++ b/crates/cli/src/shell/context.rs @@ -0,0 +1,67 @@ +use naviscope_api::NaviscopeEngine; +use naviscope_api::graph::GraphService; +use naviscope_api::models::{GraphQuery, QueryResult}; +use naviscope_api::navigation::NavigationService; +use std::sync::{Arc, RwLock}; + +#[derive(Clone)] +pub struct ShellContext { + pub engine: Arc, + pub rt_handle: tokio::runtime::Handle, + pub current_node: Arc>>, +} + +// Re-export ResolveResult from API +pub use naviscope_api::navigation::ResolveResult; + +impl ShellContext { + pub fn new( + engine: Arc, + rt_handle: tokio::runtime::Handle, + current_node: Arc>>, + ) -> Self { + Self { + engine, + rt_handle, + current_node, + } + } + + pub fn current_fqn(&self) -> Option { + self.current_node.read().unwrap().clone() + } + + pub fn set_current_fqn(&self, fqn: Option) { + *self.current_node.write().unwrap() = fqn; + } + + /// Helper to execute query synchronously using the API GraphService + pub fn execute_query( + &self, + query: &GraphQuery, + ) -> Result> { + let service: &dyn GraphService = self.engine.as_ref(); + let result = if tokio::runtime::Handle::try_current().is_ok() { + tokio::task::block_in_place(|| self.rt_handle.block_on(service.query(query))) + } else { + self.rt_handle.block_on(service.query(query)) + }; + Ok(result?) + } + + /// Resolves a user input path using the NavigationService API. + pub fn resolve_node(&self, target: &str) -> ResolveResult { + let nav_service: &dyn NavigationService = self.engine.as_ref(); + let current_context = self.current_fqn(); + + if tokio::runtime::Handle::try_current().is_ok() { + tokio::task::block_in_place(|| { + self.rt_handle + .block_on(nav_service.resolve_path(target, current_context.as_deref())) + }) + } else { + self.rt_handle + .block_on(nav_service.resolve_path(target, current_context.as_deref())) + } + } +} diff --git a/crates/cli/src/shell/handlers.rs b/crates/cli/src/shell/handlers.rs new file mode 100644 index 0000000..edb3ec4 --- /dev/null +++ b/crates/cli/src/shell/handlers.rs @@ -0,0 +1,188 @@ +use super::command::ShellCommand; +use super::context::{ResolveResult, ShellContext}; +use naviscope_api::models::GraphQuery; + +pub trait CommandHandler { + fn handle( + &self, + cmd: &ShellCommand, + context: &mut ShellContext, + ) -> Result>; +} + +pub struct CdHandler; +impl CommandHandler for CdHandler { + fn handle( + &self, + cmd: &ShellCommand, + context: &mut ShellContext, + ) -> Result> { + if let ShellCommand::Cd { path } = cmd { + match context.resolve_node(path) { + ResolveResult::Found(fqn) => { + let new_curr = if fqn.is_empty() { None } else { Some(fqn) }; + context.set_current_fqn(new_curr); + Ok(String::new()) + } + ResolveResult::Ambiguous(candidates) => { + let mut msg = format!("Ambiguous path '{}'. Candidates:\n", path); + for c in candidates.iter().take(10) { + msg.push_str(&format!(" - {}\n", c)); + } + Err(msg.into()) + } + ResolveResult::NotFound => Err(format!("Node '{}' not found.", path).into()), + } + } else { + Ok(String::new()) + } + } +} + +pub struct CatHandler; +impl CommandHandler for CatHandler { + fn handle( + &self, + cmd: &ShellCommand, + context: &mut ShellContext, + ) -> Result> { + if let ShellCommand::Cat { target } = cmd { + // First resolve the target to a concrete FQN + let fqn = match context.resolve_node(target) { + ResolveResult::Found(f) => f, + ResolveResult::Ambiguous(candidates) => { + let mut msg = + format!("Ambiguous match for '{}'. Available options:\n\n", target); + for c in candidates { + msg.push_str(&format!(" - {}\n", c)); + } + msg.push_str("\nPlease specify the full name."); + return Ok(msg); + } + ResolveResult::NotFound => { + return Ok(format!( + "Error: Target '{}' not found in current context.", + target + )); + } + }; + + if fqn.is_empty() { + return Err("Cannot cat root.".into()); + } + + let query = GraphQuery::Cat { fqn }; + let result = context.execute_query(&query)?; + + cmd.render(result, context) + } else { + Ok(String::new()) + } + } +} + +pub struct GenericQueryHandler; +impl CommandHandler for GenericQueryHandler { + fn handle( + &self, + cmd: &ShellCommand, + context: &mut ShellContext, + ) -> Result> { + let current_node = context.current_fqn(); + + // Resolve argument FQN if present + let mut resolved_target_fqn = None; + let resolved_cmd = match cmd { + ShellCommand::Ls { + fqn: Some(target), + kind, + modifiers, + long, + } => { + resolved_target_fqn = match context.resolve_node(target) { + ResolveResult::Found(f) => Some(f), + _ => Some(target.clone()), + }; + ShellCommand::Ls { + fqn: resolved_target_fqn.clone(), + kind: kind.clone(), + modifiers: modifiers.clone(), + long: *long, + } + } + ShellCommand::Deps { + fqn: Some(target), + rev, + edge_types, + } => { + resolved_target_fqn = match context.resolve_node(target) { + ResolveResult::Found(f) => Some(f), + _ => Some(target.clone()), + }; + ShellCommand::Deps { + fqn: resolved_target_fqn.clone(), + rev: *rev, + edge_types: edge_types.clone(), + } + } + _ => cmd.clone(), + }; + + let query = resolved_cmd.to_graph_query(¤t_node)?; + let result = context.execute_query(&query)?; + + if result.nodes.is_empty() { + if let Some(target) = resolved_target_fqn { + // Check if node itself exists in the graph + let check_query = naviscope_api::models::GraphQuery::Cat { + fqn: target.clone(), + }; + if let Ok(res) = context.execute_query(&check_query) { + if !res.nodes.is_empty() { + return Ok(format!( + "Node '{}' exists but has no children/relationships matching your criteria.", + target + )); + } + } + } + return Ok("NO RECORDS FOUND".to_string()); + } + + resolved_cmd.render(result, context) + } +} + +pub struct PwdHandler; +impl CommandHandler for PwdHandler { + fn handle( + &self, + _cmd: &ShellCommand, + context: &mut ShellContext, + ) -> Result> { + Ok(context.current_fqn().unwrap_or("/".to_string())) + } +} + +pub struct ClearHandler; +impl CommandHandler for ClearHandler { + fn handle( + &self, + _cmd: &ShellCommand, + _context: &mut ShellContext, + ) -> Result> { + // Clear is handled by the Reedline loop mostly, but we can return a marker if needed. + // For now, simple print or empty string. The loop handles `line_editor.clear_screen()`. + Ok(String::new()) + } +} + +pub fn get_handler(cmd: &ShellCommand) -> Box { + match cmd { + ShellCommand::Cd { .. } => Box::new(CdHandler), + ShellCommand::Cat { .. } => Box::new(CatHandler), + ShellCommand::Pwd => Box::new(PwdHandler), + ShellCommand::Clear => Box::new(ClearHandler), + _ => Box::new(GenericQueryHandler), + } +} diff --git a/crates/cli/src/shell/highlighter.rs b/crates/cli/src/shell/highlighter.rs new file mode 100644 index 0000000..496d663 --- /dev/null +++ b/crates/cli/src/shell/highlighter.rs @@ -0,0 +1,60 @@ +use nu_ansi_term::{Color, Style}; +use reedline::{Highlighter, StyledText}; + +pub struct NaviscopeHighlighter { + commands: Vec, +} + +impl NaviscopeHighlighter { + pub fn new(commands: Vec) -> Self { + Self { commands } + } +} + +impl Highlighter for NaviscopeHighlighter { + fn highlight(&self, line: &str, _cursor: usize) -> StyledText { + let mut styled_text = StyledText::new(); + // If the line ends with whitespace, we need to handle that to keep the highlighting accurate + // But for simple word-based highlighting, we can just iterate over splits and gaps. + + let mut current_pos = 0; + let words = line.split_inclusive(char::is_whitespace); + + for word in words { + let trimmed = word.trim(); + if trimmed.is_empty() { + styled_text.push((Style::new(), word.to_string())); + current_pos += word.len(); + continue; + } + + let style = if current_pos == 0 || self.is_at_start_of_command(line, current_pos) { + if self.commands.contains(&trimmed.to_string()) { + Style::new().fg(Color::LightGreen).bold() + } else { + Style::new() + } + } else if trimmed.starts_with('-') { + Style::new().fg(Color::Cyan) + } else if trimmed.contains('.') || trimmed.contains("::") || trimmed.contains('/') { + Style::new().fg(Color::Yellow) + } else { + Style::new() + }; + + // Calculate trailing whitespace + let word_to_push = word.to_string(); + styled_text.push((style, word_to_push)); + current_pos += word.len(); + } + + styled_text + } +} + +impl NaviscopeHighlighter { + fn is_at_start_of_command(&self, line: &str, pos: usize) -> bool { + let prefix = &line[..pos]; + prefix.trim().is_empty() + } +} diff --git a/src/cli/shell/mod.rs b/crates/cli/src/shell/mod.rs similarity index 50% rename from src/cli/shell/mod.rs rename to crates/cli/src/shell/mod.rs index 18f453e..1ae18d0 100644 --- a/src/cli/shell/mod.rs +++ b/crates/cli/src/shell/mod.rs @@ -2,26 +2,27 @@ mod command; mod completer; mod context; mod handlers; +mod highlighter; mod prompt; mod view; -use naviscope::index::Naviscope; -use naviscope::project::watcher::Watcher; use reedline::{ - default_emacs_keybindings, ColumnarMenu, DefaultHinter, Emacs, FileBackedHistory, KeyCode, - KeyModifiers, MenuBuilder, Reedline, ReedlineEvent, ReedlineMenu, Signal, + ColumnarMenu, DefaultHinter, Emacs, FileBackedHistory, KeyCode, KeyModifiers, MenuBuilder, + Reedline, ReedlineEvent, ReedlineMenu, Signal, default_emacs_keybindings, }; use std::path::PathBuf; use std::sync::{Arc, RwLock}; -use std::thread; -use std::time::Duration; use tracing::{error, info}; +use self::command::{ShellCommand, parse_shell_command}; use self::completer::NaviscopeCompleter; use self::context::ShellContext; -use self::command::{ShellCommand, parse_shell_command}; +use self::highlighter::NaviscopeHighlighter; use self::prompt::DefaultPrompt; +// Shell configuration constants +const SHELL_HISTORY_SIZE: usize = 500; + pub struct ReplServer { context: ShellContext, project_path: PathBuf, @@ -29,120 +30,103 @@ pub struct ReplServer { impl ReplServer { pub fn new(project_path: PathBuf) -> Self { - let engine = Naviscope::new(project_path.clone()); - let naviscope = Arc::new(RwLock::new(engine)); + let engine = naviscope_runtime::build_default_engine(project_path.clone()); let current_node = Arc::new(RwLock::new(None)); - let context = ShellContext::new(naviscope, current_node); - + + // ShellContext will get resolver from engine + let context = ShellContext::new(engine, tokio::runtime::Handle::current(), current_node); + Self { context, project_path, } } - pub fn run(&self) -> Result<(), Box> { + pub async fn run(&self) -> Result<(), Box> { println!("Project: {:?}", self.project_path); - - self.initialize_index()?; - self.start_watcher(); - + + self.initialize_index().await?; + + // Start watcher (spawns background task on the runtime) + if let Err(e) = self.context.engine.watch().await { + error!("Failed to start file watcher: {}", e); + } else { + info!("File watcher started."); + } + println!("Type 'help' for commands."); - + let line_editor = self.setup_line_editor()?; self.run_loop(line_editor) } - fn initialize_index(&self) -> Result<(), Box> { - let mut engine = self.context.naviscope.write().unwrap(); + async fn initialize_index(&self) -> Result<(), Box> { + let engine = &self.context.engine; let start = std::time::Instant::now(); - - // Try to load existing index - match engine.load() { + + // Load index (blocking on async) + match engine.load().await { Ok(true) => { - let index = engine.graph(); + let stats = engine.get_stats().await.unwrap_or_default(); println!( "Index loaded from disk in {:?}. Nodes: {}, Edges: {}", start.elapsed(), - index.topology.node_count(), - index.topology.edge_count() + stats.node_count, + stats.edge_count ); } Ok(false) => { - println!("No existing index found. Building fresh index..."); + println!("No existing index found or it was stale. Rebuilding..."); + // If load returns false, we should verify/rebuild. + // But refresh() below will handle it anyway. } Err(e) => { error!("Failed to load index: {}", e); - println!("Failed to load index: {}. Starting fresh scan...", e); + // Continue to rebuild } } - // Sync with filesystem (refresh) synchronously before starting the shell + // Sync with filesystem (rebuild/refresh) let sync_start = std::time::Instant::now(); - if let Err(e) = engine.refresh() { + if let Err(e) = engine.refresh().await { error!("Synchronization failed: {}", e); println!("Warning: Index synchronization failed: {}", e); } else { - let index = engine.graph(); - println!("Index synchronized in {:?}. Total nodes: {}", sync_start.elapsed(), index.topology.node_count()); - } - Ok(()) - } - - fn start_watcher(&self) { - let naviscope_clone = self.context.naviscope.clone(); - let path_clone = self.project_path.clone(); - - thread::spawn(move || { - let mut watcher = match Watcher::new(&path_clone) { - Ok(w) => w, - Err(e) => { - error!("Failed to start watcher: {}", e); - return; - } + let stats = engine.get_stats().await.unwrap_or_default(); + println!( + "Index synchronized in {:?}. Total nodes: {}", + sync_start.elapsed(), + stats.node_count + ); + + // Auto-set context to Project node if it exists + + let query = naviscope_api::models::GraphQuery::Ls { + fqn: None, + kind: vec![naviscope_api::models::NodeKind::Project], + modifiers: vec![], }; - loop { - if let Some(event) = watcher.next_event() { - if !event.paths.iter().any(|p| naviscope::project::is_relevant_path(p)) { - continue; - } - - thread::sleep(Duration::from_millis(500)); - while watcher.try_next_event().is_some() {} - - info!("Change detected. Re-indexing..."); - - match naviscope_clone.write() { - Ok(mut engine) => { - if let Err(e) = engine.refresh() { - error!("Error during re-indexing: {}", e); - } else { - let index = engine.graph(); - info!( - "Indexing complete! Nodes: {}, Edges: {}", - index.topology.node_count(), - index.topology.edge_count() - ); - } - } - Err(e) => error!("Failed to acquire lock for re-indexing: {}", e), - } + if let Ok(res) = engine.query(&query).await { + if res.nodes.len() == 1 { + let fqn = res.nodes[0].id.to_string(); + self.context.set_current_fqn(Some(fqn)); } } - }); + } + Ok(()) } + // Manual start_watcher removed - handled by EngineHandle::watch() + fn setup_line_editor(&self) -> Result> { - let commands = vec![ - "help".into(), "exit".into(), "quit".into(), "ls".into(), "cd".into(), - "pwd".into(), "clear".into(), "grep".into(), "cat".into(), "deps".into(), - ]; - + let commands = ShellCommand::command_names(); + let completer = Box::new(NaviscopeCompleter::new( - commands, + commands.clone(), self.context.clone(), )); - + let completion_menu = Box::new(ColumnarMenu::default().with_name("completion_menu")); let mut keybindings = default_emacs_keybindings(); @@ -166,15 +150,25 @@ impl ReplServer { .unwrap(); let history = Box::new( - FileBackedHistory::with_file(500, history_file.clone()) - .unwrap_or_else(|_| FileBackedHistory::new(500).expect("Failed to create history")), + FileBackedHistory::with_file(SHELL_HISTORY_SIZE, history_file.clone()).unwrap_or_else( + |_| FileBackedHistory::new(SHELL_HISTORY_SIZE).expect("Failed to create history"), + ), ); + let highlighter = Box::new(NaviscopeHighlighter::new(commands)); + Ok(Reedline::create() .with_history(history) .with_completer(completer) + .with_highlighter(highlighter) .with_menu(ReedlineMenu::EngineCompleter(completion_menu)) - .with_hinter(Box::new(DefaultHinter::default().with_style(nu_ansi_term::Style::new().italic().fg(nu_ansi_term::Color::LightGray)))) + .with_hinter(Box::new( + DefaultHinter::default().with_style( + nu_ansi_term::Style::new() + .italic() + .fg(nu_ansi_term::Color::LightGray), + ), + )) .with_edit_mode(Box::new(Emacs::new(keybindings)))) } @@ -185,12 +179,16 @@ impl ReplServer { let curr = context.current_fqn(); let prompt = DefaultPrompt::new(curr.clone()); let sig = line_editor.read_line(&prompt); - + match sig { Ok(Signal::Success(buffer)) => { let trimmed = buffer.trim(); - if trimmed.is_empty() { continue; } - if trimmed == "exit" || trimmed == "quit" { break; } + if trimmed.is_empty() { + continue; + } + if trimmed == "exit" || trimmed == "quit" { + break; + } match parse_shell_command(trimmed) { Ok(Some(cmd)) => { @@ -198,15 +196,17 @@ impl ReplServer { match handler.handle(&cmd, &mut context) { Ok(output) => { - if !output.is_empty() { println!("{}", output); } + if !output.is_empty() { + println!("{}", output); + } if matches!(cmd, ShellCommand::Clear) { let _ = line_editor.clear_screen(); } - }, + } Err(e) => eprintln!("Error: {}", e), } - }, - Ok(None) => {}, // Help or handled by Clap + } + Ok(None) => {} // Help or handled by Clap Err(e) => eprintln!("Error: {}", e), } } @@ -221,8 +221,9 @@ impl ReplServer { } } -pub fn run(path: Option) -> Result<(), Box> { - let project_path = path.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); +pub async fn run(path: Option) -> Result<(), Box> { + let project_path = + path.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); let server = ReplServer::new(project_path); - server.run() + server.run().await } diff --git a/src/cli/shell/prompt.rs b/crates/cli/src/shell/prompt.rs similarity index 63% rename from src/cli/shell/prompt.rs rename to crates/cli/src/shell/prompt.rs index d544627..f37ca04 100644 --- a/src/cli/shell/prompt.rs +++ b/crates/cli/src/shell/prompt.rs @@ -1,3 +1,4 @@ +use nu_ansi_term::Color; use reedline::{Prompt, PromptEditMode, PromptHistorySearch}; use std::borrow::Cow; @@ -13,16 +14,21 @@ impl DefaultPrompt { impl Prompt for DefaultPrompt { fn render_prompt_left(&self) -> Cow<'_, str> { + let prefix = Color::LightBlue.bold().paint("naviscope"); match &self.current_node { Some(node) => { - let display_node = if node.len() > 40 { + let display_node = if node.len() > 30 { shorten_fqn(node) } else { node.clone() }; - Cow::Owned(format!("naviscope {} > ", display_node)) - }, - None => Cow::Borrowed("naviscope / > "), + let path = Color::Yellow.paint(display_node); + Cow::Owned(format!("{} {} > ", prefix, path)) + } + None => { + let path = Color::Yellow.paint("/"); + Cow::Owned(format!("{} {} > ", prefix, path)) + } } } @@ -38,13 +44,17 @@ impl Prompt for DefaultPrompt { Cow::Borrowed(".. ") } - fn render_prompt_history_search_indicator(&self, _history_search: PromptHistorySearch) -> Cow<'_, str> { + fn render_prompt_history_search_indicator( + &self, + _history_search: PromptHistorySearch, + ) -> Cow<'_, str> { Cow::Borrowed("(search) ") } } fn shorten_fqn(fqn: &str) -> String { - let parts: Vec<&str> = fqn.split('.').collect(); + let separator = if fqn.contains("::") { "::" } else { "." }; + let parts: Vec<&str> = fqn.split(separator).collect(); if parts.len() <= 2 { return fqn.to_string(); } @@ -55,12 +65,12 @@ fn shorten_fqn(fqn: &str) -> String { if i < parts.len() - 2 { if let Some(c) = part.chars().next() { result.push(c); - result.push('.'); + result.push_str(separator); } } else { result.push_str(part); if i < parts.len() - 1 { - result.push('.'); + result.push_str(separator); } } } diff --git a/crates/cli/src/shell/view.rs b/crates/cli/src/shell/view.rs new file mode 100644 index 0000000..3380ccd --- /dev/null +++ b/crates/cli/src/shell/view.rs @@ -0,0 +1,110 @@ +use naviscope_api::models::{DisplayGraphNode, NodeKind}; +use tabled::Tabled; + +/// A terminal-optimized view of a GraphNode (Detailed) +#[derive(Tabled)] +pub struct ShellNodeView { + pub kind: String, + pub name: String, + pub relation: String, + pub signature: String, + pub location: String, + pub fqn: String, +} + +/// A short view of a GraphNode +#[derive(Tabled)] +pub struct ShellNodeViewShort { + pub kind: String, + pub name: String, +} + +impl ShellNodeView { + pub fn from_node( + node: &DisplayGraphNode, + relation: Option, + ) -> Self { + let location = node + .location + .as_ref() + .map(|loc| { + // DisplaySymbolLocation has String path + let path = std::path::Path::new(&loc.path); + let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("-"); + format!("{}:{}", filename, loc.range.start_line + 1) + }) + .unwrap_or_else(|| "-".to_string()); + + let is_container = matches!( + node.kind, + NodeKind::Project + | NodeKind::Module + | NodeKind::Package + | NodeKind::Class + | NodeKind::Interface + | NodeKind::Enum + | NodeKind::Annotation + ); + + let name = if is_container { + format!("{}/", node.name) + } else { + node.name.clone() + }; + + // Use pre-filled signature in DisplayGraphNode + let signature = node.signature.clone().unwrap_or_else(|| { + // Fallback for nodes without specific signature + match node.kind { + NodeKind::Project => "Project".to_string(), + _ => "-".to_string(), + } + }); + + Self { + fqn: shorten_fqn(&node.id), + name, + kind: node.kind.to_string(), + relation: relation.unwrap_or_else(|| "-".to_string()), + signature, + location, + } + } +} + +pub fn shorten_fqn(fqn: &str) -> String { + let separator = if fqn.contains("::") { "::" } else { "." }; + let parts: Vec<&str> = fqn.split(separator).collect(); + if parts.len() <= 2 { + return fqn.to_string(); + } + let mut result = String::new(); + for (i, part) in parts.iter().enumerate() { + if i < parts.len() - 2 { + if let Some(c) = part.chars().next() { + result.push(c); + result.push_str(separator); + } + } else { + result.push_str(part); + if i < parts.len() - 1 { + result.push_str(separator); + } + } + } + result +} + +pub fn get_kind_weight(kind: &str) -> i32 { + match kind.to_lowercase().as_str() { + "package" => 1, + "class" => 2, + "interface" => 3, + "enum" => 4, + "annotation" => 5, + "constructor" => 6, + "method" => 7, + "field" => 8, + _ => 99, + } +} diff --git a/crates/cli/src/watch.rs b/crates/cli/src/watch.rs new file mode 100644 index 0000000..cec0a3c --- /dev/null +++ b/crates/cli/src/watch.rs @@ -0,0 +1,21 @@ +use std::path::PathBuf; +use tracing::info; + +pub async fn run(path: PathBuf) -> Result<(), Box> { + let engine = naviscope_runtime::build_default_engine(path.clone()); + + info!("Initializing: Indexing project at: {}...", path.display()); + engine.rebuild().await?; + info!("Initial indexing complete."); + + // Start background watcher via trait + engine.watch().await?; + info!("File watcher started. Ready for changes."); + info!("Press Ctrl+C to stop."); + + // Keep the main thread alive + tokio::signal::ctrl_c().await?; + info!("Watcher stopped."); + + Ok(()) +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..b274de7 --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "naviscope-core" +version = "0.5.0" +edition = "2024" + +[dependencies] +petgraph = { workspace = true } +tree-sitter = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +walkdir = { workspace = true } +log = { workspace = true } +ignore = { workspace = true } +rayon = { workspace = true } +notify = { workspace = true } +xxhash-rust = { workspace = true } +regex = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +schemars = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-appender = { workspace = true } +rmp-serde = { workspace = true } +once_cell = { workspace = true } +lsp-types = { workspace = true } +lasso = { workspace = true } +zstd = { workspace = true } +naviscope-api = { workspace = true } +async-trait = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +tree-sitter-java = { workspace = true } diff --git a/src/error.rs b/crates/core/src/error.rs similarity index 87% rename from src/error.rs rename to crates/core/src/error.rs index a288bcc..3372391 100644 --- a/src/error.rs +++ b/crates/core/src/error.rs @@ -8,6 +8,8 @@ pub enum NaviscopeError { Json(#[from] serde_json::Error), #[error("Parsing error: {0}")] Parsing(String), + #[error("Internal error: {0}")] + Internal(String), #[error("Unknown error")] Unknown, } diff --git a/crates/core/src/facade/graph.rs b/crates/core/src/facade/graph.rs new file mode 100644 index 0000000..214f0f7 --- /dev/null +++ b/crates/core/src/facade/graph.rs @@ -0,0 +1,45 @@ +use super::EngineHandle; +use crate::error::NaviscopeError; +use crate::features::query::QueryEngine; +use async_trait::async_trait; +use naviscope_api::{graph, models}; + +#[async_trait] +impl graph::GraphService for EngineHandle { + async fn query(&self, query: &models::GraphQuery) -> graph::Result { + let graph = self.graph().await; + let query_clone = query.clone(); + let handle = self.clone(); + + let result = tokio::task::spawn_blocking( + move || -> Result { + let engine = QueryEngine::new(&graph, |lang| handle.get_node_renderer(lang)); + engine.execute(&query_clone) + }, + ) + .await + .map_err(|e| graph::GraphError::Internal(e.to_string()))? + .map_err(|e| graph::GraphError::Internal(e.to_string()))?; + + Ok(models::QueryResult { + nodes: result.nodes, + edges: result.edges, + }) + } + + async fn get_stats(&self) -> graph::Result { + let graph = self.graph().await; + Ok(graph::GraphStats { + node_count: graph.topology().node_count(), + edge_count: graph.topology().edge_count(), + }) + } + + async fn get_node_display(&self, fqn: &str) -> graph::Result> { + let query = models::GraphQuery::Cat { + fqn: fqn.to_string(), + }; + let result = self.query(&query).await?; + Ok(result.nodes.into_iter().next()) + } +} diff --git a/crates/core/src/facade/lifecycle.rs b/crates/core/src/facade/lifecycle.rs new file mode 100644 index 0000000..0246e5d --- /dev/null +++ b/crates/core/src/facade/lifecycle.rs @@ -0,0 +1,50 @@ +use super::EngineHandle; +use crate::error::NaviscopeError; +use async_trait::async_trait; +use naviscope_api::lifecycle::{EngineError, EngineLifecycle}; + +#[async_trait] +impl EngineLifecycle for EngineHandle { + async fn rebuild(&self) -> naviscope_api::lifecycle::EngineResult<()> { + self.engine + .rebuild() + .await + .map_err(|e| EngineError::Internal(e.to_string())) + } + + async fn load(&self) -> naviscope_api::lifecycle::EngineResult { + self.engine + .load() + .await + .map_err(|e| EngineError::Internal(e.to_string())) + } + + async fn save(&self) -> naviscope_api::lifecycle::EngineResult<()> { + self.engine + .save() + .await + .map_err(|e| EngineError::Internal(e.to_string())) + } + + async fn refresh(&self) -> naviscope_api::lifecycle::EngineResult<()> { + self.engine + .refresh() + .await + .map_err(|e| EngineError::Internal(e.to_string())) + } + + async fn watch(&self) -> naviscope_api::lifecycle::EngineResult<()> { + self.engine + .clone() + .watch() + .await + .map_err(|e: NaviscopeError| EngineError::Internal(e.to_string())) + } + + async fn clear_index(&self) -> naviscope_api::lifecycle::EngineResult<()> { + self.engine + .clear_project_index() + .await + .map_err(|e: NaviscopeError| EngineError::Internal(e.to_string())) + } +} diff --git a/crates/core/src/facade/mod.rs b/crates/core/src/facade/mod.rs new file mode 100644 index 0000000..c726470 --- /dev/null +++ b/crates/core/src/facade/mod.rs @@ -0,0 +1,200 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use crate::error::Result; +use crate::model::CodeGraph; +use crate::runtime::orchestrator::NaviscopeEngine as InternalEngine; +use naviscope_api::NaviscopeEngine; + +mod graph; +mod lifecycle; +mod navigation; +mod semantic; + +/// Engine handle - unified interface for all clients +/// +/// This provides both async and sync APIs: +/// - Async API: for LSP and MCP servers +/// - Sync API: for Shell REPL +#[derive(Clone)] +pub struct EngineHandle { + pub(crate) engine: Arc, +} + +impl EngineHandle { + /// Create a new engine handle + pub fn new(project_root: PathBuf) -> Self { + Self { + engine: Arc::new(InternalEngine::new(project_root)), + } + } + + /// Create a handle from an existing engine (useful for testing) + pub fn from_engine(engine: Arc) -> Self { + Self { engine } + } + + // ---- Async API (for LSP/MCP) ---- + + /// Get a snapshot of the current graph (async) + pub async fn graph(&self) -> CodeGraph { + self.engine.snapshot().await + } + + // ---- Language specific services (internal) ---- + + pub fn get_lsp_parser( + &self, + language: crate::model::source::Language, + ) -> Option> { + self.engine.get_resolver().get_lsp_parser(language) + } + + pub fn get_semantic_resolver( + &self, + language: crate::model::source::Language, + ) -> Option> { + self.engine.get_resolver().get_semantic_resolver(language) + } + + pub fn get_metadata_plugin( + &self, + language: crate::model::source::Language, + ) -> Option> { + self.engine.get_resolver().get_metadata_plugin(language) + } + + pub fn get_node_renderer( + &self, + language: crate::model::source::Language, + ) -> Option> { + self.engine.get_resolver().get_node_renderer(language) + } + + pub fn get_language_by_extension(&self, ext: &str) -> Option { + self.engine.get_resolver().get_language_by_extension(ext) + } + + pub fn get_parser_and_lang_for_path( + &self, + path: &std::path::Path, + ) -> Option<( + Arc, + crate::model::source::Language, + )> { + let ext = path.extension()?.to_str()?; + let lang = self.get_language_by_extension(ext)?; + let parser = self.get_lsp_parser(lang.clone())?; + Some((parser, lang)) + } + + // ---- File watching ---- + + /// Watch for filesystem changes + pub async fn watch(&self) -> Result<()> { + self.engine.clone().watch().await + } +} + +impl NaviscopeEngine for EngineHandle {} + +#[cfg(test)] +mod tests { + use naviscope_api::GraphService; + + use super::*; + + #[tokio::test] + async fn test_async_graph_access() { + let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let handle = EngineHandle::from_engine(engine); + + let graph = handle.graph().await; + assert_eq!(graph.node_count(), 0); // Empty initially + } + + #[test] + fn test_blocking_graph_access() { + // Create runtime in a separate thread without any existing runtime context + std::thread::spawn(|| { + let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let handle = EngineHandle::from_engine(engine); + + // Test that blocking API works via async runtime + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + + let _graph = rt.block_on(handle.graph()); + }) + .join() + .unwrap(); + } + + #[tokio::test] + async fn test_concurrent_queries() { + use tokio::task::JoinSet; + + let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let handle = Arc::new(EngineHandle::from_engine(engine)); + + let mut set = JoinSet::new(); + + for _ in 0..10 { + let h = Arc::clone(&handle); + set.spawn(async move { + for _ in 0..5 { + let graph = h.graph().await; + let _ = graph.node_count(); + } + }); + } + + while let Some(result) = set.join_next().await { + result.unwrap(); + } + } + + #[tokio::test] + async fn test_query_functionality() { + use naviscope_api::models::GraphQuery; + + let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let handle = EngineHandle::from_engine(engine); + + // Test async query + let query = GraphQuery::Find { + pattern: "test".to_string(), + kind: vec![], + limit: 10, + }; + + let result = handle.query(&query).await; + assert!(result.is_ok(), "Query should execute successfully"); + } + + #[test] + fn test_query_blocking() { + use naviscope_api::GraphService; + use naviscope_api::models::GraphQuery; + + std::thread::spawn(|| { + let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let handle = EngineHandle::from_engine(engine); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + + let query = GraphQuery::Find { + pattern: "test".to_string(), + kind: vec![], + limit: 10, + }; + + // Use trait method via async runtime + let result = rt.block_on(handle.query(&query)); + assert!(result.is_ok(), "Blocking query should execute successfully"); + }) + .join() + .unwrap(); + } +} diff --git a/crates/core/src/facade/navigation.rs b/crates/core/src/facade/navigation.rs new file mode 100644 index 0000000..1dc298c --- /dev/null +++ b/crates/core/src/facade/navigation.rs @@ -0,0 +1,19 @@ +use crate::facade::EngineHandle; +use crate::features::navigation::NavigationEngine; +use async_trait::async_trait; +use naviscope_api::navigation::{NavigationService, ResolveResult}; + +#[async_trait] +impl NavigationService for EngineHandle { + async fn resolve_path(&self, target: &str, current_context: Option<&str>) -> ResolveResult { + let graph = self.graph().await; + let engine = NavigationEngine::new(&graph); + engine.resolve_path(target, current_context) + } + + async fn get_completion_candidates(&self, prefix: &str) -> Vec { + let graph = self.graph().await; + let engine = NavigationEngine::new(&graph); + engine.get_completion_candidates(prefix) + } +} diff --git a/crates/core/src/facade/semantic.rs b/crates/core/src/facade/semantic.rs new file mode 100644 index 0000000..65a577b --- /dev/null +++ b/crates/core/src/facade/semantic.rs @@ -0,0 +1,537 @@ +use crate::facade::EngineHandle; +use crate::features::discovery::DiscoveryEngine; +use crate::util::utf16_col_to_byte_col; +use async_trait::async_trait; +use naviscope_api::graph::GraphService; +use naviscope_api::models::{ + CallHierarchyIncomingCall, CallHierarchyOutgoingCall, DisplayGraphNode, Language, NodeKind, + PositionContext, Range, ReferenceQuery, SymbolLocation, SymbolQuery, SymbolResolution, +}; +use naviscope_api::semantic::{ + CallHierarchyAnalyzer, ReferenceAnalyzer, SemanticError, SemanticResult, SymbolInfoProvider, + SymbolNavigator, +}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; + +#[async_trait] +impl SymbolNavigator for EngineHandle { + async fn resolve_symbol_at( + &self, + ctx: &PositionContext, + ) -> SemanticResult> { + let uri_str = &ctx.uri; + let path = if uri_str.starts_with("file://") { + PathBuf::from(uri_str.strip_prefix("file://").unwrap()) + } else { + PathBuf::from(uri_str) + }; + + let (parser, lang) = match self.get_parser_and_lang_for_path(&path) { + Some(x) => x, + None => return Ok(None), + }; + + let resolver = match self.get_semantic_resolver(lang.clone()) { + Some(r) => r, + None => return Ok(None), + }; + + let content = if let Some(c) = &ctx.content { + c.clone() + } else { + fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))? + }; + + let tree = parser + .parse(&content, None) + .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; + + let byte_col = utf16_col_to_byte_col(&content, ctx.line as usize, ctx.char as usize); + + let graph = self.graph().await; + + Ok(resolver.resolve_at(&tree, &content, ctx.line as usize, byte_col, &graph)) + } + + async fn find_highlights(&self, ctx: &PositionContext) -> SemanticResult> { + let uri_str = &ctx.uri; + let path = if uri_str.starts_with("file://") { + PathBuf::from(uri_str.strip_prefix("file://").unwrap()) + } else { + PathBuf::from(uri_str) + }; + + let (parser, _) = match self.get_parser_and_lang_for_path(&path) { + Some(x) => x, + None => return Ok(vec![]), + }; + + let content = if let Some(c) = &ctx.content { + c.clone() + } else { + fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))? + }; + + let tree = parser + .parse(&content, None) + .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; + + let res = match self.resolve_symbol_at(ctx).await? { + Some(r) => r, + None => return Ok(vec![]), + }; + + Ok(parser.find_occurrences(&content, &tree, &res)) + } + + async fn find_definitions(&self, query: &SymbolQuery) -> SemanticResult> { + let resolver = match self.get_semantic_resolver(query.language.clone()) { + Some(r) => r, + None => return Ok(vec![]), + }; + + let graph = self.graph().await; + let matches = resolver.find_matches(&graph, &query.resolution); + + let topology = graph.topology(); + let mut locations = Vec::new(); + + for idx in matches { + let node = &topology[idx]; + if let Some(loc) = &node.location { + let path_str = graph.symbols().resolve(&loc.path.0); + locations.push(SymbolLocation { + path: Arc::from(PathBuf::from(path_str)), + range: loc.range, + selection_range: loc.selection_range, + }); + } + } + Ok(locations) + } + + async fn find_type_definitions( + &self, + query: &SymbolQuery, + ) -> SemanticResult> { + let resolver = match self.get_semantic_resolver(query.language.clone()) { + Some(r) => r, + None => return Ok(vec![]), + }; + let graph = self.graph().await; + + let type_resolutions = resolver.resolve_type_of(&graph, &query.resolution); + let topology = graph.topology(); + let mut locations = Vec::new(); + + for res in type_resolutions { + let matches = resolver.find_matches(&graph, &res); + for idx in matches { + let node = &topology[idx]; + if let Some(loc) = &node.location { + let path_str = graph.symbols().resolve(&loc.path.0); + locations.push(SymbolLocation { + path: Arc::from(PathBuf::from(path_str)), + range: loc.range, + selection_range: loc.selection_range, + }); + } + } + } + Ok(locations) + } + + async fn find_implementations( + &self, + query: &SymbolQuery, + ) -> SemanticResult> { + let resolver = match self.get_semantic_resolver(query.language.clone()) { + Some(r) => r, + None => return Ok(vec![]), + }; + let graph = self.graph().await; + let matches = resolver.find_implementations(&graph, &query.resolution); + + let topology = graph.topology(); + let mut locations = Vec::new(); + + for idx in matches { + let node = &topology[idx]; + if let Some(loc) = &node.location { + let path_str = graph.symbols().resolve(&loc.path.0); + locations.push(SymbolLocation { + path: Arc::from(PathBuf::from(path_str)), + range: loc.range, + selection_range: loc.selection_range, + }); + } + } + Ok(locations) + } +} + +#[async_trait] +impl ReferenceAnalyzer for EngineHandle { + async fn find_references(&self, query: &ReferenceQuery) -> SemanticResult> { + let resolver = match self.get_semantic_resolver(query.language.clone()) { + Some(r) => r, + None => return Ok(vec![]), + }; + let graph = self.graph().await; + + let matches = resolver.find_matches(&graph, &query.resolution); + let discovery = DiscoveryEngine::new(&graph); + let candidate_paths = discovery.scout_references(&matches); + + let mut tasks = tokio::task::JoinSet::new(); + let shared_graph = Arc::new(graph); + + for path in candidate_paths { + let handle = self.clone(); + let resolution = query.resolution.clone(); + let graph_snap = Arc::clone(&shared_graph); + + tasks.spawn(async move { + let (parser, file_lang) = match handle.get_parser_and_lang_for_path(&path) { + Some(x) => x, + None => return Vec::new(), + }; + + let file_resolver = match handle.get_semantic_resolver(file_lang) { + Some(r) => r, + None => return Vec::new(), + }; + + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let discovery = DiscoveryEngine::new(graph_snap.as_ref()); + + let uri_str = format!("file://{}", path.display()); + let uri = match url::Url::parse(&uri_str) { + Ok(u) => u, + Err(_) => return Vec::new(), + }; + + let locations = discovery.scan_file( + parser.as_ref(), + file_resolver.as_ref(), + &content, + &resolution, + &uri, + ); + + locations + .into_iter() + .map(|loc| { + let path_buf = loc.uri.to_file_path().unwrap(); + SymbolLocation { + path: Arc::from(path_buf), + range: Range { + start_line: loc.range.start.line as usize, + start_col: loc.range.start.character as usize, + end_line: loc.range.end.line as usize, + end_col: loc.range.end.character as usize, + }, + selection_range: None, + } + }) + .collect::>() + }); + } + + let mut all_locations = Vec::new(); + while let Some(res) = tasks.join_next().await { + if let Ok(locs) = res { + all_locations.extend(locs); + } + } + + all_locations.sort_by(|a, b| { + a.path + .cmp(&b.path) + .then(a.range.start_line.cmp(&b.range.start_line)) + .then(a.range.start_col.cmp(&b.range.start_col)) + }); + all_locations.dedup_by(|a, b| { + a.path == b.path + && a.range.start_line == b.range.start_line + && a.range.start_col == b.range.start_col + }); + + Ok(all_locations) + } +} + +#[async_trait] +impl CallHierarchyAnalyzer for EngineHandle { + async fn find_incoming_calls( + &self, + fqn: &str, + ) -> SemanticResult> { + let graph = self.graph().await; + let target_indices = graph.find_matches_by_fqn(fqn); + if target_indices.is_empty() { + return Ok(vec![]); + } + + // 1. Meso-level scouting for candidate files + let discovery = DiscoveryEngine::new(&graph); + let candidate_paths = discovery.scout_references(&target_indices); + + // 2. Micro-level scanning + let mut tasks = tokio::task::JoinSet::new(); + let shared_graph = Arc::new(graph.clone()); + let resolution = SymbolResolution::Global(fqn.to_string()); + + for path in candidate_paths { + let handle = self.clone(); + let res = resolution.clone(); + let graph_snap = Arc::clone(&shared_graph); + + tasks.spawn(async move { + let (parser, file_lang) = match handle.get_parser_and_lang_for_path(&path) { + Some(x) => x, + None => return vec![], + }; + + let file_resolver = match handle.get_semantic_resolver(file_lang) { + Some(r) => r, + None => return vec![], + }; + + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return vec![], + }; + + let discovery = DiscoveryEngine::new(graph_snap.as_ref()); + let uri_str = format!("file://{}", path.display()); + let uri = match url::Url::parse(&uri_str) { + Ok(u) => u, + Err(_) => return vec![], + }; + + // Verification + discovery.scan_file( + parser.as_ref(), + file_resolver.as_ref(), + &content, + &res, + &uri, + ) + }); + } + + let mut all_call_sites = Vec::new(); + while let Some(res) = tasks.join_next().await { + if let Ok(locs) = res { + all_call_sites.extend(locs); + } + } + + // 3. Meso-level: group call sites by caller method + let mut caller_map: HashMap> = HashMap::new(); + + for loc in all_call_sites { + if let Ok(path) = loc.uri.to_file_path() { + if let Some(caller_idx) = graph.find_container_node_at( + &path, + loc.range.start.line as usize, + loc.range.start.character as usize, + ) { + let node = &graph.topology()[caller_idx]; + // Only include methods or constructors as callers + if matches!(node.kind(), NodeKind::Method | NodeKind::Constructor) { + caller_map.entry(caller_idx).or_default().push(Range { + start_line: loc.range.start.line as usize, + start_col: loc.range.start.character as usize, + end_line: loc.range.end.line as usize, + end_col: loc.range.end.character as usize, + }); + } + } + } + } + + let mut results = Vec::new(); + let symbols = graph.symbols(); + for (idx, ranges) in caller_map { + let node = &graph.topology()[idx]; + let fqn = node.fqn(symbols); + if let Some(display_node) = self + .get_node_display(fqn) + .await + .map_err(|e| SemanticError::Internal(e.to_string()))? + { + results.push(CallHierarchyIncomingCall { + from: display_node, + from_ranges: ranges, + }); + } + } + + Ok(results) + } + + async fn find_outgoing_calls( + &self, + fqn: &str, + ) -> SemanticResult> { + let graph = self.graph().await; + let node_idx = match graph.find_node(fqn) { + Some(idx) => idx, + None => return Ok(vec![]), + }; + + let node = graph.get_node(node_idx).unwrap(); + let symbols = graph.symbols(); + let path_str = node + .path(symbols) + .ok_or_else(|| SemanticError::Internal("Node has no path".into()))?; + let path = PathBuf::from(path_str); + + let range = node + .range() + .ok_or_else(|| SemanticError::Internal("Node has no range".into()))?; + + let (parser, lang) = self + .get_parser_and_lang_for_path(&path) + .ok_or_else(|| SemanticError::Internal("No parser for file".into()))?; + let resolver = self + .get_semantic_resolver(lang) + .ok_or_else(|| SemanticError::Internal("No resolver for file".into()))?; + + let content = + fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))?; + + // Micro-level scanning: extract method body and find all calls + let tree = parser + .parse(&content, None) + .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; + + let mut outgoing_calls: HashMap> = + HashMap::new(); + + // Simple AST walk to find identifiers in range + let mut stack = vec![tree.root_node()]; + + while let Some(n) = stack.pop() { + let n_range = n.range(); + if n_range.start_point.row > range.end_line { + continue; + } + if n_range.end_point.row < range.start_line { + // Not in range, but children might be + for i in 0..n.child_count() { + stack.push(n.child(i as u32).unwrap()); + } + continue; + } + + // Check if it's an identifier-like node + if matches!( + n.kind(), + "identifier" | "method_invocation" | "call_expression" + ) { + let pos_ctx = PositionContext { + uri: format!("file://{}", path.display()), + line: n_range.start_point.row as u32, + char: n_range.start_point.column as u32, + content: Some(content.clone()), + }; + + if let Ok(Some(res)) = self.resolve_symbol_at(&pos_ctx).await { + let matches = resolver.find_matches(&graph, &res); + for &m_idx in &matches { + let m_node = &graph.topology()[m_idx]; + if matches!(m_node.kind(), NodeKind::Method | NodeKind::Constructor) { + outgoing_calls.entry(m_idx).or_default().push(Range { + start_line: n_range.start_point.row, + start_col: n_range.start_point.column, + end_line: n_range.end_point.row, + end_col: n_range.end_point.column, + }); + } + } + } + } + + // Recurse children + for i in 0..n.child_count() { + stack.push(n.child(i as u32).unwrap()); + } + } + + let mut results = Vec::new(); + for (idx, ranges) in outgoing_calls { + let m_node = &graph.topology()[idx]; + let fqn = m_node.fqn(symbols); + if let Some(display_node) = self + .get_node_display(fqn) + .await + .map_err(|e| SemanticError::Internal(e.to_string()))? + { + results.push(CallHierarchyOutgoingCall { + to: display_node, + from_ranges: ranges, + }); + } + } + + Ok(results) + } +} + +#[async_trait] +impl SymbolInfoProvider for EngineHandle { + async fn get_symbol_info(&self, fqn: &str) -> SemanticResult> { + self.get_node_display(fqn) + .await + .map_err(|e| SemanticError::Internal(e.to_string())) + } + + async fn get_document_symbols(&self, uri: &str) -> SemanticResult> { + let path = if uri.starts_with("file://") { + PathBuf::from(uri.strip_prefix("file://").unwrap()) + } else { + PathBuf::from(uri) + }; + + let (parser, _lang) = match self.get_parser_and_lang_for_path(&path) { + Some(x) => x, + None => return Ok(vec![]), + }; + + let content = + fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))?; + + let tree = parser + .parse(&content, None) + .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; + + let symbols = parser.extract_symbols(&tree, &content); + + Ok(symbols) + } + + async fn get_language_for_document(&self, uri: &str) -> SemanticResult> { + let path = if uri.starts_with("file://") { + PathBuf::from(uri.strip_prefix("file://").unwrap()) + } else { + PathBuf::from(uri) + }; + + let ext = match path.extension().and_then(|e| e.to_str()) { + Some(e) => e, + None => return Ok(None), + }; + + Ok(self.get_language_by_extension(ext)) + } +} diff --git a/crates/core/src/features/discovery.rs b/crates/core/src/features/discovery.rs new file mode 100644 index 0000000..92d4650 --- /dev/null +++ b/crates/core/src/features/discovery.rs @@ -0,0 +1,178 @@ +use super::CodeGraphLike; +use crate::ingest::parser::{LspParser, SymbolResolution}; +use lsp_types::{Location, Url}; +use std::collections::HashSet; + +/// DiscoveryEngine bridges Meso-level graph knowledge with Micro-level file scanning. +pub struct DiscoveryEngine<'a> { + index: &'a dyn CodeGraphLike, +} + +impl<'a> DiscoveryEngine<'a> { + pub fn new(index: &'a dyn CodeGraphLike) -> Self { + Self { index } + } + + /// Meso-level: Scout for candidate files that likely contain references to the given nodes. + /// Returns a set of unique file paths. + /// + /// Strategy: + /// 1. Extract all possible identifier tokens from node's FQN and name + /// 2. Use reference_index (inverted index) to quickly find candidate files containing these tokens + /// + /// Note: This relies on reference_index which contains all identifier tokens found during parsing. + /// The actual reference verification is done at micro-level using tree-sitter parsing. + /// Meso-level: Scout for candidate files that likely contain references to the given nodes. + /// Returns a set of unique file paths. + /// + /// Strategy: + /// 1. Extract "primary" (name) and "context" (parent) tokens. + /// 2. If context exists, use INTERSECTION of file sets to reduce candidates. + /// 3. Fallback to primary token union if context is missing or not found. + /// Meso-level: Scout for candidate files that likely contain references to the given nodes. + /// Returns a set of unique file paths. + /// + /// Strategy: + /// 1. Extract "primary" (name) and "context" (parent) tokens. + /// 2. If context exists, use INTERSECTION of file sets to reduce candidates. + /// 3. Fallback to primary token union if context is missing or not found. + pub fn scout_references( + &self, + matches: &[petgraph::prelude::NodeIndex], + ) -> HashSet { + let mut unique_paths = HashSet::new(); + let topology = self.index.topology(); + let ref_index = self.index.reference_index(); + let symbols = self.index.symbols(); + + for &node_idx in matches { + let node = &topology[node_idx]; + + let (primary, context) = Self::extract_smart_tokens(node, self.index); + + // Helper to get paths for a token string + let get_paths = |token: &str| -> Option> { + let sym = symbols.get(token)?; + ref_index + .get(&naviscope_api::models::symbol::Symbol(sym)) + .map(|paths| { + paths + .iter() + .map(|p_sym| std::path::PathBuf::from(symbols.resolve(&p_sym.0))) + .collect() + }) + }; + + if let Some(primary_paths) = get_paths(&primary) { + if let Some(ctx_str) = context { + // Optimization: INTERSECTION + // Only candidate files that contain BOTH the context (e.g. Class) and name (e.g. Method). + if let Some(ctx_paths) = get_paths(&ctx_str) { + // SPARSITY CHECK: If context is too generic (e.g. "com", "org", "java"), + // intersection is expensive and useless. Skip if it hits > 1000 files. + if ctx_paths.len() < 1000 { + let ctx_set: HashSet<_> = ctx_paths.iter().collect(); + for p in primary_paths { + if ctx_set.contains(&p) { + unique_paths.insert(p.clone()); + } + } + continue; // Optimization applied, skip fallback + } + } + } + + // Fallback: Add all files containing the primary token + for p in primary_paths { + unique_paths.insert(p); + } + } + } + unique_paths + } + + /// Smartly extract tokens for "bag of words" intersection. + /// Returns (Primary Token, Optional Context Token) + fn extract_smart_tokens( + node: &crate::model::GraphNode, + index: &dyn CodeGraphLike, + ) -> (String, Option) { + let symbols = index.symbols(); + let name = node.name(symbols).to_string(); + let fqn = node.fqn(symbols); + + // Split by ANY non-alphanumeric character (except underscore) + // This is much more language-agnostic than hardcoding '.', ':', etc. + let parts: Vec<&str> = fqn + .split(|c: char| !c.is_alphanumeric() && c != '_') + .filter(|s: &&str| !s.is_empty()) + .collect(); + + // Context is usually the immediate parent of the name in the FQN. + // e.g. "com.example.UserService.login" -> context is "UserService" + // Also check if last part matches name to ensure alignment + let context = if parts.len() >= 2 { + // Check if last part is indeed the name + if parts.last() == Some(&name.as_str()) { + Some(parts[parts.len() - 2].to_string()) + } else { + // Should not happen for valid FQNs usually, but fallback + None + } + } else { + None + }; + + (name, context) + } + + /// Micro-level: Scan a specific file for precise symbol occurrences. + /// Now performs SEMANTIC VERIFICATION using the Resolver. + pub fn scan_file( + &self, + parser: &dyn LspParser, + resolver: &dyn crate::ingest::resolver::SemanticResolver, + source: &str, + target_resolution: &SymbolResolution, + uri: &Url, + ) -> Vec { + if let Some(tree) = parser.parse(source, None) { + // 1. Syntactic Scan (Fast) + let candidates = parser.find_occurrences(source, &tree, target_resolution); + + // 2. Semantic Verification (Precise) + let mut valid_locations = Vec::new(); + + for range in candidates { + // Resolve what is truly at this location + if let Some(resolved_at_loc) = resolver.resolve_at( + &tree, + source, + range.start_line, + range.start_col, + self.index, + ) { + // 3. Identity Check + if &resolved_at_loc == target_resolution { + valid_locations.push(Location { + uri: uri.clone(), + range: lsp_types::Range { + start: lsp_types::Position::new( + range.start_line as u32, + range.start_col as u32, + ), + end: lsp_types::Position::new( + range.end_line as u32, + range.end_col as u32, + ), + }, + }); + } + } + } + valid_locations + } else { + Vec::new() + } + } +} diff --git a/crates/core/src/features/mod.rs b/crates/core/src/features/mod.rs new file mode 100644 index 0000000..753730a --- /dev/null +++ b/crates/core/src/features/mod.rs @@ -0,0 +1,112 @@ +use naviscope_api::models::symbol::Symbol; +use std::path::Path; + +pub mod discovery; +pub mod navigation; +pub mod query; + +/// Trait to abstract over different CodeGraph implementations for features. +/// This allows features to operate on both the full indexed graph and partial/mocked graphs for tests. +pub trait CodeGraphLike: Send + Sync { + fn topology( + &self, + ) -> &petgraph::stable_graph::StableDiGraph; + fn fqn_map(&self) -> &std::collections::HashMap; + fn path_to_nodes(&self, path: &Path) -> Option<&[petgraph::stable_graph::NodeIndex]>; + fn reference_index(&self) -> &std::collections::HashMap>; + fn find_container_node_at( + &self, + path: &std::path::Path, + line: usize, + col: usize, + ) -> Option; + fn symbols(&self) -> &lasso::Rodeo; + + /// Helper to find node by string FQN + fn find_node(&self, fqn: &str) -> Option { + let key = self.symbols().get(fqn)?; + self.fqn_map().get(&Symbol(key)).copied() + } + + /// Find all nodes matching an FQN string (handle duplicates if any) + fn find_matches_by_fqn(&self, fqn: &str) -> Vec { + if let Some(idx) = self.find_node(fqn) { + vec![idx] + } else { + vec![] + } + } +} + +// Blanket implementation for references +impl CodeGraphLike for &T { + fn topology( + &self, + ) -> &petgraph::stable_graph::StableDiGraph + { + (*self).topology() + } + + fn fqn_map(&self) -> &std::collections::HashMap { + (*self).fqn_map() + } + + fn path_to_nodes(&self, path: &Path) -> Option<&[petgraph::stable_graph::NodeIndex]> { + (*self).path_to_nodes(path) + } + + fn reference_index(&self) -> &std::collections::HashMap> { + (*self).reference_index() + } + + fn find_container_node_at( + &self, + path: &std::path::Path, + line: usize, + col: usize, + ) -> Option { + (*self).find_container_node_at(path, line, col) + } + + fn symbols(&self) -> &lasso::Rodeo { + (*self).symbols() + } +} + +// Implement for the core CodeGraph model +impl CodeGraphLike for crate::model::CodeGraph { + fn topology( + &self, + ) -> &petgraph::stable_graph::StableDiGraph + { + self.topology() + } + + fn fqn_map(&self) -> &std::collections::HashMap { + self.fqn_map() + } + + fn path_to_nodes(&self, path: &Path) -> Option<&[petgraph::stable_graph::NodeIndex]> { + let key = self.symbols().get(path.to_string_lossy())?; + self.file_index() + .get(&Symbol(key)) + .map(|e| e.nodes.as_slice()) + } + + fn reference_index(&self) -> &std::collections::HashMap> { + self.reference_index() + } + + fn find_container_node_at( + &self, + path: &std::path::Path, + line: usize, + col: usize, + ) -> Option { + self.find_container_node_at(path, line, col) + } + + fn symbols(&self) -> &lasso::Rodeo { + self.symbols() + } +} diff --git a/crates/core/src/features/navigation.rs b/crates/core/src/features/navigation.rs new file mode 100644 index 0000000..cd5cda7 --- /dev/null +++ b/crates/core/src/features/navigation.rs @@ -0,0 +1,163 @@ +use super::CodeGraphLike; +use crate::model::{EdgeType, NodeKind}; +use naviscope_api::navigation::ResolveResult; + +/// NavigationEngine provides logic for resolving fuzzy/relative paths within a graph. +pub struct NavigationEngine<'a> { + graph: &'a dyn CodeGraphLike, +} + +impl<'a> NavigationEngine<'a> { + pub fn new(graph: &'a dyn CodeGraphLike) -> Self { + Self { graph } + } + + pub fn resolve_path(&self, target: &str, current_context: Option<&str>) -> ResolveResult { + // 1. Handle special paths ("/" or "root") + if target == "/" || target == "root" { + let project_nodes: Vec<_> = self + .graph + .topology() + .node_indices() + .filter_map(|idx| { + let node = &self.graph.topology()[idx]; + if matches!(node.kind(), NodeKind::Project) { + Some(node.fqn(self.graph.symbols()).to_string()) + } else { + None + } + }) + .collect(); + + return match project_nodes.len() { + 1 => ResolveResult::Found(project_nodes[0].clone()), + 0 => ResolveResult::Found("".to_string()), + _ => ResolveResult::Ambiguous(project_nodes), + }; + } + + // 2. Handle parent navigation ("..") + if target == ".." { + if let Some(current_fqn) = current_context { + if let Some(idx) = self.graph.find_node(current_fqn) { + let mut incoming = self + .graph + .topology() + .neighbors_directed(idx, petgraph::Direction::Incoming) + .detach(); + + while let Some(edge_idx) = incoming.next_edge(self.graph.topology()) { + let edge = &self.graph.topology()[edge_idx]; + if edge.edge_type == EdgeType::Contains { + let (parent_idx, _) = + self.graph.topology().edge_endpoints(edge_idx).unwrap(); + if let Some(parent_node) = self.graph.topology().node_weight(parent_idx) + { + return ResolveResult::Found( + parent_node.fqn(self.graph.symbols()).to_string(), + ); + } + } + } + } + } + return ResolveResult::NotFound; + } + + // 3. Try exact match (absolute FQN) + if self.graph.find_node(target).is_some() { + return ResolveResult::Found(target.to_string()); + } + + // 4. Try relative path from current context + if let Some(current_fqn) = current_context { + let separator = if current_fqn.contains("::") { + "::" + } else { + "." + }; + let joined = format!("{}{}{}", current_fqn, separator, target); + if self.graph.find_node(&joined).is_some() { + return ResolveResult::Found(joined); + } + } + + // 5. Try fuzzy matching (child lookup) + let current_idx = current_context.and_then(|fqn| self.graph.find_node(fqn)); + + let candidates: Vec = if let Some(parent_idx) = current_idx { + // Search in children of current node + self.graph + .topology() + .neighbors_directed(parent_idx, petgraph::Direction::Outgoing) + .filter_map(|child_idx| { + if let Some(edge_idx) = self.graph.topology().find_edge(parent_idx, child_idx) { + let edge = &self.graph.topology()[edge_idx]; + if edge.edge_type == EdgeType::Contains { + let node = &self.graph.topology()[child_idx]; + let fqn = node.fqn(self.graph.symbols()); + + // Match by simple name (last component) or display name + let simple_name = fqn.split(&['.', ':']).last().unwrap_or(fqn); + let display_name = node.name(self.graph.symbols()); + if simple_name == target || display_name == target { + Some(fqn.to_string()) + } else { + None + } + } else { + None + } + } else { + None + } + }) + .collect() + } else { + // Global fuzzy search + self.graph + .fqn_map() + .keys() + .filter_map(|sym| { + let fqn = self.graph.symbols().resolve(&sym.0); + let simple_name = fqn.split(&['.', ':']).last().unwrap_or(fqn); + if simple_name == target { + return Some(fqn.to_string()); + } + + // Also check display name + if let Some(idx) = self.graph.fqn_map().get(sym) { + let node = &self.graph.topology()[*idx]; + if node.name(self.graph.symbols()) == target { + return Some(fqn.to_string()); + } + } + + None + }) + .collect() + }; + + match candidates.len() { + 0 => ResolveResult::NotFound, + 1 => ResolveResult::Found(candidates[0].clone()), + _ => ResolveResult::Ambiguous(candidates), + } + } + + pub fn get_completion_candidates(&self, prefix: &str) -> Vec { + self.graph + .fqn_map() + .keys() + .filter_map(|sym| { + let fqn = self.graph.symbols().resolve(&sym.0); + if fqn.starts_with(prefix) { + Some(fqn.to_string()) + } else { + None + } + }) + .take(50) // Reasonable limit for candidates + .collect() + } +} diff --git a/crates/core/src/features/query.rs b/crates/core/src/features/query.rs new file mode 100644 index 0000000..5e9df7f --- /dev/null +++ b/crates/core/src/features/query.rs @@ -0,0 +1,193 @@ +use crate::error::{NaviscopeError, Result}; +use crate::model::source::Language; +use crate::model::{DisplayGraphNode, EdgeType, NodeKind}; +use crate::runtime::plugin::NodeRenderer; +pub use naviscope_api::models::{GraphQuery, QueryResult, QueryResultEdge}; +use petgraph::Direction as PetDirection; +use regex::RegexBuilder; +use std::sync::Arc; + +use super::CodeGraphLike; + +pub struct QueryEngine { + graph: G, + lookup: L, +} + +impl QueryEngine +where + G: CodeGraphLike, + L: Fn(Language) -> Option>, +{ + pub fn new(graph: G, lookup: L) -> Self { + Self { graph, lookup } + } + + fn render_node(&self, node: &crate::model::GraphNode) -> DisplayGraphNode { + let symbols = self.graph.symbols(); + let lang = node.language(symbols); + if let Some(renderer) = (self.lookup)(lang.clone()) { + renderer.render_display_node(node, symbols) + } else { + panic!( + "CRITICAL: No renderer found for language '{}'. This indicates a missing plugin for indexed data.", + lang.as_str() + ); + } + } + + pub fn execute(&self, query: &GraphQuery) -> Result { + let symbols = self.graph.symbols(); + match query { + GraphQuery::Find { + pattern, + kind, + limit, + } => { + let regex = RegexBuilder::new(pattern) + .case_insensitive(true) + .build() + .map_err(|e| NaviscopeError::Parsing(format!("Invalid regex: {}", e)))?; + + let mut nodes = Vec::new(); + + for node in self.graph.topology().node_weights() { + if regex.is_match(node.fqn(symbols)) || regex.is_match(node.name(symbols)) { + if kind.is_empty() || kind.contains(&node.kind) { + nodes.push(self.render_node(node)); + } + } + + if nodes.len() >= *limit { + break; + } + } + Ok(QueryResult::new(nodes, vec![])) + } + GraphQuery::Ls { + fqn, + kind, + modifiers: _, + } => { + if let Some(target_fqn) = fqn { + self.traverse_neighbors( + target_fqn, + &[EdgeType::Contains], + PetDirection::Outgoing, + kind, + ) + } else { + let mut nodes = Vec::new(); + + for idx in self.graph.topology().node_indices() { + let node = &self.graph.topology()[idx]; + if node.kind == NodeKind::Module { + let has_parent = self + .graph + .topology() + .edges_directed(idx, PetDirection::Incoming) + .any(|e| e.weight().edge_type == EdgeType::Contains); + + if !has_parent { + nodes.push(self.render_node(node)); + } + } + } + + if nodes.is_empty() { + for idx in self.graph.topology().node_indices() { + let node = &self.graph.topology()[idx]; + let has_parent = self + .graph + .topology() + .edges_directed(idx, PetDirection::Incoming) + .any(|e| e.weight().edge_type == EdgeType::Contains); + + if !has_parent { + if kind.is_empty() || kind.contains(&node.kind) { + nodes.push(self.render_node(node)); + } + } + if nodes.len() >= 50 { + break; + } + } + } + + Ok(QueryResult::new(nodes, vec![])) + } + } + GraphQuery::Cat { fqn } => { + if let Some(idx) = self.graph.find_node(fqn) { + let node = &self.graph.topology()[idx]; + Ok(QueryResult::new(vec![self.render_node(node)], vec![])) + } else { + Ok(QueryResult::default()) + } + } + GraphQuery::Deps { + fqn, + rev, + edge_types, + } => { + let direction = if *rev { + PetDirection::Incoming + } else { + PetDirection::Outgoing + }; + self.traverse_neighbors(fqn.as_str(), edge_types, direction, &[]) + } + } + } + + fn traverse_neighbors( + &self, + fqn: &str, + edge_filter: &[EdgeType], + dir: PetDirection, + kind_filter: &[NodeKind], + ) -> Result { + let start_idx = self + .graph + .find_node(fqn) + .ok_or_else(|| NaviscopeError::Parsing(format!("Node not found: {}", fqn)))?; + + let mut nodes = Vec::new(); + let mut edges_result = Vec::new(); + let topology = self.graph.topology(); + let mut edges = topology.neighbors_directed(start_idx, dir).detach(); + let symbols = self.graph.symbols(); + + while let Some((edge_idx, neighbor_idx)) = edges.next(topology) { + let edge_data = &topology[edge_idx]; + if edge_filter.is_empty() || edge_filter.contains(&edge_data.edge_type) { + let neighbor_node = &topology[neighbor_idx]; + let start_node = &topology[start_idx]; + + if kind_filter.is_empty() || kind_filter.contains(&neighbor_node.kind) { + nodes.push(self.render_node(neighbor_node)); + + let (from, to) = if dir == PetDirection::Outgoing { + ( + Arc::from(start_node.fqn(symbols)), + Arc::from(neighbor_node.fqn(symbols)), + ) + } else { + ( + Arc::from(neighbor_node.fqn(symbols)), + Arc::from(start_node.fqn(symbols)), + ) + }; + + edges_result.push(QueryResultEdge { + from, + to, + data: edge_data.clone(), + }); + } + } + } + + Ok(QueryResult::new(nodes, edges_result)) + } +} diff --git a/crates/core/src/ingest/builder.rs b/crates/core/src/ingest/builder.rs new file mode 100644 index 0000000..733d720 --- /dev/null +++ b/crates/core/src/ingest/builder.rs @@ -0,0 +1,290 @@ +//! Graph builder for creating and modifying code graphs +//! +//! The `CodeGraphBuilder` allows mutable operations on the graph structure. +//! It's designed to be used during index construction/updates, then converted +//! to an immutable `CodeGraph` via the `build()` method. + +use crate::model::CodeGraph; +use crate::model::graph::CodeGraphInner; +use crate::model::source::SourceFile; +// StorageContext unused +use crate::model::{GraphEdge, GraphOp}; +use naviscope_api::models::symbol::Symbol; +use petgraph::stable_graph::{NodeIndex, StableDiGraph}; +use std::collections::HashMap; +use std::path::Path; + +/// Mutable graph builder +pub struct CodeGraphBuilder { + inner: CodeGraphInner, +} + +impl CodeGraphBuilder { + /// Create a new empty builder + pub fn new() -> Self { + Self { + inner: CodeGraphInner { + instance_id: 0, // Will be updated when built + version: crate::model::graph::CURRENT_VERSION, + topology: StableDiGraph::new(), + symbols: lasso::Rodeo::default(), + fqn_index: HashMap::new(), + name_index: HashMap::new(), + file_index: HashMap::new(), + reference_index: HashMap::new(), + }, + } + } + + /// Create builder from existing graph (deep copy) + pub fn from_graph(graph: &CodeGraph) -> Self { + graph.to_builder() + } + + /// Create builder from internal data + pub(crate) fn from_inner(inner: CodeGraphInner) -> Self { + Self { inner } + } + + // ---- Mutation methods ---- + + /// Add or update a node + pub fn add_node(&mut self, node_data: crate::ingest::parser::IndexNode) -> NodeIndex { + let fqn_sym = Symbol(self.inner.symbols.get_or_intern(&node_data.id)); + + if let Some(&idx) = self.inner.fqn_index.get(&fqn_sym) { + // Node already exists + idx + } else { + let name_sym = Symbol(self.inner.symbols.get_or_intern(&node_data.name)); + let lang_sym = Symbol(self.inner.symbols.get_or_intern(&node_data.lang)); + let location = node_data + .location + .as_ref() + .map(|l| l.to_internal(&mut self.inner.symbols)); + + let mut ctx = crate::model::storage::model::GenericStorageContext { + rodeo: &mut self.inner.symbols, + }; + + let node = crate::model::GraphNode { + id: fqn_sym, + name: name_sym, + kind: node_data.kind.clone(), + lang: lang_sym, + location: location.clone(), + metadata: node_data.metadata.intern(&mut ctx), + }; + + let idx = self.inner.topology.add_node(node); + self.inner.fqn_index.insert(fqn_sym, idx); + self.inner.name_index.entry(name_sym).or_default().push(idx); + + if let Some(loc) = location { + self.inner + .file_index + .entry(loc.path) + .and_modify(|e: &mut crate::model::graph::FileEntry| e.nodes.push(idx)) + .or_insert_with(|| { + let resolved_path = self.inner.symbols.resolve(&loc.path.0); + crate::model::graph::FileEntry { + metadata: SourceFile::new( + std::path::PathBuf::from(resolved_path), + 0, + 0, + ), + nodes: vec![idx], + } + }); + } + + idx + } + } + + /// Add an edge between two nodes + pub fn add_edge(&mut self, from: NodeIndex, to: NodeIndex, edge: GraphEdge) { + // Check for duplicate edges + let already_exists = self.inner.topology.edges_connecting(from, to).any( + |e: petgraph::stable_graph::EdgeReference| { + e.weight().edge_type == edge.edge_type + }, + ); + + if !already_exists { + self.inner.topology.add_edge(from, to, edge); + } + } + + /// Remove a node + pub fn remove_node(&mut self, idx: NodeIndex) { + if let Some(node) = self.inner.topology.node_weight(idx) { + let fqn = node.id; // Symbol implements Copy + let name = node.name; + + // Remove from indices + self.inner.fqn_index.remove(&fqn); + + if let Some(nodes) = self.inner.name_index.get_mut(&name) { + nodes.retain(|&i| i != idx); + if nodes.is_empty() { + self.inner.name_index.remove(&name); + } + } + + // Remove from topology + self.inner.topology.remove_node(idx); + } + } + + /// Remove all nodes associated with a file path + pub fn remove_path(&mut self, path: &Path) { + let interned_path = Symbol( + self.inner + .symbols + .get_or_intern(path.to_string_lossy().as_ref()), + ); + if let Some(entry) = self.inner.file_index.remove(&interned_path) { + for idx in entry.nodes { + self.remove_node(idx); + } + } + + // Also remove from reference_index + for files in self.inner.reference_index.values_mut() { + files.retain(|p| *p != interned_path); + } + } + + /// Update file metadata (creates or updates FileEntry) + pub fn update_file(&mut self, path: &Path, source: SourceFile) { + let interned_path = Symbol( + self.inner + .symbols + .get_or_intern(path.to_string_lossy().as_ref()), + ); + self.inner + .file_index + .entry(interned_path) + .and_modify(|e| e.metadata = source.clone()) + .or_insert(crate::model::graph::FileEntry { + metadata: source, + nodes: Vec::new(), + }); + } + + /// Apply a graph operation + pub fn apply_op(&mut self, op: GraphOp) -> crate::error::Result<()> { + match op { + GraphOp::AddNode { data } => { + if let Some(index_node) = data { + self.add_node(index_node); + } + } + GraphOp::AddEdge { + from_id, + to_id, + edge, + } => { + let from_sym = Symbol(self.inner.symbols.get_or_intern(from_id.as_ref())); + let to_sym = Symbol(self.inner.symbols.get_or_intern(to_id.as_ref())); + + if let (Some(&from), Some(&to)) = ( + self.inner.fqn_index.get(&from_sym), + self.inner.fqn_index.get(&to_sym), + ) { + self.add_edge(from, to, edge); + } + } + GraphOp::RemovePath { path } => { + self.remove_path(&path); + } + GraphOp::UpdateIdentifiers { path, identifiers } => { + let path_sym = Symbol( + self.inner + .symbols + .get_or_intern(path.to_string_lossy().as_ref()), + ); + for token in identifiers { + let token_sym = Symbol(self.inner.symbols.get_or_intern(token.as_str())); + let files = self.inner.reference_index.entry(token_sym).or_default(); + if !files.contains(&path_sym) { + files.push(path_sym); + } + } + } + GraphOp::UpdateFile { metadata } => { + let path = metadata.path.clone(); + self.update_file(&path, metadata); + } + } + Ok(()) + } + + /// Apply multiple graph operations + pub fn apply_ops(&mut self, ops: Vec) -> crate::error::Result<()> { + for op in ops { + self.apply_op(op)?; + } + Ok(()) + } + + /// Build the immutable graph + pub fn build(self) -> CodeGraph { + CodeGraph::from_inner(self.inner) + } +} + +impl Default for CodeGraphBuilder { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::NodeKind; + + #[test] + fn test_build_from_scratch() { + let mut builder = CodeGraphBuilder::new(); + + let node = crate::ingest::parser::IndexNode { + id: "test_project".to_string(), + name: "test_project".to_string(), + kind: NodeKind::Project, + lang: "buildfile".to_string(), + location: None, + metadata: std::sync::Arc::new(crate::model::EmptyMetadata), + }; + + let _idx = builder.add_node(node); + let graph = builder.build(); + + assert_eq!(graph.node_count(), 1); + assert!(graph.find_node("test_project").is_some()); + } + + #[test] + fn test_incremental_update() { + let graph = CodeGraph::empty(); + assert_eq!(graph.node_count(), 0); + + let mut builder = CodeGraphBuilder::from_graph(&graph); + + let node = crate::ingest::parser::IndexNode { + id: "new_project".to_string(), + name: "new_project".to_string(), + kind: NodeKind::Project, + lang: "buildfile".to_string(), + location: None, + metadata: std::sync::Arc::new(crate::model::EmptyMetadata), + }; + + builder.add_node(node); + let updated = builder.build(); + + assert_eq!(updated.node_count(), 1); + } +} diff --git a/crates/core/src/ingest/mod.rs b/crates/core/src/ingest/mod.rs new file mode 100644 index 0000000..f278b9c --- /dev/null +++ b/crates/core/src/ingest/mod.rs @@ -0,0 +1,18 @@ +pub mod builder; +pub mod parser; +pub mod resolver; +pub mod scanner; + +use std::path::Path; + +pub fn is_relevant_path(path: &Path) -> bool { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with('.') { + return false; + } + if name == "target" || name == "build" || name == "node_modules" { + return false; + } + } + true +} diff --git a/crates/core/src/ingest/parser/mod.rs b/crates/core/src/ingest/parser/mod.rs new file mode 100644 index 0000000..235659a --- /dev/null +++ b/crates/core/src/ingest/parser/mod.rs @@ -0,0 +1,40 @@ +use crate::error::Result; +use crate::model::{DisplayGraphNode, NodeKind, Range}; +use std::path::Path; +use tree_sitter::Tree; + +pub mod output; +pub mod utils; + +pub use naviscope_api::SymbolResolution; +pub use output::{IndexNode, IndexRelation, ParseOutput}; + +pub trait LspParser: Send + Sync { + fn parse( + &self, + source: &str, + old_tree: Option<&tree_sitter::Tree>, + ) -> Option; + fn extract_symbols(&self, tree: &Tree, source: &str) -> Vec; + /// Maps a language-specific symbol kind string to an LSP SymbolKind + fn symbol_kind(&self, kind: &NodeKind) -> lsp_types::SymbolKind; + + /// Find occurrences of a symbol within a single file's AST. + /// This is the "Micro" part of the Discovery Engine. + fn find_occurrences(&self, source: &str, tree: &Tree, target: &SymbolResolution) -> Vec; +} + +/// Result of a global file parsing for indexing. +#[derive(Clone)] +pub struct GlobalParseResult { + pub package_name: Option, + pub imports: Vec, + pub output: ParseOutput, + pub source: Option, + pub tree: Option, +} + +/// Trait for parsers that provide data for the global code knowledge graph. +pub trait IndexParser: Send + Sync { + fn parse_file(&self, source_code: &str, file_path: Option<&Path>) -> Result; +} diff --git a/crates/core/src/ingest/parser/output.rs b/crates/core/src/ingest/parser/output.rs new file mode 100644 index 0000000..9d7921a --- /dev/null +++ b/crates/core/src/ingest/parser/output.rs @@ -0,0 +1,34 @@ +use crate::model::IndexMetadata; +use crate::model::{EdgeType, NodeKind, Range}; +use naviscope_api::models::DisplaySymbolLocation; +use std::sync::Arc; + +/// Node model during the parsing phase, before interning +/// It holds raw Strings and strongly-typed Metadata +#[derive(Debug, Clone)] +pub struct IndexNode { + pub id: String, + pub name: String, + pub kind: NodeKind, + pub lang: String, + pub location: Option, + pub metadata: Arc, +} + +/// Relation model during the parsing phase +#[derive(Debug, Clone)] +pub struct IndexRelation { + pub source_id: String, + pub target_id: String, + pub edge_type: EdgeType, + pub range: Option, +} + +/// Core model produced by the parser +#[derive(Debug, Clone, Default)] +pub struct ParseOutput { + pub nodes: Vec, + pub relations: Vec, + /// All identifiers appearing in the file (used for global search and reference indexing) + pub identifiers: Vec, +} diff --git a/src/parser/utils.rs b/crates/core/src/ingest/parser/utils.rs similarity index 64% rename from src/parser/utils.rs rename to crates/core/src/ingest/parser/utils.rs index 3e3be9c..35305ea 100644 --- a/src/parser/utils.rs +++ b/crates/core/src/ingest/parser/utils.rs @@ -1,6 +1,6 @@ use crate::error::{NaviscopeError, Result}; +use crate::model::{NodeKind, Range}; use tree_sitter::{Language, Query}; -use crate::model::graph::{Range, NodeKind}; /// Converts a tree-sitter range to our internal Range model. pub fn range_from_ts(range: tree_sitter::Range) -> Range { @@ -29,26 +29,34 @@ pub fn get_capture_index(query: &Query, name: &str) -> Result { pub struct RawSymbol<'a> { pub name: String, pub kind: NodeKind, - pub range: crate::model::graph::Range, - pub selection_range: crate::model::graph::Range, + pub range: crate::model::Range, + pub selection_range: crate::model::Range, pub node: tree_sitter::Node<'a>, } -/// Builds a hierarchical DocumentSymbol tree from flat raw symbols using AST parent-child relationships. -pub fn build_symbol_hierarchy(raw_symbols: Vec) -> Vec { +/// Builds a hierarchical DisplayGraphNode tree from flat raw symbols using AST parent-child relationships. +pub fn build_symbol_hierarchy(raw_symbols: Vec) -> Vec { use std::collections::HashMap; let mut symbols_map: HashMap = HashMap::new(); // node_id -> flat_index - let mut flat_symbols: Vec = Vec::new(); + let mut flat_symbols: Vec = Vec::new(); let mut parent_child_rels: Vec<(usize, usize)> = Vec::new(); // 1. Create flat list and map nodes to indices for (i, raw) in raw_symbols.iter().enumerate() { - flat_symbols.push(crate::parser::DocumentSymbol { + flat_symbols.push(crate::model::DisplayGraphNode { + id: raw.name.clone(), // For document symbols, FQN might not be available, use name as fallback id name: raw.name.clone(), kind: raw.kind.clone(), - range: raw.range, - selection_range: raw.selection_range, - children: Vec::new(), + lang: String::new(), // To be filled by caller if needed + location: Some(crate::model::DisplaySymbolLocation { + path: String::new(), // To be filled by caller + range: raw.range, + selection_range: Some(raw.selection_range), + }), + detail: None, + signature: None, + modifiers: vec![], + children: Some(Vec::new()), }); symbols_map.insert(raw.node.id(), i); } @@ -82,18 +90,31 @@ pub fn build_symbol_hierarchy(raw_symbols: Vec) -> Vec, + flat: &mut Vec, rels: &[(usize, usize)], - ) -> crate::parser::DocumentSymbol { + ) -> crate::model::DisplayGraphNode { let mut sym = flat[idx].clone(); - let children: Vec = rels.iter().filter(|(p, _)| *p == idx).map(|(_, c)| *c).collect(); + let children: Vec = rels + .iter() + .filter(|(p, _)| *p == idx) + .map(|(_, c)| *c) + .collect(); + let mut child_nodes = Vec::new(); for c_idx in children { - sym.children.push(build_node(c_idx, flat, rels)); + child_nodes.push(build_node(c_idx, flat, rels)); } + sym.children = if child_nodes.is_empty() { + None + } else { + Some(child_nodes) + }; sym } - roots.into_iter().map(|root_idx| build_node(root_idx, &mut flat_symbols, &parent_child_rels)).collect() + roots + .into_iter() + .map(|root_idx| build_node(root_idx, &mut flat_symbols, &parent_child_rels)) + .collect() } /// Macro to define a struct for capture indices and a `new` method to initialize it from a query. @@ -108,7 +129,7 @@ macro_rules! decl_indices { impl $name { pub fn new(query: &tree_sitter::Query) -> $crate::error::Result { Ok(Self { - $($field: $crate::parser::utils::get_capture_index(query, $capture)?,)+ + $($field: $crate::ingest::parser::utils::get_capture_index(query, $capture)?,)+ }) } } diff --git a/crates/core/src/ingest/resolver/engine.rs b/crates/core/src/ingest/resolver/engine.rs new file mode 100644 index 0000000..45c6406 --- /dev/null +++ b/crates/core/src/ingest/resolver/engine.rs @@ -0,0 +1,172 @@ +use crate::error::Result; +use crate::ingest::resolver::{ProjectContext, SemanticResolver}; +use crate::ingest::scanner::ParsedFile; +use crate::model::source::Language; +use crate::model::{GraphOp, ResolvedUnit}; +use rayon::prelude::*; +use std::sync::Arc; + +use crate::runtime::plugin::{BuildToolPlugin, LanguagePlugin}; + +/// Main resolver that dispatches to specific strategies based on file type for indexing +pub struct IndexResolver { + build_plugins: Vec>, + lang_plugins: Vec>, +} + +impl IndexResolver { + pub fn new() -> Self { + Self { + build_plugins: Vec::new(), + lang_plugins: Vec::new(), + } + } + + pub fn with_plugins( + build_plugins: Vec>, + lang_plugins: Vec>, + ) -> Self { + Self { + build_plugins, + lang_plugins, + } + } + + pub fn register_language(&mut self, plugin: Arc) { + self.lang_plugins.push(plugin); + } + + pub fn register_build_tool(&mut self, plugin: Arc) { + self.build_plugins.push(plugin); + } + + pub fn get_semantic_resolver(&self, language: Language) -> Option> { + self.lang_plugins + .iter() + .find(|p| p.name() == language) + .map(|p| p.resolver()) + } + + pub fn get_lsp_parser( + &self, + language: Language, + ) -> Option> { + self.lang_plugins + .iter() + .find(|p| p.name() == language) + .map(|p| p.lsp_parser()) + } + + pub fn get_metadata_plugin( + &self, + language: Language, + ) -> Option> { + self.lang_plugins + .iter() + .find(|p| p.name() == language) + .map(|p| p.clone() as Arc) + .or_else(|| { + self.build_plugins + .iter() + .find(|p| p.name().as_str() == language.as_str()) + .map(|p| p.clone() as Arc) + }) + } + + pub fn get_node_renderer( + &self, + language: Language, + ) -> Option> { + self.lang_plugins + .iter() + .find(|p| p.name() == language) + .map(|p| p.clone() as Arc) + .or_else(|| { + self.build_plugins + .iter() + .find(|p| p.name().as_str() == language.as_str()) + .map(|p| p.clone() as Arc) + }) + } + + pub fn get_language_by_extension(&self, ext: &str) -> Option { + for plugin in &self.lang_plugins { + if plugin.supported_extensions().contains(&ext) { + return Some(plugin.name()); + } + } + Language::from_extension(ext) + } + + /// Resolve all parsed files into graph operations using a two-phase process + pub fn resolve(&self, files: Vec) -> Result> { + let mut all_ops = Vec::new(); + + // Add RemovePath operations and UpdateFile operations for each file being processed + for file in &files { + all_ops.push(GraphOp::RemovePath { + path: Arc::from(file.file.path.as_path()), + }); + all_ops.push(GraphOp::UpdateFile { + metadata: file.file.clone(), + }); + } + + // Separate files into build and source files + let (build_files, source_files): (Vec<_>, Vec<_>) = + files.into_iter().partition(|f| f.is_build()); + + // Phase 1: Resolve Build Tools (Structure) + let mut project_context = ProjectContext::new(); + + for plugin in &self.build_plugins { + // Find files relevant to this plugin + let tool_files: Vec = build_files + .iter() + .filter(|f| { + if let Some(file_name) = f.path().file_name().and_then(|n| n.to_str()) { + plugin.recognize(file_name) + } else { + false + } + }) + .cloned() + .collect(); + + let tool_files_refs: Vec<&ParsedFile> = tool_files.iter().collect(); + + if !tool_files.is_empty() { + let resolver = plugin.build_resolver(); + let (unit, context) = resolver.resolve(&tool_files_refs)?; + all_ops.extend(unit.ops); + project_context + .path_to_module + .extend(context.path_to_module); + } + } + + // Phase 2: Resolve Source Files (Entities) in parallel + let source_results: Vec> = source_files + .par_iter() + .map(|file| { + let language = file.language().unwrap_or(Language::BUILDFILE); + let plugin = self.lang_plugins.iter().find(|p| p.name() == language); + + if let Some(p) = plugin { + let resolver = p.lang_resolver(); + resolver.resolve(file, &project_context) + } else { + Ok(ResolvedUnit::new()) + } + }) + .collect(); + + // Collect and merge source operations + for result in source_results { + let unit = result?; + all_ops.extend(unit.ops); + } + + Ok(all_ops) + } +} diff --git a/src/resolver/mod.rs b/crates/core/src/ingest/resolver/mod.rs similarity index 74% rename from src/resolver/mod.rs rename to crates/core/src/ingest/resolver/mod.rs index dba3901..1973440 100644 --- a/src/resolver/mod.rs +++ b/crates/core/src/ingest/resolver/mod.rs @@ -1,30 +1,45 @@ use crate::error::Result; -use crate::index::CodeGraph; -use crate::model::graph::{ResolvedUnit}; -use crate::parser::SymbolResolution; -use crate::project::scanner::ParsedFile; +use crate::ingest::scanner::ParsedFile; +use crate::model::ResolvedUnit; +use naviscope_api::models::SymbolResolution; use petgraph::stable_graph::NodeIndex; use std::collections::HashMap; use std::path::{Path, PathBuf}; use tree_sitter::Tree; pub mod engine; -pub mod lang; pub mod scope; +use crate::features::CodeGraphLike; + /// Core interface for resolving a symbol at a specific position in a document. pub trait SemanticResolver: Send + Sync { /// Resolve a symbol at a specific position in a document (local view) - fn resolve_at(&self, tree: &Tree, source: &str, line: usize, byte_col: usize, index: &CodeGraph) -> Option; + fn resolve_at( + &self, + tree: &Tree, + source: &str, + line: usize, + byte_col: usize, + index: &dyn CodeGraphLike, + ) -> Option; /// Find nodes in the global graph matching a resolution result (global view) - fn find_matches(&self, index: &CodeGraph, res: &SymbolResolution) -> Vec; + fn find_matches(&self, index: &dyn CodeGraphLike, res: &SymbolResolution) -> Vec; /// Resolve the type(s) of a symbol (e.g., return type of a method, type of a field) - fn resolve_type_of(&self, index: &CodeGraph, res: &SymbolResolution) -> Vec; + fn resolve_type_of( + &self, + index: &dyn CodeGraphLike, + res: &SymbolResolution, + ) -> Vec; /// Find implementations or overrides of a symbol (global view) - fn find_implementations(&self, index: &CodeGraph, res: &SymbolResolution) -> Vec; + fn find_implementations( + &self, + index: &dyn CodeGraphLike, + res: &SymbolResolution, + ) -> Vec; } /// Project context generated by BuildResolver during the first phase diff --git a/src/resolver/scope.rs b/crates/core/src/ingest/resolver/scope.rs similarity index 93% rename from src/resolver/scope.rs rename to crates/core/src/ingest/resolver/scope.rs index 7f74c9e..c25eba3 100644 --- a/src/resolver/scope.rs +++ b/crates/core/src/ingest/resolver/scope.rs @@ -1,4 +1,4 @@ -use crate::parser::SymbolResolution; +use naviscope_api::models::SymbolResolution; /// A generic trait for semantic scopes in any programming language. /// `C` represents the language-specific resolution context. @@ -9,7 +9,7 @@ pub trait SemanticScope: Send + Sync { /// - `Some(Err(()))` if the symbol is NOT found and searching should stop (shadowing/short-circuit). /// - `None` if the symbol is NOT found and searching should continue in the next scope. fn resolve(&self, name: &str, context: &C) -> Option>; - + /// Returns the name of the scope for debugging purposes. fn name(&self) -> &'static str; } diff --git a/src/project/scanner.rs b/crates/core/src/ingest/scanner.rs similarity index 57% rename from src/project/scanner.rs rename to crates/core/src/ingest/scanner.rs index 186b494..c692cc0 100644 --- a/src/project/scanner.rs +++ b/crates/core/src/ingest/scanner.rs @@ -1,9 +1,6 @@ use super::is_relevant_path; -use super::source::{BuildTool, Language, SourceFile}; -use crate::model::lang::gradle::{GradleParseResult, GradleSettings}; -use crate::parser::gradle; -use crate::parser::java::JavaParser; -use crate::parser::{IndexParser, GlobalParseResult}; +use crate::ingest::parser::GlobalParseResult; +use crate::model::source::{BuildTool, Language, SourceFile}; use ignore::WalkBuilder; use rayon::prelude::*; use std::collections::HashMap; @@ -13,12 +10,14 @@ use std::path::{Path, PathBuf}; use std::time::SystemTime; use xxhash_rust::xxh3::Xxh3; +#[derive(Clone)] pub enum ParsedContent { - Java(GlobalParseResult), - Gradle(GradleParseResult), - GradleSettings(GradleSettings), + Language(GlobalParseResult), + MetaData(serde_json::Value), + Unparsed(String), } +#[derive(Clone)] pub struct ParsedFile { pub file: SourceFile, pub content: ParsedContent, @@ -26,20 +25,58 @@ pub struct ParsedFile { impl ParsedFile { pub fn is_build(&self) -> bool { - matches!(self.content, ParsedContent::Gradle(..) | ParsedContent::GradleSettings(..)) + match self.content { + ParsedContent::Unparsed(..) => { + let name = self + .path() + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + name == "build.gradle" + || name == "build.gradle.kts" + || name == "settings.gradle" + || name == "settings.gradle.kts" + } + _ => false, + } } pub fn build_tool(&self) -> Option { match self.content { - ParsedContent::Gradle(..) | ParsedContent::GradleSettings(..) => Some(BuildTool::Gradle), + ParsedContent::Unparsed(..) => { + if self.is_build() { + Some(BuildTool::GRADLE) + } else { + None + } + } _ => None, } } pub fn language(&self) -> Option { match self.content { - ParsedContent::Java(..) => Some(Language::Java), - ParsedContent::Gradle(..) | ParsedContent::GradleSettings(..) => Some(Language::BuildFile), + ParsedContent::Language(ref res) => { + // Try to infer from package + if let Some(ref pkg) = res.package_name { + if pkg.starts_with("java.") || pkg.starts_with("javax.") { + return Some(Language::JAVA); + } + } + Some(Language::UNKNOWN) + } + ParsedContent::MetaData(..) => None, + ParsedContent::Unparsed(..) => { + if self.is_build() { + self.build_tool() + .map(|t| Language::new(t.as_str().to_string())) + } else { + self.path() + .extension() + .and_then(|e| e.to_str()) + .and_then(Language::from_extension) + } + } } } @@ -55,7 +92,15 @@ impl Scanner { root: &Path, existing_files: &HashMap, ) -> Vec { - Self::collect_paths(root) + let paths = Self::collect_paths(root); + Self::scan_files(paths, existing_files) + } + + pub fn scan_files( + paths: Vec, + existing_files: &HashMap, + ) -> Vec { + paths .par_iter() .filter_map(|path| { // 1. Check metadata (mtime) first @@ -87,33 +132,16 @@ impl Scanner { // Determine build tool or language from file extension/name let file_name = path.file_name()?.to_str()?; - let extension = path.extension()?.to_str()?; - if file_name == "build.gradle" || file_name == "build.gradle.kts" { - let deps = gradle::parse_dependencies(&content_str).unwrap_or_else(|_| Vec::new()); - Some(ParsedFile { - file: source_file, - content: ParsedContent::Gradle(GradleParseResult { dependencies: deps }), - }) - } else if file_name == "settings.gradle" || file_name == "settings.gradle.kts" { - let settings = gradle::parse_settings(&content_str).unwrap_or_else(|_| { - crate::model::lang::gradle::GradleSettings { - root_project_name: None, - included_projects: Vec::new(), - } - }); - Some(ParsedFile { - file: source_file, - content: ParsedContent::GradleSettings(settings), - }) - } else if extension == "java" { - let parser = JavaParser::new().ok()?; - let res = parser - .parse_file(&content_str, Some(&source_file.path)) - .ok()?; + if file_name == "build.gradle" + || file_name == "build.gradle.kts" + || file_name == "settings.gradle" + || file_name == "settings.gradle.kts" + || path.extension().is_some() + { Some(ParsedFile { file: source_file, - content: ParsedContent::Java(res), + content: ParsedContent::Unparsed(content_str), }) } else { None diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..be9d5b7 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,11 @@ +pub mod error; +pub mod logging; +pub mod util; + +pub mod facade; +pub mod features; +pub mod ingest; +pub mod model; +pub mod runtime; + +pub use error::Result; diff --git a/src/logging.rs b/crates/core/src/logging.rs similarity index 90% rename from src/logging.rs rename to crates/core/src/logging.rs index 923ec85..427adc3 100644 --- a/src/logging.rs +++ b/crates/core/src/logging.rs @@ -1,6 +1,6 @@ use std::path::Path; use tracing_appender::non_blocking::WorkerGuard; -use tracing_subscriber::{fmt, prelude::*, EnvFilter}; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; pub fn init_logging(component: &str) -> WorkerGuard { let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); @@ -12,8 +12,7 @@ pub fn init_logging(component: &str) -> WorkerGuard { let file_appender = tracing_appender::rolling::daily(&log_dir, component); let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("info")); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); // File layer: no ANSI colors, output to file let file_layer = fmt::layer() diff --git a/crates/core/src/model/graph.rs b/crates/core/src/model/graph.rs new file mode 100644 index 0000000..0021750 --- /dev/null +++ b/crates/core/src/model/graph.rs @@ -0,0 +1,334 @@ +//! Arc-wrapped immutable code graph +//! +//! The `CodeGraph` provides a cheap-to-clone, immutable view of the indexed codebase. +//! All data is wrapped in `Arc`, so cloning only increments a reference counter. +use crate::error::{NaviscopeError, Result}; +use crate::ingest::builder::CodeGraphBuilder; + +use crate::model::source::SourceFile; +use crate::model::{GraphEdge, GraphNode}; +use crate::runtime::plugin::MetadataPlugin; +use lasso::Rodeo; +use naviscope_api::models::symbol::Symbol; +use petgraph::stable_graph::{NodeIndex, StableDiGraph}; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_INSTANCE_ID: AtomicU64 = AtomicU64::new(1); + +pub const CURRENT_VERSION: u32 = 1; + +fn next_instance_id() -> u64 { + NEXT_INSTANCE_ID.fetch_add(1, Ordering::Relaxed) +} + +/// Immutable code graph (cheap to clone via Arc) +#[derive(Clone)] +pub struct CodeGraph { + inner: std::sync::Arc, +} + +/// Internal data structure (shared via Arc) +#[derive(Clone)] +pub struct CodeGraphInner { + /// Unique instance ID for concurrency control (not serialized) + pub instance_id: u64, + + pub version: u32, + pub topology: StableDiGraph, + + /// Core symbol table: created and destroyed with the graph instance + pub symbols: Rodeo, + + /// FQN -> NodeIndex mapping for fast lookup + pub fqn_index: HashMap, + + /// Simple name -> NodeIndices for symbol search + pub name_index: HashMap>, + + /// File-level information: metadata and nodes contained in each file + pub file_index: HashMap, + + /// Reference Index: Token (e.g. Method Name) -> Files that contain this token. + /// Used for fast "scouting" during reference discovery. + pub reference_index: HashMap>, +} + +/// Metadata and nodes associated with a single source file +#[derive(Clone, Debug)] +pub struct FileEntry { + pub metadata: SourceFile, + pub nodes: Vec, +} + +impl CodeGraph { + /// Create an empty graph + pub fn empty() -> Self { + Self { + inner: std::sync::Arc::new(CodeGraphInner { + instance_id: next_instance_id(), + version: CURRENT_VERSION, + topology: StableDiGraph::new(), + symbols: Rodeo::default(), + fqn_index: HashMap::new(), + name_index: HashMap::new(), + file_index: HashMap::new(), + reference_index: HashMap::new(), + }), + } + } + + /// Create graph from internal data + pub(crate) fn from_inner(mut inner: CodeGraphInner) -> Self { + inner.instance_id = next_instance_id(); + Self { + inner: std::sync::Arc::new(inner), + } + } + + /// Create a builder for modifying this graph + /// + /// Note: This performs a deep copy, so it should only be called when + /// building/updating the index, not during queries. + pub fn to_builder(&self) -> CodeGraphBuilder { + CodeGraphBuilder::from_inner((*self.inner).clone()) + } + + // ---- Read-only accessors ---- + + /// Get the unique instance ID for this graph version + pub fn instance_id(&self) -> u64 { + self.inner.instance_id + } + + /// Get the version number + pub fn version(&self) -> u32 { + self.inner.version + } + + pub fn symbols(&self) -> &Rodeo { + &self.inner.symbols + } + + /// Get reference to the topology graph + pub fn topology(&self) -> &StableDiGraph { + &self.inner.topology + } + + /// Get reference to the FQN index + pub fn fqn_map(&self) -> &HashMap { + &self.inner.fqn_index + } + + /// Get reference to the name index + pub fn name_map(&self) -> &HashMap> { + &self.inner.name_index + } + + /// Get reference to the file index + pub fn file_index(&self) -> &HashMap { + &self.inner.file_index + } + + /// Get reference to the reference index + pub fn reference_index(&self) -> &HashMap> { + &self.inner.reference_index + } + + /// Find node index by FQN + pub fn find_node(&self, fqn: &str) -> Option { + let key = self.inner.symbols.get(fqn)?; + self.inner.fqn_index.get(&Symbol(key)).copied() + } + + /// Get node data by index + pub fn get_node(&self, idx: NodeIndex) -> Option<&GraphNode> { + self.inner.topology.node_weight(idx) + } + + /// Find node at a specific location in a file (by name range) + pub fn find_node_at(&self, path: &Path, line: usize, col: usize) -> Option { + let path_str = path.to_string_lossy(); + let key = self.inner.symbols.get(path_str.as_ref())?; + let entry = self.inner.file_index.get(&Symbol(key))?; + + for &idx in &entry.nodes { + if let Some(node) = self.inner.topology.node_weight(idx) { + let range_opt: Option<&naviscope_api::models::symbol::Range> = node.name_range(); + if let Some(range) = range_opt { + if range.contains(line, col) { + return Some(idx); + } + } + } + } + None + } + + /// Find the smallest node whose full range contains the specific location + pub fn find_container_node_at( + &self, + path: &Path, + line: usize, + col: usize, + ) -> Option { + let path_str = path.to_string_lossy(); + let key = self.inner.symbols.get(path_str.as_ref())?; + let entry = self.inner.file_index.get(&Symbol(key))?; + + let mut best_node = None; + let mut min_range_size = usize::MAX; + + for &idx in &entry.nodes { + if let Some(node) = self.inner.topology.node_weight(idx) { + if let Some(range) = node.range() { + if range.contains(line, col) { + // Calculate a rough size to find the smallest enclosing node + let size = (range.end_line - range.start_line) * 1000 + + (range.end_col.saturating_sub(range.start_col)); + if size < min_range_size { + min_range_size = size; + best_node = Some(idx); + } + } + } + } + } + best_node + } + + /// Find nodes matching a symbol resolution result + pub fn find_matches_by_fqn(&self, fqn: &str) -> Vec { + if let Some(key) = self.inner.symbols.get(fqn) { + if let Some(&idx) = self.inner.fqn_index.get(&Symbol(key)) { + return vec![idx]; + } + } + vec![] + } + + /// Get the number of nodes + pub fn node_count(&self) -> usize { + self.inner.topology.node_count() + } + + /// Get the number of edges + pub fn edge_count(&self) -> usize { + self.inner.topology.edge_count() + } + + // ---- Serialization support ---- + + /// Serialize to bytes for persistence + pub fn serialize( + &self, + get_plugin: impl Fn(&str) -> Option>, + ) -> Result> { + use super::storage::to_storage; + let storage = to_storage(&self.inner, get_plugin); + let bytes = rmp_serde::to_vec(&storage) + .map_err(|e| NaviscopeError::Internal(format!("MSGPACK error: {}", e)))?; + + let compressed = zstd::encode_all(&bytes[..], 0) + .map_err(|e| NaviscopeError::Internal(format!("Zstd compression failed: {}", e)))?; + + Ok(compressed) + } + + /// Deserialize from bytes + pub fn deserialize( + bytes: &[u8], + get_plugin: impl Fn(&str) -> Option>, + ) -> Result { + use super::storage::{StorageGraph, from_storage}; + + // Decompress using streaming decoder to save memory + let decoder = zstd::stream::read::Decoder::new(bytes) + .map_err(|e| NaviscopeError::Internal(format!("Zstd decoder init failed: {}", e)))?; + + let storage: StorageGraph = rmp_serde::from_read(decoder) + .map_err(|e| NaviscopeError::Internal(format!("MSGPACK error: {}", e)))?; + + let inner = from_storage(storage, get_plugin); + Ok(Self::from_inner(inner)) + } + + /// Save graph to JSON file (for debugging) + pub fn save_to_json>( + &self, + path: P, + get_plugin: impl Fn(&str) -> Option>, + ) -> crate::error::Result<()> { + use super::storage::to_storage; + let file = std::fs::File::create(path)?; + let writer = std::io::BufWriter::new(file); + let storage = to_storage(&self.inner, get_plugin); + serde_json::to_writer_pretty(writer, &storage) + .map_err(|e| crate::error::NaviscopeError::Parsing(e.to_string()))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_arc_clone_is_cheap() { + let graph = CodeGraph::empty(); + + // Arc clone should be O(1) + let start = std::time::Instant::now(); + for _ in 0..100000 { + let _clone = graph.clone(); + } + let elapsed = start.elapsed(); + + // 100K clones should be fast (< 10ms) + assert!( + elapsed.as_millis() < 10, + "Arc clone should be cheap, took {:?}", + elapsed + ); + } + + #[test] + fn test_empty_graph() { + let graph = CodeGraph::empty(); + assert_eq!(graph.node_count(), 0); + assert_eq!(graph.edge_count(), 0); + assert_eq!(graph.version(), CURRENT_VERSION); + } + + #[test] + fn test_graph_serialization_roundtrip() { + use crate::ingest::builder::CodeGraphBuilder; + use crate::model::NodeKind; + + let mut builder = CodeGraphBuilder::new(); + let node = crate::ingest::parser::IndexNode { + id: "test.node".to_string(), + name: "node".to_string(), + kind: NodeKind::Class, + lang: "java".to_string(), + location: None, + metadata: std::sync::Arc::new(crate::model::EmptyMetadata), + }; + builder.add_node(node); + let graph = builder.build(); + + let serialized = graph.serialize(|_| None).expect("Serialization failed"); + let deserialized = + CodeGraph::deserialize(&serialized, |_| None).expect("Deserialization failed"); + + assert_eq!(deserialized.node_count(), 1); + let idx = deserialized.find_node("test.node").unwrap(); + let recovered_node = &deserialized.topology()[idx]; + + let symbols = deserialized.symbols(); + assert_eq!(recovered_node.name(symbols), "node"); + assert_eq!(recovered_node.language(symbols).as_str(), "java"); + } +} diff --git a/crates/core/src/model/metadata.rs b/crates/core/src/model/metadata.rs new file mode 100644 index 0000000..73413f0 --- /dev/null +++ b/crates/core/src/model/metadata.rs @@ -0,0 +1,33 @@ +use naviscope_api::models::NodeMetadata; +use std::sync::Arc; + +/// Context for interning strings during metadata conversion. +pub trait SymbolInterner { + fn intern_str(&mut self, s: &str) -> u32; +} + +/// Compilation-time/Index-time metadata. +/// This version usually contains strings and is used during the parsing phase. +/// It must be able to convert itself into a runtime NodeMetadata. +pub trait IndexMetadata: Send + Sync + std::fmt::Debug { + /// Cast to Any for downcasting to concrete types. + fn as_any(&self) -> &dyn std::any::Any; + + /// Transform this metadata into its interned/optimized version for graph storage. + fn intern(&self, interner: &mut dyn SymbolInterner) -> Arc; +} + +impl IndexMetadata for naviscope_api::models::EmptyMetadata { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn intern(&self, _interner: &mut dyn SymbolInterner) -> Arc { + Arc::new(self.clone()) + } +} + +/// Helper to implement IndexMetadata for metadata that is already in its final form. +pub fn identity_intern(metadata: Arc) -> Arc { + metadata +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs new file mode 100644 index 0000000..17ab58a --- /dev/null +++ b/crates/core/src/model/mod.rs @@ -0,0 +1,9 @@ +pub mod graph; +pub mod metadata; +pub mod source; +pub mod storage; +pub mod types; + +pub use graph::CodeGraph; +pub use source::*; +pub use types::*; diff --git a/crates/core/src/model/source.rs b/crates/core/src/model/source.rs new file mode 100644 index 0000000..040823e --- /dev/null +++ b/crates/core/src/model/source.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Build tool types +// Re-export from API +pub use naviscope_api::models::{BuildTool, Language}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceFile { + pub path: PathBuf, + pub content_hash: u64, + pub last_modified: u64, // UNIX timestamp +} + +impl SourceFile { + pub fn new(path: PathBuf, content_hash: u64, last_modified: u64) -> Self { + Self { + path, + content_hash, + last_modified, + } + } +} diff --git a/crates/core/src/model/storage/converter.rs b/crates/core/src/model/storage/converter.rs new file mode 100644 index 0000000..4d28f13 --- /dev/null +++ b/crates/core/src/model/storage/converter.rs @@ -0,0 +1,262 @@ +use super::model::*; +use crate::model::graph::{CodeGraphInner, FileEntry}; +use crate::model::{EmptyMetadata, GraphNode, InternedLocation, NodeMetadata}; +use crate::runtime::plugin::MetadataPlugin; +use lasso::{Key, Rodeo, Spur}; +use naviscope_api::models::symbol::Symbol; +use petgraph::stable_graph::NodeIndex; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +/// Fallback plugin that uses empty metadata +struct DefaultMetadataPlugin; +impl MetadataPlugin for DefaultMetadataPlugin { + fn resolve(&self, _bytes: &[u8], _ctx: &dyn StorageContext) -> Arc { + Arc::new(EmptyMetadata) + } +} + +/// Read-only context used during deserialization +struct ReadOnlyStorageContext<'a>(&'a Rodeo); + +impl crate::model::metadata::SymbolInterner for ReadOnlyStorageContext<'_> { + fn intern_str(&mut self, _s: &str) -> u32 { + unreachable!("Read-only context cannot intern strings") + } +} + +impl<'a> StorageContext for ReadOnlyStorageContext<'a> { + fn intern_path(&mut self, _p: &Path) -> u32 { + unreachable!("Read-only context") + } + fn resolve_str(&self, sid: u32) -> &str { + let spur = Spur::try_from_usize(sid as usize).unwrap(); + self.0.resolve(&spur) + } + fn resolve_path(&self, pid: u32) -> &Path { + let spur = Spur::try_from_usize(pid as usize).unwrap(); + Path::new(self.0.resolve(&spur)) + } +} + +pub fn to_storage( + inner: &CodeGraphInner, + get_plugin: impl Fn(&str) -> Option>, +) -> StorageGraph { + let mut rodeo = inner.symbols.clone(); + + let mut ctx = GenericStorageContext { rodeo: &mut rodeo }; + + let default_plugin = Arc::new(DefaultMetadataPlugin); + let mut node_id_map = HashMap::new(); + let mut nodes = Vec::new(); + + for idx in inner.topology.node_indices() { + let node = &inner.topology[idx]; + let storage_idx = nodes.len() as u32; + node_id_map.insert(idx, storage_idx); + + // Resolve language string for plugin lookup + let lang_str = ctx.resolve_str(node.lang.0.into_usize() as u32).to_string(); + let plugin = get_plugin(&lang_str).unwrap_or_else(|| default_plugin.clone()); + let metadata = plugin.intern(&*node.metadata, &mut ctx); + + nodes.push(StorageNode { + id_sid: node.id.0.into_usize() as u32, + name_sid: node.name.0.into_usize() as u32, + kind: node.kind.clone(), + lang_sid: node.lang.0.into_usize() as u32, + location: node.location.as_ref().map(|loc| StorageLocation { + path_id: loc.path.0.into_usize() as u32, + range: loc.range, + selection_range: loc.selection_range, + }), + metadata: metadata.into_boxed_slice(), + }); + } + + let edges: Vec = inner + .topology + .edge_indices() + .map(|idx| { + let (from, to) = inner.topology.edge_endpoints(idx).unwrap(); + StorageEdge { + from: *node_id_map.get(&from).unwrap(), + to: *node_id_map.get(&to).unwrap(), + data: inner.topology[idx].clone(), + } + }) + .collect(); + + let mut fqn_index: Vec<(u32, u32)> = inner + .fqn_index + .iter() + .map(|(fqn, idx)| (fqn.0.into_usize() as u32, *node_id_map.get(idx).unwrap())) + .collect(); + fqn_index.sort_unstable_by_key(|k| k.0); + + let mut name_index: Vec<(u32, Vec)> = inner + .name_index + .iter() + .map(|(name, indices)| { + ( + name.0.into_usize() as u32, + indices + .iter() + .map(|i| *node_id_map.get(i).unwrap()) + .collect(), + ) + }) + .collect(); + name_index.sort_unstable_by_key(|k| k.0); + + let mut file_index: Vec<(u32, StorageFileEntry)> = inner + .file_index + .iter() + .map(|(path, entry)| { + ( + path.0.into_usize() as u32, + StorageFileEntry { + metadata: entry.metadata.clone(), + nodes: entry + .nodes + .iter() + .map(|i| *node_id_map.get(i).unwrap()) + .collect(), + }, + ) + }) + .collect(); + file_index.sort_unstable_by_key(|k| k.0); + + let mut reference_index: Vec<(u32, Vec)> = inner + .reference_index + .iter() + .map(|(token, paths)| { + ( + token.0.into_usize() as u32, + paths.iter().map(|p| p.0.into_usize() as u32).collect(), + ) + }) + .collect(); + reference_index.sort_unstable_by_key(|k| k.0); + + StorageGraph { + version: inner.version, + rodeo, + nodes, + edges, + fqn_index, + name_index, + file_index, + reference_index, + } +} + +pub fn from_storage( + storage: StorageGraph, + get_plugin: impl Fn(&str) -> Option>, +) -> CodeGraphInner { + let mut topology = petgraph::stable_graph::StableDiGraph::new(); + let default_plugin = Arc::new(DefaultMetadataPlugin); + + let rodeo = storage.rodeo; + let ctx = ReadOnlyStorageContext(&rodeo); + + for snode in &storage.nodes { + let lang_str = ctx.resolve_str(snode.lang_sid).to_string(); + let plugin = get_plugin(&lang_str).unwrap_or_else(|| default_plugin.clone()); + let metadata = plugin.resolve(&snode.metadata, &ctx); + + let node = GraphNode { + id: Symbol(Spur::try_from_usize(snode.id_sid as usize).unwrap()), + name: Symbol(Spur::try_from_usize(snode.name_sid as usize).unwrap()), + kind: snode.kind.clone(), + lang: Symbol(Spur::try_from_usize(snode.lang_sid as usize).unwrap()), + location: snode.location.as_ref().map(|loc| InternedLocation { + path: Symbol(Spur::try_from_usize(loc.path_id as usize).unwrap()), + range: loc.range, + selection_range: loc.selection_range, + }), + metadata, + }; + topology.add_node(node); + } + + for sedge in storage.edges { + topology.add_edge( + NodeIndex::new(sedge.from as usize), + NodeIndex::new(sedge.to as usize), + sedge.data, + ); + } + + let fqn_index = storage + .fqn_index + .into_iter() + .map(|(sid, idx)| { + ( + Symbol(Spur::try_from_usize(sid as usize).unwrap()), + NodeIndex::new(idx as usize), + ) + }) + .collect(); + + let name_index = storage + .name_index + .into_iter() + .map(|(sid, indices)| { + ( + Symbol(Spur::try_from_usize(sid as usize).unwrap()), + indices + .into_iter() + .map(|i| NodeIndex::new(i as usize)) + .collect(), + ) + }) + .collect(); + + let file_index = storage + .file_index + .into_iter() + .map(|(pid, entry)| { + ( + Symbol(Spur::try_from_usize(pid as usize).unwrap()), + FileEntry { + metadata: entry.metadata, + nodes: entry + .nodes + .into_iter() + .map(|i| NodeIndex::new(i as usize)) + .collect(), + }, + ) + }) + .collect(); + + let reference_index = storage + .reference_index + .into_iter() + .map(|(sid, paths)| { + ( + Symbol(Spur::try_from_usize(sid as usize).unwrap()), + paths + .into_iter() + .map(|pid| Symbol(Spur::try_from_usize(pid as usize).unwrap())) + .collect(), + ) + }) + .collect(); + + CodeGraphInner { + instance_id: 0, // Will be updated when wrapped in CodeGraph + version: storage.version, + topology, + symbols: rodeo, + fqn_index, + name_index, + file_index, + reference_index, + } +} diff --git a/crates/core/src/model/storage/mod.rs b/crates/core/src/model/storage/mod.rs new file mode 100644 index 0000000..eb8afc7 --- /dev/null +++ b/crates/core/src/model/storage/mod.rs @@ -0,0 +1,5 @@ +pub mod converter; +pub mod model; + +pub use converter::{from_storage, to_storage}; +pub use model::StorageGraph; diff --git a/crates/core/src/model/storage/model.rs b/crates/core/src/model/storage/model.rs new file mode 100644 index 0000000..3b07dcc --- /dev/null +++ b/crates/core/src/model/storage/model.rs @@ -0,0 +1,81 @@ +use crate::model::{GraphEdge, NodeKind, Range}; +use lasso::{Key, Rodeo}; +use serde::{Deserialize, Serialize}; + +/// Context for interning and resolving symbols during storage conversion. +pub trait StorageContext: crate::model::metadata::SymbolInterner { + fn intern_path(&mut self, p: &std::path::Path) -> u32; + fn resolve_str(&self, sid: u32) -> &str; + fn resolve_path(&self, pid: u32) -> &std::path::Path; +} + +pub struct GenericStorageContext<'a> { + pub rodeo: &'a mut Rodeo, +} + +impl crate::model::metadata::SymbolInterner for GenericStorageContext<'_> { + fn intern_str(&mut self, s: &str) -> u32 { + self.rodeo.get_or_intern(s).into_usize() as u32 + } +} + +impl<'a> StorageContext for GenericStorageContext<'a> { + fn intern_path(&mut self, p: &std::path::Path) -> u32 { + let s = p.to_string_lossy(); + crate::model::metadata::SymbolInterner::intern_str(self, s.as_ref()) + } + + fn resolve_str(&self, sid: u32) -> &str { + use lasso::{Key, Spur}; + let spur = Spur::try_from_usize(sid as usize).unwrap(); + self.rodeo.resolve(&spur) + } + + fn resolve_path(&self, pid: u32) -> &std::path::Path { + use lasso::{Key, Spur}; + let spur = Spur::try_from_usize(pid as usize).unwrap(); + std::path::Path::new(self.rodeo.resolve(&spur)) + } +} + +#[derive(Serialize, Deserialize)] +pub struct StorageGraph { + pub version: u32, + pub rodeo: Rodeo, + pub nodes: Vec, + pub edges: Vec, + pub fqn_index: Vec<(u32, u32)>, // (Symbol, NodeIdx) + pub name_index: Vec<(u32, Vec)>, // (Symbol, Vec) + pub file_index: Vec<(u32, StorageFileEntry)>, // (Symbol, Entry) + pub reference_index: Vec<(u32, Vec)>, // (Symbol, Vec) +} + +#[derive(Serialize, Deserialize)] +pub struct StorageNode { + pub id_sid: u32, + pub name_sid: u32, + pub kind: NodeKind, + pub lang_sid: u32, + pub location: Option, + pub metadata: Box<[u8]>, +} + +#[derive(Serialize, Deserialize)] +pub struct StorageLocation { + pub path_id: u32, + pub range: Range, + pub selection_range: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct StorageEdge { + pub from: u32, + pub to: u32, + pub data: GraphEdge, +} + +#[derive(Serialize, Deserialize)] +pub struct StorageFileEntry { + pub metadata: crate::model::source::SourceFile, + pub nodes: Vec, +} diff --git a/crates/core/src/model/types.rs b/crates/core/src/model/types.rs new file mode 100644 index 0000000..a5be208 --- /dev/null +++ b/crates/core/src/model/types.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::Arc; + +// Re-export core models from API layer for internal use +pub use super::metadata::{IndexMetadata, SymbolInterner}; +pub use naviscope_api::models::{ + DisplayGraphNode, DisplaySymbolLocation, EdgeType, EmptyMetadata, GraphEdge, GraphNode, + InternedLocation, Language, NodeKind, NodeMetadata, QueryResultEdge, Range, SymbolLocation, +}; + +pub type NodeLocation = SymbolLocation; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum GraphOp { + /// Add or update a node + AddNode { + #[serde(skip)] + data: Option, + }, + /// Add an edge between two nodes (referenced by their IDs) + AddEdge { + #[serde(with = "naviscope_api::models::util::serde_arc_str")] + from_id: Arc, + #[serde(with = "naviscope_api::models::util::serde_arc_str")] + to_id: Arc, + edge: GraphEdge, + }, + /// Remove all nodes and edges associated with a specific file path + RemovePath { + #[serde(with = "naviscope_api::models::util::serde_arc_path")] + path: Arc, + }, + /// Update the reference index for a specific file + UpdateIdentifiers { + #[serde(with = "naviscope_api::models::util::serde_arc_path")] + path: Arc, + identifiers: Vec, + }, + /// Update file metadata (hash, mtime) + UpdateFile { + metadata: crate::model::source::SourceFile, + }, +} + +/// Result of resolving a single file +#[derive(Debug)] +pub struct ResolvedUnit { + /// The operations needed to integrate this file into the graph + pub ops: Vec, + /// Fast access to nodes being added in this unit (before interning) + pub nodes: std::collections::HashMap, crate::ingest::parser::IndexNode>, + /// All unique identifier tokens in this file + pub identifiers: Vec, +} + +impl ResolvedUnit { + pub fn new() -> Self { + Self { + ops: Vec::new(), + nodes: std::collections::HashMap::new(), + identifiers: Vec::new(), + } + } + + pub fn add_node(&mut self, data: crate::ingest::parser::IndexNode) { + self.nodes + .insert(std::sync::Arc::from(data.id.as_str()), data.clone()); + self.ops.push(GraphOp::AddNode { data: Some(data) }); + } + + pub fn add_edge(&mut self, from_id: Arc, to_id: Arc, edge: GraphEdge) { + self.ops.push(GraphOp::AddEdge { + from_id, + to_id, + edge, + }); + } +} + +pub mod util { + pub fn line_col_at_to_offset(content: &str, line: usize, col: usize) -> Option { + let mut offset = 0; + for (i, l) in content.lines().enumerate() { + if i == line { + if col <= l.len() { + return Some(offset + col); + } + return None; + } + offset += l.len() + 1; // +1 for newline + } + None + } +} diff --git a/crates/core/src/runtime/mod.rs b/crates/core/src/runtime/mod.rs new file mode 100644 index 0000000..21237f4 --- /dev/null +++ b/crates/core/src/runtime/mod.rs @@ -0,0 +1,5 @@ +pub mod orchestrator; +pub mod plugin; +pub mod watcher; + +pub const DEFAULT_INDEX_DIR: &str = ".naviscope/indices"; diff --git a/crates/core/src/runtime/orchestrator.rs b/crates/core/src/runtime/orchestrator.rs new file mode 100644 index 0000000..de6cff7 --- /dev/null +++ b/crates/core/src/runtime/orchestrator.rs @@ -0,0 +1,499 @@ +//! Core indexing engine with MVCC support + +use crate::error::{NaviscopeError, Result}; +use crate::ingest::builder::CodeGraphBuilder; +use crate::ingest::resolver::engine::IndexResolver; +use crate::ingest::scanner::{ParsedFile, Scanner}; +use crate::model::CodeGraph; +use crate::model::GraphOp; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::RwLock; +use xxhash_rust::xxh3::xxh3_64; + +use crate::runtime::plugin::{BuildToolPlugin, LanguagePlugin}; + +/// Naviscope indexing engine +/// +/// Manages the current version of the code graph using MVCC: +/// - Readers get cheap snapshots (Arc clone) +/// - Writers create new versions and atomically swap +/// - No blocking during index updates +pub struct NaviscopeEngine { + /// Current version of the graph (double Arc for MVCC) + current: Arc>>, + + /// Project root path + project_root: PathBuf, + + /// Index storage path + index_path: PathBuf, + + /// Plugins + build_plugins: Arc>>, + lang_plugins: Arc>>, + + /// Cancellation token for background tasks (like watcher) + cancel_token: tokio_util::sync::CancellationToken, +} + +impl Drop for NaviscopeEngine { + fn drop(&mut self) { + self.cancel_token.cancel(); + } +} + +impl NaviscopeEngine { + /// Create a new engine + pub fn new(project_root: PathBuf) -> Self { + let index_path = Self::compute_index_path(&project_root); + + Self { + current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), + project_root, + index_path, + build_plugins: Arc::new(Vec::new()), + lang_plugins: Arc::new(Vec::new()), + cancel_token: tokio_util::sync::CancellationToken::new(), + } + } + + pub fn register_language(&mut self, plugin: Arc) { + let mut plugins = (*self.lang_plugins).clone(); + plugins.push(plugin); + self.lang_plugins = Arc::new(plugins); + } + + pub fn register_build_tool(&mut self, plugin: Arc) { + let mut plugins = (*self.build_plugins).clone(); + plugins.push(plugin); + self.build_plugins = Arc::new(plugins); + } + + /// Get the project root path + pub fn root_path(&self) -> &Path { + &self.project_root + } + + /// Get the index resolver configured with current plugins + pub fn get_resolver(&self) -> IndexResolver { + IndexResolver::with_plugins((*self.build_plugins).clone(), (*self.lang_plugins).clone()) + } + + /// Compute index storage path for a project + fn compute_index_path(project_root: &Path) -> PathBuf { + let base_dir = Self::get_base_index_dir(); + let abs_path = project_root + .canonicalize() + .unwrap_or_else(|_| project_root.to_path_buf()); + let hash = xxh3_64(abs_path.to_string_lossy().as_bytes()); + base_dir.join(format!("{:016x}.bin", hash)) + } + + /// Get a snapshot of the current graph (cheap operation) + pub async fn snapshot(&self) -> CodeGraph { + let lock = self.current.read().await; + (**lock).clone() // CodeGraph clone is Arc clone of inner + } + + /// Load index from disk + pub async fn load(&self) -> Result { + let path = self.index_path.clone(); + let lang_plugins = self.lang_plugins.clone(); + let build_plugins = self.build_plugins.clone(); + + // Load in blocking pool + let graph_opt = tokio::task::spawn_blocking(move || { + Self::load_from_disk(&path, lang_plugins, build_plugins) + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))??; + + if let Some(graph) = graph_opt { + // Atomically update current + let mut lock = self.current.write().await; + *lock = Arc::new(graph); + Ok(true) + } else { + Ok(false) + } + } + + /// Save current graph to disk + pub async fn save(&self) -> Result<()> { + let graph = self.snapshot().await; + let path = self.index_path.clone(); + let lang_plugins = self.lang_plugins.clone(); + let build_plugins = self.build_plugins.clone(); + + tokio::task::spawn_blocking(move || { + Self::save_to_disk(&graph, &path, lang_plugins, build_plugins) + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))? + } + + /// Rebuild the index from scratch + pub async fn rebuild(&self) -> Result<()> { + let project_root = self.project_root.clone(); + let build_plugins = self.build_plugins.clone(); + let lang_plugins = self.lang_plugins.clone(); + + // Build in blocking pool (CPU-intensive) + let new_graph = tokio::task::spawn_blocking(move || { + Self::build_index(&project_root, build_plugins, lang_plugins) + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))??; + + // Atomically update (write lock held for microseconds) + { + let mut lock = self.current.write().await; + *lock = Arc::new(new_graph); + } + + // Save to disk + self.save().await?; + + Ok(()) + } + + /// Update specific files incrementally + pub async fn update_files(&self, files: Vec) -> Result<()> { + let base_graph = self.snapshot().await; + let build_plugins = self.build_plugins.clone(); + let lang_plugins = self.lang_plugins.clone(); + + // Prepare existing file metadata for change detection + let mut existing_metadata = std::collections::HashMap::new(); + for (path, entry) in base_graph.file_index() { + existing_metadata.insert( + PathBuf::from(base_graph.symbols().resolve(&path.0)), + entry.metadata.clone(), + ); + } + + // Processing in blocking pool + let result = tokio::task::spawn_blocking( + move || -> Result, Vec)>> { + let mut manual_ops = Vec::new(); + let mut to_scan = Vec::new(); + + for path in files { + if path.exists() { + to_scan.push(path); + } else { + // File was deleted + manual_ops.push(GraphOp::RemovePath { + path: Arc::from(path.as_path()), + }); + } + } + + let parse_results = Scanner::scan_files(to_scan, &existing_metadata); + + // If nothing changed and no deletions, return early + if parse_results.is_empty() && manual_ops.is_empty() { + return Ok(None); + } + + let resolver = + IndexResolver::with_plugins((*build_plugins).clone(), (*lang_plugins).clone()); + let mut ops = resolver.resolve(parse_results.clone())?; + + // Add manual deletion ops + ops.extend(manual_ops); + + Ok(Some((ops, parse_results))) + }, + ) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))??; + + if let Some((ops, _parse_results)) = result { + // Atomically update current, checking for concurrent updates + { + let mut lock = self.current.write().await; + let current_graph = &**lock; + + if current_graph.instance_id() != base_graph.instance_id() { + tracing::info!( + "Concurrent update detected, re-applying changes to latest graph..." + ); + let mut builder = current_graph.to_builder(); + builder.apply_ops(ops)?; + *lock = Arc::new(builder.build()); + } else { + let mut builder = base_graph.to_builder(); + builder.apply_ops(ops)?; + *lock = Arc::new(builder.build()); + } + } + + // Save to disk + self.save().await?; + } + + Ok(()) + } + + /// Refresh index (detect changes and update) + pub async fn refresh(&self) -> Result<()> { + let project_root = self.project_root.clone(); + + // Scan for all current files and update incrementally + let paths = tokio::task::spawn_blocking(move || Scanner::collect_paths(&project_root)) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + self.update_files(paths).await + } + + /// Watch for filesystem changes and update incrementally + pub async fn watch(self: Arc) -> Result<()> { + use crate::runtime::watcher::Watcher; + use std::collections::HashSet; + use std::time::Duration; + + let root = self.project_root.clone(); + let mut watcher = + Watcher::new(&root).map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + let engine_weak = Arc::downgrade(&self); + let cancel_token = self.cancel_token.clone(); + + tokio::spawn(async move { + tracing::info!("Started watching {}", root.display()); + let mut pending_events: Vec = Vec::new(); + let debounce_interval = Duration::from_millis(500); + + loop { + tokio::select! { + _ = cancel_token.cancelled() => { + break; + } + event = watcher.next_event_async() => { + match event { + Some(e) => pending_events.push(e), + None => break, + } + } + _ = tokio::time::sleep(debounce_interval), if !pending_events.is_empty() => { + let mut paths = HashSet::new(); + for event in &pending_events { + for path in &event.paths { + if crate::ingest::is_relevant_path(path) { + paths.insert(path.clone()); + } + } + } + pending_events.clear(); + + if !paths.is_empty() { + if let Some(engine) = engine_weak.upgrade() { + let path_vec: Vec<_> = paths.into_iter().collect(); + tracing::info!("Detected changes in {} files. Updating...", path_vec.len()); + if let Err(err) = engine.update_files(path_vec).await { + tracing::error!("Failed to update files: {}", err); + } + } else { + break; + } + } + } + } + } + tracing::info!("File watcher task ended for {}", root.display()); + }); + + Ok(()) + } + + /// Clear the index for the current project + pub async fn clear_project_index(&self) -> Result<()> { + let path = self.index_path.clone(); + if path.exists() { + tokio::fs::remove_file(path).await?; + } + + // Reset current graph + let mut lock = self.current.write().await; + *lock = Arc::new(CodeGraph::empty()); + + Ok(()) + } + + /// Clear all indices + pub fn clear_all_indices() -> Result<()> { + let base_dir = Self::get_base_index_dir(); + if base_dir.exists() { + std::fs::remove_dir_all(&base_dir)?; + } + Ok(()) + } + + /// Gets the base directory for storing indices, supporting NAVISCOPE_INDEX_DIR env var. + pub fn get_base_index_dir() -> PathBuf { + if let Ok(env_dir) = std::env::var("NAVISCOPE_INDEX_DIR") { + return PathBuf::from(env_dir); + } + + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + Path::new(&home).join(super::DEFAULT_INDEX_DIR) + } + + // ---- Helper methods ---- + + fn load_from_disk( + path: &Path, + lang_plugins: Arc>>, + build_plugins: Arc>>, + ) -> Result> { + if !path.exists() { + return Ok(None); + } + + let bytes = std::fs::read(path)?; + + let get_plugin = |lang: &str| -> Option> { + for p in lang_plugins.iter() { + if p.name().as_str() == lang { + return Some(p.clone() as Arc); + } + } + for p in build_plugins.iter() { + if p.name().as_str() == lang { + return Some(p.clone() as Arc); + } + } + None + }; + + match CodeGraph::deserialize(&bytes, get_plugin) { + Ok(graph) => { + if graph.version() != crate::model::graph::CURRENT_VERSION { + tracing::warn!( + "Index version mismatch at {} (found {}, expected {}). Will rebuild.", + path.display(), + graph.version(), + crate::model::graph::CURRENT_VERSION + ); + let _ = std::fs::remove_file(path); + return Ok(None); + } + tracing::info!("Loaded index from {}", path.display()); + Ok(Some(graph)) + } + Err(e) => { + tracing::warn!( + "Failed to parse index at {}: {:?}. Will rebuild.", + path.display(), + e + ); + let _ = std::fs::remove_file(path); + Ok(None) + } + } + } + + fn save_to_disk( + graph: &CodeGraph, + path: &Path, + lang_plugins: Arc>>, + build_plugins: Arc>>, + ) -> Result<()> { + // Ensure directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let get_plugin = |lang: &str| -> Option> { + for p in lang_plugins.iter() { + if p.name().as_str() == lang { + return Some(p.clone() as Arc); + } + } + for p in build_plugins.iter() { + if p.name().as_str() == lang { + return Some(p.clone() as Arc); + } + } + None + }; + + // Serialize the graph + let bytes = graph.serialize(get_plugin)?; + + // Write to file atomically (write to temp, then rename) + let temp_path = path.with_extension("tmp"); + std::fs::write(&temp_path, bytes)?; + std::fs::rename(temp_path, path)?; + + tracing::info!("Saved index to {}", path.display()); + + Ok(()) + } + + fn build_index( + project_root: &Path, + build_plugins: Arc>>, + lang_plugins: Arc>>, + ) -> Result { + // Scan and parse + let parse_results = + Scanner::scan_and_parse(project_root, &std::collections::HashMap::new()); + + // Resolve + let resolver = + IndexResolver::with_plugins((*build_plugins).clone(), (*lang_plugins).clone()); + let ops = resolver.resolve(parse_results)?; + + // Build graph + let mut builder = CodeGraphBuilder::new(); + builder.apply_ops(ops)?; + + Ok(builder.build()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_snapshot_is_fast() { + let engine = NaviscopeEngine::new(PathBuf::from(".")); + + let start = std::time::Instant::now(); + for _ in 0..1000 { + let _graph = engine.snapshot().await; + } + let elapsed = start.elapsed(); + + // 1000 snapshots should be very fast + assert!(elapsed.as_millis() < 100, "Snapshots should be fast"); + } + + #[tokio::test] + async fn test_concurrent_snapshots() { + use tokio::task::JoinSet; + + let engine = Arc::new(NaviscopeEngine::new(PathBuf::from("."))); + + let mut set = JoinSet::new(); + + for _ in 0..10 { + let e = Arc::clone(&engine); + set.spawn(async move { + for _ in 0..10 { + let graph = e.snapshot().await; + assert_eq!(graph.node_count(), 0); + } + }); + } + + while let Some(result) = set.join_next().await { + result.unwrap(); + } + } +} diff --git a/crates/core/src/runtime/plugin/mod.rs b/crates/core/src/runtime/plugin/mod.rs new file mode 100644 index 0000000..c0affb0 --- /dev/null +++ b/crates/core/src/runtime/plugin/mod.rs @@ -0,0 +1,78 @@ +use crate::ingest::parser::{GlobalParseResult, LspParser}; +use crate::ingest::resolver::SemanticResolver; +use crate::model::source::{BuildTool, Language}; +use crate::model::{GraphNode, NodeMetadata}; +use naviscope_api::models::DisplayGraphNode; +use std::path::Path; +use std::sync::Arc; + +/// Interface for custom metadata encoding/decoding during storage. +pub trait MetadataPlugin: Send + Sync { + /// Intern rich metadata into an optimized binary version for storage. + fn intern( + &self, + _metadata: &dyn NodeMetadata, + _ctx: &mut dyn crate::model::storage::model::StorageContext, + ) -> Vec { + // Default implementation could return empty bytes if no metadata is needed + Vec::new() + } + + /// Resolve optimized storage binary back into rich memory metadata. + fn resolve( + &self, + bytes: &[u8], + _ctx: &dyn crate::model::storage::model::StorageContext, + ) -> Arc; +} + +/// Interface for rendering internal nodes to display nodes. +pub trait NodeRenderer: Send + Sync { + /// Convert internal GraphNode to DisplayGraphNode. + fn render_display_node(&self, node: &GraphNode, rodeo: &dyn lasso::Reader) -> DisplayGraphNode; + + /// Hydrate an existing DisplayGraphNode with rich information. + fn hydrate_display_node(&self, node: &mut DisplayGraphNode); +} + +/// Unified interface for language-specific support. +pub trait LanguagePlugin: MetadataPlugin + NodeRenderer + Send + Sync { + /// Plugin name, e.g., Language::JAVA + fn name(&self) -> Language; + + /// Supported file extensions + fn supported_extensions(&self) -> &[&str]; + + /// Execute file parsing to extract nodes and relationships + fn parse_file(&self, source: &str, path: &Path) -> crate::error::Result; + + /// Get the semantic resolver for this language + fn resolver(&self) -> Arc; + + /// Get the index resolver for this language + fn lang_resolver(&self) -> Arc; + + /// Get the LSP parser for this language + fn lsp_parser(&self) -> Arc; +} + +/// Unified interface for build tool support. +pub trait BuildToolPlugin: MetadataPlugin + NodeRenderer + Send + Sync { + /// Plugin name, e.g., BuildTool::GRADLE + fn name(&self) -> BuildTool; + + /// Checks if this plugin can handle the given file name + fn recognize(&self, file_name: &str) -> bool; + + /// Parse build-specific files + fn parse_build_file(&self, source: &str) -> crate::error::Result; + + /// Get the build resolver + fn build_resolver(&self) -> Arc; +} + +/// Result of parsing a build file +pub struct BuildParseResult { + // For now, mirroring what we have. Can be expanded. + pub content: crate::ingest::scanner::ParsedContent, +} diff --git a/src/project/watcher.rs b/crates/core/src/runtime/watcher.rs similarity index 84% rename from src/project/watcher.rs rename to crates/core/src/runtime/watcher.rs index bc51f7a..0d2fbbe 100644 --- a/src/project/watcher.rs +++ b/crates/core/src/runtime/watcher.rs @@ -11,7 +11,7 @@ pub struct Watcher { impl Watcher { pub fn new(root: &Path) -> notify::Result { let (tx, rx) = mpsc::unbounded_channel(); - + let mut watcher = RecommendedWatcher::new( move |res| { let _ = tx.send(res); @@ -36,6 +36,14 @@ impl Watcher { } } + /// Returns the next event (async). + pub async fn next_event_async(&mut self) -> Option { + match self.rx.recv().await { + Some(Ok(event)) => Some(event), + _ => None, + } + } + /// Tries to receive an event without blocking. pub fn try_next_event(&mut self) -> Option { match self.rx.try_recv() { diff --git a/crates/core/src/util.rs b/crates/core/src/util.rs new file mode 100644 index 0000000..7308e49 --- /dev/null +++ b/crates/core/src/util.rs @@ -0,0 +1,14 @@ +pub fn utf16_col_to_byte_col(content: &str, line: usize, utf16_col: usize) -> usize { + let line_content = content.lines().nth(line).unwrap_or(""); + let mut curr_utf16 = 0; + let mut curr_byte = 0; + + for c in line_content.chars() { + if curr_utf16 >= utf16_col { + break; + } + curr_utf16 += c.len_utf16(); + curr_byte += c.len_utf8(); + } + curr_byte +} diff --git a/crates/core/tests/engine_api.rs b/crates/core/tests/engine_api.rs new file mode 100644 index 0000000..ebb7582 --- /dev/null +++ b/crates/core/tests/engine_api.rs @@ -0,0 +1,51 @@ +use naviscope_api::GraphService; +use naviscope_core::facade::EngineHandle; +use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; +use std::sync::Arc; + +#[tokio::test] +async fn test_engine_lifecycle() { + let temp_dir = std::env::temp_dir().join("naviscope_test_engine"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + // Use EngineHandle::new which takes project_root + let handle = EngineHandle::new(temp_dir.clone()); + + // Get a snapshot using handle.graph() + let graph: naviscope_core::model::CodeGraph = handle.graph().await; + assert_eq!(graph.node_count(), 0); + + // Verify handle can be cloned easily + let handle2 = handle.clone(); + let graph2: naviscope_core::model::CodeGraph = handle2.graph().await; + assert_eq!(graph2.node_count(), 0); + + let _ = std::fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_engine_handle_query() { + use naviscope_api::models::GraphQuery; + + let temp_dir = std::env::temp_dir().join("naviscope_test_query"); + std::fs::create_dir_all(&temp_dir).ok(); + + let engine = Arc::new(CoreEngine::new(temp_dir.clone())); + let handle = EngineHandle::from_engine(engine); + + // Test query execution via handle + let query = GraphQuery::Find { + pattern: "test".to_string(), + kind: vec![], + limit: 5, + }; + + let result: naviscope_api::graph::Result = + handle.query(&query).await; + assert!(result.is_ok()); + + let _ = std::fs::remove_dir_all(&temp_dir); +} diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs new file mode 100644 index 0000000..e8bb195 --- /dev/null +++ b/crates/core/tests/semantic_traits.rs @@ -0,0 +1,546 @@ +use naviscope_api::models::{ + DisplayGraphNode, DisplaySymbolLocation, Language, NodeKind, Range, ReferenceQuery, + SymbolQuery, SymbolResolution, +}; +use naviscope_api::semantic::{ + CallHierarchyAnalyzer, ReferenceAnalyzer, SymbolInfoProvider, SymbolNavigator, +}; +use naviscope_core::facade::EngineHandle; +use naviscope_core::features::CodeGraphLike; +use naviscope_core::ingest::parser::{GlobalParseResult, LspParser}; +use naviscope_core::ingest::resolver::{LangResolver, ProjectContext, SemanticResolver}; +use naviscope_core::ingest::scanner::ParsedFile; +use naviscope_core::model::ResolvedUnit; +use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; +use naviscope_core::runtime::plugin::{LanguagePlugin, MetadataPlugin, NodeRenderer}; +use petgraph::stable_graph::NodeIndex; +use std::any::Any; +use std::path::Path; +use std::sync::Arc; +use tree_sitter::Tree; + +#[derive(Debug)] +struct MockMetadata { + id: String, +} + +impl naviscope_api::models::NodeMetadata for MockMetadata { + fn as_any(&self) -> &dyn Any { + self + } +} + +impl naviscope_core::model::IndexMetadata for MockMetadata { + fn as_any(&self) -> &dyn Any { + self + } + + fn intern( + &self, + _interner: &mut dyn naviscope_core::model::SymbolInterner, + ) -> Arc { + Arc::new(MockMetadata { + id: self.id.clone(), + }) + } +} + +struct MockPlugin { + resolver: Arc, + lsp_parser: Arc, + lang_resolver: Arc, +} + +impl MetadataPlugin for MockPlugin { + fn intern( + &self, + metadata: &dyn naviscope_core::model::NodeMetadata, + _ctx: &mut dyn naviscope_core::model::storage::model::StorageContext, + ) -> Vec { + if let Some(m) = metadata.as_any().downcast_ref::() { + m.id.as_bytes().to_vec() + } else { + Vec::new() + } + } + + fn resolve( + &self, + bytes: &[u8], + _ctx: &dyn naviscope_core::model::storage::model::StorageContext, + ) -> Arc { + let id = String::from_utf8_lossy(bytes).to_string(); + Arc::new(MockMetadata { id }) + } +} + +impl NodeRenderer for MockPlugin { + fn render_display_node( + &self, + node: &naviscope_core::model::GraphNode, + rodeo: &dyn lasso::Reader, + ) -> DisplayGraphNode { + let mut display = DisplayGraphNode { + id: node.fqn(rodeo).to_string(), + name: node.name(rodeo).to_string(), + kind: node.kind.clone(), + lang: node.language(rodeo).to_string(), + location: node.location.as_ref().map(|l| l.to_display(rodeo)), + detail: None, + signature: None, + modifiers: vec![], + children: None, + }; + + if let Some(m) = node.metadata.as_any().downcast_ref::() { + display.detail = Some(format!("Mock detail for {}", m.id)); + display.signature = Some(format!("Mock signature for {}", m.id)); + display.modifiers = vec!["mock".to_string()]; + } + + display + } + + fn hydrate_display_node(&self, node: &mut DisplayGraphNode) { + node.detail = Some(format!("Mock detail for {}", node.id)); + node.signature = Some(format!("Mock signature for {}", node.id)); + node.modifiers = vec!["mock".to_string()]; + } +} + +impl LanguagePlugin for MockPlugin { + fn name(&self) -> Language { + Language::new("mock") + } + fn supported_extensions(&self) -> &[&str] { + &["mock"] + } + fn parse_file( + &self, + _source: &str, + _path: &Path, + ) -> naviscope_core::error::Result { + Ok(GlobalParseResult { + package_name: None, + imports: vec![], + output: naviscope_core::ingest::parser::ParseOutput { + nodes: vec![], + relations: vec![], + identifiers: vec!["Callee".to_string()], + ..Default::default() + }, + source: Some(_source.to_string()), + tree: None, + }) + } + fn resolver(&self) -> Arc { + self.resolver.clone() + } + fn lang_resolver(&self) -> Arc { + self.lang_resolver.clone() + } + fn lsp_parser(&self) -> Arc { + Arc::new(MockLspParserWrapper { + parser: self.lsp_parser.clone(), + plugin: Arc::new(self.clone_internal()), + }) + } +} + +impl MockPlugin { + fn clone_internal(&self) -> Self { + Self { + resolver: self.resolver.clone(), + lsp_parser: self.lsp_parser.clone(), + lang_resolver: self.lang_resolver.clone(), + } + } +} + +struct MockLspParserWrapper { + parser: Arc, + plugin: Arc, +} + +impl LspParser for MockLspParserWrapper { + fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { + self.parser.parse(source, old_tree) + } + + fn extract_symbols(&self, tree: &Tree, source: &str) -> Vec { + let mut symbols = self.parser.extract_symbols(tree, source); + for sym in &mut symbols { + self.plugin.hydrate_display_node(sym); + } + symbols + } + + fn symbol_kind(&self, kind: &naviscope_core::model::NodeKind) -> lsp_types::SymbolKind { + self.parser.symbol_kind(kind) + } + + fn find_occurrences( + &self, + source: &str, + tree: &Tree, + target: &naviscope_core::ingest::parser::SymbolResolution, + ) -> Vec { + self.parser.find_occurrences(source, tree, target) + } +} + +struct MockLangResolver { + nodes: std::sync::Mutex>, +} + +impl LangResolver for MockLangResolver { + fn resolve( + &self, + file: &ParsedFile, + _context: &ProjectContext, + ) -> naviscope_core::error::Result { + let mut unit = ResolvedUnit::new(); + + let identifiers = match &file.content { + naviscope_core::ingest::scanner::ParsedContent::Language(res) => { + res.output.identifiers.clone() + } + naviscope_core::ingest::scanner::ParsedContent::Unparsed(_src) => { + vec!["Callee".to_string()] + } + _ => vec![], + }; + + if !identifiers.is_empty() { + unit.identifiers = identifiers.clone(); + unit.ops + .push(naviscope_core::model::GraphOp::UpdateIdentifiers { + path: file.file.path.clone().into(), + identifiers: unit.identifiers.clone(), + }); + } + + let nodes = self.nodes.lock().unwrap(); + for node in nodes.iter() { + unit.add_node(node.clone()); + } + Ok(unit) + } +} + +struct MockResolver { + res_at: std::sync::Mutex>, +} + +impl SemanticResolver for MockResolver { + fn resolve_at( + &self, + _tree: &Tree, + _source: &str, + _line: usize, + _byte_col: usize, + _index: &dyn CodeGraphLike, + ) -> Option { + self.res_at.lock().unwrap().clone() + } + + fn find_matches(&self, index: &dyn CodeGraphLike, res: &SymbolResolution) -> Vec { + if let SymbolResolution::Global(id) = res { + if let Some(idx) = index.find_node(id.as_str()) { + return vec![idx]; + } + } + vec![] + } + + fn resolve_type_of( + &self, + _index: &dyn CodeGraphLike, + _res: &SymbolResolution, + ) -> Vec { + vec![SymbolResolution::Global("test::Type".to_string())] + } + + fn find_implementations( + &self, + index: &dyn CodeGraphLike, + _res: &SymbolResolution, + ) -> Vec { + if let Some(idx) = index.find_node("test::Impl") { + return vec![idx]; + } + vec![] + } +} + +struct MockLspParser; +impl LspParser for MockLspParser { + fn parse(&self, source: &str, _old_tree: Option<&Tree>) -> Option { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&tree_sitter_java::LANGUAGE.into()) + .ok()?; + parser.parse(source, None) + } + fn extract_symbols(&self, _tree: &Tree, _source: &str) -> Vec { + vec![] + } + fn symbol_kind(&self, _kind: &naviscope_core::model::NodeKind) -> lsp_types::SymbolKind { + lsp_types::SymbolKind::CLASS + } + fn find_occurrences( + &self, + _source: &str, + _tree: &Tree, + target: &SymbolResolution, + ) -> Vec { + if let SymbolResolution::Global(id) = target { + if id == "test::Callee" { + return vec![Range { + start_line: 1, + start_col: 1, + end_line: 1, + end_col: 5, + }]; + } + } + vec![] + } +} + +fn setup_engine(temp_dir: &Path) -> (CoreEngine, Arc) { + let mut engine = CoreEngine::new(temp_dir.to_path_buf()); + let mock_resolver = Arc::new(MockResolver { + res_at: std::sync::Mutex::new(None), + }); + let mock_parser = Arc::new(MockLspParser); + let mock_lang_resolver = Arc::new(MockLangResolver { + nodes: std::sync::Mutex::new(vec![]), + }); + let plugin = Arc::new(MockPlugin { + resolver: mock_resolver, + lsp_parser: mock_parser, + lang_resolver: mock_lang_resolver, + }); + engine.register_language(plugin.clone()); + (engine, plugin) +} + +#[tokio::test] +async fn test_symbol_navigator_queries() { + let temp_dir = std::env::temp_dir().join("naviscope_test_navigator_real"); + std::fs::create_dir_all(&temp_dir).ok(); + + let (engine, plugin) = setup_engine(&temp_dir); + + // Add a node to the mock plugin's resolver + { + let mut nodes = plugin.lang_resolver.nodes.lock().unwrap(); + nodes.push(naviscope_core::ingest::parser::IndexNode { + id: "test::Symbol".to_string(), + name: "Symbol".to_string(), + kind: NodeKind::Class, + lang: "mock".to_string(), + location: Some(DisplaySymbolLocation { + path: temp_dir.join("test.mock").to_string_lossy().to_string(), + range: Range { + start_line: 0, + start_col: 0, + end_line: 0, + end_col: 10, + }, + selection_range: None, + }), + metadata: Arc::new(MockMetadata { + id: "test::Symbol".to_string(), + }), + }); + } + + let test_file = temp_dir.join("test.mock"); + std::fs::write(&test_file, "mock content").unwrap(); + + // Trigger update to populate graph + engine.update_files(vec![test_file.clone()]).await.unwrap(); + + let handle = EngineHandle::from_engine(Arc::new(engine)); + + // Test find_definitions + let query = SymbolQuery { + language: Language::new("mock"), + resolution: SymbolResolution::Global("test::Symbol".to_string()), + }; + + let defs = handle.find_definitions(&query).await.unwrap(); + assert_eq!(defs.len(), 1); + assert_eq!(defs[0].path.as_os_str(), test_file.as_os_str()); +} + +#[tokio::test] +async fn test_reference_analyzer() { + let temp_dir = std::env::temp_dir().join("naviscope_test_references"); + std::fs::create_dir_all(&temp_dir).ok(); + + let (engine, _) = setup_engine(&temp_dir); + let handle = EngineHandle::from_engine(Arc::new(engine)); + + let query = ReferenceQuery { + language: Language::new("mock"), + resolution: SymbolResolution::Global("test::Symbol".to_string()), + include_declaration: true, + }; + + let refs = handle.find_references(&query).await.unwrap(); + assert!(refs.is_empty()); +} + +#[tokio::test] +async fn test_symbol_info_provider() { + let temp_dir = std::env::temp_dir().join("naviscope_test_info"); + std::fs::create_dir_all(&temp_dir).ok(); + + let (engine, _) = setup_engine(&temp_dir); + let handle = EngineHandle::from_engine(Arc::new(engine)); + + let test_file = temp_dir.join("test.mock"); + std::fs::write(&test_file, "mock content").unwrap(); + let uri = format!("file://{}", test_file.display()); + + let lang = handle.get_language_for_document(&uri).await.unwrap(); + assert_eq!(lang, Some(Language::new("mock"))); + + let symbols = handle.get_document_symbols(&uri).await.unwrap(); + assert!(symbols.is_empty()); +} + +#[tokio::test] +async fn test_call_hierarchy_analyzer() { + let temp_dir = std::env::temp_dir().join("naviscope_test_hierarchy"); + std::fs::create_dir_all(&temp_dir).ok(); + + let (engine, plugin) = setup_engine(&temp_dir); + + let test_file = temp_dir.join("test.mock"); + let test_file_path = test_file.to_string_lossy().to_string(); + + // Add caller and callee nodes + { + let mut nodes = plugin.lang_resolver.nodes.lock().unwrap(); + // Callee + nodes.push(naviscope_core::ingest::parser::IndexNode { + id: "test::Callee".to_string(), + name: "Callee".to_string(), + kind: NodeKind::Method, + lang: "mock".to_string(), + location: Some(DisplaySymbolLocation { + path: test_file_path.clone(), + range: Range { + start_line: 5, + start_col: 0, + end_line: 5, + end_col: 10, + }, + selection_range: None, + }), + metadata: Arc::new(MockMetadata { + id: "test::Callee".to_string(), + }), + }); + // Caller + nodes.push(naviscope_core::ingest::parser::IndexNode { + id: "test::Caller".to_string(), + name: "Caller".to_string(), + kind: NodeKind::Method, + lang: "mock".to_string(), + location: Some(DisplaySymbolLocation { + path: test_file_path.clone(), + range: Range { + start_line: 0, + start_col: 0, + end_line: 2, + end_col: 10, + }, + selection_range: None, + }), + metadata: Arc::new(MockMetadata { + id: "test::Caller".to_string(), + }), + }); + } + + std::fs::write(&test_file, "caller calls callee").unwrap(); + engine.update_files(vec![test_file.clone()]).await.unwrap(); + + // Set mock resolution for verification (needed by scan_file) + *plugin.resolver.res_at.lock().unwrap() = + Some(SymbolResolution::Global("test::Callee".to_string())); + + let handle = EngineHandle::from_engine(Arc::new(engine)); + + // 1. Test Incoming Calls (Who calls Callee?) + let incoming = handle.find_incoming_calls("test::Callee").await.unwrap(); + assert_eq!(incoming.len(), 1); + assert_eq!(incoming[0].from.id, "test::Caller"); + assert_eq!(incoming[0].from_ranges.len(), 1); + assert_eq!(incoming[0].from_ranges[0].start_line, 1); + + // 2. Test Outgoing Calls (Who does Caller call?) + let outgoing = handle.find_outgoing_calls("test::Caller").await.unwrap(); + assert!(!outgoing.is_empty()); + assert_eq!(outgoing[0].to.id, "test::Callee"); +} + +#[tokio::test] +async fn test_get_symbol_info() { + let temp_dir = std::env::temp_dir().join("naviscope_test_symbol_info_final"); + std::fs::create_dir_all(&temp_dir).ok(); + + let (engine, plugin) = setup_engine(&temp_dir); + + // Add a node to the mock plugin's resolver + { + let mut nodes = plugin.lang_resolver.nodes.lock().unwrap(); + nodes.push(naviscope_core::ingest::parser::IndexNode { + id: "test::Symbol".to_string(), + name: "Symbol".to_string(), + kind: NodeKind::Class, + lang: "mock".to_string(), + location: Some(DisplaySymbolLocation { + path: temp_dir.join("test.mock").to_string_lossy().to_string(), + range: Range { + start_line: 0, + start_col: 0, + end_line: 0, + end_col: 10, + }, + selection_range: None, + }), + metadata: Arc::new(MockMetadata { + id: "test::Symbol".to_string(), + }), + }); + } + + let test_file = temp_dir.join("test.mock"); + std::fs::write(&test_file, "mock content").unwrap(); + + // Trigger update to populate graph + engine.update_files(vec![test_file.clone()]).await.unwrap(); + + let handle = EngineHandle::from_engine(Arc::new(engine)); + + // Test get_symbol_info + let info = handle.get_symbol_info("test::Symbol").await.unwrap(); + assert!(info.is_some()); + let info = info.unwrap(); + assert_eq!(info.name, "Symbol"); + assert_eq!( + info.detail, + Some("Mock detail for test::Symbol".to_string()) + ); + assert_eq!( + info.signature, + Some("Mock signature for test::Symbol".to_string()) + ); + assert_eq!(info.lang, "mock"); +} diff --git a/crates/lang-gradle/Cargo.toml b/crates/lang-gradle/Cargo.toml new file mode 100644 index 0000000..bf28ffd --- /dev/null +++ b/crates/lang-gradle/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "naviscope-gradle" +version = "0.5.0" +edition = "2024" + +[dependencies] +naviscope-core = { workspace = true } +naviscope-api = { workspace = true } +tree-sitter = { workspace = true } +serde = { workspace = true } +tree-sitter-groovy = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +petgraph = { workspace = true } +once_cell = { workspace = true } +rmp-serde = { workspace = true } +lasso = { workspace = true } diff --git a/crates/lang-gradle/src/lib.rs b/crates/lang-gradle/src/lib.rs new file mode 100644 index 0000000..9266d2f --- /dev/null +++ b/crates/lang-gradle/src/lib.rs @@ -0,0 +1,136 @@ +pub mod model; +pub mod parser; +pub mod queries; +pub mod resolver; + +use naviscope_api::models::DisplayGraphNode; +use naviscope_core::error::Result; +use naviscope_core::ingest::resolver::BuildResolver; +use naviscope_core::ingest::scanner::ParsedContent; +use naviscope_core::model::source::BuildTool; +use naviscope_core::runtime::plugin::{ + BuildParseResult, BuildToolPlugin, MetadataPlugin, NodeRenderer, +}; +use std::sync::Arc; + +pub struct GradlePlugin { + resolver: Arc, +} + +impl NodeRenderer for GradlePlugin { + fn render_display_node( + &self, + node: &naviscope_core::model::GraphNode, + rodeo: &dyn lasso::Reader, + ) -> DisplayGraphNode { + let mut display = DisplayGraphNode { + id: node.fqn(rodeo).to_string(), + name: node.name(rodeo).to_string(), + kind: node.kind.clone(), + lang: "gradle".to_string(), + location: node.location.as_ref().map(|l| l.to_display(rodeo)), + detail: None, + signature: None, + modifiers: vec![], + children: None, + }; + + if let Some(gradle_meta) = node + .metadata + .as_any() + .downcast_ref::() + { + display.detail = gradle_meta.detail_view(rodeo); + } + + display + } + + fn hydrate_display_node(&self, _node: &mut DisplayGraphNode) { + // Hydration logic is currently disabled as DisplayGraphNode no longer carries raw metadata. + } +} + +impl GradlePlugin { + pub fn new() -> Self { + Self { + resolver: Arc::new(resolver::GradleResolver::new()), + } + } +} + +impl MetadataPlugin for GradlePlugin { + fn intern( + &self, + metadata: &dyn naviscope_core::model::NodeMetadata, + _ctx: &mut dyn naviscope_core::model::storage::model::StorageContext, + ) -> Vec { + if let Some(gradle_meta) = metadata + .as_any() + .downcast_ref::() + { + rmp_serde::to_vec(&gradle_meta.element).unwrap_or_default() + } else { + Vec::new() + } + } + + fn resolve( + &self, + bytes: &[u8], + _ctx: &dyn naviscope_core::model::storage::model::StorageContext, + ) -> Arc { + if let Ok(element) = rmp_serde::from_slice::(bytes) { + Arc::new(crate::model::GradleNodeMetadata::new(element)) + } else { + Arc::new(naviscope_core::model::EmptyMetadata) + } + } +} + +impl BuildToolPlugin for GradlePlugin { + fn name(&self) -> BuildTool { + BuildTool::GRADLE + } + + fn recognize(&self, file_name: &str) -> bool { + file_name == "build.gradle" + || file_name == "build.gradle.kts" + || file_name == "settings.gradle" + || file_name == "settings.gradle.kts" + } + + fn parse_build_file(&self, source: &str) -> Result { + // This is a bit tricky because the original code had separate methods for build vs settings. + // For now, let's keep it simple or just expose the resolver. + // Actually, the plugin trait needs to be implemented. + + // This is a placeholder as the original scan_and_parse logic was hardcoded. + // We might want to move that logic into the plugin. + // For now, let's just return a dummy or implement basic dispatch. + if source.contains("include") && (source.contains("'") || source.contains("\"")) { + let settings = + parser::parse_settings(source).unwrap_or_else(|_| model::GradleSettings { + root_project_name: None, + included_projects: Vec::new(), + }); + Ok(BuildParseResult { + content: ParsedContent::MetaData( + serde_json::to_value(settings).unwrap_or(serde_json::Value::Null), + ), + }) + } else { + let deps = parser::parse_dependencies(source).unwrap_or_default(); + Ok(BuildParseResult { + content: ParsedContent::MetaData( + serde_json::to_value(model::GradleParseResult { dependencies: deps }) + .unwrap_or(serde_json::Value::Null), + ), + }) + } + } + + fn build_resolver(&self) -> Arc { + self.resolver.clone() + } +} diff --git a/crates/lang-gradle/src/model.rs b/crates/lang-gradle/src/model.rs new file mode 100644 index 0000000..57df6bf --- /dev/null +++ b/crates/lang-gradle/src/model.rs @@ -0,0 +1,149 @@ +use lasso::{Key, Reader, Spur}; +use naviscope_api::models::NodeMetadata; +use naviscope_core::model::metadata::{IndexMetadata, SymbolInterner}; +use naviscope_core::model::storage::model::StorageContext; +use serde::{Deserialize, Serialize}; +use std::any::Any; +use std::sync::Arc; + +#[derive(Debug)] +pub struct GradleNodeMetadata { + pub element: GradleStorageElement, +} + +impl GradleNodeMetadata { + pub fn new(element: GradleStorageElement) -> Self { + Self { element } + } + + pub fn detail_view(&self, rodeo: &dyn Reader) -> Option { + match &self.element { + GradleStorageElement::Dependency(d) => { + let mut detail = String::new(); + if let Some(group_sid) = d.group_sid { + detail.push_str( + rodeo.resolve(&Spur::try_from_usize(group_sid as usize).unwrap()), + ); + } + if let Some(version_sid) = d.version_sid { + if !detail.is_empty() { + detail.push(':'); + } + detail.push_str( + rodeo.resolve(&Spur::try_from_usize(version_sid as usize).unwrap()), + ); + } + if d.is_project { + detail.push_str(" (Project)"); + } + if detail.is_empty() { + None + } else { + Some(detail) + } + } + _ => None, + } + } +} + +impl NodeMetadata for GradleNodeMetadata { + fn as_any(&self) -> &dyn Any { + self + } +} + +impl IndexMetadata for GradleNodeMetadata { + fn as_any(&self) -> &dyn Any { + self + } + + fn intern(&self, _interner: &mut dyn SymbolInterner) -> Arc { + // GradleNodeMetadata is already interned (contains GradleStorageElement) + Arc::new(GradleNodeMetadata { + element: self.element.clone(), + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum GradleElement { + Module(GradleModule), + Dependency(GradleDependency), +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum GradleStorageElement { + Module(GradleModuleStorage), + Dependency(GradleDependencyStorage), +} + +impl GradleElement { + pub fn intern(&self, ctx: &mut dyn StorageContext) -> GradleStorageElement { + match self { + GradleElement::Module(_) => GradleStorageElement::Module(GradleModuleStorage {}), + GradleElement::Dependency(d) => { + GradleStorageElement::Dependency(GradleDependencyStorage { + group_sid: d.group.as_ref().map(|s| ctx.intern_str(s)), + version_sid: d.version.as_ref().map(|s| ctx.intern_str(s)), + is_project: d.is_project, + }) + } + } + } +} + +impl GradleStorageElement { + pub fn resolve(&self, ctx: &dyn StorageContext) -> GradleElement { + match self { + GradleStorageElement::Module(_) => GradleElement::Module(GradleModule {}), + GradleStorageElement::Dependency(d) => GradleElement::Dependency(GradleDependency { + group: d.group_sid.map(|sid| ctx.resolve_str(sid).to_string()), + version: d.version_sid.map(|sid| ctx.resolve_str(sid).to_string()), + is_project: d.is_project, + }), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GradleModule {} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GradleModuleStorage {} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GradleDependency { + pub group: Option, + pub version: Option, + pub is_project: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GradleDependencyStorage { + pub group_sid: Option, + pub version_sid: Option, + pub is_project: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GradleParseResult { + pub dependencies: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct RawGradleDependency { + pub group: Option, + pub name: String, + pub version: Option, + pub is_project: bool, + pub id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GradleSettings { + pub root_project_name: Option, + pub included_projects: Vec, +} diff --git a/src/parser/gradle.rs b/crates/lang-gradle/src/parser.rs similarity index 51% rename from src/parser/gradle.rs rename to crates/lang-gradle/src/parser.rs index d095359..b2764eb 100644 --- a/src/parser/gradle.rs +++ b/crates/lang-gradle/src/parser.rs @@ -1,16 +1,29 @@ -use crate::error::{NaviscopeError, Result}; -use crate::model::lang::gradle::{GradleDependency, GradleSettings}; -use tree_sitter::{Parser, QueryCursor, StreamingIterator}; +use crate::model::{GradleSettings, RawGradleDependency}; +use naviscope_core::error::{NaviscopeError, Result}; +use once_cell::sync::Lazy; +use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator}; + +use crate::queries::gradle_definitions::GradleIndices; + +/// Cached Gradle query to avoid re-parsing the SCM file on every parse call. +static GRADLE_QUERY: Lazy = Lazy::new(|| { + let language: tree_sitter::Language = tree_sitter_groovy::LANGUAGE.into(); + match Query::new(&language, include_str!("queries/gradle_definitions.scm")) { + Ok(q) => q, + Err(e) => { + panic!("Failed to load Gradle query: {}. Error message: {}", e, e.message); + } + } +}); -unsafe extern "C" { - fn tree_sitter_groovy() -> tree_sitter::Language; +/// Gets the cached Gradle query. +fn get_gradle_query() -> &'static Query { + &GRADLE_QUERY } -use crate::parser::queries::gradle_definitions::GradleIndices; - -pub fn parse_dependencies(source_code: &str) -> Result> { +pub fn parse_dependencies(source_code: &str) -> Result> { let mut parser = Parser::new(); - let language = unsafe { tree_sitter_groovy() }; + let language: tree_sitter::Language = tree_sitter_groovy::LANGUAGE.into(); parser .set_language(&language) .map_err(|e| NaviscopeError::Parsing(e.to_string()))?; @@ -19,12 +32,9 @@ pub fn parse_dependencies(source_code: &str) -> Result> { .parse(source_code, None) .ok_or_else(|| NaviscopeError::Parsing("Failed to parse gradle file".to_string()))?; - let query = crate::parser::utils::load_query( - &language, - include_str!("queries/gradle_definitions.scm"), - )?; + let query = get_gradle_query(); - let indices = GradleIndices::new(&query)?; + let indices = GradleIndices::new(query)?; let mut query_cursor = QueryCursor::new(); let mut matches = query_cursor.matches(&query, tree.root_node(), source_code.as_bytes()); @@ -40,7 +50,7 @@ pub fn parse_dependencies(source_code: &str) -> Result> { let dependency_str = &source_code[range.start + 1..range.end - 1]; let parts: Vec<&str> = dependency_str.split(':').collect(); if parts.len() == 3 { - dependencies.push(GradleDependency { + dependencies.push(RawGradleDependency { group: Some(parts[0].to_string()), name: parts[1].to_string(), version: Some(parts[2].to_string()), @@ -53,12 +63,20 @@ pub fn parse_dependencies(source_code: &str) -> Result> { } // 2. Project dependencies - if let Some(_cap) = mat.captures.iter().find(|c| c.index == indices.project_item) { - if let Some(path_cap) = mat.captures.iter().find(|c| c.index == indices.project_path) { + if let Some(_cap) = mat + .captures + .iter() + .find(|c| c.index == indices.project_item) + { + if let Some(path_cap) = mat + .captures + .iter() + .find(|c| c.index == indices.project_path) + { let range = path_cap.node.byte_range(); if range.end - range.start >= 2 { let project_path = &source_code[range.start + 1..range.end - 1]; - dependencies.push(GradleDependency { + dependencies.push(RawGradleDependency { group: None, name: project_path.to_string(), version: None, @@ -75,21 +93,18 @@ pub fn parse_dependencies(source_code: &str) -> Result> { pub fn parse_settings(source_code: &str) -> Result { let mut parser = Parser::new(); - let language = unsafe { tree_sitter_groovy() }; + let language: tree_sitter::Language = tree_sitter_groovy::LANGUAGE.into(); parser .set_language(&language) .map_err(|e| NaviscopeError::Parsing(e.to_string()))?; - let tree = parser - .parse(source_code, None) - .ok_or_else(|| NaviscopeError::Parsing("Failed to parse gradle settings file".to_string()))?; + let tree = parser.parse(source_code, None).ok_or_else(|| { + NaviscopeError::Parsing("Failed to parse gradle settings file".to_string()) + })?; - let query = crate::parser::utils::load_query( - &language, - include_str!("queries/gradle_definitions.scm"), - )?; + let query = get_gradle_query(); - let indices = GradleIndices::new(&query)?; + let indices = GradleIndices::new(query)?; let mut query_cursor = QueryCursor::new(); let mut matches = query_cursor.matches(&query, tree.root_node(), source_code.as_bytes()); @@ -100,9 +115,11 @@ pub fn parse_settings(source_code: &str) -> Result { while let Some(mat) = matches.next() { // Root project name let mut found_root = false; - if let Some(_) = mat.captures.iter().find(|c| c.index == indices.root_assignment) { - found_root = true; - } else if let Some(_) = mat.captures.iter().find(|c| c.index == indices.root_assignment_alt) { + if let Some(_) = mat + .captures + .iter() + .find(|c| c.index == indices.root_assignment) + { found_root = true; } @@ -110,14 +127,23 @@ pub fn parse_settings(source_code: &str) -> Result { if let Some(name_cap) = mat.captures.iter().find(|c| c.index == indices.root_name) { let range = name_cap.node.byte_range(); if range.end - range.start >= 2 { - root_project_name = Some(source_code[range.start + 1..range.end - 1].to_string()); + root_project_name = + Some(source_code[range.start + 1..range.end - 1].to_string()); } } } // Included projects - if let Some(_) = mat.captures.iter().find(|c| c.index == indices.include_call) { - if let Some(path_cap) = mat.captures.iter().find(|c| c.index == indices.included_path) { + if let Some(_) = mat + .captures + .iter() + .find(|c| c.index == indices.include_call) + { + if let Some(path_cap) = mat + .captures + .iter() + .find(|c| c.index == indices.included_path) + { let range = path_cap.node.byte_range(); if range.end - range.start >= 2 { included_projects.push(source_code[range.start + 1..range.end - 1].to_string()); @@ -166,8 +192,61 @@ mod tests { "#; let settings = parse_settings(settings_file).unwrap(); - assert_eq!(settings.root_project_name, Some("spring-boot-build".to_string())); + assert_eq!( + settings.root_project_name, + Some("spring-boot-build".to_string()) + ); assert_eq!(settings.included_projects.len(), 2); assert_eq!(settings.included_projects[0], "core:spring-boot"); } + + #[test] + fn test_parse_settings_multi_include() { + let settings_file = r#" + include 'a', 'b', "c" + include 'd' + "#; + + let settings = parse_settings(settings_file).unwrap(); + assert_eq!(settings.included_projects.len(), 4); + assert!(settings.included_projects.contains(&"a".to_string())); + assert!(settings.included_projects.contains(&"b".to_string())); + assert!(settings.included_projects.contains(&"c".to_string())); + assert!(settings.included_projects.contains(&"d".to_string())); + } + + #[test] + fn test_parse_complex_spring_boot_settings() { + let settings_file = r#" + pluginManagement { + repositories { + gradlePluginPortal() + } + } + rootProject.name = "spring-boot-build" + include "spring-boot-project:spring-boot" + include "spring-boot-project:spring-boot-actuator" + include ":smoke-test:spring-boot-smoke-test-xml" + "#; + + let settings = parse_settings(settings_file).unwrap(); + + assert_eq!( + settings.root_project_name, + Some("spring-boot-build".to_string()), + "Failed to parse double-quoted rootProject.name" + ); + + assert!( + settings + .included_projects + .contains(&"spring-boot-project:spring-boot".to_string()), + "Failed to parse double-quoted include" + ); + assert!( + settings + .included_projects + .contains(&":smoke-test:spring-boot-smoke-test-xml".to_string()) + ); + } } diff --git a/src/parser/queries/gradle_definitions.rs b/crates/lang-gradle/src/queries/gradle_definitions.rs similarity index 77% rename from src/parser/queries/gradle_definitions.rs rename to crates/lang-gradle/src/queries/gradle_definitions.rs index 22dfa73..823ab9c 100644 --- a/src/parser/queries/gradle_definitions.rs +++ b/crates/lang-gradle/src/queries/gradle_definitions.rs @@ -1,11 +1,10 @@ -crate::decl_indices!(GradleIndices, { +naviscope_core::decl_indices!(GradleIndices, { block => "dependencies_block", dep_string => "dep_string", item => "dependency_item", project_item => "project_dependency_item", project_path => "project_path", root_assignment => "root_project_assignment", - root_assignment_alt => "root_project_assignment_alt", root_name => "root_name", include_call => "include_call", included_path => "included_path", diff --git a/crates/lang-gradle/src/queries/gradle_definitions.scm b/crates/lang-gradle/src/queries/gradle_definitions.scm new file mode 100644 index 0000000..6651614 --- /dev/null +++ b/crates/lang-gradle/src/queries/gradle_definitions.scm @@ -0,0 +1,72 @@ +;; Pattern for the dependencies block +( + [ + (method_invocation name: (identifier) @name) + (juxt_function_call name: (identifier) @name) + ] + (#eq? @name "dependencies") +) @dependencies_block + +;; Pattern for dependency items (External libraries) +( + [ + (method_invocation + name: (identifier) @method_name + arguments: (argument_list [ (string_literal) (character_literal) ] @dep_string)) + (juxt_function_call + name: (identifier) @method_name + args: (argument_list [ (string_literal) (character_literal) ] @dep_string)) + ] + (#match? @method_name "^(implementation|api|testImplementation|compileOnly|runtimeOnly|annotationProcessor)$") +) @dependency_item + +;; Pattern for project dependencies (Parentheses) +(method_invocation + name: (identifier) @method_name + arguments: (argument_list + (method_invocation + name: (identifier) @proj_fn + arguments: (argument_list [ (string_literal) (character_literal) ] @project_path)))) @project_dependency_item +(#match? @method_name "^(implementation|api|testImplementation|compileOnly|runtimeOnly|annotationProcessor)$") +(#eq? @proj_fn "project") + +;; Pattern for project dependencies (Juxt + Sibling in closure) +( + (juxt_function_call + name: (identifier) @method_name + args: (argument_list (identifier) @proj_fn)) @project_dependency_item + . + (expression_statement (parenthesized_expression [ (string_literal) (character_literal) ] @project_path)) + (#match? @method_name "^(implementation|api|testImplementation|compileOnly|runtimeOnly|annotationProcessor)$") + (#eq? @proj_fn "project") +) + +;; Pattern for settings.gradle: rootProject.name = '...' +( + [ + (assignment_expression + left: (field_access + object: (identifier) @obj + field: (identifier) @prop) + right: [ (string_literal) (character_literal) ] @root_name) + (method_invocation + object: (identifier) @obj + name: (identifier) @prop + arguments: (argument_list [ (string_literal) (character_literal) ] @root_name)) + ] + (#eq? @obj "rootProject") + (#eq? @prop "name") +) @root_project_assignment + +;; Support multiple arguments in include +( + [ + (method_invocation + name: (identifier) @include_fn + arguments: (argument_list [ (string_literal) (character_literal) ] @included_path)) + (juxt_function_call + name: (identifier) @include_fn + args: (argument_list [ (string_literal) (character_literal) ] @included_path)) + ] + (#eq? @include_fn "include") +) @include_call diff --git a/src/parser/queries/mod.rs b/crates/lang-gradle/src/queries/mod.rs similarity index 51% rename from src/parser/queries/mod.rs rename to crates/lang-gradle/src/queries/mod.rs index 4da79ab..11d8d6f 100644 --- a/src/parser/queries/mod.rs +++ b/crates/lang-gradle/src/queries/mod.rs @@ -1,2 +1 @@ pub mod gradle_definitions; -pub mod java_definitions; diff --git a/crates/lang-gradle/src/resolver.rs b/crates/lang-gradle/src/resolver.rs new file mode 100644 index 0000000..f558b53 --- /dev/null +++ b/crates/lang-gradle/src/resolver.rs @@ -0,0 +1,440 @@ +use naviscope_core::error::Result; +use naviscope_core::ingest::resolver::{BuildResolver, ProjectContext}; +use naviscope_core::ingest::scanner::{ParsedContent, ParsedFile}; +use naviscope_core::model::{ + DisplaySymbolLocation, EdgeType, GraphEdge, NodeKind, Range, ResolvedUnit, +}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub struct GradleResolver; + +impl GradleResolver { + pub fn new() -> Self { + Self + } + + /// Standardizes a path to ensure consistency across different OS platforms and symlinks. + fn normalize_path(&self, path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + } +} + +impl BuildResolver for GradleResolver { + fn resolve(&self, files: &[&ParsedFile]) -> Result<(ResolvedUnit, ProjectContext)> { + let mut unit = ResolvedUnit::new(); + let mut context = ProjectContext::new(); + + // --- Step 1: Discover all potential module paths --- + let mut module_map: HashMap = HashMap::new(); + + for file in files { + let dir_path = self.normalize_path(file.file.path.parent().unwrap()); + + let data = module_map + .entry(dir_path.clone()) + .or_insert_with(|| ModuleData { + build_file: None, + settings_file: None, + }); + + match &file.content { + ParsedContent::MetaData(value) => { + // Try to deserialize as GradleParseResult first + if let Ok(gradle_result) = + serde_json::from_value::(value.clone()) + { + data.build_file = Some((file, gradle_result)); + } else if let Ok(settings) = + serde_json::from_value::(value.clone()) + { + data.settings_file = Some((file, settings)); + } + } + ParsedContent::Unparsed(content_str) => { + let path = &file.file.path.clone(); + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name == "build.gradle" || name == "build.gradle.kts" { + if let Ok(deps) = crate::parser::parse_dependencies(content_str) { + let res = crate::model::GradleParseResult { dependencies: deps }; + data.build_file = Some((file, res)); + } + } else if name == "settings.gradle" || name == "settings.gradle.kts" { + if let Ok(settings) = crate::parser::parse_settings(content_str) { + data.settings_file = Some((file, settings)); + } + } + } + } + _ => {} + } + } + + if module_map.is_empty() { + return Ok((unit, context)); + } + + // --- Step 2: Identify the Global Root --- + let mut sorted_paths: Vec = module_map.keys().cloned().collect(); + sorted_paths.sort_by_key(|p| p.components().count()); + + let root_path = sorted_paths + .iter() + .find(|p| { + module_map + .get(*p) + .and_then(|m| m.settings_file.as_ref()) + .is_some() + }) + .cloned() + .unwrap_or_else(|| sorted_paths[0].clone()); + + // --- Step 3: Create Project Node --- + let root_info = module_map.get(&root_path).unwrap(); + + let project_name = if let Some((_, settings)) = &root_info.settings_file { + settings + .root_project_name + .as_ref() + .map(|n| n.trim_matches(|c| c == '\"' || c == '\'').to_string()) + .unwrap_or_else(|| { + root_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }) + } else { + root_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }; + + let project_id = format!("project:{}", project_name); + + // Add Project node + unit.add_node(naviscope_core::ingest::parser::IndexNode { + id: project_id.clone(), + name: project_name.clone(), + kind: NodeKind::Project, + lang: "gradle".to_string(), + location: Some(DisplaySymbolLocation { + path: root_path.to_string_lossy().to_string(), + range: Range { + start_line: 0, + start_col: 0, + end_line: 0, + end_col: 0, + }, + selection_range: None, + }), + metadata: Arc::new(naviscope_core::model::EmptyMetadata), + }); + + // --- Step 4: Assign Module IDs --- + let mut path_to_id: HashMap = HashMap::new(); + + for path in &sorted_paths { + let id = if path == &root_path { + // Root module is now a child of project + format!("{}::module:{}", project_id, project_name) + } else if path.starts_with(&root_path) { + let rel = path.strip_prefix(&root_path).unwrap(); + let logical = rel + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + format!("{}::module:{}", project_id, logical) + } else { + // External modules (e.g., buildSrc) + format!( + "{}::module:{}", + project_id, + path.file_name().unwrap_or_default().to_string_lossy() + ) + }; + path_to_id.insert(path.clone(), id); + } + + // --- Step 5: Construct Module Nodes and Hierarchy --- + let root_module_id = path_to_id.get(&root_path).unwrap(); + + // Add root module and link to project + { + let data = module_map.get(&root_path).unwrap(); + let display_name = root_module_id + .split("::module:") + .nth(1) + .unwrap_or(&project_name); + + unit.add_node(naviscope_core::ingest::parser::IndexNode { + id: root_module_id.clone(), + name: display_name.to_string(), + kind: NodeKind::Module, + lang: "gradle".to_string(), + location: data + .build_file + .as_ref() + .map(|(f, _)| f.file.path.clone()) + .or_else(|| { + data.settings_file + .as_ref() + .map(|(f, _)| f.file.path.clone()) + }) + .map(|path| DisplaySymbolLocation { + path: path.to_string_lossy().to_string(), + range: Range { + start_line: 0, + start_col: 0, + end_line: 0, + end_col: 0, + }, + selection_range: None, + }), + metadata: Arc::new(naviscope_core::model::EmptyMetadata), + }); + + unit.add_edge( + Arc::from(project_id.as_str()), + Arc::from(root_module_id.as_str()), + GraphEdge::new(EdgeType::Contains), + ); + + context + .path_to_module + .insert(root_path.clone(), root_module_id.clone()); + } + + // Add other modules + for path in &sorted_paths { + if path == &root_path { + continue; + } + + let data = module_map.get(path).unwrap(); + let id = path_to_id.get(path).unwrap(); + let display_name = id.split("::module:").nth(1).unwrap_or(id); + + unit.add_node(naviscope_core::ingest::parser::IndexNode { + id: id.clone(), + name: display_name.to_string(), + kind: NodeKind::Module, + lang: "gradle".to_string(), + location: data + .build_file + .as_ref() + .map(|(f, _)| f.file.path.clone()) + .or_else(|| { + data.settings_file + .as_ref() + .map(|(f, _)| f.file.path.clone()) + }) + .map(|path| DisplaySymbolLocation { + path: path.to_string_lossy().to_string(), + range: Range { + start_line: 0, + start_col: 0, + end_line: 0, + end_col: 0, + }, + selection_range: None, + }), + metadata: Arc::new(naviscope_core::model::EmptyMetadata), + }); + + context.path_to_module.insert(path.clone(), id.clone()); + + // Establish hierarchy + let mut found_parent = false; + let mut current = path.parent(); + + while let Some(p) = current { + let normalized_p = self.normalize_path(p); + if let Some(parent_id) = path_to_id.get(&normalized_p) { + unit.add_edge( + Arc::from(parent_id.as_str()), + Arc::from(id.as_str()), + GraphEdge::new(EdgeType::Contains), + ); + found_parent = true; + break; + } + if normalized_p == root_path { + break; + } + current = p.parent(); + } + + // Fallback: link to root module if no parent found + if !found_parent && path.starts_with(&root_path) { + unit.add_edge( + Arc::from(root_module_id.as_str()), + Arc::from(id.as_str()), + GraphEdge::new(EdgeType::Contains), + ); + } + } + + // --- Step 6: Build Dependencies --- + for path in &sorted_paths { + let data = module_map.get(path).unwrap(); + let id = path_to_id.get(path).unwrap(); + + if let Some((_, content)) = &data.build_file { + for dep in &content.dependencies { + let target_id = if dep.is_project { + let clean_name = dep + .name + .trim_matches(|c| c == ':' || c == '\"' || c == '\'') + .replace(':', "/"); + format!("{}::module:{}", project_id, clean_name) + } else { + let group = dep.group.as_deref().unwrap_or(""); + let version = dep.version.as_deref().unwrap_or(""); + format!("dep:{}:{}:{}", group, dep.name, version) + }; + + if !dep.is_project { + let _dep_node = crate::model::GradleDependency { + group: dep.group.clone(), + version: dep.version.clone(), + is_project: dep.is_project, + }; + unit.add_node(naviscope_core::ingest::parser::IndexNode { + id: target_id.clone(), + name: dep.name.clone(), + kind: NodeKind::Dependency, + lang: "gradle".to_string(), + location: Some(DisplaySymbolLocation { + path: data + .build_file + .as_ref() + .unwrap() + .0 + .file + .path + .to_string_lossy() + .to_string(), + range: Range { + start_line: 0, + start_col: 0, + end_line: 0, + end_col: 0, + }, + selection_range: None, + }), + metadata: Arc::new(naviscope_core::model::EmptyMetadata), + }); + } + + unit.add_edge( + Arc::from(id.as_str()), + Arc::from(target_id.as_str()), + GraphEdge::new(EdgeType::UsesDependency), + ); + } + } + } + + Ok((unit, context)) + } +} + +struct ModuleData<'a> { + build_file: Option<(&'a ParsedFile, crate::model::GradleParseResult)>, + settings_file: Option<(&'a ParsedFile, crate::model::GradleSettings)>, +} + +#[cfg(test)] +mod tests { + use super::*; + use naviscope_core::model::GraphOp; + use naviscope_core::model::source::SourceFile; + + fn create_mock_file(path: &str, content: ParsedContent) -> ParsedFile { + ParsedFile { + file: SourceFile { + path: PathBuf::from(path), + content_hash: 0, + last_modified: 0, + }, + content, + } + } + + #[test] + fn test_resolve_robust_hierarchy() { + let resolver = GradleResolver::new(); + + let root_settings = create_mock_file( + "/repo/settings.gradle", + ParsedContent::MetaData( + serde_json::to_value(crate::model::GradleSettings { + root_project_name: Some("spring-boot-build".to_string()), + included_projects: vec![], + }) + .unwrap(), + ), + ); + let sub_project_build = create_mock_file( + "/repo/spring-boot-project/build.gradle", + ParsedContent::MetaData( + serde_json::to_value(crate::model::GradleParseResult { + dependencies: vec![], + }) + .unwrap(), + ), + ); + let core_build = create_mock_file( + "/repo/spring-boot-project/spring-boot/build.gradle", + ParsedContent::MetaData( + serde_json::to_value(crate::model::GradleParseResult { + dependencies: vec![], + }) + .unwrap(), + ), + ); + + let files = vec![&root_settings, &sub_project_build, &core_build]; + let (unit, _) = resolver.resolve(&files).unwrap(); + + let edges: Vec<_> = unit + .ops + .iter() + .filter_map(|op| { + if let GraphOp::AddEdge { + from_id, + to_id, + edge, + } = op + { + if edge.edge_type == EdgeType::Contains { + Some((from_id.as_ref(), to_id.as_ref())) + } else { + None + } + } else { + None + } + }) + .collect(); + + // Should have: project -> root_module -> sub_modules + assert!(edges.contains(&( + "project:spring-boot-build", + "project:spring-boot-build::module:spring-boot-build" + ))); + assert!(edges.contains(&( + "project:spring-boot-build::module:spring-boot-build", + "project:spring-boot-build::module:spring-boot-project" + ))); + assert!(edges.contains(&( + "project:spring-boot-build::module:spring-boot-project", + "project:spring-boot-build::module:spring-boot-project/spring-boot" + ))); + } +} diff --git a/crates/lang-java/Cargo.toml b/crates/lang-java/Cargo.toml new file mode 100644 index 0000000..cb303dc --- /dev/null +++ b/crates/lang-java/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "naviscope-java" +version = "0.5.0" +edition = "2024" + +[dependencies] +naviscope-core = { workspace = true } +naviscope-api = { workspace = true } +tree-sitter = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +petgraph = { workspace = true } +lsp-types = { workspace = true } +tree-sitter-java = { workspace = true } +rmp-serde = { workspace = true } +lasso = { workspace = true } diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs new file mode 100644 index 0000000..60298c2 --- /dev/null +++ b/crates/lang-java/src/lib.rs @@ -0,0 +1,320 @@ +pub mod model; +pub mod parser; +pub mod queries; +pub mod resolver; + +use lasso::Key; +use naviscope_api::models::DisplayGraphNode; +use naviscope_core::error::Result; +use naviscope_core::ingest::parser::{GlobalParseResult, LspParser}; +use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_core::model::source::Language; +use naviscope_core::runtime::plugin::{LanguagePlugin, MetadataPlugin, NodeRenderer}; +use std::path::Path; +use std::sync::Arc; + +#[derive(Clone)] +pub struct JavaPlugin { + parser: Arc, + resolver: Arc, +} + +impl NodeRenderer for JavaPlugin { + fn render_display_node( + &self, + node: &naviscope_core::model::GraphNode, + rodeo: &dyn lasso::Reader, + ) -> DisplayGraphNode { + let mut display = DisplayGraphNode { + id: node.fqn(rodeo).to_string(), + name: node.name(rodeo).to_string(), + kind: node.kind.clone(), + lang: "java".to_string(), + location: node.location.as_ref().map(|l| l.to_display(rodeo)), + detail: None, + signature: None, + modifiers: vec![], + children: None, + }; + + let fqn = display.id.as_str(); + let parts: Vec<&str> = fqn.split('.').collect(); + if parts.len() > 1 { + let container = parts[..parts.len() - 1].join("."); + display.detail = Some(format!("*Defined in `{}`*", container)); + } + + // Real-time calculation from JavaNodeMetadata + if let Some(java_meta) = node + .metadata + .as_any() + .downcast_ref::() + { + match java_meta { + crate::model::JavaNodeMetadata::Class { modifiers_sids } + | crate::model::JavaNodeMetadata::Interface { modifiers_sids } + | crate::model::JavaNodeMetadata::Annotation { modifiers_sids } => { + display.modifiers = modifiers_sids + .iter() + .map(|&s| { + rodeo + .resolve(&lasso::Spur::try_from_usize(s as usize).unwrap()) + .to_string() + }) + .collect(); + let prefix = match node.kind { + naviscope_core::model::NodeKind::Interface => "interface", + naviscope_core::model::NodeKind::Annotation => "@interface", + _ => "class", + }; + display.signature = Some(format!("{} {}", prefix, display.name)); + } + crate::model::JavaNodeMetadata::Method { + modifiers_sids, + return_type, + parameters, + is_constructor, + } => { + display.modifiers = modifiers_sids + .iter() + .map(|&s| { + rodeo + .resolve(&lasso::Spur::try_from_usize(s as usize).unwrap()) + .to_string() + }) + .collect(); + let params_str = parameters + .iter() + .map(|p| { + format!( + "{}: {}", + rodeo.resolve( + &lasso::Spur::try_from_usize(p.name_sid as usize).unwrap() + ), + crate::model::fmt_type(return_type, rodeo) + ) + }) + .collect::>() + .join(", "); + if *is_constructor { + display.signature = Some(format!("{}({})", display.name, params_str)); + } else { + display.signature = Some(format!( + "{}({}) -> {}", + display.name, + params_str, + crate::model::fmt_type(return_type, rodeo) + )); + } + } + crate::model::JavaNodeMetadata::Field { + modifiers_sids, + type_ref, + } => { + display.modifiers = modifiers_sids + .iter() + .map(|&s| { + rodeo + .resolve(&lasso::Spur::try_from_usize(s as usize).unwrap()) + .to_string() + }) + .collect(); + display.signature = Some(format!( + "{}: {}", + display.name, + crate::model::fmt_type(type_ref, rodeo) + )); + } + crate::model::JavaNodeMetadata::Enum { modifiers_sids, .. } => { + display.modifiers = modifiers_sids + .iter() + .map(|&s| { + rodeo + .resolve(&lasso::Spur::try_from_usize(s as usize).unwrap()) + .to_string() + }) + .collect(); + display.signature = Some(format!("enum {}", display.name)); + } + _ => {} + } + } else if let Some(java_idx_meta) = node + .metadata + .as_any() + .downcast_ref::() + { + // Real-time calculation from JavaIndexMetadata (Uninterned) + match java_idx_meta { + crate::model::JavaIndexMetadata::Class { modifiers } + | crate::model::JavaIndexMetadata::Interface { modifiers } + | crate::model::JavaIndexMetadata::Annotation { modifiers } => { + display.modifiers = modifiers.clone(); + let prefix = match node.kind { + naviscope_core::model::NodeKind::Interface => "interface", + naviscope_core::model::NodeKind::Annotation => "@interface", + _ => "class", + }; + display.signature = Some(format!("{} {}", prefix, display.name)); + } + crate::model::JavaIndexMetadata::Method { + modifiers, + return_type, + parameters, + is_constructor, + } => { + display.modifiers = modifiers.clone(); + let params_str = parameters + .iter() + .map(|p| { + format!( + "{}: {}", + p.name, + crate::model::fmt_type_uninterned(&p.type_ref) + ) + }) + .collect::>() + .join(", "); + if *is_constructor { + display.signature = Some(format!("{}({})", display.name, params_str)); + } else { + display.signature = Some(format!( + "{}({}) -> {}", + display.name, + params_str, + crate::model::fmt_type_uninterned(return_type) + )); + } + } + crate::model::JavaIndexMetadata::Field { + modifiers, + type_ref, + } => { + display.modifiers = modifiers.clone(); + display.signature = Some(format!( + "{}: {}", + display.name, + crate::model::fmt_type_uninterned(type_ref) + )); + } + crate::model::JavaIndexMetadata::Enum { modifiers, .. } => { + display.modifiers = modifiers.clone(); + display.signature = Some(format!("enum {}", display.name)); + } + _ => {} + } + } + + display + } + + fn hydrate_display_node(&self, _node: &mut DisplayGraphNode) { + // Hydration logic is currently disabled as DisplayGraphNode no longer carries raw metadata. + // LSP symbols are now baked during extraction. + } +} + +impl JavaPlugin { + pub fn new() -> Result { + let parser = Arc::new(parser::JavaParser::new()?); + let resolver = Arc::new(resolver::JavaResolver { + parser: (*parser).clone(), + }); + Ok(Self { parser, resolver }) + } +} + +impl MetadataPlugin for JavaPlugin { + fn intern( + &self, + metadata: &dyn naviscope_core::model::NodeMetadata, + ctx: &mut dyn naviscope_core::model::storage::model::StorageContext, + ) -> Vec { + if let Some(java_meta) = metadata + .as_any() + .downcast_ref::() + { + rmp_serde::to_vec(&java_meta).unwrap_or_default() + } else if let Some(java_idx_meta) = metadata + .as_any() + .downcast_ref::() + { + // Convert uninterned JavaIndexMetadata to optimized storage + let storage_metadata = java_idx_meta.to_storage(ctx); + rmp_serde::to_vec(&storage_metadata).unwrap_or_default() + } else { + Vec::new() + } + } + + fn resolve( + &self, + bytes: &[u8], + _ctx: &dyn naviscope_core::model::storage::model::StorageContext, + ) -> Arc { + if let Ok(element) = rmp_serde::from_slice::(bytes) { + Arc::new(element) + } else { + Arc::new(naviscope_core::model::EmptyMetadata) + } + } +} + +impl LanguagePlugin for JavaPlugin { + fn name(&self) -> Language { + Language::JAVA + } + + fn supported_extensions(&self) -> &[&str] { + &["java"] + } + + fn parse_file(&self, source: &str, path: &Path) -> Result { + use naviscope_core::ingest::parser::IndexParser; + self.parser.parse_file(source, Some(path)) + } + + fn resolver(&self) -> Arc { + self.resolver.clone() + } + + fn lang_resolver(&self) -> Arc { + self.resolver.clone() + } + + fn lsp_parser(&self) -> Arc { + // Return self because JavaPlugin implements LspParser + // and provides the "Complete" (hydrated) view. + Arc::new(self.clone()) + } +} + +impl LspParser for JavaPlugin { + fn parse( + &self, + source: &str, + old_tree: Option<&tree_sitter::Tree>, + ) -> Option { + self.parser.parse(source, old_tree) + } + + fn extract_symbols(&self, tree: &tree_sitter::Tree, source: &str) -> Vec { + let mut symbols = self.parser.extract_symbols(tree, source); + for sym in &mut symbols { + self.hydrate_display_node(sym); + } + symbols + } + + fn symbol_kind(&self, kind: &naviscope_core::model::NodeKind) -> lsp_types::SymbolKind { + self.parser.symbol_kind(kind) + } + + fn find_occurrences( + &self, + source: &str, + tree: &tree_sitter::Tree, + target: &naviscope_core::ingest::parser::SymbolResolution, + ) -> Vec { + self.parser.find_occurrences(source, tree, target) + } +} diff --git a/crates/lang-java/src/model.rs b/crates/lang-java/src/model.rs new file mode 100644 index 0000000..2da0f82 --- /dev/null +++ b/crates/lang-java/src/model.rs @@ -0,0 +1,188 @@ +use lasso::Reader; +use naviscope_api::models::{NodeMetadata, TypeRef}; +use naviscope_core::model::metadata::{IndexMetadata, SymbolInterner}; +use serde::{Deserialize, Serialize}; +use std::any::Any; +use std::sync::Arc; + +/// Uninterned metadata used during parsing +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum JavaIndexMetadata { + Class { + modifiers: Vec, + }, + Interface { + modifiers: Vec, + }, + Enum { + modifiers: Vec, + constants: Vec, + }, + Annotation { + modifiers: Vec, + }, + Method { + modifiers: Vec, + return_type: TypeRef, + parameters: Vec, + is_constructor: bool, + }, + Field { + modifiers: Vec, + type_ref: TypeRef, + }, + Package, +} + +impl IndexMetadata for JavaIndexMetadata { + fn as_any(&self) -> &dyn Any { + self + } + + fn intern(&self, interner: &mut dyn SymbolInterner) -> Arc { + Arc::new(self.to_storage(interner)) + } +} + +/// Interned metadata stored in the graph +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum JavaNodeMetadata { + Class { + modifiers_sids: Vec, + }, + Interface { + modifiers_sids: Vec, + }, + Enum { + modifiers_sids: Vec, + constants_sids: Vec, + }, + Annotation { + modifiers_sids: Vec, + }, + Method { + modifiers_sids: Vec, + return_type: TypeRef, + parameters: Vec, + is_constructor: bool, + }, + Field { + modifiers_sids: Vec, + type_ref: TypeRef, + }, + Package, +} + +impl JavaIndexMetadata { + pub fn to_storage(&self, ctx: &mut dyn SymbolInterner) -> JavaNodeMetadata { + match self { + JavaIndexMetadata::Class { modifiers } => JavaNodeMetadata::Class { + modifiers_sids: modifiers.iter().map(|s| ctx.intern_str(s)).collect(), + }, + JavaIndexMetadata::Interface { modifiers } => JavaNodeMetadata::Interface { + modifiers_sids: modifiers.iter().map(|s| ctx.intern_str(s)).collect(), + }, + JavaIndexMetadata::Enum { + modifiers, + constants, + } => JavaNodeMetadata::Enum { + modifiers_sids: modifiers.iter().map(|s| ctx.intern_str(s)).collect(), + constants_sids: constants.iter().map(|s| ctx.intern_str(s)).collect(), + }, + JavaIndexMetadata::Annotation { modifiers } => JavaNodeMetadata::Annotation { + modifiers_sids: modifiers.iter().map(|s| ctx.intern_str(s)).collect(), + }, + JavaIndexMetadata::Method { + modifiers, + return_type, + parameters, + is_constructor, + } => JavaNodeMetadata::Method { + modifiers_sids: modifiers.iter().map(|s| ctx.intern_str(s)).collect(), + return_type: return_type.clone(), + parameters: parameters + .iter() + .map(|p| JavaParameterStorage { + name_sid: ctx.intern_str(&p.name), + type_ref: p.type_ref.clone(), + }) + .collect(), + is_constructor: *is_constructor, + }, + JavaIndexMetadata::Field { + modifiers, + type_ref, + } => JavaNodeMetadata::Field { + modifiers_sids: modifiers.iter().map(|s| ctx.intern_str(s)).collect(), + type_ref: type_ref.clone(), + }, + JavaIndexMetadata::Package => JavaNodeMetadata::Package, + } + } +} + +impl NodeMetadata for JavaNodeMetadata { + fn as_any(&self) -> &dyn Any { + self + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JavaParameter { + pub name: String, + pub type_ref: TypeRef, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JavaParameterStorage { + pub name_sid: u32, + pub type_ref: TypeRef, +} + +pub fn fmt_type(t: &TypeRef, rodeo: &dyn Reader) -> String { + match t { + TypeRef::Raw(s) => s.clone(), + TypeRef::Id(s) => s.split('.').last().unwrap_or(s).to_string(), + TypeRef::Generic { base, args } => { + let args_str = args + .iter() + .map(|a| fmt_type(a, rodeo)) + .collect::>() + .join(", "); + format!("{}<{}>", fmt_type(base, rodeo), args_str) + } + TypeRef::Array { + element, + dimensions, + } => { + format!("{}{}", fmt_type(element, rodeo), "[]".repeat(*dimensions)) + } + _ => "?".to_string(), + } +} + +pub fn fmt_type_uninterned(t: &TypeRef) -> String { + match t { + TypeRef::Raw(s) => s.clone(), + TypeRef::Id(s) => s.split('.').last().unwrap_or(s).to_string(), + TypeRef::Generic { base, args } => { + let args_str = args + .iter() + .map(|a| fmt_type_uninterned(a)) + .collect::>() + .join(", "); + format!("{}<{}>", fmt_type_uninterned(base), args_str) + } + TypeRef::Array { + element, + dimensions, + } => { + format!( + "{}{}", + fmt_type_uninterned(element), + "[]".repeat(*dimensions) + ) + } + _ => "?".to_string(), + } +} diff --git a/crates/lang-java/src/parser/ast/entities.rs b/crates/lang-java/src/parser/ast/entities.rs new file mode 100644 index 0000000..8096cc5 --- /dev/null +++ b/crates/lang-java/src/parser/ast/entities.rs @@ -0,0 +1,171 @@ +use super::super::JavaParser; +use super::super::constants::*; +use super::{JavaEntity, JavaRelation}; +use crate::model::*; +use naviscope_api::models::TypeRef; +use naviscope_core::ingest::parser::utils::range_from_ts; +use naviscope_core::model::{EdgeType, Range}; +use std::collections::HashMap; +use tree_sitter::QueryCapture; + +impl JavaParser { + pub(crate) fn identify_entities<'a>( + &self, + all_matches: &[Vec>], + source: &'a str, + package: &Option, + entities: &mut Vec>, + relations: &mut Vec, + entities_map: &mut HashMap, + ) { + for captures in all_matches { + let definition_anchor = captures.iter().find(|c| { + let i = c.index; + i == self.indices.class_def + || i == self.indices.inter_def + || i == self.indices.enum_def + || i == self.indices.annotation_def + || i == self.indices.method_def + || i == self.indices.constr_def + || i == self.indices.field_def + }); + + if let Some(anchor) = definition_anchor { + let anchor_node = anchor.node; + let (kind, name_idx) = if anchor.index == self.indices.class_def { + (KIND_LABEL_CLASS, self.indices.class_name) + } else if anchor.index == self.indices.inter_def { + (KIND_LABEL_INTERFACE, self.indices.inter_name) + } else if anchor.index == self.indices.enum_def { + (KIND_LABEL_ENUM, self.indices.enum_name) + } else if anchor.index == self.indices.annotation_def { + (KIND_LABEL_ANNOTATION, self.indices.annotation_name) + } else if anchor.index == self.indices.method_def { + (KIND_LABEL_METHOD, self.indices.method_name) + } else if anchor.index == self.indices.constr_def { + (KIND_LABEL_CONSTRUCTOR, self.indices.constr_name) + } else { + (KIND_LABEL_FIELD, self.indices.field_name) + }; + + if let Some(name_node) = captures + .iter() + .find(|c| c.index == name_idx) + .map(|c| c.node) + { + let fqn = self.get_fqn_for_definition(&name_node, source, package.as_deref()); + let name = name_node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + let range = range_from_ts(anchor_node.range()); + let name_range = range_from_ts(name_node.range()); + + if !entities_map.contains_key(&fqn) { + let new_idx = entities.len(); + let element = self.create_java_element( + kind, &fqn, &name, range, name_range, captures, source, relations, + ); + entities.push(JavaEntity { + element, + node: anchor_node, + fqn: fqn.clone(), + name: name.clone(), + }); + entities_map.insert(fqn.clone(), new_idx); + + // Structural relation (Contains) + if let Some(parent_node) = self.find_next_enclosing_definition(anchor_node) + { + if let Some(parent_name_node) = parent_node.child_by_field_name("name") + { + let parent = self.get_fqn_for_definition( + &parent_name_node, + source, + package.as_deref(), + ); + if parent != fqn { + relations.push(JavaRelation { + source_fqn: parent, + target_name: fqn.clone(), + rel_type: EdgeType::Contains, + range: None, + }); + } + } + } + } + } + } + } + } + + fn create_java_element<'a>( + &self, + kind: &str, + _fqn: &str, + _name: &str, + _range: Range, + _name_range: Range, + captures: &[QueryCapture<'a>], + source: &'a str, + relations: &mut Vec, + ) -> JavaIndexMetadata { + match kind { + KIND_LABEL_CLASS => JavaIndexMetadata::Class { modifiers: vec![] }, + KIND_LABEL_INTERFACE => JavaIndexMetadata::Interface { modifiers: vec![] }, + KIND_LABEL_ENUM => JavaIndexMetadata::Enum { + modifiers: vec![], + constants: vec![], + }, + KIND_LABEL_ANNOTATION => JavaIndexMetadata::Annotation { modifiers: vec![] }, + KIND_LABEL_METHOD | KIND_LABEL_CONSTRUCTOR => { + let mut return_type = TypeRef::raw("void"); + if let Some(ret_node) = captures + .iter() + .find(|c| c.index == self.indices.method_ret) + .map(|c| c.node) + { + return_type = self.parse_type_node(ret_node, source); + self.generate_typed_as_edges(ret_node, source, _fqn, relations); + } + JavaIndexMetadata::Method { + return_type, + parameters: vec![], + modifiers: vec![], + is_constructor: kind == KIND_LABEL_CONSTRUCTOR, + } + } + KIND_LABEL_FIELD => { + let anchor_node = captures + .iter() + .find(|c| c.index == self.indices.field_def) + .unwrap() + .node; + let type_node = captures + .iter() + .find(|c| c.index == self.indices.field_type) + .map(|c| c.node) + .or_else(|| anchor_node.child_by_field_name("type")) + .or_else(|| { + anchor_node + .parent() + .and_then(|p| p.child_by_field_name("type")) + }); + + let type_ref = if let Some(t) = type_node { + self.generate_typed_as_edges(t, source, _fqn, relations); + self.parse_type_node(t, source) + } else { + TypeRef::Unknown + }; + + JavaIndexMetadata::Field { + type_ref, + modifiers: vec![], + } + } + _ => unreachable!(), + } + } +} diff --git a/crates/lang-java/src/parser/ast/metadata.rs b/crates/lang-java/src/parser/ast/metadata.rs new file mode 100644 index 0000000..5d81eda --- /dev/null +++ b/crates/lang-java/src/parser/ast/metadata.rs @@ -0,0 +1,276 @@ +use super::super::JavaParser; +use super::{JavaEntity, JavaRelation}; +use crate::model::{JavaIndexMetadata, JavaParameter}; +use naviscope_core::ingest::parser::utils::range_from_ts; +use naviscope_core::model::EdgeType; +use std::collections::HashMap; +use tree_sitter::QueryCapture; + +impl JavaParser { + pub(super) fn enrich_metadata<'a>( + &self, + all_matches: &[Vec>], + source: &'a str, + package: &Option, + entities: &mut [JavaEntity<'a>], + relations: &mut Vec, + entities_map: &HashMap, + ) { + for captures in all_matches { + if let Some(meta_cap) = captures.iter().find(|c| { + let i = c.index; + i == self.indices.mods + || i == self.indices.class_super + || i == self.indices.class_inter + || i == self.indices.inter_ext + || i == self.indices.enum_interface + || i == self.indices.method_ret + || i == self.indices.field_type + || i == self.indices.param_match + }) { + if let Some(parent_node) = self.find_next_enclosing_definition(meta_cap.node) { + if let Some(parent_name_node) = parent_node.child_by_field_name("name") { + let enclosing_fqn = self.get_fqn_for_definition( + &parent_name_node, + source, + package.as_deref(), + ); + if let Some(&idx) = entities_map.get(&enclosing_fqn) { + self.attach_metadata_to_model( + captures, + source, + enclosing_fqn, + &mut entities[idx].element, + relations, + ); + } + } + } + } + } + } + + fn attach_metadata_to_model<'a>( + &self, + captures: &[QueryCapture<'a>], + source: &'a str, + fqn: String, + element: &mut JavaIndexMetadata, + relations: &mut Vec, + ) { + // Modifiers & Annotations + if let Some(mods_node) = captures + .iter() + .find(|c| c.index == self.indices.mods) + .map(|c| c.node) + { + let mut cursor = mods_node.walk(); + for child in mods_node.children(&mut cursor) { + let kind = child.kind(); + if kind.contains("annotation") { + let name_node = child.child_by_field_name("name").unwrap_or(child); + if let Ok(name) = name_node.utf8_text(source.as_bytes()) { + let mut name_str = name.to_string(); + if name_str.starts_with('@') { + name_str = name_str[1..].to_string(); + } + relations.push(JavaRelation { + source_fqn: fqn.clone(), + target_name: name_str, + rel_type: EdgeType::DecoratedBy, + range: Some(range_from_ts(name_node.range())), + }); + if let Ok(full_text) = child.utf8_text(source.as_bytes()) { + self.add_modifier(element, full_text.to_string()); + } + } + } else if let Ok(m) = child.utf8_text(source.as_bytes()) { + self.add_modifier(element, m.to_string()); + } + } + } + + match element { + JavaIndexMetadata::Class { modifiers: _ } => { + if let Some(s) = captures + .iter() + .find(|c| c.index == self.indices.class_super) + { + let mut s_name = s + .node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + let mut cursor = s.node.walk(); + for child in s.node.children(&mut cursor) { + if matches!( + child.kind(), + "type_identifier" | "scoped_type_identifier" | "generic_type" + ) { + s_name = child + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + break; + } + } + relations.push(JavaRelation { + source_fqn: fqn.clone(), + target_name: s_name, + rel_type: EdgeType::InheritsFrom, + range: None, + }); + } + for cc in captures + .iter() + .filter(|c| c.index == self.indices.class_inter) + { + let i = cc + .node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + relations.push(JavaRelation { + source_fqn: fqn.clone(), + target_name: i, + rel_type: EdgeType::Implements, + range: None, + }); + } + } + JavaIndexMetadata::Interface { modifiers: _ } => { + for cc in captures + .iter() + .filter(|c| c.index == self.indices.inter_ext) + { + let e = cc + .node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + relations.push(JavaRelation { + source_fqn: fqn.clone(), + target_name: e, + rel_type: EdgeType::InheritsFrom, + range: None, + }); + } + } + JavaIndexMetadata::Enum { + modifiers: _, + constants: _, + } => { + for cc in captures + .iter() + .filter(|c| c.index == self.indices.enum_interface) + { + let i = cc + .node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + relations.push(JavaRelation { + source_fqn: fqn.clone(), + target_name: i, + rel_type: EdgeType::Implements, + range: None, + }); + } + } + JavaIndexMetadata::Method { + modifiers: _, + return_type, + parameters, + is_constructor: _, + } => { + if let Some(ret) = captures.iter().find(|c| c.index == self.indices.method_ret) { + *return_type = self.parse_type_node(ret.node, source); + self.generate_typed_as_edges(ret.node, source, &fqn, relations); + } + if let (Some(t_node), Some(n_node)) = ( + captures + .iter() + .find(|c| c.index == self.indices.param_type) + .map(|c| c.node), + captures + .iter() + .find(|c| c.index == self.indices.param_name) + .map(|c| c.node), + ) { + let t_ref = self.parse_type_node(t_node, source); + let n = n_node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + if !parameters + .iter() + .any(|p| p.name == n && p.type_ref == t_ref) + { + parameters.push(JavaParameter { + type_ref: t_ref, + name: n, + }); + } + self.generate_typed_as_edges(t_node, source, &fqn, relations); + } + } + JavaIndexMetadata::Field { + modifiers: _, + type_ref, + } => { + if let Some(t) = captures.iter().find(|c| c.index == self.indices.field_type) { + *type_ref = self.parse_type_node(t.node, source); + self.generate_typed_as_edges(t.node, source, &fqn, relations); + } + } + _ => {} + } + } + + fn add_modifier(&self, element: &mut JavaIndexMetadata, m_str: String) { + match element { + JavaIndexMetadata::Class { modifiers } => { + if !modifiers.contains(&m_str) { + modifiers.push(m_str); + } + } + JavaIndexMetadata::Interface { modifiers } => { + if !modifiers.contains(&m_str) { + modifiers.push(m_str); + } + } + JavaIndexMetadata::Enum { + modifiers, + constants: _, + } => { + if !modifiers.contains(&m_str) { + modifiers.push(m_str); + } + } + JavaIndexMetadata::Annotation { modifiers } => { + if !modifiers.contains(&m_str) { + modifiers.push(m_str); + } + } + JavaIndexMetadata::Method { + modifiers, + return_type: _, + parameters: _, + is_constructor: _, + } => { + if !modifiers.contains(&m_str) { + modifiers.push(m_str); + } + } + JavaIndexMetadata::Field { + modifiers, + type_ref: _, + } => { + if !modifiers.contains(&m_str) { + modifiers.push(m_str); + } + } + JavaIndexMetadata::Package => {} + } + } +} diff --git a/crates/lang-java/src/parser/ast/mod.rs b/crates/lang-java/src/parser/ast/mod.rs new file mode 100644 index 0000000..63c2cc9 --- /dev/null +++ b/crates/lang-java/src/parser/ast/mod.rs @@ -0,0 +1,109 @@ +use super::JavaParser; +use naviscope_core::model::{EdgeType, Range}; +use std::collections::HashMap; +use tree_sitter::{Node, QueryCapture, StreamingIterator, Tree}; + +mod entities; +mod metadata; +mod relations; + +/// The native semantic model of a Java source file. +pub struct JavaFileModel<'a> { + pub package: Option, + pub imports: Vec, + pub entities: Vec>, + pub relations: Vec, + pub identifiers: Vec, +} + +pub struct JavaEntity<'a> { + pub element: crate::model::JavaIndexMetadata, + pub node: Node<'a>, + pub fqn: String, + pub name: String, +} + +pub struct JavaRelation { + pub source_fqn: String, + pub target_name: String, + pub rel_type: EdgeType, + pub range: Option, +} + +impl JavaParser { + /// Deeply analyzes a Java tree and produces a native JavaFileModel. + pub(crate) fn analyze<'a>(&self, tree: &'a Tree, source: &'a str) -> JavaFileModel<'a> { + let (package, imports) = self.extract_package_and_imports(tree, source); + let all_matches = self.collect_matches(tree, source); + + let mut entities = Vec::new(); + let mut relations = Vec::new(); + let mut entities_map = HashMap::new(); + + // Stage 1: Identify all named entities (Classes, Methods, Fields) + self.identify_entities( + &all_matches, + source, + &package, + &mut entities, + &mut relations, + &mut entities_map, + ); + + // Stage 2: Enrich identified entities with metadata (Annotations, Inheritance, Types) + self.enrich_metadata( + &all_matches, + source, + &package, + &mut entities, + &mut relations, + &entities_map, + ); + + // Stage 3: Collect Reference Index (Identifiers) + let identifiers = self.collect_identifiers(tree, source); + + JavaFileModel { + package, + imports, + entities, + relations, + identifiers, + } + } + + pub(crate) fn collect_matches<'a>( + &self, + tree: &'a Tree, + source: &'a str, + ) -> Vec>> { + let mut cursor = tree_sitter::QueryCursor::new(); + let mut matches = + cursor.matches(&self.definition_query, tree.root_node(), source.as_bytes()); + let mut all_matches = Vec::new(); + while let Some(mat) = matches.next() { + all_matches.push(mat.captures.to_vec()); + } + all_matches + } + pub(crate) fn collect_identifiers(&self, tree: &Tree, source: &str) -> Vec { + let mut identifiers = std::collections::HashSet::new(); + let mut stack = vec![tree.root_node()]; + + while let Some(node) = stack.pop() { + let kind = node.kind(); + if kind == "identifier" || kind == "type_identifier" { + if let Ok(text) = node.utf8_text(source.as_bytes()) { + identifiers.insert(text.to_string()); + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + stack.push(child); + } + } + + identifiers.into_iter().collect() + } +} diff --git a/crates/lang-java/src/parser/ast/relations.rs b/crates/lang-java/src/parser/ast/relations.rs new file mode 100644 index 0000000..902002a --- /dev/null +++ b/crates/lang-java/src/parser/ast/relations.rs @@ -0,0 +1,49 @@ +use super::super::JavaParser; +use super::JavaRelation; +use naviscope_core::ingest::parser::utils::range_from_ts; +use naviscope_core::model::EdgeType; +use tree_sitter::Node; + +impl JavaParser { + pub(super) fn generate_typed_as_edges<'a>( + &self, + type_node: Node<'a>, + source: &'a str, + source_fqn: &str, + relations: &mut Vec, + ) { + let kind = type_node.kind(); + if kind == "type_identifier" { + let type_name = type_node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + if !self.is_primitive(&type_name) { + relations.push(JavaRelation { + source_fqn: source_fqn.to_string(), + target_name: type_name, + rel_type: EdgeType::TypedAs, + range: Some(range_from_ts(type_node.range())), + }); + } + return; + } + + let mut cursor = type_node.walk(); + for child in type_node.children(&mut cursor) { + if matches!( + child.kind(), + "type_identifier" | "generic_type" | "type_arguments" | "wildcard" | "array_type" + ) { + self.generate_typed_as_edges(child, source, source_fqn, relations); + } + } + } + + fn is_primitive(&self, type_name: &str) -> bool { + matches!( + type_name, + "byte" | "short" | "int" | "long" | "float" | "double" | "boolean" | "char" | "void" + ) + } +} diff --git a/src/parser/java/constants.rs b/crates/lang-java/src/parser/constants.rs similarity index 100% rename from src/parser/java/constants.rs rename to crates/lang-java/src/parser/constants.rs diff --git a/crates/lang-java/src/parser/index.rs b/crates/lang-java/src/parser/index.rs new file mode 100644 index 0000000..4fb29b8 --- /dev/null +++ b/crates/lang-java/src/parser/index.rs @@ -0,0 +1,101 @@ +use super::JavaParser; +use naviscope_core::error::{NaviscopeError, Result}; +use naviscope_core::ingest::parser::{GlobalParseResult, IndexNode, IndexParser, ParseOutput}; +use naviscope_core::model::DisplaySymbolLocation; +use std::sync::Arc; +use tree_sitter::Parser; + +impl IndexParser for JavaParser { + fn parse_file( + &self, + source_code: &str, + file_path: Option<&std::path::Path>, + ) -> Result { + let mut parser = Parser::new(); + parser + .set_language(&self.language) + .map_err(|e| NaviscopeError::Parsing(e.to_string()))?; + + let tree = parser + .parse(source_code, None) + .ok_or_else(|| NaviscopeError::Parsing("Failed to parse Java file".to_string()))?; + + // Use the native AST analyzer + let model = self.analyze(&tree, source_code); + + let nodes = model + .entities + .into_iter() + .map(|e| { + let kind = match &e.element { + crate::model::JavaIndexMetadata::Class { .. } => { + naviscope_core::model::NodeKind::Class + } + crate::model::JavaIndexMetadata::Interface { .. } => { + naviscope_core::model::NodeKind::Interface + } + crate::model::JavaIndexMetadata::Enum { .. } => { + naviscope_core::model::NodeKind::Enum + } + crate::model::JavaIndexMetadata::Annotation { .. } => { + naviscope_core::model::NodeKind::Annotation + } + crate::model::JavaIndexMetadata::Method { is_constructor, .. } => { + if *is_constructor { + naviscope_core::model::NodeKind::Constructor + } else { + naviscope_core::model::NodeKind::Method + } + } + crate::model::JavaIndexMetadata::Field { .. } => { + naviscope_core::model::NodeKind::Field + } + crate::model::JavaIndexMetadata::Package => { + naviscope_core::model::NodeKind::Package + } + }; + + let location = file_path.map(|p| DisplaySymbolLocation { + path: p.to_string_lossy().to_string(), + range: naviscope_core::ingest::parser::utils::range_from_ts(e.node.range()), + selection_range: e + .node + .child_by_field_name("name") + .map(|n| naviscope_core::ingest::parser::utils::range_from_ts(n.range())), + }); + + IndexNode { + id: e.fqn.clone(), + name: e.name.clone(), + kind, + lang: "java".to_string(), + location, + metadata: Arc::new(e.element), + } + }) + .collect(); + + let relations = model + .relations + .into_iter() + .map(|r| naviscope_core::ingest::parser::IndexRelation { + source_id: r.source_fqn, + target_id: r.target_name, + edge_type: r.rel_type, + range: r.range, + }) + .collect(); + + Ok(GlobalParseResult { + package_name: model.package, + imports: model.imports, + output: ParseOutput { + nodes, + relations, + identifiers: model.identifiers, + }, + source: Some(source_code.to_string()), + tree: Some(tree), + }) + } +} diff --git a/crates/lang-java/src/parser/lsp.rs b/crates/lang-java/src/parser/lsp.rs new file mode 100644 index 0000000..ec98ebb --- /dev/null +++ b/crates/lang-java/src/parser/lsp.rs @@ -0,0 +1,215 @@ +use super::JavaParser; +use naviscope_core::ingest::parser::LspParser; +use naviscope_core::ingest::parser::utils::{RawSymbol, build_symbol_hierarchy}; +use naviscope_core::model::NodeKind; +use std::collections::HashMap; +use tree_sitter::Tree; + +impl LspParser for JavaParser { + fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { + let mut parser = tree_sitter::Parser::new(); + parser.set_language(&self.language).ok()?; + parser.parse(source, old_tree) + } + + fn extract_symbols( + &self, + tree: &Tree, + source: &str, + ) -> Vec { + self.extract_symbols(tree, source) + } + + fn symbol_kind(&self, kind: &NodeKind) -> lsp_types::SymbolKind { + self.symbol_kind(kind) + } + + fn find_occurrences( + &self, + source: &str, + tree: &Tree, + target: &naviscope_core::ingest::parser::SymbolResolution, + ) -> Vec { + self.find_occurrences(source, tree, target) + } +} + +impl JavaParser { + pub fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { + let mut parser = tree_sitter::Parser::new(); + parser.set_language(&self.language).ok()?; + parser.parse(source, old_tree) + } + + pub fn extract_symbols( + &self, + tree: &Tree, + source: &str, + ) -> Vec { + // Only run Stage 1: Identification of entities. + // We don't need full FQN resolution (naming) or relation resolution (Stage 3) + // for building the local document symbol tree. + let mut entities = Vec::new(); + let mut relations = Vec::new(); + let mut entities_map = HashMap::new(); + + let all_matches = self.collect_matches(tree, source); + + // Pass None for package to keep FQNs local/relative during symbol extraction + self.identify_entities( + &all_matches, + source, + &None, + &mut entities, + &mut relations, + &mut entities_map, + ); + + // Convert JavaEntity to RawSymbol for the tree builder + let raw_symbols = entities + .into_iter() + .map(|e| { + let kind = match e.element { + crate::model::JavaIndexMetadata::Class { .. } => NodeKind::Class, + crate::model::JavaIndexMetadata::Interface { .. } => NodeKind::Interface, + crate::model::JavaIndexMetadata::Enum { .. } => NodeKind::Enum, + crate::model::JavaIndexMetadata::Annotation { .. } => NodeKind::Annotation, + crate::model::JavaIndexMetadata::Method { is_constructor, .. } => { + if is_constructor { + NodeKind::Constructor + } else { + NodeKind::Method + } + } + crate::model::JavaIndexMetadata::Field { .. } => NodeKind::Field, + crate::model::JavaIndexMetadata::Package => NodeKind::Package, + }; + + RawSymbol { + name: e.name, + kind, + range: naviscope_core::ingest::parser::utils::range_from_ts(e.node.range()), + selection_range: e + .node + .child_by_field_name("name") + .map(|n| naviscope_core::ingest::parser::utils::range_from_ts(n.range())) + .unwrap_or_else(|| { + naviscope_core::ingest::parser::utils::range_from_ts(e.node.range()) + }), + node: e.node, + } + }) + .collect(); + + build_symbol_hierarchy(raw_symbols) + } + + pub fn symbol_kind(&self, kind: &NodeKind) -> lsp_types::SymbolKind { + use lsp_types::SymbolKind; + match kind { + NodeKind::Class => SymbolKind::CLASS, + NodeKind::Interface => SymbolKind::INTERFACE, + NodeKind::Enum => SymbolKind::ENUM, + NodeKind::Annotation => SymbolKind::INTERFACE, + NodeKind::Method => SymbolKind::METHOD, + NodeKind::Constructor => SymbolKind::CONSTRUCTOR, + NodeKind::Field => SymbolKind::FIELD, + NodeKind::Package => SymbolKind::PACKAGE, + _ => SymbolKind::VARIABLE, + } + } + + pub fn find_occurrences( + &self, + source: &str, + tree: &Tree, + target: &naviscope_core::ingest::parser::SymbolResolution, + ) -> Vec { + let mut ranges = Vec::new(); + + // 1. Extract the identifier name and intent + let (name, intent) = match target { + naviscope_core::ingest::parser::SymbolResolution::Local(range, _) => { + // For local symbols, we extract the name directly from the source at the declaration range + let start = naviscope_core::model::util::line_col_at_to_offset( + source, + range.start_line, + range.start_col, + ); + let end = naviscope_core::model::util::line_col_at_to_offset( + source, + range.end_line, + range.end_col, + ); + + if let (Some(s), Some(e)) = (start, end) { + if s < e && e <= source.len() { + ( + source[s..e].to_string(), + naviscope_api::models::SymbolIntent::Variable, + ) + } else { + return Vec::new(); + } + } else { + return Vec::new(); + } + } + naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, intent) => { + (fqn.split('.').last().unwrap_or(fqn).to_string(), *intent) + } + naviscope_core::ingest::parser::SymbolResolution::Global(fqn) => { + // Global resolution from graph usually implies a high-level symbol (Method/Type/Field) + // We'll try to guess intent if it's not provided, but mostly it will stay broad + ( + fqn.split('.').last().unwrap_or(fqn).to_string(), + naviscope_api::models::SymbolIntent::Unknown, + ) + } + }; + + if name.is_empty() { + return ranges; + } + + let mut cursor = tree_sitter::QueryCursor::new(); + let mut matches = + cursor.matches(&self.occurrence_query, tree.root_node(), source.as_bytes()); + + // Mapping from Intent to the capture index we care about + let target_capture_index = match intent { + naviscope_api::models::SymbolIntent::Method => Some(self.occurrence_indices.method), + naviscope_api::models::SymbolIntent::Type => Some(self.occurrence_indices.type_alias), + naviscope_api::models::SymbolIntent::Field => Some(self.occurrence_indices.field), + _ => None, // Search all identifiers + }; + + use tree_sitter::StreamingIterator; + while let Some(mat) = matches.next() { + // Optimization: If intent is specific, skip matches that don't satisfy the intent structure. + if let Some(target_idx) = target_capture_index { + if !mat.captures.iter().any(|c| c.index == target_idx) { + continue; + } + } + + // Extract the identifier node using our indices + for cap in mat.captures { + if cap.index == self.occurrence_indices.ident { + if let Ok(text) = cap.node.utf8_text(source.as_bytes()) { + if text == name { + let r = cap.node.range(); + ranges.push(naviscope_core::model::Range { + start_line: r.start_point.row, + start_col: r.start_point.column, + end_line: r.end_point.row, + end_col: r.end_point.column, + }); + } + } + } + } + } + ranges + } +} diff --git a/crates/lang-java/src/parser/mod.rs b/crates/lang-java/src/parser/mod.rs new file mode 100644 index 0000000..9dddb19 --- /dev/null +++ b/crates/lang-java/src/parser/mod.rs @@ -0,0 +1,91 @@ +use naviscope_core::error::Result; +use std::sync::Arc; +use tree_sitter::{Query, StreamingIterator, Tree}; + +mod ast; +mod constants; +mod index; +mod lsp; +mod naming; +mod scope; +mod types; + +use crate::queries::java_definitions::JavaIndices; +use crate::queries::java_occurrences::OccurrenceIndices; + +pub struct JavaParser { + pub language: tree_sitter::Language, + pub(crate) definition_query: Arc, + pub(crate) indices: JavaIndices, + pub(crate) occurrence_query: Arc, + pub(crate) occurrence_indices: OccurrenceIndices, +} + +impl Clone for JavaParser { + fn clone(&self) -> Self { + Self { + language: self.language.clone(), + definition_query: Arc::clone(&self.definition_query), + indices: self.indices.clone(), + occurrence_query: Arc::clone(&self.occurrence_query), + occurrence_indices: self.occurrence_indices.clone(), + } + } +} + +impl JavaParser { + pub fn new() -> Result { + let language: tree_sitter::Language = tree_sitter_java::LANGUAGE.into(); + + let definition_query = naviscope_core::ingest::parser::utils::load_query( + &language, + crate::queries::java_definitions::JAVA_DEFINITIONS_SCM, + )?; + let indices = JavaIndices::new(&definition_query)?; + + let occurrence_query = naviscope_core::ingest::parser::utils::load_query( + &language, + crate::queries::java_occurrences::JAVA_OCCURRENCES_SCM, + )?; + let occurrence_indices = OccurrenceIndices::new(&occurrence_query)?; + + Ok(Self { + language, + definition_query: Arc::new(definition_query), + indices, + occurrence_query: Arc::new(occurrence_query), + occurrence_indices, + }) + } + + pub fn extract_package_and_imports( + &self, + tree: &Tree, + source: &str, + ) -> (Option, Vec) { + let mut package = None; + let mut imports = Vec::new(); + let mut cursor = tree_sitter::QueryCursor::new(); + let mut matches = + cursor.matches(&self.definition_query, tree.root_node(), source.as_bytes()); + while let Some(mat) = matches.next() { + if let Some(cap) = mat.captures.iter().find(|c| c.index == self.indices.pkg) { + package = cap + .node + .utf8_text(source.as_bytes()) + .ok() + .map(|s: &str| s.to_string()); + } else if let Some(cap) = mat + .captures + .iter() + .find(|c| c.index == self.indices.import_name) + { + if let Ok(imp) = cap.node.utf8_text(source.as_bytes()) { + let imp_str: &str = imp; + imports.push(imp_str.to_string()); + } + } + } + (package, imports) + } +} diff --git a/crates/lang-java/src/parser/naming.rs b/crates/lang-java/src/parser/naming.rs new file mode 100644 index 0000000..96b28a3 --- /dev/null +++ b/crates/lang-java/src/parser/naming.rs @@ -0,0 +1,228 @@ +use super::JavaParser; +use std::collections::HashSet; +use tree_sitter::Node; + +impl JavaParser { + /// Gets the full FQN for a definition node. + pub fn get_fqn_for_definition( + &self, + name_node: &Node, + source: &str, + pkg: Option<&str>, + ) -> String { + let mut parts = Vec::new(); + let mut curr = *name_node; + let mut seen_ids = HashSet::new(); + + parts.push( + name_node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(), + ); + seen_ids.insert(name_node.id()); + + while let Some(parent) = self.find_next_enclosing_definition(curr) { + let kind = parent.kind(); + // FQN for Java elements should only include Packages and Classes/Interfaces/Enums. + // Methods and Constructors should be skipped when calculating the FQN of nested elements. + if kind.contains("class") + || kind.contains("interface") + || kind.contains("enum") + || kind.contains("annotation") + || kind == "variable_declarator" + { + if let Some(n_node) = parent.child_by_field_name("name") { + if seen_ids.insert(n_node.id()) { + if let Ok(n_text) = n_node.utf8_text(source.as_bytes()) { + parts.push(n_text.to_string()); + } + } + } + } + curr = parent; + } + + parts.reverse(); + let mut fqn = if let Some(p) = pkg { + p.to_string() + } else { + String::new() + }; + for p in parts { + if !fqn.is_empty() { + fqn.push('.'); + } + fqn.push_str(&p); + } + fqn + } + + /// Returns a list of FQNs for all enclosing classes from inner to outer. + pub fn get_enclosing_class_fqns( + &self, + node: &Node, + source: &str, + pkg: Option<&str>, + ) -> Vec { + let mut fqns = Vec::new(); + let mut curr = *node; + + // If the current node is the name of a definition, start searching from the definition itself + // to avoid including the current definition in the enclosing class list. + if let Some(parent) = curr.parent() { + if Self::is_definition_node(parent.kind()) { + if let Some(name_node) = parent.child_by_field_name("name") { + if name_node.id() == node.id() { + curr = parent; + } + } + } + } + + while let Some(container) = self.find_next_enclosing_definition(curr) { + let kind = container.kind(); + if kind.contains("class") || kind.contains("interface") || kind.contains("enum") { + if let Some(name_node) = container.child_by_field_name("name") { + fqns.push(self.get_fqn_for_definition(&name_node, source, pkg)); + } + } + curr = container; + } + fqns + } + + pub fn resolve_type_name_to_fqn( + &self, + type_name: &str, + tree: &tree_sitter::Tree, + source: &str, + ) -> Option { + let (pkg, imports) = self.extract_package_and_imports(tree, source); + self.resolve_type_name_to_fqn_data(type_name, pkg.as_deref(), &imports) + } + + pub fn resolve_type_name_to_fqn_data( + &self, + type_name: &str, + package: Option<&str>, + imports: &[String], + ) -> Option { + // 1. Check if it's a primitive type + const PRIMITIVES: &[&str] = &[ + "int", "long", "short", "byte", "float", "double", "boolean", "char", "void", + ]; + if PRIMITIVES.contains(&type_name) { + return Some(type_name.to_string()); + } + + // 2. Handle dotted names (e.g. Config.KEY or com.example.Config) + if type_name.contains('.') { + let parts: Vec<&str> = type_name.split('.').collect(); + let first_part = parts[0]; + + // If the first part is already the current package, don't recurse + if let Some(p) = package { + if first_part == p { + return Some(type_name.to_string()); + } + } + + // Try to resolve the first part as a type + if let Some(first_fqn) = + self.resolve_type_name_to_fqn_data(first_part, package, imports) + { + if first_fqn != first_part { + // It was resolved to something else (e.g. com.example.Config) + let mut full_fqn = first_fqn; + for part in &parts[1..] { + full_fqn.push('.'); + full_fqn.push_str(part); + } + return Some(full_fqn); + } + } + return Some(type_name.to_string()); + } + + // 3. Precise imports + for imp in imports { + if imp.ends_with(&format!(".{}", type_name)) { + return Some(imp.clone()); + } + } + + // 4. Wildcard imports (e.g., import java.util.*;) + // 5. java.lang (implicit import) + const JAVA_LANG_CLASSES: &[&str] = &[ + "String", + "Object", + "Integer", + "Long", + "Double", + "Float", + "Boolean", + "Byte", + "Character", + "Short", + "Exception", + "RuntimeException", + "Throwable", + "Error", + "Thread", + "System", + "Class", + "Iterable", + "Runnable", + "Comparable", + "SuppressWarnings", + "Override", + "Deprecated", + ]; + if JAVA_LANG_CLASSES.contains(&type_name) { + return Some(format!("java.lang.{}", type_name)); + } + + // 6. Current package + if let Some(p) = package { + // Don't append package if it's already an FQN starting with this package or other known packages + if type_name.starts_with(&(p.to_string() + ".")) + || type_name.starts_with("java.") + || type_name.starts_with("javax.") + || type_name.starts_with("com.") + || type_name.starts_with("org.") + || type_name.starts_with("net.") + { + return Some(type_name.to_string()); + } + return Some(format!("{}.{}", p, type_name)); + } + + Some(type_name.to_string()) + } + + pub(crate) fn is_definition_node(kind: &str) -> bool { + matches!( + kind, + "class_declaration" + | "interface_declaration" + | "enum_declaration" + | "annotation_type_declaration" + | "method_declaration" + | "constructor_declaration" + | "field_declaration" + | "variable_declarator" + ) + } + + pub fn find_next_enclosing_definition<'a>(&self, node: Node<'a>) -> Option> { + let mut curr = node; + while let Some(parent) = curr.parent() { + if Self::is_definition_node(parent.kind()) { + return Some(parent); + } + curr = parent; + } + None + } +} diff --git a/crates/lang-java/src/parser/scope.rs b/crates/lang-java/src/parser/scope.rs new file mode 100644 index 0000000..4c6f300 --- /dev/null +++ b/crates/lang-java/src/parser/scope.rs @@ -0,0 +1,173 @@ +use super::JavaParser; +use naviscope_api::models::SymbolIntent; +use naviscope_core::ingest::parser::utils::range_from_ts; +use naviscope_core::model::Range; +use tree_sitter::Node; + +impl JavaParser { + pub fn find_local_declaration( + &self, + start_node: Node, + name: &str, + source: &str, + ) -> Option<(Range, Option)> { + self.find_local_declaration_node(start_node, name, source) + .map(|(range, type_node)| { + let type_name = type_node + .and_then(|t| t.utf8_text(source.as_bytes()).ok().map(|s| s.to_string())); + (range, type_name) + }) + } + + pub fn find_local_declaration_node<'a>( + &self, + start_node: Node<'a>, + name: &str, + source: &str, + ) -> Option<(Range, Option>)> { + let mut curr = start_node; + while let Some(parent) = curr.parent() { + // Check declarations in this scope before or at the start_node (for parameters) + let mut child_cursor = parent.walk(); + for child in parent.children(&mut child_cursor) { + if let Some(res) = self.is_decl_of_node(&child, name, source) { + // If the declaration is the node itself (like a parameter), or strictly before it + if child.start_byte() <= start_node.start_byte() { + return Some(res); + } + } + if child.start_byte() >= start_node.start_byte() { + break; + } + } + // Check if parent itself is a declaration (like method parameters) + if let Some(res) = self.is_decl_of_node(&parent, name, source) { + return Some(res); + } + curr = parent; + } + None + } + + pub fn determine_intent(&self, node: &Node) -> SymbolIntent { + let parent = match node.parent() { + Some(p) => p, + None => return SymbolIntent::Unknown, + }; + match parent.kind() { + "method_invocation" => { + if let Some(name_node) = parent.child_by_field_name("name") { + if name_node.id() == node.id() { + return SymbolIntent::Method; + } + } + SymbolIntent::Type // Likely the receiver/object + } + "method_reference" => SymbolIntent::Type, + "object_creation_expression" => { + if let Some(type_node) = parent.child_by_field_name("type") { + if type_node.id() == node.id() { + return SymbolIntent::Type; + } + } + SymbolIntent::Unknown + } + "type_identifier" | "scoped_identifier" | "scoped_type_identifier" | "generic_type" => { + SymbolIntent::Type + } + "variable_declarator" => SymbolIntent::Variable, + "field_access" => { + if let Some(field_node) = parent.child_by_field_name("field") { + if field_node.id() == node.id() { + return SymbolIntent::Field; + } + } + SymbolIntent::Type // Likely the receiver/object + } + "class_declaration" + | "interface_declaration" + | "enum_declaration" + | "annotation_type_declaration" => SymbolIntent::Type, + "method_declaration" | "constructor_declaration" => { + if let Some(name_node) = parent.child_by_field_name("name") { + if name_node.id() == node.id() { + return SymbolIntent::Method; + } + } + SymbolIntent::Type + } + _ => { + if node.kind() == "type_identifier" || node.kind() == "scoped_type_identifier" { + SymbolIntent::Type + } else { + SymbolIntent::Unknown + } + } + } + } + + pub fn is_decl_of_node<'a>( + &self, + node: &Node<'a>, + name: &str, + source: &str, + ) -> Option<(Range, Option>)> { + match node.kind() { + "variable_declarator" | "formal_parameter" | "catch_formal_parameter" => { + if let Some(name_node) = node.child_by_field_name("name") { + if name_node.utf8_text(source.as_bytes()).ok()? == name { + let range = range_from_ts(name_node.range()); + let type_node = if node.kind() == "variable_declarator" { + // Type is in the parent local_variable_declaration + node.parent().and_then(|p| p.child_by_field_name("type")) + } else { + // Type is a sibling for parameters + node.child_by_field_name("type") + }; + return Some((range, type_node)); + } + } + } + "local_variable_declaration" + | "formal_parameters" + | "inferred_parameters" + | "enhanced_for_statement" + | "lambda_expression" => { + if node.kind() == "lambda_expression" { + if let Some(params) = node.child_by_field_name("parameters") { + if params.kind() == "identifier" { + if params.utf8_text(source.as_bytes()).ok()? == name { + return Some((range_from_ts(params.range()), None)); + } + } else { + return self.is_decl_of_node(¶ms, name, source); + } + } + return None; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some(res) = self.is_decl_of_node(&child, name, source) { + return Some(res); + } + } + } + _ => {} + } + None + } + + pub fn is_decl_of( + &self, + node: &Node, + name: &str, + source: &str, + ) -> Option<(Range, Option)> { + self.is_decl_of_node(node, name, source) + .map(|(range, type_node)| { + let type_name = type_node + .and_then(|t| t.utf8_text(source.as_bytes()).ok().map(|s| s.to_string())); + (range, type_name) + }) + } +} diff --git a/crates/lang-java/src/parser/types.rs b/crates/lang-java/src/parser/types.rs new file mode 100644 index 0000000..ca3c29b --- /dev/null +++ b/crates/lang-java/src/parser/types.rs @@ -0,0 +1,99 @@ +use super::JavaParser; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +impl JavaParser { + pub fn parse_type_node(&self, node: Node, source: &str) -> TypeRef { + match node.kind() { + "generic_type" => { + let base_node = node.child_by_field_name("type").or_else(|| node.child(0)); + + let base = if let Some(b) = base_node { + self.parse_type_node(b, source) + } else { + TypeRef::Unknown + }; + + let mut args = Vec::new(); + // Iterate over children to find type_arguments + // We manually iterate because child_by_field_name might not catch everything if grammar varies + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "type_arguments" { + let mut args_cursor = child.walk(); + for arg in child.children(&mut args_cursor) { + if !matches!(arg.kind(), "<" | ">" | ",") { + args.push(self.parse_type_node(arg, source)); + } + } + } + } + + TypeRef::Generic { + base: Box::new(base), + args, + } + } + "array_type" => { + let element_node = node + .child_by_field_name("element") + .or_else(|| node.child(0)); + + let element = if let Some(e) = element_node { + self.parse_type_node(e, source) + } else { + TypeRef::Unknown + }; + + let dim_node = node.child_by_field_name("dimensions"); + let count = if let Some(d) = dim_node { + d.utf8_text(source.as_bytes()) + .unwrap_or("") + .matches('[') + .count() + } else { + 1 + }; + + TypeRef::Array { + element: Box::new(element), + dimensions: count, + } + } + "wildcard" => { + // Check for bounds + let mut bound = None; + let mut is_upper = true; + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + match child.kind() { + "super" => is_upper = false, + "extends" => is_upper = true, + k if k != "?" => { + // Assume this is the type bound + bound = Some(Box::new(self.parse_type_node(child, source))); + } + _ => {} + } + } + + TypeRef::Wildcard { + bound, + is_upper_bound: is_upper, + } + } + _ => { + let text = node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + if text.is_empty() { + TypeRef::Unknown + } else { + TypeRef::Raw(text) + } + } + } + } +} diff --git a/src/parser/queries/java_definitions.rs b/crates/lang-java/src/queries/java_definitions.rs similarity index 82% rename from src/parser/queries/java_definitions.rs rename to crates/lang-java/src/queries/java_definitions.rs index 904b251..9dc46c4 100644 --- a/src/parser/queries/java_definitions.rs +++ b/crates/lang-java/src/queries/java_definitions.rs @@ -1,4 +1,4 @@ -crate::decl_indices!(JavaIndices, { +naviscope_core::decl_indices!(JavaIndices, { pkg => "package_name", class_def => "class_def", class_name => "class_name", @@ -29,7 +29,12 @@ crate::decl_indices!(JavaIndices, { call_name => "call_name", inst => "instantiation", inst_type => "inst_type", + field_obj => "field_obj", + field_name_node => "field_name_node", + field_access_meta => "field_access_meta", import_name => "import_name", lambda_def => "lambda_def", lambda_param => "lambda_param", }); + +pub const JAVA_DEFINITIONS_SCM: &str = include_str!("java_definitions.scm"); diff --git a/src/parser/queries/java_definitions.scm b/crates/lang-java/src/queries/java_definitions.scm similarity index 93% rename from src/parser/queries/java_definitions.scm rename to crates/lang-java/src/queries/java_definitions.scm index b79c70b..57f7633 100644 --- a/src/parser/queries/java_definitions.scm +++ b/crates/lang-java/src/queries/java_definitions.scm @@ -73,6 +73,10 @@ type: [ (type_identifier) (generic_type) (scoped_type_identifier) ] @inst_type ) @instantiation +(field_access + object: [ (identifier) (field_access) (this) ] @field_obj + field: (identifier) @field_name_node) @field_access_meta + (lambda_expression parameters: [ (identifier) @lambda_param diff --git a/crates/lang-java/src/queries/java_occurrences.rs b/crates/lang-java/src/queries/java_occurrences.rs new file mode 100644 index 0000000..29ff03b --- /dev/null +++ b/crates/lang-java/src/queries/java_occurrences.rs @@ -0,0 +1,9 @@ +naviscope_core::decl_indices!(OccurrenceIndices, { + ident => "ident", + method => "method_occurrence", + type_alias => "type_occurrence", + field => "field_occurrence", + generic => "generic_occurrence", +}); + +pub const JAVA_OCCURRENCES_SCM: &str = include_str!("java_occurrences.scm"); diff --git a/crates/lang-java/src/queries/java_occurrences.scm b/crates/lang-java/src/queries/java_occurrences.scm new file mode 100644 index 0000000..e5ee19a --- /dev/null +++ b/crates/lang-java/src/queries/java_occurrences.scm @@ -0,0 +1,23 @@ +;; Intent-aware SCM queries for finding symbol occurrences + +;; Method Intent +[ + (method_invocation name: (identifier) @ident) + (method_declaration name: (identifier) @ident) + (constructor_declaration name: (identifier) @ident) +] @method_occurrence + +;; Type Intent +(type_identifier) @ident @type_occurrence + +;; Field Intent +[ + (field_access field: (identifier) @ident) + (variable_declarator name: (identifier) @ident) +] @field_occurrence + +;; Generic Fallback +[ + (identifier) @ident + (type_identifier) @ident +] @generic_occurrence diff --git a/crates/lang-java/src/queries/mod.rs b/crates/lang-java/src/queries/mod.rs new file mode 100644 index 0000000..c4aa5bd --- /dev/null +++ b/crates/lang-java/src/queries/mod.rs @@ -0,0 +1,2 @@ +pub mod java_definitions; +pub mod java_occurrences; diff --git a/crates/lang-java/src/resolver/context.rs b/crates/lang-java/src/resolver/context.rs new file mode 100644 index 0000000..3bed374 --- /dev/null +++ b/crates/lang-java/src/resolver/context.rs @@ -0,0 +1,74 @@ +use crate::parser::JavaParser; +use naviscope_api::models::SymbolIntent; +use naviscope_core::features::CodeGraphLike; +use naviscope_core::model::ResolvedUnit; +use tree_sitter::{Node, Tree}; + +pub struct ResolutionContext<'a> { + pub node: Node<'a>, + pub name: String, + pub index: &'a dyn CodeGraphLike, // Updated type + pub unit: Option<&'a ResolvedUnit>, + pub source: &'a str, + pub tree: &'a Tree, + pub intent: SymbolIntent, + pub package: Option, + pub imports: Vec, + pub enclosing_classes: Vec, + pub receiver_node: Option>, +} + +impl<'a> ResolutionContext<'a> { + pub fn new( + node: Node<'a>, + name: String, + index: &'a dyn CodeGraphLike, // Updated type + source: &'a str, + tree: &'a Tree, + parser: &JavaParser, + ) -> Self { + Self::new_with_unit(node, name, index, None, source, tree, parser) + } + + pub fn new_with_unit( + node: Node<'a>, + name: String, + index: &'a dyn CodeGraphLike, // Updated type + unit: Option<&'a ResolvedUnit>, + source: &'a str, + tree: &'a Tree, + parser: &JavaParser, + ) -> Self { + let (package, imports) = parser.extract_package_and_imports(tree, source); + let enclosing_classes = parser.get_enclosing_class_fqns(&node, source, package.as_deref()); + let intent = parser.determine_intent(&node); + + let receiver_node = node.parent().and_then(|parent| match parent.kind() { + "field_access" | "method_invocation" => parent + .child_by_field_name("object") + .filter(|obj| obj.id() != node.id()), + "scoped_type_identifier" => parent + .child_by_field_name("scope") + .or_else(|| parent.named_child(0)) + .filter(|obj| obj.id() != node.id()), + "scoped_identifier" => parent + .child_by_field_name("scope") + .filter(|obj| obj.id() != node.id()), + _ => None, + }); + + Self { + node, + name, + index, + unit, + source, + tree, + intent, + package, + imports, + enclosing_classes, + receiver_node, + } + } +} diff --git a/crates/lang-java/src/resolver/mod.rs b/crates/lang-java/src/resolver/mod.rs new file mode 100644 index 0000000..d1934b7 --- /dev/null +++ b/crates/lang-java/src/resolver/mod.rs @@ -0,0 +1,562 @@ +use crate::model::{JavaIndexMetadata, JavaNodeMetadata}; +use crate::parser::JavaParser; +use naviscope_api::models::SymbolIntent; +use naviscope_api::models::TypeRef; +use naviscope_api::models::symbol::matches_intent; +use naviscope_core::error::Result; +use naviscope_core::features::CodeGraphLike; +use naviscope_core::ingest::parser::SymbolResolution; +use naviscope_core::ingest::resolver::{LangResolver, ProjectContext, SemanticResolver}; +use naviscope_core::ingest::scanner::{ParsedContent, ParsedFile}; +use naviscope_core::model::CodeGraph; +use naviscope_core::model::{EdgeType, GraphEdge, GraphOp, NodeKind, ResolvedUnit}; +use petgraph::stable_graph::NodeIndex; +use std::ops::ControlFlow; +use std::sync::Arc; +use tree_sitter::Tree; + +pub mod context; +pub mod scope; + +use context::ResolutionContext; +use scope::{BuiltinScope, ImportScope, LocalScope, MemberScope, Scope}; + +#[derive(Clone)] +pub struct JavaResolver { + pub parser: JavaParser, +} + +impl JavaResolver { + pub fn new() -> Self { + Self { + parser: JavaParser::new().expect("Failed to initialize JavaParser"), + } + } + + fn get_active_scopes<'a>(&'a self, ctx: &'a ResolutionContext) -> Vec> { + let mut scopes: Vec> = Vec::new(); + + if ctx.receiver_node.is_none() { + scopes.push(Box::new(LocalScope { + parser: &self.parser, + })); + } + + scopes.push(Box::new(MemberScope { + parser: &self.parser, + })); + scopes.push(Box::new(ImportScope { + parser: &self.parser, + })); + + if ctx.intent == SymbolIntent::Type { + scopes.push(Box::new(BuiltinScope { + parser: &self.parser, + })); + } + + scopes + } + + fn resolve_type_ref( + &self, + type_ref: &TypeRef, + package: Option<&str>, + imports: &[String], + known_fqns: &std::collections::HashSet, + ) -> TypeRef { + match type_ref { + TypeRef::Raw(name) => { + // 1. Check if name matches a known FQN suffix in the same file (Inner class priority) + if let Some(fqn) = known_fqns + .iter() + .find(|k| k.ends_with(&format!(".{}", name)) || *k == name) + { + // Simple heuristic: if the name matches the end of a known FQN, use it. + // This handles 'Source' -> '...DefaultApplicationArguments.Source' + return TypeRef::Id(fqn.clone()); + } + + if let Some(fqn) = self + .parser + .resolve_type_name_to_fqn_data(name, package, imports) + { + TypeRef::Id(fqn) + } else { + TypeRef::Raw(name.clone()) + } + } + TypeRef::Generic { base, args } => TypeRef::Generic { + base: Box::new(self.resolve_type_ref(base, package, imports, known_fqns)), + args: args + .iter() + .map(|a| self.resolve_type_ref(a, package, imports, known_fqns)) + .collect(), + }, + TypeRef::Array { + element, + dimensions, + } => TypeRef::Array { + element: Box::new(self.resolve_type_ref(element, package, imports, known_fqns)), + dimensions: *dimensions, + }, + TypeRef::Wildcard { + bound, + is_upper_bound, + } => TypeRef::Wildcard { + bound: bound + .as_ref() + .map(|b| Box::new(self.resolve_type_ref(b, package, imports, known_fqns))), + is_upper_bound: *is_upper_bound, + }, + _ => type_ref.clone(), + } + } + + pub fn resolve_symbol_internal(&self, context: &ResolutionContext) -> Option { + match self.get_active_scopes(context).into_iter().try_fold( + None, + |_: Option, scope: Box| match scope + .resolve(&context.name, context) + { + Some(Ok(res)) => ControlFlow::Break(Some(res)), + Some(Err(())) => ControlFlow::Break(None), + None => ControlFlow::Continue(None), + }, + ) { + ControlFlow::Break(res) => res, + ControlFlow::Continue(_) => None, + } + } +} + +impl SemanticResolver for JavaResolver { + fn resolve_at( + &self, + tree: &Tree, + source: &str, + line: usize, + byte_col: usize, + index: &dyn CodeGraphLike, + ) -> Option { + let point = tree_sitter::Point::new(line, byte_col); + let node = tree + .root_node() + .named_descendant_for_point_range(point, point) + .filter(|n| { + matches!( + n.kind(), + "identifier" | "type_identifier" | "scoped_identifier" | "this" + ) + })?; + + let name = node.utf8_text(source.as_bytes()).ok()?.to_string(); + let context = ResolutionContext::new(node, name, index, source, tree, &self.parser); + + self.resolve_symbol_internal(&context) + } + + fn find_matches( + &self, + index: &dyn CodeGraphLike, + resolution: &SymbolResolution, + ) -> Vec { + let symbols = index.symbols(); + match resolution { + SymbolResolution::Local(_, _) => vec![], + SymbolResolution::Precise(fqn, intent) => { + if let Some(key) = symbols.get(fqn.as_str()) { + if let Some(&idx) = index + .fqn_map() + .get(&naviscope_api::models::symbol::Symbol(key)) + { + if let Some(node) = index.topology().node_weight(idx) { + if *intent == SymbolIntent::Unknown + || matches_intent(&node.kind, *intent) + { + return vec![idx]; + } + } + } + } + vec![] + } + SymbolResolution::Global(fqn) => { + if let Some(key) = symbols.get(fqn.as_str()) { + if let Some(&idx) = index + .fqn_map() + .get(&naviscope_api::models::symbol::Symbol(key)) + { + return vec![idx]; + } + } + vec![] + } + } + } + + fn resolve_type_of( + &self, + index: &dyn CodeGraphLike, + resolution: &SymbolResolution, + ) -> Vec { + // Reuse original logic + let mut type_resolutions = Vec::new(); + let symbols = index.symbols(); + + match resolution { + SymbolResolution::Local(_, type_name) => { + if let Some(tn) = type_name { + if let Some(fqn) = self.parser.resolve_type_name_to_fqn_data(tn, None, &[]) { + type_resolutions.push(SymbolResolution::Precise(fqn, SymbolIntent::Type)); + } + } + } + SymbolResolution::Precise(fqn, intent) => { + if let Some(key) = symbols.get(fqn.as_str()) { + if let Some(&idx) = index + .fqn_map() + .get(&naviscope_api::models::symbol::Symbol(key)) + { + let node = &index.topology()[idx]; + if let Some(java_meta) = + node.metadata.as_any().downcast_ref::() + { + match java_meta { + JavaNodeMetadata::Field { type_ref, .. } => match type_ref { + TypeRef::Raw(s) => type_resolutions.push( + SymbolResolution::Precise(s.clone(), SymbolIntent::Type), + ), + TypeRef::Id(id) => type_resolutions.push( + SymbolResolution::Precise(id.clone(), SymbolIntent::Type), + ), + _ => {} + }, + JavaNodeMetadata::Method { return_type, .. } => match return_type { + TypeRef::Raw(s) => type_resolutions.push( + SymbolResolution::Precise(s.clone(), SymbolIntent::Type), + ), + TypeRef::Id(id) => type_resolutions.push( + SymbolResolution::Precise(id.clone(), SymbolIntent::Type), + ), + _ => {} + }, + _ => { + if matches_intent(&node.kind, SymbolIntent::Type) { + type_resolutions.push(resolution.clone()); + } + } + } + } + } else if *intent == SymbolIntent::Type { + type_resolutions.push(resolution.clone()); + } + } else if *intent == SymbolIntent::Type { + type_resolutions.push(resolution.clone()); + } + } + SymbolResolution::Global(fqn) => { + if let Some(key) = symbols.get(fqn.as_str()) { + if let Some(&idx) = index + .fqn_map() + .get(&naviscope_api::models::symbol::Symbol(key)) + { + let node = &index.topology()[idx]; + if matches_intent(&node.kind, SymbolIntent::Type) { + type_resolutions.push(resolution.clone()); + } + } + } + } + } + type_resolutions + } + + fn find_implementations( + &self, + index: &dyn CodeGraphLike, + resolution: &SymbolResolution, + ) -> Vec { + let target_nodes = self.find_matches(index, resolution); + let mut results = Vec::new(); + + for &node_idx in &target_nodes { + let node = &index.topology()[node_idx]; + + // Check if it's a method + let is_method = if let Some(java_meta) = + node.metadata.as_any().downcast_ref::() + { + matches!(java_meta, JavaNodeMetadata::Method { .. }) + } else { + false + }; + + if is_method { + // 1. Find the enclosing class/interface + let mut parent_incoming = index + .topology() + .neighbors_directed(node_idx, petgraph::Direction::Incoming) + .detach(); + while let Some((edge_idx, parent_idx)) = parent_incoming.next(index.topology()) { + if index.topology()[edge_idx].edge_type == EdgeType::Contains { + // 2. Find all implementations of this parent + let parent_fqn = index.topology()[parent_idx] + .fqn(index.symbols()) + .to_string(); + let parent_res = SymbolResolution::Precise(parent_fqn, SymbolIntent::Type); + let impl_classes = self.find_implementations(index, &parent_res); + + // 3. For each impl class, find a method with same name + for impl_class_idx in impl_classes { + let mut children = index + .topology() + .neighbors_directed(impl_class_idx, petgraph::Direction::Outgoing) + .detach(); + while let Some((c_edge_idx, child_idx)) = + children.next(index.topology()) + { + if index.topology()[c_edge_idx].edge_type == EdgeType::Contains { + let child_node = &index.topology()[child_idx]; + let is_child_method = if let Some(java_meta) = child_node + .metadata + .as_any() + .downcast_ref::() + { + matches!(java_meta, JavaNodeMetadata::Method { .. }) + } else { + false + }; + + if is_child_method && child_node.name == node.name { + results.push(child_idx); + } + } + } + } + } + } + continue; + } + + let mut incoming = index + .topology() + .neighbors_directed(node_idx, petgraph::Direction::Incoming) + .detach(); + while let Some((edge_idx, neighbor_idx)) = incoming.next(index.topology()) { + let edge = &index.topology()[edge_idx]; + if edge.edge_type == EdgeType::Implements + || edge.edge_type == EdgeType::InheritsFrom + { + results.push(neighbor_idx); + } + } + } + results + } +} + +impl LangResolver for JavaResolver { + fn resolve(&self, file: &ParsedFile, context: &ProjectContext) -> Result { + let mut unit = ResolvedUnit::new(); + let dummy_index = CodeGraph::empty(); + + let parse_result_owned; + let parse_result = match &file.content { + ParsedContent::Language(res) => res, + ParsedContent::Unparsed(src) => { + if file.path().extension().map_or(false, |e| e == "java") { + use naviscope_core::ingest::parser::IndexParser; + parse_result_owned = self.parser.parse_file(src, Some(&file.file.path))?; + &parse_result_owned + } else { + return Ok(unit); + } + } + _ => return Ok(unit), + }; + + { + // Scope for usage of parse_result + unit.identifiers = parse_result.output.identifiers.clone(); + unit.ops.push(GraphOp::UpdateIdentifiers { + path: Arc::from(file.file.path.as_path()), + identifiers: unit.identifiers.clone(), + }); + + let module_id = context + .find_module_for_path(&file.file.path) + .unwrap_or_else(|| "module::root".to_string()); + + let container_id = if let Some(pkg_name) = &parse_result.package_name { + let package_id = if module_id.contains("::") { + format!("{}.{}", module_id, pkg_name) + } else { + format!("{}::{}", module_id, pkg_name) + }; + + let package_node = naviscope_core::ingest::parser::IndexNode { + id: package_id.clone(), + name: pkg_name.to_string(), + kind: NodeKind::Package, + lang: "java".to_string(), + location: None, + metadata: Arc::new(crate::model::JavaIndexMetadata::Package), + }; + + unit.add_node(package_node); + + unit.add_edge( + Arc::from(module_id.as_str()), + Arc::from(package_id.as_str()), + GraphEdge::new(EdgeType::Contains), + ); + + package_id + } else { + module_id + }; + + let mut known_types = std::collections::HashSet::::new(); + let mut local_type_map = std::collections::HashMap::::new(); + + for node in &parse_result.output.nodes { + if matches!( + node.kind, + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation + ) { + known_types.insert(node.id.clone()); + } + } + + for node in &parse_result.output.nodes { + let mut node = node.clone(); + + if let Some(java_idx_meta) = node + .metadata + .as_any() + .downcast_ref::() + { + let mut element = java_idx_meta.clone(); + + match &mut element { + JavaIndexMetadata::Method { + return_type, + parameters, + .. + } => { + *return_type = self.resolve_type_ref( + return_type, + parse_result.package_name.as_deref(), + &parse_result.imports, + &known_types, + ); + for param in parameters { + param.type_ref = self.resolve_type_ref( + ¶m.type_ref, + parse_result.package_name.as_deref(), + &parse_result.imports, + &known_types, + ); + if let TypeRef::Id(type_fqn) = ¶m.type_ref { + local_type_map.insert(node.name.clone(), type_fqn.clone()); + } + } + } + JavaIndexMetadata::Field { type_ref, .. } => { + *type_ref = self.resolve_type_ref( + type_ref, + parse_result.package_name.as_deref(), + &parse_result.imports, + &known_types, + ); + if let TypeRef::Id(type_fqn) = &type_ref { + local_type_map.insert(node.name.clone(), type_fqn.clone()); + } + } + _ => {} + } + node.metadata = Arc::new(element); + } + + let is_top = matches!( + node.kind, + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation + ); + + unit.add_node(node.clone()); + if is_top { + unit.add_edge( + Arc::from(container_id.as_str()), + Arc::from(node.id.as_str()), + GraphEdge::new(EdgeType::Contains), + ); + } + } + + for rel in &parse_result.output.relations { + let mut resolved_target = rel.target_id.clone(); + + if let (Some(tree), Some(source)) = (&parse_result.tree, &parse_result.source) { + if let Some(r) = &rel.range { + let point = tree_sitter::Point::new(r.start_line, r.start_col); + if let Some(node) = tree + .root_node() + .named_descendant_for_point_range(point, point) + { + let context = ResolutionContext::new_with_unit( + node, + rel.target_id.clone(), + &dummy_index, + Some(&unit), + source, + tree, + &self.parser, + ); + + if let Some(SymbolResolution::Precise(fqn, _)) = + self.resolve_symbol_internal(&context) + { + resolved_target = fqn; + } else { + if rel.target_id.contains('.') { + let parts: Vec<&str> = rel.target_id.split('.').collect(); + if parts.len() >= 2 { + let obj_name = parts[0]; + if let Some(type_fqn) = local_type_map.get(obj_name) { + let mut new_target = type_fqn.clone(); + for part in &parts[1..] { + new_target.push('.'); + new_target.push_str(part); + } + resolved_target = new_target; + } + } + } + + if !resolved_target.contains('.') { + if let Some(res) = self.parser.resolve_type_name_to_fqn_data( + &resolved_target, + parse_result.package_name.as_deref(), + &parse_result.imports, + ) { + resolved_target = res; + } + } + } + } + } + } + + let edge = GraphEdge::new(rel.edge_type.clone()); + unit.add_edge( + Arc::from(rel.source_id.as_str()), + Arc::from(resolved_target.as_str()), + edge, + ); + } + } + + Ok(unit) + } +} diff --git a/crates/lang-java/src/resolver/scope/builtin.rs b/crates/lang-java/src/resolver/scope/builtin.rs new file mode 100644 index 0000000..9cf20fe --- /dev/null +++ b/crates/lang-java/src/resolver/scope/builtin.rs @@ -0,0 +1,96 @@ +use crate::parser::JavaParser; +use crate::resolver::context::ResolutionContext; +use crate::resolver::scope::SemanticScope; +use naviscope_api::models::SymbolIntent; +use naviscope_core::ingest::parser::SymbolResolution; + +pub struct BuiltinScope<'a> { + pub parser: &'a JavaParser, +} + +impl SemanticScope> for BuiltinScope<'_> { + fn resolve( + &self, + name: &str, + context: &ResolutionContext, + ) -> Option> { + if context.intent != SymbolIntent::Type { + return None; + } + + self.parser + .resolve_type_name_to_fqn_data(name, context.package.as_deref(), &context.imports) + .and_then(|fqn| { + // Only return if it's a known FQN or a primitive or java.lang + let known = context + .index + .symbols() + .get(fqn.as_str()) + .map_or(false, |k| { + context + .index + .fqn_map() + .contains_key(&naviscope_api::models::symbol::Symbol(k)) + }); + + if known || fqn.starts_with("java.lang.") || !fqn.contains('.') { + Some(Ok(SymbolResolution::Precise(fqn, SymbolIntent::Type))) + } else { + None + } + }) + } + fn name(&self) -> &'static str { + "Builtin" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use naviscope_core::model::CodeGraph; + use tree_sitter::Parser; + + #[test] + fn test_builtin_scope_java_lang() { + let source = "class Test { String s; }"; + let mut parser = Parser::new(); + parser + .set_language(&crate::parser::JavaParser::new().unwrap().language) + .expect("Error loading Java grammar"); + let tree = parser.parse(source, None).unwrap(); + + let string_node = tree + .root_node() + .named_descendant_for_point_range( + tree_sitter::Point::new(0, 13), + tree_sitter::Point::new(0, 19), + ) + .unwrap(); + + let java_parser = JavaParser::new().unwrap(); + let index = CodeGraph::empty(); + + let context = ResolutionContext::new( + string_node, + "String".to_string(), + &index, + source, + &tree, + &java_parser, + ); + + let scope = BuiltinScope { + parser: &java_parser, + }; + let res = scope.resolve("String", &context); + + assert!(res.is_some()); + match res.unwrap() { + Ok(SymbolResolution::Precise(fqn, _)) => { + assert_eq!(fqn, "java.lang.String"); + } + _ => panic!("Expected Precise resolution"), + } + } +} diff --git a/crates/lang-java/src/resolver/scope/import_scope.rs b/crates/lang-java/src/resolver/scope/import_scope.rs new file mode 100644 index 0000000..64a24e6 --- /dev/null +++ b/crates/lang-java/src/resolver/scope/import_scope.rs @@ -0,0 +1,93 @@ +use crate::parser::JavaParser; +use crate::resolver::context::ResolutionContext; +use crate::resolver::scope::SemanticScope; +use naviscope_core::ingest::parser::SymbolResolution; + +pub struct ImportScope<'a> { + pub parser: &'a JavaParser, +} + +impl SemanticScope> for ImportScope<'_> { + fn resolve( + &self, + name: &str, + context: &ResolutionContext, + ) -> Option> { + // 1. Precise imports + context + .imports + .iter() + .find(|imp| imp.ends_with(&format!(".{}", name))) + .map(|imp| Ok(SymbolResolution::Precise(imp.clone(), context.intent))) + .or_else(|| { + // 2. Current package + context + .package + .as_ref() + .map(|pkg| format!("{}.{}", pkg, name)) + .and_then(|candidate| { + context + .index + .symbols() + .get(candidate.as_str()) + .map(|k| naviscope_api::models::symbol::Symbol(k)) + .filter(|sym| context.index.fqn_map().contains_key(sym)) + .map(|_| candidate) + }) + .map(|fqn| Ok(SymbolResolution::Precise(fqn, context.intent))) + }) + } + fn name(&self) -> &'static str { + "Import" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use naviscope_core::model::CodeGraph; + use tree_sitter::Parser; + + #[test] + fn test_import_scope_precise() { + let source = "import java.util.List; class Test { List x; }"; + let mut parser = Parser::new(); + parser + .set_language(&crate::parser::JavaParser::new().unwrap().language) + .expect("Error loading Java grammar"); + let tree = parser.parse(source, None).unwrap(); + + let list_node = tree + .root_node() + .named_descendant_for_point_range( + tree_sitter::Point::new(0, 36), + tree_sitter::Point::new(0, 40), + ) + .unwrap(); + + let java_parser = JavaParser::new().unwrap(); + let index = CodeGraph::empty(); + + let context = ResolutionContext::new( + list_node, + "List".to_string(), + &index, + source, + &tree, + &java_parser, + ); + + let scope = ImportScope { + parser: &java_parser, + }; + let res = scope.resolve("List", &context); + + assert!(res.is_some()); + match res.unwrap() { + Ok(SymbolResolution::Precise(fqn, _)) => { + assert_eq!(fqn, "java.util.List"); + } + _ => panic!("Expected Precise resolution"), + } + } +} diff --git a/src/resolver/lang/java/scope/local.rs b/crates/lang-java/src/resolver/scope/local.rs similarity index 53% rename from src/resolver/lang/java/scope/local.rs rename to crates/lang-java/src/resolver/scope/local.rs index 3df3256..66b869a 100644 --- a/src/resolver/lang/java/scope/local.rs +++ b/crates/lang-java/src/resolver/scope/local.rs @@ -1,60 +1,68 @@ -use crate::parser::java::JavaParser; -use crate::parser::SymbolResolution; -use crate::resolver::lang::java::context::ResolutionContext; +use crate::parser::JavaParser; +use crate::resolver::context::ResolutionContext; use crate::resolver::scope::SemanticScope; +use naviscope_core::ingest::parser::SymbolResolution; pub struct LocalScope<'a> { pub parser: &'a JavaParser, } impl SemanticScope> for LocalScope<'_> { - fn resolve(&self, name: &str, context: &ResolutionContext) -> Option> { + fn resolve( + &self, + name: &str, + context: &ResolutionContext, + ) -> Option> { // Local scope is only searched if there is no explicit receiver if context.receiver_node.is_some() { return None; } - self.parser.find_local_declaration(context.node, name, context.source) + self.parser + .find_local_declaration(context.node, name, context.source) .map(|(range, type_name)| Ok(SymbolResolution::Local(range, type_name))) } - fn name(&self) -> &'static str { "Local" } + fn name(&self) -> &'static str { + "Local" + } } #[cfg(test)] mod tests { use super::*; - use crate::index::CodeGraph; + use naviscope_core::model::CodeGraph; use tree_sitter::Parser; #[test] fn test_local_scope_resolve() { let source = "class Test { void main() { int x = 1; System.out.println(x); } }"; let mut parser = Parser::new(); - parser.set_language(&crate::parser::java::JavaParser::new().unwrap().language).expect("Error loading Java grammar"); + parser + .set_language(&crate::parser::JavaParser::new().unwrap().language) + .expect("Error loading Java grammar"); let tree = parser.parse(source, None).unwrap(); - + // Find the 'x' in println(x) - let x_node = tree.root_node().named_descendant_for_point_range( - tree_sitter::Point::new(0, 57), - tree_sitter::Point::new(0, 58) - ).unwrap(); - + let x_node = tree + .root_node() + .named_descendant_for_point_range( + tree_sitter::Point::new(0, 57), + tree_sitter::Point::new(0, 58), + ) + .unwrap(); + assert_eq!(x_node.utf8_text(source.as_bytes()).unwrap(), "x"); let java_parser = JavaParser::new().unwrap(); - let index = CodeGraph::new(); - let context = ResolutionContext::new( - x_node, - "x".to_string(), - &index, - source, - &tree, - &java_parser, - ); + let index = CodeGraph::empty(); + let context = + ResolutionContext::new(x_node, "x".to_string(), &index, source, &tree, &java_parser); - let scope = LocalScope { parser: &java_parser }; + let scope = LocalScope { + parser: &java_parser, + }; let res = scope.resolve("x", &context); - + assert!(res.is_some()); match res.unwrap() { Ok(SymbolResolution::Local(range, type_name)) => { diff --git a/crates/lang-java/src/resolver/scope/member.rs b/crates/lang-java/src/resolver/scope/member.rs new file mode 100644 index 0000000..641c668 --- /dev/null +++ b/crates/lang-java/src/resolver/scope/member.rs @@ -0,0 +1,473 @@ +use crate::model::{JavaIndexMetadata, JavaNodeMetadata}; +use crate::parser::JavaParser; +use crate::resolver::context::ResolutionContext; +use crate::resolver::scope::SemanticScope; +use naviscope_api::models::TypeRef; +use naviscope_core::ingest::parser::SymbolResolution; + +pub struct MemberScope<'a> { + pub parser: &'a JavaParser, +} + +impl MemberScope<'_> { + fn resolve_type_ref_fqns(&self, type_ref: &TypeRef, context: &ResolutionContext) -> TypeRef { + match type_ref { + TypeRef::Raw(name) | TypeRef::Id(name) => { + if let Some(fqn) = + self.parser + .resolve_type_name_to_fqn(name, context.tree, context.source) + { + TypeRef::Id(fqn) + } else { + TypeRef::Raw(name.clone()) + } + } + TypeRef::Generic { base, args } => TypeRef::Generic { + base: Box::new(self.resolve_type_ref_fqns(base, context)), + args: args + .iter() + .map(|a| self.resolve_type_ref_fqns(a, context)) + .collect(), + }, + TypeRef::Array { + element, + dimensions, + } => TypeRef::Array { + element: Box::new(self.resolve_type_ref_fqns(element, context)), + dimensions: *dimensions, + }, + TypeRef::Wildcard { + bound, + is_upper_bound, + } => TypeRef::Wildcard { + bound: bound + .as_ref() + .map(|b| Box::new(self.resolve_type_ref_fqns(b, context))), + is_upper_bound: *is_upper_bound, + }, + _ => type_ref.clone(), + } + } + + fn get_base_fqn(&self, type_ref: &TypeRef) -> Option { + match type_ref { + TypeRef::Id(s) | TypeRef::Raw(s) => Some(s.clone()), + TypeRef::Generic { base, .. } => self.get_base_fqn(base), + _ => None, + } + } + + fn resolve_fqn_from_context(&self, name: &str, context: &ResolutionContext) -> Option { + let lookup_index = |n: &str| -> bool { + context.index.symbols().get(n).map_or(false, |k| { + context + .index + .fqn_map() + .contains_key(&naviscope_api::models::symbol::Symbol(k)) + }) + }; + + // 1. Check if it's already an FQN in the index or current unit + if lookup_index(name) || context.unit.map_or(false, |u| u.nodes.contains_key(name)) { + return Some(name.to_string()); + } + + // 2. Check inner classes in enclosing classes + for container_fqn in &context.enclosing_classes { + let candidate = format!("{}.{}", container_fqn, name); + if lookup_index(&candidate) + || context + .unit + .map_or(false, |u| u.nodes.contains_key(candidate.as_str())) + { + return Some(candidate); + } + } + + // 3. Use parser's resolution (imports/package) + if let Some(fqn) = self + .parser + .resolve_type_name_to_fqn(name, context.tree, context.source) + { + if fqn != name { + return Some(fqn); + } + } + + Some(name.to_string()) + } + + fn resolve_expression_type( + &self, + node: &tree_sitter::Node, + context: &ResolutionContext, + ) -> Option { + // Helper to get node from index by string FQN + let get_index_node = |fqn: &str| -> Option<&naviscope_core::model::GraphNode> { + context.index.symbols().get(fqn).and_then(|k| { + context + .index + .fqn_map() + .get(&naviscope_api::models::symbol::Symbol(k)) + .and_then(|&idx| context.index.topology().node_weight(idx)) + }) + }; + + let kind = node.kind(); + match kind { + "identifier" | "type_identifier" => { + let name = node.utf8_text(context.source.as_bytes()).ok()?; + // 1. Local Scope + if let Some((_, maybe_type_node)) = + self.parser + .find_local_declaration_node(*node, name, context.source) + { + if let Some(type_node) = maybe_type_node { + // Parse the type node properly to handle generics + let type_ref = self.parser.parse_type_node(type_node, context.source); + + // Resolve FQNs within the parsed type ref + let resolved_type_ref = self.resolve_type_ref_fqns(&type_ref, context); + return Some(resolved_type_ref); + } + + // Heuristic: Try to infer lambda parameter type + return self.infer_lambda_param_type(node, context); + } + // 2. Lexical Scope + for container_fqn in &context.enclosing_classes { + let candidate = format!("{}.{}", container_fqn, name); + + // Check index + if let Some(node) = get_index_node(&candidate) { + if let Some(java_meta) = + node.metadata.as_any().downcast_ref::() + { + if let JavaNodeMetadata::Field { type_ref, .. } = java_meta { + return Some(type_ref.clone()); + } + } + return Some(TypeRef::Id(candidate)); + } + + // Check current unit (indexing phase) + if let Some(unit) = context.unit { + if let Some(node) = unit.nodes.get(candidate.as_str()) { + if let Some(java_meta) = + node.metadata.as_any().downcast_ref::() + { + if let JavaIndexMetadata::Field { type_ref, .. } = java_meta { + return Some(type_ref.clone()); + } + } + return Some(TypeRef::Id(candidate)); + } + } + } + // 3. Global Scope (Check if it's a known class FQN in the index or unit) + let fqn = + self.parser + .resolve_type_name_to_fqn(name, context.tree, context.source)?; + + // If it's a known class, return it. + // Check index presence + let in_index = context + .index + .symbols() + .get(fqn.as_str()) + .map_or(false, |k| { + context + .index + .fqn_map() + .contains_key(&naviscope_api::models::symbol::Symbol(k)) + }); + + if in_index + || context + .unit + .map_or(false, |u| u.nodes.contains_key(fqn.as_str())) + { + return Some(TypeRef::Id(fqn.clone())); + } + + // Fallback: maybe it's a package or a class not yet in index but resolvable via imports + Some(TypeRef::Id(fqn)) + } + "field_access" => { + let receiver = node.child_by_field_name("object")?; + let field_name = node + .child_by_field_name("field")? + .utf8_text(context.source.as_bytes()) + .ok()?; + let receiver_type_ref = self.resolve_expression_type(&receiver, context)?; + let raw_receiver_type = self.get_base_fqn(&receiver_type_ref)?; + let receiver_type = self.resolve_fqn_from_context(&raw_receiver_type, context)?; + + let field_fqn = format!("{}.{}", receiver_type, field_name); + + // Check index + if let Some(node) = get_index_node(&field_fqn) { + if let Some(java_meta) = + node.metadata.as_any().downcast_ref::() + { + if let JavaNodeMetadata::Field { type_ref, .. } = java_meta { + return Some(type_ref.clone()); + } + } + } + + // Check unit + if let Some(unit) = context.unit { + if let Some(node) = unit.nodes.get(field_fqn.as_str()) { + if let Some(java_meta) = + node.metadata.as_any().downcast_ref::() + { + if let JavaIndexMetadata::Field { type_ref, .. } = java_meta { + return Some(type_ref.clone()); + } + } + } + } + None + } + "method_invocation" => { + let receiver = node.child_by_field_name("object")?; + let method_name = node + .child_by_field_name("name")? + .utf8_text(context.source.as_bytes()) + .ok()?; + let receiver_type_ref = self.resolve_expression_type(&receiver, context)?; + let raw_receiver_type = self.get_base_fqn(&receiver_type_ref)?; + let receiver_type = self.resolve_fqn_from_context(&raw_receiver_type, context)?; + + let method_fqn = format!("{}.{}", receiver_type, method_name); + + // Check index + if let Some(node) = get_index_node(&method_fqn) { + if let Some(java_meta) = + node.metadata.as_any().downcast_ref::() + { + if let JavaNodeMetadata::Method { return_type, .. } = java_meta { + return Some(return_type.clone()); + } + } + } + + // Check unit + if let Some(unit) = context.unit { + if let Some(node) = unit.nodes.get(method_fqn.as_str()) { + if let Some(java_meta) = + node.metadata.as_any().downcast_ref::() + { + if let JavaIndexMetadata::Method { return_type, .. } = java_meta { + return Some(return_type.clone()); + } + } + } + } + None + } + "this" => context + .enclosing_classes + .first() + .map(|s| TypeRef::Id(s.clone())), + "scoped_type_identifier" | "scoped_identifier" => { + let receiver = node.child_by_field_name("scope")?; + let name = node + .child_by_field_name("name")? + .utf8_text(context.source.as_bytes()) + .ok()?; + let receiver_type_ref = self.resolve_expression_type(&receiver, context)?; + let receiver_type = self.get_base_fqn(&receiver_type_ref)?; + Some(TypeRef::Id(format!("{}.{}", receiver_type, name))) + } + _ => None, + } + } + + fn infer_lambda_param_type( + &self, + node: &tree_sitter::Node, + context: &ResolutionContext, + ) -> Option { + let mut curr = *node; + while let Some(parent) = curr.parent() { + if parent.kind() == "lambda_expression" { + return self.resolve_lambda_type_from_parent(&parent, context); + } + curr = parent; + } + None + } + + fn resolve_lambda_type_from_parent( + &self, + lambda_node: &tree_sitter::Node, + context: &ResolutionContext, + ) -> Option { + let invocation = lambda_node + .parent() + .filter(|n| n.kind() == "argument_list")?; + let method_call = invocation + .parent() + .filter(|n| n.kind() == "method_invocation")?; + + let method_name = method_call + .child_by_field_name("name")? + .utf8_text(context.source.as_bytes()) + .ok()?; + + if !matches!( + method_name, + "forEach" | "filter" | "map" | "anyMatch" | "allMatch" + ) { + return None; + } + + let receiver = method_call.child_by_field_name("object")?; + let receiver_type = self.resolve_expression_type(&receiver, context)?; + + self.extract_first_generic_arg(&receiver_type) + } + + fn extract_first_generic_arg(&self, type_ref: &TypeRef) -> Option { + if let TypeRef::Generic { args, .. } = type_ref { + args.first().cloned() + } else { + None + } + } +} + +impl SemanticScope> for MemberScope<'_> { + fn resolve( + &self, + name: &str, + context: &ResolutionContext, + ) -> Option> { + if name == "this" { + return context + .enclosing_classes + .first() + .cloned() + .map(|fqn| Ok(SymbolResolution::Precise(fqn, context.intent))); + } + + // Helper to check index existence + let in_index = |fqn: &str| -> bool { + context.index.symbols().get(fqn).map_or(false, |k| { + context + .index + .fqn_map() + .contains_key(&naviscope_api::models::symbol::Symbol(k)) + }) + }; + + context + .receiver_node + .map(|recv| { + // Case A: Explicit Receiver (obj.field) + let res = self + .resolve_expression_type(&recv, context) + .and_then(|type_ref| self.get_base_fqn(&type_ref)) + .and_then(|raw_type_fqn| self.resolve_fqn_from_context(&raw_type_fqn, context)) + .map(|type_fqn| format!("{}.{}", type_fqn, name)) + .and_then(|candidate| { + let exists = in_index(&candidate) + || context + .unit + .map_or(false, |u| u.nodes.contains_key(candidate.as_str())); + if exists { Some(candidate) } else { None } + }) + .map(|fqn| Ok(SymbolResolution::Precise(fqn, context.intent))) + .unwrap_or(Err(())); + res + }) + .or_else(|| { + // Case B: Implicit this (Lexical Scope) + context + .enclosing_classes + .iter() + .map(|container_fqn| format!("{}.{}", container_fqn, name)) + .find(|candidate| { + in_index(candidate) + || context + .unit + .map_or(false, |u| u.nodes.contains_key(candidate.as_str())) + }) + .map(|fqn| Ok(SymbolResolution::Precise(fqn, context.intent))) + }) + } + fn name(&self) -> &'static str { + "Member" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use naviscope_core::ingest::builder::CodeGraphBuilder; + + use tree_sitter::Parser; + + #[test] + fn test_member_scope_implicit_this() { + let source = "class Test { int field; void main() { field = 1; } }"; + let mut parser = Parser::new(); + parser + .set_language(&crate::parser::JavaParser::new().unwrap().language) + .expect("Error loading Java grammar"); + let tree = parser.parse(source, None).unwrap(); + + // Find the 'field' in field = 1 + let field_node = tree + .root_node() + .named_descendant_for_point_range( + tree_sitter::Point::new(0, 38), + tree_sitter::Point::new(0, 43), + ) + .unwrap(); + + let java_parser = JavaParser::new().unwrap(); + + // Build graph with Test.field + let mut builder = CodeGraphBuilder::new(); + let node = naviscope_core::ingest::parser::IndexNode { + id: "Test.field".to_string(), + name: "field".to_string(), + kind: naviscope_core::model::NodeKind::Field, + lang: "java".to_string(), + location: None, + metadata: std::sync::Arc::new(JavaIndexMetadata::Field { + type_ref: naviscope_api::models::TypeRef::Raw("int".to_string()), + modifiers: vec![], + }), + }; + builder.add_node(node); + let index = builder.build(); + + let context = ResolutionContext::new( + field_node, + "field".to_string(), + &index, + source, + &tree, + &java_parser, + ); + + let scope = MemberScope { + parser: &java_parser, + }; + let res = scope.resolve("field", &context); + + assert!(res.is_some()); + match res.unwrap() { + Ok(SymbolResolution::Precise(fqn, _intent)) => { + assert_eq!(fqn, "Test.field"); + // The intent check might fail because determine_intent relies on the parent node context + // which might be different in this isolated test string + } + _ => panic!("Expected Precise resolution"), + } + } +} diff --git a/src/resolver/lang/java/scope/mod.rs b/crates/lang-java/src/resolver/scope/mod.rs similarity index 85% rename from src/resolver/lang/java/scope/mod.rs rename to crates/lang-java/src/resolver/scope/mod.rs index 707eb4f..7bd29de 100644 --- a/src/resolver/lang/java/scope/mod.rs +++ b/crates/lang-java/src/resolver/scope/mod.rs @@ -1,15 +1,15 @@ -use crate::resolver::scope::SemanticScope; use super::context::ResolutionContext; +use naviscope_core::ingest::resolver::scope::SemanticScope; pub trait Scope: for<'a> SemanticScope> {} impl SemanticScope>> Scope for T {} +pub mod builtin; +pub mod import_scope; pub mod local; pub mod member; -pub mod import_scope; -pub mod builtin; +pub use builtin::BuiltinScope; +pub use import_scope::ImportScope; pub use local::LocalScope; pub use member::MemberScope; -pub use import_scope::ImportScope; -pub use builtin::BuiltinScope; diff --git a/crates/lang-java/tests/capability_boundary.rs b/crates/lang-java/tests/capability_boundary.rs new file mode 100644 index 0000000..2bad308 --- /dev/null +++ b/crates/lang-java/tests/capability_boundary.rs @@ -0,0 +1,229 @@ +mod common; + +use common::setup_java_test_graph; +use naviscope_core::features::discovery::DiscoveryEngine; +use naviscope_core::model::EdgeType; + +/// Capability 1: Structural Indexing +/// The graph MUST represent the project structure (Package -> Class -> Method/Field) +#[test] +fn cap_structural_nesting() { + let files = vec![( + "com/example/MyClass.java", + "package com.example; public class MyClass { int field; void method() {} }", + )]; + let (index, _) = setup_java_test_graph(files); + + // Assert FQNs exist + // Note: JavaResolver prepends "module::root." to packages when no specific module is found + assert!(index.find_node("module::root.com.example").is_some()); + assert!(index.find_node("com.example.MyClass").is_some()); + assert!(index.find_node("com.example.MyClass.field").is_some()); + assert!(index.find_node("com.example.MyClass.method").is_some()); + + // Assert nesting via 'Contains' edges + let class_idx = index.find_node("com.example.MyClass").unwrap(); + let pkg_idx = index.find_node("module::root.com.example").unwrap(); + + assert!(index.topology().contains_edge(pkg_idx, class_idx)); + + let field_idx = index.find_node("com.example.MyClass.field").unwrap(); + let method_idx = index.find_node("com.example.MyClass.method").unwrap(); + assert!(index.topology().contains_edge(class_idx, field_idx)); + assert!(index.topology().contains_edge(class_idx, method_idx)); +} + +/// Capability 2: Inheritance & Implementation +/// The graph MUST track class hierarchies +#[test] +fn cap_inheritance_tracking() { + let files = vec![ + ("Base.java", "public interface Base {}"), + ("Impl.java", "public class Impl implements Base {}"), + ]; + let (index, _) = setup_java_test_graph(files); + + let base_idx = index.find_node("Base").unwrap(); + let impl_idx = index.find_node("Impl").unwrap(); + + let has_implements = index + .topology() + .edges_connecting(impl_idx, base_idx) + .any(|e| e.weight().edge_type == EdgeType::Implements); + + assert!( + has_implements, + "Graph should have Implements edge from Impl to Base" + ); +} + +/// Capability 3: Cross-File Type Resolution (TypedAs) +/// The graph MUST resolve types across files during indexing to link members to their types +#[test] +fn cap_cross_file_typing() { + let files = vec![ + ( + "com/lib/TypeA.java", + "package com.lib; public class TypeA {}", + ), + ( + "com/app/Main.java", + "package com.app; import com.lib.TypeA; public class Main { TypeA field; }", + ), + ]; + let (index, _) = setup_java_test_graph(files); + + let field_idx = index.find_node("com.app.Main.field").unwrap(); + let type_a_idx = index.find_node("com.lib.TypeA").unwrap(); + + let has_typed_as = index + .topology() + .edges_connecting(field_idx, type_a_idx) + .any(|e| e.weight().edge_type == EdgeType::TypedAs); + + assert!( + has_typed_as, + "Field 'Main.field' should be linked to 'TypeA' via TypedAs edge" + ); +} + +/// Capability 4: Direct Instantiation Tracking +/// The graph uses reference_index to discover instantiation references +#[test] +fn cap_instantiation_tracking() { + let files = vec![ + ("A.java", "public class A {}"), + ("B.java", "public class B { void m() { A a = new A(); } }"), + ]; + let (index, _) = setup_java_test_graph(files); + + let a_idx = index.find_node("A").unwrap(); + + // Check DiscoveryEngine "Scouting" (uses Reference Index) + let discovery = DiscoveryEngine::new(&index); + let candidate_files = discovery.scout_references(&[a_idx]); + assert!( + candidate_files.contains(&std::path::PathBuf::from("B.java")), + "DiscoveryEngine should find B.java as a candidate for references to A" + ); +} + +/// Capability 5: Method Call Tracking +/// The graph uses reference_index to discover method call references +#[test] +fn cap_method_call_tracking() { + let files = vec![ + ("A.java", "public class A { void target() {} }"), + ("B.java", "public class B { void m(A a) { a.target(); } }"), + ]; + let (index, _) = setup_java_test_graph(files); + + let a_target_idx = index.find_node("A.target").unwrap(); + + // Check DiscoveryEngine "Scouting" (uses Reference Index) + let discovery = DiscoveryEngine::new(&index); + let candidate_files = discovery.scout_references(&[a_target_idx]); + assert!( + candidate_files.contains(&std::path::PathBuf::from("B.java")), + "DiscoveryEngine should find B.java as a candidate for calls to A.target" + ); +} + +/// Capability 6: Interface Extension (InheritsFrom) +/// Interfaces extending other interfaces should use InheritsFrom edge +#[test] +fn cap_interface_extension() { + let files = vec![ + ("Super.java", "public interface Super {}"), + ("Sub.java", "public interface Sub extends Super {}"), + ]; + let (index, _) = setup_java_test_graph(files); + + let super_idx = index.find_node("Super").unwrap(); + let sub_idx = index.find_node("Sub").unwrap(); + + let has_inherits = index + .topology() + .edges_connecting(sub_idx, super_idx) + .any(|e| e.weight().edge_type == EdgeType::InheritsFrom); + + assert!( + has_inherits, + "Interface 'Sub' should have InheritsFrom edge to 'Super'" + ); +} + +/// Capability 7: Annotation Tracking (DecoratedBy) +/// Annotations should be linked to their targets +#[test] +fn cap_annotation_usage() { + let files = vec![ + ("MyAnno.java", "public @interface MyAnno {}"), + ("App.java", "@MyAnno public class App {}"), + ]; + let (index, _) = setup_java_test_graph(files); + + let app_idx = index.find_node("App").unwrap(); + let anno_idx = index.find_node("MyAnno").unwrap(); + + let has_decorated = index + .topology() + .edges_connecting(app_idx, anno_idx) + .any(|e| e.weight().edge_type == EdgeType::DecoratedBy); + + assert!( + has_decorated, + "Class 'App' should have DecoratedBy edge to '@MyAnno'" + ); +} + +/// Capability 8: Static Field Access +/// Tracking access to static members (e.g., Constants) +#[test] +fn cap_static_field_access() { + let files = vec![ + ( + "Config.java", + "public class Config { public static String KEY = \"v\"; }", + ), + ("Main.java", "public class Main { String s = Config.KEY; }"), + ]; + let (index, _) = setup_java_test_graph(files); + + let config_key_idx = index.find_node("Config.KEY").unwrap(); + + // Checking if Main.java is discovered as a candidate for Config.KEY + let discovery = DiscoveryEngine::new(&index); + let candidate_files = discovery.scout_references(&[config_key_idx]); + assert!( + candidate_files.contains(&std::path::PathBuf::from("Main.java")), + "Main.java should be discovered as a candidate for Config.KEY" + ); +} + +/// Capability 9: Generic Type Resolution (TypedAs) +/// Does the graph handle List by linking to TypeA? +#[test] +fn cap_generic_type_link() { + let files = vec![ + ("TypeA.java", "public class TypeA {}"), + ( + "Main.java", + "import java.util.List; public class Main { java.util.List list; }", + ), + ]; + let (index, _) = setup_java_test_graph(files); + + let list_idx = index.find_node("Main.list").unwrap(); + let type_a_idx = index.find_node("TypeA").unwrap(); + + let has_link = index + .topology() + .edges_connecting(list_idx, type_a_idx) + .any(|e| e.weight().edge_type == EdgeType::TypedAs); + + assert!( + has_link, + "Generic argument 'TypeA' should be linked via TypedAs" + ); +} diff --git a/crates/lang-java/tests/common/mod.rs b/crates/lang-java/tests/common/mod.rs new file mode 100644 index 0000000..d84b675 --- /dev/null +++ b/crates/lang-java/tests/common/mod.rs @@ -0,0 +1,67 @@ +use naviscope_core::ingest::builder::CodeGraphBuilder; +use naviscope_core::ingest::parser::IndexParser; +use naviscope_core::ingest::resolver::ProjectContext; +use naviscope_core::ingest::scanner::{ParsedContent, ParsedFile}; +use naviscope_core::model::source::SourceFile; +use naviscope_core::model::{CodeGraph, GraphOp}; +use naviscope_java::parser::JavaParser; +use naviscope_java::resolver::JavaResolver; +use std::path::PathBuf; +use tree_sitter::Parser; + +pub fn setup_java_test_graph( + files: Vec<(&str, &str)>, +) -> (CodeGraph, Vec<(PathBuf, String, tree_sitter::Tree)>) { + let mut builder = CodeGraphBuilder::new(); + let mut parsed_files = Vec::new(); + let java_parser = JavaParser::new().unwrap(); + let mut ts_parser = Parser::new(); + ts_parser.set_language(&java_parser.language).unwrap(); + + // Phase 1: Parse all files to get entities and build the graph + let mut all_parsed_files = Vec::new(); + for (path_str, content) in files { + let path = PathBuf::from(path_str); + let res = java_parser.parse_file(content, Some(&path)).unwrap(); + let source_file = SourceFile::new(path.clone(), 0, 0); + let parsed_file = ParsedFile { + file: source_file, + content: ParsedContent::Language(res), + }; + all_parsed_files.push((parsed_file, content.to_string())); + } + + // Phase 2: Resolve (using JavaResolver's LangResolver implementation) + let resolver = JavaResolver::new(); + let context = ProjectContext::new(); // Empty context for simple tests + + let mut all_ops = Vec::new(); + + for (pf, content) in all_parsed_files { + let tree = ts_parser.parse(&content, None).unwrap(); + + // Use LangResolver to get graph operations + use naviscope_core::ingest::resolver::LangResolver; + let unit = resolver.resolve(&pf, &context).unwrap(); + all_ops.extend(unit.ops); + + parsed_files.push((pf.file.path.clone(), content.to_string(), tree)); + } + + // Apply operations in two passes to ensure nodes exist before edges + // Pass 1: Nodes + for op in &all_ops { + if let GraphOp::AddNode { .. } = op { + builder.apply_op(op.clone()).unwrap(); + } + } + + // Pass 2: Edges and others + for op in &all_ops { + if !matches!(op, GraphOp::AddNode { .. }) { + builder.apply_op(op.clone()).unwrap(); + } + } + + (builder.build(), parsed_files) +} diff --git a/crates/lang-java/tests/edge_verification.rs b/crates/lang-java/tests/edge_verification.rs new file mode 100644 index 0000000..c9d707b --- /dev/null +++ b/crates/lang-java/tests/edge_verification.rs @@ -0,0 +1,205 @@ +mod common; +use common::setup_java_test_graph; +use naviscope_core::model::CodeGraph; +use naviscope_core::model::EdgeType; + +/// Helper assertion: Verify that an edge of the specified type exists from source to target in the graph +fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: EdgeType) { + let from_idx = graph.find_node(from_fqn); + let to_idx = graph.find_node(to_fqn); + + if from_idx.is_none() { + println!("Available nodes:"); + for (id, _) in graph.fqn_map() { + println!(" - {}", graph.symbols().resolve(&id.0)); + } + panic!("Source node not found: {}", from_fqn); + } + if to_idx.is_none() { + println!("Available nodes:"); + for (id, _) in graph.fqn_map() { + println!(" - {}", graph.symbols().resolve(&id.0)); + } + panic!("Target node not found: {}", to_fqn); + } + + let edge_idx = graph + .topology() + .find_edge(from_idx.unwrap(), to_idx.unwrap()); + + if edge_idx.is_none() { + println!("Graph nodes:"); + for (id, _) in graph.fqn_map() { + println!(" - {}", graph.symbols().resolve(&id.0)); + } + println!("Edges from {}:", from_fqn); + let mut edges = graph + .topology() + .neighbors_directed(from_idx.unwrap(), petgraph::Direction::Outgoing) + .detach(); + while let Some((e_idx, target_idx)) = edges.next(&graph.topology()) { + let target_node = &graph.topology()[target_idx]; + let edge = &graph.topology()[e_idx]; + println!( + " -> {} ({:?})", + target_node.fqn(graph.symbols()), + edge.edge_type + ); + } + panic!("Edge not found between {} and {}", from_fqn, to_fqn); + } + + let edge_weight = graph.topology().edge_weight(edge_idx.unwrap()).unwrap(); + assert_eq!( + edge_weight.edge_type, expected_type, + "Edge type mismatch for {} -> {}. Expected {:?}, got {:?}", + from_fqn, to_fqn, expected_type, edge_weight.edge_type + ); +} + +fn assert_reference_scouted(graph: &CodeGraph, target_fqn: &str, expected_file: &str) { + let target_idx = graph.find_node(target_fqn).expect("Target node not found"); + let discovery = naviscope_core::features::discovery::DiscoveryEngine::new(graph); + let candidate_files = discovery.scout_references(&[target_idx]); + assert!( + candidate_files.contains(&std::path::PathBuf::from(expected_file)), + "File {} should be a candidate for references to {}", + expected_file, + target_fqn + ); +} + +#[test] +fn test_edge_contains() { + let files = vec![( + "src/com/test/Container.java", + r#" + package com.test; + public class Container { + private int field; + public void method() {} + } + "#, + )]; + let (index, _) = setup_java_test_graph(files); + + // Class -> Field + assert_edge( + &index, + "com.test.Container", + "com.test.Container.field", + EdgeType::Contains, + ); + // Class -> Method + assert_edge( + &index, + "com.test.Container", + "com.test.Container.method", + EdgeType::Contains, + ); +} + +#[test] +fn test_edge_inherits_from() { + let files = vec![ + ("src/Parent.java", "public class Parent {}"), + ("src/Child.java", "public class Child extends Parent {}"), + ]; + let (index, _) = setup_java_test_graph(files); + + assert_edge(&index, "Child", "Parent", EdgeType::InheritsFrom); +} + +#[test] +fn test_edge_implements() { + let files = vec![ + ("src/IAction.java", "public interface IAction {}"), + ( + "src/ActionImpl.java", + "public class ActionImpl implements IAction {}", + ), + ]; + let (index, _) = setup_java_test_graph(files); + + assert_edge(&index, "ActionImpl", "IAction", EdgeType::Implements); +} + +#[test] +fn test_edge_calls() { + let files = vec![( + "src/Service.java", + r#" + package com.test; + public class Service { + void run() { com.test.Service.helper(); } + static void helper() {} + } + "#, + )]; + let (index, _) = setup_java_test_graph(files); + + // Using FQN in call to ensure resolution works in batch mode + assert_reference_scouted(&index, "com.test.Service.helper", "src/Service.java"); +} + +#[test] +fn test_edge_instantiates() { + let files = vec![ + ( + "src/Factory.java", + r#" + public class Factory { + void create() { new Product(); } + } + "#, + ), + ("src/Product.java", "public class Product {}"), + ]; + let (index, _) = setup_java_test_graph(files); + + assert_reference_scouted(&index, "Product", "src/Factory.java"); +} + +#[test] +fn test_edge_typed_as() { + let files = vec![ + ( + "src/User.java", + r#" + public class User { + private String name; + private Address address; + } + "#, + ), + ("src/Address.java", "public class Address {}"), + ]; + let (index, _) = setup_java_test_graph(files); + + assert_edge(&index, "User.address", "Address", EdgeType::TypedAs); +} + +#[test] +fn test_edge_decorated_by() { + let files = vec![ + ( + "src/Component.java", + r#" + @CustomAnnotation + public class Component {} + "#, + ), + ( + "src/CustomAnnotation.java", + "public @interface CustomAnnotation {}", + ), + ]; + let (index, _) = setup_java_test_graph(files); + + assert_edge( + &index, + "Component", + "CustomAnnotation", + EdgeType::DecoratedBy, + ); +} diff --git a/tests/java_integration.rs b/crates/lang-java/tests/java_integration.rs similarity index 53% rename from tests/java_integration.rs rename to crates/lang-java/tests/java_integration.rs index aa63150..a1712de 100644 --- a/tests/java_integration.rs +++ b/crates/lang-java/tests/java_integration.rs @@ -1,14 +1,20 @@ mod common; -use naviscope::resolver::lang::java::JavaResolver; -use naviscope::resolver::SemanticResolver; use common::setup_java_test_graph; +use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_java::resolver::JavaResolver; #[test] fn test_cross_file_resolution() { let files = vec![ - ("src/main/java/com/example/A.java", "package com.example; public class A { public void hello() {} }"), - ("src/main/java/com/example/B.java", "package com.example; public class B { void test() { A a = new A(); a.hello(); } }"), + ( + "src/main/java/com/example/A.java", + "package com.example; public class A { public void hello() {} }", + ), + ( + "src/main/java/com/example/B.java", + "package com.example; public class B { void test() { A a = new A(); a.hello(); } }", + ), ]; let (index, trees) = setup_java_test_graph(files); @@ -17,29 +23,39 @@ fn test_cross_file_resolution() { // Test resolving 'A' in 'A a = new A();' let b_content = &trees[1].1; let b_tree = &trees[1].2; - + // Find 'A' in 'A a' - let a_pos = b_content.find("A a").expect("Could not find 'A a' in B.java"); + let a_pos = b_content + .find("A a") + .expect("Could not find 'A a' in B.java"); println!("Found 'A a' at byte offset {}", a_pos); let res = resolver.resolve_at(b_tree, b_content, 0, a_pos, &index); assert!(res.is_some(), "Failed to resolve 'A' at {}", a_pos); - if let Some(naviscope::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.example.A"); } else { - panic!("Expected precise resolution to com.example.A, got {:?}", res); + panic!( + "Expected precise resolution to com.example.A, got {:?}", + res + ); } // Test resolving 'hello' in 'a.hello();' - let hello_pos = b_content.find("hello();").expect("Could not find 'hello();' in B.java"); + let hello_pos = b_content + .find("hello();") + .expect("Could not find 'hello();' in B.java"); println!("Found 'hello();' at byte offset {}", hello_pos); let res = resolver.resolve_at(b_tree, b_content, 0, hello_pos, &index); assert!(res.is_some(), "Failed to resolve 'hello' at {}", hello_pos); - if let Some(naviscope::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.example.A.hello"); } else { - panic!("Expected precise resolution to com.example.A.hello, got {:?}", res); + panic!( + "Expected precise resolution to com.example.A.hello, got {:?}", + res + ); } } @@ -47,7 +63,10 @@ fn test_cross_file_resolution() { fn test_inheritance_and_implementations() { let files = vec![ ("I.java", "public interface I { void run(); }"), - ("C.java", "public class C implements I { public void run() {} }"), + ( + "C.java", + "public class C implements I { public void run() {} }", + ), ]; let (index, trees) = setup_java_test_graph(files); @@ -57,23 +76,32 @@ fn test_inheritance_and_implementations() { let i_tree = &trees[0].2; // Resolve 'I' in its definition - let i_pos = i_content.find("interface I").expect("Could not find 'interface I'") + "interface ".len(); + let i_pos = i_content + .find("interface I") + .expect("Could not find 'interface I'") + + "interface ".len(); let res = resolver.resolve_at(i_tree, i_content, 0, i_pos, &index); assert!(res.is_some(), "Failed to resolve 'I' at {}", i_pos); let res = res.unwrap(); let impls = resolver.find_implementations(&index, &res); assert_eq!(impls.len(), 1); - - let node = &index.topology[impls[0]]; - assert_eq!(node.fqn(), "C"); + + let node = &index.topology()[impls[0]]; + assert_eq!(node.fqn(index.symbols()), "C"); } #[test] fn test_inner_class_resolution() { let files = vec![ - ("src/example/Outer.java", "package com.example; public class Outer { public class Inner { public void innerMethod() {} } }"), - ("src/example/Client.java", "package com.example; public class Client { void test() { Outer.Inner inner; } }"), + ( + "src/example/Outer.java", + "package com.example; public class Outer { public class Inner { public void innerMethod() {} } }", + ), + ( + "src/example/Client.java", + "package com.example; public class Client { void test() { Outer.Inner inner; } }", + ), ]; let (index, trees) = setup_java_test_graph(files); @@ -83,24 +111,41 @@ fn test_inner_class_resolution() { let client_tree = &trees[1].2; // Resolve 'Inner' in 'Outer.Inner' - let inner_pos = client_content.find("Inner inner").expect("Could not find 'Inner inner'"); + let inner_pos = client_content + .find("Inner inner") + .expect("Could not find 'Inner inner'"); let res = resolver.resolve_at(client_tree, client_content, 0, inner_pos, &index); - + assert!(res.is_some(), "Failed to resolve 'Inner' at {}", inner_pos); - if let Some(naviscope::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.example.Outer.Inner"); } else { - panic!("Expected precise resolution to com.example.Outer.Inner, got {:?}", res); + panic!( + "Expected precise resolution to com.example.Outer.Inner, got {:?}", + res + ); } } #[test] fn test_chained_calls_resolution() { let files = vec![ - ("src/chain/A.java", "package com.chain; public class A { public B getB() { return new B(); } }"), - ("src/chain/B.java", "package com.chain; public class B { public C getC() { return new C(); } }"), - ("src/chain/C.java", "package com.chain; public class C { public void execute() {} }"), - ("src/chain/Main.java", "package com.chain; public class Main { void run() { A a = new A(); a.getB().getC().execute(); } }"), + ( + "src/chain/A.java", + "package com.chain; public class A { public B getB() { return new B(); } }", + ), + ( + "src/chain/B.java", + "package com.chain; public class B { public C getC() { return new C(); } }", + ), + ( + "src/chain/C.java", + "package com.chain; public class C { public void execute() {} }", + ), + ( + "src/chain/Main.java", + "package com.chain; public class Main { void run() { A a = new A(); a.getB().getC().execute(); } }", + ), ]; let (index, trees) = setup_java_test_graph(files); @@ -110,31 +155,46 @@ fn test_chained_calls_resolution() { let main_tree = &trees[3].2; // Position of 'getC' in 'a.getB().getC().execute()' - let get_c_pos = main_content.find("getC()").expect("Could not find 'getC()'"); + let get_c_pos = main_content + .find("getC()") + .expect("Could not find 'getC()'"); let res = resolver.resolve_at(main_tree, main_content, 0, get_c_pos, &index); assert!(res.is_some(), "Failed to resolve 'getC' at {}", get_c_pos); - if let Some(naviscope::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.chain.B.getC"); } else { - panic!("Expected precise resolution to com.chain.B.getC, got {:?}", res); + panic!( + "Expected precise resolution to com.chain.B.getC, got {:?}", + res + ); } // Position of 'execute' in 'a.getB().getC().execute()' - let execute_pos = main_content.find("execute()").expect("Could not find 'execute()'"); + let execute_pos = main_content + .find("execute()") + .expect("Could not find 'execute()'"); let res = resolver.resolve_at(main_tree, main_content, 0, execute_pos, &index); - assert!(res.is_some(), "Failed to resolve 'execute' at {}", execute_pos); - if let Some(naviscope::parser::SymbolResolution::Precise(fqn, _)) = res { + assert!( + res.is_some(), + "Failed to resolve 'execute' at {}", + execute_pos + ); + if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.chain.C.execute"); } else { - panic!("Expected precise resolution to com.chain.C.execute, got {:?}", res); + panic!( + "Expected precise resolution to com.chain.C.execute, got {:?}", + res + ); } } #[test] fn test_lambda_parameter_resolution() { - let files = vec![ - ("src/LambdaTest.java", "public class LambdaTest { void test() { java.util.List list; list.forEach(it -> { String s = it; }); } }"), - ]; + let files = vec![( + "src/LambdaTest.java", + "public class LambdaTest { void test() { java.util.List list; list.forEach(it -> { String s = it; }); } }", + )]; let (index, trees) = setup_java_test_graph(files); let resolver = JavaResolver::new(); @@ -145,22 +205,35 @@ fn test_lambda_parameter_resolution() { // Resolve 'it' in 'String s = it;' let it_usage_pos = content.find("s = it").expect("Could not find 's = it'") + "s = ".len(); let res = resolver.resolve_at(tree, content, 0, it_usage_pos, &index); - - assert!(res.is_some(), "Failed to resolve lambda parameter 'it' at {}", it_usage_pos); - if let Some(naviscope::parser::SymbolResolution::Local(range, _)) = res { + + assert!( + res.is_some(), + "Failed to resolve lambda parameter 'it' at {}", + it_usage_pos + ); + if let Some(naviscope_core::ingest::parser::SymbolResolution::Local(range, _)) = res { // The definition of 'it' should be at 'it ->' let it_def_pos = content.find("it ->").expect("Could not find 'it ->'"); assert_eq!(range.start_col, it_def_pos); } else { - panic!("Expected local resolution for lambda parameter, got {:?}", res); + panic!( + "Expected local resolution for lambda parameter, got {:?}", + res + ); } } #[test] fn test_lambda_explicit_type_resolution() { let files = vec![ - ("src/A.java", "package com; public class A { public void hello() {} }"), - ("src/LambdaTypeTest.java", "package com; public class LambdaTypeTest { void test() { java.util.List list; list.forEach((A it) -> { it.hello(); }); } }"), + ( + "src/A.java", + "package com; public class A { public void hello() {} }", + ), + ( + "src/LambdaTypeTest.java", + "package com; public class LambdaTypeTest { void test() { java.util.List list; list.forEach((A it) -> { it.hello(); }); } }", + ), ]; let (index, trees) = setup_java_test_graph(files); @@ -172,9 +245,13 @@ fn test_lambda_explicit_type_resolution() { // Resolve 'hello' in 'it.hello();' let hello_pos = content.find("hello()").expect("Could not find 'hello()'"); let res = resolver.resolve_at(tree, content, 0, hello_pos, &index); - - assert!(res.is_some(), "Failed to resolve 'hello' on lambda parameter at {}", hello_pos); - if let Some(naviscope::parser::SymbolResolution::Precise(fqn, _)) = res { + + assert!( + res.is_some(), + "Failed to resolve 'hello' on lambda parameter at {}", + hello_pos + ); + if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.A.hello"); } else { panic!("Expected precise resolution for it.hello(), got {:?}", res); @@ -184,8 +261,14 @@ fn test_lambda_explicit_type_resolution() { #[test] fn test_lambda_heuristic_type_inference() { let files = vec![ - ("src/A.java", "package com; public class A { public void hello() {} }"), - ("src/LambdaHeuristicTest.java", "package com; public class LambdaHeuristicTest { void test() { java.util.List list; list.forEach(it -> it.hello()); } }"), + ( + "src/A.java", + "package com; public class A { public void hello() {} }", + ), + ( + "src/LambdaHeuristicTest.java", + "package com; public class LambdaHeuristicTest { void test() { java.util.List list; list.forEach(it -> it.hello()); } }", + ), ]; let (index, trees) = setup_java_test_graph(files); @@ -197,19 +280,27 @@ fn test_lambda_heuristic_type_inference() { // Resolve 'hello' in 'it.hello();' let hello_pos = content.find("hello()").expect("Could not find 'hello()'"); let res = resolver.resolve_at(tree, content, 0, hello_pos, &index); - - assert!(res.is_some(), "Failed to resolve 'hello' on lambda parameter via heuristic at {}", hello_pos); - if let Some(naviscope::parser::SymbolResolution::Precise(fqn, _)) = res { + + assert!( + res.is_some(), + "Failed to resolve 'hello' on lambda parameter via heuristic at {}", + hello_pos + ); + if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.A.hello"); } else { - panic!("Expected precise resolution for it.hello() via heuristic, got {:?}", res); + panic!( + "Expected precise resolution for it.hello() via heuristic, got {:?}", + res + ); } } #[test] fn test_this_keyword_resolution() { - let files = vec![ - ("src/DefaultApplicationArguments.java", r#" + let files = vec![( + "src/DefaultApplicationArguments.java", + r#" public class DefaultApplicationArguments { private final Source source; @@ -221,8 +312,8 @@ public class DefaultApplicationArguments { public List getNonOptionArgs() { return null; } } } -"#), - ]; +"#, + )]; let (index, trees) = setup_java_test_graph(files); let resolver = JavaResolver::new(); @@ -240,13 +331,20 @@ public class DefaultApplicationArguments { }; // Resolve 'this' in 'this.source.getNonOptionArgs()' - let this_pos = content.find("this.source").expect("Could not find 'this.source'"); + let this_pos = content + .find("this.source") + .expect("Could not find 'this.source'"); let (line, col) = offset_to_point(this_pos); - + let res = resolver.resolve_at(tree, content, line, col, &index); - - assert!(res.is_some(), "Failed to resolve 'this' at line {}, col {}", line, col); - if let Some(naviscope::parser::SymbolResolution::Precise(fqn, _)) = res { + + assert!( + res.is_some(), + "Failed to resolve 'this' at line {}, col {}", + line, + col + ); + if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "DefaultApplicationArguments"); } else { panic!("Expected precise resolution for 'this', got {:?}", res); @@ -289,8 +387,14 @@ public class DefaultApplicationArguments { } "#; let files = vec![ - ("src/main/java/org/springframework/boot/DefaultApplicationArguments.java", content), - ("src/main/java/org/springframework/core/env/SimpleCommandLinePropertySource.java", "package org.springframework.core.env; public class SimpleCommandLinePropertySource { public java.util.List getNonOptionArgs() { return null; } }"), + ( + "src/main/java/org/springframework/boot/DefaultApplicationArguments.java", + content, + ), + ( + "src/main/java/org/springframework/core/env/SimpleCommandLinePropertySource.java", + "package org.springframework.core.env; public class SimpleCommandLinePropertySource { public java.util.List getNonOptionArgs() { return null; } }", + ), ]; let (index, trees) = setup_java_test_graph(files); @@ -300,8 +404,11 @@ public class DefaultApplicationArguments { let source_content = &trees[0].1; // Line 18 (0-indexed) in our content is 'return this.source.getNonOptionArgs();' - let method_call_pos = source_content.find("this.source.getNonOptionArgs()").expect("Find expression") + "this.source.".len(); - + let method_call_pos = source_content + .find("this.source.getNonOptionArgs()") + .expect("Find expression") + + "this.source.".len(); + // Helper to get line/col from offset let offset_to_point = |offset: usize| { let pre = &source_content[..offset]; @@ -311,13 +418,24 @@ public class DefaultApplicationArguments { }; let (line, col) = offset_to_point(method_call_pos); - println!("Testing Spring scenario at line {}, col {} (offset {})", line, col, method_call_pos); + println!( + "Testing Spring scenario at line {}, col {} (offset {})", + line, col, method_call_pos + ); let res = resolver.resolve_at(tree, source_content, line, col, &index); - - if let Some(naviscope::parser::SymbolResolution::Precise(fqn, _)) = res { - assert_eq!(fqn, "org.springframework.boot.DefaultApplicationArguments.Source.getNonOptionArgs"); + + if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + assert_eq!( + fqn, + "org.springframework.boot.DefaultApplicationArguments.Source.getNonOptionArgs" + ); } else { + println!("Graph nodes:"); + for (fqn, idx) in index.fqn_map() { + let node = &index.topology()[*idx]; + println!(" - {} ({:?})", index.symbols().resolve(&fqn.0), node.kind()); + } panic!("Failed to resolve Spring Boot scenario, got {:?}", res); } } @@ -325,7 +443,10 @@ public class DefaultApplicationArguments { #[test] fn test_field_method_call_resolution() { let files = vec![ - ("src/A.java", "public class A { private B b; public void doA() { b.doB(); } }"), + ( + "src/A.java", + "public class A { private B b; public void doA() { b.doB(); } }", + ), ("src/B.java", "public class B { public void doB() {} }"), ]; @@ -337,7 +458,7 @@ fn test_field_method_call_resolution() { // Resolve 'doB' in 'b.doB()' let do_b_pos = a_content.find("doB()").expect("Could not find 'doB()'"); - + // We need line/col for resolve_at let offset_to_point = |offset: usize| -> (usize, usize) { let pre_content = &a_content[..offset]; @@ -346,13 +467,18 @@ fn test_field_method_call_resolution() { let col = offset - last_newline; (line, col) }; - + let (line, col) = offset_to_point(do_b_pos); let res = resolver.resolve_at(a_tree, a_content, line, col, &index); - - assert!(res.is_some(), "Failed to resolve 'doB' at line {}, col {}", line, col); - if let Some(naviscope::parser::SymbolResolution::Precise(fqn, _)) = res { + + assert!( + res.is_some(), + "Failed to resolve 'doB' at line {}, col {}", + line, + col + ); + if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "B.doB"); } else { panic!("Expected precise resolution to B.doB, got {:?}", res); diff --git a/crates/lang-java/tests/logic_goto_def.rs b/crates/lang-java/tests/logic_goto_def.rs new file mode 100644 index 0000000..247afe8 --- /dev/null +++ b/crates/lang-java/tests/logic_goto_def.rs @@ -0,0 +1,166 @@ +mod common; + +use common::setup_java_test_graph; +use naviscope_core::ingest::parser::SymbolResolution; +use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_java::resolver::JavaResolver; + +fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { + let pre_content = &content[..offset]; + let line = pre_content.lines().count().max(1) - 1; + let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); + let col = offset - last_newline; + (line, col) +} + +#[test] +fn test_goto_definition_local() { + let files = vec![( + "Test.java", + "public class Test { void main() { int x = 1; int y = x + 1; } }", + )]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let content = &trees[0].1; + let tree = &trees[0].2; + + // Position of 'x' in 'x + 1' + let usage_pos = content.rfind("x + 1").unwrap(); + let (line, col) = offset_to_point(content, usage_pos); + + let res = resolver + .resolve_at(tree, content, line, col, &index) + .expect("Should resolve"); + + if let SymbolResolution::Local(range, _) = res { + // 'int x = 1' starts at index 35 + let def_pos = content.find("int x").unwrap() + 4; + assert_eq!(range.start_col, def_pos); + } else { + panic!("Expected local resolution, got {:?}", res); + } +} + +#[test] +fn test_goto_definition_cross_file() { + let files = vec![ + ( + "A.java", + "package com; public class A { public void hello() {} }", + ), + ( + "B.java", + "package com; public class B { void test() { A a = new A(); a.hello(); } }", + ), + ]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let b_content = &trees[1].1; + let b_tree = &trees[1].2; + + // 1. Resolve Class A + let a_usage = b_content.find("A a").unwrap(); + let (line, col) = offset_to_point(b_content, a_usage); + let res = resolver + .resolve_at(b_tree, b_content, line, col, &index) + .expect("Should resolve A"); + let matches = resolver.find_matches(&index, &res); + assert!(!matches.is_empty()); + assert_eq!(index.topology()[matches[0]].fqn(index.symbols()), "com.A"); + + // 2. Resolve Method hello + let hello_usage = b_content.find("hello()").unwrap(); + let (line, col) = offset_to_point(b_content, hello_usage); + let res = resolver + .resolve_at(b_tree, b_content, line, col, &index) + .expect("Should resolve hello"); + let matches = resolver.find_matches(&index, &res); + assert!(!matches.is_empty()); + assert_eq!( + index.topology()[matches[0]].fqn(index.symbols()), + "com.A.hello" + ); +} + +#[test] +fn test_goto_definition_shadowing() { + let files = vec![( + "Test.java", + "public class Test { int x = 0; void m() { int x = 1; x = 2; } }", + )]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let content = &trees[0].1; + let tree = &trees[0].2; + + // Position of 'x' in 'x = 2' (should be local x) + let usage_pos = content.find("x = 2").unwrap(); + let (line, col) = offset_to_point(content, usage_pos); + + let res = resolver + .resolve_at(tree, content, line, col, &index) + .expect("Should resolve"); + + if let SymbolResolution::Local(range, _) = res { + let local_def = content.find("int x = 1").unwrap() + 4; + assert_eq!(range.start_col, local_def); + } else { + panic!("Expected local resolution for shadowed x, got {:?}", res); + } +} + +#[test] +fn test_goto_definition_constructor() { + let files = vec![ + ("A.java", "public class A { public A() {} }"), + ("B.java", "public class B { A a = new A(); }"), + ]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let b_content = &trees[1].1; + let b_tree = &trees[1].2; + + // Resolve 'A' in 'new A()' + let usage_pos = b_content.find("new A()").unwrap() + 4; + let (line, col) = offset_to_point(b_content, usage_pos); + + let res = resolver + .resolve_at(b_tree, b_content, line, col, &index) + .expect("Should resolve constructor"); + let matches = resolver.find_matches(&index, &res); + assert!(!matches.is_empty()); + // In our model, constructor might be the class or the method depending on implementation + assert!( + index.topology()[matches[0]] + .fqn(index.symbols()) + .contains("A") + ); +} + +#[test] +fn test_goto_definition_static() { + let files = vec![ + ("A.java", "public class A { public static int VAL = 1; }"), + ("B.java", "public class B { int x = A.VAL; }"), + ]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let b_content = &trees[1].1; + let b_tree = &trees[1].2; + + // Resolve 'VAL' in 'A.VAL' + let usage_pos = b_content.find("VAL").unwrap(); + let (line, col) = offset_to_point(b_content, usage_pos); + + let res = resolver + .resolve_at(b_tree, b_content, line, col, &index) + .expect("Should resolve static field"); + let matches = resolver.find_matches(&index, &res); + assert!(!matches.is_empty()); + assert_eq!(index.topology()[matches[0]].fqn(index.symbols()), "A.VAL"); +} diff --git a/crates/lang-java/tests/logic_goto_impl.rs b/crates/lang-java/tests/logic_goto_impl.rs new file mode 100644 index 0000000..a07d670 --- /dev/null +++ b/crates/lang-java/tests/logic_goto_impl.rs @@ -0,0 +1,77 @@ +mod common; + +use common::setup_java_test_graph; +use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_java::resolver::JavaResolver; + +fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { + let pre_content = &content[..offset]; + let line = pre_content.lines().count().max(1) - 1; + let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); + let col = offset - last_newline; + (line, col) +} + +#[test] +fn test_goto_implementation_interface() { + let files = vec![ + ("IBase.java", "public interface IBase { void act(); }"), + ( + "ImplA.java", + "public class ImplA implements IBase { public void act() {} }", + ), + ( + "ImplB.java", + "public class ImplB implements IBase { public void act() {} }", + ), + ]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let base_content = &trees[0].1; + let base_tree = &trees[0].2; + + // Resolve 'IBase' + let usage_pos = base_content.find("IBase").unwrap(); + let (line, col) = offset_to_point(base_content, usage_pos); + let res = resolver + .resolve_at(base_tree, base_content, line, col, &index) + .expect("Should resolve IBase"); + + let impls = resolver.find_implementations(&index, &res); + assert_eq!(impls.len(), 2); + + let fqns: Vec<_> = impls + .iter() + .map(|&i| index.topology()[i].fqn(index.symbols()).to_string()) + .collect(); + assert!(fqns.contains(&"ImplA".to_string())); + assert!(fqns.contains(&"ImplB".to_string())); +} + +#[test] +fn test_goto_implementation_method() { + let files = vec![ + ("IBase.java", "public interface IBase { void act(); }"), + ( + "Impl.java", + "public class Impl implements IBase { public void act() {} }", + ), + ]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let base_content = &trees[0].1; + let base_tree = &trees[0].2; + + // Resolve 'act' in IBase + let usage_pos = base_content.find("act()").unwrap(); + let (line, col) = offset_to_point(base_content, usage_pos); + let res = resolver + .resolve_at(base_tree, base_content, line, col, &index) + .expect("Should resolve act"); + + let impls = resolver.find_implementations(&index, &res); + assert_eq!(impls.len(), 1); + assert_eq!(index.topology()[impls[0]].fqn(index.symbols()), "Impl.act"); +} diff --git a/crates/lang-java/tests/logic_goto_ref.rs b/crates/lang-java/tests/logic_goto_ref.rs new file mode 100644 index 0000000..f409111 --- /dev/null +++ b/crates/lang-java/tests/logic_goto_ref.rs @@ -0,0 +1,48 @@ +mod common; + +use common::setup_java_test_graph; +use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_java::resolver::JavaResolver; + +fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { + let pre_content = &content[..offset]; + let line: usize = pre_content.lines().count().max(1) - 1; + let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); + let col = offset - last_newline; + (line, col) +} + +#[test] +fn test_goto_references_method() { + let files = vec![ + ("A.java", "public class A { public void target() {} }"), + ("B.java", "public class B { void m1(A a) { a.target(); } }"), + ("C.java", "public class C { void m2(A a) { a.target(); } }"), + ]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let a_content = &trees[0].1; + let a_tree = &trees[0].2; + + // Resolve 'target' in A + let usage_pos = a_content.find("target()").unwrap(); + let (line, col) = offset_to_point(a_content, usage_pos); + let res = resolver + .resolve_at(a_tree, a_content, line, col, &index) + .expect("Should resolve target"); + let matches = resolver.find_matches(&index, &res); + let target_idx = matches[0]; + + // Check for candidate files via DiscoveryEngine (Meso-scouting) + let discovery = naviscope_core::features::discovery::DiscoveryEngine::new(&index); + let candidate_files = discovery.scout_references(&[target_idx]); + + assert_eq!(candidate_files.len(), 3); + let paths: Vec = candidate_files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + assert!(paths.contains(&"B.java".to_string())); + assert!(paths.contains(&"C.java".to_string())); +} diff --git a/crates/lang-java/tests/logic_goto_type.rs b/crates/lang-java/tests/logic_goto_type.rs new file mode 100644 index 0000000..305a0e5 --- /dev/null +++ b/crates/lang-java/tests/logic_goto_type.rs @@ -0,0 +1,73 @@ +mod common; + +use common::setup_java_test_graph; +use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_java::resolver::JavaResolver; + +fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { + let pre_content = &content[..offset]; + let line = pre_content.lines().count().max(1) - 1; + let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); + let col = offset - last_newline; + (line, col) +} + +#[test] +fn test_goto_type_definition_variable() { + let files = vec![ + ("Model.java", "public class Model {}"), + ( + "Client.java", + "public class Client { void m() { Model m = null; } }", + ), + ]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let client_content = &trees[1].1; + let client_tree = &trees[1].2; + + // Resolve type of 'm' in 'Model m' + let usage_pos = client_content.find("m = null").unwrap(); + let (line, col) = offset_to_point(client_content, usage_pos); + + let res = resolver + .resolve_at(client_tree, client_content, line, col, &index) + .expect("Should resolve m"); + let type_res = resolver.resolve_type_of(&index, &res); + + assert!(!type_res.is_empty()); + let matches = resolver.find_matches(&index, &type_res[0]); + assert!(!matches.is_empty()); + assert_eq!(index.topology()[matches[0]].fqn(index.symbols()), "Model"); +} + +#[test] +fn test_goto_type_definition_method_return() { + let files = vec![ + ("Model.java", "public class Model {}"), + ( + "Service.java", + "public class Service { Model get() { return null; } }", + ), + ]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let service_content = &trees[1].1; + let service_tree = &trees[1].2; + + // Resolve type of method 'get' + let usage_pos = service_content.find("get()").unwrap(); + let (line, col) = offset_to_point(service_content, usage_pos); + + let res = resolver + .resolve_at(service_tree, service_content, line, col, &index) + .expect("Should resolve get"); + let type_res = resolver.resolve_type_of(&index, &res); + + assert!(!type_res.is_empty()); + let matches = resolver.find_matches(&index, &type_res[0]); + assert!(!matches.is_empty()); + assert_eq!(index.topology()[matches[0]].fqn(index.symbols()), "Model"); +} diff --git a/crates/lang-java/tests/logic_hierarchy.rs b/crates/lang-java/tests/logic_hierarchy.rs new file mode 100644 index 0000000..ac18ac2 --- /dev/null +++ b/crates/lang-java/tests/logic_hierarchy.rs @@ -0,0 +1,201 @@ +mod common; + +use common::setup_java_test_graph; +use naviscope_core::features::discovery::DiscoveryEngine; +use naviscope_core::ingest::parser::SymbolResolution; +use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_java::resolver::JavaResolver; + +fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { + let pre_content = &content[..offset]; + let line = pre_content.lines().count().max(1) - 1; + let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); + let col = offset - last_newline; + (line, col) +} + +#[test] +fn test_call_hierarchy_incoming() { + let files = vec![( + "Test.java", + "public class Test { + void leaf() {} + void caller1() { leaf(); } + void caller2() { leaf(); } + void root() { caller1(); caller2(); } + }", + )]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let content = &trees[0].1; + let tree = &trees[0].2; + + // Target: leaf() + let leaf_pos = content.find("void leaf").unwrap() + 5; + let (line, col) = offset_to_point(content, leaf_pos); + let res = resolver + .resolve_at(tree, content, line, col, &index) + .expect("Should resolve leaf"); + let target_idx = resolver.find_matches(&index, &res)[0]; + + // Check callers using DiscoveryEngine + let discovery = DiscoveryEngine::new(&index); + let candidate_files = discovery.scout_references(&[target_idx]); + + let mut callers = Vec::new(); + let abs_path = std::env::current_dir().unwrap().join("Test.java"); + let uri = lsp_types::Url::from_file_path(&abs_path).unwrap(); + + for path in candidate_files { + let locations = discovery.scan_file(&resolver.parser, &resolver, content, &res, &uri); + for loc in locations { + if let Some(container_idx) = index.find_container_node_at( + &path, + loc.range.start.line as usize, + loc.range.start.character as usize, + ) { + // Skip if the occurrence is actually the definition of the target itself + if let Some(name_range) = index.topology()[target_idx].name_range() { + if name_range.start_line == loc.range.start.line as usize + && name_range.start_col == loc.range.start.character as usize + { + continue; + } + } + let node = &index.topology()[container_idx]; + let fqn = node.fqn(index.symbols()).to_string(); + if !callers.contains(&fqn) { + callers.push(fqn); + } + } + } + } + + assert_eq!(callers.len(), 2); + assert!(callers.contains(&"Test.caller1".to_string())); + assert!(callers.contains(&"Test.caller2".to_string())); +} + +#[test] +fn test_call_hierarchy_outgoing() { + let files = vec![( + "Test.java", + "public class Test { + void root() { step1(); step2(); } + void step1() {} + void step2() {} + }", + )]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let content = &trees[0].1; + let tree = &trees[0].2; + + // Target: root() + let root_pos = content.find("void root").unwrap() + 5; + let (line, col) = offset_to_point(content, root_pos); + let res = resolver + .resolve_at(tree, content, line, col, &index) + .expect("Should resolve root"); + let target_idx = resolver.find_matches(&index, &res)[0]; + + // Check callees using manual walk (similar to outgoing_calls in LSP) + let container_range = index.topology()[target_idx].range().unwrap(); + let mut callees = Vec::new(); + + let mut stack = vec![tree.root_node()]; + while let Some(n) = stack.pop() { + let r = n.range(); + if r.start_point.row > container_range.end_line + || r.end_point.row < container_range.start_line + { + continue; + } + + if n.kind() == "identifier" { + if let Some(out_res) = resolver.resolve_at( + tree, + content, + r.start_point.row, + r.start_point.column, + &index, + ) { + let target_fqn = match out_res { + SymbolResolution::Global(fqn) => Some(fqn), + SymbolResolution::Precise(fqn, _) => Some(fqn), + _ => None, + }; + + if let Some(fqn) = target_fqn { + if !callees.contains(&fqn) && fqn != "Test.root" { + callees.push(fqn); + } + } + } + } + + let mut cursor = n.walk(); + for child in n.children(&mut cursor) { + stack.push(child); + } + } + + assert_eq!(callees.len(), 2); + assert!(callees.contains(&"Test.step1".to_string())); + assert!(callees.contains(&"Test.step2".to_string())); +} + +#[test] +fn test_call_hierarchy_recursion() { + let files = vec![( + "Test.java", + "public class Test { + void rec() { rec(); } + }", + )]; + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaResolver::new(); + + let content = &trees[0].1; + let tree = &trees[0].2; + + let pos = content.find("void rec").unwrap() + 5; + let (line, col) = offset_to_point(content, pos); + let res = resolver + .resolve_at(tree, content, line, col, &index) + .unwrap(); + let idx = resolver.find_matches(&index, &res)[0]; + + // Incoming should contain itself + let discovery = DiscoveryEngine::new(&index); + let mut callers = Vec::new(); + let abs_path = std::env::current_dir().unwrap().join("Test.java"); + let uri = lsp_types::Url::from_file_path(&abs_path).unwrap(); + + let locations = discovery.scan_file(&resolver.parser, &resolver, content, &res, &uri); + for loc in locations { + if let Some(c_idx) = index.find_container_node_at( + &std::path::PathBuf::from("Test.java"), + loc.range.start.line as usize, + loc.range.start.character as usize, + ) { + // Skip if the occurrence is actually the definition of the target itself + if let Some(name_range) = index.topology()[idx].name_range() { + if name_range.start_line == loc.range.start.line as usize + && name_range.start_col == loc.range.start.character as usize + { + continue; + } + } + let node = &index.topology()[c_idx]; + let fqn = node.fqn(index.symbols()).to_string(); + if !callers.contains(&fqn) { + callers.push(fqn); + } + } + } + + assert!(callers.contains(&"Test.rec".to_string())); +} diff --git a/crates/lsp/Cargo.toml b/crates/lsp/Cargo.toml new file mode 100644 index 0000000..39b6214 --- /dev/null +++ b/crates/lsp/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "naviscope-lsp" +version = "0.5.0" +edition = "2024" + +[dependencies] +naviscope-mcp = { workspace = true } +tower-lsp = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +dashmap = { workspace = true } +tracing = { workspace = true } +tokio-util = { workspace = true } +tree-sitter = { workspace = true } +petgraph = { workspace = true } +naviscope-api = { workspace = true } diff --git a/src/lsp/capabilities.rs b/crates/lsp/src/capabilities.rs similarity index 100% rename from src/lsp/capabilities.rs rename to crates/lsp/src/capabilities.rs diff --git a/crates/lsp/src/goto.rs b/crates/lsp/src/goto.rs new file mode 100644 index 0000000..3dc8668 --- /dev/null +++ b/crates/lsp/src/goto.rs @@ -0,0 +1,290 @@ +use crate::LspServer; +use naviscope_api::models::{PositionContext, SymbolQuery, SymbolResolution}; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::*; + +pub async fn definition( + server: &LspServer, + params: GotoDefinitionParams, +) -> Result> { + let uri = params.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + + // We need document content for PositionContext. + // Ideally, PositionContext can take URI and Engine loads it, but for unsaved files we might want to pass content. + // Our EngineHandle implementation reads from disk if content is None, or uses provided content. + // LspServer has documents map. + let content = server.documents.get(&uri).map(|d| d.content.clone()); + + let ctx = PositionContext { + uri: uri.to_string(), + line: position.line, + char: position.character, + content, + }; + + let engine_lock = server.engine.read().await; + let engine = match engine_lock.as_ref() { + Some(e) => e, + None => return Ok(None), + }; + + let resolution = match engine.resolve_symbol_at(&ctx).await { + Ok(Some(r)) => r, + Ok(None) => return Ok(None), + Err(_) => return Ok(None), // Log error? + }; + + if let SymbolResolution::Local(range, _) = resolution { + // Found declaration in the same file + return Ok(Some(GotoDefinitionResponse::Scalar(Location { + uri, + range: Range { + start: Position::new(range.start_line as u32, range.start_col as u32), + end: Position::new(range.end_line as u32, range.end_col as u32), + }, + }))); + } + + // Determine language from file extension or document + // For simplicity, we can get language from document map again or assume backend infers it. + // But SymbolQuery needs language. + // Let's get language from server documents if possible. + let language = match server.documents.get(&uri).map(|d| d.language.clone()) { + Some(l) => l, + None => return Ok(None), + }; + + let query = SymbolQuery { + resolution, + language, + }; + + let definitions = match engine.find_definitions(&query).await { + Ok(defs) => defs, + Err(_) => return Ok(None), + }; + + let locations: Vec = definitions + .into_iter() + .map(|loc| Location { + uri: Url::from_file_path(&*loc.path).unwrap(), + range: Range { + start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), + end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), + }, + }) + .collect(); + + if !locations.is_empty() { + if locations.len() == 1 { + return Ok(Some(GotoDefinitionResponse::Scalar(locations[0].clone()))); + } else { + return Ok(Some(GotoDefinitionResponse::Array(locations))); + } + } + + Ok(None) +} + +pub async fn type_definition( + server: &LspServer, + params: GotoDefinitionParams, +) -> Result> { + let uri = params.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + + // We can extract common logic (ctx creation) to a helper if needed later. + let content = server.documents.get(&uri).map(|d| d.content.clone()); + let ctx = PositionContext { + uri: uri.to_string(), + line: position.line, + char: position.character, + content, + }; + + let engine_lock = server.engine.read().await; + let engine = match engine_lock.as_ref() { + Some(e) => e, + None => return Ok(None), + }; + + let resolution = match engine.resolve_symbol_at(&ctx).await { + Ok(Some(r)) => r, + Ok(None) => return Ok(None), + Err(_) => return Ok(None), + }; + + let language = match server.documents.get(&uri).map(|d| d.language.clone()) { + Some(l) => l, + None => return Ok(None), + }; + + let query = SymbolQuery { + resolution, + language, + }; + + let locations = match engine.find_type_definitions(&query).await { + Ok(locs) => locs, + Err(_) => return Ok(None), + }; + + let lsp_locations: Vec = locations + .into_iter() + .map(|loc| Location { + uri: Url::from_file_path(&*loc.path).unwrap(), + range: Range { + start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), + end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), + }, + }) + .collect(); + + if !lsp_locations.is_empty() { + return Ok(Some(GotoDefinitionResponse::Array(lsp_locations))); + } + + Ok(None) +} + +pub async fn references( + server: &LspServer, + params: ReferenceParams, +) -> Result>> { + let uri = params.text_document_position.text_document.uri; + let position = params.text_document_position.position; + + let content = server.documents.get(&uri).map(|d| d.content.clone()); + let ctx = PositionContext { + uri: uri.to_string(), + line: position.line, + char: position.character, + content: content.clone(), // Clone for ctx + }; + + let engine_lock = server.engine.read().await; + let engine = match engine_lock.as_ref() { + Some(e) => e, + None => return Ok(None), + }; + + let resolution = match engine.resolve_symbol_at(&ctx).await { + Ok(Some(r)) => r, + Ok(None) => return Ok(None), + Err(_) => return Ok(None), + }; + + let language = match server.documents.get(&uri).map(|d| d.language.clone()) { + Some(l) => l, + None => return Ok(None), + }; + + // 1. Local textual references (Meso-level optimization not fully moved yet) + // The previous implementation had a "smart" check for local variables to use AST search. + // Ideally this logic should also be inside `engine.find_references`, but `find_references` is async and general. + // For now we can keep the local textual search here if we want, OR move it to `find_references`. + // Moving it to `find_references` is better for encapsulation. + // The previous code check `SymbolResolution::Local`. + // Let's rely on `engine.find_references` to handle it. + // But wait, `EngineHandle::find_references` implementation we just wrote uses `DiscoveryEngine::scan_file`. + // Does `DiscoveryEngine::scan_file` handle local variable textual matches efficiently? + // It uses `parser.find_occurrences` if resolution is local? + // Let's check `DiscoveryEngine::scan_file` implementation (which we didn't change). + // Yes, `scan_file` calls `parser.find_occurrences` if it can. + + use naviscope_api::models::ReferenceQuery; + let query = ReferenceQuery { + resolution, + language, + include_declaration: params.context.include_declaration, + }; + + let locations = match engine.find_references(&query).await { + Ok(locs) => locs, + Err(_) => return Ok(None), + }; + + // If local references are found by engine, they are returned. + // But `EngineHandle::find_references` spawns tasks for OTHER files found by scout. + // Does it search the CURRENT file? passing `candidate_paths` from `scout_references`. + // `scout_references` usually returns files containing the token. This includes the current file. + // So the current file should be in the list and scanned. + + let lsp_locations: Vec = locations + .into_iter() + .map(|loc| Location { + uri: Url::from_file_path(&*loc.path).unwrap(), + range: Range { + start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), + end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), + }, + }) + .collect(); + + if !lsp_locations.is_empty() { + return Ok(Some(lsp_locations)); + } + + Ok(None) +} + +pub async fn implementation( + server: &LspServer, + params: GotoDefinitionParams, +) -> Result> { + let uri = params.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + + let content = server.documents.get(&uri).map(|d| d.content.clone()); + let ctx = PositionContext { + uri: uri.to_string(), + line: position.line, + char: position.character, + content, + }; + + let engine_lock = server.engine.read().await; + let engine = match &*engine_lock { + Some(n) => n, + None => return Ok(None), + }; + + let resolution = match engine.resolve_symbol_at(&ctx).await { + Ok(Some(r)) => r, + Ok(None) => return Ok(None), + Err(_) => return Ok(None), + }; + + let language = match server.documents.get(&uri).map(|d| d.language.clone()) { + Some(l) => l, + None => return Ok(None), + }; + + let query = SymbolQuery { + resolution, + language, + }; + + let locations = match engine.find_implementations(&query).await { + Ok(locs) => locs, + Err(_) => return Ok(None), + }; + + let lsp_locations: Vec = locations + .into_iter() + .map(|loc| Location { + uri: Url::from_file_path(&*loc.path).unwrap(), + range: Range { + start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), + end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), + }, + }) + .collect(); + + if !lsp_locations.is_empty() { + return Ok(Some(GotoDefinitionResponse::Array(lsp_locations))); + } + + Ok(None) +} diff --git a/crates/lsp/src/hierarchy.rs b/crates/lsp/src/hierarchy.rs new file mode 100644 index 0000000..d7bed58 --- /dev/null +++ b/crates/lsp/src/hierarchy.rs @@ -0,0 +1,172 @@ +use crate::LspServer; +use naviscope_api::models::{PositionContext, SymbolResolution}; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::*; + +pub async fn prepare_call_hierarchy( + server: &LspServer, + params: CallHierarchyPrepareParams, +) -> Result>> { + let uri = params.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + + let engine_lock = server.engine.read().await; + let engine = match engine_lock.as_ref() { + Some(e) => e.clone(), + None => return Ok(None), + }; + + let content = server.documents.get(&uri).map(|d| d.content.clone()); + let ctx = PositionContext { + uri: uri.to_string(), + line: position.line, + char: position.character, + content, + }; + + // 1. Resolve at position + let resolution = match engine.resolve_symbol_at(&ctx).await { + Ok(Some(r)) => r, + _ => return Ok(None), + }; + + let fqn = match resolution { + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => fqn, + SymbolResolution::Local(_, _) => return Ok(None), + }; + + // 2. Fetch symbol info to get name and kind + let info = match engine.get_symbol_info(&fqn).await { + Ok(Some(i)) => i, + _ => return Ok(None), + }; + + let loc = info.location.as_ref().expect("Symbol must have location"); + let lsp_range = Range { + start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), + end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), + }; + + let item = CallHierarchyItem { + name: info.name, + kind: SymbolKind::METHOD, // Default for call hierarchy + tags: None, + detail: Some(fqn.clone()), + uri: Url::from_file_path(&loc.path).unwrap(), + range: lsp_range, + selection_range: lsp_range, + data: Some(serde_json::to_value(fqn).unwrap()), + }; + + Ok(Some(vec![item])) +} + +pub async fn incoming_calls( + server: &LspServer, + params: CallHierarchyIncomingCallsParams, +) -> Result>> { + let fqn: String = + serde_json::from_value(params.item.data.unwrap_or_default()).unwrap_or_default(); + if fqn.is_empty() { + return Ok(None); + } + + let engine_lock = server.engine.read().await; + let engine = match engine_lock.as_ref() { + Some(e) => e.clone(), + None => return Ok(None), + }; + + let calls = match engine.find_incoming_calls(&fqn).await { + Ok(c) => c, + Err(_) => return Ok(None), + }; + + let lsp_calls: Vec = calls + .into_iter() + .map(|item| { + let loc = item.from.location.as_ref().expect("Caller must have location"); + let lsp_range = Range { + start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), + end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), + }; + CallHierarchyIncomingCall { + from: CallHierarchyItem { + name: item.from.name, + kind: SymbolKind::METHOD, + tags: None, + detail: Some(item.from.id.clone()), + uri: Url::from_file_path(&loc.path).unwrap(), + range: lsp_range, + selection_range: lsp_range, + data: Some(serde_json::to_value(item.from.id).unwrap()), + }, + from_ranges: item + .from_ranges + .into_iter() + .map(|r| Range { + start: Position::new(r.start_line as u32, r.start_col as u32), + end: Position::new(r.end_line as u32, r.end_col as u32), + }) + .collect(), + } + }) + .collect(); + + Ok(Some(lsp_calls)) +} + +pub async fn outgoing_calls( + server: &LspServer, + params: CallHierarchyOutgoingCallsParams, +) -> Result>> { + let fqn: String = + serde_json::from_value(params.item.data.unwrap_or_default()).unwrap_or_default(); + if fqn.is_empty() { + return Ok(None); + } + + let engine_lock = server.engine.read().await; + let engine = match engine_lock.as_ref() { + Some(e) => e.clone(), + None => return Ok(None), + }; + + let calls = match engine.find_outgoing_calls(&fqn).await { + Ok(c) => c, + Err(_) => return Ok(None), + }; + + let lsp_calls: Vec = calls + .into_iter() + .map(|item| { + let loc = item.to.location.as_ref().expect("Callee must have location"); + let lsp_range = Range { + start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), + end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), + }; + CallHierarchyOutgoingCall { + to: CallHierarchyItem { + name: item.to.name, + kind: SymbolKind::METHOD, + tags: None, + detail: Some(item.to.id.clone()), + uri: Url::from_file_path(&loc.path).unwrap(), + range: lsp_range, + selection_range: lsp_range, + data: Some(serde_json::to_value(item.to.id).unwrap()), + }, + from_ranges: item + .from_ranges + .into_iter() + .map(|r| Range { + start: Position::new(r.start_line as u32, r.start_col as u32), + end: Position::new(r.end_line as u32, r.end_col as u32), + }) + .collect(), + } + }) + .collect(); + + Ok(Some(lsp_calls)) +} diff --git a/crates/lsp/src/highlight.rs b/crates/lsp/src/highlight.rs new file mode 100644 index 0000000..6271699 --- /dev/null +++ b/crates/lsp/src/highlight.rs @@ -0,0 +1,49 @@ +use crate::LspServer; +use naviscope_api::models::PositionContext; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::*; + +pub async fn highlight( + server: &LspServer, + params: DocumentHighlightParams, +) -> Result>> { + let uri = params.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + + let engine_lock = server.engine.read().await; + let engine = match engine_lock.as_ref() { + Some(e) => e.clone(), + None => return Ok(None), + }; + + let content = server.documents.get(&uri).map(|d| d.content.clone()); + + let ctx = PositionContext { + uri: uri.to_string(), + line: position.line, + char: position.character, + content, + }; + + let highlights = match engine.find_highlights(&ctx).await { + Ok(h) => h, + Err(_) => return Ok(None), + }; + + let lsp_highlights: Vec = highlights + .into_iter() + .map(|range| DocumentHighlight { + range: Range { + start: Position::new(range.start_line as u32, range.start_col as u32), + end: Position::new(range.end_line as u32, range.end_col as u32), + }, + kind: Some(DocumentHighlightKind::TEXT), + }) + .collect(); + + if lsp_highlights.is_empty() { + Ok(None) + } else { + Ok(Some(lsp_highlights)) + } +} diff --git a/crates/lsp/src/hover.rs b/crates/lsp/src/hover.rs new file mode 100644 index 0000000..6082593 --- /dev/null +++ b/crates/lsp/src/hover.rs @@ -0,0 +1,81 @@ +use crate::LspServer; +use naviscope_api::models::PositionContext; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::*; + +pub async fn hover(server: &LspServer, params: HoverParams) -> Result> { + let uri = params.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + + let engine_lock = server.engine.read().await; + let engine = match engine_lock.as_ref() { + Some(e) => e.clone(), + None => return Ok(None), + }; + + let ctx = PositionContext { + uri: uri.to_string(), + line: position.line, + char: position.character, + content: None, // Engine will read from disk if needed + }; + + // 1. Resolve the symbol at position + let resolution = match engine.resolve_symbol_at(&ctx).await { + Ok(Some(res)) => res, + Ok(None) => return Ok(None), + Err(e) => { + return Err(tower_lsp::jsonrpc::Error::invalid_params(format!( + "Resolution error: {}", + e + ))); + } + }; + + // 2. Map resolution to hover text + let mut hover_text = String::new(); + + match resolution { + naviscope_api::models::SymbolResolution::Local(_, type_name) => { + hover_text.push_str("**Local variable**"); + if let Some(t) = type_name { + hover_text.push_str(&format!(": `{}`", t)); + } + } + naviscope_api::models::SymbolResolution::Precise(fqn, _) + | naviscope_api::models::SymbolResolution::Global(fqn) => { + // Fetch detailed info for FQN + if let Ok(Some(info)) = engine.get_symbol_info(&fqn).await { + if let Some(sig) = info.signature { + let lang_tag = info.lang; + hover_text.push_str(&format!("```{}\n{}\n```\n", lang_tag, sig)); + } else { + hover_text.push_str(&format!( + "**{}** *{}*\n\n", + info.name, + info.kind.to_string() + )); + } + + if let Some(detail) = info.detail { + hover_text.push_str(&detail); + hover_text.push_str("\n\n"); + } + + hover_text.push_str(&format!("*`{}`*", fqn)); + } else { + // Fallback to FQN only + hover_text.push_str(&format!("**Symbol**\n\n*`{}`*", fqn)); + } + } + } + + if !hover_text.is_empty() { + return Ok(Some(Hover { + contents: HoverContents::Scalar(MarkedString::String(hover_text)), + range: None, + })); + } + + Ok(None) +} diff --git a/crates/lsp/src/indexer.rs b/crates/lsp/src/indexer.rs new file mode 100644 index 0000000..247ddc2 --- /dev/null +++ b/crates/lsp/src/indexer.rs @@ -0,0 +1,74 @@ +use naviscope_api::NaviscopeEngine; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; +use tower_lsp::Client; +use tower_lsp::lsp_types::MessageType; + +pub fn spawn_indexer( + path: PathBuf, + client: Client, + engine_lock: Arc>>>, +) { + tokio::spawn(async move { + let start = std::time::Instant::now(); + client + .log_message( + MessageType::INFO, + format!("Naviscope indexing started for {:?}", path), + ) + .await; + + // Retrieve existing handle + let engine = { + let lock = engine_lock.read().await; + match lock.as_ref() { + Some(h) => h.clone(), + None => { + client + .log_message(MessageType::ERROR, "Engine handle not initialized") + .await; + return; + } + } + }; + + // 1. Initial full index rebuild + if let Err(e) = engine.rebuild().await { + client + .log_message( + MessageType::ERROR, + format!("Initial indexing failed: {}", e), + ) + .await; + return; + } + + let duration = start.elapsed(); + let stats_msg = match engine.get_stats().await { + Ok(stats) => format!( + "Initial indexing complete in {:?}: {} nodes, {} edges", + duration, stats.node_count, stats.edge_count + ), + Err(e) => format!( + "Initial indexing complete in {:?}, but failed to get stats: {}", + duration, e + ), + }; + client.log_message(MessageType::INFO, stats_msg).await; + + // 2. Setup file watcher + if let Err(e) = engine.watch().await { + client + .log_message( + MessageType::ERROR, + format!("Failed to start file watcher: {}", e), + ) + .await; + } else { + client + .log_message(MessageType::INFO, "File watcher started successfully.") + .await; + } + }); +} diff --git a/crates/lsp/src/lib.rs b/crates/lsp/src/lib.rs new file mode 100644 index 0000000..2e120e1 --- /dev/null +++ b/crates/lsp/src/lib.rs @@ -0,0 +1,548 @@ +pub mod capabilities; +pub mod goto; +pub mod hierarchy; +pub mod highlight; +pub mod hover; +pub mod indexer; +pub mod symbols; +pub mod util; + +use crate::util::Document; +use dashmap::DashMap; +use naviscope_api::NaviscopeEngine; +use naviscope_api::models::Language; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::*; +use tower_lsp::{Client, LanguageServer}; + +pub struct LspServer { + client: Client, + pub engine: Arc>>>, + pub engine_builder: Arc Arc + Send + Sync>, + pub documents: DashMap>, + session_path: Arc>>, + cancel_token: CancellationToken, +} + +impl LspServer { + pub fn new( + client: Client, + engine_builder: Arc Arc + Send + Sync>, + ) -> Self { + Self { + client, + engine: Arc::new(RwLock::new(None)), + engine_builder, + documents: DashMap::new(), + session_path: Arc::new(RwLock::new(None)), + cancel_token: CancellationToken::new(), + } + } + + pub async fn get_language_for_uri(&self, uri: &Url) -> Option { + let engine_lock = self.engine.read().await; + let engine = engine_lock.as_ref()?; + engine + .get_language_for_document(uri.as_str()) + .await + .ok() + .flatten() + } + + fn offset_at(&self, text: &str, position: Position) -> usize { + let mut line = 0; + let mut offset = 0; + let mut chars = text.chars().peekable(); + + while line < position.line as usize { + if let Some(c) = chars.next() { + offset += c.len_utf8(); + if c == '\n' { + line += 1; + } else if c == '\r' { + if chars.peek() == Some(&'\n') { + offset += chars.next().unwrap().len_utf8(); + } + line += 1; + } + } else { + return offset; + } + } + + let mut utf16_count = 0; + while utf16_count < position.character as usize { + if let Some(c) = chars.next() { + if c == '\n' || c == '\r' { + break; + } + utf16_count += c.len_utf16(); + offset += c.len_utf8(); + } else { + break; + } + } + offset + } +} + +#[tower_lsp::async_trait] +impl LanguageServer for LspServer { + async fn initialize(&self, params: InitializeParams) -> Result { + let root_path = params.root_uri.and_then(|uri| uri.to_file_path().ok()); + + if let Some(path) = root_path { + { + let handle = (self.engine_builder)(path.clone()); + let mut guard = self.engine.write().await; + *guard = Some(handle); + } + + indexer::spawn_indexer(path.clone(), self.client.clone(), self.engine.clone()); + + // Start MCP HTTP Server via encapsulated helper + // Start MCP HTTP Server via encapsulated helper + naviscope_mcp::http::spawn_http_server( + self.client.clone(), + // This cast is problematic. Let's fix it by wrapping or adapting. + // Since we can't cheaply convert the lock type, we will fix this by + // Creating a simplified shared state adapter in a follow up. + // FOR NOW: We will use a temporary mismatched type cast (unsafe) to prove the point? NO. + // We must provide what strictly matches. + + // Let's create a derived lock that proxies? No. + // + // The correct fix is: + // 1. Initialize MCP with a `McpEngineGlue`. + // 2. Or, change LspServer's engine field to be generic? + + // Let's go with: + // Change `McpServer` to accept a trait `EngineProvider`. + // But `McpServer` is in `naviscope-mcp` which we just freed from `naviscope-core`. + + // OK, easier path: + // McpServer doesn't strictly need the SAME `RwLock` object if we just want it to work. + // But it needs to see "Some(handle)" when the LSP initializes it. + + // Let's do this: + // We can't pass `self.engine` directly. + { + // Capture the handle we just created + let handle_for_mcp = (self.engine_builder)(path.clone()); + let mcp_engine: std::sync::Arc< + tokio::sync::RwLock< + Option>, + >, + > = std::sync::Arc::new(tokio::sync::RwLock::new(Some(handle_for_mcp.clone()))); + mcp_engine + }, + path, + self.session_path.clone(), + params.client_info.map(|i| i.name), + self.cancel_token.clone(), + ); + } + + Ok(InitializeResult { + server_info: Some(ServerInfo { + name: "Naviscope".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + capabilities: capabilities::server_capabilities(), + }) + } + + async fn shutdown(&self) -> Result<()> { + self.cancel_token.cancel(); + let mut lock = self.session_path.write().await; + if let Some(path) = lock.take() { + let _ = std::fs::remove_file(path); + } + Ok(()) + } + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + let uri = params.text_document.uri; + let content = params.text_document.text; + let version = params.text_document.version; + + let lang = self + .get_language_for_uri(&uri) + .await + .unwrap_or(Language::UNKNOWN); + self.documents + .insert(uri, Arc::new(Document::new(content, lang, version))); + } + + async fn did_change(&self, params: DidChangeTextDocumentParams) { + let uri = params.text_document.uri; + let version = params.text_document.version; + + if let Some(mut doc_ref) = self.documents.get_mut(&uri) { + let doc = doc_ref.value_mut(); + + // For thin LSP, we just take the last full change or apply changes textualy + if let Some(change) = params.content_changes.last() { + // If it's a full change (no range), just replace + if change.range.is_none() { + let new_doc = Arc::new(Document::new( + change.text.clone(), + doc.language.clone(), + version, + )); + *doc = new_doc; + } else { + // Fallback to reload from engine/disk if it's too complex or just take the text if it's what's sent + // Most LSPs send full text if they are "thin". + // For now, let's assume full text if no range, otherwise we might need a more robust textual update. + let mut content = doc.content.clone(); + for change in ¶ms.content_changes { + if let Some(range) = change.range { + let start_byte = self.offset_at(&content, range.start); + let old_end_byte = self.offset_at(&content, range.end); + content.replace_range(start_byte..old_end_byte, &change.text); + } else { + content = change.text.clone(); + } + } + *doc = Arc::new(Document::new(content, doc.language.clone(), version)); + } + } + } + } + async fn did_close(&self, params: DidCloseTextDocumentParams) { + self.client + .log_message( + MessageType::LOG, + format!("LSP Event: did_close uri={}", params.text_document.uri), + ) + .await; + self.documents.remove(¶ms.text_document.uri); + } + + async fn hover(&self, params: HoverParams) -> Result> { + let uri = ¶ms.text_document_position_params.text_document.uri; + let pos = params.text_document_position_params.position; + self.client + .log_message( + MessageType::LOG, + format!( + "LSP Request: textDocument/hover uri={} pos={}:{}", + uri, pos.line, pos.character + ), + ) + .await; + let result = hover::hover(self, params).await; + match &result { + Ok(Some(_)) => { + self.client + .log_message(MessageType::LOG, "LSP Response: found hover content") + .await + } + Ok(None) => { + self.client + .log_message(MessageType::LOG, "LSP Response: no hover content") + .await + } + Err(e) => { + self.client + .log_message(MessageType::ERROR, format!("LSP Error: {}", e)) + .await + } + } + result + } + + async fn document_highlight( + &self, + params: DocumentHighlightParams, + ) -> Result>> { + let uri = ¶ms.text_document_position_params.text_document.uri; + let pos = params.text_document_position_params.position; + self.client + .log_message( + MessageType::LOG, + format!( + "LSP Request: textDocument/documentHighlight uri={} pos={}:{}", + uri, pos.line, pos.character + ), + ) + .await; + let result = highlight::highlight(self, params).await; + if let Ok(Some(h)) = &result { + self.client + .log_message( + MessageType::LOG, + format!("LSP Response: found {} highlights", h.len()), + ) + .await; + } + result + } + + async fn goto_definition( + &self, + params: GotoDefinitionParams, + ) -> Result> { + let uri = ¶ms.text_document_position_params.text_document.uri; + let pos = params.text_document_position_params.position; + self.client + .log_message( + MessageType::LOG, + format!( + "LSP Request: textDocument/definition uri={} pos={}:{}", + uri, pos.line, pos.character + ), + ) + .await; + let result = goto::definition(self, params).await; + match &result { + Ok(Some(resp)) => { + let count = match resp { + GotoDefinitionResponse::Scalar(_) => 1, + GotoDefinitionResponse::Array(v) => v.len(), + GotoDefinitionResponse::Link(v) => v.len(), + }; + self.client + .log_message( + MessageType::LOG, + format!("LSP Response: found {} locations", count), + ) + .await; + } + Ok(None) => { + self.client + .log_message(MessageType::LOG, "LSP Response: no definition found") + .await + } + Err(e) => { + self.client + .log_message(MessageType::ERROR, format!("LSP Error: {}", e)) + .await + } + } + result + } + + async fn references(&self, params: ReferenceParams) -> Result>> { + let uri = ¶ms.text_document_position.text_document.uri; + let pos = params.text_document_position.position; + self.client + .log_message( + MessageType::LOG, + format!( + "LSP Request: textDocument/references uri={} pos={}:{}", + uri, pos.line, pos.character + ), + ) + .await; + let result = goto::references(self, params).await; + if let Ok(Some(locs)) = &result { + self.client + .log_message( + MessageType::LOG, + format!("LSP Response: found {} references", locs.len()), + ) + .await; + } + result + } + + async fn document_symbol( + &self, + params: DocumentSymbolParams, + ) -> Result> { + self.client + .log_message( + MessageType::LOG, + format!( + "LSP Request: textDocument/documentSymbol uri={}", + params.text_document.uri + ), + ) + .await; + let result = symbols::document_symbol(self, params).await; + if let Ok(Some(resp)) = &result { + let count = match resp { + DocumentSymbolResponse::Flat(v) => v.len(), + DocumentSymbolResponse::Nested(v) => v.len(), + }; + self.client + .log_message( + MessageType::LOG, + format!("LSP Response: found {} symbols", count), + ) + .await; + } + result + } + + async fn symbol( + &self, + params: WorkspaceSymbolParams, + ) -> Result>> { + self.client + .log_message( + MessageType::LOG, + format!("LSP Request: workspace/symbol query='{}'", params.query), + ) + .await; + let result = symbols::workspace_symbol(self, params).await; + if let Ok(Some(syms)) = &result { + self.client + .log_message( + MessageType::LOG, + format!("LSP Response: found {} symbols", syms.len()), + ) + .await; + } + result + } + + async fn goto_implementation( + &self, + params: GotoDefinitionParams, + ) -> Result> { + let uri = ¶ms.text_document_position_params.text_document.uri; + let pos = params.text_document_position_params.position; + self.client + .log_message( + MessageType::LOG, + format!( + "LSP Request: textDocument/implementation uri={} pos={}:{}", + uri, pos.line, pos.character + ), + ) + .await; + let result = goto::implementation(self, params).await; + if let Ok(Some(_)) = &result { + self.client + .log_message(MessageType::LOG, "LSP Response: found implementations") + .await; + } + result + } + + async fn goto_type_definition( + &self, + params: GotoDefinitionParams, + ) -> Result> { + let uri = ¶ms.text_document_position_params.text_document.uri; + let pos = params.text_document_position_params.position; + self.client + .log_message( + MessageType::LOG, + format!( + "LSP Request: textDocument/typeDefinition uri={} pos={}:{}", + uri, pos.line, pos.character + ), + ) + .await; + let result = goto::type_definition(self, params).await; + if let Ok(Some(_)) = &result { + self.client + .log_message(MessageType::LOG, "LSP Response: found type definitions") + .await; + } + result + } + + async fn prepare_call_hierarchy( + &self, + params: CallHierarchyPrepareParams, + ) -> Result>> { + let uri = ¶ms.text_document_position_params.text_document.uri; + let pos = params.text_document_position_params.position; + self.client + .log_message( + MessageType::LOG, + format!( + "LSP Request: textDocument/prepareCallHierarchy uri={} pos={}:{}", + uri, pos.line, pos.character + ), + ) + .await; + let result = hierarchy::prepare_call_hierarchy(self, params).await; + if let Ok(Some(items)) = &result { + self.client + .log_message( + MessageType::LOG, + format!("LSP Response: prepared {} items", items.len()), + ) + .await; + } + result + } + + async fn incoming_calls( + &self, + params: CallHierarchyIncomingCallsParams, + ) -> Result>> { + self.client + .log_message( + MessageType::LOG, + format!( + "LSP Request: callHierarchy/incomingCalls item={}", + params.item.name + ), + ) + .await; + let result = hierarchy::incoming_calls(self, params).await; + if let Ok(Some(calls)) = &result { + self.client + .log_message( + MessageType::LOG, + format!("LSP Response: found {} incoming calls", calls.len()), + ) + .await; + } + result + } + + async fn outgoing_calls( + &self, + params: CallHierarchyOutgoingCallsParams, + ) -> Result>> { + self.client + .log_message( + MessageType::LOG, + format!( + "LSP Request: callHierarchy/outgoingCalls item={}", + params.item.name + ), + ) + .await; + let result = hierarchy::outgoing_calls(self, params).await; + if let Ok(Some(calls)) = &result { + self.client + .log_message( + MessageType::LOG, + format!("LSP Response: found {} outgoing calls", calls.len()), + ) + .await; + } + result + } +} + +pub async fn run_server(engine_builder: F) -> std::result::Result<(), Box> +where + F: Fn(std::path::PathBuf) -> Arc + Send + Sync + 'static, +{ + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + + let builder = std::sync::Arc::new(engine_builder); + + let (service, socket) = + tower_lsp::LspService::new(move |client| LspServer::new(client, builder.clone())); + tower_lsp::Server::new(stdin, stdout, socket) + .serve(service) + .await; + + Ok(()) +} diff --git a/crates/lsp/src/symbols.rs b/crates/lsp/src/symbols.rs new file mode 100644 index 0000000..93ffc3a --- /dev/null +++ b/crates/lsp/src/symbols.rs @@ -0,0 +1,129 @@ +use crate::LspServer; +use naviscope_api::models::{DisplayGraphNode, NodeKind}; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::*; + +pub async fn document_symbol( + server: &LspServer, + params: DocumentSymbolParams, +) -> Result> { + let uri = params.text_document.uri; + + let engine_lock = server.engine.read().await; + let engine = match engine_lock.as_ref() { + Some(e) => e.clone(), + None => return Ok(None), + }; + + let api_symbols = match engine.get_document_symbols(uri.as_str()).await { + Ok(s) => s, + Err(_) => return Ok(None), + }; + + let lsp_symbols = convert_api_symbols(api_symbols); + Ok(Some(DocumentSymbolResponse::Nested(lsp_symbols))) +} + +fn convert_api_symbols(symbols: Vec) -> Vec { + symbols.into_iter().map(convert_api_symbol).collect() +} + +fn convert_api_symbol(sym: DisplayGraphNode) -> DocumentSymbol { + let loc = sym.location.as_ref().expect("Symbol must have location"); + let range = Range { + start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), + end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), + }; + let selection_range = loc.selection_range.map(|sr| Range { + start: Position::new(sr.start_line as u32, sr.start_col as u32), + end: Position::new(sr.end_line as u32, sr.end_col as u32), + }).unwrap_or(range); + + #[allow(deprecated)] + DocumentSymbol { + name: sym.name, + detail: sym.detail, + kind: node_kind_to_symbol_kind(&sym.kind), + tags: None, + deprecated: None, + range, + selection_range, + children: sym.children.map(convert_api_symbols), + } +} + +fn node_kind_to_symbol_kind(kind: &NodeKind) -> SymbolKind { + match kind { + NodeKind::Package => SymbolKind::PACKAGE, + NodeKind::Module => SymbolKind::MODULE, + NodeKind::Class => SymbolKind::CLASS, + NodeKind::Interface => SymbolKind::INTERFACE, + NodeKind::Enum => SymbolKind::ENUM, + NodeKind::Annotation => SymbolKind::INTERFACE, + NodeKind::Method => SymbolKind::METHOD, + NodeKind::Constructor => SymbolKind::CONSTRUCTOR, + NodeKind::Field => SymbolKind::FIELD, + NodeKind::Variable => SymbolKind::VARIABLE, + NodeKind::Project => SymbolKind::MODULE, + NodeKind::Dependency => SymbolKind::MODULE, + NodeKind::Task => SymbolKind::FUNCTION, + NodeKind::Plugin => SymbolKind::MODULE, + NodeKind::Custom(s) => match s.as_str() { + "function" => SymbolKind::FUNCTION, + "property" => SymbolKind::PROPERTY, + _ => SymbolKind::VARIABLE, + }, + } +} + +pub async fn workspace_symbol( + server: &LspServer, + params: WorkspaceSymbolParams, +) -> Result>> { + let engine_lock = server.engine.read().await; + let engine = match engine_lock.as_ref() { + Some(e) => e.clone(), + None => return Ok(None), + }; + + // Use engine's graph query for workspace symbols + use naviscope_api::graph::GraphQuery; + let query = GraphQuery::Find { + pattern: params.query, + kind: vec![], + limit: 100, + }; + + let result = match engine.query(&query).await { + Ok(r) => r, + Err(_) => return Ok(None), + }; + + let symbols: Vec = result + .nodes + .into_iter() + .filter_map(|node| { + let loc = node.location.as_ref()?; + Some(SymbolInformation { + name: node.name.to_string(), + kind: node_kind_to_symbol_kind(&node.kind), + tags: None, + #[allow(deprecated)] + deprecated: None, + location: Location { + uri: Url::from_file_path(&loc.path).ok()?, + range: Range { + start: Position::new( + loc.range.start_line as u32, + loc.range.start_col as u32, + ), + end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), + }, + }, + container_name: Some(node.id.to_string()), + }) + }) + .collect(); + + Ok(Some(symbols)) +} diff --git a/crates/lsp/src/util.rs b/crates/lsp/src/util.rs new file mode 100644 index 0000000..c88967e --- /dev/null +++ b/crates/lsp/src/util.rs @@ -0,0 +1,67 @@ +use naviscope_api::models::Language; +use std::path::PathBuf; +use tower_lsp::lsp_types::Url; + +pub fn uri_to_path(uri: &Url) -> Option { + uri.to_file_path().ok() +} + +/// Lightweight container for document state +pub struct Document { + pub content: String, + pub language: Language, + pub version: i32, +} + +impl Document { + pub fn new(content: String, language: Language, version: i32) -> Self { + Self { + content, + language, + version, + } + } +} + +pub fn utf16_col_to_byte_col(content: &str, line: usize, utf16_col: usize) -> usize { + let line_content = content.lines().nth(line).unwrap_or(""); + let mut curr_utf16 = 0; + let mut curr_byte = 0; + + for c in line_content.chars() { + if curr_utf16 >= utf16_col { + break; + } + curr_utf16 += c.len_utf16(); + curr_byte += c.len_utf8(); + } + curr_byte +} + +pub fn get_word_at(path: &std::path::Path, line: usize, col: usize) -> Option { + let content = std::fs::read_to_string(path).ok()?; + get_word_from_content(&content, line, col) +} + +pub fn get_word_from_content(content: &str, line: usize, col: usize) -> Option { + let line_content = content.lines().nth(line)?; + + // Find the start and end of the word (alphanumeric + _ + $) + let is_ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$'; + + let start = line_content[..col.min(line_content.len())] + .rfind(|c| !is_ident(c)) + .map(|i| i + 1) + .unwrap_or(0); + + let end = line_content[col..] + .find(|c| !is_ident(c)) + .map(|i| i + col) + .unwrap_or(line_content.len()); + + if start < end { + Some(line_content[start..end].to_string()) + } else { + None + } +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 0000000..d8c4d2a --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "naviscope-mcp" +version = "0.5.0" +edition = "2024" + +[dependencies] +naviscope-api = { workspace = true } +rmcp = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +anyhow = { workspace = true } +tower-lsp = { workspace = true } +axum = { workspace = true } +futures = { workspace = true } +tokio-tungstenite = { workspace = true } +tokio-util = { workspace = true } +xxhash-rust = { workspace = true } +schemars = { workspace = true } \ No newline at end of file diff --git a/src/mcp/http.rs b/crates/mcp/src/http.rs similarity index 73% rename from src/mcp/http.rs rename to crates/mcp/src/http.rs index a605029..d709f5e 100644 --- a/src/mcp/http.rs +++ b/crates/mcp/src/http.rs @@ -1,20 +1,25 @@ +use crate::McpServer; +use axum::{ + Router, + extract::State, + extract::ws::{Message, WebSocket, WebSocketUpgrade}, + routing::get, +}; +use futures::{sink::SinkExt, stream::StreamExt}; +use naviscope_api::graph::GraphService; +use rmcp::ServiceExt; use std::path::{Path, PathBuf}; use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::RwLock; -use axum::{routing::get, Router, extract::State, extract::ws::{WebSocket, WebSocketUpgrade, Message}}; -use rmcp::ServiceExt; -use crate::index::Naviscope; -use crate::mcp::McpServer; +use tokio_util::sync::CancellationToken; use tower_lsp::Client; use tower_lsp::lsp_types::MessageType; -use tokio_util::sync::CancellationToken; use tracing::info; -use futures::{sink::SinkExt, stream::StreamExt}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; pub fn spawn_http_server( client: Client, - engine: Arc>>, + engine: Arc>>>, root_path: PathBuf, session_path_lock: Arc>>, client_name: Option, @@ -53,7 +58,9 @@ pub fn spawn_http_server( Err(e) => Some(e.to_string()), }; if let Some(e) = mcp_err { - let _ = client.log_message(MessageType::ERROR, format!("MCP HTTP Server failed: {}", e)).await; + let _ = client + .log_message(MessageType::ERROR, format!("MCP HTTP Server failed: {}", e)) + .await; } } }); @@ -77,20 +84,20 @@ fn write_cursor_config(root_path: &Path) { } pub async fn run_http_server( - engine: Arc>>, + engine: Arc>>>, _root_path: Option, // Kept for API compatibility, but not used in McpServer port: u16, cancel_token: CancellationToken, ) -> Result<(), Box> { let mcp = McpServer::new(engine); - + let app = Router::new() .route("/mcp", get(mcp_ws_handler)) .with_state(mcp); let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port)).await?; info!("MCP WebSocket server listening on 127.0.0.1:{}", port); - + axum::serve(listener, app) .with_graceful_shutdown(async move { cancel_token.cancelled().await; @@ -108,29 +115,33 @@ async fn mcp_ws_handler( async fn handle_socket(socket: WebSocket, mcp: McpServer) { let (mut ws_sink, mut ws_stream) = socket.split(); - + // Create a duplex pair to bridge WebSocket with McpServer let (client_end, server_end) = tokio::io::duplex(4096); let (mut client_reader, mut client_writer) = tokio::io::split(client_end); // Task 1: Forward WebSocket -> McpServer let mut ws_to_mcp = tokio::spawn(async move { - while let Some(Ok(msg)) = ws_stream.next().await { - match msg { - Message::Binary(data) => { - if client_writer.write_all(&data).await.is_err() { - break; + while let Some(msg_result) = StreamExt::next(&mut ws_stream).await { + let msg_result: Result = msg_result; + match msg_result { + Ok(msg) => match msg { + Message::Binary(data) => { + if client_writer.write_all(&data).await.is_err() { + break; + } + let _ = client_writer.flush().await; } - let _ = client_writer.flush().await; - } - Message::Text(data) => { - if client_writer.write_all(data.as_bytes()).await.is_err() { - break; + Message::Text(data) => { + if client_writer.write_all(data.as_bytes()).await.is_err() { + break; + } + let _ = client_writer.flush().await; } - let _ = client_writer.flush().await; - } - Message::Close(_) => break, - _ => {} + Message::Close(_) => break, + _ => {} + }, + Err(_) => break, } } }); @@ -139,8 +150,12 @@ async fn handle_socket(socket: WebSocket, mcp: McpServer) { let mut mcp_to_ws = tokio::spawn(async move { let mut buf = vec![0u8; 4096]; while let Ok(n) = client_reader.read(&mut buf).await { - if n == 0 { break; } - if ws_sink.send(Message::Binary(buf[..n].to_vec().into())).await.is_err() { + if n == 0 { + break; + } + let send_res: Result<(), axum::Error> = + SinkExt::send(&mut ws_sink, Message::Binary(buf[..n].to_vec().into())).await; + if send_res.is_err() { break; } } diff --git a/src/mcp/mod.rs b/crates/mcp/src/lib.rs similarity index 51% rename from src/mcp/mod.rs rename to crates/mcp/src/lib.rs index ff1e320..3c32121 100644 --- a/src/mcp/mod.rs +++ b/crates/mcp/src/lib.rs @@ -1,23 +1,21 @@ +use naviscope_api::graph::GraphService; +use naviscope_api::models::{EdgeType, GraphQuery, NodeKind}; use rmcp::{ + ErrorData as McpError, handler::server::{tool::ToolRouter, wrapper::Parameters}, model::{CallToolResult, Content, Implementation, InitializeResult, ServerCapabilities}, tool, tool_handler, tool_router, - ErrorData as McpError, }; -use crate::query::GraphQuery; -use crate::index::Naviscope; -use crate::query::QueryEngine; -use crate::model::graph::{EdgeType, NodeKind}; +use schemars::JsonSchema; +use serde::Deserialize; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::RwLock; -use serde::Deserialize; -use schemars::JsonSchema; use xxhash_rust::xxh3::xxh3_64; pub mod http; -pub mod stdio; pub mod proxy; +pub mod stdio; #[derive(serde::Serialize, serde::Deserialize)] pub struct SessionInfo { @@ -30,27 +28,26 @@ pub fn get_session_path(root_path: &Path) -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); let session_dir = Path::new(&home).join(".naviscope/sessions"); let _ = std::fs::create_dir_all(&session_dir); - - let abs_path = root_path.canonicalize().unwrap_or_else(|_| root_path.to_path_buf()); + + let abs_path = root_path + .canonicalize() + .unwrap_or_else(|_| root_path.to_path_buf()); let hash = xxh3_64(abs_path.to_string_lossy().as_bytes()); session_dir.join(format!("{:016x}.json", hash)) } #[derive(Clone)] pub struct McpServer { - pub(crate) tool_router: ToolRouter, - pub(crate) engine: Arc>>, + pub(crate) tool_router: Arc>, + pub(crate) engine: Arc>>>, } #[derive(Deserialize, JsonSchema)] -pub struct GrepArgs { +pub struct FindArgs { /// Search pattern (simple string or regex) for code element names pub pattern: String, /// Optional: Filter by element type. - /// Available kinds: - /// - Code: class, interface, enum, annotation, method, constructor, field, package - /// - Build: module, dependency, task, plugin - pub kind: Option>, + pub kind: Option>, /// Maximum number of results to return (default: 20) pub limit: Option, } @@ -60,16 +57,13 @@ pub struct LsArgs { /// Target node FQN to list children for. If null, lists top-level modules. pub fqn: Option, /// Optional: Filter results by element type. - /// Available kinds: - /// - Code: class, interface, enum, annotation, method, constructor, field, package - /// - Build: module, dependency, task, plugin - pub kind: Option>, + pub kind: Option>, /// Optional: Filter results by modifiers (e.g. ["public", "static"]) pub modifiers: Option>, } #[derive(Deserialize, JsonSchema)] -pub struct InspectArgs { +pub struct CatArgs { /// The Fully Qualified Name (FQN) of the code element to inspect pub fqn: String, } @@ -78,15 +72,11 @@ pub struct InspectArgs { pub struct DepsArgs { /// The Fully Qualified Name (FQN) of the target code element pub fqn: String, - /// If true, find incoming dependencies (who depends on me). + /// If true, find incoming dependencies (who depends on me). /// If false (default), find outgoing dependencies (who do I depend on). #[serde(default)] pub rev: bool, /// Optional: Filter by relationship types. - /// Available types: - /// - Structural: Contains - /// - Inheritance: InheritsFrom, Implements - /// - Usage: Calls, Instantiates, TypedAs, DecoratedBy, UsesDependency pub edge_type: Option>, } @@ -95,18 +85,18 @@ pub struct GetGuideArgs {} #[tool_router] impl McpServer { - pub fn new(engine: Arc>>) -> Self { + pub fn new(engine: Arc>>>) -> Self { Self { - tool_router: Self::tool_router(), + tool_router: Arc::new(Self::tool_router()), engine, } } - pub(crate) async fn get_or_build_index(&self) -> Result { + pub(crate) async fn get_or_build_index(&self) -> Result, McpError> { let lock = self.engine.read().await; - + match &*lock { - Some(navi) => Ok(navi.clone()), + Some(handle) => Ok(handle.clone()), None => { // Index not yet built by LSP, return error Err(McpError::new( @@ -118,34 +108,39 @@ impl McpServer { } } - pub(crate) async fn execute_query(&self, query: GraphQuery) -> Result { + pub(crate) async fn execute_query( + &self, + query: GraphQuery, + ) -> Result { let engine = self.get_or_build_index().await?; - - let result = tokio::task::spawn_blocking(move || { - let query_engine = QueryEngine::new(engine.graph()); - let result = query_engine.execute(&query).map_err(|e| e.to_string())?; - serde_json::to_string_pretty(&result).map_err(|e| e.to_string()) - }).await.map_err(|e| McpError::new(rmcp::model::ErrorCode(-32000), e.to_string(), None))?; - - match result { + + // EngineHandle now handles async execution and error mapping internally + let result = engine + .query(&query) + .await + .map_err(|e| McpError::new(rmcp::model::ErrorCode(-32000), e.to_string(), None))?; + + match serde_json::to_string_pretty(&result) { Ok(json_str) => Ok(CallToolResult::success(vec![Content::text(json_str)])), - Err(e) => Err(McpError::new(rmcp::model::ErrorCode(-32000), e, None)), + Err(e) => Err(McpError::new( + rmcp::model::ErrorCode(-32000), + e.to_string(), + None, + )), } } - fn to_node_kinds(kinds: Option>) -> Vec { - kinds.unwrap_or_default() - .iter() - .map(|s| NodeKind::from(s.as_str())) - .collect() - } - - #[tool(description = "Returns a comprehensive user guide and examples for using Naviscope. Call this tool first to understand how to effectively explore and analyze the codebase using the available tools.")] - pub async fn get_guide(&self, _params: Parameters) -> Result { + #[tool( + description = "Returns a comprehensive user guide and examples for using Naviscope. Call this tool first to understand how to effectively explore and analyze the codebase using the available tools." + )] + pub async fn get_guide( + &self, + _params: Parameters, + ) -> Result { let guide = r#" # Naviscope User Guide -Naviscope is a graph-based code understanding engine. Unlike text search, it understands the structural and semantic relationships in your code (Calls, Inheritance, Dependencies). +Naviscope is a graph-based code understanding engine. Unlike text search, it understands the structural and semantic relationships in your code (Type Usage, Inheritance, Dependencies). ## 🚀 Recommended Workflow @@ -153,55 +148,66 @@ Naviscope is a graph-based code understanding engine. Unlike text search, it und - `ls()` -> List root modules - `ls(fqn="com.example")` -> List contents of a package -2. **Find Entry Points**: Use `grep` to locate specific symbols (classes, methods) by name. - - `grep(pattern="UserController", kind=["class"])` +2. **Find Entry Points**: Use `find` to locate specific symbols (classes, methods) by name. + - `find(pattern="UserController", kind=["class"])` -3. **Deep Analysis**: Once you have a Fully Qualified Name (FQN), use `inspect` and `deps`. - - `inspect(fqn="...")` -> View source code and metadata - - `deps(fqn="...")` -> View outgoing calls/dependencies (What does this code use?) - - `deps(fqn="...", rev=true)` -> View incoming calls (Who uses this code?) +3. **Deep Analysis**: Once you have a Fully Qualified Name (FQN), use `cat` and `deps`. + - `cat(fqn="...")` -> View source code and metadata + - `deps(fqn="...")` -> View outgoing dependencies (What does this code use?) + - `deps(fqn="...", rev=true)` -> View incoming dependencies (Who uses this code?) ## 💡 Tips -- **FQNs**: Naviscope relies on Fully Qualified Names (e.g., `com.example.MyClass`, `src/main.rs`). Always use the FQN returned by `ls` or `grep` for subsequent `inspect`/`deps` calls. -- **Filters**: Use the `kind` (e.g., "class", "method") and `edge_type` (e.g., "Calls", "InheritsFrom") filters to narrow down noisy results. +- **FQNs**: Naviscope relies on Fully Qualified Names (e.g., `com.example.MyClass`, `src/main.rs`). Always use the FQN returned by `ls` or `find` for subsequent `cat`/`deps` calls. +- **Filters**: Use the `kind` (e.g., "class", "method") and `edge_type` (e.g., "TypedAs", "InheritsFrom") filters to narrow down noisy results. "#; Ok(CallToolResult::success(vec![Content::text(guide)])) } - #[tool(description = "Search for code elements (classes, methods, fields, etc.) across the project using a name pattern or regex. Use this to find definitions when you only know a name or part of it.")] - pub async fn grep(&self, params: Parameters) -> Result { + #[tool( + description = "Search for code elements (classes, methods, fields, etc.) across the project using a name pattern or regex. Use this to find definitions when you only know a name or part of it." + )] + pub async fn find(&self, params: Parameters) -> Result { let args = params.0; - self.execute_query(GraphQuery::Grep { - pattern: args.pattern, - kind: Self::to_node_kinds(args.kind), - limit: args.limit.unwrap_or(20) - }).await + self.execute_query(GraphQuery::Find { + pattern: args.pattern, + kind: args.kind.unwrap_or_default(), + limit: args.limit.unwrap_or(20), + }) + .await } - #[tool(description = "List sub-elements of a given node (FQN) or list top-level project modules if FQN is omitted. Use this to explore package structures or class members.")] + #[tool( + description = "List sub-elements of a given node (FQN) or list top-level project modules if FQN is omitted. Use this to explore package structures or class members." + )] pub async fn ls(&self, params: Parameters) -> Result { let args = params.0; - self.execute_query(GraphQuery::Ls { - fqn: args.fqn, - kind: Self::to_node_kinds(args.kind), + self.execute_query(GraphQuery::Ls { + fqn: args.fqn, + kind: args.kind.unwrap_or_default(), modifiers: args.modifiers.unwrap_or_default(), - }).await + }) + .await } - #[tool(description = "Retrieve detailed information about a specific code element by its Fully Qualified Name (FQN), including its source code snippet, location, and metadata.")] - pub async fn inspect(&self, params: Parameters) -> Result { + #[tool( + description = "Retrieve detailed information about a specific code element by its Fully Qualified Name (FQN), including its source code snippet, location, and metadata." + )] + pub async fn cat(&self, params: Parameters) -> Result { let args = params.0; self.execute_query(GraphQuery::Cat { fqn: args.fqn }).await } - #[tool(description = "Analyze dependencies for a given FQN. By default, shows outgoing dependencies (who I depend on). Use rev=true for incoming dependencies (who depends on me/impact analysis).")] + #[tool( + description = "Analyze dependencies for a given FQN. By default, shows outgoing dependencies (who I depend on). Use rev=true for incoming dependencies (who depends on me/impact analysis)." + )] pub async fn deps(&self, params: Parameters) -> Result { let args = params.0; - self.execute_query(GraphQuery::Deps { - fqn: args.fqn, + self.execute_query(GraphQuery::Deps { + fqn: args.fqn, rev: args.rev, - edge_types: args.edge_type.unwrap_or_default() - }).await + edge_types: args.edge_type.unwrap_or_default(), + }) + .await } } @@ -215,9 +221,7 @@ impl rmcp::ServerHandler for McpServer { version: env!("CARGO_PKG_VERSION").into(), ..Default::default() }, - capabilities: ServerCapabilities::builder() - .enable_tools() - .build(), + capabilities: ServerCapabilities::builder().enable_tools().build(), ..Default::default() } } diff --git a/src/mcp/proxy.rs b/crates/mcp/src/proxy.rs similarity index 84% rename from src/mcp/proxy.rs rename to crates/mcp/src/proxy.rs index da7c700..1c7d7b9 100644 --- a/src/mcp/proxy.rs +++ b/crates/mcp/src/proxy.rs @@ -1,8 +1,8 @@ +use crate::{SessionInfo, get_session_path}; +use futures::{SinkExt, StreamExt}; use std::path::Path; -use futures::{StreamExt, SinkExt}; -use crate::mcp::{get_session_path, SessionInfo}; -use tracing::{warn, info}; -use tokio::time::{sleep, Duration, timeout}; +use tokio::time::{Duration, sleep, timeout}; +use tracing::{info, warn}; pub async fn run_mcp_proxy(path: &Path) -> Result<(), Box> { // 1. Find session file @@ -11,7 +11,7 @@ pub async fn run_mcp_proxy(path: &Path) -> Result<(), Box // Wait for LSP to start (if session file doesn't exist yet) if !session_path.exists() { info!("LSP session not found, waiting for LSP server to start..."); - + // Wait up to 60 seconds for LSP to start let wait_result = timeout(Duration::from_secs(60), async { loop { @@ -20,7 +20,8 @@ pub async fn run_mcp_proxy(path: &Path) -> Result<(), Box } sleep(Duration::from_millis(500)).await; } - }).await; + }) + .await; if wait_result.is_err() { return Err(format!( @@ -28,21 +29,29 @@ pub async fn run_mcp_proxy(path: &Path) -> Result<(), Box path.display() ).into()); } - + info!("LSP session file detected, connecting..."); } // 2. Read session and check PID - let session: SessionInfo = match std::fs::read_to_string(&session_path).ok().and_then(|s| serde_json::from_str(&s).ok()) { + let session: SessionInfo = match std::fs::read_to_string(&session_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + { Some(s) => s, None => { - return Err(format!("Failed to parse session file at {}", session_path.display()).into()); + return Err( + format!("Failed to parse session file at {}", session_path.display()).into(), + ); } }; // Check if PID is alive if !is_pid_alive(session.pid) { - warn!("LSP process (PID: {}) is not alive, removing stale session file", session.pid); + warn!( + "LSP process (PID: {}) is not alive, removing stale session file", + session.pid + ); let _ = std::fs::remove_file(session_path); return Err(format!("LSP process (PID: {}) is not running", session.pid).into()); } @@ -69,17 +78,17 @@ fn is_pid_alive(pid: u32) -> bool { #[cfg(not(unix))] { // Simple fallback for non-unix, might need better implementation - true + true } } async fn start_ws_proxy(port: u16) -> Result<(), Box> { - use tokio::io::{stdin, stdout, AsyncReadExt, AsyncWriteExt}; + use tokio::io::{AsyncReadExt, AsyncWriteExt, stdin, stdout}; use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; - + let url = format!("ws://127.0.0.1:{}/mcp", port); let (ws_stream, _) = connect_async(&url).await?; - + let (mut ws_sink, mut ws_stream) = ws_stream.split(); let mut stdin = stdin(); let mut stdout = stdout(); @@ -88,7 +97,9 @@ async fn start_ws_proxy(port: u16) -> Result<(), Box> { let mut stdin_to_ws = tokio::spawn(async move { let mut buf = vec![0u8; 4096]; while let Ok(n) = stdin.read(&mut buf).await { - if n == 0 { break; } + if n == 0 { + break; + } let msg = Message::Binary(buf[..n].to_vec().into()); if ws_sink.send(msg).await.is_err() { break; diff --git a/src/mcp/stdio.rs b/crates/mcp/src/stdio.rs similarity index 67% rename from src/mcp/stdio.rs rename to crates/mcp/src/stdio.rs index 9f6ff9d..3a58a8b 100644 --- a/src/mcp/stdio.rs +++ b/crates/mcp/src/stdio.rs @@ -1,12 +1,12 @@ +use crate::McpServer; +use naviscope_api::graph::GraphService; +use rmcp::{ServiceExt, transport::stdio}; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; -use rmcp::{transport::stdio, ServiceExt}; -use crate::index::Naviscope; -use crate::mcp::McpServer; pub async fn run_stdio_server( - engine: Arc>>, + engine: Arc>>>, _root_path: Option, // Not used anymore, kept for API compatibility ) -> Result<(), Box> { let service = McpServer::new(engine).serve(stdio()).await?; diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml new file mode 100644 index 0000000..4495662 --- /dev/null +++ b/crates/runtime/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "naviscope-runtime" +version = "0.5.0" +edition = "2024" + +[dependencies] +naviscope-api = { workspace = true } +naviscope-core = { workspace = true } +naviscope-java = { workspace = true } +naviscope-gradle = { workspace = true } +tracing = { workspace = true } diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs new file mode 100644 index 0000000..43ccf67 --- /dev/null +++ b/crates/runtime/src/lib.rs @@ -0,0 +1,41 @@ +use naviscope_api::NaviscopeEngine; +use naviscope_api::lifecycle::EngineResult; +use std::path::PathBuf; +use std::sync::Arc; + +/// Bootstraps a full-featured Naviscope engine with all available plugins. +/// +/// This function acts as the central factory for the Naviscope runtime, +/// assembling the core engine with language-specific plugins like Java and Gradle. +pub fn build_default_engine(path: PathBuf) -> Arc { + let mut engine = naviscope_core::runtime::orchestrator::NaviscopeEngine::new(path); + + // Register Build Tool Plugins + engine.register_build_tool(Arc::new(naviscope_gradle::GradlePlugin::new())); + + // Register Language Plugins + match naviscope_java::JavaPlugin::new() { + Ok(plugin) => engine.register_language(Arc::new(plugin)), + Err(e) => tracing::error!("Failed to load Java plugin: {}", e), + } + + // Wrap in the standard EngineHandle which implements all API traits + Arc::new(naviscope_core::facade::EngineHandle::from_engine(Arc::new( + engine, + ))) +} + +/// Initializes the logging system for a specific component. +/// This delegates to the core logging module. +pub fn init_logging(component: &str) -> Option { + Some(naviscope_core::logging::init_logging(component)) +} + +/// Utility to clear all indices stored on the local system. +pub fn clear_all_indices() -> EngineResult<()> { + naviscope_core::runtime::orchestrator::NaviscopeEngine::clear_all_indices().map_err( + |e: naviscope_core::error::NaviscopeError| { + naviscope_api::lifecycle::EngineError::Internal(e.to_string()) + }, + ) +} diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 3d05ec8..090ee74 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "naviscope", "displayName": "Naviscope", "description": "Graph-based structured code navigation for Java", - "version": "0.2.1", + "version": "0.5.0", "publisher": "naviscope", "license": "MIT", "repository": { diff --git a/editors/vscode/src/bootstrap.ts b/editors/vscode/src/bootstrap.ts index a6a68ac..956819c 100644 --- a/editors/vscode/src/bootstrap.ts +++ b/editors/vscode/src/bootstrap.ts @@ -13,7 +13,7 @@ const BINARY_NAME = 'naviscope'; const REPO_OWNER = 'biuld'; const REPO_NAME = 'naviscope'; // Update this version when bundling a new version of the extension -const EXPECTED_VERSION = '0.2.1'; +const EXPECTED_VERSION = '0.5.0'; /** * Check if naviscope is available in PATH diff --git a/src/cli/clear.rs b/src/cli/clear.rs deleted file mode 100644 index 50ad51f..0000000 --- a/src/cli/clear.rs +++ /dev/null @@ -1,17 +0,0 @@ -use naviscope::index::Naviscope; -use std::path::PathBuf; -use tracing::info; - -pub fn run(path: Option) -> Result<(), Box> { - if let Some(path) = path { - let engine = Naviscope::new(path.clone()); - info!("Clearing index for project at: {}...", path.display()); - engine.clear_project_index()?; - info!("Project index cleared."); - } else { - info!("Clearing all indices at: {}...", Naviscope::get_base_index_dir().display()); - Naviscope::clear_all_indices()?; - info!("All indices cleared."); - } - Ok(()) -} diff --git a/src/cli/index.rs b/src/cli/index.rs deleted file mode 100644 index 5972666..0000000 --- a/src/cli/index.rs +++ /dev/null @@ -1,27 +0,0 @@ -use naviscope::index::Naviscope; -use std::path::PathBuf; -use tracing::info; - -pub fn run(path: PathBuf, debug: bool) -> Result<(), Box> { - let mut engine = Naviscope::new(path.clone()); - info!("Indexing project at: {}...", path.display()); - engine.build_index()?; - - if debug { - let json_path = PathBuf::from("naviscope_debug.json"); - info!("Debug mode: saving JSON index to: {}...", json_path.display()); - engine.save_to_json(json_path)?; - } - - let index = engine.graph(); - info!("Indexing complete!"); - info!("Nodes: {}", index.topology.node_count()); - info!("Edges: {}", index.topology.edge_count()); - - info!("Top 10 nodes:"); - for (fqn, _) in index.fqn_map.iter().take(10) { - info!(" - {}", fqn); - } - - Ok(()) -} diff --git a/src/cli/shell/command.rs b/src/cli/shell/command.rs deleted file mode 100644 index 3384f4d..0000000 --- a/src/cli/shell/command.rs +++ /dev/null @@ -1,168 +0,0 @@ -use clap::Parser; -use naviscope::model::graph::{EdgeType, NodeKind}; -use naviscope::query::{GraphQuery, QueryResult}; -use super::view::{ShellNodeView, ShellNodeViewShort, get_kind_weight}; -use tabled::{Table, settings::Style}; -use shlex; - -/// Helper struct for Clap parsing within the shell -#[derive(Parser)] -#[command(no_binary_name = true)] -pub enum ShellCommand { - /// List members or structure - Ls { - /// Target node FQN (optional, defaults to current node) - fqn: Option, - /// Filter by node kind (e.g. class, interface, method) - #[arg(long, value_delimiter = ',')] - kind: Vec, - /// Filter by modifiers (e.g. public, static) - #[arg(long, value_delimiter = ',')] - modifiers: Vec, - /// Use long listing format - #[arg(short, long)] - long: bool, - }, - /// Change current node context (internal shell command) - Cd { - /// Target path - path: String, - }, - /// Print current node context - Pwd, - /// Clear the screen - Clear, - /// Search for symbols - Grep { - /// Pattern to search for - pattern: String, - /// Filter by node kind - #[arg(long, value_delimiter = ',')] - kind: Vec, - /// Limit number of results - #[arg(long, default_value_t = 20)] - limit: usize, - }, - /// Inspect node details - Cat { - /// Target node FQN (optional, defaults to current node) or member name - target: String, - }, - /// Find dependencies - Deps { - /// Target node FQN (optional, defaults to current node) - fqn: Option, - /// If set, find incoming dependencies (who depends on me) - #[arg(long)] - rev: bool, - /// Filter by edge types (e.g. Calls, Extends) - #[arg(long, value_delimiter = ',')] - edge_types: Vec, - }, -} - -use clap::error::ErrorKind; - -pub fn parse_shell_command(input: &str) -> Result, Box> { - // Use shlex to split arguments while respecting quotes - let args = shlex::split(input).ok_or("Invalid quoting")?; - - // Parse using Clap - match ShellCommand::try_parse_from(args) { - Ok(c) => Ok(Some(c)), - Err(e) => { - // Handle help/version display without returning an error - if e.kind() == ErrorKind::DisplayHelp || e.kind() == ErrorKind::DisplayVersion { - println!("{}", e); - return Ok(None); - } - Err(Box::new(e)) - } - } -} - -impl ShellCommand { - pub fn to_graph_query(&self, current_node: &Option) -> Result> { - match self { - ShellCommand::Ls { fqn, kind, modifiers, .. } => { - let target_fqn = fqn.clone().or_else(|| current_node.clone()); - Ok(GraphQuery::Ls { - fqn: target_fqn, - kind: kind.clone(), - modifiers: modifiers.clone(), - }) - }, - ShellCommand::Grep { pattern, kind, limit } => Ok(GraphQuery::Grep { - pattern: pattern.clone(), - kind: kind.clone(), - limit: *limit, - }), - ShellCommand::Cat { target } => { - Ok(GraphQuery::Cat { fqn: target.clone() }) - }, - ShellCommand::Deps { fqn, rev, edge_types } => { - let target_fqn = fqn.clone().or_else(|| current_node.clone()) - .ok_or("No FQN provided and no current context")?; - Ok(GraphQuery::Deps { - fqn: target_fqn, - rev: *rev, - edge_types: edge_types.clone(), - }) - }, - ShellCommand::Cd { .. } | ShellCommand::Pwd | ShellCommand::Clear => { - Err("Internal shell command should be handled by ReplServer".into()) - } - } - } - - pub fn render(&self, result: QueryResult) -> Result> { - if result.is_empty() { - return Ok("NO RECORDS FOUND".to_string()); - } - - match self { - ShellCommand::Ls { long: false, .. } => { - let mut views: Vec = result.nodes.iter().map(|node| { - ShellNodeViewShort { - kind: node.kind().to_string(), - name: if is_container(node.kind()) { format!("{}/", node.name()) } else { node.name().to_string() }, - } - }).collect(); - - views.sort_by(|a, b| { - let wa = get_kind_weight(&a.kind); - let wb = get_kind_weight(&b.kind); - if wa != wb { wa.cmp(&wb) } else { a.name.cmp(&b.name) } - }); - - Ok(Table::new(&views).with(Style::psql()).to_string()) - } - ShellCommand::Cat { .. } if result.nodes.len() == 1 => { - Ok(serde_json::to_string_pretty(&result.nodes[0])?) - } - _ => { - // Default detailed table view for Grep, Deps, and Ls -l - let mut views: Vec = result.nodes.iter().map(|node| { - let relation = result.edges.iter() - .filter(|e| e.to == node.fqn() || e.from == node.fqn()) - .map(|e| format!("{:?}", e.data.edge_type)) - .collect::>() - .join(", "); - ShellNodeView::from_node(node, if relation.is_empty() { None } else { Some(relation) }) - }).collect(); - - views.sort_by(|a, b| { - let wa = get_kind_weight(&a.kind); - let wb = get_kind_weight(&b.kind); - if wa != wb { wa.cmp(&wb) } else { a.name.cmp(&b.name) } - }); - - Ok(Table::new(&views).with(Style::psql()).to_string()) - } - } - } -} - -fn is_container(kind: NodeKind) -> bool { - matches!(kind, NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation | NodeKind::Module | NodeKind::Package) -} diff --git a/src/cli/shell/completer.rs b/src/cli/shell/completer.rs deleted file mode 100644 index 273beea..0000000 --- a/src/cli/shell/completer.rs +++ /dev/null @@ -1,102 +0,0 @@ -use naviscope::query::{GraphQuery, QueryEngine}; -use reedline::{Completer, Suggestion}; -use super::context::ShellContext; - -pub struct NaviscopeCompleter<'a> { - pub commands: Vec, - pub context: ShellContext, - pub _marker: std::marker::PhantomData<&'a ()>, -} - -impl<'a> NaviscopeCompleter<'a> { - pub fn new( - commands: Vec, - context: ShellContext, - ) -> Self { - Self { - commands, - context, - _marker: std::marker::PhantomData, - } - } -} - -impl<'a> Completer for NaviscopeCompleter<'a> { - fn complete(&mut self, line: &str, pos: usize) -> Vec { - let trimmed = line.trim_start(); - - // 1. Command completion (at start of line) - if !trimmed.contains(' ') { - return self.commands.iter() - .filter(|cmd| cmd.starts_with(trimmed)) - .map(|cmd| Suggestion { - value: cmd.clone(), - description: None, - style: None, - extra: None, - span: reedline::Span { start: pos - trimmed.len(), end: pos }, - append_whitespace: true, - match_indices: None, - }) - .collect(); - } - - // 2. Argument completion (for cd, ls, cat, deps) - let parts: Vec<&str> = trimmed.split_whitespace().collect(); - if parts.len() >= 1 { - let cmd = parts[0]; - if matches!(cmd, "cd" | "ls" | "cat" | "deps") { - // Determine the partial FQN being typed - let last_word = if line.ends_with(' ') { "" } else { parts.last().unwrap_or(&"") }; - let span_start = pos - last_word.len(); - - // Get current context - let parent_fqn = self.context.current_fqn(); - - // Query graph for children of current context (or partial match) - // If last_word contains dots, we might need to resolve relative to root or parent - // For simplicity: list children of current_node, filtering by last_word - - let search_fqn = if let Some(parent) = &parent_fqn { - if last_word.is_empty() { - Some(parent.clone()) - } else { - // Naive: just look for children of parent that start with last_word - Some(parent.clone()) - } - } else { - None // Root - }; - - // Use engine to list children - let query = GraphQuery::Ls { - fqn: search_fqn, - kind: vec![], - modifiers: vec![] - }; - - if let Ok(naviscope) = self.context.naviscope.read() { - let engine = QueryEngine::new(naviscope.graph()); - - if let Ok(result) = engine.execute(&query) { - return result.nodes.iter() - .map(|node| node.name()) - .filter(|name| name.starts_with(last_word)) - .map(|name| Suggestion { - value: name.to_string(), - description: None, - style: None, - extra: None, - span: reedline::Span { start: span_start, end: pos }, - append_whitespace: true, - match_indices: None, - }) - .collect(); - } - } - } - } - - vec![] - } -} diff --git a/src/cli/shell/context.rs b/src/cli/shell/context.rs deleted file mode 100644 index 1614459..0000000 --- a/src/cli/shell/context.rs +++ /dev/null @@ -1,177 +0,0 @@ -use std::sync::{Arc, RwLock}; -use naviscope::index::{Naviscope, CodeGraph}; -use naviscope::query::{QueryEngine, GraphQuery}; -use naviscope::model::graph::GraphNode; - -#[derive(Clone)] -pub struct ShellContext { - pub naviscope: Arc>, - pub current_node: Arc>>, -} - -pub enum ResolveResult { - Found(String), - Ambiguous(Vec), - NotFound, -} - -impl ShellContext { - pub fn new(naviscope: Arc>, current_node: Arc>>) -> Self { - Self { - naviscope, - current_node, - } - } - - pub fn current_fqn(&self) -> Option { - self.current_node.read().unwrap().clone() - } - - pub fn set_current_fqn(&self, fqn: Option) { - *self.current_node.write().unwrap() = fqn; - } - - /// Resolves a user input path (absolute FQN, relative path, or fuzzy name) to a concrete FQN. - pub fn resolve_node(&self, target: &str) -> ResolveResult { - // 1. Handle special paths - if let Some(result) = Self::resolve_special_path(target) { - return result; - } - - let curr = self.current_fqn(); - let engine_guard = self.naviscope.read().unwrap(); - let graph = engine_guard.graph(); - - // 2. Handle Parent (..) navigation - if let Some(result) = Self::resolve_parent(target, &curr, graph) { - return result; - } - - // 3. Try Exact Match (Absolute FQN) - if let Some(result) = Self::resolve_exact_match(target, graph) { - return result; - } - - // 4. Try Child Lookup (Relative / Fuzzy) - Self::resolve_child_lookup(target, &curr, graph) - } - - /// Handles special paths like "/" (root). - fn resolve_special_path(target: &str) -> Option { - if target == "/" { - Some(ResolveResult::Found("".to_string())) // Marker for root - } else { - None - } - } - - /// Handles parent navigation (".."). - fn resolve_parent(target: &str, current_fqn: &Option, graph: &CodeGraph) -> Option { - if target != ".." { - return None; - } - - if let Some(c) = current_fqn { - // Graph-based parent lookup - if let Some(&idx) = graph.fqn_map.get(c) { - let mut incoming = graph.topology - .neighbors_directed(idx, petgraph::Direction::Incoming) - .detach(); - - while let Some((edge_idx, neighbor_idx)) = incoming.next(&graph.topology) { - let edge = &graph.topology[edge_idx]; - if edge.edge_type == naviscope::model::graph::EdgeType::Contains { - if let Some(parent_node) = graph.topology.node_weight(neighbor_idx) { - return Some(ResolveResult::Found(parent_node.fqn().to_string())); - } - } - } - } - - // Fallback: String manipulation - if let Some(last_dot) = c.rfind('.') { - return Some(ResolveResult::Found(c[0..last_dot].to_string())); - } else if c.contains("::") { - let parts: Vec<&str> = c.split("::").collect(); - if parts.len() > 1 { - let parent = parts[..parts.len()-1].join("::"); - if parent == "module" { - return Some(ResolveResult::Found("".to_string())); // Root - } - return Some(ResolveResult::Found(parent)); - } - } - Some(ResolveResult::Found("".to_string())) // Root - } else { - Some(ResolveResult::Found("".to_string())) // Already at root - } - } - - /// Tries exact match against absolute FQN. - fn resolve_exact_match(target: &str, graph: &CodeGraph) -> Option { - if graph.fqn_map.contains_key(target) { - Some(ResolveResult::Found(target.to_string())) - } else { - None - } - } - - /// Tries child lookup with exact and fuzzy name matching. - fn resolve_child_lookup(target: &str, current_fqn: &Option, graph: &CodeGraph) -> ResolveResult { - let query_engine = QueryEngine::new(graph); - let children_query = GraphQuery::Ls { - fqn: current_fqn.clone(), - kind: vec![], - modifiers: vec![], - }; - - if let Ok(res) = query_engine.execute(&children_query) { - // First pass: Exact Name Match - let exact_matches = Self::find_exact_name_match(target, &res.nodes); - if !exact_matches.is_empty() { - if exact_matches.len() == 1 { - return ResolveResult::Found(exact_matches[0].clone()); - } else { - return ResolveResult::Ambiguous(exact_matches); - } - } - - // Second pass: Fuzzy Name Match (e.g. method name without signature) - let fuzzy_matches = Self::find_fuzzy_name_match(target, &res.nodes); - if !fuzzy_matches.is_empty() { - if fuzzy_matches.len() == 1 { - return ResolveResult::Found(fuzzy_matches[0].clone()); - } else { - return ResolveResult::Ambiguous(fuzzy_matches); - } - } - } - - ResolveResult::NotFound - } - - /// Finds nodes with exact name match. - fn find_exact_name_match(target: &str, nodes: &[GraphNode]) -> Vec { - nodes.iter() - .filter(|n| { - let name = n.name(); - // Handle display names with trailing slash - let clean_name = name.trim_end_matches('/'); - clean_name == target - }) - .map(|n| n.fqn().to_string()) - .collect() - } - - /// Finds nodes with fuzzy name match (e.g. method name without signature). - fn find_fuzzy_name_match(target: &str, nodes: &[GraphNode]) -> Vec { - nodes.iter() - .filter(|n| { - let name = n.name(); - let clean_name = name.trim_end_matches('/'); - clean_name.split('(').next().unwrap_or("") == target - }) - .map(|n| n.fqn().to_string()) - .collect() - } -} \ No newline at end of file diff --git a/src/cli/shell/handlers.rs b/src/cli/shell/handlers.rs deleted file mode 100644 index 34b9329..0000000 --- a/src/cli/shell/handlers.rs +++ /dev/null @@ -1,115 +0,0 @@ -use naviscope::query::QueryEngine; -use naviscope::query::GraphQuery; -use super::command::ShellCommand; -use super::context::{ShellContext, ResolveResult}; - -pub trait CommandHandler { - fn handle(&self, cmd: &ShellCommand, context: &mut ShellContext) -> Result>; -} - -pub struct CdHandler; -impl CommandHandler for CdHandler { - fn handle(&self, cmd: &ShellCommand, context: &mut ShellContext) -> Result> { - if let ShellCommand::Cd { path } = cmd { - match context.resolve_node(path) { - ResolveResult::Found(fqn) => { - let new_curr = if fqn.is_empty() { None } else { Some(fqn) }; - context.set_current_fqn(new_curr); - Ok(String::new()) - }, - ResolveResult::Ambiguous(candidates) => { - let mut msg = format!("Ambiguous path '{}'. Candidates:\n", path); - for c in candidates.iter().take(10) { - msg.push_str(&format!(" - {}\n", c)); - } - Err(msg.into()) - }, - ResolveResult::NotFound => Err(format!("Node '{}' not found.", path).into()), - } - } else { - Ok(String::new()) - } - } -} - -pub struct CatHandler; -impl CommandHandler for CatHandler { - fn handle(&self, cmd: &ShellCommand, context: &mut ShellContext) -> Result> { - if let ShellCommand::Cat { target } = cmd { - // First resolve the target to a concrete FQN - let fqn = match context.resolve_node(target) { - ResolveResult::Found(f) => f, - ResolveResult::Ambiguous(candidates) => { - let mut msg = format!("Ambiguous match for '{}'. Available options:\n\n", target); - // We should probably look up the names for these FQNs to show better hints, - // but for now showing FQNs is correct. - for c in candidates { - msg.push_str(&format!(" - {}\n", c)); - } - msg.push_str("\nPlease specify the full name."); - return Ok(msg); - }, - ResolveResult::NotFound => { - // If not resolved locally, fallback to trying target as raw FQN (handled by engine) - // This covers cases where target is an FQN but not reachable via 'ls' from current node? - // Actually resolve_node step 3 covers Exact Match. So it's truly not found. - return Ok("NO RECORDS FOUND".to_string()); - } - }; - - if fqn.is_empty() { - return Err("Cannot cat root.".into()); - } - - let engine_guard = context.naviscope.read().unwrap(); - let engine = QueryEngine::new(engine_guard.graph()); - let query = GraphQuery::Cat { fqn }; - let result = engine.execute(&query)?; - - // Re-use ShellCommand's render for consistent output format - cmd.render(result) - } else { - Ok(String::new()) - } - } -} - -pub struct GenericQueryHandler; -impl CommandHandler for GenericQueryHandler { - fn handle(&self, cmd: &ShellCommand, context: &mut ShellContext) -> Result> { - let current_node = context.current_fqn(); - let query = cmd.to_graph_query(¤t_node)?; - - let engine_guard = context.naviscope.read().unwrap(); - let engine = QueryEngine::new(engine_guard.graph()); - - let result = engine.execute(&query)?; - cmd.render(result) - } -} - -pub struct PwdHandler; -impl CommandHandler for PwdHandler { - fn handle(&self, _cmd: &ShellCommand, context: &mut ShellContext) -> Result> { - Ok(context.current_fqn().unwrap_or("/".to_string())) - } -} - -pub struct ClearHandler; -impl CommandHandler for ClearHandler { - fn handle(&self, _cmd: &ShellCommand, _context: &mut ShellContext) -> Result> { - // Clear is handled by the Reedline loop mostly, but we can return a marker if needed. - // For now, simple print or empty string. The loop handles `line_editor.clear_screen()`. - Ok(String::new()) - } -} - -pub fn get_handler(cmd: &ShellCommand) -> Box { - match cmd { - ShellCommand::Cd { .. } => Box::new(CdHandler), - ShellCommand::Cat { .. } => Box::new(CatHandler), - ShellCommand::Pwd => Box::new(PwdHandler), - ShellCommand::Clear => Box::new(ClearHandler), - _ => Box::new(GenericQueryHandler), - } -} diff --git a/src/cli/shell/view.rs b/src/cli/shell/view.rs deleted file mode 100644 index fb6d9c5..0000000 --- a/src/cli/shell/view.rs +++ /dev/null @@ -1,158 +0,0 @@ -use naviscope::model::graph::{BuildElement, CodeElement, GraphNode, NodeKind}; -use naviscope::model::signature::TypeRef; -use naviscope::model::lang::java::{JavaElement, JavaParameter}; -use naviscope::model::lang::gradle::GradleElement; -use tabled::Tabled; -use std::path::PathBuf; - -/// A terminal-optimized view of a GraphNode (Detailed) -#[derive(Tabled)] -pub struct ShellNodeView { - pub kind: String, - pub name: String, - pub relation: String, - pub signature: String, - pub location: String, - pub fqn: String, -} - -/// A short view of a GraphNode -#[derive(Tabled)] -pub struct ShellNodeViewShort { - pub kind: String, - pub name: String, -} - -impl ShellNodeView { - pub fn from_node(node: &GraphNode, relation: Option) -> Self { - let location = node.file_path().map(|path: &PathBuf| { - let filename = path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("-"); - - if let Some(range) = node.range() { - return format!("{}:{}", filename, range.start_line + 1); - } - filename.to_string() - }).unwrap_or_else(|| "-".to_string()); - - let is_container = matches!(node.kind(), - NodeKind::Class | - NodeKind::Interface | - NodeKind::Enum | - NodeKind::Annotation); - - let name = if is_container { - format!("{}/", node.name()) - } else { - node.name().to_string() - }; - - let signature = match node { - GraphNode::Code(code_el) => match code_el { - CodeElement::Java { element, .. } => match element { - JavaElement::Method(m) => { - if m.is_constructor { - let params_str = m.parameters.iter() - .map(|p| format!("{}", fmt_type(&p.type_ref))) - .collect::>() - .join(", "); - format!("{}({})", m.name, params_str) - } else { - fmt_shell_signature(&m.parameters, &m.return_type) - } - } - JavaElement::Field(f) => { - format!("{} {}", fmt_type(&f.type_ref), f.name) - } - _ => "-".to_string(), - }, - }, - GraphNode::Build(build_el) => match build_el { - BuildElement::Gradle { element, .. } => match element { - GradleElement::Dependency(d) => { - let group = d.group.as_deref().unwrap_or("?"); - let version = d.version.as_deref().unwrap_or("?"); - format!("{}:{}:{}", group, d.name, version) - } - _ => "-".to_string(), - }, - }, - }; - - Self { - fqn: shorten_fqn(node.fqn()), - name, - kind: node.kind().to_string(), - relation: relation.unwrap_or_else(|| "-".to_string()), - signature, - location, - } - } -} - -pub fn shorten_fqn(fqn: &str) -> String { - let parts: Vec<&str> = fqn.split('.').collect(); - if parts.len() <= 2 { - return fqn.to_string(); - } - let mut result = String::new(); - for (i, part) in parts.iter().enumerate() { - if i < parts.len() - 2 { - if let Some(c) = part.chars().next() { - result.push(c); - result.push('.'); - } - } else { - result.push_str(part); - if i < parts.len() - 1 { - result.push('.'); - } - } - } - result -} - -fn fmt_type(t: &TypeRef) -> String { - match t { - TypeRef::Raw(s) => s.clone(), - TypeRef::Id(s) => s.split('.').last().unwrap_or(s).to_string(), - TypeRef::Generic { base, args } => { - let args_str = args.iter().map(fmt_type).collect::>().join(", "); - format!("{}<{}>", fmt_type(base), args_str) - }, - TypeRef::Array { element, dimensions } => { - format!("{}{}", fmt_type(element), "[]".repeat(*dimensions)) - }, - _ => "?".to_string(), - } -} - -fn fmt_shell_signature(params: &[JavaParameter], return_type: &TypeRef) -> String { - let return_type_str = fmt_type(return_type); - let params_str = params.iter() - .map(|p| fmt_type(&p.type_ref)) - .collect::>() - .join(", "); - - let total_len = params_str.len() + return_type_str.len(); - if total_len <= 50 { - format!("({}) -> {}", params_str, return_type_str) - } else { - format!("(...)\n -> {}", return_type_str) - } -} - -pub fn get_kind_weight(kind: &str) -> i32 { - match kind.to_lowercase().as_str() { - "package" => 1, - "class" => 2, - "interface" => 3, - "enum" => 4, - "annotation" => 5, - "constructor" => 6, - "method" => 7, - "field" => 8, - _ => 99, - } -} diff --git a/src/cli/watch.rs b/src/cli/watch.rs deleted file mode 100644 index 0d33ce3..0000000 --- a/src/cli/watch.rs +++ /dev/null @@ -1,48 +0,0 @@ -use naviscope::index::Naviscope; -use naviscope::project::watcher::Watcher; -use std::path::PathBuf; -use std::thread; -use std::time::Duration; -use tracing::{info, error}; - -pub fn run(path: PathBuf, debug: bool) -> Result<(), Box> { - let mut engine = Naviscope::new(path.clone()); - info!("Initializing: Indexing project at: {}...", path.display()); - engine.build_index()?; - info!("Initial indexing complete. Ready to watch for changes."); - - let mut watcher = Watcher::new(&path)?; - - loop { - // Wait for the first event - if let Some(event) = watcher.next_event() { - if !event.paths.iter().any(|p| naviscope::project::is_relevant_path(p)) { - continue; - } - - // Debounce: wait for more events in the next 500ms - thread::sleep(Duration::from_millis(500)); - - // Drain all pending events - while watcher.try_next_event().is_some() {} - - info!("Change detected. Re-indexing..."); - match engine.build_index() { - Ok(_) => { - let index = engine.graph(); - info!( - "Indexing complete! Nodes: {}, Edges: {}", - index.topology.node_count(), - index.topology.edge_count() - ); - - if debug { - let json_path = PathBuf::from("naviscope_debug.json"); - engine.save_to_json(json_path)?; - } - } - Err(e) => error!("Error during re-indexing: {}", e), - } - } - } -} diff --git a/src/index.rs b/src/index.rs deleted file mode 100644 index 1ccc3c2..0000000 --- a/src/index.rs +++ /dev/null @@ -1,344 +0,0 @@ -use crate::error::{NaviscopeError, Result}; -use crate::model::graph::{EdgeType, GraphEdge, GraphNode}; -use crate::project::scanner::Scanner; -use crate::project::source::SourceFile; -use petgraph::stable_graph::{NodeIndex, StableDiGraph}; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; -use tracing; -use xxhash_rust::xxh3::xxh3_64; - -pub const CURRENT_VERSION: u32 = 1; -pub const DEFAULT_INDEX_DIR: &str = ".naviscope/indices"; - -#[derive(Serialize, Deserialize, Clone)] -pub struct CodeGraph { - pub version: u32, - pub topology: StableDiGraph, - pub fqn_map: HashMap, - pub name_map: HashMap>, - pub file_map: HashMap, - pub path_to_nodes: HashMap>, -} - -impl CodeGraph { - pub fn new() -> Self { - Self { - version: CURRENT_VERSION, - topology: StableDiGraph::new(), - fqn_map: HashMap::new(), - name_map: HashMap::new(), - file_map: HashMap::new(), - path_to_nodes: HashMap::new(), - } - } - - pub fn get_or_create_node(&mut self, id: &str, node_data: GraphNode) -> NodeIndex { - if let Some(&idx) = self.fqn_map.get(id) { - // Optional: Update node data if needed - idx - } else { - let name = node_data.name().to_string(); - let idx = self.topology.add_node(node_data); - self.fqn_map.insert(id.to_string(), idx); - self.name_map.entry(name).or_default().push(idx); - idx - } - } - - pub fn find_node_at(&self, path: &Path, line: usize, col: usize) -> Option { - let nodes = self.path_to_nodes.get(path)?; - - for &idx in nodes { - if let Some(node) = self.topology.node_weight(idx) { - if let Some(range) = node.name_range() { - if range.contains(line, col) { - return Some(idx); - } - } - } - } - None - } - - /// Finds an edge whose range contains the given position. - /// This is used to find references from source code. - pub fn find_edge_at(&self, path: &Path, line: usize, col: usize) -> Option<(NodeIndex, NodeIndex, &GraphEdge)> { - let nodes = self.path_to_nodes.get(path)?; - - for &node_idx in nodes { - // Check outgoing edges from nodes in this file - let mut edges = self.topology.neighbors_directed(node_idx, petgraph::Direction::Outgoing).detach(); - while let Some((edge_idx, neighbor_idx)) = edges.next(&self.topology) { - let edge = &self.topology[edge_idx]; - if let Some(range) = &edge.range { - if range.contains(line, col) { - return Some((node_idx, neighbor_idx, edge)); - } - } - } - } - None - } - - /// Finds nodes matching a symbol resolution result. - /// This is a low-level query used by resolvers. - pub fn find_matches_by_fqn(&self, fqn: &str) -> Vec { - if let Some(&idx) = self.fqn_map.get(fqn) { - vec![idx] - } else { - vec![] - } - } -} - -#[derive(Clone)] -pub struct Naviscope { - graph: CodeGraph, - project_root: PathBuf, -} - -impl Naviscope { - pub fn new(project_root: PathBuf) -> Self { - Self { - graph: CodeGraph::new(), - project_root, - } - } - - /// Gets the base directory for storing indices, supporting NAVISCOPE_INDEX_DIR env var. - pub fn get_base_index_dir() -> PathBuf { - if let Ok(env_dir) = std::env::var("NAVISCOPE_INDEX_DIR") { - return PathBuf::from(env_dir); - } - - let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - Path::new(&home).join(DEFAULT_INDEX_DIR) - } - - /// Clears all built indices by removing the base index directory. - pub fn clear_all_indices() -> Result<()> { - let base_dir = Self::get_base_index_dir(); - if base_dir.exists() { - std::fs::remove_dir_all(&base_dir)?; - } - Ok(()) - } - - /// Clears the index for the current project. - pub fn clear_project_index(&self) -> Result<()> { - let path = self.get_project_index_path(); - if path.exists() { - std::fs::remove_file(path)?; - } - Ok(()) - } - - /// Gets the index file path for the current project. - fn get_project_index_path(&self) -> PathBuf { - let base_dir = Self::get_base_index_dir(); - let abs_path = self.project_root.canonicalize().unwrap_or(self.project_root.clone()); - let hash = xxh3_64(abs_path.to_string_lossy().as_bytes()); - base_dir.join(format!("{:016x}.bin", hash)) - } - - /// Loads the index for the project from the fixed storage path. - /// Returns Ok(true) if loaded successfully, Ok(false) if file doesn't exist or is incompatible. - /// Automatically handles incompatible binaries by cleaning up and resetting the graph. - pub fn load(&mut self) -> Result { - let path = self.get_project_index_path(); - if !path.exists() { - return Ok(false); - } - - let file = std::fs::File::open(&path)?; - let reader = std::io::BufReader::new(file); - match rmp_serde::from_read(reader) { - Ok(graph) => { - self.graph = graph; - Ok(true) - } - Err(e) => { - // Handle incompatible binary: log warning, clean up file, and reset graph - tracing::warn!( - "Failed to parse index at {}: {}. Incompatible binary detected, will rebuild.", - path.display(), - e - ); - self.graph = CodeGraph::new(); - // Try to remove the corrupted file (ignore errors) - let _ = std::fs::remove_file(&path); - Ok(false) - } - } - } - - /// Saves the index to the fixed storage path. - pub fn save(&self) -> Result<()> { - let path = self.get_project_index_path(); - - // Ensure directory exists - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - - let file = std::fs::File::create(path)?; - let mut writer = std::io::BufWriter::new(file); - rmp_serde::encode::write(&mut writer, &self.graph) - .map_err(|e| NaviscopeError::Parsing(e.to_string()))?; - Ok(()) - } - - /// Saves the index to a file in JSON format for debugging. - pub fn save_to_json>(&self, path: P) -> Result<()> { - let file = std::fs::File::create(path)?; - let writer = std::io::BufWriter::new(file); - serde_json::to_writer_pretty(writer, &self.graph) - .map_err(|e| NaviscopeError::Parsing(e.to_string()))?; - Ok(()) - } - - pub fn build_index(&mut self) -> Result<()> { - // Try to load existing index first if memory is empty - if self.graph.file_map.is_empty() { - let _ = self.load(); - } - - // Refresh will handle version compatibility check and rebuild if needed - self.refresh() - } - - /// Scans for changes and updates the graph in memory. - /// Does not reload from disk, but saves to disk if changes are detected. - /// Assumes the graph is already loaded in memory (via load() or previous refresh()). - pub fn refresh(&mut self) -> Result<()> { - use crate::model::graph::GraphOp; - use crate::resolver::engine::IndexResolver; - - // Check version compatibility - if mismatch, clear disk index and reset graph - if self.graph.version != CURRENT_VERSION { - tracing::info!( - "Index version mismatch (found {}, current {}). Rebuilding...", - self.graph.version, - CURRENT_VERSION - ); - let _ = self.clear_project_index(); - // Reset to a fresh index with current version - self.graph = CodeGraph::new(); - } - - // Phase 1: Scan and Parse (parallel I/O and CPU-intensive work) - let parse_results = Scanner::scan_and_parse(&self.project_root, &self.graph.file_map); - - // Detect and handle deleted files - let current_paths: HashSet = - Scanner::collect_paths(&self.project_root).into_iter().collect(); - - let mut deleted_paths = Vec::new(); - for path in self.graph.file_map.keys() { - if !current_paths.contains(path) { - deleted_paths.push(path.clone()); - } - } - - for path in &deleted_paths { - self.apply_graph_op(GraphOp::RemovePath { path: path.clone() })?; - self.graph.file_map.remove(path); - } - - // Update file metadata for each parsed file - for parsed in &parse_results { - self.graph - .file_map - .insert(parsed.file.path.clone(), parsed.file.clone()); - } - - // Phase 2: Resolve (coordinated by Resolver in two phases) - let resolver = IndexResolver::new(); - let all_ops = resolver.resolve(parse_results)?; - - // Phase 3: Apply (serial merge into the graph - fast memory operations) - let has_changes = !all_ops.is_empty() || !deleted_paths.is_empty(); - for op in all_ops { - self.apply_graph_op(op)?; - } - - // Save updated index only if there were changes - if has_changes { - self.save()?; - } - - Ok(()) - } - - /// Apply a single graph operation to the index - fn apply_graph_op(&mut self, op: crate::model::graph::GraphOp) -> Result<()> { - use crate::model::graph::GraphOp; - - match op { - GraphOp::AddNode { id, data } => { - let path = data.file_path().cloned(); - let idx = self.graph.get_or_create_node(&id, data); - - // Update path_to_nodes mapping - if let Some(p) = path { - self.graph - .path_to_nodes - .entry(p) - .or_default() - .push(idx); - } - } - GraphOp::AddEdge { - from_id, - to_id, - edge, - } => { - // Look up node indices - let from_idx = self.graph.fqn_map.get(&from_id).cloned(); - let to_idx = self.graph.fqn_map.get(&to_id).cloned(); - - if let (Some(s_idx), Some(t_idx)) = (from_idx, to_idx) { - // For structural edges (Contains), avoid duplicates - if edge.edge_type == EdgeType::Contains { - let already_exists = self.graph.topology - .edges_connecting(s_idx, t_idx) - .any(|e| e.weight().edge_type == EdgeType::Contains); - if !already_exists { - self.graph.topology.add_edge(s_idx, t_idx, edge); - } - } else { - // For other edges (Calls, References, etc.), always add to capture multiple occurrences - self.graph.topology.add_edge(s_idx, t_idx, edge); - } - } - } - GraphOp::RemovePath { path } => { - if let Some(nodes) = self.graph.path_to_nodes.remove(&path) { - for node_idx in nodes { - // Get FQN before removing from graph - if let Some(node) = self.graph.topology.node_weight(node_idx) { - let fqn = node.fqn(); - let name = node.name().to_string(); - self.graph.fqn_map.remove(fqn); - if let Some(nodes_with_name) = self.graph.name_map.get_mut(&name) { - nodes_with_name.retain(|&idx| idx != node_idx); - if nodes_with_name.is_empty() { - self.graph.name_map.remove(&name); - } - } - } - self.graph.topology.remove_node(node_idx); - } - } - } - } - - Ok(()) - } - - pub fn graph(&self) -> &CodeGraph { - &self.graph - } -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index d52db82..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod error; -pub mod index; -pub mod logging; -pub mod lsp; -pub mod mcp; -pub mod model; -pub mod parser; -pub mod project; -pub mod resolver; -pub mod query; diff --git a/src/lsp/goto.rs b/src/lsp/goto.rs deleted file mode 100644 index 23c0d50..0000000 --- a/src/lsp/goto.rs +++ /dev/null @@ -1,333 +0,0 @@ -use crate::lsp::LspServer; -use crate::lsp::util::get_word_from_content; -use crate::parser::SymbolResolution; -use tower_lsp::jsonrpc::Result; -use tower_lsp::lsp_types::*; -use tree_sitter::QueryCursor; - -pub async fn definition( - server: &LspServer, - params: GotoDefinitionParams, -) -> Result> { - let uri = params.text_document_position_params.text_document.uri; - let position = params.text_document_position_params.position; - - let doc = match server.documents.get(&uri) { - Some(d) => d.clone(), - None => return Ok(None), - }; - - let engine_lock = server.engine.read().await; - let engine = match engine_lock.as_ref() { - Some(e) => e, - None => return Ok(None), - }; - let index = engine.graph(); - - // 1. Precise resolution using Semantic Resolver - let resolution = { - let resolver = match server.resolver.get_semantic_resolver(doc.language) { - Some(r) => r, - None => return Ok(None), - }; - let byte_col = crate::lsp::util::utf16_col_to_byte_col(&doc.content, position.line as usize, position.character as usize); - match resolver.resolve_at(&doc.tree, &doc.content, position.line as usize, byte_col, index) { - Some(r) => r, - None => return Ok(None), - } - }; - - if let SymbolResolution::Local(range, _) = resolution { - // Found declaration in the same file - return Ok(Some(GotoDefinitionResponse::Scalar(Location { - uri, - range: crate::lsp::util::to_lsp_range( - tree_sitter::Range { - start_byte: 0, // Not used by to_lsp_range - end_byte: 0, - start_point: tree_sitter::Point::new(range.start_line, range.start_col), - end_point: tree_sitter::Point::new(range.end_line, range.end_col), - }, - &doc.content, - ), - }))); - } - - let matches = { - let resolver = match server.resolver.get_semantic_resolver(doc.language) { - Some(r) => r, - None => return Ok(None), - }; - resolver.find_matches(index, &resolution) - }; - let mut locations = Vec::new(); - - for &node_idx in &matches { - let node = &index.topology[node_idx]; - if let (Some(target_path), Some(range)) = (node.file_path(), node.range()) { - locations.push(Location { - uri: Url::from_file_path(target_path).unwrap(), - range: Range { - start: Position::new(range.start_line as u32, range.start_col as u32), - end: Position::new(range.end_line as u32, range.end_col as u32), - }, - }); - } - } - - if !locations.is_empty() { - if locations.len() == 1 { - return Ok(Some(GotoDefinitionResponse::Scalar(locations[0].clone()))); - } else { - return Ok(Some(GotoDefinitionResponse::Array(locations))); - } - } - - Ok(None) -} - -pub async fn type_definition( - server: &LspServer, - params: GotoDefinitionParams, -) -> Result> { - let uri = params.text_document_position_params.text_document.uri; - let position = params.text_document_position_params.position; - - let doc = match server.documents.get(&uri) { - Some(d) => d.clone(), - None => return Ok(None), - }; - - let engine_lock = server.engine.read().await; - let engine = match engine_lock.as_ref() { - Some(e) => e, - None => return Ok(None), - }; - let index = engine.graph(); - - // 1. Precise resolution using Semantic Resolver - let resolution = { - let resolver = match server.resolver.get_semantic_resolver(doc.language) { - Some(r) => r, - None => return Ok(None), - }; - let byte_col = crate::lsp::util::utf16_col_to_byte_col(&doc.content, position.line as usize, position.character as usize); - match resolver.resolve_at(&doc.tree, &doc.content, position.line as usize, byte_col, index) { - Some(r) => r, - None => return Ok(None), - } - }; - - let resolver = match server.resolver.get_semantic_resolver(doc.language) { - Some(r) => r, - None => return Ok(None), - }; - - let type_resolutions = resolver.resolve_type_of(index, &resolution); - - let mut locations = Vec::new(); - for res in type_resolutions { - let matches = resolver.find_matches(index, &res); - for idx in matches { - let target = &index.topology[idx]; - if let (Some(tp), Some(tr)) = (target.file_path(), target.range()) { - let loc = Location { - uri: Url::from_file_path(tp).unwrap(), - range: Range { - start: Position::new(tr.start_line as u32, tr.start_col as u32), - end: Position::new(tr.end_line as u32, tr.end_col as u32), - }, - }; - if !locations.contains(&loc) { - locations.push(loc); - } - } - } - } - - if !locations.is_empty() { - return Ok(Some(GotoDefinitionResponse::Array(locations))); - } - - Ok(None) -} - -pub async fn references( - server: &LspServer, - params: ReferenceParams, -) -> Result>> { - let uri = params.text_document_position.text_document.uri; - let position = params.text_document_position.position; - - let doc = match server.documents.get(&uri) { - Some(d) => d.clone(), - None => return Ok(None), - }; - - let engine_lock = server.engine.read().await; - let engine = match engine_lock.as_ref() { - Some(e) => e, - None => return Ok(None), - }; - let index = engine.graph(); - - // 1. Precise resolution using Semantic Resolver - let resolution = { - let resolver = match server.resolver.get_semantic_resolver(doc.language) { - Some(r) => r, - None => return Ok(None), - }; - let byte_col = crate::lsp::util::utf16_col_to_byte_col(&doc.content, position.line as usize, position.character as usize); - match resolver.resolve_at(&doc.tree, &doc.content, position.line as usize, byte_col, index) { - Some(r) => r, - None => return Ok(None), - } - }; - - let mut all_locations = Vec::new(); - - match resolution { - SymbolResolution::Local(_, _) => { - // Find all occurrences of this name in current file's AST - let word = get_word_from_content( - &doc.content, - position.line as usize, - position.character as usize, - ) - .unwrap_or_default(); - let query_str = format!("((identifier) @ident (#eq? @ident \"{}\"))", word); - if let Ok(query) = tree_sitter::Query::new(&doc.tree.language(), &query_str) { - let mut cursor = QueryCursor::new(); - let matches = - cursor.matches(&query, doc.tree.root_node(), doc.content.as_bytes()); - use tree_sitter::StreamingIterator; - let mut matches = matches; - while let Some(mat) = matches.next() { - for cap in mat.captures { - let r = cap.node.range(); - all_locations.push(Location { - uri: uri.clone(), - range: Range { - start: Position::new( - r.start_point.row as u32, - r.start_point.column as u32, - ), - end: Position::new( - r.end_point.row as u32, - r.end_point.column as u32, - ), - }, - }); - } - } - } - } - _ => { - let resolver = match server.resolver.get_semantic_resolver(doc.language) { - Some(r) => r, - None => return Ok(None), - }; - let matches = resolver.find_matches(index, &resolution); - for node_idx in matches { - let mut incoming = index - .topology - .neighbors_directed(node_idx, petgraph::Direction::Incoming) - .detach(); - while let Some((edge_idx, neighbor_idx)) = incoming.next(&index.topology) { - let edge = &index.topology[edge_idx]; - - // Filter edges for references - match edge.edge_type { - crate::model::graph::EdgeType::Calls | - crate::model::graph::EdgeType::Instantiates | - crate::model::graph::EdgeType::TypedAs | - crate::model::graph::EdgeType::DecoratedBy => {}, - _ => continue, - } - - let source_node = &index.topology[neighbor_idx]; - if let (Some(source_path), Some(range)) = - (source_node.file_path(), &edge.range) - { - all_locations.push(Location { - uri: Url::from_file_path(source_path).unwrap(), - range: Range { - start: Position::new( - range.start_line as u32, - range.start_col as u32, - ), - end: Position::new(range.end_line as u32, range.end_col as u32), - }, - }); - } - } - } - } - } - - if !all_locations.is_empty() { - return Ok(Some(all_locations)); - } - - Ok(None) -} - -pub async fn implementation( - server: &LspServer, - params: GotoDefinitionParams, -) -> Result> { - let uri = params.text_document_position_params.text_document.uri; - let position = params.text_document_position_params.position; - - let doc = match server.documents.get(&uri) { - Some(d) => d.clone(), - None => return Ok(None), - }; - - let engine_lock = server.engine.read().await; - let engine = match &*engine_lock { - Some(n) => n, - None => return Ok(None), - }; - let index = engine.graph(); - - // 1. Precise resolution using Semantic Resolver - let resolution = { - let resolver = match server.resolver.get_semantic_resolver(doc.language) { - Some(r) => r, - None => return Ok(None), - }; - let byte_col = crate::lsp::util::utf16_col_to_byte_col(&doc.content, position.line as usize, position.character as usize); - match resolver.resolve_at(&doc.tree, &doc.content, position.line as usize, byte_col, index) { - Some(r) => r, - None => return Ok(None), - } - }; - - let resolver = match server.resolver.get_semantic_resolver(doc.language) { - Some(r) => r, - None => return Ok(None), - }; - - let implementations = resolver.find_implementations(index, &resolution); - let mut locations = Vec::new(); - - for &node_idx in &implementations { - let node = &index.topology[node_idx]; - if let (Some(source_path), Some(range)) = (node.file_path(), node.range()) { - locations.push(Location { - uri: Url::from_file_path(source_path).unwrap(), - range: Range { - start: Position::new(range.start_line as u32, range.start_col as u32), - end: Position::new(range.end_line as u32, range.end_col as u32), - }, - }); - } - } - - if !locations.is_empty() { - return Ok(Some(GotoDefinitionResponse::Array(locations))); - } - - Ok(None) -} diff --git a/src/lsp/hierarchy.rs b/src/lsp/hierarchy.rs deleted file mode 100644 index 0d1c19f..0000000 --- a/src/lsp/hierarchy.rs +++ /dev/null @@ -1,193 +0,0 @@ -use tower_lsp::jsonrpc::Result; -use tower_lsp::lsp_types::*; -use crate::lsp::LspServer; -use crate::model::graph::{EdgeType, NodeKind}; - - -pub async fn prepare_call_hierarchy(server: &LspServer, params: CallHierarchyPrepareParams) -> Result>> { - let uri = params.text_document_position_params.text_document.uri; - let position = params.text_document_position_params.position; - - let doc = match server.documents.get(&uri) { - Some(d) => d.clone(), - None => return Ok(None), - }; - - let engine_lock = server.engine.read().await; - let engine = match engine_lock.as_ref() { - Some(n) => n, - None => return Ok(None), - }; - let index = engine.graph(); - - // 1. Precise resolution using Semantic Resolver - let resolution = { - let resolver = match server.resolver.get_semantic_resolver(doc.language) { - Some(r) => r, - None => return Ok(None), - }; - let byte_col = crate::lsp::util::utf16_col_to_byte_col(&doc.content, position.line as usize, position.character as usize); - match resolver.resolve_at(&doc.tree, &doc.content, position.line as usize, byte_col, index) { - Some(r) => r, - None => return Ok(None), - } - }; - - let mut items = Vec::new(); - let matches = { - let resolver = match server.resolver.get_semantic_resolver(doc.language) { - Some(r) => r, - None => return Ok(None), - }; - resolver.find_matches(index, &resolution) - }; - - for idx in matches { - let node = &index.topology[idx]; - let kind = node.kind(); - if kind == NodeKind::Method || kind == NodeKind::Constructor { - if let (Some(target_path), Some(range)) = (node.file_path(), node.range()) { - let lsp_range = Range { - start: Position::new(range.start_line as u32, range.start_col as u32), - end: Position::new(range.end_line as u32, range.end_col as u32), - }; - items.push(CallHierarchyItem { - name: node.name().to_string(), - kind: SymbolKind::METHOD, - tags: None, - detail: Some(node.fqn().to_string()), - uri: Url::from_file_path(target_path).unwrap(), - range: lsp_range, - selection_range: lsp_range, - data: Some(serde_json::to_value(node.fqn().to_string()).unwrap()), - }); - } - } - } - - if !items.is_empty() { - Ok(Some(items)) - } else { - Ok(None) - } -} - -pub async fn incoming_calls(server: &LspServer, params: CallHierarchyIncomingCallsParams) -> Result>> { - let fqn: String = serde_json::from_value(params.item.data.unwrap_or_default()).unwrap_or_default(); - if fqn.is_empty() { return Ok(None); } - - let engine_lock = server.engine.read().await; - let engine = match &*engine_lock { - Some(n) => n, - None => return Ok(None), - }; - - let index = engine.graph(); - let node_idx = match index.fqn_map.get(&fqn) { - Some(&idx) => idx, - None => return Ok(None), - }; - - let mut calls = Vec::new(); - let mut incoming = index.topology.neighbors_directed(node_idx, petgraph::Direction::Incoming).detach(); - - while let Some((edge_idx, neighbor_idx)) = incoming.next(&index.topology) { - let edge = &index.topology[edge_idx]; - if edge.edge_type == EdgeType::Calls { - let source_node = &index.topology[neighbor_idx]; - if let (Some(source_path), Some(range)) = (source_node.file_path(), source_node.range()) { - let lsp_range = Range { - start: Position::new(range.start_line as u32, range.start_col as u32), - end: Position::new(range.end_line as u32, range.end_col as u32), - }; - - let from_item = CallHierarchyItem { - name: source_node.name().to_string(), - kind: SymbolKind::METHOD, - tags: None, - detail: Some(source_node.fqn().to_string()), - uri: Url::from_file_path(source_path).unwrap(), - range: lsp_range, - selection_range: lsp_range, - data: Some(serde_json::to_value(source_node.fqn().to_string()).unwrap()), - }; - - let call_range = if let Some(r) = &edge.range { - Range { - start: Position::new(r.start_line as u32, r.start_col as u32), - end: Position::new(r.end_line as u32, r.end_col as u32), - } - } else { - lsp_range - }; - - calls.push(CallHierarchyIncomingCall { - from: from_item, - from_ranges: vec![call_range], - }); - } - } - } - - Ok(Some(calls)) -} - -pub async fn outgoing_calls(server: &LspServer, params: CallHierarchyOutgoingCallsParams) -> Result>> { - let fqn: String = serde_json::from_value(params.item.data.unwrap_or_default()).unwrap_or_default(); - if fqn.is_empty() { return Ok(None); } - - let engine_lock = server.engine.read().await; - let engine = match &*engine_lock { - Some(n) => n, - None => return Ok(None), - }; - - let index = engine.graph(); - let node_idx = match index.fqn_map.get(&fqn) { - Some(&idx) => idx, - None => return Ok(None), - }; - - let mut calls = Vec::new(); - let mut outgoing = index.topology.neighbors_directed(node_idx, petgraph::Direction::Outgoing).detach(); - - while let Some((edge_idx, neighbor_idx)) = outgoing.next(&index.topology) { - let edge = &index.topology[edge_idx]; - if edge.edge_type == EdgeType::Calls { - let target_node = &index.topology[neighbor_idx]; - if let (Some(target_path), Some(range)) = (target_node.file_path(), target_node.range()) { - let lsp_range = Range { - start: Position::new(range.start_line as u32, range.start_col as u32), - end: Position::new(range.end_line as u32, range.end_col as u32), - }; - - let to_item = CallHierarchyItem { - name: target_node.name().to_string(), - kind: SymbolKind::METHOD, - tags: None, - detail: Some(target_node.fqn().to_string()), - uri: Url::from_file_path(target_path).unwrap(), - range: lsp_range, - selection_range: lsp_range, - data: Some(serde_json::to_value(target_node.fqn().to_string()).unwrap()), - }; - - let call_range = if let Some(r) = &edge.range { - Range { - start: Position::new(r.start_line as u32, r.start_col as u32), - end: Position::new(r.end_line as u32, r.end_col as u32), - } - } else { - lsp_range - }; - - calls.push(CallHierarchyOutgoingCall { - to: to_item, - from_ranges: vec![call_range], - }); - } - } - } - - Ok(Some(calls)) -} diff --git a/src/lsp/highlight.rs b/src/lsp/highlight.rs deleted file mode 100644 index 0c8a2f5..0000000 --- a/src/lsp/highlight.rs +++ /dev/null @@ -1,56 +0,0 @@ -use tower_lsp::jsonrpc::Result; -use tower_lsp::lsp_types::*; -use crate::lsp::LspServer; - -pub async fn highlight(server: &LspServer, params: DocumentHighlightParams) -> Result>> { - let uri = params.text_document_position_params.text_document.uri; - let position = params.text_document_position_params.position; - - let doc = match server.documents.get(&uri) { - Some(d) => d.clone(), - None => return Ok(None), - }; - - // 1. Get the word under cursor precisely - let word = crate::lsp::util::find_node_at(&doc.tree, &doc.content, position.line as usize, position.character as usize) - .and_then(|node| { - if node.kind() == "identifier" || node.kind() == "type_identifier" { - doc.content.get(node.byte_range()) - } else { - None - } - }) - .map(|s| s.to_string()); - - let word = match word { - Some(w) => w, - None => return Ok(None), - }; - - // 2. Find all occurrences of this word in the current file's AST - let mut highlights = Vec::new(); - let language = doc.tree.language(); - let query_str = format!("((identifier) @ident (#eq? @ident \"{}\")) ((type_identifier) @ident (#eq? @ident \"{}\"))", word, word); - if let Ok(query) = tree_sitter::Query::new(&language, &query_str) { - let mut cursor = tree_sitter::QueryCursor::new(); - let matches = cursor.matches(&query, doc.tree.root_node(), doc.content.as_bytes()); - - use tree_sitter::StreamingIterator; - let mut matches = matches; - while let Some(mat) = matches.next() { - for cap in mat.captures { - let range = cap.node.range(); - highlights.push(DocumentHighlight { - range: crate::lsp::util::to_lsp_range(range, &doc.content), - kind: Some(DocumentHighlightKind::TEXT), - }); - } - } - } - - if highlights.is_empty() { - Ok(None) - } else { - Ok(Some(highlights)) - } -} diff --git a/src/lsp/hover.rs b/src/lsp/hover.rs deleted file mode 100644 index 5570fb2..0000000 --- a/src/lsp/hover.rs +++ /dev/null @@ -1,131 +0,0 @@ -use tower_lsp::jsonrpc::Result; -use tower_lsp::lsp_types::*; -use crate::lsp::LspServer; -use crate::model::graph::{GraphNode, CodeElement, BuildElement}; -use crate::model::signature::TypeRef; -use crate::parser::SymbolResolution; - -fn fmt_type(t: &TypeRef) -> String { - match t { - TypeRef::Raw(s) => s.clone(), - TypeRef::Id(s) => s.split('.').last().unwrap_or(s).to_string(), - TypeRef::Generic { base, args } => { - let args_str = args.iter().map(fmt_type).collect::>().join(", "); - format!("{}<{}>", fmt_type(base), args_str) - }, - TypeRef::Array { element, dimensions } => { - format!("{}{}", fmt_type(element), "[]".repeat(*dimensions)) - }, - _ => "?".to_string(), - } -} - -fn get_node_signature(node: &GraphNode) -> Option { - match node { - GraphNode::Code(code_el) => match code_el { - CodeElement::Java { element, .. } => match element { - crate::model::lang::java::JavaElement::Method(m) => { - let params_str = m.parameters.iter() - .map(|p| format!("{}", fmt_type(&p.type_ref))) - .collect::>() - .join(", "); - let return_type_str = fmt_type(&m.return_type); - Some(format!("({}) -> {}", params_str, return_type_str)) - } - crate::model::lang::java::JavaElement::Field(f) => { - Some(format!("{} {}", fmt_type(&f.type_ref), f.name)) - } - _ => None, - }, - }, - GraphNode::Build(build_el) => match build_el { - BuildElement::Gradle { element, .. } => match element { - crate::model::lang::gradle::GradleElement::Dependency(d) => { - let group = d.group.as_deref().unwrap_or("?"); - let version = d.version.as_deref().unwrap_or("?"); - Some(format!("{}:{}:{}", group, d.name, version)) - } - _ => None, - }, - }, - } -} - -pub async fn hover(server: &LspServer, params: HoverParams) -> Result> { - let uri = params.text_document_position_params.text_document.uri; - let position = params.text_document_position_params.position; - - let doc = match server.documents.get(&uri) { - Some(d) => d.clone(), - None => return Ok(None), - }; - - let engine_lock = server.engine.read().await; - let engine = match engine_lock.as_ref() { - Some(e) => e, - None => return Ok(None), - }; - let index = engine.graph(); - - // 1. Precise resolution using Semantic Resolver - let resolution = { - let resolver = match server.resolver.get_semantic_resolver(doc.language) { - Some(r) => r, - None => return Ok(None), - }; - let byte_col = crate::lsp::util::utf16_col_to_byte_col(&doc.content, position.line as usize, position.character as usize); - match resolver.resolve_at(&doc.tree, &doc.content, position.line as usize, byte_col, index) { - Some(r) => r, - None => return Ok(None), - } - }; - - if let SymbolResolution::Local(_, _) = resolution { - return Ok(Some(Hover { - contents: HoverContents::Scalar(MarkedString::String("**Local variable**".to_string())), - range: None, - })); - } - - let mut hover_text = String::new(); - let matches = { - let resolver = match server.resolver.get_semantic_resolver(doc.language) { - Some(r) => r, - None => return Ok(None), - }; - resolver.find_matches(index, &resolution) - }; - - for &idx in &matches { - let node = &index.topology[idx]; - if !hover_text.is_empty() { - hover_text.push_str("\n\n---\n\n"); - } - - // Method/Field name as title - hover_text.push_str(&format!("**{}** *{}*\n\n", node.name(), node.kind().to_string())); - - // Signature in code block - if let Some(sig) = get_node_signature(node) { - hover_text.push_str(&format!("```java\n{}\n```\n", sig)); - } - - // Metadata: FQN only - hover_text.push_str(&format!("\n*`{}`*", node.fqn())); - } - - if hover_text.is_empty() { - if let SymbolResolution::Precise(fqn, _) = resolution { - hover_text.push_str(&format!("**External Reference**\n\n*`{}`*", fqn)); - } - } - - if !hover_text.is_empty() { - return Ok(Some(Hover { - contents: HoverContents::Scalar(MarkedString::String(hover_text)), - range: None, - })); - } - - Ok(None) -} diff --git a/src/lsp/indexer.rs b/src/lsp/indexer.rs deleted file mode 100644 index cff62a4..0000000 --- a/src/lsp/indexer.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::RwLock; -use tower_lsp::Client; -use tower_lsp::lsp_types::MessageType; -use crate::index::Naviscope; -use crate::project::is_relevant_path; - -pub fn spawn_indexer( - path: PathBuf, - client: Client, - engine_lock: Arc>>, -) { - tokio::spawn(async move { - let start = std::time::Instant::now(); - client.log_message(MessageType::INFO, format!("Naviscope indexing started for {:?}", path)).await; - let mut navi = Naviscope::new(path.clone()); - - // 1. Initial full index - let (res, n) = { - let mut n = navi; - tokio::task::spawn_blocking(move || { - let res = n.build_index(); - (res, n) - }).await.expect("Indexer task panicked") - }; - navi = n; - - if let Err(e) = res { - client.log_message(MessageType::ERROR, format!("Initial indexing failed: {}", e)).await; - } else { - let duration = start.elapsed(); - let stats = { - let n = navi.graph().topology.node_count(); - let e = navi.graph().topology.edge_count(); - format!("Initial indexing complete in {:?}: {} nodes, {} edges", duration, n, e) - }; - client.log_message(MessageType::INFO, stats).await; - - // Publish the initial index - { - let mut lock = engine_lock.write().await; - *lock = Some(navi.clone()); - } - } - - // 2. Setup file watcher - use crate::project::watcher::Watcher; - let mut watcher = match Watcher::new(&path) { - Ok(w) => w, - Err(e) => { - client.log_message(MessageType::ERROR, format!("Failed to start file watcher: {}", e)).await; - return; - } - }; - - client.log_message(MessageType::INFO, "File watcher active. Real-time indexing enabled.").await; - - // 3. Watcher loop with debouncing - while let Some(res) = watcher.rx.recv().await { - let event = match res { - Ok(e) => e, - Err(_) => continue, - }; - - // Filter relevant paths - if !event.paths.iter().any(|p| is_relevant_path(p)) { - continue; - } - - // Debounce: wait for 500ms of quiet after the last event - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - // Drain any pending events - while let Ok(_) = watcher.rx.try_recv() {} - - client.log_message(MessageType::INFO, "Change detected on disk, re-indexing...").await; - let start = std::time::Instant::now(); - - let (res, n) = { - let mut n = navi; - tokio::task::spawn_blocking(move || { - let res = n.build_index(); - (res, n) - }).await.expect("Indexer task panicked") - }; - navi = n; - - if let Err(e) = res { - client.log_message(MessageType::ERROR, format!("Incremental re-indexing failed: {}", e)).await; - } else { - let duration = start.elapsed(); - let n = navi.graph().topology.node_count(); - let e = navi.graph().topology.edge_count(); - client.log_message(MessageType::INFO, format!("Re-indexing complete in {:?}. Total: {} nodes, {} edges", duration, n, e)).await; - - // Publish updated index - let mut lock = engine_lock.write().await; - *lock = Some(navi.clone()); - } - } - }); -} diff --git a/src/lsp/mod.rs b/src/lsp/mod.rs deleted file mode 100644 index 8da3ef1..0000000 --- a/src/lsp/mod.rs +++ /dev/null @@ -1,410 +0,0 @@ -pub mod capabilities; -pub mod goto; -pub mod hierarchy; -pub mod highlight; -pub mod hover; -pub mod symbols; -pub mod util; -pub mod indexer; - -use tower_lsp::jsonrpc::Result; -use tower_lsp::lsp_types::*; -use tower_lsp::{Client, LanguageServer}; -use crate::index::Naviscope; -use std::sync::Arc; -use tokio::sync::RwLock; -use dashmap::DashMap; -use crate::lsp::util::Document; -use std::path::PathBuf; -use tokio_util::sync::CancellationToken; - -pub struct LspServer { - client: Client, - pub engine: Arc>>, - pub documents: DashMap>, - pub resolver: Arc, - session_path: Arc>>, - cancel_token: CancellationToken, -} - -impl LspServer { - pub fn new(client: Client) -> Self { - Self { - client, - engine: Arc::new(RwLock::new(None)), - documents: DashMap::new(), - resolver: Arc::new(crate::resolver::engine::IndexResolver::new()), - session_path: Arc::new(RwLock::new(None)), - cancel_token: CancellationToken::new(), - } - } - - pub fn get_parser_and_lang_for_uri(&self, uri: &Url) -> Option<(Arc, crate::project::source::Language)> { - let path = uri.to_file_path().ok()?; - let ext = path.extension()?.to_str()?; - let lang = self.resolver.get_language_by_extension(ext)?; - let parser = self.resolver.get_lsp_parser(lang)?; - Some((parser, lang)) - } - - fn point_at(&self, text: &str, offset: usize) -> tree_sitter::Point { - let mut row = 0; - let mut col = 0; - for (i, c) in text.char_indices() { - if i >= offset { - break; - } - if c == '\n' { - row += 1; - col = 0; - } else { - col += c.len_utf8(); - } - } - tree_sitter::Point::new(row, col) - } - - fn offset_at(&self, text: &str, position: Position) -> usize { - let mut line = 0; - let mut offset = 0; - let mut chars = text.chars().peekable(); - - while line < position.line as usize { - if let Some(c) = chars.next() { - offset += c.len_utf8(); - if c == '\n' { - line += 1; - } else if c == '\r' { - if chars.peek() == Some(&'\n') { - offset += chars.next().unwrap().len_utf8(); - } - line += 1; - } - } else { - return offset; - } - } - - let mut utf16_count = 0; - while utf16_count < position.character as usize { - if let Some(c) = chars.next() { - if c == '\n' || c == '\r' { - break; - } - utf16_count += c.len_utf16(); - offset += c.len_utf8(); - } else { - break; - } - } - offset - } -} - -#[tower_lsp::async_trait] -impl LanguageServer for LspServer { - async fn initialize(&self, params: InitializeParams) -> Result { - let root_path = params.root_uri.and_then(|uri| uri.to_file_path().ok()); - - if let Some(path) = root_path { - indexer::spawn_indexer( - path.clone(), - self.client.clone(), - self.engine.clone(), - ); - - // Start MCP HTTP Server via encapsulated helper - crate::mcp::http::spawn_http_server( - self.client.clone(), - self.engine.clone(), - path, - self.session_path.clone(), - params.client_info.map(|i| i.name), - self.cancel_token.clone(), - ); - } - - Ok(InitializeResult { - server_info: Some(ServerInfo { - name: "Naviscope".to_string(), - version: Some(env!("CARGO_PKG_VERSION").to_string()), - }), - capabilities: capabilities::server_capabilities(), - }) - } - - async fn shutdown(&self) -> Result<()> { - self.cancel_token.cancel(); - let mut lock = self.session_path.write().await; - if let Some(path) = lock.take() { - let _ = std::fs::remove_file(path); - } - Ok(()) - } - - async fn did_open(&self, params: DidOpenTextDocumentParams) { - let uri = params.text_document.uri; - self.client.log_message(MessageType::LOG, format!("LSP Event: did_open uri={}", uri)).await; - let content = params.text_document.text; - - if let Some((parser, lang)) = self.get_parser_and_lang_for_uri(&uri) { - if let Some(tree) = parser.parse(&content, None) { - self.documents.insert(uri, Arc::new(Document::new(content, tree, parser, lang))); - } - } - } - - async fn did_change(&self, params: DidChangeTextDocumentParams) { - let uri = params.text_document.uri; - self.client.log_message(MessageType::LOG, format!("LSP Event: did_change uri={}", uri)).await; - let (mut content, mut tree, parser, lang) = { - let state = match self.documents.get(&uri) { - Some(s) => s, - None => return, - }; - (state.content.clone(), state.tree.clone(), state.parser.clone(), state.language.clone()) - }; - - for change in params.content_changes { - if let Some(range) = change.range { - let start_byte = self.offset_at(&content, range.start); - let old_end_byte = self.offset_at(&content, range.end); - - let start_point = tree_sitter::Point::new( - range.start.line as usize, - util::utf16_col_to_byte_col(&content, range.start.line as usize, range.start.character as usize) - ); - let old_end_point = tree_sitter::Point::new( - range.end.line as usize, - util::utf16_col_to_byte_col(&content, range.end.line as usize, range.end.character as usize) - ); - - content.replace_range(start_byte..old_end_byte, &change.text); - - let new_end_byte = start_byte + change.text.len(); - let new_end_point = self.point_at(&content, new_end_byte); - - let edit = tree_sitter::InputEdit { - start_byte, - old_end_byte, - new_end_byte, - start_position: start_point, - old_end_position: old_end_point, - new_end_position: new_end_point, - }; - tree.edit(&edit); - } else { - content = change.text; - if let Some(new_tree) = parser.parse(&content, None) { - tree = new_tree; - } - } - } - - if let Some(new_tree) = parser.parse(&content, Some(&tree)) { - tree = new_tree; - } - - self.documents.insert(uri, Arc::new(Document::new(content, tree, parser, lang))); - } - - async fn did_close(&self, params: DidCloseTextDocumentParams) { - self.client.log_message(MessageType::LOG, format!("LSP Event: did_close uri={}", params.text_document.uri)).await; - self.documents.remove(¶ms.text_document.uri); - } - - async fn hover(&self, params: HoverParams) -> Result> { - let uri = ¶ms.text_document_position_params.text_document.uri; - let pos = params.text_document_position_params.position; - self.client.log_message( - MessageType::LOG, - format!("LSP Request: textDocument/hover uri={} pos={}:{}", uri, pos.line, pos.character) - ).await; - let result = hover::hover(self, params).await; - match &result { - Ok(Some(_)) => self.client.log_message(MessageType::LOG, "LSP Response: found hover content").await, - Ok(None) => self.client.log_message(MessageType::LOG, "LSP Response: no hover content").await, - Err(e) => self.client.log_message(MessageType::ERROR, format!("LSP Error: {}", e)).await, - } - result - } - - async fn document_highlight( - &self, - params: DocumentHighlightParams, - ) -> Result>> { - let uri = ¶ms.text_document_position_params.text_document.uri; - let pos = params.text_document_position_params.position; - self.client.log_message( - MessageType::LOG, - format!("LSP Request: textDocument/documentHighlight uri={} pos={}:{}", uri, pos.line, pos.character) - ).await; - let result = highlight::highlight(self, params).await; - if let Ok(Some(h)) = &result { - self.client.log_message(MessageType::LOG, format!("LSP Response: found {} highlights", h.len())).await; - } - result - } - - async fn goto_definition( - &self, - params: GotoDefinitionParams, - ) -> Result> { - let uri = ¶ms.text_document_position_params.text_document.uri; - let pos = params.text_document_position_params.position; - self.client.log_message( - MessageType::LOG, - format!("LSP Request: textDocument/definition uri={} pos={}:{}", uri, pos.line, pos.character) - ).await; - let result = goto::definition(self, params).await; - match &result { - Ok(Some(resp)) => { - let count = match resp { - GotoDefinitionResponse::Scalar(_) => 1, - GotoDefinitionResponse::Array(v) => v.len(), - GotoDefinitionResponse::Link(v) => v.len(), - }; - self.client.log_message(MessageType::LOG, format!("LSP Response: found {} locations", count)).await; - } - Ok(None) => self.client.log_message(MessageType::LOG, "LSP Response: no definition found").await, - Err(e) => self.client.log_message(MessageType::ERROR, format!("LSP Error: {}", e)).await, - } - result - } - - async fn references(&self, params: ReferenceParams) -> Result>> { - let uri = ¶ms.text_document_position.text_document.uri; - let pos = params.text_document_position.position; - self.client.log_message( - MessageType::LOG, - format!("LSP Request: textDocument/references uri={} pos={}:{}", uri, pos.line, pos.character) - ).await; - let result = goto::references(self, params).await; - if let Ok(Some(locs)) = &result { - self.client.log_message(MessageType::LOG, format!("LSP Response: found {} references", locs.len())).await; - } - result - } - - async fn document_symbol( - &self, - params: DocumentSymbolParams, - ) -> Result> { - self.client.log_message( - MessageType::LOG, - format!("LSP Request: textDocument/documentSymbol uri={}", params.text_document.uri) - ).await; - let result = symbols::document_symbol(self, params).await; - if let Ok(Some(resp)) = &result { - let count = match resp { - DocumentSymbolResponse::Flat(v) => v.len(), - DocumentSymbolResponse::Nested(v) => v.len(), - }; - self.client.log_message(MessageType::LOG, format!("LSP Response: found {} symbols", count)).await; - } - result - } - - async fn symbol( - &self, - params: WorkspaceSymbolParams, - ) -> Result>> { - self.client.log_message(MessageType::LOG, format!("LSP Request: workspace/symbol query='{}'", params.query)).await; - let result = symbols::workspace_symbol(self, params).await; - if let Ok(Some(syms)) = &result { - self.client.log_message(MessageType::LOG, format!("LSP Response: found {} symbols", syms.len())).await; - } - result - } - - async fn goto_implementation( - &self, - params: GotoDefinitionParams, - ) -> Result> { - let uri = ¶ms.text_document_position_params.text_document.uri; - let pos = params.text_document_position_params.position; - self.client.log_message( - MessageType::LOG, - format!("LSP Request: textDocument/implementation uri={} pos={}:{}", uri, pos.line, pos.character) - ).await; - let result = goto::implementation(self, params).await; - if let Ok(Some(_)) = &result { - self.client.log_message(MessageType::LOG, "LSP Response: found implementations").await; - } - result - } - - async fn goto_type_definition( - &self, - params: GotoDefinitionParams, - ) -> Result> { - let uri = ¶ms.text_document_position_params.text_document.uri; - let pos = params.text_document_position_params.position; - self.client.log_message( - MessageType::LOG, - format!("LSP Request: textDocument/typeDefinition uri={} pos={}:{}", uri, pos.line, pos.character) - ).await; - let result = goto::type_definition(self, params).await; - if let Ok(Some(_)) = &result { - self.client.log_message(MessageType::LOG, "LSP Response: found type definitions").await; - } - result - } - - async fn prepare_call_hierarchy( - &self, - params: CallHierarchyPrepareParams, - ) -> Result>> { - let uri = ¶ms.text_document_position_params.text_document.uri; - let pos = params.text_document_position_params.position; - self.client.log_message( - MessageType::LOG, - format!("LSP Request: textDocument/prepareCallHierarchy uri={} pos={}:{}", uri, pos.line, pos.character) - ).await; - let result = hierarchy::prepare_call_hierarchy(self, params).await; - if let Ok(Some(items)) = &result { - self.client.log_message(MessageType::LOG, format!("LSP Response: prepared {} items", items.len())).await; - } - result - } - - async fn incoming_calls( - &self, - params: CallHierarchyIncomingCallsParams, - ) -> Result>> { - self.client.log_message( - MessageType::LOG, - format!("LSP Request: callHierarchy/incomingCalls item={}", params.item.name) - ).await; - let result = hierarchy::incoming_calls(self, params).await; - if let Ok(Some(calls)) = &result { - self.client.log_message(MessageType::LOG, format!("LSP Response: found {} incoming calls", calls.len())).await; - } - result - } - - async fn outgoing_calls( - &self, - params: CallHierarchyOutgoingCallsParams, - ) -> Result>> { - self.client.log_message( - MessageType::LOG, - format!("LSP Request: callHierarchy/outgoingCalls item={}", params.item.name) - ).await; - let result = hierarchy::outgoing_calls(self, params).await; - if let Ok(Some(calls)) = &result { - self.client.log_message(MessageType::LOG, format!("LSP Response: found {} outgoing calls", calls.len())).await; - } - result - } -} - -pub async fn run_server() -> std::result::Result<(), Box> { - let stdin = tokio::io::stdin(); - let stdout = tokio::io::stdout(); - - let (service, socket) = tower_lsp::LspService::new(|client| LspServer::new(client)); - tower_lsp::Server::new(stdin, stdout, socket).serve(service).await; - - Ok(()) -} diff --git a/src/lsp/symbols.rs b/src/lsp/symbols.rs deleted file mode 100644 index cc05470..0000000 --- a/src/lsp/symbols.rs +++ /dev/null @@ -1,92 +0,0 @@ -use tower_lsp::jsonrpc::Result; -use tower_lsp::lsp_types::*; -use crate::lsp::LspServer; - -pub async fn document_symbol(server: &LspServer, params: DocumentSymbolParams) -> Result> { - let uri = params.text_document.uri; - - // 1. Try real-time AST-based symbols first (supports unsaved changes) - if let Some(doc) = server.documents.get(&uri) { - let symbols = doc.parser.extract_symbols(&doc.tree, &doc.content); - if !symbols.is_empty() { - let lsp_symbols = convert_symbols(symbols, doc.parser.as_ref()); - return Ok(Some(DocumentSymbolResponse::Nested(lsp_symbols))); - } - } - - Ok(None) -} - -fn convert_symbols(symbols: Vec, parser: &dyn crate::parser::LspParser) -> Vec { - symbols.into_iter().map(|s| convert_symbol(s, parser)).collect() -} - -fn convert_symbol(sym: crate::parser::DocumentSymbol, parser: &dyn crate::parser::LspParser) -> DocumentSymbol { - let range = Range { - start: Position::new(sym.range.start_line as u32, sym.range.start_col as u32), - end: Position::new(sym.range.end_line as u32, sym.range.end_col as u32), - }; - let selection_range = Range { - start: Position::new(sym.selection_range.start_line as u32, sym.selection_range.start_col as u32), - end: Position::new(sym.selection_range.end_line as u32, sym.selection_range.end_col as u32), - }; - - #[allow(deprecated)] - DocumentSymbol { - name: sym.name, - detail: None, - kind: parser.symbol_kind(&sym.kind), - tags: None, - deprecated: None, - range, - selection_range, - children: if sym.children.is_empty() { - None - } else { - Some(convert_symbols(sym.children, parser)) - }, - } -} - -pub async fn workspace_symbol(server: &LspServer, params: WorkspaceSymbolParams) -> Result>> { - let engine_lock = server.engine.read().await; - let engine = match &*engine_lock { - Some(n) => n, - None => return Ok(None), - }; - - let index = engine.graph(); - let query = params.query.to_lowercase(); - let mut symbols = Vec::new(); - - for node in index.topology.node_weights() { - if node.name().to_lowercase().contains(&query) || node.fqn().to_string().to_lowercase().contains(&query) { - if let (Some(path), Some(range)) = (node.file_path(), node.range()) { - let kind = server.resolver.get_lsp_parser(node.language()) - .map(|parser| parser.symbol_kind(&node.kind())) - .unwrap_or(SymbolKind::VARIABLE); - - #[allow(deprecated)] - symbols.push(SymbolInformation { - name: node.name().to_string(), - kind, - tags: None, - deprecated: None, - location: Location { - uri: Url::from_file_path(path).unwrap(), - range: Range { - start: Position::new(range.start_line as u32, range.start_col as u32), - end: Position::new(range.end_line as u32, range.end_col as u32), - }, - }, - container_name: Some(node.fqn().to_string()), - }); - } - } - if symbols.len() >= 100 { - break; - } - } - - Ok(Some(symbols)) -} diff --git a/src/lsp/util.rs b/src/lsp/util.rs deleted file mode 100644 index 4de06e3..0000000 --- a/src/lsp/util.rs +++ /dev/null @@ -1,127 +0,0 @@ -use crate::parser::LspParser; -use crate::project::source::Language; -use std::path::PathBuf; -use std::sync::Arc; -use tower_lsp::lsp_types::Url; -use tree_sitter::Tree; - -pub fn uri_to_path(uri: &Url) -> Option { - uri.to_file_path().ok() -} - -/// Lightweight container for document state -pub struct Document { - pub content: String, - pub tree: Tree, - pub parser: Arc, - pub language: Language, -} - -impl Document { - pub fn new(content: String, tree: Tree, parser: Arc, language: Language) -> Self { - Self { - content, - tree, - parser, - language, - } - } -} - -pub fn utf16_col_to_byte_col(content: &str, line: usize, utf16_col: usize) -> usize { - let line_content = content.lines().nth(line).unwrap_or(""); - let mut curr_utf16 = 0; - let mut curr_byte = 0; - - for c in line_content.chars() { - if curr_utf16 >= utf16_col { - break; - } - curr_utf16 += c.len_utf16(); - curr_byte += c.len_utf8(); - } - curr_byte -} - -pub fn find_node_at<'a>(tree: &'a Tree, content: &str, line: usize, utf16_col: usize) -> Option> { - let root = tree.root_node(); - let byte_col = utf16_col_to_byte_col(content, line, utf16_col); - let point = tree_sitter::Point::new(line, byte_col); - let node = root.named_descendant_for_point_range(point, point)?; - - // If the node is not an identifier but we are at the end of one, try moving left by 1 byte - if node.kind() != "identifier" - && node.kind() != "type_identifier" - && node.kind() != "scoped_identifier" - && byte_col > 0 - { - let prev_point = tree_sitter::Point::new(line, byte_col - 1); - if let Some(prev_node) = root.named_descendant_for_point_range(prev_point, prev_point) { - if prev_node.kind() == "identifier" - || prev_node.kind() == "type_identifier" - || prev_node.kind() == "scoped_identifier" - { - return Some(prev_node); - } - } - } - - Some(node) -} - -pub fn get_word_at(path: &std::path::Path, line: usize, col: usize) -> Option { - let content = std::fs::read_to_string(path).ok()?; - get_word_from_content(&content, line, col) -} - -pub fn get_word_from_content(content: &str, line: usize, col: usize) -> Option { - let line_content = content.lines().nth(line)?; - - // Find the start and end of the word (alphanumeric + _ + $) - let is_ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$'; - - let start = line_content[..col.min(line_content.len())] - .rfind(|c| !is_ident(c)) - .map(|i| i + 1) - .unwrap_or(0); - - let end = line_content[col..] - .find(|c| !is_ident(c)) - .map(|i| i + col) - .unwrap_or(line_content.len()); - - if start < end { - Some(line_content[start..end].to_string()) - } else { - None - } -} - -pub fn to_lsp_range(range: tree_sitter::Range, content: &str) -> tower_lsp::lsp_types::Range { - tower_lsp::lsp_types::Range { - start: to_lsp_position(range.start_point, content), - end: to_lsp_position(range.end_point, content), - } -} - -pub fn to_lsp_position(point: tree_sitter::Point, content: &str) -> tower_lsp::lsp_types::Position { - let line_idx = point.row; - let byte_col = point.column; - - // Use split_terminator to handle all types of line endings and get the line efficiently - let line_content = content.split_terminator('\n').nth(line_idx).unwrap_or(""); - let line_content = line_content.trim_end_matches('\r'); - - let mut utf16_col = 0; - let mut curr_byte = 0; - - for c in line_content.chars() { - if curr_byte >= byte_col { - break; - } - curr_byte += c.len_utf8(); - utf16_col += c.len_utf16(); - } - - tower_lsp::lsp_types::Position::new(line_idx as u32, utf16_col as u32) -} diff --git a/src/model/graph.rs b/src/model/graph.rs deleted file mode 100644 index 47747f1..0000000 --- a/src/model/graph.rs +++ /dev/null @@ -1,258 +0,0 @@ -use super::lang::gradle::GradleElement; -use super::lang::java::JavaElement; -use serde::{Deserialize, Serialize}; -use clap::ValueEnum; -use schemars::JsonSchema; - -use crate::project::source::Language; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)] -pub struct Range { - pub start_line: usize, - pub start_col: usize, - pub end_line: usize, - pub end_col: usize, -} - -impl Range { - pub fn contains(&self, line: usize, col: usize) -> bool { - if line < self.start_line || line > self.end_line { - return false; - } - if line == self.start_line && col < self.start_col { - return false; - } - if line == self.end_line && col > self.end_col { - return false; - } - true - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema, ValueEnum)] -#[serde(rename_all = "lowercase")] -pub enum NodeKind { - Class, - Interface, - Enum, - Annotation, - Method, - Constructor, - Field, - Package, - // Build specific - Module, - Dependency, - Task, - Plugin, - // Fallback - Other, -} - -impl From<&str> for NodeKind { - fn from(s: &str) -> Self { - match s.to_lowercase().as_str() { - "class" => NodeKind::Class, - "interface" => NodeKind::Interface, - "enum" => NodeKind::Enum, - "annotation" => NodeKind::Annotation, - "method" => NodeKind::Method, - "constructor" => NodeKind::Constructor, - "field" => NodeKind::Field, - "package" => NodeKind::Package, - "module" => NodeKind::Module, - "dependency" => NodeKind::Dependency, - "task" => NodeKind::Task, - "plugin" => NodeKind::Plugin, - _ => NodeKind::Other, - } - } -} - -impl ToString for NodeKind { - fn to_string(&self) -> String { - match self { - NodeKind::Class => "class".to_string(), - NodeKind::Interface => "interface".to_string(), - NodeKind::Enum => "enum".to_string(), - NodeKind::Annotation => "annotation".to_string(), - NodeKind::Method => "method".to_string(), - NodeKind::Constructor => "constructor".to_string(), - NodeKind::Field => "field".to_string(), - NodeKind::Package => "package".to_string(), - NodeKind::Module => "module".to_string(), - NodeKind::Dependency => "dependency".to_string(), - NodeKind::Task => "task".to_string(), - NodeKind::Plugin => "plugin".to_string(), - NodeKind::Other => "other".to_string(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum GraphNode { - Code(CodeElement), - Build(BuildElement), -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum CodeElement { - Java { - element: JavaElement, - file_path: Option, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum BuildElement { - Gradle { - element: GradleElement, - file_path: Option, - }, -} - -impl GraphNode { - pub fn language(&self) -> Language { - match self { - GraphNode::Code(CodeElement::Java { .. }) => Language::Java, - GraphNode::Build(BuildElement::Gradle { .. }) => Language::BuildFile, - } - } - - pub fn fqn(&self) -> &str { - match self { - GraphNode::Code(CodeElement::Java { element, .. }) => element.id(), - GraphNode::Build(BuildElement::Gradle { element, .. }) => element.id(), - } - } - - pub fn name(&self) -> &str { - match self { - GraphNode::Code(CodeElement::Java { element, .. }) => element.name(), - GraphNode::Build(BuildElement::Gradle { element, .. }) => element.name(), - } - } - - pub fn kind(&self) -> NodeKind { - match self { - GraphNode::Code(CodeElement::Java { element, .. }) => match element { - JavaElement::Class(_) => NodeKind::Class, - JavaElement::Interface(_) => NodeKind::Interface, - JavaElement::Enum(_) => NodeKind::Enum, - JavaElement::Annotation(_) => NodeKind::Annotation, - JavaElement::Method(m) => if m.is_constructor { NodeKind::Constructor } else { NodeKind::Method }, - JavaElement::Field(_) => NodeKind::Field, - JavaElement::Package(_) => NodeKind::Package, - }, - GraphNode::Build(BuildElement::Gradle { element, .. }) => NodeKind::from(element.kind()), - } - } - - pub fn file_path(&self) -> Option<&PathBuf> { - match self { - GraphNode::Code(CodeElement::Java { file_path, .. }) => file_path.as_ref(), - GraphNode::Build(BuildElement::Gradle { file_path, .. }) => file_path.as_ref(), - } - } - - pub fn range(&self) -> Option<&Range> { - match self { - GraphNode::Code(CodeElement::Java { element, .. }) => element.range(), - GraphNode::Build(_) => None, - } - } - - pub fn name_range(&self) -> Option<&Range> { - match self { - GraphNode::Code(CodeElement::Java { element, .. }) => element.name_range(), - GraphNode::Build(_) => None, - } - } - - pub fn java(element: JavaElement, file_path: Option) -> Self { - GraphNode::Code(CodeElement::Java { element, file_path }) - } - - pub fn gradle(element: GradleElement, file_path: Option) -> Self { - GraphNode::Build(BuildElement::Gradle { element, file_path }) - } -} - -/// Graph operation commands that can be computed in parallel -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum GraphOp { - /// Add or update a node - AddNode { id: String, data: GraphNode }, - /// Add an edge between two nodes (referenced by their IDs) - AddEdge { - from_id: String, - to_id: String, - edge: GraphEdge, - }, - /// Remove all nodes and edges associated with a specific file path - RemovePath { path: PathBuf }, -} - -/// Result of resolving a single file -#[derive(Debug)] -pub struct ResolvedUnit { - /// The operations needed to integrate this file into the graph - pub ops: Vec, -} - -impl ResolvedUnit { - pub fn new() -> Self { - Self { ops: Vec::new() } - } - - pub fn add_node(&mut self, id: String, data: GraphNode) { - self.ops.push(GraphOp::AddNode { id, data }); - } - - pub fn add_edge(&mut self, from_id: String, to_id: String, edge: GraphEdge) { - self.ops.push(GraphOp::AddEdge { - from_id, - to_id, - edge, - }); - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema, ValueEnum)] -pub enum EdgeType { - // Structural relationships - Contains, - // Inheritance/Implementation - InheritsFrom, - Implements, - // Usage/Reference - Calls, - Instantiates, - TypedAs, - DecoratedBy, - // Build system relationships - UsesDependency, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] -pub struct GraphEdge { - pub edge_type: EdgeType, - pub range: Option, -} - -impl GraphEdge { - pub fn new(edge_type: EdgeType) -> Self { - Self { - edge_type, - range: None, - } - } - - pub fn with_range(edge_type: EdgeType, range: Range) -> Self { - Self { - edge_type, - range: Some(range), - } - } -} diff --git a/src/model/lang/gradle.rs b/src/model/lang/gradle.rs deleted file mode 100644 index 78b09d9..0000000 --- a/src/model/lang/gradle.rs +++ /dev/null @@ -1,56 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum GradleElement { - Module(GradleModule), - Dependency(GradleDependency), -} - -impl GradleElement { - pub fn id(&self) -> &str { - match self { - GradleElement::Module(m) => &m.id, - GradleElement::Dependency(d) => &d.id, - } - } - - pub fn name(&self) -> &str { - match self { - GradleElement::Module(m) => &m.name, - GradleElement::Dependency(d) => &d.name, - } - } - - pub fn kind(&self) -> &str { - match self { - GradleElement::Module(_) => "module", - GradleElement::Dependency(_) => "dependency", - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct GradleModule { - pub name: String, - pub id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct GradleDependency { - pub group: Option, - pub name: String, - pub version: Option, - pub is_project: bool, - pub id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct GradleParseResult { - pub dependencies: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct GradleSettings { - pub root_project_name: Option, - pub included_projects: Vec, -} diff --git a/src/model/lang/java.rs b/src/model/lang/java.rs deleted file mode 100644 index 81276a2..0000000 --- a/src/model/lang/java.rs +++ /dev/null @@ -1,135 +0,0 @@ -use crate::model::graph::Range; -use crate::model::signature::TypeRef; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum JavaElement { - Class(JavaClass), - Interface(JavaInterface), - Enum(JavaEnum), - Annotation(JavaAnnotation), - Method(JavaMethod), - Field(JavaField), - Package(JavaPackage), -} - -impl JavaElement { - pub fn id(&self) -> &str { - match self { - JavaElement::Class(e) => &e.id, - JavaElement::Interface(e) => &e.id, - JavaElement::Enum(e) => &e.id, - JavaElement::Annotation(e) => &e.id, - JavaElement::Method(e) => &e.id, - JavaElement::Field(e) => &e.id, - JavaElement::Package(e) => &e.id, - } - } - - pub fn name(&self) -> &str { - match self { - JavaElement::Class(e) => &e.name, - JavaElement::Interface(e) => &e.name, - JavaElement::Enum(e) => &e.name, - JavaElement::Annotation(e) => &e.name, - JavaElement::Method(e) => &e.name, - JavaElement::Field(e) => &e.name, - JavaElement::Package(e) => &e.name, - } - } - - pub fn range(&self) -> Option<&Range> { - match self { - JavaElement::Class(e) => e.range.as_ref(), - JavaElement::Interface(e) => e.range.as_ref(), - JavaElement::Enum(e) => e.range.as_ref(), - JavaElement::Annotation(e) => e.range.as_ref(), - JavaElement::Method(e) => e.range.as_ref(), - JavaElement::Field(e) => e.range.as_ref(), - JavaElement::Package(_) => None, - } - } - - pub fn name_range(&self) -> Option<&Range> { - match self { - JavaElement::Class(e) => e.name_range.as_ref(), - JavaElement::Interface(e) => e.name_range.as_ref(), - JavaElement::Enum(e) => e.name_range.as_ref(), - JavaElement::Annotation(e) => e.name_range.as_ref(), - JavaElement::Method(e) => e.name_range.as_ref(), - JavaElement::Field(e) => e.name_range.as_ref(), - JavaElement::Package(_) => None, - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JavaClass { - pub name: String, - pub id: String, // FQN - pub modifiers: Vec, - pub range: Option, - pub name_range: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JavaInterface { - pub name: String, - pub id: String, // FQN - pub modifiers: Vec, - pub range: Option, - pub name_range: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JavaEnum { - pub name: String, - pub id: String, // FQN - pub modifiers: Vec, - pub constants: Vec, - pub range: Option, - pub name_range: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JavaAnnotation { - pub name: String, - pub id: String, - pub modifiers: Vec, - pub range: Option, - pub name_range: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JavaField { - pub name: String, - pub id: String, - pub type_ref: TypeRef, - pub modifiers: Vec, - pub range: Option, - pub name_range: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JavaMethod { - pub name: String, - pub id: String, - pub return_type: TypeRef, - pub parameters: Vec, - pub modifiers: Vec, - pub is_constructor: bool, - pub range: Option, - pub name_range: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JavaParameter { - pub name: String, - pub type_ref: TypeRef, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JavaPackage { - pub name: String, - pub id: String, -} diff --git a/src/model/lang/mod.rs b/src/model/lang/mod.rs deleted file mode 100644 index b31aff2..0000000 --- a/src/model/lang/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod gradle; -pub mod java; diff --git a/src/model/mod.rs b/src/model/mod.rs deleted file mode 100644 index 66caab1..0000000 --- a/src/model/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod graph; -pub mod lang; -pub mod signature; diff --git a/src/model/signature.rs b/src/model/signature.rs deleted file mode 100644 index 59c9902..0000000 --- a/src/model/signature.rs +++ /dev/null @@ -1,49 +0,0 @@ -use serde::{Deserialize, Serialize}; -use schemars::JsonSchema; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash, JsonSchema)] -pub enum TypeRef { - /// Unresolved or primitive type name (e.g., "int", "void", "List") - Raw(String), - - /// Resolved reference to a Type definition node (FQN) - Id(String), - - /// Generic instantiation (e.g., List) - Generic { - base: Box, - args: Vec, - }, - - /// Array type (e.g., String[]) - Array { - element: Box, - dimensions: usize, - }, - - /// Wildcard type (e.g., ? extends Number) - Wildcard { - bound: Option>, - is_upper_bound: bool, // true: extends, false: super - }, - - Unknown, -} - -impl TypeRef { - /// Helper to create a Raw type - pub fn raw(s: impl Into) -> Self { - TypeRef::Raw(s.into()) - } - - /// Helper to create an Id type - pub fn id(s: impl Into) -> Self { - TypeRef::Id(s.into()) - } -} - -impl Default for TypeRef { - fn default() -> Self { - TypeRef::Unknown - } -} diff --git a/src/parser/java/ast.rs b/src/parser/java/ast.rs deleted file mode 100644 index 00dbe7d..0000000 --- a/src/parser/java/ast.rs +++ /dev/null @@ -1,415 +0,0 @@ -use crate::model::graph::{EdgeType, Range}; -use crate::parser::utils::range_from_ts; -use crate::model::lang::java::{ - JavaAnnotation, JavaClass, JavaElement, JavaEnum, JavaField, JavaInterface, JavaMethod, - JavaParameter, -}; -use crate::model::signature::TypeRef; -use tree_sitter::{Node, QueryMatch, Tree, StreamingIterator}; -use super::JavaParser; -use super::constants::*; - -/// The native semantic model of a Java source file. -pub struct JavaFileModel<'a> { - pub package: Option, - pub imports: Vec, - pub entities: Vec>, - pub relations: Vec, -} - -pub struct JavaEntity<'a> { - pub element: JavaElement, - pub node: Node<'a>, -} - -pub struct JavaRelation { - pub source_fqn: String, - pub target_name: String, - pub rel_type: EdgeType, - pub range: Option, -} - -impl JavaParser { - /// Deeply analyzes a Java tree and produces a native JavaFileModel. - pub(crate) fn analyze<'a>(&self, tree: &'a Tree, source: &'a str) -> JavaFileModel<'a> { - let (package, imports) = self.extract_package_and_imports(tree, source); - let mut entities = Vec::new(); - let mut relations = Vec::new(); - - let mut cursor = tree_sitter::QueryCursor::new(); - let mut matches = cursor.matches( - &self.definition_query, - tree.root_node(), - source.as_bytes(), - ); - - let mut entities_map: std::collections::HashMap = std::collections::HashMap::new(); - - while let Some(mat) = matches.next() { - // 1. Find definitions (Anchors) - let definition_anchor = mat.captures.iter().find(|c| { - let i = c.index; - i == self.indices.class_def - || i == self.indices.inter_def - || i == self.indices.enum_def - || i == self.indices.annotation_def - || i == self.indices.method_def - || i == self.indices.constr_def - || i == self.indices.field_def - }); - - if let Some(anchor) = definition_anchor { - let anchor_node = anchor.node; - let (kind, name_idx) = if anchor.index == self.indices.class_def { - (KIND_LABEL_CLASS, self.indices.class_name) - } else if anchor.index == self.indices.inter_def { - (KIND_LABEL_INTERFACE, self.indices.inter_name) - } else if anchor.index == self.indices.enum_def { - (KIND_LABEL_ENUM, self.indices.enum_name) - } else if anchor.index == self.indices.annotation_def { - (KIND_LABEL_ANNOTATION, self.indices.annotation_name) - } else if anchor.index == self.indices.method_def { - (KIND_LABEL_METHOD, self.indices.method_name) - } else if anchor.index == self.indices.constr_def { - (KIND_LABEL_CONSTRUCTOR, self.indices.constr_name) - } else { - (KIND_LABEL_FIELD, self.indices.field_name) - }; - - if let Some(name_node) = mat.captures.iter().find(|c| c.index == name_idx).map(|c| c.node) { - let fqn = self.get_fqn_for_definition(&name_node, source, package.as_deref()); - let name = name_node.utf8_text(source.as_bytes()).unwrap_or_default().to_string(); - let range = range_from_ts(anchor_node.range()); - let name_range = range_from_ts(name_node.range()); - - if !entities_map.contains_key(&fqn) { - let new_idx = entities.len(); - let element = match kind { - KIND_LABEL_CLASS => JavaElement::Class(JavaClass { - id: fqn.clone(), name: name.clone(), modifiers: vec![], - range: Some(range), name_range: Some(name_range), - }), - KIND_LABEL_INTERFACE => JavaElement::Interface(JavaInterface { - id: fqn.clone(), name: name.clone(), modifiers: vec![], - range: Some(range), name_range: Some(name_range), - }), - KIND_LABEL_ENUM => JavaElement::Enum(JavaEnum { - id: fqn.clone(), name: name.clone(), modifiers: vec![], - constants: vec![], range: Some(range), name_range: Some(name_range), - }), - KIND_LABEL_ANNOTATION => JavaElement::Annotation(JavaAnnotation { - id: fqn.clone(), name: name.clone(), modifiers: vec![], - range: Some(range), name_range: Some(name_range), - }), - KIND_LABEL_METHOD | KIND_LABEL_CONSTRUCTOR => { - let mut return_type = TypeRef::raw("void"); - if let Some(ret_node) = mat.captures.iter().find(|c| c.index == self.indices.method_ret).map(|c| c.node) { - return_type = self.parse_type_node(ret_node, source); - self.generate_typed_as_edges(ret_node, source, &fqn, &mut relations); - } - - JavaElement::Method(JavaMethod { - id: fqn.clone(), name: name.clone(), return_type, - parameters: vec![], modifiers: vec![], is_constructor: kind == KIND_LABEL_CONSTRUCTOR, - range: Some(range), name_range: Some(name_range), - }) - }, - KIND_LABEL_FIELD => { - let type_node = mat.captures.iter() - .find(|c| c.index == self.indices.field_type) - .map(|c| c.node) - .or_else(|| anchor_node.child_by_field_name("type")) - .or_else(|| anchor_node.parent().and_then(|p| p.child_by_field_name("type"))); - - let type_ref = if let Some(t) = type_node { - self.generate_typed_as_edges(t, source, &fqn, &mut relations); - self.parse_type_node(t, source) - } else { - TypeRef::Unknown - }; - - JavaElement::Field(JavaField { - id: fqn.clone(), name: name.clone(), - type_ref, - modifiers: vec![], range: Some(range), name_range: Some(name_range), - }) - }, - _ => unreachable!(), - }; - entities.push(JavaEntity { - element, - node: anchor_node, - }); - entities_map.insert(fqn.clone(), new_idx); - - // Structural relation - if let Some(parent_node) = self.find_next_enclosing_definition(anchor_node) { - if let Some(parent_name_node) = parent_node.child_by_field_name("name") { - let parent = self.get_fqn_for_definition(&parent_name_node, source, package.as_deref()); - if parent != fqn { - relations.push(JavaRelation { - source_fqn: parent, - target_name: fqn.clone(), - rel_type: EdgeType::Contains, - range: None, - }); - } - } - } - } - } - continue; - } - - // 2. Metadata (Modifiers, Types, Params) - if let Some(meta_cap) = mat.captures.iter().find(|c| { - let i = c.index; - i == self.indices.mods - || i == self.indices.class_super - || i == self.indices.class_inter - || i == self.indices.inter_ext - || i == self.indices.enum_interface - || i == self.indices.method_ret - || i == self.indices.field_type - || i == self.indices.param_match - }) { - if let Some(parent_node) = self.find_next_enclosing_definition(meta_cap.node) { - if let Some(parent_name_node) = parent_node.child_by_field_name("name") { - let enclosing_fqn = self.get_fqn_for_definition(&parent_name_node, source, package.as_deref()); - if let Some(&idx) = entities_map.get(&enclosing_fqn) { - self.attach_metadata_to_model(mat, source, enclosing_fqn, &mut entities[idx].element, &mut relations); - } - } - } - continue; - } - - // 3. Call/Instantiates relations - if let Some(call_cap) = mat.captures.iter().find(|c| c.index == self.indices.call) { - if let (Some(target_node), Some(parent_node)) = ( - mat.captures.iter().find(|c| c.index == self.indices.call_name).map(|c| c.node), - self.find_next_enclosing_definition(call_cap.node), - ) { - if let Some(parent_name_node) = parent_node.child_by_field_name("name") { - let source_fqn = self.get_fqn_for_definition(&parent_name_node, source, package.as_deref()); - let mut target = target_node.utf8_text(source.as_bytes()).unwrap_or("").to_string(); - if let Some(obj) = call_cap.node.child_by_field_name("object") { - let obj_text = obj.utf8_text(source.as_bytes()).unwrap_or_default(); - target = format!("{}.{}", obj_text, target); - } - relations.push(JavaRelation { - source_fqn, - target_name: target, - rel_type: EdgeType::Calls, - range: Some(range_from_ts(target_node.range())), - }); - } - } - } else if let Some(inst_cap) = mat.captures.iter().find(|c| c.index == self.indices.inst) { - if let (Some(target_node), Some(parent_node)) = ( - mat.captures.iter().find(|c| c.index == self.indices.inst_type).map(|c| c.node), - self.find_next_enclosing_definition(inst_cap.node), - ) { - if let Some(parent_name_node) = parent_node.child_by_field_name("name") { - let source_fqn = self.get_fqn_for_definition(&parent_name_node, source, package.as_deref()); - let target = target_node.utf8_text(source.as_bytes()).unwrap_or("").to_string(); - relations.push(JavaRelation { - source_fqn, - target_name: target, - rel_type: EdgeType::Instantiates, - range: Some(range_from_ts(target_node.range())), - }); - } - } - } - } - - JavaFileModel { - package, - imports, - entities, - relations, - } - } - - fn attach_metadata_to_model( - &self, - mat: &QueryMatch, - source: &str, - fqn: String, - element: &mut JavaElement, - relations: &mut Vec, - ) { - // Modifiers & Annotations - if let Some(mods_node) = mat.captures.iter().find(|c| c.index == self.indices.mods).map(|c| c.node) { - let mut cursor = mods_node.walk(); - for child in mods_node.children(&mut cursor) { - let kind = child.kind(); - if kind.contains("annotation") { - // It's an annotation. Try to extract the name. - // Structure usually: (marker_annotation name: (identifier)) - // or (annotation name: (identifier) arguments: (...)) - let name_node = child.child_by_field_name("name").unwrap_or(child); - if let Ok(name) = name_node.utf8_text(source.as_bytes()) { - let name_str = name.to_string(); - // Add DecoratedBy edge - relations.push(JavaRelation { - source_fqn: fqn.clone(), - target_name: name_str.clone(), // We might want to resolve this to FQN if possible - rel_type: EdgeType::DecoratedBy, - range: Some(range_from_ts(name_node.range())), - }); - // Also add to modifiers list as string representation (e.g. "@Override") - // We need to reconstruct the full annotation text or just the name with @ - // Existing logic added full text, let's keep it simple: just add the name prefixed with @ if not present - // Actually, child.utf8_text() gives the full annotation text "@Override" - if let Ok(full_text) = child.utf8_text(source.as_bytes()) { - let m_str = full_text.to_string(); - self.add_modifier(element, m_str); - } - } - } else if let Ok(m) = child.utf8_text(source.as_bytes()) { - let m_str = m.to_string(); - self.add_modifier(element, m_str); - } - } - } - - match element { - JavaElement::Class(_) => { - if let Some(s) = mat.captures.iter().find(|c| c.index == self.indices.class_super) { - let mut s_name = s.node.utf8_text(source.as_bytes()).unwrap_or_default().to_string(); - let mut cursor = s.node.walk(); - for child in s.node.children(&mut cursor) { - if child.kind() == "type_identifier" || child.kind() == "scoped_type_identifier" || child.kind() == "generic_type" { - s_name = child.utf8_text(source.as_bytes()).unwrap_or_default().to_string(); - break; - } - } - relations.push(JavaRelation { - source_fqn: fqn.clone(), - target_name: s_name, - rel_type: EdgeType::InheritsFrom, - range: None, - }); - } - for cc in mat.captures.iter().filter(|c| c.index == self.indices.class_inter) { - let i = cc.node.utf8_text(source.as_bytes()).unwrap_or_default().to_string(); - relations.push(JavaRelation { - source_fqn: fqn.clone(), - target_name: i, - rel_type: EdgeType::Implements, - range: None, - }); - } - } - JavaElement::Interface(_) => { - for cc in mat.captures.iter().filter(|c| c.index == self.indices.inter_ext) { - let e = cc.node.utf8_text(source.as_bytes()).unwrap_or_default().to_string(); - relations.push(JavaRelation { - source_fqn: fqn.clone(), - target_name: e, - rel_type: EdgeType::InheritsFrom, - range: None, - }); - } - } - JavaElement::Enum(_) => { - for cc in mat.captures.iter().filter(|c| c.index == self.indices.enum_interface) { - let i = cc.node.utf8_text(source.as_bytes()).unwrap_or_default().to_string(); - relations.push(JavaRelation { - source_fqn: fqn.clone(), - target_name: i, - rel_type: EdgeType::Implements, - range: None, - }); - } - } - JavaElement::Annotation(_) => {} - JavaElement::Method(m) => { - if let Some(ret) = mat.captures.iter().find(|c| c.index == self.indices.method_ret) { - m.return_type = self.parse_type_node(ret.node, source); - // Generate TypedAs edge for return type - self.generate_typed_as_edges(ret.node, source, &fqn, relations); - } - if let (Some(t_node), Some(n_node)) = ( - mat.captures.iter().find(|c| c.index == self.indices.param_type).map(|c| c.node), - mat.captures.iter().find(|c| c.index == self.indices.param_name).map(|c| c.node), - ) { - let t_ref = self.parse_type_node(t_node, source); - let n = n_node.utf8_text(source.as_bytes()).unwrap_or_default().to_string(); - if !m.parameters.iter().any(|p| p.name == n && p.type_ref == t_ref) { - m.parameters.push(JavaParameter { type_ref: t_ref, name: n }); - } - // Generate TypedAs edge for parameter type - self.generate_typed_as_edges(t_node, source, &fqn, relations); - } - } - JavaElement::Field(f) => { - if let Some(t) = mat.captures.iter().find(|c| c.index == self.indices.field_type) { - f.type_ref = self.parse_type_node(t.node, source); - // Generate TypedAs edge for field type - self.generate_typed_as_edges(t.node, source, &fqn, relations); - } - } - JavaElement::Package(_) => {} - } - } - - fn add_modifier(&self, element: &mut JavaElement, m_str: String) { - match element { - JavaElement::Class(c) => if !c.modifiers.contains(&m_str) { c.modifiers.push(m_str); } - JavaElement::Interface(i) => if !i.modifiers.contains(&m_str) { i.modifiers.push(m_str); } - JavaElement::Enum(e) => if !e.modifiers.contains(&m_str) { e.modifiers.push(m_str); } - JavaElement::Annotation(a) => if !a.modifiers.contains(&m_str) { a.modifiers.push(m_str); } - JavaElement::Method(m_node) => if !m_node.modifiers.contains(&m_str) { m_node.modifiers.push(m_str); } - JavaElement::Field(f) => if !f.modifiers.contains(&m_str) { f.modifiers.push(m_str); } - JavaElement::Package(_) => {} - } - } - - /// Recursively extracts type references and generates TypedAs edges - fn generate_typed_as_edges( - &self, - type_node: Node, - source: &str, - source_fqn: &str, - relations: &mut Vec, - ) { - let kind = type_node.kind(); - - // Base case: simple type identifier - if kind == "type_identifier" { - let type_name = type_node.utf8_text(source.as_bytes()).unwrap_or_default().to_string(); - // Ignore primitive types for edge generation - if !self.is_primitive(&type_name) { - relations.push(JavaRelation { - source_fqn: source_fqn.to_string(), - target_name: type_name, - rel_type: EdgeType::TypedAs, - range: Some(range_from_ts(type_node.range())), - }); - } - return; - } - - // Recursive cases - let mut cursor = type_node.walk(); - for child in type_node.children(&mut cursor) { - let child_kind = child.kind(); - match child_kind { - "type_identifier" | "generic_type" | "type_arguments" | "wildcard" | "array_type" => { - self.generate_typed_as_edges(child, source, source_fqn, relations); - }, - _ => {} - } - } - } - - fn is_primitive(&self, type_name: &str) -> bool { - matches!( - type_name, - "byte" | "short" | "int" | "long" | "float" | "double" | "boolean" | "char" | "void" - ) - } -} diff --git a/src/parser/java/index.rs b/src/parser/java/index.rs deleted file mode 100644 index 55c7d83..0000000 --- a/src/parser/java/index.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::error::{NaviscopeError, Result}; -use crate::model::graph::GraphNode; -use tree_sitter::Parser; -use crate::parser::{IndexParser, GlobalParseResult}; -use super::JavaParser; - -impl IndexParser for JavaParser { - fn parse_file(&self, source_code: &str, file_path: Option<&std::path::Path>) -> Result { - let mut parser = Parser::new(); - parser - .set_language(&self.language) - .map_err(|e| NaviscopeError::Parsing(e.to_string()))?; - - let tree = parser - .parse(source_code, None) - .ok_or_else(|| NaviscopeError::Parsing("Failed to parse Java file".to_string()))?; - - // Use the native AST analyzer - let model = self.analyze(&tree, source_code); - - let nodes = model.entities - .into_iter() - .map(|e| GraphNode::java(e.element, file_path.map(|p| p.to_path_buf()))) - .collect(); - - let relations = model.relations - .into_iter() - .map(|r| (r.source_fqn, r.target_name, r.rel_type, r.range)) - .collect(); - - Ok(GlobalParseResult { - package_name: model.package, - imports: model.imports, - nodes, - relations, - }) - } -} diff --git a/src/parser/java/lsp.rs b/src/parser/java/lsp.rs deleted file mode 100644 index 06fcc72..0000000 --- a/src/parser/java/lsp.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::parser::LspParser; -use crate::parser::utils::{RawSymbol, build_symbol_hierarchy}; -use crate::model::graph::NodeKind; -use tree_sitter::Tree; -use super::JavaParser; - -impl LspParser for JavaParser { - fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { - let mut parser = tree_sitter::Parser::new(); - parser.set_language(&self.language).ok()?; - parser.parse(source, old_tree) - } - - fn extract_symbols(&self, tree: &Tree, source: &str) -> Vec { - // Use the native AST analyzer - let model = self.analyze(tree, source); - - // Convert JavaEntity to RawSymbol for the tree builder - let raw_symbols = model.entities - .into_iter() - .map(|e| { - let kind = match e.element { - crate::model::lang::java::JavaElement::Class(_) => NodeKind::Class, - crate::model::lang::java::JavaElement::Interface(_) => NodeKind::Interface, - crate::model::lang::java::JavaElement::Enum(_) => NodeKind::Enum, - crate::model::lang::java::JavaElement::Annotation(_) => NodeKind::Annotation, - crate::model::lang::java::JavaElement::Method(ref m) => if m.is_constructor { NodeKind::Constructor } else { NodeKind::Method }, - crate::model::lang::java::JavaElement::Field(_) => NodeKind::Field, - crate::model::lang::java::JavaElement::Package(_) => NodeKind::Package, - }; - - RawSymbol { - name: e.element.name().to_string(), - kind, - range: e.element.range().cloned().unwrap_or(crate::model::graph::Range { start_line: 0, start_col: 0, end_line: 0, end_col: 0 }), - selection_range: e.element.name_range().cloned().unwrap_or(crate::model::graph::Range { start_line: 0, start_col: 0, end_line: 0, end_col: 0 }), - node: e.node, - } - }) - .collect(); - - build_symbol_hierarchy(raw_symbols) - } - - fn symbol_kind(&self, kind: &NodeKind) -> tower_lsp::lsp_types::SymbolKind { - use tower_lsp::lsp_types::SymbolKind; - match kind { - NodeKind::Class => SymbolKind::CLASS, - NodeKind::Interface => SymbolKind::INTERFACE, - NodeKind::Enum => SymbolKind::ENUM, - NodeKind::Annotation => SymbolKind::INTERFACE, - NodeKind::Method => SymbolKind::METHOD, - NodeKind::Constructor => SymbolKind::CONSTRUCTOR, - NodeKind::Field => SymbolKind::FIELD, - NodeKind::Package => SymbolKind::PACKAGE, - _ => SymbolKind::VARIABLE, - } - } -} diff --git a/src/parser/java/mod.rs b/src/parser/java/mod.rs deleted file mode 100644 index 382a0ee..0000000 --- a/src/parser/java/mod.rs +++ /dev/null @@ -1,430 +0,0 @@ -use crate::error::Result; -use crate::model::graph::Range; -use crate::model::signature::TypeRef; -use crate::parser::utils::range_from_ts; -use crate::parser::SymbolIntent; -use tree_sitter::{Node, Query, Tree, StreamingIterator}; -use std::sync::Arc; - -mod constants; -mod lsp; -mod index; -mod ast; - -unsafe extern "C" { - fn tree_sitter_java() -> tree_sitter::Language; -} - -use crate::parser::queries::java_definitions::JavaIndices; - -pub struct JavaParser { - pub language: tree_sitter::Language, - pub(crate) definition_query: Arc, - pub(crate) indices: JavaIndices, -} - -impl Clone for JavaParser { - fn clone(&self) -> Self { - Self { - language: self.language.clone(), - definition_query: Arc::clone(&self.definition_query), - indices: self.indices.clone(), - } - } -} - -impl JavaParser { - pub fn new() -> Result { - let language = unsafe { tree_sitter_java() }; - let definition_query = crate::parser::utils::load_query( - &language, - include_str!("../queries/java_definitions.scm"), - )?; - let indices = JavaIndices::new(&definition_query)?; - - Ok(Self { - language, - definition_query: Arc::new(definition_query), - indices, - }) - } - - pub fn parse_type_node(&self, node: Node, source: &str) -> TypeRef { - match node.kind() { - "generic_type" => { - let base_node = node.child_by_field_name("type") - .or_else(|| node.child(0)); - - let base = if let Some(b) = base_node { - self.parse_type_node(b, source) - } else { - TypeRef::Unknown - }; - - let mut args = Vec::new(); - // Iterate over children to find type_arguments - // We manually iterate because child_by_field_name might not catch everything if grammar varies - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - if child.kind() == "type_arguments" { - let mut args_cursor = child.walk(); - for arg in child.children(&mut args_cursor) { - if !matches!(arg.kind(), "<" | ">" | ",") { - args.push(self.parse_type_node(arg, source)); - } - } - } - } - - TypeRef::Generic { - base: Box::new(base), - args, - } - }, - "array_type" => { - let element_node = node.child_by_field_name("element") - .or_else(|| node.child(0)); - - let element = if let Some(e) = element_node { - self.parse_type_node(e, source) - } else { - TypeRef::Unknown - }; - - let dim_node = node.child_by_field_name("dimensions"); - let count = if let Some(d) = dim_node { - d.utf8_text(source.as_bytes()).unwrap_or("").matches('[').count() - } else { - 1 - }; - - TypeRef::Array { - element: Box::new(element), - dimensions: count, - } - }, - "wildcard" => { - // Check for bounds - let mut bound = None; - let mut is_upper = true; - - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - match child.kind() { - "super" => is_upper = false, - "extends" => is_upper = true, - k if k != "?" => { - // Assume this is the type bound - bound = Some(Box::new(self.parse_type_node(child, source))); - } - _ => {} - } - } - - TypeRef::Wildcard { - bound, - is_upper_bound: is_upper, - } - }, - _ => { - let text = node.utf8_text(source.as_bytes()).unwrap_or_default().to_string(); - if text.is_empty() { - TypeRef::Unknown - } else { - TypeRef::Raw(text) - } - } - } - } - - // --- Core Atomic Helpers (Shared between Global and Local) --- - - /// Gets the full FQN for a definition node. - pub fn get_fqn_for_definition(&self, name_node: &Node, source: &str, pkg: Option<&str>) -> String { - let mut parts = Vec::new(); - let mut curr = *name_node; - let mut seen_ids = std::collections::HashSet::new(); - - parts.push(name_node.utf8_text(source.as_bytes()).unwrap_or_default().to_string()); - seen_ids.insert(name_node.id()); - - while let Some(parent) = self.find_next_enclosing_definition(curr) { - let kind = parent.kind(); - // FQN for Java elements should only include Packages and Classes/Interfaces/Enums. - // Methods and Constructors should be skipped when calculating the FQN of nested elements. - if kind.contains("class") || kind.contains("interface") || kind.contains("enum") || kind.contains("annotation") { - if let Some(n_node) = parent.child_by_field_name("name") { - if seen_ids.insert(n_node.id()) { - if let Ok(n_text) = n_node.utf8_text(source.as_bytes()) { - parts.push(n_text.to_string()); - } - } - } - } - curr = parent; - } - - parts.reverse(); - let mut fqn = if let Some(p) = pkg { p.to_string() } else { String::new() }; - for p in parts { - if !fqn.is_empty() { fqn.push('.'); } - fqn.push_str(&p); - } - fqn - } - - /// Returns a list of FQNs for all enclosing classes from inner to outer. - pub fn get_enclosing_class_fqns(&self, node: &Node, source: &str, pkg: Option<&str>) -> Vec { - let mut fqns = Vec::new(); - let mut curr = *node; - - // If the current node is the name of a definition, start searching from the definition itself - // to avoid including the current definition in the enclosing class list. - if let Some(parent) = curr.parent() { - if Self::is_definition_node(parent.kind()) { - if let Some(name_node) = parent.child_by_field_name("name") { - if name_node.id() == node.id() { - curr = parent; - } - } - } - } - - while let Some(container) = self.find_next_enclosing_definition(curr) { - let kind = container.kind(); - if kind.contains("class") || kind.contains("interface") || kind.contains("enum") { - if let Some(name_node) = container.child_by_field_name("name") { - fqns.push(self.get_fqn_for_definition(&name_node, source, pkg)); - } - } - curr = container; - } - fqns - } - - pub fn find_local_declaration(&self, start_node: Node, name: &str, source: &str) -> Option<(Range, Option)> { - self.find_local_declaration_node(start_node, name, source) - .map(|(range, type_node)| { - let type_name = type_node.and_then(|t| t.utf8_text(source.as_bytes()).ok().map(|s| s.to_string())); - (range, type_name) - }) - } - - pub fn find_local_declaration_node<'a>(&self, start_node: Node<'a>, name: &str, source: &str) -> Option<(Range, Option>)> { - let mut curr = start_node; - while let Some(parent) = curr.parent() { - // Check declarations in this scope before the start_node - let mut child_cursor = parent.walk(); - for child in parent.children(&mut child_cursor) { - if child.start_byte() >= start_node.start_byte() { - break; - } - if let Some(res) = self.is_decl_of_node(&child, name, source) { - return Some(res); - } - } - // Check if parent itself is a declaration (like method parameters) - if let Some(res) = self.is_decl_of_node(&parent, name, source) { - return Some(res); - } - curr = parent; - } - None - } - - pub fn extract_package_and_imports(&self, tree: &Tree, source: &str) -> (Option, Vec) { - let mut package = None; - let mut imports = Vec::new(); - let mut cursor = tree_sitter::QueryCursor::new(); - let mut matches = cursor.matches(&self.definition_query, tree.root_node(), source.as_bytes()); - while let Some(mat) = matches.next() { - if let Some(cap) = mat.captures.iter().find(|c| c.index == self.indices.pkg) { - package = cap.node.utf8_text(source.as_bytes()).ok().map(|s: &str| s.to_string()); - } else if let Some(cap) = mat.captures.iter().find(|c| c.index == self.indices.import_name) { - if let Ok(imp) = cap.node.utf8_text(source.as_bytes()) { - let imp_str: &str = imp; - imports.push(imp_str.to_string()); - } - } - } - (package, imports) - } - - pub fn resolve_type_name_to_fqn_data(&self, type_name: &str, package: Option<&str>, imports: &[String]) -> Option { - // 1. Check if it's a primitive type - const PRIMITIVES: &[&str] = &["int", "long", "short", "byte", "float", "double", "boolean", "char", "void"]; - if PRIMITIVES.contains(&type_name) { - return Some(type_name.to_string()); - } - - // 2. Already an FQN? - if type_name.contains('.') { - return Some(type_name.to_string()); - } - - // 3. Precise imports - for imp in imports { - if imp.ends_with(&format!(".{}", type_name)) { - return Some(imp.clone()); - } - } - - // 4. Wildcard imports (e.g., import java.util.*;) - // Note: This is heuristic-lite but necessary for correctness in Java - // We'll return the first one that might match, or wait for index resolution? - // Actually, without a full classpath, we can only guess. - // For now, let's focus on java.lang which is always there. - - // 5. java.lang (implicit import) - // List of common java.lang classes to avoid false positives? - // Or just assume if not found elsewhere, it might be java.lang - const JAVA_LANG_CLASSES: &[&str] = &[ - "String", "Object", "Integer", "Long", "Double", "Float", "Boolean", "Byte", "Character", "Short", - "Exception", "RuntimeException", "Throwable", "Error", "Thread", "System", "Class", "Iterable", - "Runnable", "Comparable", "SuppressWarnings", "Override", "Deprecated" - ]; - if JAVA_LANG_CLASSES.contains(&type_name) { - return Some(format!("java.lang.{}", type_name)); - } - - // 6. Current package - if let Some(p) = package { - return Some(format!("{}.{}", p, type_name)); - } - - Some(type_name.to_string()) - } - - pub fn determine_intent(&self, node: &Node) -> SymbolIntent { - let parent = match node.parent() { - Some(p) => p, - None => return SymbolIntent::Unknown, - }; - match parent.kind() { - "method_invocation" => { - if let Some(name_node) = parent.child_by_field_name("name") { - if name_node.id() == node.id() { - return SymbolIntent::Method; - } - } - SymbolIntent::Type // Likely the receiver/object - } - "method_reference" => SymbolIntent::Type, - "object_creation_expression" => { - if let Some(type_node) = parent.child_by_field_name("type") { - if type_node.id() == node.id() { - return SymbolIntent::Type; - } - } - SymbolIntent::Unknown - } - "type_identifier" | "scoped_identifier" | "scoped_type_identifier" | "generic_type" => { - SymbolIntent::Type - } - "variable_declarator" => SymbolIntent::Variable, - "field_access" => { - if let Some(field_node) = parent.child_by_field_name("field") { - if field_node.id() == node.id() { - return SymbolIntent::Field; - } - } - SymbolIntent::Type // Likely the receiver/object - } - "class_declaration" | "interface_declaration" | "enum_declaration" | "annotation_type_declaration" => SymbolIntent::Type, - "method_declaration" | "constructor_declaration" => { - if let Some(name_node) = parent.child_by_field_name("name") { - if name_node.id() == node.id() { - return SymbolIntent::Method; - } - } - SymbolIntent::Type - }, - _ => { - if node.kind() == "type_identifier" || node.kind() == "scoped_type_identifier" { - SymbolIntent::Type - } else { - SymbolIntent::Unknown - } - } - } - } - - pub fn is_decl_of_node<'a>(&self, node: &Node<'a>, name: &str, source: &str) -> Option<(Range, Option>)> { - match node.kind() { - "variable_declarator" | "formal_parameter" | "catch_formal_parameter" => { - if let Some(name_node) = node.child_by_field_name("name") { - if name_node.utf8_text(source.as_bytes()).ok()? == name { - let range = range_from_ts(name_node.range()); - let type_node = if node.kind() == "variable_declarator" { - // Type is in the parent local_variable_declaration - node.parent() - .and_then(|p| p.child_by_field_name("type")) - } else { - // Type is a sibling for parameters - node.child_by_field_name("type") - }; - return Some((range, type_node)); - } - } - } - "local_variable_declaration" | "formal_parameters" | "inferred_parameters" | "enhanced_for_statement" | "lambda_expression" => { - if node.kind() == "lambda_expression" { - if let Some(params) = node.child_by_field_name("parameters") { - if params.kind() == "identifier" { - if params.utf8_text(source.as_bytes()).ok()? == name { - return Some((range_from_ts(params.range()), None)); - } - } else { - return self.is_decl_of_node(¶ms, name, source); - } - } - return None; - } - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - if let Some(res) = self.is_decl_of_node(&child, name, source) { return Some(res); } - } - } - _ => {} - } - None - } - - pub fn is_decl_of(&self, node: &Node, name: &str, source: &str) -> Option<(Range, Option)> { - self.is_decl_of_node(node, name, source) - .map(|(range, type_node)| { - let type_name = type_node.and_then(|t| t.utf8_text(source.as_bytes()).ok().map(|s| s.to_string())); - (range, type_name) - }) - } - - pub fn resolve_type_name_to_fqn(&self, type_name: &str, tree: &Tree, source: &str) -> Option { - let (pkg, imports) = self.extract_package_and_imports(tree, source); - self.resolve_type_name_to_fqn_data(type_name, pkg.as_deref(), &imports) - } - - // --- Private Helpers --- - - fn is_definition_node(kind: &str) -> bool { - matches!( - kind, - "class_declaration" - | "interface_declaration" - | "enum_declaration" - | "annotation_type_declaration" - | "method_declaration" - | "constructor_declaration" - ) - } - - pub fn find_next_enclosing_definition<'a>(&self, node: Node<'a>) -> Option> { - let mut curr = node; - while let Some(parent) = curr.parent() { - if Self::is_definition_node(parent.kind()) { - return Some(parent); - } - curr = parent; - } - None - } -} diff --git a/src/parser/mod.rs b/src/parser/mod.rs deleted file mode 100644 index 2fda928..0000000 --- a/src/parser/mod.rs +++ /dev/null @@ -1,63 +0,0 @@ -use crate::model::graph::{GraphNode, NodeKind, Range}; -use tree_sitter::Tree; -use std::path::Path; -use crate::error::Result; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SymbolIntent { - Type, - Method, - Field, - Variable, - Unknown, -} - -pub fn matches_intent(node_kind: &NodeKind, intent: SymbolIntent) -> bool { - match intent { - SymbolIntent::Type => matches!(node_kind, NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation), - SymbolIntent::Method => matches!(node_kind, NodeKind::Method | NodeKind::Constructor), - SymbolIntent::Field => matches!(node_kind, NodeKind::Field), - SymbolIntent::Variable => false, // Graph nodes are rarely variables, usually only Definitions - SymbolIntent::Unknown => true, - } -} - -#[derive(Debug, Clone)] -pub enum SymbolResolution { - Local(Range, Option), // Range of declaration, and optional type name - Precise(String, SymbolIntent), -} - -pub trait LspParser: Send + Sync { - fn parse(&self, source: &str, old_tree: Option<&tree_sitter::Tree>) -> Option; - fn extract_symbols(&self, tree: &Tree, source: &str) -> Vec; - /// Maps a language-specific symbol kind string to an LSP SymbolKind - fn symbol_kind(&self, kind: &NodeKind) -> tower_lsp::lsp_types::SymbolKind; -} - -/// Result of a global file parsing for indexing. -pub struct GlobalParseResult { - pub package_name: Option, - pub imports: Vec, - pub nodes: Vec, - pub relations: Vec<(String, String, crate::model::graph::EdgeType, Option)>, -} - -/// Trait for parsers that provide data for the global code knowledge graph. -pub trait IndexParser: Send + Sync { - fn parse_file(&self, source_code: &str, file_path: Option<&Path>) -> Result; -} - -#[derive(Debug, Clone)] -pub struct DocumentSymbol { - pub name: String, - pub kind: NodeKind, - pub range: Range, - pub selection_range: Range, - pub children: Vec, -} - -pub mod gradle; -pub mod java; -pub mod queries; -pub mod utils; diff --git a/src/parser/queries/gradle_definitions.scm b/src/parser/queries/gradle_definitions.scm deleted file mode 100644 index ee5da7a..0000000 --- a/src/parser/queries/gradle_definitions.scm +++ /dev/null @@ -1,68 +0,0 @@ -;; Pattern for the dependencies block -( - [ - (function_call function: (identifier) @name) - (juxt_function_call function: (identifier) @name) - ] - (#eq? @name "dependencies") -) @dependencies_block - -;; Pattern for dependency items -( - [ - (function_call - function: (identifier) @method_name - args: (argument_list (string) @dep_string)) - (juxt_function_call - function: (identifier) @method_name - args: (argument_list (string) @dep_string)) - ] - (#match? @method_name "^(implementation|api|testImplementation|compileOnly)$") -) @dependency_item - -;; Pattern for project dependencies -( - [ - (function_call - function: (identifier) @method_name - args: (argument_list - (function_call - function: (identifier) @proj_fn - args: (argument_list (string) @project_path)))) - (juxt_function_call - function: (identifier) @method_name - args: (argument_list - (function_call - function: (identifier) @proj_fn - args: (argument_list (string) @project_path)))) - ] - (#match? @method_name "^(implementation|api|testImplementation|compileOnly)$") - (#eq? @proj_fn "project") -) @project_dependency_item - -;; Pattern for settings.gradle -( - (assignment - left: [ - (field_access - object: (identifier) @obj - field: (identifier) @field) - (identifier) @field_id - ] - right: (string) @root_name) - (#match? @obj "rootProject") - (#match? @field "name") - (#match? @field_id "rootProject.name") -) @root_project_assignment - -( - [ - (function_call - function: (identifier) @include_fn - args: (argument_list (string) @included_path)) - (juxt_function_call - function: (identifier) @include_fn - args: (argument_list (string) @included_path)) - ] - (#eq? @include_fn "include") -) @include_call diff --git a/src/project/mod.rs b/src/project/mod.rs deleted file mode 100644 index 5b6c532..0000000 --- a/src/project/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub mod scanner; -pub mod source; -pub mod watcher; - -use std::path::Path; - -/// Checks if a path is relevant to Naviscope (e.g., Java source or build files). -pub fn is_relevant_path(path: &Path) -> bool { - path.extension() - .and_then(|s| s.to_str()) - .map(|ext| ext == "java" || ext == "gradle" || ext == "kts") - .unwrap_or(false) -} diff --git a/src/project/source.rs b/src/project/source.rs deleted file mode 100644 index 34bf195..0000000 --- a/src/project/source.rs +++ /dev/null @@ -1,47 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -/// Build tool types -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum BuildTool { - Gradle, - Maven, - Cargo, - Npm, - Poetry, - Bazel, -} - -/// Programming language types -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Language { - Java, - Kotlin, - Rust, - JavaScript, - TypeScript, - Python, - Go, - BuildFile, // For build files themselves -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SourceFile { - pub path: PathBuf, - pub content_hash: u64, - pub last_modified: u64, // UNIX timestamp -} - -impl SourceFile { - pub fn new( - path: PathBuf, - content_hash: u64, - last_modified: u64, - ) -> Self { - Self { - path, - content_hash, - last_modified, - } - } -} diff --git a/src/query/dsl.rs b/src/query/dsl.rs deleted file mode 100644 index 778b4a1..0000000 --- a/src/query/dsl.rs +++ /dev/null @@ -1,46 +0,0 @@ -use crate::model::graph::{EdgeType, NodeKind}; -use serde::{Deserialize, Serialize}; -use schemars::JsonSchema; - -#[derive(Serialize, Deserialize, Debug, JsonSchema)] -#[serde(tag = "command", rename_all = "snake_case")] -pub enum GraphQuery { - /// List members or structure (Rich Listing) - Ls { - /// Target node FQN, defaults to project modules if null - fqn: Option, - #[serde(default)] - kind: Vec, - #[serde(default)] - modifiers: Vec, - }, - - /// Search for symbols - Grep { - pattern: String, - #[serde(default)] - kind: Vec, - #[serde(default = "default_limit")] - limit: usize, - }, - - /// Inspect node details (Source & Metadata) - Cat { - fqn: String, - }, - - /// Find dependencies (outgoing) or dependents (incoming) - Deps { - fqn: String, - /// If true, find incoming dependencies (who depends on me). - /// If false (default), find outgoing dependencies (who do I depend on). - #[serde(default)] - rev: bool, - #[serde(default)] - edge_types: Vec, - }, -} - -fn default_limit() -> usize { - 20 -} diff --git a/src/query/engine.rs b/src/query/engine.rs deleted file mode 100644 index d46e300..0000000 --- a/src/query/engine.rs +++ /dev/null @@ -1,161 +0,0 @@ -use crate::error::{NaviscopeError, Result}; -use crate::index::CodeGraph; -use crate::model::graph::{EdgeType, NodeKind}; -use crate::query::dsl::GraphQuery; -use crate::query::model::{QueryResult, QueryResultEdge}; -use petgraph::Direction as PetDirection; -use regex::RegexBuilder; - -pub struct QueryEngine<'a> { - graph: &'a CodeGraph, -} - -impl<'a> QueryEngine<'a> { - pub fn new(graph: &'a CodeGraph) -> Self { - Self { graph } - } - - pub fn execute(&self, query: &GraphQuery) -> Result { - match query { - GraphQuery::Grep { - pattern, - kind, - limit, - } => { - let regex = RegexBuilder::new(pattern) - .case_insensitive(true) - .build() - .map_err(|e| NaviscopeError::Parsing(format!("Invalid regex: {}", e)))?; - - let mut nodes = Vec::new(); - - for node in self.graph.topology.node_weights() { - // Check if either FQN or Name matches the pattern - if regex.is_match(node.fqn()) || regex.is_match(node.name()) { - if kind.is_empty() || kind.contains(&node.kind()) { - nodes.push(node.clone()); - } - } - - if nodes.len() >= *limit { - break; - } - } - Ok(QueryResult::new(nodes, vec![])) - } - GraphQuery::Ls { fqn, kind, modifiers: _ } => { - if let Some(target_fqn) = fqn { - self.traverse_neighbors(target_fqn, &[EdgeType::Contains], PetDirection::Outgoing, kind) - } else { - // When FQN is missing, list all top-level nodes - let mut nodes = Vec::new(); - - // 1. Try to find Modules first (this is what we almost always want in root) - for idx in self.graph.topology.node_indices() { - let node = &self.graph.topology[idx]; - if node.kind() == NodeKind::Module { - let has_parent = self.graph.topology - .edges_directed(idx, PetDirection::Incoming) - .any(|e| e.weight().edge_type == EdgeType::Contains); - - if !has_parent { - nodes.push(node.clone()); - } - } - } - - // 2. If no top-level modules, but user asked for specific kind or we found nothing - if nodes.is_empty() { - for idx in self.graph.topology.node_indices() { - let node = &self.graph.topology[idx]; - let has_parent = self.graph.topology - .edges_directed(idx, PetDirection::Incoming) - .any(|e| e.weight().edge_type == EdgeType::Contains); - - if !has_parent { - if kind.is_empty() || kind.contains(&node.kind()) { - nodes.push(node.clone()); - } - } - if nodes.len() >= 50 { break; } - } - } - - Ok(QueryResult::new(nodes, vec![])) - } - } - GraphQuery::Cat { fqn } => { - if let Some(&idx) = self.graph.fqn_map.get(fqn) { - let node = &self.graph.topology[idx]; - Ok(QueryResult::new(vec![node.clone()], vec![])) - } else { - Ok(QueryResult::empty()) - } - } - GraphQuery::Deps { fqn, rev, edge_types } => { - let direction = if *rev { - PetDirection::Incoming - } else { - PetDirection::Outgoing - }; - self.traverse_neighbors(fqn, edge_types, direction, &[]) - } - } - } - - fn traverse_neighbors( - &self, - fqn: &str, - edge_filter: &[EdgeType], - dir: PetDirection, - kind_filter: &[NodeKind], - ) -> Result { - let start_idx = self - .graph - .fqn_map - .get(fqn) - .ok_or_else(|| { - // Debug log to help identify the mismatch - eprintln!("DEBUG: traverse_neighbors failed. Looking for FQN: '{}'", fqn); - eprintln!("DEBUG: Available FQNs count: {}", self.graph.fqn_map.len()); - if let Some(closest) = self.graph.fqn_map.keys().find(|k| k.contains(fqn)) { - eprintln!("DEBUG: Found something containing '{}': '{}'", fqn, closest); - } - NaviscopeError::Parsing(format!("Node not found: {}", fqn)) - })?; - - let mut nodes = Vec::new(); - let mut edges_result = Vec::new(); - let mut edges = self - .graph - .topology - .neighbors_directed(*start_idx, dir) - .detach(); - - while let Some((edge_idx, neighbor_idx)) = edges.next(&self.graph.topology) { - let edge_data = &self.graph.topology[edge_idx]; - if edge_filter.is_empty() || edge_filter.contains(&edge_data.edge_type) { - let neighbor_node = &self.graph.topology[neighbor_idx]; - let start_node = &self.graph.topology[*start_idx]; - - if kind_filter.is_empty() || kind_filter.contains(&neighbor_node.kind()) { - nodes.push(neighbor_node.clone()); - - let (from, to) = if dir == PetDirection::Outgoing { - (start_node.fqn().to_string(), neighbor_node.fqn().to_string()) - } else { - (neighbor_node.fqn().to_string(), start_node.fqn().to_string()) - }; - - edges_result.push(QueryResultEdge { - from, - to, - data: edge_data.clone(), - }); - } - } - } - - Ok(QueryResult::new(nodes, edges_result)) - } -} diff --git a/src/query/mod.rs b/src/query/mod.rs deleted file mode 100644 index 52a4403..0000000 --- a/src/query/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod dsl; -pub mod engine; -pub mod model; - -pub use dsl::GraphQuery; -pub use engine::QueryEngine; -pub use model::QueryResult; diff --git a/src/query/model.rs b/src/query/model.rs deleted file mode 100644 index 502f56b..0000000 --- a/src/query/model.rs +++ /dev/null @@ -1,31 +0,0 @@ -use crate::model::graph::{GraphEdge, GraphNode}; -use serde::{Deserialize, Serialize}; - -/// A structured edge in the query result -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct QueryResultEdge { - pub from: String, - pub to: String, - pub data: GraphEdge, -} - -/// The result of a query execution, representing a subgraph -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct QueryResult { - pub nodes: Vec, - pub edges: Vec, -} - -impl QueryResult { - pub fn new(nodes: Vec, edges: Vec) -> Self { - Self { nodes, edges } - } - - pub fn empty() -> Self { - Self::default() - } - - pub fn is_empty(&self) -> bool { - self.nodes.is_empty() - } -} diff --git a/src/resolver/engine.rs b/src/resolver/engine.rs deleted file mode 100644 index e280a2d..0000000 --- a/src/resolver/engine.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use rayon::prelude::*; -use crate::error::Result; -use crate::model::graph::{GraphOp, ResolvedUnit}; -use crate::project::scanner::ParsedFile; -use crate::project::source::{BuildTool, Language}; -use crate::resolver::{SemanticResolver, BuildResolver, LangResolver, ProjectContext}; -use crate::resolver::lang::gradle::GradleResolver; -use crate::resolver::lang::java::JavaResolver; - -/// Main resolver that dispatches to specific strategies based on file type for indexing -pub struct IndexResolver { - build_strategies: HashMap>, - lang_strategies: HashMap>, - semantic_resolvers: HashMap>, -} - -impl IndexResolver { - pub fn new() -> Self { - let mut build_strategies: HashMap> = HashMap::new(); - let mut lang_strategies: HashMap> = HashMap::new(); - let mut semantic_resolvers: HashMap> = HashMap::new(); - - // Register build strategies - build_strategies.insert(BuildTool::Gradle, Box::new(GradleResolver::new())); - - // Register language strategies - let java_resolver = JavaResolver::new(); - lang_strategies.insert(Language::Java, Box::new(java_resolver.clone())); - semantic_resolvers.insert(Language::Java, Box::new(java_resolver)); - - Self { - build_strategies, - lang_strategies, - semantic_resolvers, - } - } - - pub fn get_semantic_resolver(&self, language: Language) -> Option<&dyn SemanticResolver> { - self.semantic_resolvers.get(&language).map(|r| r.as_ref()) - } - - pub fn get_lsp_parser(&self, language: Language) -> Option> { - match language { - Language::Java => Some(Arc::new(crate::parser::java::JavaParser::new().ok()?)), - _ => None, - } - } - - pub fn get_language_by_extension(&self, ext: &str) -> Option { - match ext { - "java" => Some(Language::Java), - "gradle" => Some(Language::BuildFile), - _ => None, - } - } - - /// Resolve all parsed files into graph operations using a two-phase process - pub fn resolve(&self, files: Vec) -> Result> { - let mut all_ops = Vec::new(); - - // Add RemovePath operations for each file being processed to ensure a clean state - for file in &files { - all_ops.push(GraphOp::RemovePath { - path: file.file.path.clone(), - }); - } - - // Separate files into build and source files - let (build_files, source_files): (Vec<_>, Vec<_>) = - files.into_iter().partition(|f| f.is_build()); - - // Phase 1: Resolve Build Tools (Structure) - let mut project_context = ProjectContext::new(); - - // Group build files by tool - let mut builds_by_tool: HashMap> = HashMap::new(); - for f in &build_files { - if let Some(tool) = f.build_tool() { - builds_by_tool.entry(tool).or_default().push(f); - } - } - - for (tool, tool_files) in builds_by_tool { - if let Some(strategy) = self.build_strategies.get(&tool) { - let (unit, context) = strategy.resolve(&tool_files)?; - all_ops.extend(unit.ops); - // Merge context - project_context.path_to_module.extend(context.path_to_module); - } - } - - // Phase 2: Resolve Source Files (Entities) in parallel - let source_results: Vec> = source_files - .par_iter() - .map(|file| { - let language = file.language().unwrap_or(Language::BuildFile); - - if let Some(strategy) = self.lang_strategies.get(&language) { - strategy.resolve(file, &project_context) - } else { - Ok(ResolvedUnit::new()) - } - }) - .collect(); - - // Collect and merge source operations - for result in source_results { - all_ops.extend(result?.ops); - } - - Ok(all_ops) - } -} diff --git a/src/resolver/lang/gradle.rs b/src/resolver/lang/gradle.rs deleted file mode 100644 index 0f255d5..0000000 --- a/src/resolver/lang/gradle.rs +++ /dev/null @@ -1,269 +0,0 @@ -use crate::resolver::{BuildResolver, ProjectContext}; -use crate::error::Result; -use crate::model::graph::{EdgeType, GraphEdge, GraphNode, ResolvedUnit}; -use crate::model::lang::gradle::{GradleElement, GradleModule}; -use crate::project::scanner::{ParsedContent, ParsedFile}; -use std::collections::HashMap; - -pub struct GradleResolver; - -impl GradleResolver { - pub fn new() -> Self { - Self - } -} - -impl BuildResolver for GradleResolver { - fn resolve(&self, files: &[&ParsedFile]) -> Result<(ResolvedUnit, ProjectContext)> { - let mut unit = ResolvedUnit::new(); - let mut context = ProjectContext::new(); - - // 1. First pass: identify root and all modules - let settings_file = files - .iter() - .find(|f| matches!(f.content, ParsedContent::GradleSettings(_))); - - let mut root_name = "root".to_string(); - let mut root_path = std::path::PathBuf::new(); - let mut included_projects = Vec::new(); - - if let Some(f) = settings_file { - if let ParsedContent::GradleSettings(s) = &f.content { - if let Some(name) = &s.root_project_name { - root_name = name.clone(); - } - root_path = f.file.path.parent().unwrap().to_path_buf(); - included_projects = s.included_projects.clone(); - } - } else if let Some(first) = files.first() { - root_path = first.file.path.parent().unwrap().to_path_buf(); - root_name = root_path.file_name().and_then(|s| s.to_str()).unwrap_or("root").to_string(); - } - - let root_module_id = "module::root".to_string(); - context.path_to_module.insert(root_path.clone(), root_module_id.clone()); - - // Create root node - unit.add_node( - root_module_id.clone(), - GraphNode::gradle( - GradleElement::Module(GradleModule { - name: root_name.clone(), - id: root_module_id.clone(), - }), - None, - ), - ); - - // Pre-create all included modules to ensure nodes exist before edges - let mut module_to_path = HashMap::new(); - module_to_path.insert(":".to_string(), root_path.clone()); - - for project_path in &included_projects { - let mut current_name = String::new(); - let mut current_fs_path = root_path.clone(); - - for part in project_path.split(':') { - if part.is_empty() { continue; } - - let parent_name = if current_name.is_empty() { ":".to_string() } else { current_name.clone() }; - current_name = format!("{}:{}", current_name, part); - current_fs_path.push(part); - - let current_id = format!("module:{}", current_name); - let parent_id = if parent_name == ":" { "module::root".to_string() } else { format!("module:{}", parent_name) }; - - // Pre-create node - unit.add_node( - current_id.clone(), - GraphNode::gradle( - GradleElement::Module(GradleModule { - name: current_name.clone(), - id: current_id.clone(), - }), - None, // Will be updated if build.gradle is found - ), - ); - - unit.add_edge(parent_id, current_id.clone(), GraphEdge::new(EdgeType::Contains)); - - if current_name == format!(":{}", project_path.trim_start_matches(':')) { - module_to_path.insert(current_name.clone(), current_fs_path.clone()); - } - } - } - - // 2. Second pass: process build.gradle files to add detailed info and dependencies - for file in files { - if let ParsedContent::Gradle(parse_result) = &file.content { - let current_fs_path = file.file.path.parent().unwrap(); - - let module_name = module_to_path - .iter() - .find(|(_, path)| *path == current_fs_path) - .map(|(name, _)| name.clone()) - .unwrap_or_else(|| { - format!(":{}", current_fs_path.file_name().unwrap().to_str().unwrap()) - }); - - let module_id = if module_name == ":" { - "module::root".to_string() - } else { - format!("module:{}", module_name) - }; - - context.path_to_module.insert(current_fs_path.to_path_buf(), module_id.clone()); - - // Update node with file path (AddNode with same ID updates it) - unit.add_node( - module_id.clone(), - GraphNode::gradle( - GradleElement::Module(GradleModule { - name: if module_name == ":" { root_name.clone() } else { module_name.clone() }, - id: module_id.clone(), - }), - Some(file.file.path.clone()), - ), - ); - - for dep in &parse_result.dependencies { - if dep.is_project { - let target_module_name = if dep.name.starts_with(':') { - dep.name.clone() - } else { - format!("{}:{}", module_name, dep.name) - }; - let target_id = if target_module_name == ":" { "module::root".to_string() } else { format!("module:{}", target_module_name) }; - unit.add_edge(module_id.clone(), target_id, GraphEdge::new(EdgeType::UsesDependency)); - } else { - let group = dep.group.as_deref().unwrap_or(""); - let version = dep.version.as_deref().unwrap_or(""); - let dep_id = format!("dep:{}:{}:{}", group, dep.name, version); - - let mut dep_node = dep.clone(); - dep_node.id = dep_id.clone(); - - unit.add_node( - dep_id.clone(), - GraphNode::gradle( - GradleElement::Dependency(dep_node), - Some(file.file.path.clone()), - ), - ); - - unit.add_edge(module_id.clone(), dep_id, GraphEdge::new(EdgeType::UsesDependency)); - } - } - } - } - - Ok((unit, context)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::lang::gradle::{GradleDependency, GradleSettings, GradleParseResult}; - use crate::project::source::SourceFile; - use crate::model::graph::GraphOp; - use std::path::PathBuf; - - fn create_mock_file(path: &str, content: ParsedContent) -> ParsedFile { - ParsedFile { - file: SourceFile { - path: PathBuf::from(path), - content_hash: 0, - last_modified: 0, - }, - content, - } - } - - #[test] - fn test_resolve_multi_module_hierarchy() { - let resolver = GradleResolver::new(); - - let settings = GradleSettings { - root_project_name: Some("my-project".to_string()), - included_projects: vec!["core".to_string(), "core:api".to_string()], - }; - let settings_file = create_mock_file("/repo/settings.gradle", ParsedContent::GradleSettings(settings)); - - let root_build = create_mock_file("/repo/build.gradle", ParsedContent::Gradle(GradleParseResult { dependencies: vec![] })); - let core_build = create_mock_file("/repo/core/build.gradle", ParsedContent::Gradle(GradleParseResult { dependencies: vec![] })); - let api_build = create_mock_file("/repo/core/api/build.gradle", ParsedContent::Gradle(GradleParseResult { - dependencies: vec![ - GradleDependency { - group: None, - name: ":core".to_string(), - version: None, - is_project: true, - id: String::new(), - } - ] - })); - - let files = vec![&settings_file, &root_build, &core_build, &api_build]; - let (unit, _) = resolver.resolve(&files).unwrap(); - - let node_ids: Vec<_> = unit.ops.iter().filter_map(|op| { - if let GraphOp::AddNode { id, .. } = op { Some(id.clone()) } else { None } - }).collect(); - - assert!(node_ids.contains(&"module::root".to_string())); - assert!(node_ids.contains(&"module::core".to_string())); - assert!(node_ids.contains(&"module::core:api".to_string())); - - let contains_edges: Vec<_> = unit.ops.iter().filter_map(|op| { - if let GraphOp::AddEdge { from_id, to_id, edge } = op { - if edge.edge_type == EdgeType::Contains { Some((from_id.clone(), to_id.clone())) } else { None } - } else { None } - }).collect(); - - assert!(contains_edges.contains(&("module::root".to_string(), "module::core".to_string()))); - assert!(contains_edges.contains(&("module::core".to_string(), "module::core:api".to_string()))); - - let dep_edges: Vec<_> = unit.ops.iter().filter_map(|op| { - if let GraphOp::AddEdge { from_id, to_id, edge } = op { - if edge.edge_type == EdgeType::UsesDependency { Some((from_id.clone(), to_id.clone())) } else { None } - } else { None } - }).collect(); - - assert!(dep_edges.contains(&("module::core:api".to_string(), "module::core".to_string()))); - } - - #[test] - fn test_resolve_external_dependencies() { - let resolver = GradleResolver::new(); - - let build_file = create_mock_file("/repo/build.gradle", ParsedContent::Gradle(GradleParseResult { - dependencies: vec![ - GradleDependency { - group: Some("com.google.guava".to_string()), - name: "guava".to_string(), - version: Some("31.1-jre".to_string()), - is_project: false, - id: String::new(), - } - ] - })); - - let files = vec![&build_file]; - let (unit, _) = resolver.resolve(&files).unwrap(); - - let dep_id = "dep:com.google.guava:guava:31.1-jre".to_string(); - - let has_dep_node = unit.ops.iter().any(|op| { - if let GraphOp::AddNode { id, .. } = op { id == &dep_id } else { false } - }); - assert!(has_dep_node); - - let has_edge = unit.ops.iter().any(|op| { - if let GraphOp::AddEdge { from_id, to_id, edge } = op { - from_id == "module::root" && to_id == &dep_id && edge.edge_type == EdgeType::UsesDependency - } else { false } - }); - assert!(has_edge); - } -} diff --git a/src/resolver/lang/java/context.rs b/src/resolver/lang/java/context.rs deleted file mode 100644 index f5bc503..0000000 --- a/src/resolver/lang/java/context.rs +++ /dev/null @@ -1,66 +0,0 @@ -use crate::index::CodeGraph; -use crate::parser::SymbolIntent; -use crate::parser::java::JavaParser; -use tree_sitter::{Node, Tree}; - -pub struct ResolutionContext<'a> { - pub node: Node<'a>, - pub name: String, - pub index: &'a CodeGraph, - pub source: &'a str, - pub tree: &'a Tree, - pub intent: SymbolIntent, - pub package: Option, - pub imports: Vec, - pub enclosing_classes: Vec, - pub receiver_node: Option>, -} - -impl<'a> ResolutionContext<'a> { - pub fn new( - node: Node<'a>, - name: String, - index: &'a CodeGraph, - source: &'a str, - tree: &'a Tree, - parser: &JavaParser, - ) -> Self { - let (package, imports) = parser.extract_package_and_imports(tree, source); - let enclosing_classes = parser.get_enclosing_class_fqns(&node, source, package.as_deref()); - let intent = parser.determine_intent(&node); - - let receiver_node = node.parent().and_then(|parent| { - match parent.kind() { - "field_access" | "method_invocation" => { - parent.child_by_field_name("object") - .filter(|obj| obj.id() != node.id()) - } - "scoped_type_identifier" => { - // scoped_type_identifier in tree-sitter-java doesn't always have field names - // It's usually [scope, '.', name] - parent.child_by_field_name("scope") - .or_else(|| parent.named_child(0)) - .filter(|obj| obj.id() != node.id()) - } - "scoped_identifier" => { - parent.child_by_field_name("scope") - .filter(|obj| obj.id() != node.id()) - } - _ => None, - } - }); - - Self { - node, - name, - index, - source, - tree, - intent, - package, - imports, - enclosing_classes, - receiver_node, - } - } -} diff --git a/src/resolver/lang/java/mod.rs b/src/resolver/lang/java/mod.rs deleted file mode 100644 index 7206ab4..0000000 --- a/src/resolver/lang/java/mod.rs +++ /dev/null @@ -1,290 +0,0 @@ -use crate::resolver::{LangResolver, ProjectContext}; -use crate::error::Result; -use crate::model::graph::{EdgeType, GraphEdge, GraphNode, NodeKind, ResolvedUnit}; -use crate::resolver::SemanticResolver; -use crate::index::CodeGraph; -use crate::project::scanner::{ParsedContent, ParsedFile}; -use crate::parser::{SymbolResolution, matches_intent}; -use crate::parser::SymbolIntent; -use crate::parser::java::JavaParser; -use crate::model::lang::java::{JavaElement, JavaPackage}; -use crate::model::signature::TypeRef; -use petgraph::stable_graph::NodeIndex; -use tree_sitter::Tree; -use std::ops::ControlFlow; - -pub mod context; -pub mod scope; - -use context::ResolutionContext; -use scope::{Scope, LocalScope, MemberScope, ImportScope, BuiltinScope}; - -#[derive(Clone)] -pub struct JavaResolver { - parser: JavaParser, -} - -impl JavaResolver { - pub fn new() -> Self { - Self { - parser: JavaParser::new().expect("Failed to initialize JavaParser"), - } - } - - fn is_top_level_node(&self, node: &GraphNode) -> bool { - let kind = node.kind(); - matches!(kind, NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation) - } - - fn get_active_scopes<'a>(&'a self, ctx: &'a ResolutionContext) -> Vec> { - let mut scopes: Vec> = Vec::new(); - - if ctx.receiver_node.is_none() { - scopes.push(Box::new(LocalScope { parser: &self.parser })); - } - - scopes.push(Box::new(MemberScope { parser: &self.parser })); - scopes.push(Box::new(ImportScope { parser: &self.parser })); - - if ctx.intent == SymbolIntent::Type { - scopes.push(Box::new(BuiltinScope { parser: &self.parser })); - } - - scopes - } - - fn resolve_type_ref(&self, type_ref: &TypeRef, package: Option<&str>, imports: &[String], known_fqns: &std::collections::HashSet) -> TypeRef { - match type_ref { - TypeRef::Raw(name) => { - // 1. Check if name matches a known FQN suffix in the same file (Inner class priority) - if let Some(fqn) = known_fqns.iter().find(|k| k.ends_with(&format!(".{}", name)) || *k == name) { - // Simple heuristic: if the name matches the end of a known FQN, use it. - // This handles 'Source' -> '...DefaultApplicationArguments.Source' - return TypeRef::Id(fqn.clone()); - } - - if let Some(fqn) = self.parser.resolve_type_name_to_fqn_data(name, package, imports) { - TypeRef::Id(fqn) - } else { - TypeRef::Raw(name.clone()) - } - }, - TypeRef::Generic { base, args } => { - TypeRef::Generic { - base: Box::new(self.resolve_type_ref(base, package, imports, known_fqns)), - args: args.iter().map(|a| self.resolve_type_ref(a, package, imports, known_fqns)).collect() - } - }, - TypeRef::Array { element, dimensions } => { - TypeRef::Array { - element: Box::new(self.resolve_type_ref(element, package, imports, known_fqns)), - dimensions: *dimensions - } - }, - TypeRef::Wildcard { bound, is_upper_bound } => { - TypeRef::Wildcard { - bound: bound.as_ref().map(|b| Box::new(self.resolve_type_ref(b, package, imports, known_fqns))), - is_upper_bound: *is_upper_bound - } - }, - _ => type_ref.clone() - } - } -} - -impl SemanticResolver for JavaResolver { - fn resolve_at(&self, tree: &Tree, source: &str, line: usize, byte_col: usize, index: &CodeGraph) -> Option { - let point = tree_sitter::Point::new(line, byte_col); - let node = tree - .root_node() - .named_descendant_for_point_range(point, point) - .filter(|n| matches!(n.kind(), "identifier" | "type_identifier" | "scoped_identifier" | "this"))?; - - let name = node.utf8_text(source.as_bytes()).ok()?.to_string(); - let context = ResolutionContext::new(node, name, index, source, tree, &self.parser); - - match self.get_active_scopes(&context) - .into_iter() - .try_fold(None, |_: Option, scope| { - match scope.resolve(&context.name, &context) { - Some(Ok(res)) => ControlFlow::Break(Some(res)), - Some(Err(())) => ControlFlow::Break(None), - None => ControlFlow::Continue(None), - } - }) { - ControlFlow::Break(res) => res, - ControlFlow::Continue(_) => None, - } - } - - fn find_matches(&self, index: &CodeGraph, resolution: &SymbolResolution) -> Vec { - match resolution { - SymbolResolution::Local(_, _) => vec![], - SymbolResolution::Precise(fqn, intent) => { - if let Some(&idx) = index.fqn_map.get(fqn) { - if let Some(node) = index.topology.node_weight(idx) { - if *intent == SymbolIntent::Unknown || matches_intent(&node.kind(), *intent) { - return vec![idx]; - } - } - } - vec![] - } - } - } - - fn resolve_type_of(&self, index: &CodeGraph, resolution: &SymbolResolution) -> Vec { - // Reuse original logic - let mut type_resolutions = Vec::new(); - match resolution { - SymbolResolution::Local(_, type_name) => { - if let Some(tn) = type_name { - if let Some(fqn) = self.parser.resolve_type_name_to_fqn_data(tn, None, &[]) { - type_resolutions.push(SymbolResolution::Precise(fqn, SymbolIntent::Type)); - } - } - } - SymbolResolution::Precise(fqn, intent) => { - if let Some(&idx) = index.fqn_map.get(fqn) { - let node = &index.topology[idx]; - if let GraphNode::Code(crate::model::graph::CodeElement::Java { element, .. }) = node { - match element { - crate::model::lang::java::JavaElement::Field(f) => { - if let crate::model::signature::TypeRef::Raw(s) = &f.type_ref { - type_resolutions.push(SymbolResolution::Precise(s.clone(), SymbolIntent::Type)) - } - } - crate::model::lang::java::JavaElement::Method(m) => { - if let crate::model::signature::TypeRef::Raw(s) = &m.return_type { - type_resolutions.push(SymbolResolution::Precise(s.clone(), SymbolIntent::Type)) - } - } - _ => { - if matches_intent(&node.kind(), SymbolIntent::Type) { - type_resolutions.push(resolution.clone()); - } - } - } - } - } else if *intent == SymbolIntent::Type { - type_resolutions.push(resolution.clone()); - } - } - } - type_resolutions - } - - fn find_implementations(&self, index: &CodeGraph, resolution: &SymbolResolution) -> Vec { - let target_nodes = self.find_matches(index, resolution); - let mut results = Vec::new(); - - for &node_idx in &target_nodes { - let mut incoming = index - .topology - .neighbors_directed(node_idx, petgraph::Direction::Incoming) - .detach(); - while let Some((edge_idx, neighbor_idx)) = incoming.next(&index.topology) { - let edge = &index.topology[edge_idx]; - if edge.edge_type == EdgeType::Implements - || edge.edge_type == EdgeType::InheritsFrom - { - results.push(neighbor_idx); - } - } - } - results - } -} - -impl LangResolver for JavaResolver { - fn resolve(&self, file: &ParsedFile, context: &ProjectContext) -> Result { - let mut unit = ResolvedUnit::new(); - - if let ParsedContent::Java(parse_result) = &file.content { - let module_id = context - .find_module_for_path(&file.file.path) - .unwrap_or_else(|| "module::root".to_string()); - - let container_id = if let Some(pkg_name) = &parse_result.package_name { - let package_id = if module_id.contains("::") { - format!("{}.{}", module_id, pkg_name) - } else { - format!("{}::{}", module_id, pkg_name) - }; - - // Create package node - unit.add_node( - package_id.clone(), - GraphNode::java( - JavaElement::Package(JavaPackage { - name: pkg_name.clone(), - id: package_id.clone(), - }), - None, - ), - ); - - // Link package to module - unit.add_edge(module_id.clone(), package_id.clone(), GraphEdge::new(EdgeType::Contains)); - - package_id - } else { - module_id - }; - - let mut known_fqns = std::collections::HashSet::new(); - for node in &parse_result.nodes { - if self.is_top_level_node(node) || matches!(node.kind(), NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation) { - known_fqns.insert(node.fqn().to_string()); - } - } - - for node in &parse_result.nodes { - let fqn = node.fqn(); - let mut node = node.clone(); - - // Enhance node with resolved types - if let GraphNode::Code(crate::model::graph::CodeElement::Java { element, .. }) = &mut node { - match element { - crate::model::lang::java::JavaElement::Method(m) => { - m.return_type = self.resolve_type_ref(&m.return_type, parse_result.package_name.as_deref(), &parse_result.imports, &known_fqns); - for param in &mut m.parameters { - param.type_ref = self.resolve_type_ref(¶m.type_ref, parse_result.package_name.as_deref(), &parse_result.imports, &known_fqns); - } - }, - crate::model::lang::java::JavaElement::Field(f) => { - f.type_ref = self.resolve_type_ref(&f.type_ref, parse_result.package_name.as_deref(), &parse_result.imports, &known_fqns); - }, - _ => {} - } - } - - unit.add_node(fqn.to_string(), node.clone()); - if self.is_top_level_node(&node) { - unit.add_edge(container_id.clone(), fqn.to_string(), GraphEdge::new(EdgeType::Contains)); - } - } - - for (source_fqn, target_fqn, edge_type, range) in &parse_result.relations { - let mut resolved_target = target_fqn.clone(); - if !target_fqn.contains('.') { - // Try to resolve using known FQNs first - if let Some(fqn) = known_fqns.iter().find(|k| k.ends_with(&format!(".{}", target_fqn)) || *k == target_fqn) { - resolved_target = fqn.clone(); - } else if let Some(res) = self.parser.resolve_type_name_to_fqn_data( - target_fqn, - parse_result.package_name.as_deref(), - &parse_result.imports, - ) { - resolved_target = res; - } - } - let mut edge = GraphEdge::new(edge_type.clone()); - edge.range = *range; - unit.add_edge(source_fqn.clone(), resolved_target, edge); - } - } - - Ok(unit) - } -} diff --git a/src/resolver/lang/java/scope/builtin.rs b/src/resolver/lang/java/scope/builtin.rs deleted file mode 100644 index 4625bfe..0000000 --- a/src/resolver/lang/java/scope/builtin.rs +++ /dev/null @@ -1,71 +0,0 @@ -use crate::parser::java::JavaParser; -use crate::parser::SymbolResolution; -use crate::parser::SymbolIntent; -use crate::resolver::lang::java::context::ResolutionContext; -use crate::resolver::scope::SemanticScope; - -pub struct BuiltinScope<'a> { - pub parser: &'a JavaParser, -} - -impl SemanticScope> for BuiltinScope<'_> { - fn resolve(&self, name: &str, context: &ResolutionContext) -> Option> { - if context.intent != SymbolIntent::Type { - return None; - } - - self.parser.resolve_type_name_to_fqn_data(name, context.package.as_deref(), &context.imports) - .and_then(|fqn| { - // Only return if it's a known FQN or a primitive or java.lang - if context.index.fqn_map.contains_key(&fqn) || fqn.starts_with("java.lang.") || !fqn.contains('.') { - Some(Ok(SymbolResolution::Precise(fqn, SymbolIntent::Type))) - } else { - None - } - }) - } - fn name(&self) -> &'static str { "Builtin" } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::index::CodeGraph; - use tree_sitter::Parser; - - #[test] - fn test_builtin_scope_java_lang() { - let source = "class Test { String s; }"; - let mut parser = Parser::new(); - parser.set_language(&crate::parser::java::JavaParser::new().unwrap().language).expect("Error loading Java grammar"); - let tree = parser.parse(source, None).unwrap(); - - let string_node = tree.root_node().named_descendant_for_point_range( - tree_sitter::Point::new(0, 13), - tree_sitter::Point::new(0, 19) - ).unwrap(); - - let java_parser = JavaParser::new().unwrap(); - let index = CodeGraph::new(); - - let context = ResolutionContext::new( - string_node, - "String".to_string(), - &index, - source, - &tree, - &java_parser, - ); - - let scope = BuiltinScope { parser: &java_parser }; - let res = scope.resolve("String", &context); - - assert!(res.is_some()); - match res.unwrap() { - Ok(SymbolResolution::Precise(fqn, _)) => { - assert_eq!(fqn, "java.lang.String"); - } - _ => panic!("Expected Precise resolution"), - } - } -} diff --git a/src/resolver/lang/java/scope/import_scope.rs b/src/resolver/lang/java/scope/import_scope.rs deleted file mode 100644 index c6643e4..0000000 --- a/src/resolver/lang/java/scope/import_scope.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::parser::java::JavaParser; -use crate::parser::SymbolResolution; -use crate::resolver::lang::java::context::ResolutionContext; -use crate::resolver::scope::SemanticScope; - -pub struct ImportScope<'a> { - pub parser: &'a JavaParser, -} - -impl SemanticScope> for ImportScope<'_> { - fn resolve(&self, name: &str, context: &ResolutionContext) -> Option> { - // 1. Precise imports - context.imports.iter() - .find(|imp| imp.ends_with(&format!(".{}", name))) - .map(|imp| Ok(SymbolResolution::Precise(imp.clone(), context.intent))) - .or_else(|| { - // 2. Current package - context.package.as_ref() - .map(|pkg| format!("{}.{}", pkg, name)) - .and_then(|candidate| context.index.fqn_map.contains_key(&candidate).then_some(candidate)) - .map(|fqn| Ok(SymbolResolution::Precise(fqn, context.intent))) - }) - } - fn name(&self) -> &'static str { "Import" } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::index::CodeGraph; - use tree_sitter::Parser; - - #[test] - fn test_import_scope_precise() { - let source = "import java.util.List; class Test { List x; }"; - let mut parser = Parser::new(); - parser.set_language(&crate::parser::java::JavaParser::new().unwrap().language).expect("Error loading Java grammar"); - let tree = parser.parse(source, None).unwrap(); - - let list_node = tree.root_node().named_descendant_for_point_range( - tree_sitter::Point::new(0, 36), - tree_sitter::Point::new(0, 40) - ).unwrap(); - - let java_parser = JavaParser::new().unwrap(); - let index = CodeGraph::new(); - - let context = ResolutionContext::new( - list_node, - "List".to_string(), - &index, - source, - &tree, - &java_parser, - ); - - let scope = ImportScope { parser: &java_parser }; - let res = scope.resolve("List", &context); - - assert!(res.is_some()); - match res.unwrap() { - Ok(SymbolResolution::Precise(fqn, _)) => { - assert_eq!(fqn, "java.util.List"); - } - _ => panic!("Expected Precise resolution"), - } - } -} diff --git a/src/resolver/lang/java/scope/member.rs b/src/resolver/lang/java/scope/member.rs deleted file mode 100644 index e9014f2..0000000 --- a/src/resolver/lang/java/scope/member.rs +++ /dev/null @@ -1,274 +0,0 @@ -use crate::parser::java::JavaParser; -use crate::parser::SymbolResolution; -use crate::model::lang::java::JavaElement; -use crate::model::graph::GraphNode; -use crate::model::signature::TypeRef; -use crate::resolver::lang::java::context::ResolutionContext; -use crate::resolver::scope::SemanticScope; - -pub struct MemberScope<'a> { - pub parser: &'a JavaParser, -} - -impl MemberScope<'_> { - fn resolve_type_ref_fqns(&self, type_ref: &TypeRef, context: &ResolutionContext) -> TypeRef { - match type_ref { - TypeRef::Raw(name) | TypeRef::Id(name) => { - if let Some(fqn) = self.parser.resolve_type_name_to_fqn(name, context.tree, context.source) { - TypeRef::Id(fqn) - } else { - TypeRef::Raw(name.clone()) - } - }, - TypeRef::Generic { base, args } => { - TypeRef::Generic { - base: Box::new(self.resolve_type_ref_fqns(base, context)), - args: args.iter().map(|a| self.resolve_type_ref_fqns(a, context)).collect() - } - }, - TypeRef::Array { element, dimensions } => { - TypeRef::Array { - element: Box::new(self.resolve_type_ref_fqns(element, context)), - dimensions: *dimensions - } - }, - TypeRef::Wildcard { bound, is_upper_bound } => { - TypeRef::Wildcard { - bound: bound.as_ref().map(|b| Box::new(self.resolve_type_ref_fqns(b, context))), - is_upper_bound: *is_upper_bound - } - }, - _ => type_ref.clone() - } - } - - fn get_base_fqn(&self, type_ref: &TypeRef) -> Option { - match type_ref { - TypeRef::Id(s) | TypeRef::Raw(s) => Some(s.clone()), - TypeRef::Generic { base, .. } => self.get_base_fqn(base), - _ => None, - } - } - - fn resolve_fqn_from_context(&self, name: &str, context: &ResolutionContext) -> Option { - // 1. Check if it's already an FQN in the index - if context.index.fqn_map.contains_key(name) { - return Some(name.to_string()); - } - - // 2. Check inner classes in enclosing classes - for container_fqn in &context.enclosing_classes { - let candidate = format!("{}.{}", container_fqn, name); - if context.index.fqn_map.contains_key(&candidate) { - return Some(candidate); - } - } - - // 3. Use parser's resolution (imports/package) - if let Some(fqn) = self.parser.resolve_type_name_to_fqn(name, context.tree, context.source) { - if fqn != name { - return Some(fqn); - } - } - - Some(name.to_string()) - } - - fn resolve_expression_type(&self, node: &tree_sitter::Node, context: &ResolutionContext) -> Option { - let kind = node.kind(); - match kind { - "identifier" | "type_identifier" => { - let name = node.utf8_text(context.source.as_bytes()).ok()?; - // 1. Local Scope - if let Some((_, maybe_type_node)) = self.parser.find_local_declaration_node(*node, name, context.source) { - if let Some(type_node) = maybe_type_node { - // Parse the type node properly to handle generics - let type_ref = self.parser.parse_type_node(type_node, context.source); - - // Resolve FQNs within the parsed type ref - let resolved_type_ref = self.resolve_type_ref_fqns(&type_ref, context); - return Some(resolved_type_ref); - } - - // Heuristic: Try to infer lambda parameter type - return self.infer_lambda_param_type(node, context); - } - // 2. Lexical Scope - for container_fqn in &context.enclosing_classes { - let candidate = format!("{}.{}", container_fqn, name); - if let Some(&idx) = context.index.fqn_map.get(&candidate) { - let node = &context.index.topology[idx]; - if let GraphNode::Code(crate::model::graph::CodeElement::Java { element: JavaElement::Field(f), .. }) = node { - return Some(f.type_ref.clone()); - } - return Some(TypeRef::Id(candidate)); - } - } - // 3. Global Scope (Check if it's a known class FQN in the index) - let fqn = self.parser.resolve_type_name_to_fqn(name, context.tree, context.source)?; - - // If it's a known class, return it. - if context.index.fqn_map.contains_key(&fqn) { - return Some(TypeRef::Id(fqn.clone())); - } - - // Fallback: maybe it's a package or a class not yet in index but resolvable via imports - Some(TypeRef::Id(fqn)) - } - "field_access" => { - let receiver = node.child_by_field_name("object")?; - let field_name = node.child_by_field_name("field")?.utf8_text(context.source.as_bytes()).ok()?; - let receiver_type_ref = self.resolve_expression_type(&receiver, context)?; - let raw_receiver_type = self.get_base_fqn(&receiver_type_ref)?; - let receiver_type = self.resolve_fqn_from_context(&raw_receiver_type, context)?; - - let field_fqn = format!("{}.{}", receiver_type, field_name); - - if let Some(&idx) = context.index.fqn_map.get(&field_fqn) { - if let GraphNode::Code(crate::model::graph::CodeElement::Java { element: JavaElement::Field(f), .. }) = &context.index.topology[idx] { - return Some(f.type_ref.clone()); - } - } - None - } - "method_invocation" => { - let receiver = node.child_by_field_name("object")?; - let method_name = node.child_by_field_name("name")?.utf8_text(context.source.as_bytes()).ok()?; - let receiver_type_ref = self.resolve_expression_type(&receiver, context)?; - let raw_receiver_type = self.get_base_fqn(&receiver_type_ref)?; - let receiver_type = self.resolve_fqn_from_context(&raw_receiver_type, context)?; - - let method_fqn = format!("{}.{}", receiver_type, method_name); - if let Some(&idx) = context.index.fqn_map.get(&method_fqn) { - if let GraphNode::Code(crate::model::graph::CodeElement::Java { element: JavaElement::Method(m), .. }) = &context.index.topology[idx] { - return Some(m.return_type.clone()); - } - } - None - } - "this" => context.enclosing_classes.first().map(|s| TypeRef::Id(s.clone())), - "scoped_type_identifier" | "scoped_identifier" => { - let receiver = node.child_by_field_name("scope")?; - let name = node.child_by_field_name("name")?.utf8_text(context.source.as_bytes()).ok()?; - let receiver_type_ref = self.resolve_expression_type(&receiver, context)?; - let receiver_type = self.get_base_fqn(&receiver_type_ref)?; - Some(TypeRef::Id(format!("{}.{}", receiver_type, name))) - } - _ => None - } - } - - fn infer_lambda_param_type(&self, node: &tree_sitter::Node, context: &ResolutionContext) -> Option { - let mut curr = *node; - while let Some(parent) = curr.parent() { - if parent.kind() == "lambda_expression" { - return self.resolve_lambda_type_from_parent(&parent, context); - } - curr = parent; - } - None - } - - fn resolve_lambda_type_from_parent(&self, lambda_node: &tree_sitter::Node, context: &ResolutionContext) -> Option { - let invocation = lambda_node.parent().filter(|n| n.kind() == "argument_list")?; - let method_call = invocation.parent().filter(|n| n.kind() == "method_invocation")?; - - let method_name = method_call - .child_by_field_name("name")? - .utf8_text(context.source.as_bytes()) - .ok()?; - - if !matches!(method_name, "forEach" | "filter" | "map" | "anyMatch" | "allMatch") { - return None; - } - - let receiver = method_call.child_by_field_name("object")?; - let receiver_type = self.resolve_expression_type(&receiver, context)?; - - self.extract_first_generic_arg(&receiver_type) - } - - fn extract_first_generic_arg(&self, type_ref: &TypeRef) -> Option { - if let TypeRef::Generic { args, .. } = type_ref { - args.first().cloned() - } else { - None - } - } -} - -impl SemanticScope> for MemberScope<'_> { - fn resolve(&self, name: &str, context: &ResolutionContext) -> Option> { - if name == "this" { - return context.enclosing_classes.first().cloned() - .map(|fqn| Ok(SymbolResolution::Precise(fqn, context.intent))); - } - context.receiver_node - .map(|recv| { - // Case A: Explicit Receiver (obj.field) - self.resolve_expression_type(&recv, context) - .and_then(|type_ref| self.get_base_fqn(&type_ref)) - .and_then(|raw_type_fqn| self.resolve_fqn_from_context(&raw_type_fqn, context)) - .map(|type_fqn| format!("{}.{}", type_fqn, name)) - .and_then(|candidate| context.index.fqn_map.contains_key(&candidate).then_some(candidate)) - .map(|fqn| Ok(SymbolResolution::Precise(fqn, context.intent))) - .unwrap_or(Err(())) - }) - .or_else(|| { - // Case B: Implicit this (Lexical Scope) - context.enclosing_classes.iter() - .map(|container_fqn| format!("{}.{}", container_fqn, name)) - .find(|candidate| context.index.fqn_map.contains_key(candidate)) - .map(|fqn| Ok(SymbolResolution::Precise(fqn, context.intent))) - }) - } - fn name(&self) -> &'static str { "Member" } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::index::CodeGraph; - use tree_sitter::Parser; - - #[test] - fn test_member_scope_implicit_this() { - let source = "class Test { int field; void main() { field = 1; } }"; - let mut parser = Parser::new(); - parser.set_language(&crate::parser::java::JavaParser::new().unwrap().language).expect("Error loading Java grammar"); - let tree = parser.parse(source, None).unwrap(); - - // Find the 'field' in field = 1 - let field_node = tree.root_node().named_descendant_for_point_range( - tree_sitter::Point::new(0, 38), - tree_sitter::Point::new(0, 43) - ).unwrap(); - - let java_parser = JavaParser::new().unwrap(); - let mut index = CodeGraph::new(); - // Register Test.field in index - index.fqn_map.insert("Test.field".to_string(), petgraph::graph::NodeIndex::new(0)); - - let context = ResolutionContext::new( - field_node, - "field".to_string(), - &index, - source, - &tree, - &java_parser, - ); - - let scope = MemberScope { parser: &java_parser }; - let res = scope.resolve("field", &context); - - assert!(res.is_some()); - match res.unwrap() { - Ok(SymbolResolution::Precise(fqn, _intent)) => { - assert_eq!(fqn, "Test.field"); - // The intent check might fail because determine_intent relies on the parent node context - // which might be different in this isolated test string - } - _ => panic!("Expected Precise resolution"), - } - } -} diff --git a/src/resolver/lang/mod.rs b/src/resolver/lang/mod.rs deleted file mode 100644 index b31aff2..0000000 --- a/src/resolver/lang/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod gradle; -pub mod java; diff --git a/tests/common/mod.rs b/tests/common/mod.rs deleted file mode 100644 index 6ecb348..0000000 --- a/tests/common/mod.rs +++ /dev/null @@ -1,84 +0,0 @@ -use naviscope::index::CodeGraph; -use naviscope::model::graph::GraphOp; -use naviscope::parser::java::JavaParser; -use naviscope::parser::IndexParser; -use naviscope::resolver::lang::java::JavaResolver; -use naviscope::project::scanner::{ParsedFile, ParsedContent}; -use naviscope::project::source::SourceFile; -use naviscope::resolver::ProjectContext; -use std::path::PathBuf; -use tree_sitter::Parser; - -pub fn setup_java_test_graph(files: Vec<(&str, &str)>) -> (CodeGraph, Vec<(PathBuf, String, tree_sitter::Tree)>) { - let mut index = CodeGraph::new(); - let mut parsed_files = Vec::new(); - let java_parser = JavaParser::new().unwrap(); - let mut ts_parser = Parser::new(); - ts_parser.set_language(&java_parser.language).unwrap(); - - // Phase 1: Parse all files to get entities and build the graph - let mut all_parsed_files = Vec::new(); - for (path_str, content) in files { - let path = PathBuf::from(path_str); - let res = java_parser.parse_file(content, Some(&path)).unwrap(); - let source_file = SourceFile::new(path.clone(), 0, 0); - let parsed_file = ParsedFile { - file: source_file, - content: ParsedContent::Java(res), - }; - all_parsed_files.push((parsed_file, content.to_string())); - } - - // Phase 2: Resolve (using JavaResolver's LangResolver implementation) - let resolver = JavaResolver::new(); - let context = ProjectContext::new(); // Empty context for simple tests - - let mut all_ops = Vec::new(); - - for (pf, content) in all_parsed_files { - let tree = ts_parser.parse(&content, None).unwrap(); - - // Use LangResolver to get graph operations - use naviscope::resolver::LangResolver; - let unit = resolver.resolve(&pf, &context).unwrap(); - all_ops.extend(unit.ops); - - parsed_files.push((pf.file.path.clone(), content.to_string(), tree)); - } - - // Apply Node operations first - for op in &all_ops { - if let GraphOp::AddNode { .. } = op { - apply_op_to_graph(&mut index, op.clone()); - } - } - - // Apply Edge operations next - for op in &all_ops { - if let GraphOp::AddEdge { .. } = op { - apply_op_to_graph(&mut index, op.clone()); - } - } - - (index, parsed_files) -} - -pub fn apply_op_to_graph(index: &mut CodeGraph, op: GraphOp) { - match op { - GraphOp::AddNode { id, data } => { - let path = data.file_path().cloned(); - let idx = index.get_or_create_node(&id, data); - if let Some(p) = path { - index.path_to_nodes.entry(p).or_default().push(idx); - } - } - GraphOp::AddEdge { from_id, to_id, edge } => { - let from_idx = index.fqn_map.get(&from_id).cloned(); - let to_idx = index.fqn_map.get(&to_id).cloned(); - if let (Some(s_idx), Some(t_idx)) = (from_idx, to_idx) { - index.topology.add_edge(s_idx, t_idx, edge); - } - } - _ => {} - } -} diff --git a/tests/edge_verification.rs b/tests/edge_verification.rs deleted file mode 100644 index 9e08134..0000000 --- a/tests/edge_verification.rs +++ /dev/null @@ -1,151 +0,0 @@ -mod common; -use common::setup_java_test_graph; -use naviscope::index::CodeGraph; -use naviscope::model::graph::EdgeType; - -/// Helper assertion: Verify that an edge of the specified type exists from source to target in the graph -fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: EdgeType) { - let from_idx = graph.fqn_map.get(from_fqn); - let to_idx = graph.fqn_map.get(to_fqn); - - if from_idx.is_none() { - println!("Available nodes:"); - for (id, _) in &graph.fqn_map { - println!(" - {}", id); - } - panic!("Source node not found: {}", from_fqn); - } - if to_idx.is_none() { - println!("Available nodes:"); - for (id, _) in &graph.fqn_map { - println!(" - {}", id); - } - panic!("Target node not found: {}", to_fqn); - } - - let edge_idx = graph.topology.find_edge(*from_idx.unwrap(), *to_idx.unwrap()); - - if edge_idx.is_none() { - println!("Graph nodes:"); - for (id, _) in &graph.fqn_map { - println!(" - {}", id); - } - println!("Edges from {}:", from_fqn); - let mut edges = graph.topology.neighbors_directed(*from_idx.unwrap(), petgraph::Direction::Outgoing).detach(); - while let Some((e_idx, target_idx)) = edges.next(&graph.topology) { - let target_node = &graph.topology[target_idx]; - let edge = &graph.topology[e_idx]; - println!(" -> {} ({:?})", target_node.fqn(), edge.edge_type); - } - panic!("Edge not found between {} and {}", from_fqn, to_fqn); - } - - let edge_weight = graph.topology.edge_weight(edge_idx.unwrap()).unwrap(); - assert_eq!(edge_weight.edge_type, expected_type, - "Edge type mismatch for {} -> {}. Expected {:?}, got {:?}", - from_fqn, to_fqn, expected_type, edge_weight.edge_type); -} - -#[test] -fn test_edge_contains() { - let files = vec![ - ("src/com/test/Container.java", r#" - package com.test; - public class Container { - private int field; - public void method() {} - } - "#), - ]; - let (index, _) = setup_java_test_graph(files); - - // Class -> Field - assert_edge(&index, "com.test.Container", "com.test.Container.field", EdgeType::Contains); - // Class -> Method - assert_edge(&index, "com.test.Container", "com.test.Container.method", EdgeType::Contains); -} - -#[test] -fn test_edge_inherits_from() { - let files = vec![ - ("src/Parent.java", "public class Parent {}"), - ("src/Child.java", "public class Child extends Parent {}"), - ]; - let (index, _) = setup_java_test_graph(files); - - assert_edge(&index, "Child", "Parent", EdgeType::InheritsFrom); -} - -#[test] -fn test_edge_implements() { - let files = vec![ - ("src/IAction.java", "public interface IAction {}"), - ("src/ActionImpl.java", "public class ActionImpl implements IAction {}"), - ]; - let (index, _) = setup_java_test_graph(files); - - assert_edge(&index, "ActionImpl", "IAction", EdgeType::Implements); -} - -#[test] -fn test_edge_calls() { - let files = vec![ - ("src/Service.java", r#" - package com.test; - public class Service { - void run() { com.test.Service.helper(); } - static void helper() {} - } - "#), - ]; - let (index, _) = setup_java_test_graph(files); - - // Using FQN in call to ensure resolution works in batch mode - assert_edge(&index, "com.test.Service.run", "com.test.Service.helper", EdgeType::Calls); -} - -#[test] -fn test_edge_instantiates() { - let files = vec![ - ("src/Factory.java", r#" - public class Factory { - void create() { new Product(); } - } - "#), - ("src/Product.java", "public class Product {}"), - ]; - let (index, _) = setup_java_test_graph(files); - - assert_edge(&index, "Factory.create", "Product", EdgeType::Instantiates); -} - -#[test] -fn test_edge_typed_as() { - let files = vec![ - ("src/User.java", r#" - public class User { - private String name; - private Address address; - } - "#), - ("src/Address.java", "public class Address {}"), - ]; - let (index, _) = setup_java_test_graph(files); - - assert_edge(&index, "User.address", "Address", EdgeType::TypedAs); -} - -#[test] -#[ignore] -fn test_edge_decorated_by() { - let files = vec![ - ("src/Component.java", r#" - @CustomAnnotation - public class Component {} - "#), - ("src/CustomAnnotation.java", "public @interface CustomAnnotation {}"), - ]; - let (index, _) = setup_java_test_graph(files); - - assert_edge(&index, "Component", "CustomAnnotation", EdgeType::DecoratedBy); -} diff --git a/tree-sitter-groovy b/tree-sitter-groovy deleted file mode 160000 index 8691159..0000000 --- a/tree-sitter-groovy +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 86911590a8e46d71301c66468e5620d9faa5b6af diff --git a/tree-sitter-java b/tree-sitter-java deleted file mode 160000 index e10607b..0000000 --- a/tree-sitter-java +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e10607b45ff745f5f876bfa3e94fbcc6b44bdc11