diff --git a/.gitignore b/.gitignore index 41eee24..a40d227 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ node_modules/ out/ -*.vsix \ No newline at end of file +*.vsix + +plan \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 3bdc5f8..82653dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -9,6 +26,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -130,14 +148,14 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.7.9" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "async-trait", "axum-core", "base64", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -150,15 +168,14 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.24.0", + "tokio-tungstenite", "tower 0.5.3", "tower-layer", "tower-service", @@ -167,19 +184,17 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -250,6 +265,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cc" version = "1.2.55" @@ -282,6 +306,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.57" @@ -328,6 +362,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.10.0" @@ -352,6 +392,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -491,6 +555,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deflate64" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + [[package]] name = "deranged" version = "0.5.5" @@ -530,6 +600,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -630,6 +701,25 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "fnv" version = "1.0.7" @@ -782,6 +872,21 @@ dependencies = [ "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasip3", + "wasm-bindgen", +] + [[package]] name = "globset" version = "0.4.18" @@ -827,6 +932,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -1013,6 +1127,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1088,6 +1208,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1166,6 +1295,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.180" @@ -1238,6 +1379,29 @@ dependencies = [ "url", ] +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "lzma-rust2" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d673a11333485e7d8b93d62a9a5b07b22daf5e8a8655a44c1bb18aa4bf3d1524" +dependencies = [ + "crc", + "sha2", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1249,15 +1413,24 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] [[package]] name = "mimalloc" @@ -1274,6 +1447,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1288,19 +1471,19 @@ dependencies = [ [[package]] name = "naviscope-api" -version = "0.5.5" +version = "0.7.0" dependencies = [ "async-trait", "lasso", "schemars", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", ] [[package]] name = "naviscope-cli" -version = "0.5.5" +version = "0.7.0" dependencies = [ "clap", "dirs", @@ -1324,15 +1507,17 @@ dependencies = [ [[package]] name = "naviscope-core" -version = "0.5.5" +version = "0.7.0" dependencies = [ "async-trait", "dashmap 6.1.0", + "dirs", "ignore", "lasso", "log", - "lsp-types", + "lsp-types 0.97.0", "naviscope-api", + "naviscope-java", "naviscope-plugin", "notify", "once_cell", @@ -1342,9 +1527,10 @@ dependencies = [ "rmp-serde", "schemars", "serde", + "serde_bytes", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-util", "tracing", @@ -1360,9 +1546,11 @@ dependencies = [ [[package]] name = "naviscope-gradle" -version = "0.5.5" +version = "0.7.0" dependencies = [ + "dirs", "lasso", + "lsp-types 0.97.0", "naviscope-api", "naviscope-core", "naviscope-plugin", @@ -1371,32 +1559,41 @@ dependencies = [ "rmp-serde", "serde", "serde_json", - "thiserror 2.0.18", + "tempfile", + "thiserror", "tree-sitter", "tree-sitter-groovy", + "walkdir", ] [[package]] name = "naviscope-java" -version = "0.5.5" +version = "0.7.0" dependencies = [ + "dirs", "lasso", - "lsp-types", + "lsp-types 0.97.0", "naviscope-api", "naviscope-core", "naviscope-plugin", "petgraph", + "regex", + "ristretto_classfile", + "ristretto_jimage", "rmp-serde", "serde", "serde_json", - "thiserror 2.0.18", + "tempfile", + "thiserror", + "tokio", "tree-sitter", "tree-sitter-java", + "zip", ] [[package]] name = "naviscope-lsp" -version = "0.5.5" +version = "0.7.0" dependencies = [ "dashmap 6.1.0", "naviscope-api", @@ -1413,7 +1610,7 @@ dependencies = [ [[package]] name = "naviscope-mcp" -version = "0.5.5" +version = "0.7.0" dependencies = [ "anyhow", "axum", @@ -1424,7 +1621,7 @@ dependencies = [ "serde", "serde_json", "tokio", - "tokio-tungstenite 0.28.0", + "tokio-tungstenite", "tokio-util", "tower-lsp", "tracing", @@ -1433,20 +1630,21 @@ dependencies = [ [[package]] name = "naviscope-plugin" -version = "0.5.5" +version = "0.7.0" dependencies = [ "async-trait", - "lsp-types", + "lsp-types 0.97.0", "naviscope-api", "serde", + "serde_bytes", "serde_json", - "thiserror 2.0.18", + "thiserror", "tree-sitter", ] [[package]] name = "naviscope-runtime" -version = "0.5.5" +version = "0.7.0" dependencies = [ "naviscope-api", "naviscope-core", @@ -1564,6 +1762,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1636,6 +1844,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1645,6 +1859,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1691,35 +1915,14 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -1729,16 +1932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "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", + "rand_core", ] [[package]] @@ -1787,7 +1981,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1805,7 +1999,7 @@ dependencies = [ "strip-ansi-escapes", "strum", "strum_macros", - "thiserror 2.0.18", + "thiserror", "unicase", "unicode-segmentation", "unicode-width", @@ -1860,11 +2054,36 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "ristretto_classfile" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce028b4c8fd5d7d8363ad78154324eb31576ac464a2f8c77910066573510c7c3" +dependencies = [ + "ahash", + "bitflags 2.10.0", + "byteorder", + "indexmap", + "thiserror", +] + +[[package]] +name = "ristretto_jimage" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e049dd1c0f2ffafb0ac0a7f82558d59fb0690ac388c3f834558ed0fd416e59cd" +dependencies = [ + "byteorder", + "memchr", + "memmap2", + "thiserror", +] + [[package]] name = "rmcp" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1815dbc06c414d720f8bc1951eccd66bc99efc6376331f1e7093a119b3eb508" +checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" dependencies = [ "async-trait", "base64", @@ -1876,7 +2095,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-util", "tracing", @@ -1884,9 +2103,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f0bc7008fa102e771a76c6d2c9b253be3f2baa5964e060464d038ae1cbc573" +checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe" dependencies = [ "darling", "proc-macro2", @@ -2005,6 +2224,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -2095,6 +2324,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2141,6 +2381,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.12" @@ -2209,6 +2455,12 @@ 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" @@ -2283,33 +2535,13 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -2334,12 +2566,13 @@ dependencies = [ [[package]] name = "time" -version = "0.3.46" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -2355,9 +2588,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -2400,18 +2633,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.24.0", -] - [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -2421,7 +2642,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.28.0", + "tungstenite", ] [[package]] @@ -2485,7 +2706,7 @@ dependencies = [ "dashmap 5.5.3", "futures", "httparse", - "lsp-types", + "lsp-types 0.94.1", "memchr", "serde", "serde_json", @@ -2532,7 +2753,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror 2.0.18", + "thiserror", "time", "tracing-subscriber", ] @@ -2629,38 +2850,26 @@ checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ - "byteorder", "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.8.5", + "rand", "sha1", - "thiserror 1.0.69", + "thiserror", "utf-8", ] [[package]] -name = "tungstenite" -version = "0.28.0" +name = "typed-path" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.2", - "sha1", - "thiserror 2.0.18", - "utf-8", -] +checksum = "3015e6ce46d5ad8751e4a772543a30c7511468070e98e64e20165f8f81155b64" [[package]] name = "typenum" @@ -2692,6 +2901,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.8" @@ -2769,6 +2984,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -2814,6 +3038,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3065,6 +3323,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -3142,6 +3482,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerotrie" version = "0.2.3" @@ -3175,12 +3535,57 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b32dd4ad3aca14ae109f8cce0495ac1c57f6f4f00ad459a40e582f89440d97" +dependencies = [ + "aes", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.4.1", + "hmac", + "indexmap", + "lzma-rust2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" + [[package]] name = "zmij" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 84526ff..55f91c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,11 +26,11 @@ petgraph = { version = "0.8", features = ["serde-1"] } tree-sitter = "0.26" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_bytes = "0.11.19" thiserror = "2.0" walkdir = "2.5" log = "0.4" ignore = "0.4.25" -rayon = "1.11.0" notify = "8.2.0" xxhash-rust = { version = "0.8.15", features = ["xxh3"] } regex = "1.11.1" @@ -42,7 +42,7 @@ tracing-appender = "0.2.4" rmp-serde = "1.3.1" once_cell = "1.20" tower-lsp = "0.20" -lsp-types = "0.94" +lsp-types = "0.97" cc = "1.2" dashmap = "6.1.0" tokio-util = "0.7.18" @@ -54,15 +54,19 @@ nu-ansi-term = "0.50.3" shlex = "1.3.0" tabled = "0.20.0" indexmap = { version = "2.1.0", features = ["serde"] } -axum = { version = "0.7.5", features = ["ws"] } +axum = { version = "0.8.8", features = ["ws"] } futures = "0.3.31" tokio-tungstenite = "0.28.0" -rmcp = { version = "0.13.0", features = ["macros", "server", "transport-io"] } +rmcp = { version = "0.15.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" +rayon = "1.10.0" tree-sitter-java = "0.23.5" tree-sitter-groovy = "0.1.2" mimalloc = "0.1" tempfile = "3.10" +zip = "8.0.0" +ristretto_jimage = "0.29.0" +ristretto_classfile = "0.29.0" diff --git a/README.md b/README.md index 43ccf7d..5c4a5d5 100644 --- a/README.md +++ b/README.md @@ -106,12 +106,74 @@ Naviscope is built on a **layered crate architecture** that separates concerns a - **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 implementations that implement the standard plugin contracts. -- **Plugin Layer** (`naviscope-plugin`): Defines the standard contracts and traits (`LanguagePlugin`, `BuildToolPlugin`, `Resolver`) effectively decoupling the core engine from specific language logic. +- **Plugin Layer** (`naviscope-plugin`): Defines capability traits (parse/indexing/runtime/asset/presentation/metadata) that decouple Core from language-specific implementations. - **Core Layer** (`naviscope-core`): The heart of the system - graph storage, indexing, file scanning, and persistence. It consumes the plugin traits to process files. - **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. +### Trait Organization + +Naviscope's trait design is split into two layers: engine-facing API traits (`naviscope-api`) and language capability traits (`naviscope-plugin`). + +```mermaid +graph TB + subgraph A["Layer 1: Engine API (`naviscope-api`)"] + APIComposite["API Composite
NaviscopeEngine"] + APIServices["API Services
GraphService | NavigationService
SymbolNavigator | ReferenceAnalyzer
CallHierarchyAnalyzer | SymbolInfoProvider
EngineLifecycle | StubCacheManager"] + end + + subgraph B["Layer 2: Runtime Semantics (`naviscope-plugin`)"] + RuntimeSemantic["Runtime Semantic Cap
SemanticCap"] + RuntimeServices["Semantic Services
SymbolResolveService | SymbolQueryService
LspSyntaxService | ReferenceCheckService"] + end + + subgraph C["Layer 3: Parse & Index (`naviscope-plugin`)"] + ParseCap["Parse Cap
FileMatcherCap | LanguageParseCap | BuildParseCap"] + IndexCap["Index Cap
SourceIndexCap | BuildIndexCap | ProjectContext"] + end + + subgraph D["Layer 4: Asset & Output (`naviscope-plugin`)"] + AssetCap["Asset Cap"] + PresentationCap["Presentation Cap"] + MetadataCodecCap["Metadata Codec Cap"] + end + + APIComposite --> APIServices + APIServices --> RuntimeSemantic + RuntimeSemantic --> RuntimeServices + + RuntimeServices --> ParseCap + RuntimeServices --> IndexCap + ParseCap --> IndexCap + + IndexCap --> AssetCap + RuntimeServices --> PresentationCap + RuntimeServices --> MetadataCodecCap +``` + +#### `naviscope-api` service traits + +- `GraphService`: graph query, stats, and node display retrieval. +- `NavigationService`: CLI-style path resolution and completion. +- `SymbolNavigator`: resolve/go-to-definition/type-definition/implementation/highlights. +- `ReferenceAnalyzer`: find references. +- `CallHierarchyAnalyzer`: incoming/outgoing calls. +- `SymbolInfoProvider`: symbol info, document symbols, language detection. +- `EngineLifecycle`: rebuild/load/save/refresh/watch/clear. +- `StubCacheManager`: cache stats/scan/inspect/clear. +- `NaviscopeEngine`: composite trait that bundles all service traits above. + +#### `naviscope-plugin` capability traits + +- File routing and parsing: `FileMatcherCap`, `LanguageParseCap`, `BuildParseCap`. +- Indexing: `SourceIndexCap`, `BuildIndexCap`. +- Runtime semantics: `SymbolResolveService`, `SymbolQueryService`, `LspSyntaxService`, `ReferenceCheckService` (grouped by `SemanticCap`). +- Asset integration: `AssetCap` (discover/index/source-locate/stub-generate). +- Output shaping: `PresentationCap`, `MetadataCodecCap`. + +This split keeps runtime interfaces stable for clients while allowing per-language capability composition internally. + ### 🔍 Reference Discovery Strategy Naviscope uses a **two-phase reference discovery** approach for optimal performance: diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index b5df7de..58e062d 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-api" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] diff --git a/crates/api/src/cache.rs b/crates/api/src/cache.rs new file mode 100644 index 0000000..e5820b0 --- /dev/null +++ b/crates/api/src/cache.rs @@ -0,0 +1,46 @@ +use crate::ApiResult; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Summary of a cached asset +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedAssetSummary { + pub hash: String, + pub path: String, + pub size_bytes: u64, + pub stub_count: usize, + pub version: u32, + pub created_at: u64, +} + +/// Detailed inspection result for a cached asset +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheInspectResult { + pub summary: CachedAssetSummary, + pub metadata_distribution: HashMap, + pub sample_entries: Vec, +} + +/// Statistics for the global stub cache +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheStats { + pub total_assets: usize, + pub total_entries: usize, + pub cache_dir: PathBuf, +} + +/// Service interface for managing the global stub cache +pub trait StubCacheManager: Send + Sync { + /// Get cache statistics + fn stats(&self) -> CacheStats; + + /// Scan all cached assets returning their summaries + fn scan_assets(&self) -> Vec; + + /// Inspect a specific cached asset by hash (full or prefix) + fn inspect_asset(&self, hash_prefix: &str) -> Option; + + /// Clear all cached data + fn clear(&self) -> ApiResult<()>; +} diff --git a/crates/api/src/error.rs b/crates/api/src/error.rs new file mode 100644 index 0000000..680a953 --- /dev/null +++ b/crates/api/src/error.rs @@ -0,0 +1,16 @@ +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("Unsupported capability: {capability} for language {language}")] + UnsupportedCapability { + capability: &'static str, + language: String, + }, + #[error("Not found: {0}")] + NotFound(String), + #[error("Invalid argument: {0}")] + InvalidArgument(String), + #[error("Internal error: {0}")] + Internal(String), +} + +pub type ApiResult = std::result::Result; diff --git a/crates/api/src/graph.rs b/crates/api/src/graph.rs index 424bbf2..85fb53a 100644 --- a/crates/api/src/graph.rs +++ b/crates/api/src/graph.rs @@ -1,14 +1,7 @@ +use crate::ApiResult; 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, @@ -17,9 +10,12 @@ pub struct GraphStats { #[async_trait] pub trait GraphService: Send + Sync { - async fn query(&self, query: &GraphQuery) -> Result; - async fn get_stats(&self) -> Result; + async fn query(&self, query: &GraphQuery) -> ApiResult; + async fn get_stats(&self) -> ApiResult; /// Get a fully hydrated display node by its FQN. - async fn get_node_display(&self, fqn: &str) -> Result>; + async fn get_node_display( + &self, + fqn: &str, + ) -> ApiResult>; } diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index eb36c62..9625043 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,3 +1,5 @@ +pub mod cache; +pub mod error; pub mod graph; pub mod lifecycle; pub mod models; @@ -5,8 +7,10 @@ pub mod navigation; pub mod semantic; // Re-export commonly used types +pub use cache::{CacheInspectResult, CacheStats, CachedAssetSummary, StubCacheManager}; +pub use error::{ApiError, ApiResult}; pub use graph::GraphService; -pub use lifecycle::EngineLifecycle; +pub use lifecycle::{EngineLifecycle, EngineWatchHandle}; pub use models::*; pub use navigation::NavigationService; pub use semantic::{CallHierarchyAnalyzer, ReferenceAnalyzer, SymbolInfoProvider, SymbolNavigator}; @@ -22,4 +26,6 @@ pub trait NaviscopeEngine: + SymbolInfoProvider + EngineLifecycle { + /// Get the stub cache manager. + fn get_stub_cache_manager(&self) -> std::sync::Arc; } diff --git a/crates/api/src/lifecycle.rs b/crates/api/src/lifecycle.rs index 344798e..68f1bcd 100644 --- a/crates/api/src/lifecycle.rs +++ b/crates/api/src/lifecycle.rs @@ -1,30 +1,27 @@ +use crate::ApiResult; use async_trait::async_trait; -#[derive(Debug, thiserror::Error)] -pub enum EngineError { - #[error("Internal error: {0}")] - Internal(String), +pub trait EngineWatchHandle: Send + Sync { + fn stop(&self); } -pub type EngineResult = std::result::Result; - #[async_trait] pub trait EngineLifecycle: Send + Sync { /// Rebuild the index from scratch - async fn rebuild(&self) -> EngineResult<()>; + async fn rebuild(&self) -> ApiResult<()>; /// Load the index from disk - async fn load(&self) -> EngineResult; + async fn load(&self) -> ApiResult; /// Save the index to disk - async fn save(&self) -> EngineResult<()>; + async fn save(&self) -> ApiResult<()>; /// Refresh the index (find new files, etc.) - async fn refresh(&self) -> EngineResult<()>; + async fn refresh(&self) -> ApiResult<()>; /// Watch for filesystem changes - async fn watch(&self) -> EngineResult<()>; + async fn start_watch(&self) -> ApiResult>; /// Clear the index for the current project - async fn clear_index(&self) -> EngineResult<()>; + async fn clear_index(&self) -> ApiResult<()>; } diff --git a/crates/api/src/models/fqn.rs b/crates/api/src/models/fqn.rs new file mode 100644 index 0000000..dd32df0 --- /dev/null +++ b/crates/api/src/models/fqn.rs @@ -0,0 +1,16 @@ +use super::graph::NodeKind; +use super::symbol::{FqnId, Symbol}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +pub struct FqnNode { + pub parent: Option, + pub name: Symbol, + pub kind: NodeKind, +} + +pub trait FqnReader { + fn resolve_node(&self, id: FqnId) -> Option; + fn resolve_atom(&self, atom: Symbol) -> &str; +} diff --git a/crates/api/src/models/graph.rs b/crates/api/src/models/graph.rs index 792c1e7..af488f9 100644 --- a/crates/api/src/models/graph.rs +++ b/crates/api/src/models/graph.rs @@ -116,6 +116,34 @@ impl NodeMetadata for EmptyMetadata { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +pub enum NodeSource { + /// Defined in the current project (source code available) + Project, + /// External dependency (library, vendor code) + External, + /// Language builtin / Primitive type + Builtin, +} + +impl Default for NodeSource { + fn default() -> Self { + Self::Project + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, Hash, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum ResolutionStatus { + /// Just a placeholder (name and ID known, metadata may be empty) + Unresolved, + /// Structure known from bytecode or partial scan (stubs available) + Stubbed, + /// Full details known from source code or complete parsing + #[default] + Resolved, +} + #[derive(Debug, Clone)] pub struct GraphNode { /// Unique Identifier (Structured FQN) @@ -126,6 +154,10 @@ pub struct GraphNode { pub kind: NodeKind, /// Language identifier (Symbol) pub lang: Symbol, + /// Source origin + pub source: NodeSource, + /// Current resolution depth/state + pub status: ResolutionStatus, /// Physical Location pub location: Option, /// Extension metadata @@ -139,6 +171,8 @@ impl Default for GraphNode { name: Symbol(lasso::Spur::default()), kind: NodeKind::Custom("unknown".to_string()), lang: Symbol(lasso::Spur::default()), + source: NodeSource::Project, + status: ResolutionStatus::Resolved, location: None, metadata: Arc::new(EmptyMetadata), } @@ -187,6 +221,10 @@ pub struct DisplayGraphNode { pub name: String, pub kind: NodeKind, pub lang: String, + #[serde(default)] + pub source: NodeSource, + #[serde(default)] + pub status: ResolutionStatus, pub location: Option, // Rendering fields @@ -196,6 +234,7 @@ pub struct DisplayGraphNode { pub modifiers: Vec, // Hierarchy support + #[serde(skip_serializing_if = "Option::is_none")] pub children: Option>, } @@ -209,6 +248,8 @@ pub enum GraphQuery { #[serde(default)] kind: Vec, #[serde(default)] + sources: Vec, + #[serde(default)] modifiers: Vec, }, @@ -217,6 +258,8 @@ pub enum GraphQuery { pattern: String, #[serde(default)] kind: Vec, + #[serde(default)] + sources: Vec, #[serde(default = "default_limit")] limit: usize, }, diff --git a/crates/api/src/models/mod.rs b/crates/api/src/models/mod.rs index e37c9e1..b2b6137 100644 --- a/crates/api/src/models/mod.rs +++ b/crates/api/src/models/mod.rs @@ -1,8 +1,10 @@ +pub mod fqn; pub mod graph; pub mod language; pub mod symbol; pub mod util; +pub use fqn::*; 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 index 6d64ade..6be573a 100644 --- a/crates/api/src/models/symbol.rs +++ b/crates/api/src/models/symbol.rs @@ -1,10 +1,11 @@ +pub use super::fqn::{FqnNode, FqnReader}; 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)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Symbol(pub lasso::Spur); impl JsonSchema for Symbol { @@ -72,7 +73,7 @@ impl NodeId { pub type SymbolAtom = Symbol; -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct FqnId(pub u32); impl JsonSchema for FqnId { @@ -85,18 +86,6 @@ impl JsonSchema for FqnId { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] -pub struct FqnNode { - pub parent: Option, - pub name: Symbol, - pub kind: NodeKind, -} - -pub trait FqnReader { - fn resolve_node(&self, id: FqnId) -> Option; - fn resolve_atom(&self, atom: Symbol) -> &str; -} - #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)] pub struct Range { pub start_line: usize, @@ -160,6 +149,16 @@ pub enum SymbolResolution { Global(String), } +impl SymbolResolution { + pub fn fqn(&self) -> Option<&str> { + match self { + SymbolResolution::Local(_, _) => None, + SymbolResolution::Precise(fqn, _) => Some(fqn), + SymbolResolution::Global(fqn) => Some(fqn), + } + } +} + // --- New Core API Types --- #[derive(Debug, Clone)] diff --git a/crates/api/src/navigation.rs b/crates/api/src/navigation.rs index 2781720..b4e110b 100644 --- a/crates/api/src/navigation.rs +++ b/crates/api/src/navigation.rs @@ -1,3 +1,4 @@ +use crate::error::ApiResult; use async_trait::async_trait; /// Result of resolving a user-provided path to a node FQN. @@ -53,8 +54,13 @@ pub trait NavigationService: Send + Sync { /// 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; + async fn resolve_path( + &self, + target: &str, + current_context: Option<&str>, + ) -> ApiResult; /// Get completion candidates for a prefix. - async fn get_completion_candidates(&self, prefix: &str) -> Vec; + async fn get_completion_candidates(&self, prefix: &str, limit: usize) + -> ApiResult>; } diff --git a/crates/api/src/semantic.rs b/crates/api/src/semantic.rs index 560fc70..3be995c 100644 --- a/crates/api/src/semantic.rs +++ b/crates/api/src/semantic.rs @@ -1,21 +1,9 @@ +use crate::ApiResult; 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 @@ -26,68 +14,51 @@ pub type SemanticResult = std::result::Result; #[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>; + async fn resolve_symbol_at(&self, ctx: &PositionContext) + -> ApiResult>; /// Find all definition locations for a given symbol query. - async fn find_definitions(&self, query: &SymbolQuery) -> SemanticResult>; + async fn find_definitions(&self, query: &SymbolQuery) -> ApiResult>; /// Find type definition locations (e.g., the class definition of a variable's type). - async fn find_type_definitions( - &self, - query: &SymbolQuery, - ) -> SemanticResult>; + async fn find_type_definitions(&self, query: &SymbolQuery) -> ApiResult>; /// Find all implementation locations (e.g., classes implementing an interface). - async fn find_implementations( - &self, - query: &SymbolQuery, - ) -> SemanticResult>; + async fn find_implementations(&self, query: &SymbolQuery) -> ApiResult>; /// Find occurrences of a symbol for document highlighting. - async fn find_highlights( - &self, - ctx: &PositionContext, - ) -> SemanticResult>; + async fn find_highlights(&self, ctx: &PositionContext) -> ApiResult>; } /// 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>; + async fn find_references(&self, query: &ReferenceQuery) -> ApiResult>; } /// 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>; + async fn find_incoming_calls(&self, fqn: &str) -> ApiResult>; /// Find all outgoing calls (callees) from the specified function/method. - async fn find_outgoing_calls( - &self, - fqn: &str, - ) -> SemanticResult>; + async fn find_outgoing_calls(&self, fqn: &str) -> ApiResult>; } /// 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>; + async fn get_symbol_info(&self, fqn: &str) -> ApiResult>; /// Get all symbols defined in a specific document. - async fn get_document_symbols(&self, uri: &str) -> SemanticResult>; + async fn get_document_symbols(&self, uri: &str) -> ApiResult>; /// Get the language of a specific document. async fn get_language_for_document( &self, uri: &str, - ) -> SemanticResult>; + ) -> ApiResult>; } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 3af40a8..cca1bc6 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-cli" -version = "0.5.5" +version = "0.7.0" edition = "2024" [[bin]] diff --git a/crates/cli/src/cache.rs b/crates/cli/src/cache.rs new file mode 100644 index 0000000..bef0e9c --- /dev/null +++ b/crates/cli/src/cache.rs @@ -0,0 +1,142 @@ +use clap::Subcommand; +use tabled::{Table, Tabled}; + +#[derive(Subcommand)] +pub enum CacheCommands { + /// Show cache statistics + Stats, + /// List cached assets + List { + /// Sort by size or date + #[arg(long, value_parser = ["size", "date"])] + sort: Option, + /// Filter by path pattern + #[arg(long)] + filter: Option, + }, + /// Inspect a specific cached asset + Inspect { + /// Asset hash (full or prefix) + hash: String, + }, + /// Clear the cache + Clear, +} + +#[derive(Tabled)] +struct AssetRow { + #[tabled(rename = "Hash")] + hash: String, + #[tabled(rename = "Path")] + path: String, + #[tabled(rename = "Size")] + size: String, + #[tabled(rename = "Stubs")] + stubs: usize, + #[tabled(rename = "Ver")] + version: u32, + #[tabled(rename = "Age")] + age: String, +} + +pub async fn run(cmd: CacheCommands) -> Result<(), Box> { + // Cache is global, we don't need a full engine to access it + let cache = naviscope_runtime::get_cache_manager(); + + match cmd { + CacheCommands::Stats => { + let stats = cache.stats(); + println!("Cache Directory: {}", stats.cache_dir.display()); + println!("Total Assets: {}", stats.total_assets); + println!("Total Entries: {}", stats.total_entries); + } + CacheCommands::List { sort, filter } => { + let mut assets = cache.scan_assets(); + + // Filter + if let Some(pattern) = filter { + assets.retain(|a| a.path.contains(&pattern)); + } + + // Sort + if let Some(key) = sort { + match key.as_str() { + "size" => assets.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes)), + "date" => assets.sort_by(|a, b| b.created_at.cmp(&a.created_at)), + _ => {} + } + } + + let rows: Vec = assets + .into_iter() + .map(|a| { + let bytes = a.size_bytes; + let size_str = if bytes < 1024 { + format!("{} B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / 1024.0 / 1024.0) + }; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let age = now.saturating_sub(a.created_at); + let age_str = if age < 60 { + format!("{}s ago", age) + } else if age < 3600 { + format!("{}m ago", age / 60) + } else if age < 86400 { + format!("{}h ago", age / 3600) + } else { + format!("{}d ago", age / 86400) + }; + + AssetRow { + hash: a.hash, + path: a.path, + size: size_str, + stubs: a.stub_count, + version: a.version, + age: age_str, + } + }) + .collect(); + + if rows.is_empty() { + println!("No cached assets found."); + } else { + println!("{}", Table::new(rows)); + } + } + CacheCommands::Inspect { hash } => { + if let Some(result) = cache.inspect_asset(&hash) { + println!("Asset Summary:"); + println!(" Path: {}", result.summary.path); + println!(" Hash: {}", result.summary.hash); + println!(" Version: {}", result.summary.version); + println!(" Stubs: {}", result.summary.stub_count); + + println!("\nMetadata Distribution:"); + for (type_tag, count) in result.metadata_distribution { + println!(" {}: {}", type_tag, count); + } + + println!("\nSample Entries:"); + for (i, entry) in result.sample_entries.iter().enumerate() { + println!(" {}. {}", i + 1, entry); + } + } else { + println!("Asset not found with hash prefix: {}", hash); + } + } + CacheCommands::Clear => { + cache.clear()?; + println!("Cache cleared successfully."); + } + } + + Ok(()) +} diff --git a/crates/cli/src/index.rs b/crates/cli/src/index.rs index c3081d1..31903d9 100644 --- a/crates/cli/src/index.rs +++ b/crates/cli/src/index.rs @@ -19,6 +19,7 @@ pub async fn run(path: PathBuf) -> Result<(), Box> { let query = naviscope_api::models::GraphQuery::Ls { fqn: None, kind: vec![], + sources: vec![], modifiers: vec![], }; if let Ok(res) = engine.query(&query).await { diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index d212de2..5590c1a 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,3 +1,4 @@ +mod cache; mod clear; mod index; mod shell; @@ -70,6 +71,11 @@ pub enum Commands { }, /// Start the Language Server Protocol (LSP) server Lsp, + /// Manage global stub cache + Cache { + #[command(subcommand)] + command: cache::CacheCommands, + }, } pub fn run() -> Result<(), Box> { @@ -80,6 +86,8 @@ pub fn run() -> Result<(), Box> { Commands::Lsp => ("lsp", false), Commands::Mcp { .. } => ("mcp", false), Commands::Shell { .. } => ("cli", false), + Commands::Cache { .. } => ("cli", false), + Commands::Clear { .. } => ("cli", false), _ => ("cli", true), }; let _guard = naviscope_runtime::init_logging(component, to_stderr); @@ -88,9 +96,13 @@ pub fn run() -> Result<(), Box> { match cli.command { Commands::Index { path } => rt.block_on(index::run(path.canonicalize()?)), - Commands::Shell { path } => rt.block_on(shell::run(path.map(|p| p.canonicalize()).transpose()?)), + Commands::Shell { path } => { + rt.block_on(shell::run(path.map(|p| p.canonicalize()).transpose()?)) + } Commands::Watch { path } => rt.block_on(watch::run(path.canonicalize()?)), - Commands::Clear { path } => rt.block_on(clear::run(path.map(|p| p.canonicalize()).transpose()?)), + Commands::Clear { path } => { + rt.block_on(clear::run(path.map(|p| p.canonicalize()).transpose()?)) + } Commands::Mcp { path } => { let project_path = match path { Some(p) => p.canonicalize()?, @@ -107,5 +119,6 @@ pub fn run() -> Result<(), Box> { })?; Ok(()) } + Commands::Cache { command } => rt.block_on(cache::run(command)), } } diff --git a/crates/cli/src/shell/command.rs b/crates/cli/src/shell/command.rs index f0f9e8c..ddc8f69 100644 --- a/crates/cli/src/shell/command.rs +++ b/crates/cli/src/shell/command.rs @@ -1,6 +1,6 @@ use super::view::{ShellNodeView, ShellNodeViewShort, get_kind_weight}; use clap::{Parser, ValueEnum}; -use naviscope_api::models::{EdgeType, GraphQuery, NodeKind, QueryResult}; +use naviscope_api::models::{EdgeType, GraphQuery, NodeKind, NodeSource, QueryResult}; use shlex; use tabled::{Table, settings::Style}; @@ -26,6 +26,24 @@ pub enum CliNodeKind { Other, } +#[derive(Clone, Debug, ValueEnum)] +#[clap(rename_all = "lowercase")] +pub enum CliNodeSource { + Project, + External, + Builtin, +} + +impl From for NodeSource { + fn from(source: CliNodeSource) -> Self { + match source { + CliNodeSource::Project => NodeSource::Project, + CliNodeSource::External => NodeSource::External, + CliNodeSource::Builtin => NodeSource::Builtin, + } + } +} + impl From for NodeKind { fn from(kind: CliNodeKind) -> Self { match kind { @@ -81,12 +99,18 @@ pub enum ShellCommand { /// Filter by node kind (e.g. class, interface, method) #[arg(long, value_delimiter = ',')] kind: Vec, + /// Filter by node source (e.g. project, external, builtin) + #[arg(long, value_delimiter = ',')] + source: Vec, /// Filter by modifiers (e.g. public, static) #[arg(long, value_delimiter = ',')] modifiers: Vec, /// Use long listing format #[arg(short, long)] long: bool, + /// Show all nodes (including external and builtins) + #[arg(short, long)] + all: bool, }, /// Change current node context (internal shell command) Cd { @@ -104,6 +128,9 @@ pub enum ShellCommand { /// Filter by node kind #[arg(long, value_delimiter = ',')] kind: Vec, + /// Filter by node source + #[arg(long, value_delimiter = ',')] + source: Vec, /// Limit number of results #[arg(long, default_value_t = DEFAULT_SEARCH_LIMIT)] limit: usize, @@ -169,23 +196,42 @@ impl ShellCommand { ShellCommand::Ls { fqn, kind, + source, modifiers, + all, .. } => { let target_fqn = fqn.clone().or_else(|| current_node.clone()); + let sources = if *all { + vec![] + } else if source.is_empty() { + vec![NodeSource::Project] + } else { + source + .iter() + .map(|s| s.clone().into()) + .collect::>() + }; + Ok(GraphQuery::Ls { fqn: target_fqn, kind: kind.iter().map(|k| k.clone().into()).collect(), + sources, modifiers: modifiers.clone(), }) } ShellCommand::Find { pattern, kind, + source, limit, } => Ok(GraphQuery::Find { pattern: pattern.clone(), kind: kind.iter().map(|k| k.clone().into()).collect(), + sources: source + .iter() + .map(|s| s.clone().into()) + .collect::>(), limit: *limit, }), ShellCommand::Cat { target } => Ok(GraphQuery::Cat { diff --git a/crates/cli/src/shell/completer.rs b/crates/cli/src/shell/completer.rs index fe520c6..bd2484a 100644 --- a/crates/cli/src/shell/completer.rs +++ b/crates/cli/src/shell/completer.rs @@ -72,27 +72,29 @@ impl<'a> Completer for NaviscopeCompleter<'a> { tokio::task::block_in_place(|| { self.context .rt_handle - .block_on(nav_service.get_completion_candidates(last_word)) + .block_on(nav_service.get_completion_candidates(last_word, 50)) }) } else { self.context .rt_handle - .block_on(nav_service.get_completion_candidates(last_word)) + .block_on(nav_service.get_completion_candidates(last_word, 50)) }; - 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, - }); + if let Ok(matches) = matches { + 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, + }); + } } } @@ -100,6 +102,7 @@ impl<'a> Completer for NaviscopeCompleter<'a> { let query = GraphQuery::Ls { fqn: parent_fqn.clone(), kind: vec![], + sources: vec![], modifiers: vec![], }; diff --git a/crates/cli/src/shell/context.rs b/crates/cli/src/shell/context.rs index 1e9d52a..95c8434 100644 --- a/crates/cli/src/shell/context.rs +++ b/crates/cli/src/shell/context.rs @@ -50,11 +50,11 @@ impl ShellContext { } /// Resolves a user input path using the NavigationService API. - pub fn resolve_node(&self, target: &str) -> ResolveResult { + pub fn resolve_node(&self, target: &str) -> Result> { let nav_service: &dyn NavigationService = self.engine.as_ref(); let current_context = self.current_fqn(); - if tokio::runtime::Handle::try_current().is_ok() { + let result = 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())) @@ -62,6 +62,7 @@ impl ShellContext { } else { self.rt_handle .block_on(nav_service.resolve_path(target, current_context.as_deref())) - } + }; + Ok(result?) } } diff --git a/crates/cli/src/shell/handlers.rs b/crates/cli/src/shell/handlers.rs index edb3ec4..8a69869 100644 --- a/crates/cli/src/shell/handlers.rs +++ b/crates/cli/src/shell/handlers.rs @@ -18,7 +18,7 @@ impl CommandHandler for CdHandler { context: &mut ShellContext, ) -> Result> { if let ShellCommand::Cd { path } = cmd { - match context.resolve_node(path) { + 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); @@ -48,7 +48,7 @@ impl CommandHandler for CatHandler { ) -> Result> { if let ShellCommand::Cat { target } = cmd { // First resolve the target to a concrete FQN - let fqn = match context.resolve_node(target) { + let fqn = match context.resolve_node(target)? { ResolveResult::Found(f) => f, ResolveResult::Ambiguous(candidates) => { let mut msg = @@ -96,18 +96,22 @@ impl CommandHandler for GenericQueryHandler { ShellCommand::Ls { fqn: Some(target), kind, + source, modifiers, long, + all, } => { resolved_target_fqn = match context.resolve_node(target) { - ResolveResult::Found(f) => Some(f), + Ok(ResolveResult::Found(f)) => Some(f), _ => Some(target.clone()), }; ShellCommand::Ls { fqn: resolved_target_fqn.clone(), kind: kind.clone(), + source: source.clone(), modifiers: modifiers.clone(), long: *long, + all: *all, } } ShellCommand::Deps { @@ -116,7 +120,7 @@ impl CommandHandler for GenericQueryHandler { edge_types, } => { resolved_target_fqn = match context.resolve_node(target) { - ResolveResult::Found(f) => Some(f), + Ok(ResolveResult::Found(f)) => Some(f), _ => Some(target.clone()), }; ShellCommand::Deps { diff --git a/crates/cli/src/shell/mod.rs b/crates/cli/src/shell/mod.rs index bfe2789..351973a 100644 --- a/crates/cli/src/shell/mod.rs +++ b/crates/cli/src/shell/mod.rs @@ -48,16 +48,25 @@ impl ReplServer { 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."); - } + let watch_handle = match self.context.engine.start_watch().await { + Ok(handle) => { + info!("File watcher started."); + Some(handle) + } + Err(e) => { + error!("Failed to start file watcher: {}", e); + None + } + }; println!("Type 'help' for commands."); let line_editor = self.setup_line_editor()?; - self.run_loop(line_editor) + let run_result = self.run_loop(line_editor); + if let Some(handle) = watch_handle { + handle.stop(); + } + run_result } async fn initialize_index(&self) -> Result<(), Box> { @@ -104,6 +113,7 @@ impl ReplServer { let query = naviscope_api::models::GraphQuery::Ls { fqn: None, kind: vec![naviscope_api::models::NodeKind::Project], + sources: vec![], modifiers: vec![], }; diff --git a/crates/cli/src/shell/view.rs b/crates/cli/src/shell/view.rs index 3380ccd..9574a9a 100644 --- a/crates/cli/src/shell/view.rs +++ b/crates/cli/src/shell/view.rs @@ -20,10 +20,7 @@ pub struct ShellNodeViewShort { } impl ShellNodeView { - pub fn from_node( - node: &DisplayGraphNode, - relation: Option, - ) -> Self { + pub fn from_node(node: &DisplayGraphNode, relation: Option) -> Self { let location = node .location .as_ref() diff --git a/crates/cli/src/watch.rs b/crates/cli/src/watch.rs index cec0a3c..8688c77 100644 --- a/crates/cli/src/watch.rs +++ b/crates/cli/src/watch.rs @@ -9,12 +9,13 @@ pub async fn run(path: PathBuf) -> Result<(), Box> { info!("Initial indexing complete."); // Start background watcher via trait - engine.watch().await?; + let watch_handle = engine.start_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?; + watch_handle.stop(); info!("Watcher stopped."); Ok(()) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 93a0b37..efad719 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-core" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] @@ -13,7 +13,6 @@ 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 } @@ -32,7 +31,11 @@ naviscope-api = { workspace = true } naviscope-plugin = { workspace = true } async-trait = { workspace = true } url = { workspace = true } +dirs = { workspace = true } +serde_bytes = { workspace = true } +rayon = { workspace = true } [dev-dependencies] tree-sitter-java = { workspace = true } tempfile = { workspace = true } +naviscope-java = { workspace = true } diff --git a/crates/core/src/asset/mod.rs b/crates/core/src/asset/mod.rs new file mode 100644 index 0000000..96eb4dd --- /dev/null +++ b/crates/core/src/asset/mod.rs @@ -0,0 +1,35 @@ +//! Asset/Stub Layer - Independent module for asset discovery and stub generation. +//! +//! This module provides the core infrastructure for: +//! - Discovering assets (JARs, JDK modules) from various sources +//! - Building route tables (FQN prefix -> asset paths) +//! - Managing stub requests and generation +//! +//! ## Architecture +//! +//! ```text +//! ┌─────────────────────────┐ ┌───────────────────────┐ +//! │ AssetDiscoverer[] │ │ AssetIndexer[] │ +//! │ (JDK, Gradle, Maven) │───▶│ (extract prefixes) │ +//! └─────────────────────────┘ └───────────┬───────────┘ +//! │ +//! ▼ +//! ┌───────────────────────────┐ +//! │ AssetRouteRegistry │ +//! │ (prefix → paths) │ +//! └───────────────────────────┘ +//! ``` +//! +//! ## Note +//! +//! Concrete discoverers are implemented in their respective crates: +//! - `naviscope-java::JdkDiscoverer` - JDK asset discovery +//! - `naviscope-gradle::GradleCacheDiscoverer` - Gradle cache discovery + +pub mod registry; +pub mod scanner; +pub mod service; + +pub use registry::InMemoryRouteRegistry; +pub use scanner::AssetScanner; +pub use service::AssetStubService; diff --git a/crates/core/src/asset/registry.rs b/crates/core/src/asset/registry.rs new file mode 100644 index 0000000..4a2d90d --- /dev/null +++ b/crates/core/src/asset/registry.rs @@ -0,0 +1,293 @@ +//! In-memory implementation of AssetRouteRegistry. +//! +//! Provides thread-safe storage for FQN prefix → AssetEntry mappings. + +use naviscope_plugin::{AssetEntry, AssetRouteRegistry, RegistryStats}; +use std::collections::HashMap; +use std::sync::RwLock; + +/// Thread-safe in-memory route registry +pub struct InMemoryRouteRegistry { + /// Mapping from package prefix to asset entries + routes: RwLock>>, +} + +impl InMemoryRouteRegistry { + pub fn new() -> Self { + Self { + routes: RwLock::new(HashMap::new()), + } + } + + /// Create with initial capacity + pub fn with_capacity(capacity: usize) -> Self { + Self { + routes: RwLock::new(HashMap::with_capacity(capacity)), + } + } + + /// Register multiple routes at once (more efficient than individual calls) + pub fn register_batch(&self, entries: impl IntoIterator) { + let mut routes = self.routes.write().unwrap(); + for (prefix, entry) in entries { + routes.entry(prefix).or_default().push(entry); + } + } + + /// Clear all routes + pub fn clear(&self) { + let mut routes = self.routes.write().unwrap(); + routes.clear(); + } + + /// Get number of unique prefixes + pub fn prefix_count(&self) -> usize { + let routes = self.routes.read().unwrap(); + routes.len() + } +} + +impl Default for InMemoryRouteRegistry { + fn default() -> Self { + Self::new() + } +} + +impl AssetRouteRegistry for InMemoryRouteRegistry { + fn register(&self, prefix: String, entry: AssetEntry) { + let mut routes = self.routes.write().unwrap(); + routes.entry(prefix).or_default().push(entry); + } + + fn lookup(&self, fqn: &str) -> Option> { + let routes = self.routes.read().unwrap(); + + // Try exact match first + if let Some(entries) = routes.get(fqn) { + return Some(entries.clone()); + } + + // Try prefix matching (longest match wins) + let mut best_match: Option<(&str, &Vec)> = None; + + for (prefix, entries) in routes.iter() { + if fqn.starts_with(prefix) { + // Check if this is a valid prefix (followed by '.' or end of string) + let remainder = &fqn[prefix.len()..]; + if remainder.is_empty() || remainder.starts_with('.') { + match &best_match { + None => best_match = Some((prefix, entries)), + Some((best_prefix, _)) if prefix.len() > best_prefix.len() => { + best_match = Some((prefix, entries)) + } + _ => {} + } + } + } + } + + best_match.map(|(_, entries)| entries.clone()) + } + + fn lookup_by_source(&self, fqn: &str, source_type: &str) -> Option> { + self.lookup(fqn).map(|entries| { + entries + .into_iter() + .filter(|e| e.source.source_type() == source_type) + .collect() + }) + } + + fn all_routes(&self) -> HashMap> { + let routes = self.routes.read().unwrap(); + routes.clone() + } + + fn stats(&self) -> RegistryStats { + let routes = self.routes.read().unwrap(); + + let mut total_entries = 0; + let mut by_source: HashMap = HashMap::new(); + + for entries in routes.values() { + total_entries += entries.len(); + for entry in entries { + *by_source + .entry(entry.source.source_type().to_string()) + .or_default() += 1; + } + } + + RegistryStats { + total_prefixes: routes.len(), + total_entries, + by_source, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use naviscope_plugin::AssetSource; + use std::path::PathBuf; + + fn make_entry(path: &str, source: AssetSource) -> AssetEntry { + AssetEntry::new(PathBuf::from(path), source) + } + + #[test] + fn test_register_and_lookup() { + let registry = InMemoryRouteRegistry::new(); + + let entry = make_entry( + "/path/to/rt.jar", + AssetSource::Jdk { + version: Some("17".to_string()), + path: PathBuf::from("/usr/lib/jvm/java-17"), + }, + ); + + registry.register("java.lang".to_string(), entry.clone()); + + // Exact match + let result = registry.lookup("java.lang"); + assert!(result.is_some()); + assert_eq!(result.unwrap().len(), 1); + + // Prefix match + let result = registry.lookup("java.lang.String"); + assert!(result.is_some()); + assert_eq!(result.unwrap().len(), 1); + + // No match + let result = registry.lookup("com.example"); + assert!(result.is_none()); + + // Invalid prefix (must be followed by '.' or end) + registry.register( + "java".to_string(), + make_entry("/other.jar", AssetSource::Unknown), + ); + let result = registry.lookup("javascript.foo"); + assert!(result.is_none()); + } + + #[test] + fn test_longest_prefix_match() { + let registry = InMemoryRouteRegistry::new(); + + let entry1 = make_entry("/jdk.jar", AssetSource::Unknown); + let entry2 = make_entry("/netty.jar", AssetSource::Unknown); + + registry.register("io".to_string(), entry1); + registry.register("io.netty".to_string(), entry2); + + // Should match the longer prefix + let result = registry.lookup("io.netty.channel.Channel"); + assert!(result.is_some()); + let entries = result.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, PathBuf::from("/netty.jar")); + } + + #[test] + fn test_lookup_by_source() { + let registry = InMemoryRouteRegistry::new(); + + let jdk_entry = make_entry( + "/jdk.jar", + AssetSource::Jdk { + version: Some("17".to_string()), + path: PathBuf::from("/jdk"), + }, + ); + let gradle_entry = make_entry( + "/gradle.jar", + AssetSource::Gradle { + group: "test".to_string(), + artifact: "test".to_string(), + version: "1.0".to_string(), + }, + ); + + registry.register("java.lang".to_string(), jdk_entry); + registry.register("java.lang".to_string(), gradle_entry); + + // Filter by source + let jdk_only = registry.lookup_by_source("java.lang", "jdk"); + assert!(jdk_only.is_some()); + assert_eq!(jdk_only.unwrap().len(), 1); + + let gradle_only = registry.lookup_by_source("java.lang", "gradle"); + assert!(gradle_only.is_some()); + assert_eq!(gradle_only.unwrap().len(), 1); + } + + #[test] + fn test_stats() { + let registry = InMemoryRouteRegistry::new(); + + registry.register( + "java.lang".to_string(), + make_entry( + "/a.jar", + AssetSource::Jdk { + version: None, + path: PathBuf::from("/jdk"), + }, + ), + ); + registry.register( + "java.util".to_string(), + make_entry( + "/b.jar", + AssetSource::Jdk { + version: None, + path: PathBuf::from("/jdk"), + }, + ), + ); + registry.register( + "io.netty".to_string(), + make_entry( + "/c.jar", + AssetSource::Gradle { + group: "io.netty".to_string(), + artifact: "netty".to_string(), + version: "4.1".to_string(), + }, + ), + ); + + let stats = registry.stats(); + assert_eq!(stats.total_prefixes, 3); + assert_eq!(stats.total_entries, 3); + assert_eq!(stats.by_source.get("jdk"), Some(&2)); + assert_eq!(stats.by_source.get("gradle"), Some(&1)); + } + + #[test] + fn test_register_batch() { + let registry = InMemoryRouteRegistry::new(); + + let entries = vec![ + ( + "java.lang".to_string(), + make_entry("/a.jar", AssetSource::Unknown), + ), + ( + "java.util".to_string(), + make_entry("/b.jar", AssetSource::Unknown), + ), + ( + "java.io".to_string(), + make_entry("/c.jar", AssetSource::Unknown), + ), + ]; + + registry.register_batch(entries); + + assert_eq!(registry.prefix_count(), 3); + } +} diff --git a/crates/core/src/asset/scanner.rs b/crates/core/src/asset/scanner.rs new file mode 100644 index 0000000..42ad879 --- /dev/null +++ b/crates/core/src/asset/scanner.rs @@ -0,0 +1,248 @@ +//! Asset scanner that combines multiple discoverers and indexers. +//! +//! The scanner orchestrates the discovery and indexing pipeline: +//! 1. Discoverers find asset files (JAR, jimage, etc.) +//! 2. Indexers extract package prefixes from each asset +//! 3. Results are registered in the route registry + +use naviscope_plugin::{AssetDiscoverer, AssetEntry, AssetIndexer, AssetRouteRegistry}; +use std::sync::Arc; +use tracing::{debug, info, warn}; + +/// Combines multiple asset discoverers with indexers +pub struct AssetScanner { + discoverers: Vec>, + indexers: Vec>, +} + +impl AssetScanner { + pub fn new() -> Self { + Self { + discoverers: Vec::new(), + indexers: Vec::new(), + } + } + + /// Add a discoverer + pub fn add_discoverer(mut self, discoverer: Box) -> Self { + self.discoverers.push(discoverer); + self + } + + /// Add an indexer + pub fn add_indexer(mut self, indexer: Arc) -> Self { + self.indexers.push(indexer); + self + } + + /// Add multiple discoverers + pub fn with_discoverers( + mut self, + discoverers: impl IntoIterator>, + ) -> Self { + self.discoverers.extend(discoverers); + self + } + + /// Add multiple indexers + pub fn with_indexers( + mut self, + indexers: impl IntoIterator>, + ) -> Self { + self.indexers.extend(indexers); + self + } + + /// Scan all assets and register routes + /// + /// This method streams through assets to maintain constant memory usage: + /// - Discoverers yield assets one at a time + /// - Indexers extract prefixes and immediately register them + pub fn scan(&self, registry: &dyn AssetRouteRegistry) -> ScanResult { + let mut result = ScanResult::default(); + let start = std::time::Instant::now(); + + for discoverer in &self.discoverers { + debug!("Scanning with discoverer: {}", discoverer.name()); + let discoverer_start = std::time::Instant::now(); + let mut discoverer_assets = 0; + + for entry in discoverer.discover() { + discoverer_assets += 1; + result.total_assets += 1; + + // Find a suitable indexer + if let Some(indexer) = self.find_indexer(&entry) { + match self.index_and_register(&entry, indexer.as_ref(), registry) { + Ok(prefix_count) => { + result.indexed_assets += 1; + result.total_prefixes += prefix_count; + } + Err(e) => { + warn!("Failed to index {:?}: {}", entry.path, e); + result.failed_assets += 1; + } + } + } else { + debug!("No indexer for {:?}", entry.path); + result.skipped_assets += 1; + } + } + + debug!( + "Discoverer {} found {} assets in {:?}", + discoverer.name(), + discoverer_assets, + discoverer_start.elapsed() + ); + } + + result.duration = start.elapsed(); + info!( + "Asset scan complete: {} assets, {} indexed, {} prefixes in {:?}", + result.total_assets, result.indexed_assets, result.total_prefixes, result.duration + ); + + result + } + + /// Find an indexer that can handle the given asset + fn find_indexer(&self, entry: &AssetEntry) -> Option> { + self.indexers + .iter() + .find(|indexer| indexer.can_index(&entry.path)) + .cloned() + } + + /// Index an asset and register all discovered prefixes + fn index_and_register( + &self, + entry: &AssetEntry, + indexer: &dyn AssetIndexer, + registry: &dyn AssetRouteRegistry, + ) -> Result> { + let prefixes = indexer.index(&entry.path)?; + let count = prefixes.len(); + + for prefix in prefixes { + registry.register(prefix, entry.clone()); + } + + Ok(count) + } +} + +impl Default for AssetScanner { + fn default() -> Self { + Self::new() + } +} + +/// Result of an asset scan operation +#[derive(Debug, Default, Clone)] +pub struct ScanResult { + /// Total number of assets discovered + pub total_assets: usize, + /// Number of assets successfully indexed + pub indexed_assets: usize, + /// Number of assets skipped (no indexer available) + pub skipped_assets: usize, + /// Number of assets that failed to index + pub failed_assets: usize, + /// Total number of prefixes registered + pub total_prefixes: usize, + /// Time taken for the scan + pub duration: std::time::Duration, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::asset::registry::InMemoryRouteRegistry; + use naviscope_plugin::{AssetSource, BoxError}; + use std::path::{Path, PathBuf}; + + /// Mock discoverer for testing + struct MockDiscoverer { + assets: Vec, + } + + impl AssetDiscoverer for MockDiscoverer { + fn discover(&self) -> Box + Send + '_> { + Box::new(self.assets.iter().cloned()) + } + + fn name(&self) -> &str { + "Mock Discoverer" + } + + fn source_type(&self) -> &str { + "mock" + } + } + + /// Mock indexer for testing + struct MockIndexer { + prefixes_per_asset: Vec, + } + + impl AssetIndexer for MockIndexer { + fn can_index(&self, _asset: &Path) -> bool { + true + } + + fn index(&self, _asset: &Path) -> Result, BoxError> { + Ok(self.prefixes_per_asset.clone()) + } + } + + #[test] + fn test_scanner_basic() { + let discoverer = Box::new(MockDiscoverer { + assets: vec![ + AssetEntry::new(PathBuf::from("/test1.jar"), AssetSource::Unknown), + AssetEntry::new(PathBuf::from("/test2.jar"), AssetSource::Unknown), + ], + }); + + let indexer = Arc::new(MockIndexer { + prefixes_per_asset: vec!["com.example".to_string(), "org.test".to_string()], + }); + + let scanner = AssetScanner::new() + .add_discoverer(discoverer) + .add_indexer(indexer); + + let registry = InMemoryRouteRegistry::new(); + let result = scanner.scan(®istry); + + assert_eq!(result.total_assets, 2); + assert_eq!(result.indexed_assets, 2); + assert_eq!(result.total_prefixes, 4); // 2 prefixes × 2 assets + + // Verify registry contents + let stats = registry.stats(); + assert_eq!(stats.total_prefixes, 2); // "com.example" and "org.test" + assert_eq!(stats.total_entries, 4); // Each prefix has 2 entries (from 2 assets) + } + + #[test] + fn test_scanner_no_indexer() { + let discoverer = Box::new(MockDiscoverer { + assets: vec![AssetEntry::new( + PathBuf::from("/test.jar"), + AssetSource::Unknown, + )], + }); + + // No indexer added + let scanner = AssetScanner::new().add_discoverer(discoverer); + + let registry = InMemoryRouteRegistry::new(); + let result = scanner.scan(®istry); + + assert_eq!(result.total_assets, 1); + assert_eq!(result.skipped_assets, 1); + assert_eq!(result.indexed_assets, 0); + } +} diff --git a/crates/core/src/asset/service.rs b/crates/core/src/asset/service.rs new file mode 100644 index 0000000..6cd8681 --- /dev/null +++ b/crates/core/src/asset/service.rs @@ -0,0 +1,337 @@ +//! Unified Asset/Stub service facade. +//! +//! Provides a single entry point for: +//! - Asset discovery and route management +//! - Stub request handling +//! - Background scanning + +use crate::asset::registry::InMemoryRouteRegistry; +use crate::asset::scanner::{AssetScanner, ScanResult}; +use naviscope_plugin::{ + AssetDiscoverer, AssetEntry, AssetIndexer, AssetRouteRegistry, AssetSourceLocator, + RegistryStats, StubGenerator, +}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio::task::JoinHandle; + +/// Unified Asset/Stub service +pub struct AssetStubService { + /// Route registry (shared, thread-safe) + registry: Arc, + + /// Asset scanner configuration + scanner: AssetScanner, + + /// Stub generators (from language plugins) + generators: Vec>, + + /// Mapping from binary asset path to source asset path (if available) + source_map: Arc>>, + + /// Source locators (from language/build plugins) + source_locators: Vec>, +} + +impl AssetStubService { + /// Create a new service with discoverers, indexers, and generators + pub fn new( + discoverers: Vec>, + indexers: Vec>, + generators: Vec>, + source_locators: Vec>, + ) -> Self { + let scanner = AssetScanner::new() + .with_discoverers(discoverers) + .with_indexers(indexers); + + Self { + registry: Arc::new(InMemoryRouteRegistry::new()), + scanner, + generators, + source_map: Arc::new(RwLock::new(HashMap::new())), + source_locators, + } + } + + /// Create with custom registry (for testing or shared state) + pub fn with_registry( + registry: Arc, + discoverers: Vec>, + indexers: Vec>, + generators: Vec>, + source_locators: Vec>, + ) -> Self { + let scanner = AssetScanner::new() + .with_discoverers(discoverers) + .with_indexers(indexers); + + Self { + registry, + scanner, + generators, + source_map: Arc::new(RwLock::new(HashMap::new())), + source_locators, + } + } + + /// Get a reference to the registry + pub fn registry(&self) -> Arc { + self.registry.clone() + } + + /// Perform a synchronous scan (blocks until complete) + pub fn scan_sync(&self) -> ScanResult { + let result = self.scanner.scan(self.registry.as_ref()); + self.refresh_source_map(); + result + } + + /// Start a background scan task + pub fn spawn_scan(&self) -> JoinHandle { + let registry = self.registry.clone(); + let scanner = self.build_scanner_clone(); + let source_map = self.source_map.clone(); + let source_locators = self.source_locators.clone(); + + tokio::spawn(async move { + // Run scan in blocking thread pool + tokio::task::spawn_blocking(move || { + let result = scanner.scan(registry.as_ref()); + let map = Self::build_source_map(registry.as_ref(), &source_locators); + if let Ok(mut guard) = source_map.try_write() { + *guard = map; + } + result + }) + .await + .unwrap_or_default() + }) + } + + /// Lookup asset entries for an FQN + pub fn lookup_asset(&self, fqn: &str) -> Option> { + self.registry.lookup(fqn) + } + + /// Lookup asset paths for an FQN (legacy compatibility) + pub fn lookup_paths(&self, fqn: &str) -> Option> { + self.registry + .lookup(fqn) + .map(|entries| entries.into_iter().map(|e| e.path).collect()) + } + + /// Lookup source asset for a binary asset path + pub fn lookup_source(&self, binary_path: &std::path::Path) -> Option { + self.source_map + .try_read() + .ok() + .and_then(|map| map.get(binary_path).cloned()) + } + + /// Get a snapshot of all routes (for serialization or passing to resolver) + pub fn routes_snapshot(&self) -> HashMap> { + self.registry + .all_routes() + .into_iter() + .map(|(k, v)| (k, v.into_iter().map(|e| e.path).collect())) + .collect() + } + + /// Refresh source map using discovered binary assets + pub fn refresh_source_map(&self) { + let map = Self::build_source_map(self.registry.as_ref(), &self.source_locators); + if let Ok(mut guard) = self.source_map.try_write() { + *guard = map; + } + } + + /// Get registry statistics + pub fn stats(&self) -> RegistryStats { + self.registry.stats() + } + + /// Get stub generators + pub fn generators(&self) -> &[Arc] { + &self.generators + } + + /// Find a generator that can handle the given asset + pub fn find_generator(&self, asset: &std::path::Path) -> Option> { + self.generators + .iter() + .find(|g| g.can_generate(asset)) + .cloned() + } + + // Helper to rebuild scanner (since AssetScanner contains non-Clone types) + fn build_scanner_clone(&self) -> AssetScanner { + // Note: This is a limitation - we can't easily clone the scanner + // For now, return a default scanner. The real implementation would + // need to use a different approach (e.g., Arc or factory pattern) + AssetScanner::new() + } + + fn build_source_map( + registry: &InMemoryRouteRegistry, + locators: &[Arc], + ) -> HashMap { + let mut map = HashMap::new(); + let mut seen = HashSet::new(); + for entries in registry.all_routes().values() { + for entry in entries { + if !seen.insert(entry.path.clone()) { + continue; + } + for locator in locators { + if let Some(source) = locator.locate_source(entry) { + map.insert(entry.path.clone(), source); + break; + } + } + } + } + map + } +} + +/// Builder for AssetStubService +pub struct AssetStubServiceBuilder { + discoverers: Vec>, + indexers: Vec>, + generators: Vec>, + source_locators: Vec>, + registry: Option>, +} + +impl AssetStubServiceBuilder { + pub fn new() -> Self { + Self { + discoverers: Vec::new(), + indexers: Vec::new(), + generators: Vec::new(), + source_locators: Vec::new(), + registry: None, + } + } + + pub fn add_discoverer(mut self, discoverer: Box) -> Self { + self.discoverers.push(discoverer); + self + } + + pub fn add_indexer(mut self, indexer: Arc) -> Self { + self.indexers.push(indexer); + self + } + + pub fn add_generator(mut self, generator: Arc) -> Self { + self.generators.push(generator); + self + } + + pub fn add_source_locator(mut self, locator: Arc) -> Self { + self.source_locators.push(locator); + self + } + + pub fn with_registry(mut self, registry: Arc) -> Self { + self.registry = Some(registry); + self + } + + pub fn build(self) -> AssetStubService { + let service = if let Some(registry) = self.registry { + AssetStubService::with_registry( + registry, + self.discoverers, + self.indexers, + self.generators, + self.source_locators, + ) + } else { + AssetStubService::new( + self.discoverers, + self.indexers, + self.generators, + self.source_locators, + ) + }; + + service + } +} + +impl Default for AssetStubServiceBuilder { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use naviscope_plugin::{AssetSource, BoxError}; + use std::path::Path; + + struct MockDiscoverer; + + impl AssetDiscoverer for MockDiscoverer { + fn discover(&self) -> Box + Send + '_> { + Box::new(std::iter::once(AssetEntry::new( + PathBuf::from("/test.jar"), + AssetSource::Unknown, + ))) + } + + fn name(&self) -> &str { + "Mock" + } + + fn source_type(&self) -> &str { + "mock" + } + } + + struct MockIndexer; + + impl AssetIndexer for MockIndexer { + fn can_index(&self, _: &Path) -> bool { + true + } + + fn index(&self, _: &Path) -> Result, BoxError> { + Ok(vec!["com.example".to_string()]) + } + } + + #[test] + fn test_service_basic() { + let service = AssetStubService::new( + vec![Box::new(MockDiscoverer)], + vec![Arc::new(MockIndexer)], + vec![], + vec![], + ); + + let result = service.scan_sync(); + assert_eq!(result.indexed_assets, 1); + assert_eq!(result.total_prefixes, 1); + + let entries = service.lookup_asset("com.example.Foo"); + assert!(entries.is_some()); + } + + #[test] + fn test_builder() { + let service = AssetStubServiceBuilder::new() + .add_discoverer(Box::new(MockDiscoverer)) + .add_indexer(Arc::new(MockIndexer)) + .build(); + + let result = service.scan_sync(); + assert_eq!(result.indexed_assets, 1); + } +} diff --git a/crates/core/src/cache/mod.rs b/crates/core/src/cache/mod.rs new file mode 100644 index 0000000..20771d4 --- /dev/null +++ b/crates/core/src/cache/mod.rs @@ -0,0 +1,5 @@ +//! Caching subsystem + +pub mod stub_cache; + +pub use stub_cache::{AssetKey, GlobalStubCache}; diff --git a/crates/core/src/cache/stub_cache.rs b/crates/core/src/cache/stub_cache.rs new file mode 100644 index 0000000..e765d12 --- /dev/null +++ b/crates/core/src/cache/stub_cache.rs @@ -0,0 +1,374 @@ +//! Global stub cache for external dependencies +//! +//! Stores parsed stub data from external assets (JARs, jmods, etc.) in a global cache +//! to avoid re-parsing the same dependencies across different projects. + +use naviscope_plugin::IndexNode; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; +use std::time::SystemTime; +use xxhash_rust::xxh3::xxh3_64; + +// Note: We use the metadata registry from naviscope_plugin + +/// Key identifying an external asset +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct AssetKey { + pub path: PathBuf, + pub size: u64, + pub mtime: u64, // Unix timestamp for serialization simplicity +} + +impl AssetKey { + /// Create an AssetKey from a file path + pub fn from_path(path: &Path) -> std::io::Result { + let metadata = fs::metadata(path)?; + let mtime = metadata + .modified()? + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Ok(Self { + path: path.to_path_buf(), + size: metadata.len(), + mtime, + }) + } + + /// Compute a hash for this asset key + pub fn hash(&self) -> u64 { + let key_str = format!("{}:{}:{}", self.path.display(), self.size, self.mtime); + xxh3_64(key_str.as_bytes()) + } +} + +/// Cached stub entry (serializable, language-agnostic) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedStub { + pub fqn: String, + pub id: String, + pub name: String, + pub kind: naviscope_api::models::graph::NodeKind, + pub lang: naviscope_api::models::Language, + pub source: naviscope_api::models::graph::NodeSource, + pub status: naviscope_api::models::graph::ResolutionStatus, + /// Encapsulated metadata + pub metadata: naviscope_plugin::CachedMetadata, +} + +impl CachedStub { + /// Convert from IndexNode (language-agnostic serialization) + pub fn from_index_node(node: &IndexNode) -> Self { + let id = match &node.id { + naviscope_api::models::symbol::NodeId::Flat(s) => s.clone(), + naviscope_api::models::symbol::NodeId::Structured(s) => format!("{:?}", s), + }; + + // Use trait method for serialization + let metadata = node.metadata.to_cached_metadata(); + + Self { + fqn: id.clone(), + id, + name: node.name.clone(), + kind: node.kind.clone(), + lang: naviscope_api::models::Language::from(node.lang.clone()), + source: node.source.clone(), + status: node.status.clone(), + metadata, + } + } + + /// Convert back to IndexNode + pub fn to_index_node(&self) -> IndexNode { + // Deserialize metadata + let metadata = naviscope_plugin::deserialize_metadata(&self.metadata); + + IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat(self.id.clone()), + name: self.name.clone(), + kind: self.kind.clone(), + lang: self.lang.to_string(), + source: self.source.clone(), + status: self.status.clone(), + location: None, + metadata, + } + } +} + +/// Cache file for a single asset +#[derive(Debug, Serialize, Deserialize)] +pub struct StubCacheFile { + pub version: u32, + pub asset_hash: u64, + pub asset_path: String, + pub created_at: u64, + pub entries: HashMap, +} + +impl StubCacheFile { + pub fn new(asset: &AssetKey) -> Self { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Self { + version: 1, + asset_hash: asset.hash(), + asset_path: asset.path.display().to_string(), + created_at: now, + entries: HashMap::new(), + } + } +} + +/// Global stub cache manager +pub struct GlobalStubCache { + base_dir: PathBuf, + loaded: Arc>>>>, +} + +use naviscope_api::cache::{CacheInspectResult, CacheStats, CachedAssetSummary, StubCacheManager}; +use naviscope_api::{ApiError, ApiResult}; + +impl StubCacheManager for GlobalStubCache { + fn stats(&self) -> CacheStats { + self.stats() + } + + fn scan_assets(&self) -> Vec { + self.scan_assets() + } + + fn inspect_asset(&self, hash_prefix: &str) -> Option { + self.inspect_asset(hash_prefix) + } + + fn clear(&self) -> ApiResult<()> { + self.clear().map_err(|e| ApiError::Internal(e.to_string())) + } +} + +impl GlobalStubCache { + /// Create a new global stub cache + pub fn new(base_dir: PathBuf) -> Self { + fs::create_dir_all(&base_dir).unwrap_or_default(); + Self { + base_dir, + loaded: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Get the default global cache location + pub fn default_location() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home).join(".naviscope").join("stub_cache") + } + + /// Create a global cache at the default location + pub fn at_default_location() -> Self { + Self::new(Self::default_location()) + } + + /// Get the cache file path for an asset + fn cache_path(&self, asset_hash: u64) -> PathBuf { + self.base_dir.join(format!("{:016x}.stubs", asset_hash)) + } + + /// Load or create cache for an asset + fn get_or_create_cache(&self, asset: &AssetKey) -> Arc> { + let hash = asset.hash(); + + // Check if already loaded + { + let loaded = self.loaded.read().unwrap(); + if let Some(cache) = loaded.get(&hash) { + return cache.clone(); + } + } + + // Try to load from disk + let cache_path = self.cache_path(hash); + let cache = if cache_path.exists() { + match fs::read(&cache_path) { + Ok(bytes) => match rmp_serde::from_slice::(&bytes) { + Ok(file) if file.asset_hash == hash => file, + _ => StubCacheFile::new(asset), + }, + Err(_) => StubCacheFile::new(asset), + } + } else { + StubCacheFile::new(asset) + }; + + let cache = Arc::new(RwLock::new(cache)); + + // Store in memory + { + let mut loaded = self.loaded.write().unwrap(); + loaded.insert(hash, cache.clone()); + } + + cache + } + + /// Look up a cached stub + pub fn lookup(&self, asset: &AssetKey, fqn: &str) -> Option { + let cache = self.get_or_create_cache(asset); + let cache = cache.read().unwrap(); + + cache.entries.get(fqn).map(|e| e.to_index_node()) + } + + /// Store a stub in the cache + pub fn store(&self, asset: &AssetKey, stub: &IndexNode) { + let fqn = match &stub.id { + naviscope_api::models::symbol::NodeId::Flat(s) => s.clone(), + naviscope_api::models::symbol::NodeId::Structured(s) => format!("{:?}", s), + }; + + let cache = self.get_or_create_cache(asset); + { + let mut cache = cache.write().unwrap(); + cache.entries.insert(fqn, CachedStub::from_index_node(stub)); + } + + // Persist to disk + self.save_cache(asset); + } + + /// Save cache to disk + fn save_cache(&self, asset: &AssetKey) { + let hash = asset.hash(); + let loaded = self.loaded.read().unwrap(); + + if let Some(cache) = loaded.get(&hash) { + let cache = cache.read().unwrap(); + let cache_path = self.cache_path(hash); + + if let Ok(bytes) = rmp_serde::to_vec(&*cache) { + let _ = fs::write(cache_path, bytes); + } + } + } + + /// Clear all cached data + pub fn clear(&self) -> std::io::Result<()> { + // Clear in-memory cache + { + let mut loaded = self.loaded.write().unwrap(); + loaded.clear(); + } + + // Remove all cache files + if self.base_dir.exists() { + for entry in fs::read_dir(&self.base_dir)? { + let entry = entry?; + if entry + .path() + .extension() + .map(|e| e == "stubs") + .unwrap_or(false) + { + let _ = fs::remove_file(entry.path()); + } + } + } + + Ok(()) + } + + /// Scan all cached assets returning their summaries + pub fn scan_assets(&self) -> Vec { + let mut summaries = Vec::new(); + + if !self.base_dir.exists() { + return summaries; + } + + if let Ok(entries) = fs::read_dir(&self.base_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map_or(false, |ext| ext == "stubs") { + if let Ok(metadata) = fs::metadata(&path) { + // Try to read file header + // Note: We read the whole file for now. + // Optimization: Define a Header-only struct for rmp-serde if needed. + if let Ok(bytes) = fs::read(&path) { + if let Ok(file) = rmp_serde::from_slice::(&bytes) { + summaries.push(CachedAssetSummary { + hash: format!("{:016x}", file.asset_hash), + path: file.asset_path, + size_bytes: metadata.len(), + stub_count: file.entries.len(), + version: file.version, + created_at: file.created_at, + }); + } + } + } + } + } + } + + summaries + } + + /// Inspect a specific cached asset by hash (full or prefix) + pub fn inspect_asset(&self, hash_prefix: &str) -> Option { + let summaries = self.scan_assets(); + let target = summaries.iter().find(|s| s.hash.starts_with(hash_prefix))?; + + // Convert hex string back to u64 for file path lookup + let hash = u64::from_str_radix(&target.hash, 16).ok()?; + let cache_path = self.cache_path(hash); + + if let Ok(bytes) = fs::read(&cache_path) { + if let Ok(file) = rmp_serde::from_slice::(&bytes) { + let mut distro = HashMap::new(); + let mut samples = Vec::new(); + + for (i, entry) in file.entries.values().enumerate() { + *distro.entry(entry.metadata.type_tag.clone()).or_insert(0) += 1; + if i < 10 { + samples.push(entry.fqn.clone()); + } + } + + return Some(CacheInspectResult { + summary: CachedAssetSummary { + hash: target.hash.clone(), + path: file.asset_path, + size_bytes: target.size_bytes, + stub_count: file.entries.len(), + version: file.version, + created_at: file.created_at, + }, + metadata_distribution: distro, + sample_entries: samples, + }); + } + } + + None + } + + /// Get cache statistics + pub fn stats(&self) -> CacheStats { + let summaries = self.scan_assets(); + let total_assets = summaries.len(); + let total_entries = summaries.iter().map(|s| s.stub_count).sum(); + + CacheStats { + total_assets, + total_entries, + cache_dir: self.base_dir.clone(), + } + } +} diff --git a/crates/core/src/facade/graph.rs b/crates/core/src/facade/graph.rs index 69cde09..a4d9473 100644 --- a/crates/core/src/facade/graph.rs +++ b/crates/core/src/facade/graph.rs @@ -2,11 +2,11 @@ use super::EngineHandle; use crate::error::NaviscopeError; use crate::features::query::QueryEngine; use async_trait::async_trait; -use naviscope_api::{graph, models}; +use naviscope_api::{ApiError, ApiResult, graph, models}; #[async_trait] impl graph::GraphService for EngineHandle { - async fn query(&self, query: &models::GraphQuery) -> graph::Result { + async fn query(&self, query: &models::GraphQuery) -> ApiResult { let graph = self.graph().await; let query_clone = query.clone(); let handle = self.clone(); @@ -14,13 +14,14 @@ impl graph::GraphService for EngineHandle { let result = tokio::task::spawn_blocking( move || -> Result { let conventions = (*handle.naming_conventions()).clone(); - let engine = QueryEngine::new(&graph, |lang| handle.get_node_adapter(lang), conventions); + let engine = + QueryEngine::new(&graph, |lang| handle.get_node_presenter(lang), conventions); engine.execute(&query_clone) }, ) .await - .map_err(|e| graph::GraphError::Internal(e.to_string()))? - .map_err(|e| graph::GraphError::Internal(e.to_string()))?; + .map_err(|e| ApiError::Internal(e.to_string()))? + .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(models::QueryResult { nodes: result.nodes, @@ -28,7 +29,7 @@ impl graph::GraphService for EngineHandle { }) } - async fn get_stats(&self) -> graph::Result { + async fn get_stats(&self) -> ApiResult { let graph = self.graph().await; Ok(graph::GraphStats { node_count: graph.topology().node_count(), @@ -36,7 +37,7 @@ impl graph::GraphService for EngineHandle { }) } - async fn get_node_display(&self, fqn: &str) -> graph::Result> { + async fn get_node_display(&self, fqn: &str) -> ApiResult> { let query = models::GraphQuery::Cat { fqn: fqn.to_string(), }; diff --git a/crates/core/src/facade/lifecycle.rs b/crates/core/src/facade/lifecycle.rs index 0246e5d..073ade5 100644 --- a/crates/core/src/facade/lifecycle.rs +++ b/crates/core/src/facade/lifecycle.rs @@ -1,50 +1,65 @@ use super::EngineHandle; use crate::error::NaviscopeError; use async_trait::async_trait; -use naviscope_api::lifecycle::{EngineError, EngineLifecycle}; +use naviscope_api::lifecycle::{EngineLifecycle, EngineWatchHandle}; +use naviscope_api::{ApiError, ApiResult}; +use std::sync::Arc; + +struct WatchHandle { + token: tokio_util::sync::CancellationToken, +} + +impl EngineWatchHandle for WatchHandle { + fn stop(&self) { + self.token.cancel(); + } +} #[async_trait] impl EngineLifecycle for EngineHandle { - async fn rebuild(&self) -> naviscope_api::lifecycle::EngineResult<()> { + async fn rebuild(&self) -> ApiResult<()> { self.engine .rebuild() .await - .map_err(|e| EngineError::Internal(e.to_string())) + .map_err(|e| ApiError::Internal(e.to_string())) } - async fn load(&self) -> naviscope_api::lifecycle::EngineResult { + async fn load(&self) -> ApiResult { self.engine .load() .await - .map_err(|e| EngineError::Internal(e.to_string())) + .map_err(|e| ApiError::Internal(e.to_string())) } - async fn save(&self) -> naviscope_api::lifecycle::EngineResult<()> { + async fn save(&self) -> ApiResult<()> { self.engine .save() .await - .map_err(|e| EngineError::Internal(e.to_string())) + .map_err(|e| ApiError::Internal(e.to_string())) } - async fn refresh(&self) -> naviscope_api::lifecycle::EngineResult<()> { + async fn refresh(&self) -> ApiResult<()> { self.engine .refresh() .await - .map_err(|e| EngineError::Internal(e.to_string())) + .map_err(|e| ApiError::Internal(e.to_string())) } - async fn watch(&self) -> naviscope_api::lifecycle::EngineResult<()> { + async fn start_watch(&self) -> ApiResult> { + let watch_token = tokio_util::sync::CancellationToken::new(); self.engine .clone() - .watch() + .start_watch_with_token(watch_token.clone()) .await - .map_err(|e: NaviscopeError| EngineError::Internal(e.to_string())) + .map_err(|e: NaviscopeError| ApiError::Internal(e.to_string()))?; + + Ok(Arc::new(WatchHandle { token: watch_token })) } - async fn clear_index(&self) -> naviscope_api::lifecycle::EngineResult<()> { + async fn clear_index(&self) -> ApiResult<()> { self.engine .clear_project_index() .await - .map_err(|e: NaviscopeError| EngineError::Internal(e.to_string())) + .map_err(|e: NaviscopeError| ApiError::Internal(e.to_string())) } } diff --git a/crates/core/src/facade/mod.rs b/crates/core/src/facade/mod.rs index cab9b96..749e49d 100644 --- a/crates/core/src/facade/mod.rs +++ b/crates/core/src/facade/mod.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use crate::error::Result; use crate::model::CodeGraph; -use crate::runtime::orchestrator::NaviscopeEngine as InternalEngine; +use crate::runtime::NaviscopeEngine as InternalEngine; use naviscope_api::NaviscopeEngine; mod graph; @@ -25,7 +25,7 @@ impl EngineHandle { /// Create a new engine handle pub fn new(project_root: PathBuf) -> Self { Self { - engine: Arc::new(InternalEngine::new(project_root)), + engine: Arc::new(InternalEngine::builder(project_root).build()), } } @@ -43,42 +43,44 @@ impl EngineHandle { // ---- Language specific services (internal) ---- - pub fn get_lsp_parser( + pub fn get_semantic_resolver( &self, language: crate::model::source::Language, - ) -> Option> { - self.engine.get_resolver().get_lsp_parser(language) + ) -> Option> { + self.engine.semantic_cap(language) } - pub fn get_semantic_resolver( + pub fn get_node_presenter( &self, language: crate::model::source::Language, - ) -> Option> { - self.engine.get_resolver().get_semantic_resolver(language) + ) -> Option> { + self.engine.node_presenter(language) } - pub fn get_node_adapter( + pub fn get_metadata_codec( &self, language: crate::model::source::Language, - ) -> Option> { - self.engine.get_resolver().get_node_adapter(language) + ) -> Option> { + self.engine.metadata_codec(language) } - pub fn get_language_by_extension(&self, ext: &str) -> Option { - self.engine.get_resolver().get_language_by_extension(ext) + pub fn get_language_for_path( + &self, + path: &std::path::Path, + ) -> Option { + self.engine.language_for_path(path) } - pub fn get_parser_and_lang_for_path( + pub fn get_services_for_path( &self, path: &std::path::Path, ) -> Option<( - Arc, + 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)) + let lang = self.get_language_for_path(path)?; + let semantic_cap = self.get_semantic_resolver(lang.clone())?; + Some((semantic_cap, lang)) } /// Get naming convention for a specific language @@ -88,9 +90,11 @@ impl EngineHandle { ) -> Option> { self.engine.naming_conventions().get(language).cloned() } - + /// Get all naming conventions (cheap Arc clone) - pub(crate) fn naming_conventions(&self) -> Arc>> { + pub(crate) fn naming_conventions( + &self, + ) -> Arc>> { self.engine.naming_conventions() } @@ -102,7 +106,11 @@ impl EngineHandle { } } -impl NaviscopeEngine for EngineHandle {} +impl NaviscopeEngine for EngineHandle { + fn get_stub_cache_manager(&self) -> Arc { + self.engine.get_stub_cache() + } +} #[cfg(test)] mod tests { @@ -112,7 +120,7 @@ mod tests { #[tokio::test] async fn test_async_graph_access() { - let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let engine = Arc::new(InternalEngine::builder(PathBuf::from(".")).build()); let handle = EngineHandle::from_engine(engine); let graph = handle.graph().await; @@ -123,13 +131,13 @@ mod tests { 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 engine = Arc::new(InternalEngine::builder(PathBuf::from(".")).build()); + let handle = EngineHandle::from_engine(engine); + let _graph = rt.block_on(handle.graph()); }) .join() @@ -140,7 +148,7 @@ mod tests { async fn test_concurrent_queries() { use tokio::task::JoinSet; - let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let engine = Arc::new(InternalEngine::builder(PathBuf::from(".")).build()); let handle = Arc::new(EngineHandle::from_engine(engine)); let mut set = JoinSet::new(); @@ -164,13 +172,14 @@ mod tests { async fn test_query_functionality() { use naviscope_api::models::GraphQuery; - let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let engine = Arc::new(InternalEngine::builder(PathBuf::from(".")).build()); let handle = EngineHandle::from_engine(engine); // Test async query let query = GraphQuery::Find { pattern: "test".to_string(), kind: vec![], + sources: vec![], limit: 10, }; @@ -184,15 +193,16 @@ mod tests { 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 engine = Arc::new(InternalEngine::builder(PathBuf::from(".")).build()); + let handle = EngineHandle::from_engine(engine); + let query = GraphQuery::Find { pattern: "test".to_string(), kind: vec![], + sources: vec![], limit: 10, }; diff --git a/crates/core/src/facade/navigation.rs b/crates/core/src/facade/navigation.rs index 13948db..8de01aa 100644 --- a/crates/core/src/facade/navigation.rs +++ b/crates/core/src/facade/navigation.rs @@ -1,21 +1,30 @@ use crate::facade::EngineHandle; use crate::features::navigation::NavigationEngine; use async_trait::async_trait; +use naviscope_api::ApiResult; use naviscope_api::navigation::{NavigationService, ResolveResult}; #[async_trait] impl NavigationService for EngineHandle { - async fn resolve_path(&self, target: &str, current_context: Option<&str>) -> ResolveResult { + async fn resolve_path( + &self, + target: &str, + current_context: Option<&str>, + ) -> ApiResult { let graph = self.graph().await; let conventions = (*self.naming_conventions()).clone(); let engine = NavigationEngine::new(&graph, conventions); engine.resolve_path(target, current_context) } - async fn get_completion_candidates(&self, prefix: &str) -> Vec { + async fn get_completion_candidates( + &self, + prefix: &str, + limit: usize, + ) -> ApiResult> { let graph = self.graph().await; let conventions = (*self.naming_conventions()).clone(); let engine = NavigationEngine::new(&graph, conventions); - engine.get_completion_candidates(prefix) + engine.get_completion_candidates(prefix, limit) } } diff --git a/crates/core/src/facade/semantic.rs b/crates/core/src/facade/semantic.rs index 3eb2343..ebb2445 100644 --- a/crates/core/src/facade/semantic.rs +++ b/crates/core/src/facade/semantic.rs @@ -4,95 +4,146 @@ 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::{ApiError, ApiResult}; 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, + CallHierarchyAnalyzer, ReferenceAnalyzer, SymbolInfoProvider, SymbolNavigator, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::PathBuf; use std::sync::Arc; +use tokio::time::{Duration, sleep}; + +fn path_from_uri_like(uri: &str) -> PathBuf { + if uri.starts_with("file://") { + PathBuf::from(uri.strip_prefix("file://").unwrap_or(uri)) + } else { + PathBuf::from(uri) + } +} + +impl EngineHandle { + async fn hydrate_symbol_if_missing(&self, fqn: &str) -> ApiResult<()> { + if self + .get_node_display(fqn) + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + .is_some() + { + return Ok(()); + } + + if !self.engine.request_stub_for_fqn(fqn) { + let _ = self.engine.scan_global_assets().await; + let _ = self.engine.request_stub_for_fqn(fqn); + } + + for _ in 0..3 { + sleep(Duration::from_millis(25)).await; + if self + .get_node_display(fqn) + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + .is_some() + { + break; + } + } + + Ok(()) + } + + async fn hydrate_resolution_if_needed(&self, resolution: &SymbolResolution) -> ApiResult<()> { + if let Some(fqn) = resolution.fqn() { + self.hydrate_symbol_if_missing(fqn).await?; + } + Ok(()) + } +} #[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) - }; + ) -> ApiResult> { + let path = path_from_uri_like(&ctx.uri); - let (parser, lang) = match self.get_parser_and_lang_for_path(&path) { + let (semantic, _lang) = match self.get_services_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), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.resolve_symbol_at", + language: "unknown".to_string(), + }); + } }; let content = if let Some(c) = &ctx.content { c.clone() } else { - fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))? + fs::read_to_string(&path).map_err(|e| ApiError::Internal(e.to_string()))? }; - let tree = parser + let tree = semantic .parse(&content, None) - .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; + .ok_or_else(|| ApiError::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)) + Ok(semantic.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) - }; + async fn find_highlights(&self, ctx: &PositionContext) -> ApiResult> { + let path = path_from_uri_like(&ctx.uri); - let (parser, _) = match self.get_parser_and_lang_for_path(&path) { + let (semantic, _) = match self.get_services_for_path(&path) { Some(x) => x, - None => return Ok(vec![]), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.find_highlights", + language: "unknown".to_string(), + }); + } }; let content = if let Some(c) = &ctx.content { c.clone() } else { - fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))? + fs::read_to_string(&path).map_err(|e| ApiError::Internal(e.to_string()))? }; - let tree = parser + let tree = semantic .parse(&content, None) - .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; + .ok_or_else(|| ApiError::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)) + let graph = self.graph().await; + Ok(semantic.find_occurrences(&content, &tree, &res, Some(&graph))) } - async fn find_definitions(&self, query: &SymbolQuery) -> SemanticResult> { + async fn find_definitions(&self, query: &SymbolQuery) -> ApiResult> { + self.hydrate_resolution_if_needed(&query.resolution).await?; + let resolver = match self.get_semantic_resolver(query.language.clone()) { Some(r) => r, - None => return Ok(vec![]), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.find_definitions", + language: query.language.to_string(), + }); + } }; let graph = self.graph().await; @@ -117,13 +168,17 @@ impl SymbolNavigator for EngineHandle { Ok(locations) } - async fn find_type_definitions( - &self, - query: &SymbolQuery, - ) -> SemanticResult> { + async fn find_type_definitions(&self, query: &SymbolQuery) -> ApiResult> { + self.hydrate_resolution_if_needed(&query.resolution).await?; + let resolver = match self.get_semantic_resolver(query.language.clone()) { Some(r) => r, - None => return Ok(vec![]), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.find_type_definitions", + language: query.language.to_string(), + }); + } }; let graph = self.graph().await; @@ -150,13 +205,17 @@ impl SymbolNavigator for EngineHandle { Ok(locations) } - async fn find_implementations( - &self, - query: &SymbolQuery, - ) -> SemanticResult> { + async fn find_implementations(&self, query: &SymbolQuery) -> ApiResult> { + self.hydrate_resolution_if_needed(&query.resolution).await?; + let resolver = match self.get_semantic_resolver(query.language.clone()) { Some(r) => r, - None => return Ok(vec![]), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.find_implementations", + language: query.language.to_string(), + }); + } }; let graph = self.graph().await; let matches = resolver.find_implementations(&graph, &query.resolution); @@ -183,14 +242,27 @@ impl SymbolNavigator for EngineHandle { #[async_trait] impl ReferenceAnalyzer for EngineHandle { - async fn find_references(&self, query: &ReferenceQuery) -> SemanticResult> { + async fn find_references(&self, query: &ReferenceQuery) -> ApiResult> { + self.hydrate_resolution_if_needed(&query.resolution).await?; + let resolver = match self.get_semantic_resolver(query.language.clone()) { Some(r) => r, - None => return Ok(vec![]), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.find_references", + language: query.language.to_string(), + }); + } }; let graph = self.graph().await; - let matches = resolver.find_matches(&graph, &query.resolution); + let mut matches = resolver.find_matches(&graph, &query.resolution); + + // If searching for a method/class, also include implementations as "matches" + // for the purpose of filtering declarations. + let impls = resolver.find_implementations(&graph, &query.resolution); + matches.extend(impls); + let match_indices: Vec<_> = matches .iter() .filter_map(|id| graph.fqn_map().get(id).copied()) @@ -209,42 +281,39 @@ impl ReferenceAnalyzer for EngineHandle { let conventions_clone = conventions.clone(); tasks.spawn(async move { - let (parser, file_lang) = match handle.get_parser_and_lang_for_path(&path) { + let (semantic, _file_lang) = match handle.get_services_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(), + Err(e) => { + tracing::warn!("find_references failed to read {}: {}", path.display(), e); + return Vec::new(); + } }; let discovery = DiscoveryEngine::new(graph_snap.as_ref(), conventions_clone); let uri_str = format!("file://{}", path.display()); - let uri = match url::Url::parse(&uri_str) { + let uri: lsp_types::Uri = match uri_str.parse() { Ok(u) => u, - Err(_) => return Vec::new(), + Err(e) => { + tracing::warn!("find_references failed to parse URI {}: {}", uri_str, e); + return Vec::new(); + } }; - let locations = discovery.scan_file( - parser.as_ref(), - file_resolver.as_ref(), - &content, - &resolution, - &uri, - ); + let locations = discovery.scan_file(semantic.as_ref(), &content, &resolution, &uri); locations .into_iter() - .map(|loc| { - let path_buf = loc.uri.to_file_path().unwrap(); - SymbolLocation { + .filter_map(|loc| { + let path_buf = url::Url::parse(&loc.uri.to_string()) + .ok() + .and_then(|u| u.to_file_path().ok())?; + Some(SymbolLocation { path: Arc::from(path_buf), range: Range { start_line: loc.range.start.line as usize, @@ -253,7 +322,7 @@ impl ReferenceAnalyzer for EngineHandle { end_col: loc.range.end.character as usize, }, selection_range: None, - } + }) }) .collect::>() }); @@ -266,6 +335,25 @@ impl ReferenceAnalyzer for EngineHandle { } } + // 4. Optional: Filter out declarations if requested + if !query.include_declaration { + let decl_locations: HashSet<_> = match_indices + .iter() + .filter_map(|&idx| { + let node = &shared_graph.topology()[idx]; + let loc = node.location.as_ref()?; + let path = shared_graph.symbols().resolve(&loc.path.0); + let range = loc.selection_range.unwrap_or(loc.range); + Some((path.to_string(), range)) + }) + .collect(); + + all_locations.retain(|loc| { + let path_str = loc.path.to_string_lossy().to_string(); + !decl_locations.contains(&(path_str, loc.range)) + }); + } + all_locations.sort_by(|a, b| { a.path .cmp(&b.path) @@ -284,16 +372,40 @@ impl ReferenceAnalyzer for EngineHandle { #[async_trait] impl CallHierarchyAnalyzer for EngineHandle { - async fn find_incoming_calls( - &self, - fqn: &str, - ) -> SemanticResult> { + async fn find_incoming_calls(&self, fqn: &str) -> ApiResult> { + self.hydrate_symbol_if_missing(fqn).await?; + let graph = self.graph().await; - let target_indices = graph.find_matches_by_fqn(fqn); + let mut target_indices = graph.find_matches_by_fqn(fqn); + if target_indices.is_empty() { return Ok(vec![]); } + // 2. Identify unique languages from the found nodes to expand potential implementation targets + let mut unique_langs = std::collections::HashSet::new(); + for &idx in &target_indices { + let lang_symbol = graph.topology()[idx].lang.0; + unique_langs.insert(graph.symbols().resolve(&lang_symbol).to_string()); + } + + // 3. For each language, find implementations to avoid them being counted + // as callers when they are actually override sites. + let resolution = SymbolResolution::Global(fqn.to_string()); + for lang_str in unique_langs { + let language = Language::new(lang_str); + if let Some(resolver) = self.get_semantic_resolver(language) { + let impls = resolver.find_implementations(&graph, &resolution); + for impl_id in impls { + if let Some(&node_idx) = graph.fqn_map().get(&impl_id) { + if !target_indices.contains(&node_idx) { + target_indices.push(node_idx); + } + } + } + } + } + // 1. Meso-level scouting for candidate files let conventions = (*self.naming_conventions()).clone(); let discovery = DiscoveryEngine::new(&graph, conventions.clone()); @@ -311,36 +423,39 @@ impl CallHierarchyAnalyzer for EngineHandle { let conventions_clone = conventions.clone(); tasks.spawn(async move { - let (parser, file_lang) = match handle.get_parser_and_lang_for_path(&path) { + let (semantic, _file_lang) = match handle.get_services_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![], + Err(e) => { + tracing::warn!( + "find_incoming_calls failed to read {}: {}", + path.display(), + e + ); + return vec![]; + } }; let discovery = DiscoveryEngine::new(graph_snap.as_ref(), conventions_clone); let uri_str = format!("file://{}", path.display()); - let uri = match url::Url::parse(&uri_str) { + let uri: lsp_types::Uri = match uri_str.parse() { Ok(u) => u, - Err(_) => return vec![], + Err(e) => { + tracing::warn!( + "find_incoming_calls failed to parse URI {}: {}", + uri_str, + e + ); + return vec![]; + } }; // Verification - discovery.scan_file( - parser.as_ref(), - file_resolver.as_ref(), - &content, - &res, - &uri, - ) + discovery.scan_file(semantic.as_ref(), &content, &res, &uri) }); } @@ -355,7 +470,10 @@ impl CallHierarchyAnalyzer for EngineHandle { 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(path) = url::Url::parse(&loc.uri.to_string()) + .ok() + .and_then(|u| u.to_file_path().ok()) + { if let Some(caller_idx) = graph.find_container_node_at( &path, loc.range.start.line as usize, @@ -363,7 +481,12 @@ impl CallHierarchyAnalyzer for EngineHandle { ) { let node = &graph.topology()[caller_idx]; // Only include methods or constructors as callers - if matches!(node.kind(), NodeKind::Method | NodeKind::Constructor) { + // AND avoid reflexive calls that are actually just the definition site + let is_reflexive = target_indices.contains(&caller_idx); + + if matches!(node.kind(), NodeKind::Method | NodeKind::Constructor) + && !is_reflexive + { 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, @@ -386,7 +509,7 @@ impl CallHierarchyAnalyzer for EngineHandle { if let Some(display_node) = self .get_node_display(&fqn_str) .await - .map_err(|e| SemanticError::Internal(e.to_string()))? + .map_err(|e| ApiError::Internal(e.to_string()))? { results.push(CallHierarchyIncomingCall { from: display_node, @@ -398,10 +521,9 @@ impl CallHierarchyAnalyzer for EngineHandle { Ok(results) } - async fn find_outgoing_calls( - &self, - fqn: &str, - ) -> SemanticResult> { + async fn find_outgoing_calls(&self, fqn: &str) -> ApiResult> { + self.hydrate_symbol_if_missing(fqn).await?; + let graph = self.graph().await; let conventions = (*self.naming_conventions()).clone(); let node_idx = match graph.find_node(fqn) { @@ -409,31 +531,29 @@ impl CallHierarchyAnalyzer for EngineHandle { None => return Ok(vec![]), }; - let node = graph.get_node(node_idx).unwrap(); + let node = graph + .get_node(node_idx) + .ok_or_else(|| ApiError::Internal(format!("missing node for index {}", node_idx.index())))?; let symbols = graph.symbols(); let path_str = node .path(symbols) - .ok_or_else(|| SemanticError::Internal("Node has no path".into()))?; + .ok_or_else(|| ApiError::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()))?; + .ok_or_else(|| ApiError::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 (semantic, _lang) = self + .get_services_for_path(&path) + .ok_or_else(|| ApiError::Internal("No services for file".into()))?; - let content = - fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))?; + let content = fs::read_to_string(&path).map_err(|e| ApiError::Internal(e.to_string()))?; // Micro-level scanning: extract method body and find all calls - let tree = parser + let tree = semantic .parse(&content, None) - .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; + .ok_or_else(|| ApiError::Internal("Failed to parse".into()))?; let mut outgoing_calls: HashMap> = HashMap::new(); @@ -449,7 +569,9 @@ impl CallHierarchyAnalyzer for EngineHandle { 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()); + if let Some(child) = n.child(i as u32) { + stack.push(child); + } } continue; } @@ -467,7 +589,7 @@ impl CallHierarchyAnalyzer for EngineHandle { }; if let Ok(Some(res)) = self.resolve_symbol_at(&pos_ctx).await { - let matches = resolver.find_matches(&graph, &res); + let matches = semantic.find_matches(&graph, &res); for fqn_id in matches { if let Some(&m_idx) = graph.fqn_map().get(&fqn_id) { let m_node = &graph.topology()[m_idx]; @@ -486,7 +608,9 @@ impl CallHierarchyAnalyzer for EngineHandle { // Recurse children for i in 0..n.child_count() { - stack.push(n.child(i as u32).unwrap()); + if let Some(child) = n.child(i as u32) { + stack.push(child); + } } } @@ -501,7 +625,7 @@ impl CallHierarchyAnalyzer for EngineHandle { if let Some(display_node) = self .get_node_display(&fqn_str) .await - .map_err(|e| SemanticError::Internal(e.to_string()))? + .map_err(|e| ApiError::Internal(e.to_string()))? { results.push(CallHierarchyOutgoingCall { to: display_node, @@ -516,48 +640,58 @@ impl CallHierarchyAnalyzer for EngineHandle { #[async_trait] impl SymbolInfoProvider for EngineHandle { - async fn get_symbol_info(&self, fqn: &str) -> SemanticResult> { + async fn get_symbol_info(&self, fqn: &str) -> ApiResult> { + self.hydrate_symbol_if_missing(fqn).await?; self.get_node_display(fqn) .await - .map_err(|e| SemanticError::Internal(e.to_string())) + .map_err(|e| ApiError::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) - }; + async fn get_document_symbols(&self, uri: &str) -> ApiResult> { + let path = path_from_uri_like(uri); - let (parser, _lang) = match self.get_parser_and_lang_for_path(&path) { + let (semantic, _lang) = match self.get_services_for_path(&path) { Some(x) => x, - None => return Ok(vec![]), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.get_document_symbols", + language: "unknown".to_string(), + }); + } }; - let content = - fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))?; + let content = fs::read_to_string(&path).map_err(|e| ApiError::Internal(e.to_string()))?; - let tree = parser + let tree = semantic .parse(&content, None) - .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; + .ok_or_else(|| ApiError::Internal("Failed to parse".into()))?; - let symbols = parser.extract_symbols(&tree, &content); + let symbols = semantic.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) - }; + async fn get_language_for_document(&self, uri: &str) -> ApiResult> { + let path = path_from_uri_like(uri); - let ext = match path.extension().and_then(|e| e.to_str()) { - Some(e) => e, - None => return Ok(None), - }; + Ok(self.get_language_for_path(&path)) + } +} + +#[cfg(test)] +mod tests { + use super::path_from_uri_like; + use std::path::PathBuf; + + #[test] + fn path_from_uri_like_handles_file_uri() { + let p = path_from_uri_like("file:///tmp/naviscope_test.java"); + assert_eq!(p, PathBuf::from("/tmp/naviscope_test.java")); + } - Ok(self.get_language_by_extension(ext)) + #[test] + fn path_from_uri_like_keeps_plain_path() { + let p = path_from_uri_like("/tmp/naviscope_test.java"); + assert_eq!(p, PathBuf::from("/tmp/naviscope_test.java")); } } diff --git a/crates/core/src/features/discovery.rs b/crates/core/src/features/discovery.rs index 000aa86..28e5cff 100644 --- a/crates/core/src/features/discovery.rs +++ b/crates/core/src/features/discovery.rs @@ -1,7 +1,7 @@ use super::CodeGraphLike; -use crate::ingest::parser::LspParser; -use lsp_types::{Location, Url}; +use lsp_types::{Location, Uri}; pub use naviscope_api::models::SymbolResolution; +use naviscope_plugin::SemanticCap; use std::collections::HashSet; /// DiscoveryEngine bridges Meso-level graph knowledge with Micro-level file scanning. @@ -148,30 +148,38 @@ impl<'a> DiscoveryEngine<'a> { /// Now performs SEMANTIC VERIFICATION using the Resolver. pub fn scan_file( &self, - parser: &dyn LspParser, - resolver: &dyn crate::ingest::resolver::SemanticResolver, + semantic: &dyn SemanticCap, source: &str, target_resolution: &SymbolResolution, - uri: &Url, + uri: &Uri, ) -> Vec { - if let Some(tree) = parser.parse(source, None) { + if let Some(tree) = semantic.parse(source, None) { // 1. Syntactic Scan (Fast) - let candidates = parser.find_occurrences(source, &tree, target_resolution); + let candidates = semantic.find_occurrences( + source, + &tree, + target_resolution, + Some(self.index.as_plugin_graph()), + ); // 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( + if let Some(resolved_at_loc) = semantic.resolve_at( &tree, source, range.start_line, range.start_col, self.index.as_plugin_graph(), ) { - // 3. Identity Check - if &resolved_at_loc == target_resolution { + // 3. Identity & inheritance check + if semantic.is_reference_to( + self.index.as_plugin_graph(), + &resolved_at_loc, + target_resolution, + ) { valid_locations.push(Location { uri: uri.clone(), range: lsp_types::Range { diff --git a/crates/core/src/features/mod.rs b/crates/core/src/features/mod.rs index bffb7fc..889cf45 100644 --- a/crates/core/src/features/mod.rs +++ b/crates/core/src/features/mod.rs @@ -37,7 +37,7 @@ pub trait CodeGraphLike: Send + Sync { nc.render_fqn(node.id, self.fqns()) } else { // Fallback to default dot convention - naviscope_plugin::DotPathConvention.render_fqn(node.id, self.fqns()) + naviscope_plugin::StandardNamingConvention.render_fqn(node.id, self.fqns()) } } diff --git a/crates/core/src/features/navigation.rs b/crates/core/src/features/navigation.rs index 55f1180..6a53296 100644 --- a/crates/core/src/features/navigation.rs +++ b/crates/core/src/features/navigation.rs @@ -1,30 +1,42 @@ use super::CodeGraphLike; use crate::model::{EdgeType, NodeKind}; use naviscope_api::navigation::ResolveResult; +use naviscope_api::{ApiError, ApiResult}; /// NavigationEngine provides logic for resolving fuzzy/relative paths within a graph. pub struct NavigationEngine<'a> { graph: &'a dyn CodeGraphLike, - naming_conventions: std::collections::HashMap>, + naming_conventions: + std::collections::HashMap>, } impl<'a> NavigationEngine<'a> { pub fn new( graph: &'a dyn CodeGraphLike, - naming_conventions: std::collections::HashMap>, + naming_conventions: std::collections::HashMap< + String, + std::sync::Arc, + >, ) -> Self { Self { graph, naming_conventions, } } - - fn get_convention(&self, node: &crate::model::GraphNode) -> Option<&dyn naviscope_plugin::NamingConvention> { + + fn get_convention( + &self, + node: &crate::model::GraphNode, + ) -> Option<&dyn naviscope_plugin::NamingConvention> { let lang_str = self.graph.symbols().resolve(&node.lang.0); self.naming_conventions.get(lang_str).map(|c| c.as_ref()) } - pub fn resolve_path(&self, target: &str, current_context: Option<&str>) -> ResolveResult { + pub fn resolve_path( + &self, + target: &str, + current_context: Option<&str>, + ) -> ApiResult { // 1. Handle special paths ("/" or "root") if target == "/" || target == "root" { let project_nodes: Vec<_> = self @@ -43,9 +55,9 @@ impl<'a> NavigationEngine<'a> { .collect(); return match project_nodes.len() { - 1 => ResolveResult::Found(project_nodes[0].clone()), - 0 => ResolveResult::Found("".to_string()), - _ => ResolveResult::Ambiguous(project_nodes), + 1 => Ok(ResolveResult::Found(project_nodes[0].clone())), + 0 => Err(ApiError::NotFound("project root node".to_string())), + _ => Ok(ResolveResult::Ambiguous(project_nodes)), }; } @@ -67,18 +79,20 @@ impl<'a> NavigationEngine<'a> { if let Some(parent_node) = self.graph.topology().node_weight(parent_idx) { let convention = self.get_convention(parent_node); - return ResolveResult::Found(self.graph.render_fqn(parent_node, convention)); + return Ok(ResolveResult::Found( + self.graph.render_fqn(parent_node, convention), + )); } } } } } - return ResolveResult::NotFound; + return Ok(ResolveResult::NotFound); } // 3. Try exact match (absolute FQN) if self.graph.find_node(target).is_some() { - return ResolveResult::Found(target.to_string()); + return Ok(ResolveResult::Found(target.to_string())); } // 4. Try relative path from current context @@ -90,7 +104,7 @@ impl<'a> NavigationEngine<'a> { }; let joined = format!("{}{}{}", current_fqn, separator, target); if self.graph.find_node(&joined).is_some() { - return ResolveResult::Found(joined); + return Ok(ResolveResult::Found(joined)); } } @@ -152,15 +166,16 @@ impl<'a> NavigationEngine<'a> { .collect() }; - match candidates.len() { + Ok(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 + pub fn get_completion_candidates(&self, prefix: &str, limit: usize) -> ApiResult> { + let candidates = self + .graph .fqn_map() .keys() .filter_map(|&fid| { @@ -174,7 +189,8 @@ impl<'a> NavigationEngine<'a> { } None }) - .take(50) // Reasonable limit for candidates - .collect() + .take(limit) + .collect(); + Ok(candidates) } } diff --git a/crates/core/src/features/query.rs b/crates/core/src/features/query.rs index c05c4b0..3cd5cf7 100644 --- a/crates/core/src/features/query.rs +++ b/crates/core/src/features/query.rs @@ -1,7 +1,7 @@ +use naviscope_plugin::NodePresenter; use crate::error::{NaviscopeError, Result}; use crate::model::source::Language; use crate::model::{DisplayGraphNode, EdgeType, NodeKind}; -use crate::plugin::NodeAdapter; pub use naviscope_api::models::{GraphQuery, QueryResult, QueryResultEdge}; use petgraph::Direction as PetDirection; use regex::RegexBuilder; @@ -12,18 +12,22 @@ use super::CodeGraphLike; pub struct QueryEngine { graph: G, lookup: L, - naming_conventions: std::collections::HashMap>, + naming_conventions: + std::collections::HashMap>, } impl QueryEngine where G: CodeGraphLike, - L: Fn(Language) -> Option>, + L: Fn(Language) -> Option>, { pub fn new( graph: G, lookup: L, - naming_conventions: std::collections::HashMap>, + naming_conventions: std::collections::HashMap< + String, + Arc, + >, ) -> Self { Self { graph, @@ -51,6 +55,7 @@ where GraphQuery::Find { pattern, kind, + sources, limit, } => { let regex = RegexBuilder::new(pattern) @@ -65,7 +70,9 @@ where let convention = self.naming_conventions.get(lang_str).map(|c| c.as_ref()); let fqn_str = self.graph.render_fqn(node, convention); if regex.is_match(&fqn_str) || regex.is_match(node.name(symbols)) { - if kind.is_empty() || kind.contains(&node.kind) { + let kind_match = kind.is_empty() || kind.contains(&node.kind); + let source_match = sources.is_empty() || sources.contains(&node.source); + if kind_match && source_match { nodes.push(self.render_node(node)); } } @@ -79,6 +86,7 @@ where GraphQuery::Ls { fqn, kind, + sources, modifiers: _, } => { if let Some(target_fqn) = fqn { @@ -87,6 +95,7 @@ where &[EdgeType::Contains], PetDirection::Outgoing, kind, + sources, ) } else { let mut nodes = Vec::new(); @@ -101,7 +110,11 @@ where .any(|e| e.weight().edge_type == EdgeType::Contains); if !has_parent { - nodes.push(self.render_node(node)); + let source_match = + sources.is_empty() || sources.contains(&node.source); + if source_match { + nodes.push(self.render_node(node)); + } } } } @@ -116,7 +129,10 @@ where .any(|e| e.weight().edge_type == EdgeType::Contains); if !has_parent { - if kind.is_empty() || kind.contains(&node.kind) { + let kind_match = kind.is_empty() || kind.contains(&node.kind); + let source_match = + sources.is_empty() || sources.contains(&node.source); + if kind_match && source_match { nodes.push(self.render_node(node)); } } @@ -147,7 +163,7 @@ where } else { PetDirection::Outgoing }; - self.traverse_neighbors(fqn.as_str(), edge_types, direction, &[]) + self.traverse_neighbors(fqn.as_str(), edge_types, direction, &[], &[]) } } } @@ -158,6 +174,7 @@ where edge_filter: &[EdgeType], dir: PetDirection, kind_filter: &[NodeKind], + source_filter: &[naviscope_api::models::graph::NodeSource], ) -> Result { let start_idx = self .graph @@ -175,14 +192,20 @@ where let neighbor_node = &topology[neighbor_idx]; let start_node = &topology[start_idx]; - if kind_filter.is_empty() || kind_filter.contains(&neighbor_node.kind) { + if (kind_filter.is_empty() || kind_filter.contains(&neighbor_node.kind)) + && (source_filter.is_empty() || source_filter.contains(&neighbor_node.source)) + { nodes.push(self.render_node(neighbor_node)); let symbols = self.graph.symbols(); let start_lang = symbols.resolve(&start_node.lang.0); let neighbor_lang = symbols.resolve(&neighbor_node.lang.0); - let start_convention = self.naming_conventions.get(start_lang).map(|c| c.as_ref()); - let neighbor_convention = self.naming_conventions.get(neighbor_lang).map(|c| c.as_ref()); + let start_convention = + self.naming_conventions.get(start_lang).map(|c| c.as_ref()); + let neighbor_convention = self + .naming_conventions + .get(neighbor_lang) + .map(|c| c.as_ref()); let (from, to) = if dir == PetDirection::Outgoing { ( diff --git a/crates/core/src/indexing/build.rs b/crates/core/src/indexing/build.rs new file mode 100644 index 0000000..4d48b26 --- /dev/null +++ b/crates/core/src/indexing/build.rs @@ -0,0 +1,76 @@ +use crate::error::Result; +use crate::indexing::scanner::ParsedFile; +use crate::model::GraphOp; +use naviscope_plugin::{BuildCaps, BuildContent, ParsedContent, ProjectContext}; +use std::fs; + +pub struct BuildCompiler { + build_caps: Vec, +} + +impl BuildCompiler { + pub fn with_caps(build_caps: Vec) -> Self { + Self { build_caps } + } + + pub fn compile_build_batch( + &self, + build_files: &[ParsedFile], + context: &mut ProjectContext, + ) -> Result> { + let mut all_ops = Vec::new(); + for caps in &self.build_caps { + let tool_files: Vec<&ParsedFile> = build_files + .iter() + .filter(|f| caps.matcher.supports_path(f.path())) + .collect(); + + if !tool_files.is_empty() { + let parsed_tool_files: Vec = tool_files + .iter() + .map(|f| Self::prepare_build_file(caps, f)) + .collect::>>()?; + let parsed_tool_file_refs: Vec<&ParsedFile> = parsed_tool_files.iter().collect(); + let (unit, ctx) = caps + .indexing + .compile_build(&parsed_tool_file_refs) + .map_err(crate::error::NaviscopeError::from)?; + all_ops.extend(unit.ops); + context.path_to_module.extend(ctx.path_to_module); + } + } + Ok(all_ops) + } + + fn prepare_build_file(caps: &BuildCaps, file: &ParsedFile) -> Result { + let source = match &file.content { + ParsedContent::Unparsed(s) => s.clone(), + ParsedContent::Lazy => fs::read_to_string(file.path()).map_err(|e| { + crate::error::NaviscopeError::Internal(format!( + "Failed to read build file {}: {}", + file.path().display(), + e + )) + })?, + ParsedContent::Metadata(_) => return Ok(file.clone()), + ParsedContent::Language(_) => return Ok(file.clone()), + }; + + let parse_result = caps + .parser + .parse_build_file(&source) + .map_err(crate::error::NaviscopeError::from)?; + + let content = match parse_result.content { + BuildContent::Metadata(value) => ParsedContent::Metadata(value), + BuildContent::Unparsed(text) => ParsedContent::Unparsed(text), + // Build indexing currently consumes Metadata/Unparsed; preserve source for this case. + BuildContent::Parsed(_) => ParsedContent::Unparsed(source), + }; + + Ok(ParsedFile { + file: file.file.clone(), + content, + }) + } +} diff --git a/crates/core/src/ingest/mod.rs b/crates/core/src/indexing/mod.rs similarity index 57% rename from crates/core/src/ingest/mod.rs rename to crates/core/src/indexing/mod.rs index 09d11cf..e822cce 100644 --- a/crates/core/src/ingest/mod.rs +++ b/crates/core/src/indexing/mod.rs @@ -1,8 +1,15 @@ -pub mod builder; -pub mod parser; -pub mod pipeline; -pub mod resolver; +pub mod build; pub mod scanner; +pub mod source; + +pub use naviscope_plugin::IndexNode; + +/// A request to asynchronously generate a stub for an external FQN. +#[derive(Debug, Clone)] +pub struct StubRequest { + pub fqn: String, + pub candidate_paths: Vec, +} use std::path::Path; diff --git a/crates/core/src/indexing/scanner.rs b/crates/core/src/indexing/scanner.rs new file mode 100644 index 0000000..2456447 --- /dev/null +++ b/crates/core/src/indexing/scanner.rs @@ -0,0 +1,88 @@ +use super::is_relevant_path; + +use crate::model::source::SourceFile; +use ignore::WalkBuilder; +use std::collections::HashMap; +use std::fs; +use std::hash::Hasher; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; +use xxhash_rust::xxh3::Xxh3; + +pub use naviscope_plugin::{ParsedContent, ParsedFile}; + +pub struct Scanner; + +impl Scanner { + pub fn scan_files_iter<'a>( + paths: Vec, + existing_files: &'a HashMap, + ) -> impl Iterator + 'a { + paths + .into_iter() + .filter_map(|path| Self::parse_path(&path, existing_files)) + } + + pub(crate) fn collect_paths(root: &Path) -> Vec { + WalkBuilder::new(root) + .build() + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + if path.is_file() && is_relevant_path(path) { + return Some(path.to_path_buf()); + } + None + }) + .collect() + } + + fn process_file_with_mtime(path: &Path, mtime: u64) -> Option { + let content = fs::read(path).ok()?; + let mut hasher = Xxh3::new(); + hasher.write(&content); + let hash = hasher.finish(); + + Some(SourceFile { + path: path.to_path_buf(), + content_hash: hash, + last_modified: mtime, + }) + } + + fn parse_path(path: &Path, existing_files: &HashMap) -> Option { + // 1. Check metadata (mtime) first + let metadata = fs::metadata(path).ok()?; + let modified = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or(std::time::Duration::ZERO) + .as_secs(); + + if let Some(existing) = existing_files.get(path) { + if existing.last_modified == modified { + return None; + } + } + + // 2. Read and hash content + let source_file = Self::process_file_with_mtime(path, modified)?; + + // 3. Double check hash (mtime might change but content remains same) + if let Some(existing) = existing_files.get(path) { + if existing.content_hash == source_file.content_hash { + return None; + } + } + + if path.extension().is_some() { + Some(ParsedFile { + file: source_file, + content: ParsedContent::Lazy, + }) + } else { + None + } + } +} diff --git a/crates/core/src/indexing/source/executor.rs b/crates/core/src/indexing/source/executor.rs new file mode 100644 index 0000000..6497a08 --- /dev/null +++ b/crates/core/src/indexing/source/executor.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, RwLock}; + +use naviscope_plugin::{ + LanguageCaps, ParsedFile, ProjectContext, SourceAnalyzeArtifact, SourceCollectArtifact, +}; + +use crate::error::{NaviscopeError, Result}; +use crate::indexing::StubRequest; +use crate::model::{CodeGraph, GraphOp}; + +use super::stub_ops::{find_asset_for_fqn, plan_stub_requests, resolve_stub_requests}; + +pub struct SourcePhaseExecutor { + pub lang_caps: Arc>, + pub project_context: Arc>, + pub routes: Arc>>>, + pub current: Arc>>, + pub stub_cache: Arc, + pub collect_cache: Arc>>>, + pub analyze_cache: Arc>>>, + pub collect_cache_limit: usize, + pub analyze_cache_limit: usize, +} + +pub struct SourceLowerOutput { + pub ops: Vec, + pub stub_requests: Vec, +} + +impl SourcePhaseExecutor { + pub fn collect_file(&self, file: &ParsedFile) -> Result<()> { + let caps = self + .lang_caps + .iter() + .find(|c| c.matcher.supports_path(file.path())); + let Some(caps) = caps else { + return Ok(()); + }; + + let mut cache = self + .collect_cache + .lock() + .map_err(|_| NaviscopeError::Internal("collect cache poisoned".to_string()))?; + + if let Some(collected) = cache.get(file.path()) { + self.merge_collected_symbols(collected.as_ref())?; + return Ok(()); + } + + let context = self + .project_context + .read() + .map_err(|_| NaviscopeError::Internal("project context poisoned".to_string()))? + .clone(); + let collected = caps + .indexing + .collect_source(file, &context) + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + self.merge_collected_symbols(collected.as_ref())?; + bounded_insert( + &mut cache, + file.path().to_path_buf(), + collected, + self.collect_cache_limit, + ); + Ok(()) + } + + pub fn analyze_file(&self, file: &ParsedFile) -> Result<()> { + let caps = self + .lang_caps + .iter() + .find(|c| c.matcher.supports_path(file.path())); + let Some(caps) = caps else { + return Ok(()); + }; + + let context = self + .project_context + .read() + .map_err(|_| NaviscopeError::Internal("project context poisoned".to_string()))? + .clone(); + + let collected = { + let mut cache = self + .collect_cache + .lock() + .map_err(|_| NaviscopeError::Internal("collect cache poisoned".to_string()))?; + if let Some(c) = cache.remove(file.path()) { + c + } else { + caps.indexing + .collect_source(file, &context) + .map_err(|e| NaviscopeError::Internal(e.to_string()))? + } + }; + + let analyzed = caps + .indexing + .analyze_source(collected, &context) + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + let mut cache = self + .analyze_cache + .lock() + .map_err(|_| NaviscopeError::Internal("analyze cache poisoned".to_string()))?; + bounded_insert( + &mut cache, + file.path().to_path_buf(), + analyzed, + self.analyze_cache_limit, + ); + Ok(()) + } + + pub fn lower_file(&self, file: &ParsedFile) -> Result { + let caps = self + .lang_caps + .iter() + .find(|c| c.matcher.supports_path(file.path())); + let Some(caps) = caps else { + return Ok(SourceLowerOutput { + ops: Vec::new(), + stub_requests: Vec::new(), + }); + }; + + let context = self + .project_context + .read() + .map_err(|_| NaviscopeError::Internal("project context poisoned".to_string()))? + .clone(); + + let analyzed = { + let mut cache = self + .analyze_cache + .lock() + .map_err(|_| NaviscopeError::Internal("analyze cache poisoned".to_string()))?; + if let Some(a) = cache.remove(file.path()) { + a + } else { + let collected = caps + .indexing + .collect_source(file, &context) + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + caps.indexing + .analyze_source(collected, &context) + .map_err(|e| NaviscopeError::Internal(e.to_string()))? + } + }; + + let unit = caps + .indexing + .lower_source(analyzed, &context) + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + let path = file.path().to_path_buf(); + let mut ops = Vec::with_capacity(8); + ops.push(GraphOp::RemovePath { + path: Arc::from(path.as_path()), + }); + ops.push(GraphOp::UpdateFile { + metadata: file.file.clone(), + }); + + let deferred_targets: Vec = + unit.deferred_symbols.into_iter().map(|d| d.target).collect(); + ops.extend(unit.ops); + + let routes_snapshot = self + .routes + .read() + .map_err(|_| NaviscopeError::Internal("routes map poisoned".to_string()))? + .clone(); + + let mut stub_requests = plan_stub_requests(&ops, &routes_snapshot); + let deferred_stub_requests = + deferred_targets_to_stub_requests(&deferred_targets, &routes_snapshot); + stub_requests.extend(deferred_stub_requests); + + Ok(SourceLowerOutput { ops, stub_requests }) + } + + pub fn stub_phase(&self, requests: Vec) -> Vec { + resolve_stub_requests( + requests, + Arc::clone(&self.current), + Arc::clone(&self.lang_caps), + Arc::clone(&self.stub_cache), + ) + } + + fn merge_collected_symbols(&self, collected: &dyn SourceCollectArtifact) -> Result<()> { + let mut ctx = self + .project_context + .write() + .map_err(|_| NaviscopeError::Internal("project context poisoned".to_string()))?; + for sym in collected.collected_type_symbols() { + ctx.symbol_table.type_symbols.insert(sym.clone()); + } + for sym in collected.collected_method_symbols() { + ctx.symbol_table.method_symbols.insert(sym.clone()); + } + Ok(()) + } +} + +fn bounded_insert(cache: &mut HashMap, key: PathBuf, value: T, limit: usize) { + let cap = limit.max(1); + if cache.len() >= cap { + if let Some(evict_key) = cache.keys().next().cloned() { + cache.remove(&evict_key); + } + } + cache.insert(key, value); +} + +fn deferred_targets_to_stub_requests( + deferred_targets: &[String], + routes: &HashMap>, +) -> Vec { + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for target in deferred_targets { + if let Some(paths) = find_asset_for_fqn(target, routes) + && seen.insert(target.clone()) + { + out.push(StubRequest { + fqn: target.clone(), + candidate_paths: paths.clone(), + }); + } + } + + out +} diff --git a/crates/core/src/indexing/source/flow_control.rs b/crates/core/src/indexing/source/flow_control.rs new file mode 100644 index 0000000..2b033eb --- /dev/null +++ b/crates/core/src/indexing/source/flow_control.rs @@ -0,0 +1,32 @@ +#[derive(Clone, Copy)] +pub(super) struct SourceFlowControl { + pub(super) max_parallelism: usize, + pub(super) collect_cache_limit: usize, + pub(super) analyze_cache_limit: usize, +} + +impl Default for SourceFlowControl { + fn default() -> Self { + let max_parallelism = std::env::var("NAVISCOPE_SOURCE_MAX_PARALLELISM") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or_else(|| std::thread::available_parallelism().map_or(4, usize::from)); + let collect_cache_limit = std::env::var("NAVISCOPE_SOURCE_COLLECT_CACHE_LIMIT") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or(512); + let analyze_cache_limit = std::env::var("NAVISCOPE_SOURCE_ANALYZE_CACHE_LIMIT") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or(512); + + Self { + max_parallelism, + collect_cache_limit, + analyze_cache_limit, + } + } +} diff --git a/crates/core/src/indexing/source/mod.rs b/crates/core/src/indexing/source/mod.rs new file mode 100644 index 0000000..d2d4f79 --- /dev/null +++ b/crates/core/src/indexing/source/mod.rs @@ -0,0 +1,265 @@ +mod executor; +mod flow_control; +mod stub_ops; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; + +use naviscope_plugin::{LanguageCaps, NamingConvention, ParsedFile, ProjectContext}; +use rayon::prelude::*; + +use crate::error::{NaviscopeError, Result}; +use crate::indexing::StubRequest; +use crate::model::{CodeGraph, GraphOp, Language}; + +use executor::{SourceLowerOutput, SourcePhaseExecutor}; +use flow_control::SourceFlowControl; +use stub_ops::resolve_stub_requests; +pub use stub_ops::plan_stub_requests; + +pub struct SourceCompiler { + inflight_compiles: AtomicUsize, + completed_source_epochs: AtomicU64, + pending_stub_requests: Arc>>, + flow_control: SourceFlowControl, +} + +impl SourceCompiler { + pub fn new() -> Self { + Self { + inflight_compiles: AtomicUsize::new(0), + completed_source_epochs: AtomicU64::new(0), + pending_stub_requests: Arc::new(Mutex::new(Vec::new())), + flow_control: SourceFlowControl::default(), + } + } + + #[allow(clippy::too_many_arguments)] + pub async fn compile_source_files( + &self, + base_graph: CodeGraph, + source_files: Vec, + project_context: ProjectContext, + routes: HashMap>, + current: Arc>>, + naming_conventions: Arc>>, + lang_caps: Arc>, + stub_cache: Arc, + ) -> Result { + if source_files.is_empty() { + return Ok(base_graph); + } + + self.inflight_compiles.fetch_add(1, Ordering::AcqRel); + let _compile_guard = CompileGuard { + inflight_compiles: &self.inflight_compiles, + }; + let phase_ops = tokio::task::spawn_blocking({ + let pending_queue = Arc::clone(&self.pending_stub_requests); + let phase_current = Arc::clone(¤t); + let phase_lang_caps = Arc::clone(&lang_caps); + let phase_stub_cache = Arc::clone(&stub_cache); + let flow = self.flow_control; + move || { + run_source_phases_blocking( + source_files, + project_context, + routes, + pending_queue, + phase_current, + phase_lang_caps, + phase_stub_cache, + flow, + ) + } + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))??; + + let next_graph = apply_ops_to_graph(base_graph, naming_conventions, phase_ops)?; + self.completed_source_epochs.fetch_add(1, Ordering::AcqRel); + Ok(next_graph) + } + + pub fn try_submit_or_enqueue_stub_request( + &self, + req: StubRequest, + current: Arc>>, + naming_conventions: Arc>>, + lang_caps: Arc>, + stub_cache: Arc, + ) -> bool { + if let Ok(mut pending) = self.pending_stub_requests.lock() { + pending.push(req); + } else { + return false; + } + + // No completed source phase yet: queue only (replayed in next compile). + if self.completed_source_epochs.load(Ordering::Acquire) == 0 { + return true; + } + // Source phase in progress: queue only (drained inside phase). + if self.inflight_compiles.load(Ordering::Acquire) > 0 { + return true; + } + + let queued = Self::drain_pending_stub_requests(&self.pending_stub_requests); + if queued.is_empty() { + return true; + } + + let ops = resolve_stub_requests(queued, current.clone(), lang_caps, stub_cache); + if ops.is_empty() { + return true; + } + + apply_ops_to_current(current, naming_conventions, ops).is_ok() + } + + fn drain_pending_stub_requests(queue: &Arc>>) -> Vec { + match queue.lock() { + Ok(mut pending) => pending.drain(..).collect(), + Err(_) => Vec::new(), + } + } +} + +fn run_source_phases_blocking( + source_files: Vec, + project_context: ProjectContext, + routes: HashMap>, + pending_stub_requests: Arc>>, + current: Arc>>, + lang_caps: Arc>, + stub_cache: Arc, + flow: SourceFlowControl, +) -> Result> { + let mut queued_stub_requests = + SourceCompiler::drain_pending_stub_requests(&pending_stub_requests); + + let executor = Arc::new(SourcePhaseExecutor { + lang_caps, + project_context: Arc::new(RwLock::new(project_context)), + routes: Arc::new(RwLock::new(routes)), + current, + stub_cache, + collect_cache: Arc::new(Mutex::new(HashMap::new())), + analyze_cache: Arc::new(Mutex::new(HashMap::new())), + collect_cache_limit: flow.collect_cache_limit, + analyze_cache_limit: flow.analyze_cache_limit, + }); + + let thread_pool = rayon::ThreadPoolBuilder::new() + .num_threads(flow.max_parallelism.max(1)) + .build() + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + let collect_results: Vec> = thread_pool.install(|| { + source_files + .par_iter() + .map(|file| executor.collect_file(file)) + .collect() + }); + for result in collect_results { + result?; + } + + let analyze_results: Vec> = thread_pool.install(|| { + source_files + .par_iter() + .map(|file| executor.analyze_file(file)) + .collect() + }); + for result in analyze_results { + result?; + } + + let lowered_results: Vec> = thread_pool.install(|| { + source_files + .par_iter() + .map(|file| executor.lower_file(file)) + .collect() + }); + + let mut ops = Vec::new(); + let mut stub_requests = Vec::new(); + for result in lowered_results { + let output = result?; + ops.extend(output.ops); + stub_requests.extend(output.stub_requests); + } + queued_stub_requests.extend(stub_requests); + queued_stub_requests.extend(SourceCompiler::drain_pending_stub_requests( + &pending_stub_requests, + )); + let stub_ops = executor.stub_phase(queued_stub_requests); + if !stub_ops.is_empty() { + ops.extend(stub_ops); + } + + Ok(ops) +} + +fn apply_ops_to_graph( + base_graph: CodeGraph, + naming_conventions: Arc>>, + ops: Vec, +) -> Result { + if ops.is_empty() { + return Ok(base_graph); + } + + let mut builder = base_graph.to_builder(); + for (lang, naming) in naming_conventions.iter() { + builder + .naming_conventions + .insert(Language::new(lang.clone()), Arc::clone(naming)); + } + builder + .apply_ops(ops) + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + Ok(builder.build()) +} + +fn apply_ops_to_current( + current: Arc>>, + naming_conventions: Arc>>, + ops: Vec, +) -> Result<()> { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + std::thread::spawn(move || { + handle.block_on(async move { + let mut lock = current.write().await; + let next = apply_ops_to_graph(lock.as_ref().clone(), naming_conventions, ops)?; + *lock = Arc::new(next); + Ok(()) + }) + }) + .join() + .map_err(|_| NaviscopeError::Internal("stub apply thread panicked".to_string()))? + } else { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + runtime.handle().block_on(async move { + let mut lock = current.write().await; + let next = apply_ops_to_graph(lock.as_ref().clone(), naming_conventions, ops)?; + *lock = Arc::new(next); + Ok(()) + }) + } +} + +struct CompileGuard<'a> { + inflight_compiles: &'a AtomicUsize, +} + +impl Drop for CompileGuard<'_> { + fn drop(&mut self) { + self.inflight_compiles.fetch_sub(1, Ordering::AcqRel); + } +} diff --git a/crates/core/src/indexing/source/stub_ops.rs b/crates/core/src/indexing/source/stub_ops.rs new file mode 100644 index 0000000..b8cad3a --- /dev/null +++ b/crates/core/src/indexing/source/stub_ops.rs @@ -0,0 +1,202 @@ +use std::sync::Arc; +use std::{collections::HashMap, path::PathBuf}; + +use naviscope_api::models::EdgeType; +use naviscope_api::models::graph::NodeSource; +use naviscope_plugin::{AssetEntry, AssetSource, LanguageCaps}; + +use crate::indexing::StubRequest; +use crate::model::{CodeGraph, GraphOp}; + +pub fn plan_stub_requests( + ops: &[GraphOp], + routes: &HashMap>, +) -> Vec { + let mut requests = Vec::new(); + let mut seen_fqns = std::collections::HashSet::new(); + + for op in ops { + match op { + GraphOp::AddEdge { to_id, .. } => { + seen_fqns.insert(to_id.to_string()); + } + GraphOp::AddNode { + data: Some(node_data), + } if node_data.source == NodeSource::External => { + seen_fqns.insert(node_data.id.to_string()); + } + _ => {} + } + } + + if seen_fqns.is_empty() || routes.is_empty() { + return requests; + } + + for fqn in seen_fqns { + if let Some(paths) = find_asset_for_fqn(&fqn, routes) { + requests.push(StubRequest { + fqn, + candidate_paths: paths.clone(), + }); + } + } + requests +} + +pub fn find_asset_for_fqn<'a>( + fqn: &str, + routes: &'a HashMap>, +) -> Option<&'a Vec> { + let mut current = fqn.to_string(); + while !current.is_empty() { + if let Some(paths) = routes.get(¤t) { + return Some(paths); + } + if let Some(idx) = current.rfind('.') { + current.truncate(idx); + } else { + break; + } + } + None +} + +pub fn generate_stub_ops( + req: &StubRequest, + current: Arc>>, + lang_caps: Arc>, + stub_cache: Arc, +) -> Vec { + let mut ops = Vec::new(); + + // Skip if node already exists and resolved. + let already_resolved = if let Ok(handle) = tokio::runtime::Handle::try_current() { + let current = Arc::clone(¤t); + let fqn = req.fqn.clone(); + std::thread::spawn(move || { + handle.block_on(async move { + let lock = current.read().await; + let graph = &**lock; + if let Some(idx) = graph.find_node(&fqn) + && let Some(node) = graph.get_node(idx) + { + return node.status == naviscope_api::models::graph::ResolutionStatus::Resolved; + } + false + }) + }) + .join() + .unwrap_or(false) + } else { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build temporary tokio runtime for stub lookup"); + let fqn = req.fqn.clone(); + runtime.handle().block_on(async move { + let lock = current.read().await; + let graph = &**lock; + if let Some(idx) = graph.find_node(&fqn) + && let Some(node) = graph.get_node(idx) + { + return node.status == naviscope_api::models::graph::ResolutionStatus::Resolved; + } + false + }) + }; + if already_resolved { + return ops; + } + + for asset_path in &req.candidate_paths { + let entry = AssetEntry::new(asset_path.clone(), AssetSource::Unknown); + let asset_key = crate::cache::AssetKey::from_path(asset_path).ok(); + + for caps in lang_caps.iter() { + let Some(generator) = caps.asset.stub_generator() else { + continue; + }; + if !generator.can_generate(asset_path) { + continue; + } + let cached_primary = asset_key + .as_ref() + .and_then(|k| stub_cache.lookup(k, &req.fqn)); + + match generator.generate_stubs(&req.fqn, &entry) { + Ok(mut nodes) => { + if let Some(cached) = cached_primary { + let cached_fqn = cached.id.to_string(); + if !nodes.iter().any(|n| n.id.to_string() == cached_fqn) { + nodes.insert(0, cached); + } + } + + if nodes.is_empty() { + continue; + } + + let primary_fqn = nodes + .iter() + .find(|n| n.id.to_string() == req.fqn) + .map(|n| n.id.to_string()) + .unwrap_or_else(|| nodes[0].id.to_string()); + + if let Some(ref key) = asset_key + && let Some(primary) = nodes.iter().find(|n| n.id.to_string() == req.fqn) + { + stub_cache.store(key, primary); + } + + let mut seen = std::collections::HashSet::new(); + for node in nodes { + let fqn = node.id.to_string(); + if !seen.insert(fqn.clone()) { + continue; + } + ops.push(GraphOp::AddNode { data: Some(node) }); + if fqn != primary_fqn { + ops.push(GraphOp::AddEdge { + from_id: naviscope_api::models::symbol::NodeId::Flat( + primary_fqn.clone(), + ), + to_id: naviscope_api::models::symbol::NodeId::Flat(fqn), + edge: naviscope_api::models::GraphEdge::new(EdgeType::Contains), + }); + } + } + break; + } + Err(err) => tracing::debug!("Failed to generate stub for {}: {}", req.fqn, err), + }; + } + + if !ops.is_empty() { + break; + } + } + + ops +} + +pub fn resolve_stub_requests( + requests: Vec, + current: Arc>>, + lang_caps: Arc>, + stub_cache: Arc, +) -> Vec { + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for req in requests { + if seen.insert(req.fqn.clone()) { + out.extend(generate_stub_ops( + &req, + Arc::clone(¤t), + Arc::clone(&lang_caps), + Arc::clone(&stub_cache), + )); + } + } + out +} diff --git a/crates/core/src/ingest/parser/mod.rs b/crates/core/src/ingest/parser/mod.rs deleted file mode 100644 index 9fb6c28..0000000 --- a/crates/core/src/ingest/parser/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -use crate::error::Result; -use std::path::Path; - -pub mod output; -pub mod utils; - -pub use naviscope_api::SymbolResolution; -pub use naviscope_api::models::symbol::NodeId; -pub use naviscope_plugin::{GlobalParseResult, IndexNode, IndexRelation, LspParser, ParseOutput}; - -/// 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 deleted file mode 100644 index 0542b5d..0000000 --- a/crates/core/src/ingest/parser/output.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub use naviscope_api::models::symbol::NodeId; -pub use naviscope_plugin::{IndexNode, IndexRelation, ParseOutput}; diff --git a/crates/core/src/ingest/parser/utils.rs b/crates/core/src/ingest/parser/utils.rs deleted file mode 100644 index 35305ea..0000000 --- a/crates/core/src/ingest/parser/utils.rs +++ /dev/null @@ -1,137 +0,0 @@ -use crate::error::{NaviscopeError, Result}; -use crate::model::{NodeKind, Range}; -use tree_sitter::{Language, Query}; - -/// Converts a tree-sitter range to our internal Range model. -pub fn range_from_ts(range: tree_sitter::Range) -> Range { - Range { - start_line: range.start_point.row, - start_col: range.start_point.column, - end_line: range.end_point.row, - end_col: range.end_point.column, - } -} - -/// Loads a Tree-sitter query from an SCM string. -pub fn load_query(language: &Language, scm: &str) -> Result { - Query::new(language, scm) - .map_err(|e| NaviscopeError::Parsing(format!("Invalid query: {:?}", e))) -} - -/// Gets the index of a capture name in a query. -pub fn get_capture_index(query: &Query, name: &str) -> Result { - query - .capture_index_for_name(name) - .ok_or_else(|| NaviscopeError::Parsing(format!("Capture name '{}' not found in SCM", name))) -} - -/// A raw symbol representation used during tree construction. -pub struct RawSymbol<'a> { - pub name: String, - pub kind: NodeKind, - pub range: crate::model::Range, - pub selection_range: crate::model::Range, - pub node: tree_sitter::Node<'a>, -} - -/// 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 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::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(), - 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); - } - - // 2. Determine parent-child relationships using AST - for (i, raw) in raw_symbols.iter().enumerate() { - let mut curr = raw.node; - while let Some(parent) = curr.parent() { - if let Some(&parent_idx) = symbols_map.get(&parent.id()) { - if parent_idx != i { - parent_child_rels.push((parent_idx, i)); - break; - } - } - curr = parent; - } - } - - // 3. Build the tree - let mut has_parent = vec![false; flat_symbols.len()]; - for (_p, c) in &parent_child_rels { - has_parent[*c] = true; - } - - let mut roots = Vec::new(); - for i in 0..flat_symbols.len() { - if !has_parent[i] { - roots.push(i); - } - } - - fn build_node( - idx: usize, - flat: &mut Vec, - rels: &[(usize, usize)], - ) -> crate::model::DisplayGraphNode { - let mut sym = flat[idx].clone(); - let children: Vec = rels - .iter() - .filter(|(p, _)| *p == idx) - .map(|(_, c)| *c) - .collect(); - let mut child_nodes = Vec::new(); - for c_idx in children { - 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() -} - -/// Macro to define a struct for capture indices and a `new` method to initialize it from a query. -#[macro_export] -macro_rules! decl_indices { - ($name:ident, { $($field:ident => $capture:expr),+ $(,)? }) => { - #[derive(Clone)] - pub struct $name { - $(pub $field: u32,)+ - } - - impl $name { - pub fn new(query: &tree_sitter::Query) -> $crate::error::Result { - Ok(Self { - $($field: $crate::ingest::parser::utils::get_capture_index(query, $capture)?,)+ - }) - } - } - }; -} diff --git a/crates/core/src/ingest/pipeline.rs b/crates/core/src/ingest/pipeline.rs deleted file mode 100644 index d585fb0..0000000 --- a/crates/core/src/ingest/pipeline.rs +++ /dev/null @@ -1,53 +0,0 @@ -use crate::error::Result; -use crate::model::GraphOp; -use std::path::PathBuf; - -/// Ingest task context, used to share state between batches -pub trait PipelineContext: Send + Sync + 'static {} -impl PipelineContext for naviscope_plugin::ProjectContext {} - -/// A processing stage of the pipeline -pub trait PipelineStage: Send + Sync { - type Output; - - /// Processes a batch of paths - fn process(&self, context: &C, paths: Vec) -> Result>; -} - -/// Ingest pipeline engine -pub struct IngestPipeline { - batch_size: usize, -} - -impl IngestPipeline { - pub fn new(batch_size: usize) -> Self { - Self { - batch_size: if batch_size == 0 { 100 } else { batch_size }, - } - } - - /// Executes the pipeline: processes paths in chunks and commits the products - pub fn execute( - &self, - context: &C, - paths: Vec, - stage: &S, - mut committer: F, - ) -> Result<()> - where - C: PipelineContext, - S: PipelineStage, - F: FnMut(Vec) -> Result<()>, - { - for chunk in paths.chunks(self.batch_size) { - // 1. Process the current batch - let outputs = stage.process(context, chunk.to_vec())?; - - // 2. Commit the products - committer(outputs)?; - - // End of batch, local variables are cleaned up - } - Ok(()) - } -} diff --git a/crates/core/src/ingest/resolver/engine.rs b/crates/core/src/ingest/resolver/engine.rs deleted file mode 100644 index 7d2d02a..0000000 --- a/crates/core/src/ingest/resolver/engine.rs +++ /dev/null @@ -1,213 +0,0 @@ -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::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_node_adapter( - &self, - language: Language, - ) -> Option> { - self.lang_plugins - .iter() - .find(|p| p.name() == language) - .and_then(|p| p.get_node_adapter()) - .or_else(|| { - self.build_plugins - .iter() - .find(|p| p.name().as_str() == language.as_str()) - .and_then(|p| p.get_node_adapter()) - }) - } - - 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) - } - - pub fn get_naming_convention( - &self, - language: Language, - ) -> Option> { - self.lang_plugins - .iter() - .find(|p| p.name() == language) - .and_then(|p| p.get_naming_convention()) - } - - /// Resolve all parsed files into graph operations using a two-phase process - pub fn resolve(&self, files: Vec) -> Result> { - let (mut all_ops, build_files, source_files) = self.prepare_and_partition(files); - - // Phase 1: Build Tools - let mut project_context = ProjectContext::new(); - let build_ops = self.resolve_build_batch(&build_files, &mut project_context)?; - all_ops.extend(build_ops); - - // Phase 2: Source Files - let source_ops = self.resolve_source_batch(&source_files, &project_context)?; - all_ops.extend(source_ops); - - Ok(all_ops) - } - - fn prepare_and_partition( - &self, - files: Vec, - ) -> (Vec, Vec, Vec) { - let mut all_ops = Vec::new(); - 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(), - }); - } - - let (build_files, source_files): (Vec<_>, Vec<_>) = - files.into_iter().partition(|f| f.is_build()); - - (all_ops, build_files, source_files) - } - - pub fn resolve_build_batch( - &self, - build_files: &[ParsedFile], - context: &mut ProjectContext, - ) -> Result> { - let mut all_ops = Vec::new(); - for plugin in &self.build_plugins { - let tool_files: Vec<&ParsedFile> = 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 - } - }) - .collect(); - - if !tool_files.is_empty() { - let resolver = plugin.build_resolver(); - let (unit, ctx) = resolver - .resolve(&tool_files) - .map_err(crate::error::NaviscopeError::from)?; - all_ops.extend(unit.ops); - context.path_to_module.extend(ctx.path_to_module); - } - } - Ok(all_ops) - } - - pub fn resolve_source_batch( - &self, - source_files: &[ParsedFile], - context: &ProjectContext, - ) -> Result> { - 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, context) - .map_err(crate::error::NaviscopeError::from) - } else { - Ok(ResolvedUnit::new()) - } - }) - .collect(); - - let mut all_ops = Vec::new(); - for result in source_results { - let unit = result?; - all_ops.extend(unit.ops); - } - Ok(all_ops) - } -} - -impl crate::ingest::pipeline::PipelineStage for IndexResolver { - type Output = GraphOp; - - fn process( - &self, - context: &ProjectContext, - paths: Vec, - ) -> Result> { - // In a pipeline batch, we need to scan and then resolve - // For simplicity in this first step, we assume paths are already filtered - // We need existing_metadata to avoid redundant parsing, but for a simple pipeline we can skip it or pass it in context - let files = - crate::ingest::scanner::Scanner::scan_files(paths, &std::collections::HashMap::new()); - let (mut all_ops, _build, source) = self.prepare_and_partition(files); - - // In this stage, we only care about source files in the pipeline - let source_ops = self.resolve_source_batch(&source, context)?; - all_ops.extend(source_ops); - - Ok(all_ops) - } -} diff --git a/crates/core/src/ingest/resolver/mod.rs b/crates/core/src/ingest/resolver/mod.rs deleted file mode 100644 index 462c64d..0000000 --- a/crates/core/src/ingest/resolver/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod engine; -pub mod scope; - -pub use naviscope_plugin::{BuildResolver, LangResolver, ProjectContext, SemanticResolver}; diff --git a/crates/core/src/ingest/resolver/scope.rs b/crates/core/src/ingest/resolver/scope.rs deleted file mode 100644 index c25eba3..0000000 --- a/crates/core/src/ingest/resolver/scope.rs +++ /dev/null @@ -1,15 +0,0 @@ -use naviscope_api::models::SymbolResolution; - -/// A generic trait for semantic scopes in any programming language. -/// `C` represents the language-specific resolution context. -pub trait SemanticScope: Send + Sync { - /// Resolve a name within this specific scope. - /// Returns: - /// - `Some(Ok(res))` if the symbol is found. - /// - `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/crates/core/src/ingest/scanner.rs b/crates/core/src/ingest/scanner.rs deleted file mode 100644 index 79a1efb..0000000 --- a/crates/core/src/ingest/scanner.rs +++ /dev/null @@ -1,113 +0,0 @@ -use super::is_relevant_path; - -use crate::model::source::SourceFile; -use ignore::WalkBuilder; -use rayon::prelude::*; -use std::collections::HashMap; -use std::fs; -use std::hash::Hasher; -use std::path::{Path, PathBuf}; -use std::time::SystemTime; -use xxhash_rust::xxh3::Xxh3; - -pub use naviscope_plugin::{ParsedContent, ParsedFile}; - -pub struct Scanner; - -impl Scanner { - pub fn scan_and_parse( - root: &Path, - existing_files: &HashMap, - ) -> Vec { - 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 - let metadata = fs::metadata(path).ok()?; - let modified = metadata - .modified() - .unwrap_or(SystemTime::UNIX_EPOCH) - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or(std::time::Duration::ZERO) - .as_secs(); - - if let Some(existing) = existing_files.get(path) { - if existing.last_modified == modified { - return None; - } - } - - // 2. Read and hash content - let (source_file, content) = Self::process_file_with_mtime(path, modified)?; - - // 3. Double check hash (mtime might change but content remains same) - if let Some(existing) = existing_files.get(path) { - if existing.content_hash == source_file.content_hash { - return None; - } - } - - let content_str = String::from_utf8(content).ok()?; - - // Determine build tool or language from file extension/name - let file_name = path.file_name()?.to_str()?; - - if file_name == "build.gradle" - || file_name == "build.gradle.kts" - || file_name == "settings.gradle" - || file_name == "settings.gradle.kts" - { - Some(ParsedFile { - file: source_file, - content: ParsedContent::Unparsed(content_str), - }) - } else if path.extension().is_some() { - Some(ParsedFile { - file: source_file, - content: ParsedContent::Lazy, - }) - } else { - None - } - }) - .collect() - } - - pub(crate) fn collect_paths(root: &Path) -> Vec { - WalkBuilder::new(root) - .build() - .filter_map(|entry| { - let entry = entry.ok()?; - let path = entry.path(); - if path.is_file() && is_relevant_path(path) { - return Some(path.to_path_buf()); - } - None - }) - .collect() - } - - fn process_file_with_mtime(path: &Path, mtime: u64) -> Option<(SourceFile, Vec)> { - let content = fs::read(path).ok()?; - let mut hasher = Xxh3::new(); - hasher.write(&content); - let hash = hasher.finish(); - - Some(( - SourceFile { - path: path.to_path_buf(), - content_hash: hash, - last_modified: mtime, - }, - content, - )) - } -} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 71b674a..0d50e01 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,12 +1,13 @@ +pub mod asset; +pub mod cache; pub mod error; pub mod logging; pub mod util; pub mod facade; pub mod features; -pub mod ingest; +pub mod indexing; pub mod model; -pub mod plugin; pub mod runtime; // FQN types are now exported from model module diff --git a/crates/core/src/logging.rs b/crates/core/src/logging.rs index a2f14e6..5b7bc2c 100644 --- a/crates/core/src/logging.rs +++ b/crates/core/src/logging.rs @@ -20,9 +20,7 @@ pub fn init_logging(component: &str, to_stderr: bool) -> WorkerGuard { .with_ansi(false) .with_target(true); - let registry = tracing_subscriber::registry() - .with(filter) - .with(file_layer); + let registry = tracing_subscriber::registry().with(filter).with(file_layer); if to_stderr { let stderr_layer = fmt::layer() diff --git a/crates/core/src/ingest/builder.rs b/crates/core/src/model/builder.rs similarity index 69% rename from crates/core/src/ingest/builder.rs rename to crates/core/src/model/builder.rs index ff7e853..58defb3 100644 --- a/crates/core/src/ingest/builder.rs +++ b/crates/core/src/model/builder.rs @@ -7,7 +7,7 @@ use crate::model::CodeGraph; use crate::model::graph::CodeGraphInner; use crate::model::source::SourceFile; -// StorageContext unused +// codec context unused use crate::model::{GraphEdge, GraphOp}; use naviscope_api::models::symbol::Symbol; use naviscope_plugin::{FqnInterner, ModelConverter}; @@ -75,7 +75,7 @@ impl CodeGraphBuilder { // Or simpler: Java is special. // BETTER: `naming_conventions` is a map. If we have keys, we try them. - for (_, nc) in &self.naming_conventions { + for (_lang, nc) in &self.naming_conventions { match id { naviscope_api::models::symbol::NodeId::Flat(s) => { // Try to upgrade @@ -85,7 +85,9 @@ impl CodeGraphBuilder { // This assumes we don't mix conflicting conventions in one builder session recklessly. let parts = nc.parse_fqn(s, kind_hint.clone()); let structured_id = naviscope_api::models::symbol::NodeId::Structured(parts); - return self.inner.fqns.intern_node_id(&structured_id); + let fqn_id = self.inner.fqns.intern_node_id(&structured_id); + + return fqn_id; } _ => {} } @@ -95,7 +97,7 @@ impl CodeGraphBuilder { } /// Add or update a node - pub fn add_node(&mut self, node_data: crate::ingest::parser::IndexNode) -> NodeIndex { + pub fn add_node(&mut self, node_data: crate::indexing::IndexNode) -> NodeIndex { // We have language info here! Use it to select convention. let lang = crate::model::Language::new(node_data.lang.clone()); let fqn_id = if let Some(nc) = self.naming_conventions.get(&lang) { @@ -113,7 +115,30 @@ impl CodeGraphBuilder { }; if let Some(&idx) = self.inner.fqn_index.get(&fqn_id) { - // Node already exists + // Node already exists - check if we should update metadata + if let Some(existing_node) = self.inner.topology.node_weight_mut(idx) { + // If the new metadata is NOT empty, or we want to force an update, do it here. + // For stubbing, we move from EmptyMetadata to rich language metadata. + let mut ctx = crate::model::storage::model::GenericStorageContext { + rodeo: self.inner.symbols.clone(), + }; + existing_node.metadata = node_data.metadata.intern(&mut ctx); + + // Update status and kind (necessary for upgrading placeholders) + existing_node.status = node_data.status; + existing_node.kind = node_data.kind; + + // Update location if it was missing + if existing_node.location.is_none() && node_data.location.is_some() { + existing_node.location = node_data + .location + .as_ref() + .map(|l| l.to_internal(&self.inner.fqns)); + } + + // Also update source if it was External and now it's Project (or just keep it updated) + existing_node.source = node_data.source; + } idx } else { let name_sym = self.inner.fqns.intern_atom(&node_data.name); @@ -132,6 +157,8 @@ impl CodeGraphBuilder { name: name_sym, kind: node_data.kind.clone(), lang: lang_sym, + source: node_data.source, + status: node_data.status, location: location.clone(), metadata: node_data.metadata.intern(&mut ctx), }; @@ -192,11 +219,7 @@ impl CodeGraphBuilder { /// 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()), - ); + let interned_path = Symbol(self.inner.symbols.get_or_intern(&path.to_string_lossy())); if let Some(entry) = self.inner.file_index.remove(&interned_path) { for idx in entry.nodes { self.remove_node(idx); @@ -211,11 +234,7 @@ impl CodeGraphBuilder { /// 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()), - ); + let interned_path = Symbol(self.inner.symbols.get_or_intern(&path.to_string_lossy())); self.inner .file_index .entry(interned_path) @@ -239,25 +258,44 @@ impl CodeGraphBuilder { to_id, edge, } => { - let from_id = self.resolve_storage_id(&from_id, None); - let to_id = self.resolve_storage_id(&to_id, None); - - if let (Some(&from), Some(&to)) = ( - self.inner.fqn_index.get(&from_id), - self.inner.fqn_index.get(&to_id), - ) { - self.add_edge(from, to, edge); + let from_fqn = self.resolve_storage_id(&from_id, None); + let to_fqn = self.resolve_storage_id(&to_id, None); + + let from_idx = self.inner.fqn_index.get(&from_fqn).cloned(); + let mut to_idx = self.inner.fqn_index.get(&to_fqn).cloned(); + + // If target node doesn't exist, create an external placeholder + if to_idx.is_none() && from_idx.is_some() { + let from_node = self.inner.topology.node_weight(from_idx.unwrap()).unwrap(); + let lang_str = self.inner.symbols.resolve(&from_node.lang.0).to_string(); + + // Heuristic for external node: use class/unknown kind + let name = to_id.to_string(); + let placeholder = crate::indexing::IndexNode { + id: to_id.clone(), + name, + kind: naviscope_api::models::graph::NodeKind::Class, // Default to class for external types + lang: lang_str, + source: naviscope_api::models::graph::NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Unresolved, + location: None, + metadata: std::sync::Arc::new(crate::model::EmptyMetadata), + }; + to_idx = Some(self.add_node(placeholder)); + } + + match (from_idx, to_idx) { + (Some(from), Some(to)) => { + 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()), - ); + let path_sym = Symbol(self.inner.symbols.get_or_intern(&path.to_string_lossy())); 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(); @@ -274,9 +312,33 @@ impl CodeGraphBuilder { Ok(()) } - /// Apply multiple graph operations + /// Apply multiple graph operations in a single atomic-like batch. + /// Reorders ops to ensure correct application: + /// 1. Removals + /// 2. Node additions & updates + /// 3. Edge additions (Relational) pub fn apply_ops(&mut self, ops: Vec) -> crate::error::Result<()> { + let mut destructive = Vec::new(); + let mut additive = Vec::new(); + let mut relational = Vec::new(); + for op in ops { + match op { + GraphOp::RemovePath { .. } => destructive.push(op), + GraphOp::AddNode { .. } + | GraphOp::UpdateFile { .. } + | GraphOp::UpdateIdentifiers { .. } => additive.push(op), + GraphOp::AddEdge { .. } => relational.push(op), + } + } + + for op in destructive { + self.apply_op(op)?; + } + for op in additive { + self.apply_op(op)?; + } + for op in relational { self.apply_op(op)?; } Ok(()) @@ -303,11 +365,13 @@ mod tests { fn test_build_from_scratch() { let mut builder = CodeGraphBuilder::new(); - let node = crate::ingest::parser::IndexNode { + let node = crate::indexing::IndexNode { id: "test_project".into(), name: "test_project".to_string(), kind: NodeKind::Project, lang: "buildfile".to_string(), + source: naviscope_api::models::graph::NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: None, metadata: std::sync::Arc::new(crate::model::EmptyMetadata), }; @@ -326,11 +390,13 @@ mod tests { let mut builder = CodeGraphBuilder::from_graph(&graph); - let node = crate::ingest::parser::IndexNode { + let node = crate::indexing::IndexNode { id: "new_project".into(), name: "new_project".to_string(), kind: NodeKind::Project, lang: "buildfile".to_string(), + source: naviscope_api::models::graph::NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: None, metadata: std::sync::Arc::new(crate::model::EmptyMetadata), }; diff --git a/crates/core/src/model/fqn.rs b/crates/core/src/model/fqn.rs index 6dbd935..de0e1a5 100644 --- a/crates/core/src/model/fqn.rs +++ b/crates/core/src/model/fqn.rs @@ -14,6 +14,35 @@ pub struct FqnManager { pub nodes: Arc>, pub lookup: Arc, Symbol, NodeKind), FqnId>>, pub next_id: Arc, + /// Registry of naming conventions for polyglot resolution + pub registry: Arc>, +} + +/// Registry to hold multiple naming conventions (e.g., Standard, C++, Rust). +/// The query engine will try them in order. +#[derive(Debug)] +pub struct NamingRegistry { + conventions: Vec>, +} + +impl Default for NamingRegistry { + fn default() -> Self { + Self { + conventions: vec![Box::new( + naviscope_plugin::StandardNamingConvention::default(), + )], + } + } +} + +impl NamingRegistry { + pub fn register(&mut self, convention: Box) { + self.conventions.push(convention); + } + + pub fn iter(&self) -> std::slice::Iter<'_, Box> { + self.conventions.iter() + } } impl Default for FqnManager { @@ -29,6 +58,7 @@ impl FqnManager { nodes: Arc::new(DashMap::new()), lookup: Arc::new(DashMap::new()), next_id: Arc::new(std::sync::atomic::AtomicU32::new(1)), + registry: Arc::new(std::sync::RwLock::new(NamingRegistry::default())), } } @@ -38,6 +68,7 @@ impl FqnManager { nodes: Arc::new(DashMap::new()), lookup: Arc::new(DashMap::new()), next_id: Arc::new(std::sync::atomic::AtomicU32::new(1)), + registry: Arc::new(std::sync::RwLock::new(NamingRegistry::default())), } } @@ -45,6 +76,13 @@ impl FqnManager { self.nodes.get(&id).map(|n| n.clone()) } + /// Register a new naming convention logic for query resolution + pub fn register_convention(&self, convention: Box) { + if let Ok(mut reg) = self.registry.write() { + reg.register(convention); + } + } + /// Try to find a child node with the given name under the given parent. /// Since we don't know the Kind, we have to try potential Kinds. pub fn find_child(&self, parent: Option, name: &str) -> Vec { @@ -79,44 +117,98 @@ impl FqnManager { results } + /// Try to resolve a structured path to a single FqnId. + /// This follows the exact path structure without guessing kinds. + pub fn resolve_path(&self, path: &[(NodeKind, String)]) -> Option { + let mut current = None; + for (kind, name) in path { + let symbol = if let Some(s) = self.rodeo.get(name) { + Symbol(s) + } else { + return None; + }; + let key = (current, symbol, kind.clone()); + match self.lookup.get(&key) { + Some(id) => current = Some(*id), + None => return None, + } + } + current + } + + /// Resolve a dot/colon separated string to potential FqnIds. + /// Uses StandardNamingConvention for parsing, and performs intelligent lookup + /// based on the parsed NodeKinds. /// Resolve a dot/colon separated string to potential FqnIds. - /// This is an expensive operation as it involves walking the path and guessing kinds. + /// Uses all registered NamingConventions to parse and lookup path logic. pub fn resolve_fqn_string(&self, fqn: &str) -> Vec { - // 1. Split the string into segments - // We handle "." and "#" and "$" as separators. - let segments: Vec<&str> = fqn - .split(|c| c == '.' || c == '#' || c == '$') - .filter(|s| !s.is_empty()) - .collect(); + let registry = self.registry.read().unwrap(); - if segments.is_empty() { - return Vec::new(); - } + let mut all_results = Vec::new(); - // 2. Simple Tree Search - // We maintain a list of valid current_ids. Initially [None] (root). - let mut current_ids: Vec> = vec![None]; - - for (_i, segment) in segments.iter().enumerate() { - let mut next_ids = Vec::new(); + // 1. Iterate over ALL registered conventions. + // Different languages might parse the same string differently (or successfully/unsuccessfully) + for convention in registry.iter() { + let path = convention.parse_fqn(fqn, None); + if path.is_empty() { + continue; + } - for parent in current_ids { - // Try to find children matching this segment - let children = self.find_child(parent, segment); - for child_id in children { - next_ids.push(Some(child_id)); + // Tree Search with Convention-guided constraints + let mut current_ids: Vec> = vec![None]; + + for (kind, name) in path { + let mut next_ids = Vec::new(); + + // IMPROVEMENT: Use the parsed kind to optimize lookup + // If it is strictly a Member (Method/Field/Ctor), we trust the parser's judgment (due to '#'). + let is_strict_member = matches!( + kind, + NodeKind::Method | NodeKind::Field | NodeKind::Constructor + ); + + for parent in current_ids { + if is_strict_member { + // Semantic Lookup: We know it's a member, but parsing heuristics (e.g. defaulting to Method) + // might mismatch the actual graph node type (e.g. Field). + // So we try all member-like kinds. + if let Some(symbol) = self.rodeo.get(&name) { + let sym = Symbol(symbol); + + let member_kinds = + [NodeKind::Method, NodeKind::Field, NodeKind::Constructor]; + + for member_kind in member_kinds { + let key = (parent, sym, member_kind); + if let Some(id) = self.lookup.get(&key) { + next_ids.push(Some(*id)); + } + } + } + } else { + // Ambiguous/Fuzzy lookup + let children = self.find_child(parent, &name); + for child_id in children { + next_ids.push(Some(child_id)); + } + } } - } - if next_ids.is_empty() { - // Heuristic: If we fail to match a segment in the middle... - return Vec::new(); + if next_ids.is_empty() { + current_ids = Vec::new(); + break; + } + current_ids = next_ids; } - current_ids = next_ids; + all_results.extend(current_ids.into_iter().flatten()); } - current_ids.into_iter().flatten().collect() + // Deduplicate results if multiple conventions yield the same ID + all_results.sort(); + all_results.dedup(); + + all_results } } @@ -234,6 +326,7 @@ impl<'de> Deserialize<'de> for FqnManager { nodes: Arc::new(nodes_map), lookup: Arc::new(lookup_map), next_id: Arc::new(std::sync::atomic::AtomicU32::new(raw.next_id)), + registry: Arc::new(std::sync::RwLock::new(NamingRegistry::default())), // Re-init with defaults }) } } diff --git a/crates/core/src/model/graph.rs b/crates/core/src/model/graph.rs index 4866f87..aff141e 100644 --- a/crates/core/src/model/graph.rs +++ b/crates/core/src/model/graph.rs @@ -3,13 +3,13 @@ //! 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::builder::CodeGraphBuilder; +use naviscope_plugin::NodeMetadataCodec; use crate::features::CodeGraphLike; use crate::model::FqnManager; use crate::model::source::SourceFile; use crate::model::{GraphEdge, GraphNode}; -use crate::plugin::NodeAdapter; use lasso::ThreadedRodeo; use naviscope_api::models::symbol::{FqnId, FqnReader, Symbol}; use petgraph::stable_graph::{NodeIndex, StableDiGraph}; @@ -103,6 +103,16 @@ impl CodeGraph { CodeGraphBuilder::from_inner((*self.inner).clone()) } + /// Register a new naming convention for this graph instance. + /// This allows plugins to provide language-specific FQN parsing logic. + /// Note: This affects global query behavior for this graph instance. + pub fn register_naming_convention( + &self, + convention: Box, + ) { + self.inner.fqns.register_convention(convention); + } + // ---- Read-only accessors ---- /// Get the unique instance ID for this graph version @@ -168,7 +178,7 @@ impl CodeGraph { /// 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 key = self.inner.symbols.get(&path_str)?; let entry = self.inner.file_index.get(&Symbol(key))?; for &idx in &entry.nodes { @@ -192,7 +202,7 @@ impl CodeGraph { col: usize, ) -> Option { let path_str = path.to_string_lossy(); - let key = self.inner.symbols.get(path_str.as_ref())?; + let key = self.inner.symbols.get(&path_str)?; let entry = self.inner.file_index.get(&Symbol(key))?; let mut best_node = None; @@ -243,10 +253,10 @@ impl CodeGraph { /// Serialize to bytes for persistence pub fn serialize( &self, - get_plugin: impl Fn(&str) -> Option>, + get_codec: impl Fn(&str) -> Option>, ) -> Result> { use super::storage::to_storage; - let storage = to_storage(&self.inner, get_plugin); + let storage = to_storage(&self.inner, get_codec); let bytes = rmp_serde::to_vec(&storage) .map_err(|e| NaviscopeError::Internal(format!("MSGPACK error: {}", e)))?; @@ -259,7 +269,7 @@ impl CodeGraph { /// Deserialize from bytes pub fn deserialize( bytes: &[u8], - get_plugin: impl Fn(&str) -> Option>, + get_codec: impl Fn(&str) -> Option>, ) -> Result { use super::storage::{StorageGraph, from_storage}; @@ -270,7 +280,7 @@ impl CodeGraph { let storage: StorageGraph = rmp_serde::from_read(decoder) .map_err(|e| NaviscopeError::Internal(format!("MSGPACK error: {}", e)))?; - let inner = from_storage(storage, get_plugin); + let inner = from_storage(storage, get_codec); Ok(Self::from_inner(inner)) } @@ -278,12 +288,12 @@ impl CodeGraph { pub fn save_to_json>( &self, path: P, - get_plugin: impl Fn(&str) -> Option>, + get_codec: 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); + let storage = to_storage(&self.inner, get_codec); serde_json::to_writer_pretty(writer, &storage) .map_err(|e| crate::error::NaviscopeError::Parsing(e.to_string()))?; Ok(()) @@ -406,7 +416,7 @@ impl naviscope_plugin::CodeGraph for CodeGraph { } #[cfg(test)] mod tests { - use super::{CURRENT_VERSION, CodeGraph}; + use super::*; #[test] fn test_arc_clone_is_cheap() { @@ -437,15 +447,17 @@ mod tests { #[test] fn test_graph_serialization_roundtrip() { - use crate::ingest::builder::CodeGraphBuilder; + use crate::model::builder::CodeGraphBuilder; use crate::model::NodeKind; let mut builder = CodeGraphBuilder::new(); - let node = crate::ingest::parser::IndexNode { + let node = crate::indexing::IndexNode { id: naviscope_api::models::symbol::NodeId::Flat("test_node".to_string()), name: "node".to_string(), kind: NodeKind::Class, lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: None, metadata: std::sync::Arc::new(crate::model::EmptyMetadata), }; diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index aa283f9..73795fb 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -1,3 +1,4 @@ +pub mod builder; pub mod fqn; pub mod graph; pub mod metadata; diff --git a/crates/core/src/model/storage/converter.rs b/crates/core/src/model/storage/converter.rs index 687dfea..76c978d 100644 --- a/crates/core/src/model/storage/converter.rs +++ b/crates/core/src/model/storage/converter.rs @@ -1,7 +1,7 @@ use super::model::*; +use naviscope_plugin::NodeMetadataCodec; use crate::model::graph::{CodeGraphInner, FileEntry}; use crate::model::{EmptyMetadata, GraphNode, InternedLocation, NodeMetadata}; -use crate::plugin::NodeAdapter; use lasso::{Key, Spur, ThreadedRodeo}; use naviscope_api::models::symbol::{FqnId, Symbol}; use petgraph::stable_graph::NodeIndex; @@ -9,21 +9,13 @@ use std::collections::HashMap; use std::path::Path; use std::sync::Arc; -/// Fallback adapter that uses empty metadata -struct DefaultNodeAdapter; -impl NodeAdapter for DefaultNodeAdapter { - fn render_display_node( - &self, - _node: &GraphNode, - _fqns: &dyn naviscope_api::models::symbol::FqnReader, - ) -> naviscope_api::models::DisplayGraphNode { - unimplemented!("DefaultNodeAdapter should not be used for rendering") - } - +/// Fallback codec that uses empty metadata. +struct DefaultNodeMetadataCodec; +impl NodeMetadataCodec for DefaultNodeMetadataCodec { fn encode_metadata( &self, _metadata: &dyn NodeMetadata, - _ctx: &mut dyn naviscope_plugin::StorageContext, + _ctx: &mut dyn naviscope_plugin::CodecContext, ) -> Vec { Vec::new() } @@ -31,7 +23,7 @@ impl NodeAdapter for DefaultNodeAdapter { fn decode_metadata( &self, _bytes: &[u8], - _ctx: &dyn naviscope_plugin::StorageContext, + _ctx: &dyn naviscope_plugin::CodecContext, ) -> Arc { Arc::new(EmptyMetadata) } @@ -46,7 +38,7 @@ impl crate::model::metadata::SymbolInterner for ReadOnlyStorageContext { } } -impl naviscope_plugin::StorageContext for ReadOnlyStorageContext { +impl naviscope_plugin::CodecContext for ReadOnlyStorageContext { fn interner(&mut self) -> &mut dyn naviscope_plugin::FqnInterner { unreachable!("Read-only context cannot intern") } @@ -69,7 +61,7 @@ impl naviscope_api::models::symbol::FqnReader for ReadOnlyStorageContext { } } -impl StorageContext for ReadOnlyStorageContext { +impl CodecContext for ReadOnlyStorageContext { fn intern_path(&mut self, _p: &Path) -> u32 { unreachable!("Read-only context") } @@ -89,12 +81,12 @@ impl StorageContext for ReadOnlyStorageContext { pub fn to_storage( inner: &CodeGraphInner, - get_plugin: impl Fn(&str) -> Option>, + get_codec: impl Fn(&str) -> Option>, ) -> StorageGraph { let rodeo_ref = inner.symbols.clone(); let mut ctx = GenericStorageContext { rodeo: rodeo_ref }; - let default_plugin = Arc::new(DefaultNodeAdapter); + let default_codec = Arc::new(DefaultNodeMetadataCodec); let mut node_id_map = HashMap::new(); let mut nodes = Vec::new(); @@ -105,14 +97,16 @@ pub fn to_storage( // 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.encode_metadata(&*node.metadata, &mut ctx); + let codec = get_codec(&lang_str).unwrap_or_else(|| default_codec.clone()); + let metadata = codec.encode_metadata(&*node.metadata, &mut ctx); nodes.push(StorageNode { id_sid: node.id.0, name_sid: node.name.0.into_usize() as u32, kind: node.kind.clone(), lang_sid: node.lang.0.into_usize() as u32, + source: node.source.clone(), + status: node.status, location: node.location.as_ref().map(|loc| StorageLocation { path_id: loc.path.0.into_usize() as u32, range: loc.range, @@ -202,24 +196,26 @@ pub fn to_storage( pub fn from_storage( storage: StorageGraph, - get_plugin: impl Fn(&str) -> Option>, + get_codec: impl Fn(&str) -> Option>, ) -> CodeGraphInner { let mut topology = petgraph::stable_graph::StableDiGraph::new(); - let default_plugin = Arc::new(DefaultNodeAdapter); + let default_codec = Arc::new(DefaultNodeMetadataCodec); let rodeo = storage.fqns.rodeo.clone(); let ctx = ReadOnlyStorageContext(rodeo.clone()); 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.decode_metadata(&snode.metadata, &ctx); + let codec = get_codec(&lang_str).unwrap_or_else(|| default_codec.clone()); + let metadata = codec.decode_metadata(&snode.metadata, &ctx); let node = GraphNode { id: FqnId(snode.id_sid), 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()), + source: snode.source.clone(), + status: snode.status, location: snode.location.as_ref().map(|loc| InternedLocation { path: Symbol(Spur::try_from_usize(loc.path_id as usize).unwrap()), range: loc.range, diff --git a/crates/core/src/model/storage/model.rs b/crates/core/src/model/storage/model.rs index 14189b4..e65aed5 100644 --- a/crates/core/src/model/storage/model.rs +++ b/crates/core/src/model/storage/model.rs @@ -1,13 +1,14 @@ use crate::model::FqnStorage; use crate::model::{GraphEdge, NodeKind, Range}; use lasso::{Key, ThreadedRodeo}; +use naviscope_api::models::graph::{NodeSource, ResolutionStatus}; use naviscope_api::models::symbol::Symbol; use naviscope_plugin::FqnInterner; use serde::{Deserialize, Serialize}; use std::sync::Arc; /// Context for interning and resolving symbols during storage conversion. -pub trait StorageContext: crate::model::metadata::SymbolInterner { +pub trait CodecContext: 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; @@ -17,7 +18,7 @@ pub struct GenericStorageContext { pub rodeo: Arc, } -impl naviscope_plugin::StorageContext for GenericStorageContext { +impl naviscope_plugin::CodecContext for GenericStorageContext { fn interner(&mut self) -> &mut dyn FqnInterner { self } @@ -72,7 +73,7 @@ impl crate::model::metadata::SymbolInterner for GenericStorageContext { } } -impl StorageContext for GenericStorageContext { +impl CodecContext for GenericStorageContext { 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()) @@ -117,6 +118,8 @@ pub struct StorageNode { pub name_sid: u32, pub kind: NodeKind, pub lang_sid: u32, + pub source: NodeSource, + pub status: ResolutionStatus, pub location: Option, pub metadata: Box<[u8]>, } diff --git a/crates/core/src/plugin/mod.rs b/crates/core/src/plugin/mod.rs deleted file mode 100644 index 2aca216..0000000 --- a/crates/core/src/plugin/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub use naviscope_api::models::graph::DisplayGraphNode; -pub use naviscope_plugin::{ - NamingConvention, NodeAdapter, PluginHandle, PluginInfo, PluginInstance, -}; - -pub use naviscope_plugin::{BuildToolPlugin, LanguagePlugin}; - -pub use naviscope_plugin::{BuildContent, BuildParseResult}; diff --git a/crates/core/src/runtime/lifecycle.rs b/crates/core/src/runtime/lifecycle.rs new file mode 100644 index 0000000..cc006dd --- /dev/null +++ b/crates/core/src/runtime/lifecycle.rs @@ -0,0 +1,207 @@ +use super::*; + +impl NaviscopeEngine { + /// Load index from disk + pub async fn load(&self) -> Result { + let path = self.index_path.clone(); + let lang_caps = self.lang_caps.clone(); + let build_caps = self.build_caps.clone(); + + // Load in blocking pool + let graph_opt = + tokio::task::spawn_blocking(move || Self::load_from_disk(&path, lang_caps, build_caps)) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))??; + + if let Some(graph) = graph_opt { + 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_caps = self.lang_caps.clone(); + let build_caps = self.build_caps.clone(); + + tokio::task::spawn_blocking(move || { + Self::save_to_disk(&graph, &path, lang_caps, build_caps) + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))? + } + + /// Rebuild the index from scratch + pub async fn rebuild(&self) -> Result<()> { + { + let mut lock = self.current.write().await; + *lock = Arc::new(CodeGraph::empty()); + } + + let project_root = self.project_root.clone(); + 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 + } + + /// Update specific files incrementally + pub async fn update_files(&self, files: Vec) -> Result<()> { + let _ = self.scan_global_assets().await; + let base_graph = self.snapshot().await; + let existing_metadata = Self::collect_existing_metadata(&base_graph); + let (graph_after_build, source_paths, project_context) = + self.run_build_phase(base_graph, files, existing_metadata).await?; + let next_graph = self + .run_source_phase(graph_after_build, source_paths, project_context) + .await?; + self.apply_graph_snapshot(next_graph).await; + self.finalize_update().await?; + Ok(()) + } + + /// Refresh index (detect changes and update) + pub async fn refresh(&self) -> Result<()> { + let project_root = self.project_root.clone(); + + 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 + } + + fn collect_existing_metadata( + base_graph: &CodeGraph, + ) -> std::collections::HashMap { + 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(), + ); + } + existing_metadata + } + + async fn run_build_phase( + &self, + base_graph: CodeGraph, + files: Vec, + existing_metadata: std::collections::HashMap, + ) -> Result<(CodeGraph, Vec, naviscope_plugin::ProjectContext)> { + let build_caps = self.build_caps.clone(); + let lang_caps = self.lang_caps.clone(); + tokio::task::spawn_blocking(move || -> Result<_> { + let mut manual_ops = Vec::new(); + let mut to_scan = Vec::new(); + + for path in files { + if path.exists() { + to_scan.push(path); + } else { + manual_ops.push(GraphOp::RemovePath { + path: Arc::from(path.as_path()), + }); + } + } + + let mut build_files = Vec::new(); + let mut source_paths = Vec::new(); + for file in Scanner::scan_files_iter(to_scan, &existing_metadata) { + if build_caps + .iter() + .any(|caps| caps.matcher.supports_path(file.path())) + { + build_files.push(file); + } else { + source_paths.push(file.path().to_path_buf()); + } + } + + if build_files.is_empty() && source_paths.is_empty() && manual_ops.is_empty() { + return Ok((base_graph, Vec::new(), naviscope_plugin::ProjectContext::new())); + } + + let compiler = crate::indexing::build::BuildCompiler::with_caps((*build_caps).clone()); + + let mut project_context = naviscope_plugin::ProjectContext::new(); + let mut initial_ops = manual_ops; + + for bf in &build_files { + initial_ops.push(GraphOp::RemovePath { + path: Arc::from(bf.path()), + }); + initial_ops.push(GraphOp::UpdateFile { + metadata: bf.file.clone(), + }); + } + + let build_ops = compiler.compile_build_batch(&build_files, &mut project_context)?; + initial_ops.extend(build_ops); + + let mut builder = base_graph.to_builder(); + for caps in lang_caps.iter() { + if let Some(nc) = caps.presentation.naming_convention() { + builder.naming_conventions.insert(caps.language.clone(), nc); + } + } + builder.apply_ops(initial_ops)?; + + Ok((builder.build(), source_paths, project_context)) + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))? + } + + async fn apply_graph_snapshot(&self, graph: CodeGraph) { + let mut lock = self.current.write().await; + *lock = Arc::new(graph); + } + + async fn run_source_phase( + &self, + base_graph: CodeGraph, + source_paths: Vec, + project_context: naviscope_plugin::ProjectContext, + ) -> Result { + if source_paths.is_empty() { + return Ok(base_graph); + } + + let routes = self.global_asset_routes(); + let source_files = tokio::task::spawn_blocking(move || { + let existing = std::collections::HashMap::new(); + Scanner::scan_files_iter(source_paths, &existing).collect::>() + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + if source_files.is_empty() { + return Ok(base_graph); + } + + self.source_compiler + .compile_source_files( + base_graph, + source_files, + project_context, + routes, + self.current_graph_arc(), + self.naming_conventions(), + self.lang_caps_arc(), + self.stub_cache_arc(), + ) + .await + } + + async fn finalize_update(&self) -> Result<()> { + self.save().await + } +} diff --git a/crates/core/src/runtime/mod.rs b/crates/core/src/runtime/mod.rs index 13b95a4..6b3a199 100644 --- a/crates/core/src/runtime/mod.rs +++ b/crates/core/src/runtime/mod.rs @@ -1,4 +1,388 @@ -pub mod orchestrator; -pub mod watcher; +//! Core indexing engine with MVCC support + +use crate::asset::service::AssetStubService; +use crate::error::{NaviscopeError, Result}; +use crate::indexing::source::SourceCompiler; +use crate::indexing::scanner::Scanner; +use crate::indexing::StubRequest; +use crate::model::{CodeGraph, GraphOp}; +use naviscope_plugin::{ + AssetDiscoverer, AssetIndexer, AssetSourceLocator, BuildCaps, LanguageCaps, NamingConvention, +}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::RwLock; +use xxhash_rust::xxh3::xxh3_64; + +mod lifecycle; +mod storage; +mod watch; pub const DEFAULT_INDEX_DIR: &str = ".naviscope/indices"; + +/// 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, + + /// Registered capabilities + build_caps: Arc>, + lang_caps: Arc>, + + /// Runtime registry: language name -> naming convention + naming_conventions: Arc>>, + + /// Cancellation token for background tasks (like watcher) + cancel_token: tokio_util::sync::CancellationToken, + + /// Global stub cache + stub_cache: Arc, + + /// Global asset service (new architecture) + asset_service: Option>, + + /// Source compiler facade that owns source runtime lifecycle. + source_compiler: Arc, +} + +pub struct NaviscopeEngineBuilder { + project_root: PathBuf, + build_caps: Vec, + lang_caps: Vec, +} + +impl NaviscopeEngineBuilder { + pub fn new(project_root: PathBuf) -> Self { + Self { + project_root, + build_caps: Vec::new(), + lang_caps: Vec::new(), + } + } + + pub fn with_language_caps(mut self, caps: LanguageCaps) -> Self { + self.lang_caps.push(caps); + self + } + + pub fn with_build_caps(mut self, caps: BuildCaps) -> Self { + self.build_caps.push(caps); + self + } + + pub fn build(self) -> NaviscopeEngine { + let canonical_root = self + .project_root + .canonicalize() + .unwrap_or_else(|_| self.project_root.clone()); + let index_path = NaviscopeEngine::compute_index_path(&canonical_root); + let cancel_token = tokio_util::sync::CancellationToken::new(); + // Initialize global cache once + let stub_cache = Arc::new(crate::cache::GlobalStubCache::at_default_location()); + + // Process naming conventions + let mut conventions = HashMap::new(); + for caps in &self.lang_caps { + if let Some(nc) = caps.presentation.naming_convention() { + conventions.insert(caps.language.to_string(), nc); + } + } + + // Collect asset indexers from language plugins + let indexers: Vec> = self + .lang_caps + .iter() + .filter_map(|c| c.asset.asset_indexer()) + .collect(); + + // Collect asset discoverers from all plugins + let mut discoverers: Vec> = Vec::new(); + + // From language plugins (e.g., JdkDiscoverer from Java) + for caps in &self.lang_caps { + if let Some(d) = caps.asset.global_asset_discoverer() { + discoverers.push(d); + } + } + + // From build tool plugins (e.g., GradleCacheDiscoverer from Gradle) + for caps in &self.build_caps { + if let Some(d) = caps.asset.global_asset_discoverer() { + discoverers.push(d); + } + } + + // Collect asset source locators from all plugins + let mut source_locators: Vec> = Vec::new(); + for caps in &self.lang_caps { + if let Some(locator) = caps.asset.asset_source_locator() { + source_locators.push(locator); + } + } + for caps in &self.build_caps { + if let Some(locator) = caps.asset.asset_source_locator() { + source_locators.push(locator); + } + } + + // Project-local asset discoverers (optional hook) + for caps in &self.lang_caps { + if let Some(d) = caps.asset.project_asset_discoverer(&canonical_root) { + discoverers.push(d); + } + } + + for caps in &self.build_caps { + if let Some(d) = caps.asset.project_asset_discoverer(&canonical_root) { + discoverers.push(d); + } + } + + // Create asset service with discoverers from plugins + let asset_service = if !indexers.is_empty() && !discoverers.is_empty() { + Some(Arc::new(AssetStubService::new( + discoverers, + indexers, + vec![], // Generators will be added later + source_locators, + ))) + } else { + None + }; + + let build_caps = Arc::new(self.build_caps); + let lang_caps = Arc::new(self.lang_caps); + let source_compiler = Arc::new(SourceCompiler::new()); + + NaviscopeEngine { + current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), + project_root: canonical_root, + index_path, + build_caps, + lang_caps, + naming_conventions: Arc::new(conventions), + cancel_token, + stub_cache, + asset_service, + source_compiler, + } + } +} + +impl Drop for NaviscopeEngine { + fn drop(&mut self) { + self.cancel_token.cancel(); + } +} + +impl NaviscopeEngine { + /// Create a builder for the engine + pub fn builder(project_root: PathBuf) -> NaviscopeEngineBuilder { + NaviscopeEngineBuilder::new(project_root) + } + + /// Get the project root path + pub fn root_path(&self) -> &Path { + &self.project_root + } + + /// Query semantic capabilities for a language. + pub fn semantic_cap( + &self, + language: crate::model::source::Language, + ) -> Option> { + self.lang_caps + .iter() + .find(|c| c.language == language) + .map(|c| c.semantic.clone()) + } + + /// Query node presenter for language or matching build-tool capability. + pub fn node_presenter( + &self, + language: crate::model::source::Language, + ) -> Option> { + self.lang_caps + .iter() + .find(|c| c.language == language) + .and_then(|c| c.presentation.node_presenter()) + .or_else(|| { + self.build_caps + .iter() + .find(|c| c.build_tool.as_str() == language.as_str()) + .and_then(|c| c.presentation.node_presenter()) + }) + } + + /// Query metadata codec for language or matching build-tool capability. + pub fn metadata_codec( + &self, + language: crate::model::source::Language, + ) -> Option> { + self.lang_caps + .iter() + .find(|c| c.language == language) + .and_then(|c| c.metadata_codec.metadata_codec()) + .or_else(|| { + self.build_caps + .iter() + .find(|c| c.build_tool.as_str() == language.as_str()) + .and_then(|c| c.metadata_codec.metadata_codec()) + }) + } + + /// Detect language capability by path. + pub fn language_for_path( + &self, + path: &std::path::Path, + ) -> Option { + self.lang_caps + .iter() + .find(|c| c.matcher.supports_path(path)) + .map(|c| c.language.clone()) + } + + /// Get naming conventions registry (cheap Arc clone) + pub(crate) fn naming_conventions( + &self, + ) -> Arc>> { + self.naming_conventions.clone() + } + + pub(crate) fn lang_caps_arc(&self) -> Arc> { + Arc::clone(&self.lang_caps) + } + + pub(crate) fn current_graph_arc(&self) -> Arc>> { + Arc::clone(&self.current) + } + + pub(crate) fn stub_cache_arc(&self) -> Arc { + Arc::clone(&self.stub_cache) + } + + /// Get the asset service (if available) + pub fn asset_service(&self) -> Option<&Arc> { + self.asset_service.as_ref() + } + + /// Request on-demand stub generation for a single FQN. + /// Returns true if a request was accepted for execution. + pub fn request_stub_for_fqn(&self, fqn: &str) -> bool { + let Some(service) = &self.asset_service else { + return false; + }; + let Some(candidate_paths) = service.lookup_paths(fqn) else { + return false; + }; + if candidate_paths.is_empty() { + return false; + } + + let req = StubRequest { + fqn: fqn.to_string(), + candidate_paths, + }; + + self.source_compiler.try_submit_or_enqueue_stub_request( + req, + self.current_graph_arc(), + self.naming_conventions(), + self.lang_caps_arc(), + self.stub_cache_arc(), + ) + } + + /// Run the global asset scan and populate routes + /// Returns the scan result with statistics + pub async fn scan_global_assets(&self) -> Option { + if let Some(service) = &self.asset_service { + let service = service.clone(); + let result = tokio::task::spawn_blocking(move || service.scan_sync()) + .await + .ok(); + result + } else { + None + } + } + + /// Get global asset routes snapshot (for passing to resolvers) + pub fn global_asset_routes(&self) -> HashMap> { + if let Some(service) = &self.asset_service { + service.routes_snapshot() + } else { + HashMap::new() + } + } + + /// 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() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_snapshot_is_fast() { + let engine = NaviscopeEngine::builder(PathBuf::from(".")).build(); + + 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::builder(PathBuf::from(".")).build()); + + 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/orchestrator.rs b/crates/core/src/runtime/orchestrator.rs deleted file mode 100644 index 87c1f3b..0000000 --- a/crates/core/src/runtime/orchestrator.rs +++ /dev/null @@ -1,564 +0,0 @@ -//! 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::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 std::collections::HashMap; -use naviscope_plugin::NamingConvention; - -use crate::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>>, - - /// Runtime registry: language name -> naming convention - naming_conventions: 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 canonical_root = project_root - .canonicalize() - .unwrap_or_else(|_| project_root.clone()); - let index_path = Self::compute_index_path(&canonical_root); - - Self { - current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), - project_root: canonical_root, - index_path, - build_plugins: Arc::new(Vec::new()), - lang_plugins: Arc::new(Vec::new()), - naming_conventions: Arc::new(std::collections::HashMap::new()), - cancel_token: tokio_util::sync::CancellationToken::new(), - } - } - - pub fn register_language(&mut self, plugin: Arc) { - // Register naming convention if available - if let Some(nc) = plugin.get_naming_convention() { - let mut conventions = (*self.naming_conventions).clone(); - conventions.insert(plugin.name().to_string(), nc); - self.naming_conventions = Arc::new(conventions); - } - - 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) { - // Register naming convention if available - if let Some(nc) = plugin.get_naming_convention() { - let mut conventions = (*self.naming_conventions).clone(); - conventions.insert(plugin.name().to_string(), nc); - self.naming_conventions = Arc::new(conventions); - } - - 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()) - } - - /// Get naming conventions registry (cheap Arc clone) - pub(crate) fn naming_conventions(&self) -> Arc>> { - self.naming_conventions.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(), - ); - } - - let current_lock = self.current.clone(); - - // Processing in blocking pool - tokio::task::spawn_blocking(move || -> Result<()> { - 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()), - }); - } - } - - // 1. Initial scan to identify file types and changes - let scan_results = Scanner::scan_files(to_scan, &existing_metadata); - if scan_results.is_empty() && manual_ops.is_empty() { - return Ok(()); - } - - // Partition into build and source - let (build_files, source_files): (Vec<_>, Vec<_>) = - scan_results.into_iter().partition(|f| f.is_build()); - - let resolver = - IndexResolver::with_plugins((*build_plugins).clone(), (*lang_plugins).clone()); - - // 2. Phase 1: Heavy Build Resolution (Global Context) - let mut project_context = crate::ingest::resolver::ProjectContext::new(); - let mut initial_ops = manual_ops; - - // IMPORTANT: RemovePath MUST come before AddNode for the same paths. - // Add RemovePath and UpdateFile for build files up front. - for bf in &build_files { - initial_ops.push(GraphOp::RemovePath { - path: Arc::from(bf.path()), - }); - initial_ops.push(GraphOp::UpdateFile { - metadata: bf.file.clone(), - }); - } - - // For build files, we still process them up front because they define the structure - let build_ops = resolver.resolve_build_batch(&build_files, &mut project_context)?; - initial_ops.extend(build_ops); - - // 3. Phase 2: Pipeline Batch Processing for source files - let pipeline = crate::ingest::pipeline::IngestPipeline::new(500); // 500 files per batch - let source_paths: Vec = source_files - .into_iter() - .map(|f| f.path().to_path_buf()) - .collect(); - - let mut builder = base_graph.to_builder(); - - // Register naming conventions - for plugin in lang_plugins.iter() { - if let Some(nc) = plugin.get_naming_convention() { - builder.naming_conventions.insert(plugin.name(), nc); - } - } - - builder.apply_ops(initial_ops)?; - - // Note: We are in a blocking thread, resolver and context are Thread-safe. - pipeline.execute(&project_context, source_paths, &resolver, |batch_ops| { - builder.apply_ops(batch_ops)?; - Ok(()) - })?; - - // 4. Final Swap - let final_graph = Arc::new(builder.build()); - let rt = tokio::runtime::Handle::current(); - rt.block_on(async { - let mut lock = current_lock.write().await; - *lock = final_graph; - }); - - Ok(()) - }) - .await - .map_err(|e| crate::error::NaviscopeError::Internal(e.to_string()))??; - - // Save at the very end - 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 p.get_node_adapter(); - } - } - for p in build_plugins.iter() { - if p.name().as_str() == lang { - return p.get_node_adapter(); - } - } - 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 p.get_node_adapter(); - } - } - for p in build_plugins.iter() { - if p.name().as_str() == lang { - return p.get_node_adapter(); - } - } - 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(); - - // Register naming conventions - for plugin in lang_plugins.iter() { - if let Some(nc) = plugin.get_naming_convention() { - builder.naming_conventions.insert(plugin.name(), nc); - } - } - - 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/storage.rs b/crates/core/src/runtime/storage.rs new file mode 100644 index 0000000..70d7b56 --- /dev/null +++ b/crates/core/src/runtime/storage.rs @@ -0,0 +1,132 @@ +use super::*; + +impl NaviscopeEngine { + /// 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 ---- + + pub(super) fn load_from_disk( + path: &Path, + lang_caps: Arc>, + build_caps: Arc>, + ) -> Result> { + if !path.exists() { + return Ok(None); + } + + let bytes = std::fs::read(path)?; + + let get_codec = |lang: &str| -> Option> { + for caps in lang_caps.iter() { + if caps.language.as_str() == lang { + return caps.metadata_codec.metadata_codec(); + } + } + for caps in build_caps.iter() { + if caps.build_tool.as_str() == lang { + return caps.metadata_codec.metadata_codec(); + } + } + None + }; + + match CodeGraph::deserialize(&bytes, get_codec) { + 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) + } + } + } + + pub(super) fn save_to_disk( + graph: &CodeGraph, + path: &Path, + lang_caps: Arc>, + build_caps: Arc>, + ) -> Result<()> { + // Ensure directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let get_codec = |lang: &str| -> Option> { + for caps in lang_caps.iter() { + if caps.language.as_str() == lang { + return caps.metadata_codec.metadata_codec(); + } + } + for caps in build_caps.iter() { + if caps.build_tool.as_str() == lang { + return caps.metadata_codec.metadata_codec(); + } + } + None + }; + + // Serialize the graph + let bytes = graph.serialize(get_codec)?; + + // 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(()) + } + + pub fn get_stub_cache(&self) -> Arc { + self.stub_cache.clone() + } +} diff --git a/crates/core/src/runtime/watch.rs b/crates/core/src/runtime/watch.rs new file mode 100644 index 0000000..14f47d4 --- /dev/null +++ b/crates/core/src/runtime/watch.rs @@ -0,0 +1,102 @@ +use super::*; +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; +use std::path::Path; +use tokio::sync::mpsc; + +struct FsWatcher { + _watcher: RecommendedWatcher, + rx: mpsc::UnboundedReceiver>, +} + +impl FsWatcher { + fn new(root: &Path) -> notify::Result { + let (tx, rx) = mpsc::unbounded_channel(); + let mut watcher = RecommendedWatcher::new( + move |res| { + let _ = tx.send(res); + }, + Config::default(), + )?; + watcher.watch(root, RecursiveMode::Recursive)?; + Ok(Self { + _watcher: watcher, + rx, + }) + } + + async fn next_event_async(&mut self) -> Option { + match self.rx.recv().await { + Some(Ok(event)) => Some(event), + _ => None, + } + } +} + +impl NaviscopeEngine { + /// Watch for filesystem changes and update incrementally. + /// The watcher task exits when `cancel_token` is cancelled. + pub async fn start_watch_with_token( + self: Arc, + cancel_token: tokio_util::sync::CancellationToken, + ) -> Result<()> { + use std::collections::HashSet; + use std::time::Duration; + + let root = self.project_root.clone(); + let mut watcher = FsWatcher::new(&root).map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + let engine_weak = Arc::downgrade(&self); + + 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::indexing::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(()) + } + + /// Backward-compatible helper that uses the engine-wide cancellation token. + pub async fn watch(self: Arc) -> Result<()> { + let cancel_token = self.cancel_token.clone(); + self.start_watch_with_token(cancel_token).await + } +} diff --git a/crates/core/src/runtime/watcher.rs b/crates/core/src/runtime/watcher.rs deleted file mode 100644 index 0d2fbbe..0000000 --- a/crates/core/src/runtime/watcher.rs +++ /dev/null @@ -1,54 +0,0 @@ -use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; -use std::path::Path; -use tokio::sync::mpsc; - -pub struct Watcher { - // Keep watcher alive - _watcher: RecommendedWatcher, - pub(crate) rx: mpsc::UnboundedReceiver>, -} - -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); - }, - Config::default(), - )?; - - // Watch recursively - watcher.watch(root, RecursiveMode::Recursive)?; - - Ok(Self { - _watcher: watcher, - rx, - }) - } - - /// Blocks until an event is received. - pub fn next_event(&mut self) -> Option { - match self.rx.blocking_recv() { - Some(Ok(event)) => Some(event), - _ => None, - } - } - - /// 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() { - Ok(Ok(event)) => Some(event), - _ => None, - } - } -} diff --git a/crates/core/tests/async_stubbing.rs b/crates/core/tests/async_stubbing.rs new file mode 100644 index 0000000..ebfd0cb --- /dev/null +++ b/crates/core/tests/async_stubbing.rs @@ -0,0 +1,492 @@ +//! Tests for async stubbing workflow + +use naviscope_api::models::graph::ResolutionStatus; +use naviscope_api::graph::GraphService; +use naviscope_api::models::{PositionContext, SymbolResolution}; +use naviscope_api::semantic::SymbolInfoProvider; +use naviscope_api::semantic::SymbolNavigator; +use naviscope_core::indexing::source::plan_stub_requests; +use naviscope_core::facade::EngineHandle; +use naviscope_core::model::GraphOp; +use naviscope_core::runtime::NaviscopeEngine; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Once; + +fn ensure_test_index_dir() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let dir = std::env::temp_dir().join("naviscope_test_index_dir_async_stubbing"); + std::fs::create_dir_all(&dir).unwrap(); + unsafe { + std::env::set_var("NAVISCOPE_INDEX_DIR", dir); + } + }); +} + +fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { + let mut line = 0usize; + let mut col = 0usize; + for (i, ch) in content.char_indices() { + if i >= offset { + break; + } + if ch == '\n' { + line += 1; + col = 0; + } else { + col += ch.len_utf16(); + } + } + (line, col) +} + +/// Test that JavaPlugin correctly reports external asset handling +#[test] +fn test_java_plugin_handles_external_assets() { + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let generator = java_caps + .asset + .stub_generator() + .expect("Java should provide a stub generator"); + + // Java plugin should handle these + assert!(generator.can_generate(Path::new("test.jar"))); + assert!(generator.can_generate(Path::new("java.base.jmod"))); + assert!(generator.can_generate(Path::new("modules"))); + + // Java plugin should NOT handle these + assert!(!generator.can_generate(Path::new("foo.rs"))); + assert!(!generator.can_generate(Path::new("foo.py"))); + assert!(!generator.can_generate(Path::new("foo.go"))); +} + +/// Test the full async stubbing flow with a real JAR file +#[tokio::test] +async fn test_async_stubbing_with_jar() { + use std::time::Duration; + ensure_test_index_dir(); + + // Create a temporary directory for the test project + let temp_dir = std::env::temp_dir().join("naviscope_stub_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + // Create engine with Java plugin + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = NaviscopeEngine::builder(temp_dir.clone()) + .with_language_caps(java_caps) + .build(); + let _ = engine.scan_global_assets().await; + + // Find a real JAR file (use JDK's rt.jar or similar) + let java_home = std::env::var("JAVA_HOME").ok(); + let jmod_path = java_home + .as_ref() + .map(|h| PathBuf::from(h).join("jmods").join("java.base.jmod")); + + if let Some(jmod) = jmod_path.filter(|p| p.exists()) { + let mut routes = std::collections::HashMap::new(); + routes.insert("java.lang.String".to_string(), vec![jmod.clone()]); + + let ops = vec![GraphOp::AddNode { + data: Some(naviscope_plugin::IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat("java.lang.String".to_string()), + name: "String".to_string(), + kind: naviscope_api::models::graph::NodeKind::Class, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::External, + status: ResolutionStatus::Unresolved, + location: None, + metadata: Arc::new(naviscope_api::models::graph::EmptyMetadata), + }), + }]; + + let reqs = plan_stub_requests(&ops, &routes); + for req in reqs { + assert!(engine.request_stub_for_fqn(&req.fqn)); + } + + tokio::time::sleep(Duration::from_millis(500)).await; + + let graph = engine.snapshot().await; + println!( + "Graph after stubbing: {} nodes, {} edges", + graph.node_count(), + graph.edge_count() + ); + } else { + println!("Skipping JAR test: JAVA_HOME not set or jmods not found"); + } + + let _ = std::fs::remove_dir_all(&temp_dir); +} + +/// Ensure realtime stub requests can materialize external symbols once source runtime is started. +#[tokio::test] +async fn test_realtime_stub_hydration_after_runtime_started() { + use std::time::Duration; + ensure_test_index_dir(); + + let temp_dir = std::env::temp_dir().join("naviscope_stub_runtime_started"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let java_home = std::env::var("JAVA_HOME").ok(); + let jmod_path = java_home + .as_ref() + .map(|h| PathBuf::from(h).join("jmods").join("java.base.jmod")); + let Some(_jmod) = jmod_path.filter(|p| p.exists()) else { + println!("Skipping realtime stub hydration test: JAVA_HOME not set or jmods not found"); + let _ = std::fs::remove_dir_all(&temp_dir); + return; + }; + + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = Arc::new( + NaviscopeEngine::builder(temp_dir.clone()) + .with_language_caps(java_caps) + .build(), + ); + + // Build+source update starts source runtime lazily via submit_source_stream. + let java_file = temp_dir.join("Bootstrap.java"); + std::fs::write(&java_file, "class Bootstrap {}").unwrap(); + engine.update_files(vec![java_file]).await.unwrap(); + let _ = engine.scan_global_assets().await; + + assert!(engine.request_stub_for_fqn("java.lang.String")); + + let handle = EngineHandle::from_engine(Arc::clone(&engine)); + let mut found = None; + for _ in 0..20 { + if let Ok(Some(node)) = handle.get_node_display("java.lang.String").await { + found = Some(node); + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let node = found.expect("expected java.lang.String to be hydrated from stub request"); + assert_eq!(node.source, naviscope_api::models::NodeSource::External); + + // Also verify SymbolInfoProvider path (used by hover chain) can resolve hydrated stub symbol. + let symbol_info = handle + .get_symbol_info("java.lang.String") + .await + .expect("get_symbol_info should not fail") + .expect("java.lang.String should exist after stub hydration"); + assert_eq!(symbol_info.source, naviscope_api::models::NodeSource::External); + assert_eq!(symbol_info.id, "java.lang.String"); + + let _ = std::fs::remove_dir_all(&temp_dir); +} + +/// Verify overload resolution remains precise for external/stub symbols. +#[tokio::test] +async fn test_external_stub_overload_resolution_is_precise() { + use std::time::Duration; + ensure_test_index_dir(); + + let temp_dir = std::env::temp_dir().join("naviscope_stub_overload_precise"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let java_home = std::env::var("JAVA_HOME").ok(); + let jmod_path = java_home + .as_ref() + .map(|h| PathBuf::from(h).join("jmods").join("java.base.jmod")); + let Some(_jmod) = jmod_path.filter(|p| p.exists()) else { + println!("Skipping external overload test: JAVA_HOME not set or jmods not found"); + let _ = std::fs::remove_dir_all(&temp_dir); + return; + }; + + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = Arc::new( + NaviscopeEngine::builder(temp_dir.clone()) + .with_language_caps(java_caps) + .build(), + ); + + let file = temp_dir.join("Use.java"); + let source = r#" +class Use { + void run() { + "abc".indexOf(97); + "abc".indexOf("a"); + } +} +"#; + std::fs::write(&file, source).unwrap(); + + // Start source runtime and index the source file. + engine.update_files(vec![file.clone()]).await.unwrap(); + let _ = engine.scan_global_assets().await; + assert!(engine.request_stub_for_fqn("java.lang.String")); + + let handle = EngineHandle::from_engine(Arc::clone(&engine)); + + // Ensure base class stub is hydrated first. + let mut string_ready = false; + for _ in 0..20 { + if let Ok(Some(node)) = handle.get_node_display("java.lang.String").await + && node.source == naviscope_api::models::NodeSource::External + { + string_ready = true; + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!(string_ready, "java.lang.String should be hydrated before overload checks"); + + let call_int = source.find("indexOf(97)").expect("indexOf(97) should exist"); + let (line_int, col_int) = offset_to_point(source, call_int); + let call_str = source + .find("indexOf(\"a\")") + .expect("indexOf(\"a\") should exist"); + let (line_str, col_str) = offset_to_point(source, call_str); + + let mut maybe_int = None; + let mut maybe_bool = None; + for _ in 0..30 { + maybe_int = handle + .resolve_symbol_at(&PositionContext { + uri: format!("file://{}", file.display()), + line: line_int as u32, + char: col_int as u32, + content: Some(source.to_string()), + }) + .await + .ok() + .flatten(); + maybe_bool = handle + .resolve_symbol_at(&PositionContext { + uri: format!("file://{}", file.display()), + line: line_str as u32, + char: col_str as u32, + content: Some(source.to_string()), + }) + .await + .ok() + .flatten(); + + if maybe_int.is_some() && maybe_bool.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let res_int = maybe_int.expect("int call should resolve"); + let res_bool = maybe_bool.expect("bool call should resolve"); + + let fqn_int = match res_int { + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => fqn, + SymbolResolution::Local(_, _) => panic!("expected non-local resolution for int call"), + }; + let fqn_bool = match res_bool { + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => fqn, + SymbolResolution::Local(_, _) => panic!("expected non-local resolution for string call"), + }; + + assert_ne!( + fqn_int, fqn_bool, + "different indexOf overload callsites should resolve to different FQNs" + ); + assert!( + fqn_int.starts_with("java.lang.String#indexOf(") + && fqn_bool.starts_with("java.lang.String#indexOf("), + "both resolutions should map to String.indexOf overloads" + ); + + let _ = std::fs::remove_dir_all(&temp_dir); +} + +/// Ensure stub requests queued before source runtime startup are replayed after runtime starts. +#[tokio::test] +async fn test_stub_request_replayed_after_runtime_start() { + use std::time::Duration; + ensure_test_index_dir(); + + let temp_dir = std::env::temp_dir().join("naviscope_stub_replay_after_start"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let java_home = std::env::var("JAVA_HOME").ok(); + let jmod_path = java_home + .as_ref() + .map(|h| PathBuf::from(h).join("jmods").join("java.base.jmod")); + let Some(_jmod) = jmod_path.filter(|p| p.exists()) else { + println!("Skipping pending-stub replay test: JAVA_HOME not set or jmods not found"); + let _ = std::fs::remove_dir_all(&temp_dir); + return; + }; + + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = Arc::new( + NaviscopeEngine::builder(temp_dir.clone()) + .with_language_caps(java_caps) + .build(), + ); + + // Build asset routes first; runtime is still not started. + let _ = engine.scan_global_assets().await; + assert!( + engine.request_stub_for_fqn("java.lang.String"), + "request should be accepted and queued before runtime start" + ); + + let handle = EngineHandle::from_engine(Arc::clone(&engine)); + let before_start = handle.get_node_display("java.lang.String").await.unwrap(); + assert!( + before_start.is_none(), + "queued request should not materialize symbol before runtime start" + ); + + // Trigger runtime startup through source update. + let java_file = temp_dir.join("Boot.java"); + std::fs::write(&java_file, "class Boot {}").unwrap(); + engine.update_files(vec![java_file]).await.unwrap(); + + let mut found = None; + for _ in 0..20 { + if let Ok(Some(node)) = handle.get_node_display("java.lang.String").await { + found = Some(node); + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let node = found.expect("queued stub request should be replayed after runtime starts"); + assert_eq!(node.source, naviscope_api::models::NodeSource::External); + + let _ = std::fs::remove_dir_all(&temp_dir); +} + +/// Regression: source indexing with external imports should not deadlock waiting for +/// unreachable resource dependencies. +#[tokio::test] +async fn test_update_files_with_external_import_does_not_hang() { + use std::time::Duration; + ensure_test_index_dir(); + + let temp_dir = std::env::temp_dir().join("naviscope_external_import_no_hang"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = NaviscopeEngine::builder(temp_dir.clone()) + .with_language_caps(java_caps) + .build(); + + let a_file = temp_dir.join("A.java"); + let b_file = temp_dir.join("B.java"); + + std::fs::write( + &a_file, + r#" +import java.util.List; + +class A { + B ref; + List names; +} +"#, + ) + .unwrap(); + std::fs::write( + &b_file, + r#" +class B { + String value; +} +"#, + ) + .unwrap(); + + tokio::time::timeout(Duration::from_secs(10), engine.update_files(vec![a_file, b_file])) + .await + .expect("update_files should finish within timeout") + .expect("update_files should succeed"); + + let graph = engine.snapshot().await; + assert!(graph.node_count() > 0, "graph should contain indexed nodes"); + + let _ = std::fs::remove_dir_all(&temp_dir); +} + +/// Regression: with collect->analyze phase barrier, cross-file type resolution should +/// remain stable and resolve to FQN. +#[tokio::test] +async fn test_cross_file_type_resolution_stays_fqn_precise() { + ensure_test_index_dir(); + + let temp_dir = std::env::temp_dir().join("naviscope_cross_file_fqn_precise"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(temp_dir.join("p")).unwrap(); + + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = Arc::new( + NaviscopeEngine::builder(temp_dir.clone()) + .with_language_caps(java_caps) + .build(), + ); + + let a_file = temp_dir.join("p").join("A.java"); + let b_file = temp_dir.join("p").join("B.java"); + + let a_source = r#" +package p; + +class A { + B ref; +} +"#; + let b_source = r#" +package p; + +class B { + String value; +} +"#; + + std::fs::write(&a_file, a_source).unwrap(); + std::fs::write(&b_file, b_source).unwrap(); + + engine.update_files(vec![a_file.clone(), b_file]).await.unwrap(); + + let handle = EngineHandle::from_engine(Arc::clone(&engine)); + let type_off = a_source.find("B ref").expect("B ref should exist"); + let (line, col) = offset_to_point(a_source, type_off); + let resolution = handle + .resolve_symbol_at(&PositionContext { + uri: format!("file://{}", a_file.display()), + line: line as u32, + char: col as u32, + content: Some(a_source.to_string()), + }) + .await + .expect("resolve_symbol_at should succeed") + .expect("type usage should resolve"); + + let fqn = match resolution { + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => fqn, + SymbolResolution::Local(_, _) => panic!("expected global/precise resolution for type"), + }; + assert_eq!(fqn, "p.B"); + + let _ = std::fs::remove_dir_all(&temp_dir); +} diff --git a/crates/core/tests/engine_api.rs b/crates/core/tests/engine_api.rs index ebb7582..811ac30 100644 --- a/crates/core/tests/engine_api.rs +++ b/crates/core/tests/engine_api.rs @@ -1,6 +1,6 @@ use naviscope_api::GraphService; use naviscope_core::facade::EngineHandle; -use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; +use naviscope_core::runtime::NaviscopeEngine as CoreEngine; use std::sync::Arc; #[tokio::test] @@ -33,17 +33,18 @@ async fn test_engine_handle_query() { 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 engine = Arc::new(CoreEngine::builder(temp_dir.clone()).build()); let handle = EngineHandle::from_engine(engine); // Test query execution via handle let query = GraphQuery::Find { pattern: "test".to_string(), kind: vec![], + sources: vec![], limit: 5, }; - let result: naviscope_api::graph::Result = + let result: naviscope_api::ApiResult = handle.query(&query).await; assert!(result.is_ok()); diff --git a/crates/core/tests/global_assets.rs b/crates/core/tests/global_assets.rs new file mode 100644 index 0000000..6891b5d --- /dev/null +++ b/crates/core/tests/global_assets.rs @@ -0,0 +1,28 @@ +use naviscope_core::runtime::NaviscopeEngine; +use tempfile::tempdir; + +#[tokio::test] +async fn test_global_asset_scan_produces_routes() { + let dir = tempdir().unwrap(); + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = NaviscopeEngine::builder(dir.path().to_path_buf()) + .with_language_caps(java_caps) + .build(); + + let scan = engine + .scan_global_assets() + .await + .expect("Expected asset service to be available"); + + let routes = engine.global_asset_routes(); + + assert!(scan.total_assets >= scan.indexed_assets + scan.skipped_assets + scan.failed_assets); + + if scan.total_assets > 0 { + assert!( + scan.total_prefixes > 0, + "Expected some prefixes to be indexed" + ); + assert!(!routes.is_empty(), "Expected routes to be populated"); + } +} diff --git a/crates/core/tests/incremental_consistency.rs b/crates/core/tests/incremental_consistency.rs new file mode 100644 index 0000000..9c191d8 --- /dev/null +++ b/crates/core/tests/incremental_consistency.rs @@ -0,0 +1,202 @@ +use naviscope_api::models::{Language, PositionContext, ReferenceQuery, SymbolQuery, SymbolResolution}; +use naviscope_api::semantic::{ReferenceAnalyzer, SymbolInfoProvider, SymbolNavigator}; +use naviscope_core::facade::EngineHandle; +use naviscope_core::runtime::NaviscopeEngine as CoreEngine; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::Once; + +fn ensure_test_index_dir() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let dir = std::env::temp_dir().join("naviscope_test_index_dir_incremental_consistency"); + std::fs::create_dir_all(&dir).unwrap(); + unsafe { + std::env::set_var("NAVISCOPE_INDEX_DIR", dir); + } + }); +} + +fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { + let mut line = 0usize; + let mut col = 0usize; + for (i, ch) in content.char_indices() { + if i >= offset { + break; + } + if ch == '\n' { + line += 1; + col = 0; + } else { + col += ch.len_utf16(); + } + } + (line, col) +} + +fn normalize_path(path: &Path) -> String { + path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Observation { + resolved_fqn: String, + defs: Vec<(String, usize, usize, usize, usize)>, + refs: Vec<(String, usize, usize, usize, usize)>, + symbol_id: String, + symbol_source: naviscope_api::models::NodeSource, +} + +async fn observe( + engine: Arc, + use_path: &Path, + use_content: &str, +) -> Observation { + let handle = EngineHandle::from_engine(engine); + let call_offset = use_content + .find("target();") + .expect("use file should contain target() call"); + let (line, col) = offset_to_point(use_content, call_offset); + + let ctx = PositionContext { + uri: format!("file://{}", use_path.display()), + line: line as u32, + char: col as u32, + content: Some(use_content.to_string()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("callsite should resolve"); + + let resolved_fqn = match &resolution { + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => fqn.clone(), + SymbolResolution::Local(_, _) => panic!("method call should not resolve as local"), + }; + + let query = SymbolQuery { + language: Language::JAVA, + resolution: resolution.clone(), + }; + + let mut defs: Vec<_> = handle + .find_definitions(&query) + .await + .unwrap() + .into_iter() + .map(|loc| { + ( + normalize_path(&loc.path), + loc.range.start_line, + loc.range.start_col, + loc.range.end_line, + loc.range.end_col, + ) + }) + .collect(); + defs.sort(); + + let mut refs: Vec<_> = handle + .find_references(&ReferenceQuery { + language: Language::JAVA, + resolution, + include_declaration: false, + }) + .await + .unwrap() + .into_iter() + .map(|loc| { + ( + normalize_path(&loc.path), + loc.range.start_line, + loc.range.start_col, + loc.range.end_line, + loc.range.end_col, + ) + }) + .collect(); + refs.sort(); + + let info = handle + .get_symbol_info(&resolved_fqn) + .await + .unwrap() + .expect("resolved symbol should have display info"); + + Observation { + resolved_fqn, + defs, + refs, + symbol_id: info.id, + symbol_source: info.source, + } +} + +#[tokio::test] +async fn test_incremental_vs_full_consistency_for_java_navigation() { + ensure_test_index_dir(); + let full_dir = std::env::temp_dir().join("naviscope_full_consistency_java"); + let inc_dir = std::env::temp_dir().join("naviscope_inc_consistency_java"); + + for dir in [&full_dir, &inc_dir] { + if dir.exists() { + let _ = std::fs::remove_dir_all(dir); + } + std::fs::create_dir_all(dir.join("com/example")).unwrap(); + } + + let a_rel = PathBuf::from("com/example/A.java"); + let use_rel = PathBuf::from("com/example/Use.java"); + let a_src = "package com.example; public class A { void target() {} }"; + let use_src = r#" +package com.example; +public class Use { + void run() { + new A().target(); + } +} +"#; + + std::fs::write(full_dir.join(&a_rel), a_src).unwrap(); + std::fs::write(full_dir.join(&use_rel), use_src).unwrap(); + std::fs::write(inc_dir.join(&a_rel), a_src).unwrap(); + std::fs::write(inc_dir.join(&use_rel), use_src).unwrap(); + + let java_caps_full = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine_full = Arc::new( + CoreEngine::builder(full_dir.clone()) + .with_language_caps(java_caps_full) + .build(), + ); + engine_full + .update_files(vec![full_dir.join(&a_rel), full_dir.join(&use_rel)]) + .await + .unwrap(); + let full_obs = observe(Arc::clone(&engine_full), &full_dir.join(&use_rel), use_src).await; + + let java_caps_inc = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine_inc = Arc::new( + CoreEngine::builder(inc_dir.clone()) + .with_language_caps(java_caps_inc) + .build(), + ); + engine_inc + .update_files(vec![inc_dir.join(&a_rel)]) + .await + .unwrap(); + engine_inc + .update_files(vec![inc_dir.join(&use_rel)]) + .await + .unwrap(); + let inc_obs = observe(Arc::clone(&engine_inc), &inc_dir.join(&use_rel), use_src).await; + + assert_eq!(full_obs, inc_obs, "incremental and full observations must match"); + + let _ = std::fs::remove_dir_all(&full_dir); + let _ = std::fs::remove_dir_all(&inc_dir); +} diff --git a/crates/core/tests/indexing.rs b/crates/core/tests/indexing.rs index 1307379..4c9423a 100644 --- a/crates/core/tests/indexing.rs +++ b/crates/core/tests/indexing.rs @@ -1,75 +1,50 @@ -use naviscope_api::models::graph::NodeKind; +use naviscope_api::models::graph::{NodeKind, NodeSource, ResolutionStatus}; use naviscope_api::models::{BuildTool, EmptyMetadata, Range}; -use naviscope_core::ingest::parser::IndexNode; -use naviscope_core::ingest::resolver::BuildResolver; -use naviscope_core::runtime::orchestrator::NaviscopeEngine; +use naviscope_core::runtime::NaviscopeEngine; use naviscope_plugin::{ - BuildParseResult, BuildToolPlugin, NodeAdapter, ParsedFile, PluginInstance, ProjectContext, - ResolvedUnit, StorageContext, + AssetCap, BuildCaps, BuildContent, BuildIndexCap, BuildParseCap, FileMatcherCap, + MetadataCodecCap, ParsedFile, PresentationCap, ProjectContext, ResolvedUnit, }; use std::fs; +use std::path::Path; use std::sync::Arc; +use std::sync::Once; use tempfile::tempdir; -struct MockBuildPlugin; +struct MockBuildCap; -impl PluginInstance for MockBuildPlugin { - fn get_node_adapter(&self) -> Option> { - Some(Arc::new(MockBuildPlugin)) +impl FileMatcherCap for MockBuildCap { + fn supports_path(&self, path: &Path) -> bool { + path.file_name().and_then(|n| n.to_str()) == Some("build.gradle") } } -impl NodeAdapter for MockBuildPlugin { - fn render_display_node( - &self, - _node: &naviscope_api::models::GraphNode, - _rodeo: &dyn naviscope_api::models::symbol::FqnReader, - ) -> naviscope_api::models::DisplayGraphNode { - unimplemented!() - } - - fn decode_metadata( - &self, - _bytes: &[u8], - _ctx: &dyn StorageContext, - ) -> Arc { - Arc::new(EmptyMetadata) - } -} - -impl BuildToolPlugin for MockBuildPlugin { - fn name(&self) -> BuildTool { - BuildTool::GRADLE - } - fn recognize(&self, name: &str) -> bool { - name == "build.gradle" - } +impl BuildParseCap for MockBuildCap { fn parse_build_file( &self, _source: &str, - ) -> Result> { - unimplemented!() - } - fn build_resolver(&self) -> Arc { - Arc::new(MockBuildResolver) + ) -> Result { + Ok(naviscope_plugin::BuildParseResult { + content: BuildContent::Unparsed(String::new()), + }) } } -struct MockBuildResolver; - -impl BuildResolver for MockBuildResolver { - fn resolve( +impl BuildIndexCap for MockBuildCap { + fn compile_build( &self, files: &[&ParsedFile], - ) -> Result<(ResolvedUnit, ProjectContext), Box> { + ) -> Result<(ResolvedUnit, ProjectContext), naviscope_plugin::BoxError> { let mut unit = ResolvedUnit::new(); let mut context = ProjectContext::new(); if let Some(f) = files.first() { - unit.add_node(IndexNode { + unit.add_node(naviscope_plugin::IndexNode { id: naviscope_api::models::symbol::NodeId::Flat("project:test".to_string()), name: "test".to_string(), kind: NodeKind::Project, lang: "gradle".to_string(), + source: NodeSource::Project, + status: ResolutionStatus::Resolved, location: Some(naviscope_api::models::DisplaySymbolLocation { path: f.path().to_string_lossy().to_string(), range: Range::default(), @@ -86,33 +61,62 @@ impl BuildResolver for MockBuildResolver { } } +impl AssetCap for MockBuildCap {} + +impl PresentationCap for MockBuildCap { + fn symbol_kind(&self, _kind: &NodeKind) -> lsp_types::SymbolKind { + lsp_types::SymbolKind::MODULE + } +} + +impl MetadataCodecCap for MockBuildCap {} + +fn mock_build_caps() -> BuildCaps { + let cap = Arc::new(MockBuildCap); + BuildCaps { + build_tool: BuildTool::GRADLE, + matcher: cap.clone(), + parser: cap.clone(), + indexing: cap.clone(), + asset: cap.clone(), + presentation: cap.clone(), + metadata_codec: cap, + } +} + +fn ensure_test_index_dir() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let dir = std::env::temp_dir().join("naviscope_test_index_dir"); + std::fs::create_dir_all(&dir).unwrap(); + unsafe { + std::env::set_var("NAVISCOPE_INDEX_DIR", dir); + } + }); +} + #[tokio::test] async fn test_update_files_persistence_integration() { + ensure_test_index_dir(); let dir = tempdir().unwrap(); let build_gradle = dir.path().join("build.gradle"); fs::write(&build_gradle, "println 'hello'").unwrap(); - let mut engine = NaviscopeEngine::new(dir.path().to_path_buf()); - engine.register_build_tool(Arc::new(MockBuildPlugin)); + let engine = NaviscopeEngine::builder(dir.path().to_path_buf()) + .with_build_caps(mock_build_caps()) + .build(); - // First index engine .update_files(vec![build_gradle.clone()]) .await .unwrap(); - let graph = engine.snapshot().await; - - // Use a more direct check on nodes - let has_project = graph.node_count() > 0; assert!( - has_project, + graph.node_count() > 0, "Project node should exist after first indexing" ); - // Second index (incremental update on the same file) engine.update_files(vec![build_gradle]).await.unwrap(); - let graph = engine.snapshot().await; assert!( graph.node_count() > 0, diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index 8e13bca..0e51dbe 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -1,230 +1,173 @@ use naviscope_api::models::{ - DisplayGraphNode, DisplaySymbolLocation, Language, NodeKind, Range, ReferenceQuery, - SymbolQuery, SymbolResolution, -}; -use naviscope_api::semantic::{ - CallHierarchyAnalyzer, ReferenceAnalyzer, SymbolInfoProvider, SymbolNavigator, + DisplayGraphNode, DisplaySymbolLocation, Language, NodeKind, NodeSource, Range, SymbolQuery, + SymbolResolution, }; +use naviscope_api::semantic::{SymbolInfoProvider, SymbolNavigator}; use naviscope_core::facade::EngineHandle; -// naviscope_core imports removed in favor of naviscope_plugin -use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; +use naviscope_core::runtime::NaviscopeEngine as CoreEngine; use naviscope_plugin::{ - GlobalParseResult, LangResolver, LanguagePlugin, LspParser, NamingConvention, NodeAdapter, - ParsedFile, PluginInstance, ProjectContext, ResolvedUnit, SemanticResolver, StorageContext, + AssetCap, CodecContext, FileMatcherCap, GlobalParseResult, LanguageCaps, LanguageParseCap, + LspSyntaxService, MetadataCodecCap, NamingConvention, NodeMetadataCodec, NodePresenter, + ParsedContent, ParsedFile, PresentationCap, ProjectContext, ReferenceCheckService, + ResolvedUnit, SemanticCap, SourceAnalyzeArtifact, SourceCollectArtifact, SourceIndexCap, + StandardNamingConvention, SymbolQueryService, SymbolResolveService, }; -use std::any::Any; use std::path::Path; use std::sync::Arc; +use std::sync::Once; use tree_sitter::Tree; -#[derive(Debug)] -struct MockMetadata { - id: String, -} +#[derive(Clone)] +struct MockCap; -impl naviscope_api::models::NodeMetadata for MockMetadata { - fn as_any(&self) -> &dyn Any { - self - } +struct MockCollected { + file: ParsedFile, + type_symbols: Vec, + method_symbols: Vec, + provided_symbols: Vec, + required_symbols: Vec, } -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, +struct MockAnalyzed { + file: ParsedFile, + identifiers: Vec, } -impl NodeAdapter for MockPlugin { - fn render_display_node( - &self, - node: &naviscope_api::models::graph::GraphNode, - rodeo: &dyn naviscope_api::models::symbol::FqnReader, - ) -> DisplayGraphNode { - let display_id = naviscope_plugin::DotPathConvention.render_fqn(node.id, rodeo); - let mut display = DisplayGraphNode { - id: display_id, - name: rodeo.resolve_atom(node.name).to_string(), - kind: node.kind.clone(), - lang: rodeo.resolve_atom(node.lang).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 encode_metadata( - &self, - metadata: &dyn naviscope_api::models::graph::NodeMetadata, - _ctx: &mut dyn StorageContext, - ) -> Vec { - if let Some(m) = metadata.as_any().downcast_ref::() { - m.id.as_bytes().to_vec() - } else { - Vec::new() - } - } - - fn decode_metadata( - &self, - bytes: &[u8], - _ctx: &dyn StorageContext, - ) -> Arc { - let id = String::from_utf8_lossy(bytes).to_string(); - Arc::new(MockMetadata { id }) - } -} - -impl PluginInstance for MockPlugin { - fn get_node_adapter(&self) -> Option> { - Some(Arc::new(self.clone_internal())) +impl FileMatcherCap for MockCap { + fn supports_path(&self, path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()) == Some("mock") } } -impl LanguagePlugin for MockPlugin { - fn name(&self) -> Language { - Language::new("mock") - } - fn supported_extensions(&self) -> &[&str] { - &["mock"] - } - fn parse_file( +impl LanguageParseCap for MockCap { + fn parse_language_file( &self, - _source: &str, + source: &str, _path: &Path, - ) -> Result> { + ) -> Result { Ok(GlobalParseResult { package_name: None, imports: vec![], - output: naviscope_core::ingest::parser::ParseOutput { + output: naviscope_plugin::ParseOutput { nodes: vec![], relations: vec![], - identifiers: vec!["Callee".to_string()], - ..Default::default() + identifiers: vec!["Symbol".to_string()], }, - source: Some(_source.to_string()), + source: Some(source.to_string()), tree: None, }) } - fn resolver(&self) -> Arc { - self.resolver.clone() - } - fn lang_resolver(&self) -> Arc { - self.lang_resolver.clone() +} + +impl SourceIndexCap for MockCap { + fn collect_source( + &self, + file: &ParsedFile, + _context: &ProjectContext, + ) -> Result, naviscope_plugin::BoxError> { + Ok(Box::new(MockCollected { + file: file.clone(), + type_symbols: vec!["test::Symbol".to_string()], + method_symbols: vec!["test::Symbol#method()".to_string()], + provided_symbols: vec!["test::Symbol".to_string()], + required_symbols: vec![], + })) } - fn lsp_parser(&self) -> Arc { - Arc::new(MockLspParserWrapper { - parser: self.lsp_parser.clone(), - }) + + fn analyze_source( + &self, + collected: Box, + _context: &ProjectContext, + ) -> Result, naviscope_plugin::BoxError> { + let collected = collected + .into_any() + .downcast::() + .map_err(|_| "invalid mock collected artifact")?; + let identifiers = match &collected.file.content { + ParsedContent::Language(res) => res.output.identifiers.clone(), + _ => vec!["Symbol".to_string()], + }; + Ok(Box::new(MockAnalyzed { + file: collected.file, + identifiers, + })) } -} -impl MockPlugin { - fn clone_internal(&self) -> Self { - Self { - resolver: self.resolver.clone(), - lsp_parser: self.lsp_parser.clone(), - lang_resolver: self.lang_resolver.clone(), - } + fn lower_source( + &self, + analyzed: Box, + _context: &ProjectContext, + ) -> Result { + let analyzed = analyzed + .into_any() + .downcast::() + .map_err(|_| "invalid mock analyzed artifact")?; + let mut unit = ResolvedUnit::new(); + unit.identifiers = analyzed.identifiers.clone(); + unit.ops.push(naviscope_plugin::GraphOp::UpdateIdentifiers { + path: Arc::from(analyzed.file.file.path.as_path()), + identifiers: analyzed.identifiers, + }); + unit.add_node(naviscope_plugin::IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat("test::Symbol".to_string()), + name: "Symbol".to_string(), + kind: NodeKind::Class, + lang: "mock".to_string(), + source: NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, + location: Some(DisplaySymbolLocation { + path: analyzed.file.path().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(naviscope_api::models::graph::EmptyMetadata), + }); + Ok(unit) } } -struct MockLspParserWrapper { - parser: Arc, -} +impl SourceCollectArtifact for MockCollected { + fn as_any(&self) -> &dyn std::any::Any { + self + } -impl LspParser for MockLspParserWrapper { - fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { - self.parser.parse(source, old_tree) + fn into_any(self: Box) -> Box { + self } - fn extract_symbols(&self, tree: &Tree, source: &str) -> Vec { - // Symbols are already fully rendered - self.parser.extract_symbols(tree, source) + fn collected_type_symbols(&self) -> &[String] { + &self.type_symbols } - fn symbol_kind(&self, kind: &naviscope_core::model::NodeKind) -> lsp_types::SymbolKind { - self.parser.symbol_kind(kind) + fn collected_method_symbols(&self) -> &[String] { + &self.method_symbols } - fn find_occurrences( - &self, - source: &str, - tree: &Tree, - target: &naviscope_core::ingest::parser::SymbolResolution, - ) -> Vec { - self.parser.find_occurrences(source, tree, target) + fn provided_dependency_symbols(&self) -> &[String] { + &self.provided_symbols } -} -struct MockLangResolver { - nodes: std::sync::Mutex>, + fn required_dependency_symbols(&self) -> &[String] { + &self.required_symbols + } } -impl LangResolver for MockLangResolver { - fn resolve( - &self, - file: &ParsedFile, - _context: &ProjectContext, - ) -> 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!["Callee".to_string()], - }; - - 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) +impl SourceAnalyzeArtifact for MockAnalyzed { + fn as_any(&self) -> &dyn std::any::Any { + self } -} -struct MockResolver { - res_at: std::sync::Mutex>, + fn into_any(self: Box) -> Box { + self + } } -impl SemanticResolver for MockResolver { +impl SymbolResolveService for MockCap { fn resolve_at( &self, _tree: &Tree, @@ -233,18 +176,21 @@ impl SemanticResolver for MockResolver { _byte_col: usize, _index: &dyn naviscope_plugin::CodeGraph, ) -> Option { - self.res_at.lock().unwrap().clone() + Some(SymbolResolution::Global("test::Symbol".to_string())) } +} +impl SymbolQueryService for MockCap { fn find_matches( &self, index: &dyn naviscope_plugin::CodeGraph, res: &SymbolResolution, ) -> Vec { - if let SymbolResolution::Global(id) = res { - return index.resolve_fqn(id.as_str()); + match res { + SymbolResolution::Global(fqn) => index.resolve_fqn(fqn), + SymbolResolution::Precise(fqn, _) => index.resolve_fqn(fqn), + SymbolResolution::Local(_, _) => vec![], } - vec![] } fn resolve_type_of( @@ -252,137 +198,166 @@ impl SemanticResolver for MockResolver { _index: &dyn naviscope_plugin::CodeGraph, _res: &SymbolResolution, ) -> Vec { - vec![SymbolResolution::Global("test::Type".to_string())] + vec![] } fn find_implementations( &self, - index: &dyn naviscope_plugin::CodeGraph, + _index: &dyn naviscope_plugin::CodeGraph, _res: &SymbolResolution, ) -> Vec { - index.resolve_fqn("test::Impl") + vec![] } } -struct MockLspParser; -impl LspParser for MockLspParser { - fn parse(&self, source: &str, _old_tree: Option<&Tree>) -> Option { +impl LspSyntaxService for MockCap { + 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) + parser.parse(source, old_tree) } + 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, + _target: &SymbolResolution, + _index: Option<&dyn naviscope_plugin::CodeGraph>, ) -> 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) +impl ReferenceCheckService for MockCap { + fn is_reference_to( + &self, + _graph: &dyn naviscope_plugin::CodeGraph, + candidate: &SymbolResolution, + target: &SymbolResolution, + ) -> bool { + candidate == target + } } -#[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(); +impl AssetCap for MockCap {} - let (engine, plugin) = setup_engine(&temp_dir); +impl NodePresenter for MockCap { + fn render_display_node( + &self, + node: &naviscope_api::models::graph::GraphNode, + fqns: &dyn naviscope_api::models::symbol::FqnReader, + ) -> DisplayGraphNode { + DisplayGraphNode { + id: StandardNamingConvention.render_fqn(node.id, fqns), + name: fqns.resolve_atom(node.name).to_string(), + kind: node.kind.clone(), + lang: fqns.resolve_atom(node.lang).to_string(), + source: node.source.clone(), + status: node.status, + location: node.location.as_ref().map(|l| l.to_display(fqns)), + detail: None, + signature: None, + modifiers: vec![], + children: None, + } + } +} - // 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: naviscope_api::models::symbol::NodeId::Flat("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(), - }), - }); +impl NodeMetadataCodec for MockCap { + fn encode_metadata( + &self, + _metadata: &dyn naviscope_api::models::graph::NodeMetadata, + _ctx: &mut dyn CodecContext, + ) -> Vec { + Vec::new() } - let test_file = temp_dir.join("test.mock"); - std::fs::write(&test_file, "mock content").unwrap(); + fn decode_metadata( + &self, + _bytes: &[u8], + _ctx: &dyn CodecContext, + ) -> Arc { + Arc::new(naviscope_api::models::graph::EmptyMetadata) + } +} - // Trigger update to populate graph - engine.update_files(vec![test_file.clone()]).await.unwrap(); +impl PresentationCap for MockCap { + fn naming_convention(&self) -> Option> { + Some(Arc::new(StandardNamingConvention)) + } - let handle = EngineHandle::from_engine(Arc::new(engine)); + fn node_presenter(&self) -> Option> { + Some(Arc::new(self.clone())) + } - // Test find_definitions - let query = SymbolQuery { + fn symbol_kind(&self, _kind: &NodeKind) -> lsp_types::SymbolKind { + lsp_types::SymbolKind::CLASS + } +} + +impl MetadataCodecCap for MockCap { + fn metadata_codec(&self) -> Option> { + Some(Arc::new(self.clone())) + } +} + +fn mock_caps() -> LanguageCaps { + let cap = Arc::new(MockCap); + LanguageCaps { language: Language::new("mock"), - resolution: SymbolResolution::Global("test::Symbol".to_string()), - }; + matcher: cap.clone(), + parser: cap.clone(), + semantic: cap.clone() as Arc, + indexing: cap.clone(), + asset: cap.clone(), + presentation: cap.clone(), + metadata_codec: cap, + } +} - 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()); +fn setup_engine(temp_dir: &Path) -> CoreEngine { + ensure_test_index_dir(); + CoreEngine::builder(temp_dir.to_path_buf()) + .with_language_caps(mock_caps()) + .build() +} + +fn ensure_test_index_dir() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let dir = std::env::temp_dir().join("naviscope_test_index_dir"); + std::fs::create_dir_all(&dir).unwrap(); + unsafe { + std::env::set_var("NAVISCOPE_INDEX_DIR", dir); + } + }); } #[tokio::test] -async fn test_reference_analyzer() { - let temp_dir = std::env::temp_dir().join("naviscope_test_references"); +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, _) = setup_engine(&temp_dir); - let handle = EngineHandle::from_engine(Arc::new(engine)); + let engine = setup_engine(&temp_dir); + let test_file = temp_dir.join("test.mock"); + std::fs::write(&test_file, "class Symbol {}").unwrap(); + engine.update_files(vec![test_file.clone()]).await.unwrap(); - let query = ReferenceQuery { + let handle = EngineHandle::from_engine(Arc::new(engine)); + let query = SymbolQuery { 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()); + 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] @@ -390,11 +365,11 @@ 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 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(); + std::fs::write(&test_file, "class Symbol {}").unwrap(); let uri = format!("file://{}", test_file.display()); let lang = handle.get_language_for_document(&uri).await.unwrap(); @@ -403,135 +378,3 @@ async fn test_symbol_info_provider() { 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: naviscope_api::models::symbol::NodeId::Flat("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: naviscope_api::models::symbol::NodeId::Flat("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: naviscope_api::models::symbol::NodeId::Flat("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/core/tests/stub_cache.rs b/crates/core/tests/stub_cache.rs new file mode 100644 index 0000000..b663289 --- /dev/null +++ b/crates/core/tests/stub_cache.rs @@ -0,0 +1,224 @@ +//! Tests for global stub cache + +use naviscope_api::models::graph::{EmptyMetadata, NodeKind, NodeSource, ResolutionStatus}; +use naviscope_api::models::symbol::NodeId; +use naviscope_core::cache::{AssetKey, GlobalStubCache}; +use naviscope_plugin::IndexNode; +use std::sync::Arc; +use tempfile::TempDir; + +fn create_test_stub(fqn: &str, name: &str) -> IndexNode { + IndexNode { + id: NodeId::Flat(fqn.to_string()), + name: name.to_string(), + kind: NodeKind::Class, + lang: "java".to_string(), + source: NodeSource::External, + status: ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(EmptyMetadata), + } +} + +#[test] +fn test_cache_store_and_lookup() { + let temp = TempDir::new().unwrap(); + let cache = GlobalStubCache::new(temp.path().to_path_buf()); + + // Create a fake asset file + let asset_file = temp.path().join("test.jar"); + std::fs::write(&asset_file, b"fake jar content").unwrap(); + + let asset_key = AssetKey::from_path(&asset_file).unwrap(); + + // Store a stub + let stub = create_test_stub("com.example.Foo", "Foo"); + cache.store(&asset_key, &stub); + + // Lookup should return the stub + let cached = cache.lookup(&asset_key, "com.example.Foo"); + assert!(cached.is_some()); + let cached = cached.unwrap(); + assert_eq!(cached.name, "Foo"); + assert_eq!(cached.lang, "java"); +} + +#[test] +fn test_cache_miss() { + let temp = TempDir::new().unwrap(); + let cache = GlobalStubCache::new(temp.path().to_path_buf()); + + // Create a fake asset file + let asset_file = temp.path().join("test.jar"); + std::fs::write(&asset_file, b"fake jar content").unwrap(); + + let asset_key = AssetKey::from_path(&asset_file).unwrap(); + + // Lookup without storing should return None + let cached = cache.lookup(&asset_key, "com.example.NotCached"); + assert!(cached.is_none()); +} + +#[test] +fn test_cache_persistence() { + let temp = TempDir::new().unwrap(); + let cache_dir = temp.path().join("stub_cache"); + + // Create a fake asset file + let asset_file = temp.path().join("test.jar"); + std::fs::write(&asset_file, b"fake jar content").unwrap(); + + let asset_key = AssetKey::from_path(&asset_file).unwrap(); + + // Store with first cache instance + { + let cache = GlobalStubCache::new(cache_dir.clone()); + let stub = create_test_stub("com.example.Persisted", "Persisted"); + cache.store(&asset_key, &stub); + } + + // Create new cache instance and lookup + { + let cache = GlobalStubCache::new(cache_dir.clone()); + let cached = cache.lookup(&asset_key, "com.example.Persisted"); + assert!(cached.is_some()); + assert_eq!(cached.unwrap().name, "Persisted"); + } +} + +#[test] +fn test_cache_invalidation_on_file_change() { + let temp = TempDir::new().unwrap(); + let cache = GlobalStubCache::new(temp.path().join("cache")); + + // Create first version of asset + let asset_file = temp.path().join("test.jar"); + std::fs::write(&asset_file, b"version 1").unwrap(); + let key1 = AssetKey::from_path(&asset_file).unwrap(); + + // Store a stub + let stub = create_test_stub("com.example.V1", "V1"); + cache.store(&key1, &stub); + + // "Modify" the file (change content and mtime) + std::thread::sleep(std::time::Duration::from_millis(10)); + std::fs::write(&asset_file, b"version 2 with different size").unwrap(); + let key2 = AssetKey::from_path(&asset_file).unwrap(); + + // Keys should be different (different size/mtime) + assert_ne!(key1.hash(), key2.hash()); + + // Old key should still find the stub + assert!(cache.lookup(&key1, "com.example.V1").is_some()); + + // New key should NOT find the stub (different asset version) + assert!(cache.lookup(&key2, "com.example.V1").is_none()); +} + +#[test] +fn test_cache_stats() { + let temp = TempDir::new().unwrap(); + let cache = GlobalStubCache::new(temp.path().to_path_buf()); + + // Create multiple assets + for i in 0..3 { + let asset_file = temp.path().join(format!("asset{}.jar", i)); + std::fs::write(&asset_file, format!("content{}", i)).unwrap(); + let key = AssetKey::from_path(&asset_file).unwrap(); + + for j in 0..5 { + let stub = create_test_stub( + &format!("com.example.Class{}_{}", i, j), + &format!("Class{}_{}", i, j), + ); + cache.store(&key, &stub); + } + } + + let stats = cache.stats(); + assert_eq!(stats.total_assets, 3); + assert_eq!(stats.total_entries, 15); +} + +#[test] +fn test_cache_clear() { + let temp = TempDir::new().unwrap(); + let cache = GlobalStubCache::new(temp.path().to_path_buf()); + + // Create and populate cache + let asset_file = temp.path().join("test.jar"); + std::fs::write(&asset_file, b"content").unwrap(); + let key = AssetKey::from_path(&asset_file).unwrap(); + + cache.store(&key, &create_test_stub("com.example.Test", "Test")); + assert!(cache.lookup(&key, "com.example.Test").is_some()); + + // Clear cache + cache.clear().unwrap(); + + // After clear, should not find anything + let cache2 = GlobalStubCache::new(temp.path().to_path_buf()); + assert!(cache2.lookup(&key, "com.example.Test").is_none()); +} + +#[test] +fn test_cache_with_java_metadata() { + use naviscope_java::model::JavaIndexMetadata; + use naviscope_plugin::register_metadata_deserializer; + + let temp = TempDir::new().unwrap(); + let cache_dir = temp.path().join("cache"); + + // Register the deserializer (normally done when plugin is initialized) + register_metadata_deserializer("java", JavaIndexMetadata::deserialize_for_cache); + + // Create a fake asset file + let asset_file = temp.path().join("test.jar"); + std::fs::write(&asset_file, b"content").unwrap(); + let key = AssetKey::from_path(&asset_file).unwrap(); + + // 1. Create a stub with Java-specific metadata + let java_meta = JavaIndexMetadata::Class { + modifiers: vec!["public".to_string(), "final".to_string()], + type_parameters: vec![], + }; + + let stub = IndexNode { + id: NodeId::Flat("com.example.Foo".to_string()), + name: "Foo".to_string(), + kind: NodeKind::Class, + lang: "java".to_string(), + source: NodeSource::External, + status: ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(java_meta), + }; + + // 2. Store in cache + { + let cache = GlobalStubCache::new(cache_dir.clone()); + cache.store(&key, &stub); + } + + // 3. Load from a new cache instance (simulating a restart) + { + let cache = GlobalStubCache::new(cache_dir); + let cached = cache + .lookup(&key, "com.example.Foo") + .expect("Should find cached stub"); + + // 4. Verify metadata is reconstituted as JavaIndexMetadata + let meta = cached + .metadata + .as_any() + .downcast_ref::() + .expect("Metadata should be downcastable to JavaIndexMetadata"); + + if let JavaIndexMetadata::Class { modifiers, .. } = meta { + assert!(modifiers.contains(&"public".to_string())); + assert!(modifiers.contains(&"final".to_string())); + } else { + panic!("Metadata should be of Class type"); + } + } +} diff --git a/crates/lang-gradle/Cargo.toml b/crates/lang-gradle/Cargo.toml index 1a8ab14..9b781e0 100644 --- a/crates/lang-gradle/Cargo.toml +++ b/crates/lang-gradle/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-gradle" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] @@ -16,3 +16,9 @@ petgraph = { workspace = true } once_cell = { workspace = true } rmp-serde = { workspace = true } lasso = { workspace = true } +dirs = { workspace = true } +walkdir = { workspace = true } +lsp-types = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/lang-gradle/src/cap/asset.rs b/crates/lang-gradle/src/cap/asset.rs new file mode 100644 index 0000000..84d07f0 --- /dev/null +++ b/crates/lang-gradle/src/cap/asset.rs @@ -0,0 +1,8 @@ +use crate::GradlePlugin; +use naviscope_plugin::{AssetCap, AssetDiscoverer}; + +impl AssetCap for GradlePlugin { + fn global_asset_discoverer(&self) -> Option> { + Some(Box::new(crate::discoverer::GradleCacheDiscoverer::new())) + } +} diff --git a/crates/lang-gradle/src/cap/indexing.rs b/crates/lang-gradle/src/cap/indexing.rs new file mode 100644 index 0000000..a3bafc6 --- /dev/null +++ b/crates/lang-gradle/src/cap/indexing.rs @@ -0,0 +1,18 @@ +use crate::GradlePlugin; +use naviscope_plugin::BuildIndexCap; + +impl BuildIndexCap for GradlePlugin { + fn compile_build( + &self, + files: &[&naviscope_plugin::ParsedFile], + ) -> Result< + ( + naviscope_plugin::ResolvedUnit, + naviscope_plugin::ProjectContext, + ), + naviscope_plugin::BoxError, + > { + let resolver = crate::resolve::GradleResolver::new(); + resolver.compile_build(files) + } +} diff --git a/crates/lang-gradle/src/cap/matcher.rs b/crates/lang-gradle/src/cap/matcher.rs new file mode 100644 index 0000000..b5f3d45 --- /dev/null +++ b/crates/lang-gradle/src/cap/matcher.rs @@ -0,0 +1,17 @@ +use crate::GradlePlugin; +use naviscope_plugin::FileMatcherCap; +use std::path::Path; + +impl FileMatcherCap for GradlePlugin { + fn supports_path(&self, path: &Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .map(|file_name| { + file_name == "build.gradle" + || file_name == "build.gradle.kts" + || file_name == "settings.gradle" + || file_name == "settings.gradle.kts" + }) + .unwrap_or(false) + } +} diff --git a/crates/lang-gradle/src/cap/metadata.rs b/crates/lang-gradle/src/cap/metadata.rs new file mode 100644 index 0000000..c05b13d --- /dev/null +++ b/crates/lang-gradle/src/cap/metadata.rs @@ -0,0 +1,38 @@ +use crate::GradlePlugin; +use naviscope_plugin::{CodecContext, MetadataCodecCap, NodeMetadataCodec}; +use std::sync::Arc; + +impl NodeMetadataCodec for GradlePlugin { + fn encode_metadata( + &self, + metadata: &dyn naviscope_api::models::graph::NodeMetadata, + _ctx: &mut dyn CodecContext, + ) -> Vec { + if let Some(gradle_meta) = metadata + .as_any() + .downcast_ref::() + { + rmp_serde::to_vec(&gradle_meta).unwrap_or_default() + } else { + Vec::new() + } + } + + fn decode_metadata( + &self, + bytes: &[u8], + _ctx: &dyn CodecContext, + ) -> Arc { + if let Ok(element) = rmp_serde::from_slice::(bytes) { + Arc::new(element) + } else { + Arc::new(naviscope_api::models::graph::EmptyMetadata) + } + } +} + +impl MetadataCodecCap for GradlePlugin { + fn metadata_codec(&self) -> Option> { + Some(Arc::new(Self::new())) + } +} diff --git a/crates/lang-gradle/src/cap/mod.rs b/crates/lang-gradle/src/cap/mod.rs new file mode 100644 index 0000000..40b7287 --- /dev/null +++ b/crates/lang-gradle/src/cap/mod.rs @@ -0,0 +1,9 @@ +mod asset; +mod indexing; +mod matcher; +mod metadata; +mod parse; +mod presentation; +mod registration; + +pub use registration::gradle_caps; diff --git a/crates/lang-gradle/src/cap/parse.rs b/crates/lang-gradle/src/cap/parse.rs new file mode 100644 index 0000000..2f5ad06 --- /dev/null +++ b/crates/lang-gradle/src/cap/parse.rs @@ -0,0 +1,31 @@ +use crate::GradlePlugin; +use naviscope_plugin::{BuildContent, BuildParseCap, BuildParseResult}; + +impl BuildParseCap for GradlePlugin { + fn parse_build_file( + &self, + source: &str, + ) -> Result> { + if source.contains("include") && (source.contains("'") || source.contains("\"")) { + let settings = crate::parser::parse_settings(source).unwrap_or_else(|_| { + crate::model::GradleSettings { + root_project_name: None, + included_projects: Vec::new(), + } + }); + Ok(BuildParseResult { + content: BuildContent::Metadata( + serde_json::to_value(settings).unwrap_or(serde_json::Value::Null), + ), + }) + } else { + let deps = crate::parser::parse_dependencies(source).unwrap_or_default(); + Ok(BuildParseResult { + content: BuildContent::Metadata( + serde_json::to_value(crate::model::GradleParseResult { dependencies: deps }) + .unwrap_or(serde_json::Value::Null), + ), + }) + } + } +} diff --git a/crates/lang-gradle/src/cap/presentation.rs b/crates/lang-gradle/src/cap/presentation.rs new file mode 100644 index 0000000..3a51406 --- /dev/null +++ b/crates/lang-gradle/src/cap/presentation.rs @@ -0,0 +1,46 @@ +use crate::GradlePlugin; +use naviscope_api::models::graph::{DisplayGraphNode, GraphNode, NodeKind}; +use naviscope_api::models::symbol::FqnReader; +use naviscope_plugin::{ + NamingConvention, NodePresenter, PresentationCap, StandardNamingConvention, +}; +use std::sync::Arc; + +impl NodePresenter for GradlePlugin { + fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode { + let display_id = StandardNamingConvention.render_fqn(node.id, fqns); + let mut display = DisplayGraphNode { + id: display_id, + name: fqns.resolve_atom(node.name).to_string(), + kind: node.kind.clone(), + lang: "gradle".to_string(), + source: node.source.clone(), + status: node.status, + location: node.location.as_ref().map(|l| l.to_display(fqns)), + 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(fqns); + } + + display + } +} + +impl PresentationCap for GradlePlugin { + fn node_presenter(&self) -> Option> { + Some(Arc::new(Self::new())) + } + + fn symbol_kind(&self, _kind: &NodeKind) -> lsp_types::SymbolKind { + lsp_types::SymbolKind::MODULE + } +} diff --git a/crates/lang-gradle/src/cap/registration.rs b/crates/lang-gradle/src/cap/registration.rs new file mode 100644 index 0000000..21b2be1 --- /dev/null +++ b/crates/lang-gradle/src/cap/registration.rs @@ -0,0 +1,17 @@ +use crate::GradlePlugin; +use naviscope_api::models::BuildTool; +use naviscope_plugin::BuildCaps; +use std::sync::Arc; + +pub fn gradle_caps() -> BuildCaps { + let plugin = Arc::new(GradlePlugin::new()); + BuildCaps { + build_tool: BuildTool::GRADLE, + matcher: plugin.clone(), + parser: plugin.clone(), + indexing: plugin.clone(), + asset: plugin.clone(), + presentation: plugin.clone(), + metadata_codec: plugin, + } +} diff --git a/crates/lang-gradle/src/discoverer/cache.rs b/crates/lang-gradle/src/discoverer/cache.rs new file mode 100644 index 0000000..48dbf6d --- /dev/null +++ b/crates/lang-gradle/src/discoverer/cache.rs @@ -0,0 +1,184 @@ +//! Gradle cache asset discoverer. +//! +//! Discovers JAR files from the Gradle cache directory: +//! `~/.gradle/caches/modules-2/files-2.1` + +use naviscope_plugin::{AssetDiscoverer, AssetEntry, AssetSource}; +use std::path::PathBuf; +use walkdir::WalkDir; + +/// Gradle cache asset discoverer +pub struct GradleCacheDiscoverer { + cache_path: Option, +} + +impl GradleCacheDiscoverer { + pub fn new() -> Self { + let cache_path = dirs::home_dir().map(|h| h.join(".gradle/caches/modules-2/files-2.1")); + + Self { cache_path } + } + + /// Create with a custom cache path (for testing) + pub fn with_path(path: PathBuf) -> Self { + Self { + cache_path: Some(path), + } + } + + /// Parse Gradle cache path to extract Maven coordinates + /// Path format: ~/.gradle/caches/modules-2/files-2.1/{group}/{artifact}/{version}/{hash}/{file} + fn parse_cache_path(&self, path: &std::path::Path) -> AssetSource { + let Some(cache_root) = &self.cache_path else { + return AssetSource::Unknown; + }; + + let Ok(relative) = path.strip_prefix(cache_root) else { + return AssetSource::Unknown; + }; + + let components: Vec<_> = relative.components().collect(); + + // Expected: group/artifact/version/hash/file.jar + if components.len() >= 4 { + let group = components[0].as_os_str().to_string_lossy().to_string(); + let artifact = components[1].as_os_str().to_string_lossy().to_string(); + let version = components[2].as_os_str().to_string_lossy().to_string(); + + return AssetSource::Gradle { + group, + artifact, + version, + }; + } + + AssetSource::Unknown + } +} + +impl Default for GradleCacheDiscoverer { + fn default() -> Self { + Self::new() + } +} + +impl AssetDiscoverer for GradleCacheDiscoverer { + fn discover(&self) -> Box + Send + '_> { + let Some(cache_path) = &self.cache_path else { + return Box::new(std::iter::empty()); + }; + + if !cache_path.exists() { + return Box::new(std::iter::empty()); + } + + // Use WalkDir for lazy/streaming directory traversal + let cache_path_clone = cache_path.clone(); + Box::new( + WalkDir::new(&cache_path_clone) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| { + let path = e.path(); + // Only include JAR files + path.extension().and_then(|ext| ext.to_str()) == Some("jar") + }) + .filter(|e| { + // Exclude sources and javadoc JARs + let name = e.path().file_name().and_then(|n| n.to_str()).unwrap_or(""); + !name.ends_with("-sources.jar") && !name.ends_with("-javadoc.jar") + }) + .map(move |e| { + let path = e.path().to_path_buf(); + let source = self.parse_cache_path(&path); + AssetEntry::new(path, source) + }), + ) + } + + fn name(&self) -> &str { + "Gradle Cache Discoverer" + } + + fn source_type(&self) -> &str { + "gradle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_gradle_cache_discoverer_empty() { + let temp = tempfile::tempdir().unwrap(); + let discoverer = GradleCacheDiscoverer::with_path(temp.path().to_path_buf()); + + let assets: Vec<_> = discoverer.discover().collect(); + assert!(assets.is_empty()); + } + + #[test] + fn test_gradle_cache_discoverer_finds_jars() { + let temp = tempfile::tempdir().unwrap(); + let cache_path = temp.path().to_path_buf(); + + // Create mock Gradle cache structure + // group/artifact/version/hash/file.jar + let jar_dir = cache_path.join("io.netty/netty-common/4.1.100.Final/abc123"); + fs::create_dir_all(&jar_dir).unwrap(); + + let jar_path = jar_dir.join("netty-common-4.1.100.Final.jar"); + fs::File::create(&jar_path).unwrap(); + + // Create sources jar (should be excluded) + let sources_jar = jar_dir.join("netty-common-4.1.100.Final-sources.jar"); + fs::File::create(&sources_jar).unwrap(); + + let discoverer = GradleCacheDiscoverer::with_path(cache_path); + let assets: Vec<_> = discoverer.discover().collect(); + + assert_eq!(assets.len(), 1); + assert_eq!(assets[0].path, jar_path); + + if let AssetSource::Gradle { + group, + artifact, + version, + } = &assets[0].source + { + assert_eq!(group, "io.netty"); + assert_eq!(artifact, "netty-common"); + assert_eq!(version, "4.1.100.Final"); + } else { + panic!("Expected Gradle source"); + } + } + + #[test] + fn test_parse_cache_path() { + let temp = tempfile::tempdir().unwrap(); + let cache_path = temp.path().to_path_buf(); + + let discoverer = GradleCacheDiscoverer::with_path(cache_path.clone()); + + let jar_path = + cache_path.join("com.google.guava/guava/31.1-jre/deadbeef/guava-31.1-jre.jar"); + + let source = discoverer.parse_cache_path(&jar_path); + + if let AssetSource::Gradle { + group, + artifact, + version, + } = source + { + assert_eq!(group, "com.google.guava"); + assert_eq!(artifact, "guava"); + assert_eq!(version, "31.1-jre"); + } else { + panic!("Expected Gradle source"); + } + } +} diff --git a/crates/lang-gradle/src/discoverer/mod.rs b/crates/lang-gradle/src/discoverer/mod.rs new file mode 100644 index 0000000..b5d24bf --- /dev/null +++ b/crates/lang-gradle/src/discoverer/mod.rs @@ -0,0 +1,5 @@ +//! Asset discoverers for Gradle ecosystem. + +mod cache; + +pub use cache::GradleCacheDiscoverer; diff --git a/crates/lang-gradle/src/lib.rs b/crates/lang-gradle/src/lib.rs index 13d3efa..9100d68 100644 --- a/crates/lang-gradle/src/lib.rs +++ b/crates/lang-gradle/src/lib.rs @@ -1,132 +1,19 @@ +pub mod cap; +pub mod discoverer; pub mod model; pub mod parser; pub mod queries; -pub mod resolver; +pub mod resolve; -use naviscope_api::models::BuildTool; -use naviscope_api::models::graph::DisplayGraphNode; -use naviscope_api::models::symbol::FqnReader; -use naviscope_plugin::{ - BuildContent, BuildParseResult, BuildToolPlugin, DotPathConvention, NamingConvention, - NodeAdapter, PluginInstance, StorageContext, -}; -use std::sync::Arc; +pub use cap::gradle_caps; +pub use discoverer::GradleCacheDiscoverer; pub struct GradlePlugin { - resolver: Arc, -} - -impl NodeAdapter for GradlePlugin { - fn render_display_node( - &self, - node: &naviscope_api::models::graph::GraphNode, - fqns: &dyn FqnReader, - ) -> DisplayGraphNode { - let display_id = DotPathConvention.render_fqn(node.id, fqns); - let mut display = DisplayGraphNode { - id: display_id, - name: fqns.resolve_atom(node.name).to_string(), - kind: node.kind.clone(), - lang: "gradle".to_string(), - location: node.location.as_ref().map(|l| l.to_display(fqns)), - 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(fqns); - } - - display - } - - fn encode_metadata( - &self, - metadata: &dyn naviscope_api::models::graph::NodeMetadata, - _ctx: &mut dyn StorageContext, - ) -> Vec { - if let Some(gradle_meta) = metadata - .as_any() - .downcast_ref::() - { - rmp_serde::to_vec(&gradle_meta).unwrap_or_default() - } else { - Vec::new() - } - } - - fn decode_metadata( - &self, - bytes: &[u8], - _ctx: &dyn StorageContext, - ) -> Arc { - if let Ok(element) = rmp_serde::from_slice::(bytes) { - Arc::new(element) - } else { - Arc::new(naviscope_api::models::graph::EmptyMetadata) - } - } + _private: (), } impl GradlePlugin { pub fn new() -> Self { - Self { - resolver: Arc::new(resolver::GradleResolver::new()), - } - } -} - -impl PluginInstance for GradlePlugin { - fn get_node_adapter(&self) -> Option> { - Some(Arc::new(Self::new())) - } -} - -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> { - 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: BuildContent::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: BuildContent::Metadata( - serde_json::to_value(model::GradleParseResult { dependencies: deps }) - .unwrap_or(serde_json::Value::Null), - ), - }) - } - } - - fn build_resolver(&self) -> Arc { - self.resolver.clone() + Self { _private: () } } } diff --git a/crates/lang-gradle/src/resolver.rs b/crates/lang-gradle/src/resolve/build.rs similarity index 95% rename from crates/lang-gradle/src/resolver.rs rename to crates/lang-gradle/src/resolve/build.rs index 840b944..80310a1 100644 --- a/crates/lang-gradle/src/resolver.rs +++ b/crates/lang-gradle/src/resolve/build.rs @@ -1,9 +1,9 @@ use naviscope_api::models::graph::{ - DisplaySymbolLocation, EdgeType, EmptyMetadata, GraphEdge, NodeKind, + DisplaySymbolLocation, EdgeType, EmptyMetadata, GraphEdge, NodeKind, NodeSource, }; use naviscope_api::models::symbol::{NodeId, Range}; use naviscope_plugin::{ - BuildResolver, IndexNode, ParsedContent, ParsedFile, ProjectContext, ResolvedUnit, + BuildIndexCap, IndexNode, ParsedContent, ParsedFile, ProjectContext, ResolvedUnit, }; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -22,8 +22,8 @@ impl GradleResolver { } } -impl BuildResolver for GradleResolver { - fn resolve( +impl BuildIndexCap for GradleResolver { + fn compile_build( &self, files: &[&ParsedFile], ) -> std::result::Result<(ResolvedUnit, ProjectContext), Box> @@ -127,6 +127,8 @@ impl BuildResolver for GradleResolver { name: project_name.clone(), kind: NodeKind::Project, lang: "gradle".to_string(), + source: NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: Some(DisplaySymbolLocation { path: root_path.to_string_lossy().to_string(), range: Range { @@ -183,6 +185,8 @@ impl BuildResolver for GradleResolver { name: display_name.to_string(), kind: NodeKind::Module, lang: "gradle".to_string(), + source: NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: data .build_file .as_ref() @@ -232,6 +236,8 @@ impl BuildResolver for GradleResolver { name: display_name.to_string(), kind: NodeKind::Module, lang: "gradle".to_string(), + source: NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: data .build_file .as_ref() @@ -313,6 +319,8 @@ impl BuildResolver for GradleResolver { name: dep.name.clone(), kind: NodeKind::Dependency, lang: "gradle".to_string(), + source: NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: Some(DisplaySymbolLocation { path: data .build_file @@ -356,7 +364,7 @@ struct ModuleData<'a> { #[cfg(test)] mod tests { use super::*; - use naviscope_plugin::{GraphOp, SourceFile}; + use naviscope_plugin::{BuildIndexCap, GraphOp, SourceFile}; fn create_mock_file(path: &str, content: ParsedContent) -> ParsedFile { ParsedFile { @@ -403,7 +411,7 @@ mod tests { ); let files = vec![&root_settings, &sub_project_build, &core_build]; - let (unit, _) = resolver.resolve(&files).unwrap(); + let (unit, _) = resolver.compile_build(&files).unwrap(); let edges: Vec<_> = unit .ops diff --git a/crates/lang-gradle/src/resolve/mod.rs b/crates/lang-gradle/src/resolve/mod.rs new file mode 100644 index 0000000..c46cb20 --- /dev/null +++ b/crates/lang-gradle/src/resolve/mod.rs @@ -0,0 +1,3 @@ +pub mod build; + +pub use build::GradleResolver; diff --git a/crates/lang-java/Cargo.toml b/crates/lang-java/Cargo.toml index 6b32ea3..1ad88bc 100644 --- a/crates/lang-java/Cargo.toml +++ b/crates/lang-java/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-java" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] @@ -15,6 +15,13 @@ lsp-types = { workspace = true } tree-sitter-java = { workspace = true } rmp-serde = { workspace = true } lasso = { workspace = true } +zip = { workspace = true } +ristretto_jimage = { workspace = true } +ristretto_classfile = { workspace = true } +dirs = { workspace = true } +regex = { workspace = true } [dev-dependencies] naviscope-core = { workspace = true } +tokio = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/lang-java/src/cap/asset.rs b/crates/lang-java/src/cap/asset.rs new file mode 100644 index 0000000..6e7ac4b --- /dev/null +++ b/crates/lang-java/src/cap/asset.rs @@ -0,0 +1,21 @@ +use crate::JavaPlugin; +use naviscope_plugin::{AssetCap, AssetDiscoverer, AssetIndexer, AssetSourceLocator}; +use std::sync::Arc; + +impl AssetCap for JavaPlugin { + fn global_asset_discoverer(&self) -> Option> { + Some(Box::new(crate::discoverer::JdkDiscoverer::new())) + } + + fn asset_indexer(&self) -> Option> { + Some(Arc::new(crate::resolve::external::JavaExternalResolver)) + } + + fn asset_source_locator(&self) -> Option> { + Some(Arc::new(crate::resolve::external::JavaExternalResolver)) + } + + fn stub_generator(&self) -> Option> { + Some(Arc::new(crate::resolve::external::JavaExternalResolver)) + } +} diff --git a/crates/lang-java/src/cap/indexing.rs b/crates/lang-java/src/cap/indexing.rs new file mode 100644 index 0000000..5a42d08 --- /dev/null +++ b/crates/lang-java/src/cap/indexing.rs @@ -0,0 +1 @@ +// Source indexing implementation is provided in `crate::resolve::lang`. diff --git a/crates/lang-java/src/cap/matcher.rs b/crates/lang-java/src/cap/matcher.rs new file mode 100644 index 0000000..89598dc --- /dev/null +++ b/crates/lang-java/src/cap/matcher.rs @@ -0,0 +1,12 @@ +use crate::JavaPlugin; +use naviscope_plugin::FileMatcherCap; +use std::path::Path; + +impl FileMatcherCap for JavaPlugin { + fn supports_path(&self, path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("java")) + .unwrap_or(false) + } +} diff --git a/crates/lang-java/src/cap/metadata.rs b/crates/lang-java/src/cap/metadata.rs new file mode 100644 index 0000000..c21ea28 --- /dev/null +++ b/crates/lang-java/src/cap/metadata.rs @@ -0,0 +1,44 @@ +use crate::JavaPlugin; +use naviscope_api::models::graph::EmptyMetadata; +use naviscope_plugin::{CodecContext, MetadataCodecCap, NodeMetadataCodec}; +use std::sync::Arc; + +impl NodeMetadataCodec for JavaPlugin { + fn encode_metadata( + &self, + metadata: &dyn naviscope_api::models::graph::NodeMetadata, + _ctx: &mut dyn CodecContext, + ) -> 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::() + { + rmp_serde::to_vec(&java_idx_meta).unwrap_or_default() + } else { + Vec::new() + } + } + + fn decode_metadata( + &self, + bytes: &[u8], + _ctx: &dyn CodecContext, + ) -> Arc { + if let Ok(element) = rmp_serde::from_slice::(bytes) { + Arc::new(element) + } else { + Arc::new(EmptyMetadata) + } + } +} + +impl MetadataCodecCap for JavaPlugin { + fn metadata_codec(&self) -> Option> { + Some(Arc::new(self.clone())) + } +} diff --git a/crates/lang-java/src/cap/mod.rs b/crates/lang-java/src/cap/mod.rs new file mode 100644 index 0000000..c0c9cd4 --- /dev/null +++ b/crates/lang-java/src/cap/mod.rs @@ -0,0 +1,10 @@ +mod asset; +mod indexing; +mod matcher; +mod metadata; +mod parse; +mod presentation; +mod registration; +mod runtime; + +pub use registration::java_caps; diff --git a/crates/lang-java/src/cap/parse.rs b/crates/lang-java/src/cap/parse.rs new file mode 100644 index 0000000..260bb55 --- /dev/null +++ b/crates/lang-java/src/cap/parse.rs @@ -0,0 +1,13 @@ +use crate::JavaPlugin; +use naviscope_plugin::LanguageParseCap; +use std::path::Path; + +impl LanguageParseCap for JavaPlugin { + fn parse_language_file( + &self, + source: &str, + path: &Path, + ) -> std::result::Result { + self.parser.parse_file(source, Some(path)) + } +} diff --git a/crates/lang-java/src/cap/presentation.rs b/crates/lang-java/src/cap/presentation.rs new file mode 100644 index 0000000..58c48b8 --- /dev/null +++ b/crates/lang-java/src/cap/presentation.rs @@ -0,0 +1,385 @@ +use crate::JavaPlugin; +use lasso::Key; +use naviscope_api::models::DisplayGraphNode; +use naviscope_api::models::graph::{GraphNode, NodeKind}; +use naviscope_api::models::symbol::{FqnReader, Symbol}; +use naviscope_plugin::{NamingConvention, NodePresenter, PresentationCap}; +use std::sync::Arc; + +impl NodePresenter for JavaPlugin { + fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode { + let resolve_sid = |sid: u32| { + lasso::Spur::try_from_usize(sid as usize) + .map(|spur| fqns.resolve_atom(Symbol(spur)).to_string()) + }; + + let mut display = DisplayGraphNode { + id: crate::naming::JavaNamingConvention::default().render_fqn(node.id, fqns), + name: fqns.resolve_atom(node.name).to_string(), + kind: node.kind.clone(), + lang: "java".to_string(), + source: node.source.clone(), + status: node.status, + location: node.location.as_ref().map(|l| l.to_display(fqns)), + detail: None, + signature: None, + modifiers: vec![], + children: None, + }; + + let fqn = display.id.as_str(); + let container = if let Some((owner, _member)) = fqn.split_once('#') { + Some(owner.to_string()) + } else { + fqn.rsplit_once('.').map(|(owner, _)| owner.to_string()) + }; + if let Some(container) = container { + display.detail = Some(format!("*Defined in `{}`*", container)); + } + + 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() + .filter_map(|&s| resolve_sid(s)) + .collect(); + let prefix = match node.kind { + NodeKind::Interface => "interface", + 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() + .filter_map(|&s| resolve_sid(s)) + .collect(); + let params_str = parameters + .iter() + .enumerate() + .map(|(idx, p)| { + let param_type = if p.is_varargs { + match &p.type_ref { + naviscope_api::models::TypeRef::Array { element, .. } => { + format!("{}...", crate::model::fmt_type(element)) + } + _ => format!("{}...", crate::model::fmt_type(&p.type_ref)), + } + } else { + crate::model::fmt_type(&p.type_ref) + }; + format!( + "{}: {}", + resolve_sid(p.name_sid) + .unwrap_or_else(|| format!("arg{}", idx)), + param_type + ) + }) + .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) + )); + } + } + crate::model::JavaNodeMetadata::Field { + modifiers_sids, + type_ref, + } => { + display.modifiers = modifiers_sids + .iter() + .filter_map(|&s| resolve_sid(s)) + .collect(); + display.signature = Some(format!( + "{}: {}", + display.name, + crate::model::fmt_type(type_ref) + )); + } + _ => {} + } + } else if let Some(java_idx_meta) = node + .metadata + .as_any() + .downcast_ref::() + { + 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 { + NodeKind::Interface => "interface", + 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| { + let param_type = if p.is_varargs { + match &p.type_ref { + naviscope_api::models::TypeRef::Array { element, .. } => { + format!("{}...", crate::model::fmt_type_uninterned(element)) + } + _ => format!("{}...", crate::model::fmt_type_uninterned(&p.type_ref)), + } + } else { + crate::model::fmt_type_uninterned(&p.type_ref) + }; + format!( + "{}: {}", + p.name, + param_type + ) + }) + .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 + } +} + +impl PresentationCap for JavaPlugin { + fn naming_convention(&self) -> Option> { + Some(Arc::new(crate::naming::JavaNamingConvention::default())) + } + + fn node_presenter(&self) -> Option> { + Some(Arc::new(self.clone())) + } + + 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, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{JavaNodeMetadata, JavaParameterStorage}; + use lasso::Key; + use naviscope_api::models::fqn::FqnNode; + use naviscope_api::models::graph::{GraphNode, NodeKind}; + use naviscope_api::models::symbol::{FqnId, Symbol}; + use naviscope_api::models::TypeRef; + use std::collections::HashMap; + use std::sync::Arc; + + struct FakeFqnReader { + nodes: HashMap, + atoms: HashMap, + } + + impl FqnReader for FakeFqnReader { + fn resolve_node(&self, id: FqnId) -> Option { + self.nodes.get(&id).cloned() + } + + fn resolve_atom(&self, atom: Symbol) -> &str { + self.atoms + .get(&(atom.0.into_usize() as u32)) + .map(String::as_str) + .unwrap_or("") + } + } + + fn spur(id: usize) -> lasso::Spur { + lasso::Spur::try_from_usize(id).expect("valid spur") + } + + fn sym(id: usize) -> Symbol { + Symbol(spur(id)) + } + + fn fake_fqns() -> FakeFqnReader { + let mut nodes = HashMap::new(); + nodes.insert( + FqnId(1), + FqnNode { + parent: None, + name: sym(1), + kind: NodeKind::Package, + }, + ); + nodes.insert( + FqnId(2), + FqnNode { + parent: Some(FqnId(1)), + name: sym(2), + kind: NodeKind::Class, + }, + ); + nodes.insert( + FqnId(3), + FqnNode { + parent: Some(FqnId(2)), + name: sym(3), + kind: NodeKind::Method, + }, + ); + + let mut atoms = HashMap::new(); + atoms.insert(1, "com".to_string()); + atoms.insert(2, "User".to_string()); + atoms.insert(3, "setNames".to_string()); + atoms.insert(4, "java".to_string()); + atoms.insert(5, "public".to_string()); + atoms.insert(6, "names".to_string()); + + FakeFqnReader { nodes, atoms } + } + + #[test] + fn render_display_node_formats_varargs_signature() { + let plugin = JavaPlugin::new().expect("plugin"); + let fqns = fake_fqns(); + let metadata = JavaNodeMetadata::Method { + modifiers_sids: vec![5], + return_type: TypeRef::Raw("void".to_string()), + parameters: vec![JavaParameterStorage { + name_sid: 6, + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".to_string())), + dimensions: 1, + }, + is_varargs: true, + }], + is_constructor: false, + }; + + let node = GraphNode { + id: FqnId(3), + name: sym(3), + kind: NodeKind::Method, + lang: sym(4), + metadata: Arc::new(metadata), + ..GraphNode::default() + }; + + let display = plugin.render_display_node(&node, &fqns); + assert_eq!(display.signature.as_deref(), Some("setNames(names: String...) -> void")); + assert_eq!(display.modifiers, vec!["public".to_string()]); + } + + #[test] + fn render_display_node_invalid_sid_falls_back_without_panic() { + let plugin = JavaPlugin::new().expect("plugin"); + let fqns = fake_fqns(); + let metadata = JavaNodeMetadata::Method { + modifiers_sids: vec![u32::MAX], + return_type: TypeRef::Raw("void".to_string()), + parameters: vec![JavaParameterStorage { + name_sid: u32::MAX, + type_ref: TypeRef::Id("java.lang.String".to_string()), + is_varargs: false, + }], + is_constructor: false, + }; + + let node = GraphNode { + id: FqnId(3), + name: sym(3), + kind: NodeKind::Method, + lang: sym(4), + metadata: Arc::new(metadata), + ..GraphNode::default() + }; + + let display = plugin.render_display_node(&node, &fqns); + assert_eq!(display.signature.as_deref(), Some("setNames(arg0: String) -> void")); + assert!(display.modifiers.is_empty()); + } + + #[test] + fn render_display_node_member_detail_uses_member_owner() { + let plugin = JavaPlugin::new().expect("plugin"); + let fqns = fake_fqns(); + let metadata = JavaNodeMetadata::Method { + modifiers_sids: vec![], + return_type: TypeRef::Raw("void".to_string()), + parameters: vec![], + is_constructor: false, + }; + + let node = GraphNode { + id: FqnId(3), + name: sym(3), + kind: NodeKind::Method, + lang: sym(4), + metadata: Arc::new(metadata), + ..GraphNode::default() + }; + + let display = plugin.render_display_node(&node, &fqns); + assert_eq!(display.id, "com.User#setNames"); + assert_eq!(display.detail.as_deref(), Some("*Defined in `com.User`*")); + } +} diff --git a/crates/lang-java/src/cap/registration.rs b/crates/lang-java/src/cap/registration.rs new file mode 100644 index 0000000..75039b1 --- /dev/null +++ b/crates/lang-java/src/cap/registration.rs @@ -0,0 +1,18 @@ +use crate::JavaPlugin; +use naviscope_api::models::Language; +use naviscope_plugin::{LanguageCaps, SemanticCap}; +use std::sync::Arc; + +pub fn java_caps() -> std::result::Result> { + let plugin = Arc::new(JavaPlugin::new()?); + Ok(LanguageCaps { + language: Language::JAVA, + matcher: plugin.clone(), + parser: plugin.clone(), + semantic: plugin.clone() as Arc, + indexing: plugin.clone(), + asset: plugin.clone(), + presentation: plugin.clone(), + metadata_codec: plugin, + }) +} diff --git a/crates/lang-java/src/cap/runtime.rs b/crates/lang-java/src/cap/runtime.rs new file mode 100644 index 0000000..bd291f0 --- /dev/null +++ b/crates/lang-java/src/cap/runtime.rs @@ -0,0 +1,46 @@ +use crate::JavaPlugin; +use naviscope_plugin::{LspSyntaxService, ReferenceCheckService}; + +impl LspSyntaxService for JavaPlugin { + fn parse( + &self, + source: &str, + old_tree: Option<&tree_sitter::Tree>, + ) -> Option { + crate::lsp::JavaLspService::new(self.parser.clone()).parse(source, old_tree) + } + + fn extract_symbols( + &self, + tree: &tree_sitter::Tree, + source: &str, + ) -> Vec { + crate::lsp::JavaLspService::new(self.parser.clone()).extract_symbols(tree, source) + } + + fn find_occurrences( + &self, + source: &str, + tree: &tree_sitter::Tree, + target: &naviscope_api::models::SymbolResolution, + index: Option<&dyn naviscope_plugin::CodeGraph>, + ) -> Vec { + crate::lsp::JavaLspService::new(self.parser.clone()) + .find_occurrences(source, tree, target, index) + } +} + +impl ReferenceCheckService for JavaPlugin { + fn is_reference_to( + &self, + graph: &dyn naviscope_plugin::CodeGraph, + candidate: &naviscope_api::models::SymbolResolution, + target: &naviscope_api::models::SymbolResolution, + ) -> bool { + self.type_system.is_reference_to(graph, candidate, target) + } + + fn is_subtype(&self, graph: &dyn naviscope_plugin::CodeGraph, sub: &str, sup: &str) -> bool { + self.type_system.is_subtype(graph, sub, sup) + } +} diff --git a/crates/lang-java/src/discoverer/jdk.rs b/crates/lang-java/src/discoverer/jdk.rs new file mode 100644 index 0000000..deb57c5 --- /dev/null +++ b/crates/lang-java/src/discoverer/jdk.rs @@ -0,0 +1,284 @@ +//! JDK asset discoverer. +//! +//! Discovers JDK standard library assets from: +//! - JAVA_HOME environment variable +//! - macOS java_home tool +//! - Common installation paths +//! - SDKMAN + +use naviscope_plugin::{AssetDiscoverer, AssetEntry, AssetSource}; +use std::path::{Path, PathBuf}; + +/// JDK asset discoverer +pub struct JdkDiscoverer { + /// Cached JDK assets (discovered once) + cached_assets: Vec, +} + +impl JdkDiscoverer { + pub fn new() -> Self { + let mut discoverer = Self { + cached_assets: Vec::new(), + }; + discoverer.discover_jdk(); + discoverer + } + + /// Get the discovered JDK root path (if any) + pub fn jdk_root(&self) -> Option<&Path> { + self.cached_assets.first().map(|e| { + // Navigate up from lib/modules or jre/lib/rt.jar to JDK root + let path = &e.path; + if path.ends_with("lib/modules") { + path.parent().and_then(|p| p.parent()) + } else if path.ends_with("jre/lib/rt.jar") || path.ends_with("lib/rt.jar") { + path.parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + } else { + // jmods case + path.parent().and_then(|p| p.parent()) + } + .unwrap_or(path.as_path()) + }) + } + + fn discover_jdk(&mut self) { + let mut jdk_root: Option = None; + + // 1. Check JAVA_HOME + if let Ok(java_home) = std::env::var("JAVA_HOME") { + let path = PathBuf::from(&java_home); + if self.collect_sdk_assets(&path).is_some() { + jdk_root = Some(path); + } + } + + // 2. macOS specific: Use java_home tool + #[cfg(target_os = "macos")] + if self.cached_assets.is_empty() { + if let Ok(output) = std::process::Command::new("/usr/libexec/java_home").output() { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let path = PathBuf::from(&path_str); + if self.collect_sdk_assets(&path).is_some() { + jdk_root = Some(path); + } + } + } + } + + // 3. Search common installation paths + if self.cached_assets.is_empty() { + let mut search_roots = Vec::new(); + + #[cfg(target_os = "macos")] + { + search_roots.push(PathBuf::from("/Library/Java/JavaVirtualMachines/")); + search_roots.push(PathBuf::from("/opt/homebrew/opt/openjdk/")); + search_roots.push(PathBuf::from("/usr/local/opt/openjdk/")); + } + #[cfg(target_os = "linux")] + { + search_roots.push(PathBuf::from("/usr/lib/jvm/")); + } + #[cfg(target_os = "windows")] + { + search_roots.push(PathBuf::from("C:\\Program Files\\Java\\")); + } + + // SDKMAN + if let Some(mut sdkman) = dirs::home_dir() { + sdkman.push(".sdkman/candidates/java/"); + search_roots.push(sdkman); + } + + for root in search_roots { + if !root.exists() { + continue; + } + + // If root itself is a JDK (e.g. Homebrew symlink) + if self.collect_sdk_assets(&root).is_some() { + jdk_root = Some(root.clone()); + break; + } + + // If root is a parent directory containing multiple SDKs + if let Ok(entries) = std::fs::read_dir(&root) { + for entry in entries.flatten() { + let mut sdk_path = entry.path(); + if cfg!(target_os = "macos") && sdk_path.join("Contents/Home").exists() { + sdk_path = sdk_path.join("Contents/Home"); + } + if self.collect_sdk_assets(&sdk_path).is_some() { + jdk_root = Some(sdk_path); + break; + } + } + } + + if !self.cached_assets.is_empty() { + break; + } + } + } + + // Update all entries with JDK source info + if let Some(root) = jdk_root { + let version = self.detect_jdk_version(&root); + for entry in &mut self.cached_assets { + entry.source = AssetSource::Jdk { + version: version.clone(), + path: root.clone(), + }; + } + } + } + + fn detect_jdk_version(&self, jdk_root: &Path) -> Option { + // Try to read release file + let release_file = jdk_root.join("release"); + if let Ok(content) = std::fs::read_to_string(&release_file) { + for line in content.lines() { + if line.starts_with("JAVA_VERSION=") { + let version = line + .trim_start_matches("JAVA_VERSION=") + .trim_matches('"') + .to_string(); + return Some(version); + } + } + } + + // Fallback: try to extract from path + let path_str = jdk_root.to_string_lossy(); + if let Some(cap) = regex::Regex::new(r"jdk-?(\d+(?:\.\d+)*)") + .ok() + .and_then(|re| re.captures(&path_str)) + { + return cap.get(1).map(|m| m.as_str().to_string()); + } + + None + } + + fn collect_sdk_assets(&mut self, sdk_path: &Path) -> Option<()> { + if !sdk_path.exists() { + return None; + } + + // Priority 1: Java 9+ Runtime Image (The most correct way for modern Java) + let modules = sdk_path.join("lib/modules"); + if modules.exists() { + self.cached_assets.push(AssetEntry::unknown(modules)); + return Some(()); + } + + // Priority 2: Java 8 Legacy Runtime + let rt_jar = sdk_path.join("jre/lib/rt.jar"); + if rt_jar.exists() { + self.cached_assets.push(AssetEntry::unknown(rt_jar)); + return Some(()); + } + let lib_rt_jar = sdk_path.join("lib/rt.jar"); + if lib_rt_jar.exists() { + self.cached_assets.push(AssetEntry::unknown(lib_rt_jar)); + return Some(()); + } + + // Priority 3: jmods (Fallback for some JDK builds without lib/modules) + let jmods = sdk_path.join("jmods"); + if jmods.exists() { + if let Ok(entries) = std::fs::read_dir(&jmods) { + let mut found = false; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jmod") { + self.cached_assets.push(AssetEntry::unknown(path)); + found = true; + } + } + if found { + return Some(()); + } + } + } + + None + } +} + +impl Default for JdkDiscoverer { + fn default() -> Self { + Self::new() + } +} + +impl AssetDiscoverer for JdkDiscoverer { + fn discover(&self) -> Box + Send + '_> { + Box::new(self.cached_assets.iter().cloned()) + } + + fn name(&self) -> &str { + "JDK Discoverer" + } + + fn source_type(&self) -> &str { + "jdk" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_jdk_discoverer_creation() { + let discoverer = JdkDiscoverer::new(); + // Should not panic, may or may not find JDK depending on environment + let assets: Vec<_> = discoverer.discover().collect(); + println!("Found {} JDK assets", assets.len()); + for asset in &assets { + println!(" - {:?} from {:?}", asset.path, asset.source); + } + } + + #[test] + fn test_collect_sdk_assets_java_11() { + let temp = tempfile::tempdir().unwrap(); + let sdk_path = temp.path().to_path_buf(); + + let modules_path = sdk_path.join("lib/modules"); + std::fs::create_dir_all(modules_path.parent().unwrap()).unwrap(); + std::fs::File::create(&modules_path).unwrap(); + + let mut discoverer = JdkDiscoverer { + cached_assets: Vec::new(), + }; + let result = discoverer.collect_sdk_assets(&sdk_path); + + assert!(result.is_some()); + assert_eq!(discoverer.cached_assets.len(), 1); + assert_eq!(discoverer.cached_assets[0].path, modules_path); + } + + #[test] + fn test_collect_sdk_assets_java_8() { + let temp = tempfile::tempdir().unwrap(); + let sdk_path = temp.path().to_path_buf(); + + let rt_jar_path = sdk_path.join("jre/lib/rt.jar"); + std::fs::create_dir_all(rt_jar_path.parent().unwrap()).unwrap(); + std::fs::File::create(&rt_jar_path).unwrap(); + + let mut discoverer = JdkDiscoverer { + cached_assets: Vec::new(), + }; + let result = discoverer.collect_sdk_assets(&sdk_path); + + assert!(result.is_some()); + assert_eq!(discoverer.cached_assets.len(), 1); + assert_eq!(discoverer.cached_assets[0].path, rt_jar_path); + } +} diff --git a/crates/lang-java/src/discoverer/mod.rs b/crates/lang-java/src/discoverer/mod.rs new file mode 100644 index 0000000..3f34460 --- /dev/null +++ b/crates/lang-java/src/discoverer/mod.rs @@ -0,0 +1,5 @@ +//! Asset discoverers for Java ecosystem. + +mod jdk; + +pub use jdk::JdkDiscoverer; diff --git a/crates/lang-java/src/inference/adapters/graph.rs b/crates/lang-java/src/inference/adapters/graph.rs new file mode 100644 index 0000000..3aa163d --- /dev/null +++ b/crates/lang-java/src/inference/adapters/graph.rs @@ -0,0 +1,577 @@ +//! CodeGraph adapter for JavaTypeSystem. +//! +//! Adapts the CodeGraph to the JavaTypeSystem trait. + +use lasso::Key; +use naviscope_api::models::TypeRef; +use naviscope_api::models::graph::{EdgeType, NodeKind, NodeMetadata}; +use naviscope_api::models::symbol::{FqnId, Symbol}; +use naviscope_plugin::{CodeGraph, Direction}; +use std::collections::HashSet; +use std::sync::Arc; + +use crate::inference::core::types::TypeParameter; +use crate::inference::{InheritanceProvider, MemberProvider, TypeProvider}; +use crate::inference::{MemberInfo, MemberKind, TypeInfo, TypeKind, TypeResolutionContext}; +use crate::model::{JavaIndexMetadata, JavaNodeMetadata}; + +/// Adapter that implements JavaTypeSystem using CodeGraph. +pub struct CodeGraphTypeSystem<'a> { + graph: &'a dyn CodeGraph, +} + +impl<'a> CodeGraphTypeSystem<'a> { + /// Create a new adapter wrapping the given CodeGraph. + pub fn new(graph: &'a dyn CodeGraph) -> Self { + Self { graph } + } + + /// Convert NodeKind to TypeKind. + fn node_kind_to_type_kind(&self, kind: &NodeKind) -> TypeKind { + match kind { + NodeKind::Class => TypeKind::Class, + NodeKind::Interface => TypeKind::Interface, + NodeKind::Enum => TypeKind::Enum, + NodeKind::Annotation => TypeKind::Annotation, + _ => TypeKind::Class, + } + } + + fn resolve_sid(&self, sid: u32) -> Option { + lasso::Spur::try_from_usize(sid as usize) + .map(|spur| self.graph.fqns().resolve_atom(Symbol(spur)).to_string()) + } + + /// Extract modifiers from node metadata. + fn extract_modifiers(&self, metadata: &Arc) -> Vec { + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + return match java_meta { + JavaNodeMetadata::Class { modifiers_sids, .. } + | JavaNodeMetadata::Interface { modifiers_sids, .. } + | JavaNodeMetadata::Enum { modifiers_sids, .. } + | JavaNodeMetadata::Annotation { modifiers_sids } + | JavaNodeMetadata::Method { modifiers_sids, .. } + | JavaNodeMetadata::Field { modifiers_sids, .. } => modifiers_sids + .iter() + .filter_map(|sid| self.resolve_sid(*sid)) + .collect(), + _ => vec![], + }; + } + + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + return match java_meta { + JavaIndexMetadata::Class { modifiers, .. } => modifiers.clone(), + JavaIndexMetadata::Interface { modifiers, .. } => modifiers.clone(), + JavaIndexMetadata::Enum { modifiers, .. } => modifiers.clone(), + JavaIndexMetadata::Method { modifiers, .. } => modifiers.clone(), + JavaIndexMetadata::Field { modifiers, .. } => modifiers.clone(), + _ => vec![], + }; + } + vec![] + } + + fn extract_type_parameters(&self, metadata: &Arc) -> Vec { + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + let names: Vec = match java_meta { + JavaNodeMetadata::Class { + type_parameters_sids, + .. + } + | JavaNodeMetadata::Interface { + type_parameters_sids, + .. + } => type_parameters_sids + .iter() + .filter_map(|sid| self.resolve_sid(*sid)) + .collect(), + _ => vec![], + }; + return names + .into_iter() + .map(|name| TypeParameter { + name, + bounds: vec![], + }) + .collect(); + } + + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + let names: Vec = match java_meta { + JavaIndexMetadata::Class { + type_parameters, .. + } + | JavaIndexMetadata::Interface { + type_parameters, .. + } => type_parameters.clone(), + _ => vec![], + }; + return names + .into_iter() + .map(|name| TypeParameter { + name, + bounds: vec![], + }) + .collect(); + } + + vec![] + } + + /// Extract type ref from metadata. + fn extract_type_from_metadata(&self, metadata: &Arc) -> TypeRef { + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + return match java_meta { + JavaNodeMetadata::Method { return_type, .. } => return_type.clone(), + JavaNodeMetadata::Field { type_ref, .. } => type_ref.clone(), + _ => TypeRef::Id("java.lang.Object".to_string()), + }; + } + + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + return match java_meta { + JavaIndexMetadata::Method { return_type, .. } => return_type.clone(), + JavaIndexMetadata::Field { type_ref, .. } => type_ref.clone(), + _ => TypeRef::Id("java.lang.Object".to_string()), + }; + } + TypeRef::Id("java.lang.Object".to_string()) + } + + /// Render FQN for a node ID. + fn render_fqn_id(&self, node_id: FqnId) -> String { + use crate::naming::JavaNamingConvention; + use naviscope_plugin::NamingConvention; + + // Use Java naming convention to render FQN + JavaNamingConvention::default().render_fqn(node_id, self.graph.fqns()) + } + + /// Extract parameters from metadata. + fn extract_parameters( + &self, + metadata: &Arc, + ) -> Option> { + use crate::inference::ParameterInfo; + + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + match java_meta { + JavaNodeMetadata::Method { parameters, .. } => { + return Some( + parameters + .iter() + .enumerate() + .map(|(i, p)| ParameterInfo { + name: self + .resolve_sid(p.name_sid) + .unwrap_or_else(|| format!("arg{}", i)), + type_ref: p.type_ref.clone(), + is_varargs: p.is_varargs, + }) + .collect(), + ); + } + _ => return None, + } + } + + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + match java_meta { + JavaIndexMetadata::Method { parameters, .. } => { + return Some( + parameters + .iter() + .map(|p| ParameterInfo { + name: p.name.clone(), + type_ref: p.type_ref.clone(), + is_varargs: p.is_varargs, + }) + .collect(), + ); + } + _ => return None, + } + } + None + } +} + +impl<'a> TypeProvider for CodeGraphTypeSystem<'a> { + fn get_type_info(&self, fqn: &str) -> Option { + let node_ids = self.graph.resolve_fqn(fqn); + let node_id = *node_ids.first()?; + let node = self.graph.get_node(node_id)?; + + Some(TypeInfo { + fqn: fqn.to_string(), + kind: self.node_kind_to_type_kind(&node.kind), + modifiers: self.extract_modifiers(&node.metadata), + type_parameters: self.extract_type_parameters(&node.metadata), + }) + } + + fn resolve_type_name(&self, simple_name: &str, ctx: &TypeResolutionContext) -> Option { + // 0. Check known FQNs (Current file types) + for fqn in &ctx.known_fqns { + if fqn.ends_with(&format!(".{}", simple_name)) + || fqn.ends_with(&format!("#{}", simple_name)) + { + return Some(fqn.clone()); + } + } + + // 1. Check explicit imports + for imp in &ctx.imports { + if imp.ends_with(&format!(".{}", simple_name)) { + return Some(imp.clone()); + } + } + + // 2. Check same package + if let Some(pkg) = &ctx.package { + let candidate = format!("{}.{}", pkg, simple_name); + if !self.graph.resolve_fqn(&candidate).is_empty() { + return Some(candidate); + } + } + + // 3. Check wildcard imports + for imp in &ctx.imports { + if imp.ends_with(".*") { + let prefix = &imp[..imp.len() - 2]; + let candidate = format!("{}.{}", prefix, simple_name); + if !self.graph.resolve_fqn(&candidate).is_empty() { + return Some(candidate); + } + } + } + + // 4. Check java.lang + let java_lang = format!("java.lang.{}", simple_name); + if !self.graph.resolve_fqn(&java_lang).is_empty() { + return Some(java_lang); + } + + // 5. Fallback to raw name if it exists in graph (Default Package) + let results = self.graph.resolve_fqn(simple_name); + if !results.is_empty() { + return Some(simple_name.to_string()); + } + + None + } +} + +impl<'a> InheritanceProvider for CodeGraphTypeSystem<'a> { + fn get_superclass(&self, fqn: &str) -> Option { + let node_ids = self.graph.resolve_fqn(fqn); + + for node_id in node_ids { + let neighbors = self.graph.get_neighbors( + node_id, + Direction::Outgoing, + Some(EdgeType::InheritsFrom), + ); + + if let Some(&parent_id) = neighbors.first() { + return Some(self.render_fqn_id(parent_id)); + } + } + + None + } + + fn get_interfaces(&self, fqn: &str) -> Vec { + let node_ids = self.graph.resolve_fqn(fqn); + let mut seen = HashSet::new(); + let mut result = vec![]; + + for node_id in node_ids { + let neighbors = + self.graph + .get_neighbors(node_id, Direction::Outgoing, Some(EdgeType::Implements)); + + for iface_id in neighbors { + let iface_fqn = self.render_fqn_id(iface_id); + if seen.insert(iface_fqn.clone()) { + result.push(iface_fqn); + } + } + } + + result + } + + fn walk_ancestors(&self, fqn: &str) -> Box + '_> { + Box::new(AncestorIterator::new(self, fqn, 10)) + } + + fn get_direct_subtypes(&self, fqn: &str) -> Vec { + let node_ids = self.graph.resolve_fqn(fqn); + let mut seen = HashSet::new(); + let mut result = vec![]; + + for node_id in node_ids { + // Find nodes that inherit from or implement this type (Incoming edges) + let subs = self.graph.get_neighbors( + node_id, + Direction::Incoming, + Some(EdgeType::InheritsFrom), + ); + for sub_id in subs { + let sub_fqn = self.render_fqn_id(sub_id); + if seen.insert(sub_fqn.clone()) { + result.push(sub_fqn); + } + } + + let impls = + self.graph + .get_neighbors(node_id, Direction::Incoming, Some(EdgeType::Implements)); + for sub_id in impls { + let sub_fqn = self.render_fqn_id(sub_id); + if seen.insert(sub_fqn.clone()) { + result.push(sub_fqn); + } + } + } + + result + } + + fn walk_descendants(&self, fqn: &str) -> Box + '_> { + Box::new(DescendantIterator::new(self, fqn, 10)) + } +} + +impl<'a> MemberProvider for CodeGraphTypeSystem<'a> { + fn get_members(&self, type_fqn: &str, member_name: &str) -> Vec { + // With signature-based FQNs, methods are stored as e.g. `A#target(int)`. + // We can't construct the full member FQN from just the simple name, so + // we traverse the type's children and match by simple name. + // Normalize: callers may pass either `leaf` or `leaf()` as the member name. + let needle = crate::naming::extract_simple_name(member_name); + let node_ids = self.graph.resolve_fqn(type_fqn); + let mut members = Vec::new(); + + for &type_node_id in &node_ids { + let children = self.graph.get_neighbors( + type_node_id, + Direction::Outgoing, + Some(EdgeType::Contains), + ); + + for child_id in children { + let Some(node) = self.graph.get_node(child_id) else { + continue; + }; + + let kind = match &node.kind { + NodeKind::Method => MemberKind::Method, + NodeKind::Field => MemberKind::Field, + NodeKind::Constructor => MemberKind::Constructor, + _ => continue, + }; + + let child_fqn = self.render_fqn_id(child_id); + // Extract the member part (after `#`) and strip signature to compare + let raw_member = crate::naming::extract_member_name(&child_fqn) + .unwrap_or_else(|| self.graph.fqns().resolve_atom(node.name)); + let simple = crate::naming::extract_simple_name(raw_member); + + if simple != needle { + continue; + } + + let type_ref = self.extract_type_from_metadata(&node.metadata); + + members.push(MemberInfo { + name: raw_member.to_string(), + fqn: child_fqn, + kind, + declaring_type: type_fqn.to_string(), + type_ref, + parameters: self.extract_parameters(&node.metadata), + modifiers: self.extract_modifiers(&node.metadata), + generic_signature: None, + }); + } + } + + members + } + + fn get_all_members(&self, type_fqn: &str) -> Vec { + let node_ids = self.graph.resolve_fqn(type_fqn); + let mut members = Vec::new(); + + if let Some(&node_id) = node_ids.first() { + let children = + self.graph + .get_neighbors(node_id, Direction::Outgoing, Some(EdgeType::Contains)); + + for child_id in children { + if let Some(node) = self.graph.get_node(child_id) { + let kind = match &node.kind { + NodeKind::Method => MemberKind::Method, + NodeKind::Field => MemberKind::Field, + NodeKind::Constructor => MemberKind::Constructor, + _ => continue, + }; + + let child_fqn = self.render_fqn_id(child_id); + // Extract member name using unified convention + // Members always use '#' separator, so this should always succeed + let name = crate::naming::extract_member_name(&child_fqn) + .unwrap_or_else(|| self.graph.fqns().resolve_atom(node.name)) + .to_string(); + + let type_ref = self.extract_type_from_metadata(&node.metadata); + + members.push(MemberInfo { + name, + fqn: child_fqn, + kind, + declaring_type: type_fqn.to_string(), + type_ref, + parameters: self.extract_parameters(&node.metadata), + modifiers: self.extract_modifiers(&node.metadata), + generic_signature: None, + }); + } + } + } + members + } +} + +/// Iterator over ancestor types (BFS). +struct AncestorIterator<'a> { + provider: &'a CodeGraphTypeSystem<'a>, + queue: std::collections::VecDeque, + visited: std::collections::HashSet, + max_depth: usize, + current_depth: usize, +} + +impl<'a> AncestorIterator<'a> { + fn new(provider: &'a CodeGraphTypeSystem<'a>, start: &str, max_depth: usize) -> Self { + let mut queue = std::collections::VecDeque::new(); + let mut visited = std::collections::HashSet::new(); + + // Start with direct parents + if let Some(super_class) = provider.get_superclass(start) { + queue.push_back(super_class); + } + for iface in provider.get_interfaces(start) { + queue.push_back(iface); + } + + visited.insert(start.to_string()); + + Self { + provider, + queue, + visited, + max_depth, + current_depth: 0, + } + } +} + +impl<'a> Iterator for AncestorIterator<'a> { + type Item = String; + + fn next(&mut self) -> Option { + if self.current_depth >= self.max_depth { + return None; + } + + while let Some(fqn) = self.queue.pop_front() { + if self.visited.contains(&fqn) { + continue; + } + + self.visited.insert(fqn.clone()); + self.current_depth += 1; + + // Add parents of this type + if let Some(super_class) = self.provider.get_superclass(&fqn) { + if !self.visited.contains(&super_class) { + self.queue.push_back(super_class); + } + } + for iface in self.provider.get_interfaces(&fqn) { + if !self.visited.contains(&iface) { + self.queue.push_back(iface); + } + } + + return Some(fqn); + } + + None + } +} + +/// Iterator over descendant types (BFS). +struct DescendantIterator<'a> { + provider: &'a CodeGraphTypeSystem<'a>, + queue: std::collections::VecDeque, + visited: std::collections::HashSet, + max_depth: usize, + current_depth: usize, +} + +impl<'a> DescendantIterator<'a> { + fn new(provider: &'a CodeGraphTypeSystem<'a>, start: &str, max_depth: usize) -> Self { + let mut queue = std::collections::VecDeque::new(); + let mut visited = std::collections::HashSet::new(); + + // Start with direct children + for sub in provider.get_direct_subtypes(start) { + queue.push_back(sub); + } + + visited.insert(start.to_string()); + + Self { + provider, + queue, + visited, + max_depth, + current_depth: 0, + } + } +} + +impl<'a> Iterator for DescendantIterator<'a> { + type Item = String; + + fn next(&mut self) -> Option { + if self.current_depth >= self.max_depth { + return None; + } + + while let Some(fqn) = self.queue.pop_front() { + if self.visited.contains(&fqn) { + continue; + } + + self.visited.insert(fqn.clone()); + self.current_depth += 1; + + // Add children of this type + for sub in self.provider.get_direct_subtypes(&fqn) { + if !self.visited.contains(&sub) { + self.queue.push_back(sub); + } + } + + return Some(fqn); + } + + None + } +} diff --git a/crates/lang-java/src/inference/adapters/heuristic.rs b/crates/lang-java/src/inference/adapters/heuristic.rs new file mode 100644 index 0000000..3252a1a --- /dev/null +++ b/crates/lang-java/src/inference/adapters/heuristic.rs @@ -0,0 +1,138 @@ +use crate::inference::{ + InheritanceProvider, MemberInfo, MemberProvider, TypeInfo, TypeProvider, TypeResolutionContext, +}; + +/// A TypeProvider that uses heuristics to resolve type names without a backing graph. +/// +/// This is primarily used during indexing when the graph is incomplete, +/// allowing for optimistic resolution of FQNs based on conventions and imports. +pub struct HeuristicAdapter; + +impl TypeProvider for HeuristicAdapter { + fn get_type_info(&self, _fqn: &str) -> Option { + None + } + + fn resolve_type_name(&self, type_name: &str, ctx: &TypeResolutionContext) -> 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 !first_part.is_empty() && first_part.chars().next().unwrap_or(' ').is_lowercase() { + return Some(type_name.to_string()); + } + + if let Some(p) = &ctx.package { + if first_part == p { + return Some(type_name.to_string()); + } + } + + if let Some(first_fqn) = self.resolve_type_name(first_part, ctx) { + if first_fqn != first_part { + 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 &ctx.imports { + if imp.ends_with(&format!(".{}", type_name)) { + return Some(imp.clone()); + } + } + + // 4. 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)); + } + + // 5. Current package + if let Some(p) = &ctx.package { + 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()) + } +} + +impl InheritanceProvider for HeuristicAdapter { + fn get_superclass(&self, _fqn: &str) -> Option { + None + } + + fn get_interfaces(&self, _fqn: &str) -> Vec { + vec![] + } + + fn walk_ancestors(&self, _fqn: &str) -> Box + '_> { + Box::new(std::iter::empty()) + } + + fn get_direct_subtypes(&self, _fqn: &str) -> Vec { + vec![] + } + + fn walk_descendants(&self, _fqn: &str) -> Box + '_> { + Box::new(std::iter::empty()) + } +} + +impl MemberProvider for HeuristicAdapter { + fn get_members(&self, _type_fqn: &str, _member_name: &str) -> Vec { + vec![] + } + + fn get_all_members(&self, _type_fqn: &str) -> Vec { + vec![] + } +} diff --git a/crates/lang-java/src/inference/adapters/mod.rs b/crates/lang-java/src/inference/adapters/mod.rs new file mode 100644 index 0000000..1fb15ee --- /dev/null +++ b/crates/lang-java/src/inference/adapters/mod.rs @@ -0,0 +1,9 @@ +//! Adapters that implement JavaTypeSystem for various data sources. + +mod graph; +mod heuristic; +mod noop; + +pub use graph::CodeGraphTypeSystem; +pub use heuristic::HeuristicAdapter; +pub use noop::NoOpTypeSystem; diff --git a/crates/lang-java/src/inference/adapters/noop.rs b/crates/lang-java/src/inference/adapters/noop.rs new file mode 100644 index 0000000..41353d2 --- /dev/null +++ b/crates/lang-java/src/inference/adapters/noop.rs @@ -0,0 +1,48 @@ +use crate::inference::core::type_system::{InheritanceProvider, MemberProvider, TypeProvider}; +use crate::inference::core::types::{MemberInfo, TypeInfo, TypeResolutionContext}; + +/// A no-op implementation of JavaTypeSystem. +/// Useful for testing or when global index is unavailable. +pub struct NoOpTypeSystem; + +impl TypeProvider for NoOpTypeSystem { + fn resolve_type_name(&self, _name: &str, _ctx: &TypeResolutionContext) -> Option { + None + } + + fn get_type_info(&self, _fqn: &str) -> Option { + None + } +} + +impl InheritanceProvider for NoOpTypeSystem { + fn get_superclass(&self, _fqn: &str) -> Option { + None + } + + fn get_interfaces(&self, _fqn: &str) -> Vec { + vec![] + } + + fn walk_ancestors(&self, _fqn: &str) -> Box + '_> { + Box::new(std::iter::empty()) + } + + fn get_direct_subtypes(&self, _fqn: &str) -> Vec { + vec![] + } + + fn walk_descendants(&self, _fqn: &str) -> Box + '_> { + Box::new(std::iter::empty()) + } +} + +impl MemberProvider for NoOpTypeSystem { + fn get_members(&self, _type_fqn: &str, _member_name: &str) -> Vec { + vec![] + } + + fn get_all_members(&self, _type_fqn: &str) -> Vec { + vec![] + } +} diff --git a/crates/lang-java/src/inference/chain.rs b/crates/lang-java/src/inference/chain.rs new file mode 100644 index 0000000..9c69f45 --- /dev/null +++ b/crates/lang-java/src/inference/chain.rs @@ -0,0 +1,174 @@ +//! Chain resolution for method chaining. +//! +//! Uses functional unfold pattern to resolve chains like: +//! `response.getContext().get("key")` + +use crate::inference::strategy::infer_expression; +use crate::inference::{InferContext, TypeRefExt}; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Result of resolving a chain. +#[derive(Debug, Clone)] +pub struct ChainResolution { + /// The final member FQN (e.g., "SessionContext#get") + pub member_fqn: Option, + /// The result type of the chain + pub result_type: TypeRef, +} + +impl ChainResolution { + /// Create from just a type (for simple expressions) + pub fn from_type(ty: TypeRef) -> Self { + Self { + member_fqn: None, + result_type: ty, + } + } + + /// Create with both member and type + pub fn with_member(member_fqn: String, result_type: TypeRef) -> Self { + Self { + member_fqn: Some(member_fqn), + result_type, + } + } +} + +/// A step in the chain resolution process. +#[allow(dead_code)] // WithReceiver variant is part of incomplete chain resolution +enum ChainStep<'a> { + /// Initial node to resolve + Initial(&'a Node<'a>), + /// Have a receiver type, resolving member + WithReceiver { + receiver_type: TypeRef, + node: &'a Node<'a>, + }, + /// Final resolution + Resolved(ChainResolution), +} + +impl<'a> ChainStep<'a> { + /// Get the resolution if this is the final state. + fn resolution(&self) -> Option { + match self { + ChainStep::Resolved(r) => Some(r.clone()), + _ => None, + } + } + + /// Advance to the next step. + fn next(&self, ctx: &InferContext) -> Option> { + match self { + ChainStep::Initial(node) => { + // Infer type of initial expression + let ty = infer_expression(node, ctx)?; + + // Check if there's a parent chain node + if let Some(parent) = node.parent() { + if is_chain_parent(parent.kind()) { + // Store reference to parent - this is tricky with lifetimes + // For now, resolve immediately + return Some(ChainStep::Resolved(ChainResolution::from_type(ty))); + } + } + + Some(ChainStep::Resolved(ChainResolution::from_type(ty))) + } + + ChainStep::WithReceiver { + receiver_type, + node, + } => { + // Get member name from the node + let member_name = extract_member_name(node, ctx)?; + let type_fqn = receiver_type.as_fqn()?; + + // Find member in hierarchy + let members = ctx.ts.find_member_in_hierarchy(&type_fqn, &member_name); + let member = members.first()?; + + // Check for more chain + if let Some(parent) = node.parent() { + if is_chain_parent(parent.kind()) { + // Continue chain - simplified for now + return Some(ChainStep::Resolved(ChainResolution::with_member( + member.fqn.clone(), + member.type_ref.clone(), + ))); + } + } + + Some(ChainStep::Resolved(ChainResolution::with_member( + member.fqn.clone(), + member.type_ref.clone(), + ))) + } + + ChainStep::Resolved(_) => None, // Terminal state + } + } +} + +/// Check if a node kind is a chain parent (method_invocation, field_access). +fn is_chain_parent(kind: &str) -> bool { + matches!(kind, "method_invocation" | "field_access") +} + +/// Extract member name from a chain node. +fn extract_member_name(node: &Node, ctx: &InferContext) -> Option { + let name_node = match node.kind() { + "method_invocation" => node.child_by_field_name("name"), + "field_access" => node.child_by_field_name("field"), + _ => None, + }?; + + name_node + .utf8_text(ctx.source.as_bytes()) + .ok() + .map(|s| s.to_string()) +} + +/// Resolve a chain of method calls / field accesses. +/// +/// Uses `std::iter::successors` for functional unfold. +/// +/// # Example +/// +/// For `response.getContext().get("key")`: +/// 1. Resolve `response` → HttpResponseMessage +/// 2. Find `getContext` in HttpResponseMessage hierarchy → SessionContext +/// 3. Find `get` in SessionContext hierarchy → Object +pub fn resolve_chain<'a>( + initial: &'a Node<'a>, + ctx: &'a InferContext<'a>, +) -> Option { + const MAX_DEPTH: usize = 20; + + // Use successors to unfold the chain + let steps: Vec<_> = + std::iter::successors(Some(ChainStep::Initial(initial)), |step| step.next(ctx)) + .take(MAX_DEPTH) + .collect(); + + // Return the last resolved step + steps.into_iter().rev().find_map(|s| s.resolution()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chain_resolution_from_type() { + let res = ChainResolution::from_type(TypeRef::Id("String".into())); + assert!(res.member_fqn.is_none()); + } + + #[test] + fn test_chain_resolution_with_member() { + let res = ChainResolution::with_member("List#get".into(), TypeRef::Id("Object".into())); + assert_eq!(res.member_fqn, Some("List#get".into())); + } +} diff --git a/crates/lang-java/src/inference/context.rs b/crates/lang-java/src/inference/context.rs new file mode 100644 index 0000000..56ef986 --- /dev/null +++ b/crates/lang-java/src/inference/context.rs @@ -0,0 +1,117 @@ +//! Context for type inference. +//! +//! Holds the state passed through the inference strategy chain. + +use crate::inference::core::type_system::JavaTypeSystem; +use crate::inference::core::types::TypeResolutionContext; +use crate::inference::scope::ScopeManager; +use naviscope_api::models::TypeRef; + +/// Context for type inference +/// +/// This is passed through the inference chain. It is immutable; +/// updates create new contexts. +#[derive(Clone)] +pub struct InferContext<'a> { + /// Source code being analyzed + pub source: &'a str, + /// Type system for lookups + pub ts: &'a dyn JavaTypeSystem, + /// Current package + pub package: Option, + /// Imports in the current file + pub imports: Vec, + /// Enclosing class FQN (for `this` resolution) + pub enclosing_class: Option, + /// Type parameters in scope + pub type_parameters: Vec, + /// Expected type for bidirectional inference (check mode) + pub expected_type: Option, + /// Optional Scope Manager for optimized lookup + pub scope_manager: Option<&'a ScopeManager>, + /// Types defined in the current file + pub known_fqns: Vec, +} + +impl<'a> InferContext<'a> { + /// Create a new inference context + pub fn new(source: &'a str, ts: &'a dyn JavaTypeSystem) -> Self { + Self { + source, + ts, + package: None, + imports: Vec::new(), + enclosing_class: None, + type_parameters: Vec::new(), + expected_type: None, + scope_manager: None, + known_fqns: Vec::new(), + } + } + + /// Create a context with package and imports + pub fn with_file_context( + source: &'a str, + ts: &'a dyn JavaTypeSystem, + package: Option, + imports: Vec, + ) -> Self { + Self { + source, + ts, + package, + imports, + enclosing_class: None, + type_parameters: Vec::new(), + expected_type: None, + scope_manager: None, + known_fqns: Vec::new(), + } + } + + /// Set the enclosing class + pub fn with_enclosing_class(mut self, class: String) -> Self { + self.enclosing_class = Some(class); + self + } + + /// Set imports + pub fn with_imports(mut self, imports: Vec) -> Self { + self.imports = imports; + self + } + + /// Set package + pub fn with_package(mut self, package: Option) -> Self { + self.package = package; + self + } + + /// Set known FQNs + pub fn with_known_fqns(mut self, fqns: Vec) -> Self { + self.known_fqns = fqns; + self + } + + /// Set expected type for checking mode + pub fn with_expected_type(mut self, expected: Option) -> Self { + self.expected_type = expected; + self + } + + /// Set scope manager + pub fn with_scope_manager(mut self, manager: &'a ScopeManager) -> Self { + self.scope_manager = Some(manager); + self + } + + /// Convert to TypeResolutionContext + pub fn to_resolution_context(&self) -> TypeResolutionContext { + TypeResolutionContext { + package: self.package.clone(), + imports: self.imports.clone(), + type_parameters: self.type_parameters.clone(), + known_fqns: self.known_fqns.clone(), + } + } +} diff --git a/crates/lang-java/src/inference/core/mod.rs b/crates/lang-java/src/inference/core/mod.rs new file mode 100644 index 0000000..df923e1 --- /dev/null +++ b/crates/lang-java/src/inference/core/mod.rs @@ -0,0 +1,5 @@ +pub mod normalization; +pub mod subtyping; +pub mod type_system; +pub mod types; +pub mod unification; diff --git a/crates/lang-java/src/inference/core/normalization.rs b/crates/lang-java/src/inference/core/normalization.rs new file mode 100644 index 0000000..1ea718c --- /dev/null +++ b/crates/lang-java/src/inference/core/normalization.rs @@ -0,0 +1,56 @@ +//! Type normalization logic. +//! +//! Converts raw/string types into structured, fully-qualified TypeRefs. + +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; + +/// Normalize a TypeRef using the given context. +/// +/// This resolves simple names to FQNs and handles generics. +pub fn normalize_type(ty: TypeRef, ctx: &InferContext) -> TypeRef { + match ty { + TypeRef::Raw(s) => { + // Try to parse and resolve + // For now, simple FQN resolution + if let Some(fqn) = ctx.ts.resolve_type_name(&s, &ctx.to_resolution_context()) { + TypeRef::Id(fqn) + } else { + // If not resolved, keep as Raw or assume it's FQN if contains dot? + if s.contains('.') { + TypeRef::Id(s) + } else { + TypeRef::Raw(s) + } + } + } + TypeRef::Id(id) => { + // Ensure ID is fully qualified if possible + if !id.contains('.') { + if let Some(fqn) = ctx.ts.resolve_type_name(&id, &ctx.to_resolution_context()) { + TypeRef::Id(fqn) + } else { + TypeRef::Id(id) + } + } else { + TypeRef::Id(id) + } + } + TypeRef::Generic { base, args } => { + let base = Box::new(normalize_type(*base, ctx)); + let args = args + .into_iter() + .map(|arg| normalize_type(arg, ctx)) + .collect(); + TypeRef::Generic { base, args } + } + TypeRef::Array { + element, + dimensions, + } => TypeRef::Array { + element: Box::new(normalize_type(*element, ctx)), + dimensions, + }, + _ => ty, + } +} diff --git a/crates/lang-java/src/inference/core/subtyping.rs b/crates/lang-java/src/inference/core/subtyping.rs new file mode 100644 index 0000000..c355365 --- /dev/null +++ b/crates/lang-java/src/inference/core/subtyping.rs @@ -0,0 +1,136 @@ +//! Subtyping rules implementation. +//! +//! Determines if one type is a subtype of another. +//! Supports: +//! - Identity +//! - Primitives (widening) +//! - Classes (extends) +//! - Interfaces (implements) +//! - Arrays (covariant) + +use crate::inference::core::type_system::JavaTypeSystem; +use naviscope_api::models::TypeRef; + +/// Check if `sub` is a subtype of `super_type`. +pub fn is_subtype(sub: &TypeRef, super_type: &TypeRef, ts: &T) -> bool { + // 1. Reflexivity + if sub == super_type { + return true; + } + + // 2. java.lang.Object is supertype of all reference types + if let TypeRef::Id(id) = super_type { + if id == "java.lang.Object" { + // Primitives are not Objects (unless autoboxed, but strict subtyping usually separates them) + // For now, assume strict subtyping for checking + return !matches!(sub, TypeRef::Raw(_)); + } + } + + match (sub, super_type) { + // Primitive widening + (TypeRef::Raw(s1), TypeRef::Raw(s2)) => is_primitive_subtype(s1, s2), + + // Primitive boxing + reference widening, e.g. int -> Integer / Number / Object + (TypeRef::Raw(prim), TypeRef::Id(super_id)) => { + // Allow relaxed matching for bad parser output (Id("int")) + if prim == super_id { + return true; + } + if let Some(wrapper) = primitive_to_wrapper(prim) { + if wrapper == super_id { + return true; + } + return is_class_subtype(wrapper, super_id, ts); + } + false + } + + // Wrapper unboxing (+ primitive widening), e.g. Integer -> int / long + (TypeRef::Id(sub_id), TypeRef::Raw(super_prim)) => { + if sub_id == super_prim { + return true; + } + if let Some(unboxed) = wrapper_to_primitive(sub_id) { + return unboxed == super_prim || is_primitive_subtype(unboxed, super_prim); + } + false + } + + // Class/Interface hierarchy + (TypeRef::Id(sub_id), TypeRef::Id(super_id)) => { + if sub_id == super_id { + return true; + } + if sub_id == &format!("java.lang.{}", super_id) { + return true; + } + is_class_subtype(sub_id, super_id, ts) + } + + // Arrays (Covariant for references) + (TypeRef::Array { element: e1, .. }, TypeRef::Array { element: e2, .. }) => { + is_subtype(e1, e2, ts) + } + + // TODO: Generics (Invariant? Covariant with wildcards?) + // For now, simple equality on generics was caught by step 1 + _ => false, + } +} + +fn primitive_to_wrapper(primitive: &str) -> Option<&'static str> { + match primitive { + "byte" => Some("java.lang.Byte"), + "short" => Some("java.lang.Short"), + "char" => Some("java.lang.Character"), + "int" => Some("java.lang.Integer"), + "long" => Some("java.lang.Long"), + "float" => Some("java.lang.Float"), + "double" => Some("java.lang.Double"), + "boolean" => Some("java.lang.Boolean"), + _ => None, + } +} + +fn wrapper_to_primitive(wrapper: &str) -> Option<&'static str> { + match wrapper { + "java.lang.Byte" => Some("byte"), + "java.lang.Short" => Some("short"), + "java.lang.Character" => Some("char"), + "java.lang.Integer" => Some("int"), + "java.lang.Long" => Some("long"), + "java.lang.Float" => Some("float"), + "java.lang.Double" => Some("double"), + "java.lang.Boolean" => Some("boolean"), + _ => None, + } +} + +fn is_primitive_subtype(sub: &str, sup: &str) -> bool { + match sub { + "byte" => matches!(sup, "short" | "int" | "long" | "float" | "double"), + "short" => matches!(sup, "int" | "long" | "float" | "double"), + "char" => matches!(sup, "int" | "long" | "float" | "double"), + "int" => matches!(sup, "long" | "float" | "double"), + "long" => matches!(sup, "float" | "double"), + "float" => matches!(sup, "double"), + _ => false, + } +} + +fn is_class_subtype(sub_fqn: &str, super_fqn: &str, ts: &T) -> bool { + if sub_fqn == super_fqn { + return true; + } + + // BFS search up the hierarchy + // JavaTypeSystem::walk_ancestors returns an iterator of all ancestors + for ancestor in ts.walk_ancestors(sub_fqn) { + if ancestor == super_fqn { + return true; + } + } + + false +} diff --git a/crates/lang-java/src/inference/core/type_system.rs b/crates/lang-java/src/inference/core/type_system.rs new file mode 100644 index 0000000..4e6357b --- /dev/null +++ b/crates/lang-java/src/inference/core/type_system.rs @@ -0,0 +1,338 @@ +//! Core trait definitions for the type system abstraction. +//! +//! These traits abstract away the data source, allowing the inference +//! engine to work with CodeGraph, stubs, or mock implementations. + +use super::types::{MemberInfo, ParameterInfo, TypeInfo, TypeResolutionContext}; + +/// Provides type information by FQN. +/// +/// This is the primary way to look up type metadata. +pub trait TypeProvider: Send + Sync { + /// Get type info for a fully qualified name. + /// + /// Returns `None` if the type is not found. + fn get_type_info(&self, fqn: &str) -> Option; + + /// Resolve a simple type name to its FQN. + /// + /// Uses the provided context (imports, package) to resolve the name. + fn resolve_type_name( + &self, + simple_name: &str, + context: &TypeResolutionContext, + ) -> Option; +} + +/// Provides inheritance relationship information. +/// +/// This is used to traverse the type hierarchy when looking for inherited members. +pub trait InheritanceProvider: Send + Sync { + /// Get the direct superclass of a type. + /// + /// Returns `None` for `java.lang.Object` or interfaces. + fn get_superclass(&self, fqn: &str) -> Option; + + /// Get the interfaces directly implemented by a type. + fn get_interfaces(&self, fqn: &str) -> Vec; + + /// Walk all ancestor types (superclasses and interfaces). + /// + /// The iterator yields types in BFS order, stopping at max depth. + fn walk_ancestors(&self, fqn: &str) -> Box + '_>; + + /// Get all direct subtypes (classes that extend or implement this type). + fn get_direct_subtypes(&self, fqn: &str) -> Vec; + + /// Walk all descendant types (subclasses and sub-interfaces). + fn walk_descendants(&self, fqn: &str) -> Box + '_>; +} + +/// Provides member (field/method) lookup. +/// +/// This is used to find members within a single type (not walking inheritance). +pub trait MemberProvider: Send + Sync { + /// Find all members directly declared in the given type with the matching name. + /// + /// Does NOT search the inheritance hierarchy. + fn get_members(&self, type_fqn: &str, member_name: &str) -> Vec; + + /// Get all members directly declared in the given type. + fn get_all_members(&self, type_fqn: &str) -> Vec; +} + +use naviscope_api::models::TypeRef; + +/// The combined type system interface. +/// +/// Provides a unified facade for type inference operations. +/// Includes a default implementation for hierarchy search. +pub trait JavaTypeSystem: TypeProvider + InheritanceProvider + MemberProvider { + /// Find a member in the type hierarchy. + /// + /// Searches the type itself first, then walks ancestors. + fn find_member_in_hierarchy(&self, type_fqn: &str, member_name: &str) -> Vec { + // Check the type itself first + let mut members = self.get_members(type_fqn, member_name); + if !members.is_empty() { + return members; + } + + // Walk ancestors + for ancestor in self.walk_ancestors(type_fqn) { + members = self.get_members(&ancestor, member_name); + if !members.is_empty() { + return members; + } + } + + vec![] + } + + /// Resolve the best matching method among candidates based on argument types. + /// + /// Implements basic Java overload resolution rules. + fn resolve_method( + &self, + candidates: &[MemberInfo], + arg_types: &[TypeRef], + ) -> Option { + if candidates.is_empty() { + return None; + } + + // 1. Precise match (exact signatures) + let exact_fixed = collect_matching_candidates(candidates, |params| { + matches_fixed_arity(params, arg_types, |arg, expected| arg == expected) + }); + if !exact_fixed.is_empty() { + return select_most_specific(self, exact_fixed, arg_types); + } + + // 2. Subtype match (widening) + let subtype_fixed = collect_matching_candidates(candidates, |params| { + matches_fixed_arity(params, arg_types, |arg, expected| { + self.is_subtype(arg, expected) + }) + }); + if !subtype_fixed.is_empty() { + return select_most_specific(self, subtype_fixed, arg_types); + } + + // 3. Varargs exact match + let exact_varargs = collect_matching_candidates(candidates, |params| { + matches_varargs_arity(params, arg_types, |arg, expected| arg == expected) + }); + if !exact_varargs.is_empty() { + return select_most_specific(self, exact_varargs, arg_types); + } + + // 4. Varargs subtype match + let subtype_varargs = collect_matching_candidates(candidates, |params| { + matches_varargs_arity(params, arg_types, |arg, expected| { + self.is_subtype(arg, expected) + }) + }); + if !subtype_varargs.is_empty() { + return select_most_specific(self, subtype_varargs, arg_types); + } + + // Strict mode: if no overload matches exactly or via subtype, + // we return None. No fallback to arbitrary candidate. + None + } + + /// Check if sub is a subtype of super_type. + /// + /// This is a basic implementation that can be overridden by more sophisticated logic. + /// Check if sub is a subtype of super_type. + /// + /// Delegates to `subtyping::is_subtype` logic. + fn is_subtype(&self, sub: &TypeRef, super_type: &TypeRef) -> bool { + crate::inference::core::subtyping::is_subtype(sub, super_type, self) + } +} + +// Blanket implementation: any type implementing all three traits gets JavaTypeSystem +impl JavaTypeSystem for T {} + +fn matches_fixed_arity(params: &[ParameterInfo], arg_types: &[TypeRef], mut matches: F) -> bool +where + F: FnMut(&TypeRef, &TypeRef) -> bool, +{ + if params.len() != arg_types.len() { + return false; + } + + params + .iter() + .zip(arg_types.iter()) + .all(|(p, a)| matches(a, &p.type_ref)) +} + +fn matches_varargs_arity(params: &[ParameterInfo], arg_types: &[TypeRef], mut matches: F) -> bool +where + F: FnMut(&TypeRef, &TypeRef) -> bool, +{ + let Some(last_param) = params.last() else { + return false; + }; + + if !is_varargs_parameter(last_param) { + return false; + } + + let TypeRef::Array { element, .. } = &last_param.type_ref else { + return false; + }; + + let fixed_count = params.len() - 1; + if arg_types.len() < fixed_count { + return false; + } + + // Prefix arguments (before varargs tail) + if !params[..fixed_count] + .iter() + .zip(arg_types[..fixed_count].iter()) + .all(|(p, a)| matches(a, &p.type_ref)) + { + return false; + } + + // No varargs arguments provided + if arg_types.len() == fixed_count { + return true; + } + + // Direct array pass-through: foo(String[]) called with one String[] argument. + if arg_types.len() == params.len() && matches(&arg_types[fixed_count], &last_param.type_ref) { + return true; + } + + // Expanded varargs: foo(String...) called with N String arguments. + arg_types[fixed_count..] + .iter() + .all(|a| matches(a, element.as_ref())) +} + +fn is_varargs_parameter(param: &ParameterInfo) -> bool { + param.is_varargs +} + +fn collect_matching_candidates(candidates: &[MemberInfo], mut matches: F) -> Vec +where + F: FnMut(&[ParameterInfo]) -> bool, +{ + candidates + .iter() + .filter_map(|cand| { + let params = cand.parameters.as_ref()?; + if matches(params) { + Some(cand.clone()) + } else { + None + } + }) + .collect() +} + +fn select_most_specific( + ts: &T, + candidates: Vec, + arg_types: &[TypeRef], +) -> Option { + if candidates.is_empty() { + return None; + } + if candidates.len() == 1 { + return candidates.into_iter().next(); + } + + let mut best_idx = 0usize; + let mut best_score = i32::MIN; + + for (i, cand) in candidates.iter().enumerate() { + let mut score = 0i32; + for (j, other) in candidates.iter().enumerate() { + if i == j { + continue; + } + let cand_more_specific = is_more_specific_than(ts, cand, other, arg_types); + let other_more_specific = is_more_specific_than(ts, other, cand, arg_types); + if cand_more_specific && !other_more_specific { + score += 1; + } else if other_more_specific && !cand_more_specific { + score -= 1; + } + } + + if score > best_score { + best_score = score; + best_idx = i; + } + } + + candidates.get(best_idx).cloned() +} + +fn is_more_specific_than( + ts: &T, + left: &MemberInfo, + right: &MemberInfo, + arg_types: &[TypeRef], +) -> bool { + let Some(left_types) = effective_param_types(left, arg_types.len()) else { + return false; + }; + let Some(right_types) = effective_param_types(right, arg_types.len()) else { + return false; + }; + if left_types.len() != right_types.len() { + return false; + } + + let mut strict = false; + for (l, r) in left_types.iter().zip(right_types.iter()) { + if l == r { + continue; + } + if ts.is_subtype(l, r) { + strict = true; + } else { + return false; + } + } + strict +} + +fn effective_param_types(member: &MemberInfo, arg_count: usize) -> Option> { + let params = member.parameters.as_ref()?; + let Some(last) = params.last() else { + return Some(vec![]); + }; + + if !is_varargs_parameter(last) { + if params.len() == arg_count { + return Some(params.iter().map(|p| p.type_ref.clone()).collect()); + } + return None; + } + + let TypeRef::Array { element, .. } = &last.type_ref else { + return None; + }; + let fixed_count = params.len() - 1; + if arg_count < fixed_count { + return None; + } + + let mut types = Vec::with_capacity(arg_count); + for p in ¶ms[..fixed_count] { + types.push(p.type_ref.clone()); + } + for _ in fixed_count..arg_count { + types.push(element.as_ref().clone()); + } + Some(types) +} diff --git a/crates/lang-java/src/inference/core/types.rs b/crates/lang-java/src/inference/core/types.rs new file mode 100644 index 0000000..447ceb2 --- /dev/null +++ b/crates/lang-java/src/inference/core/types.rs @@ -0,0 +1,105 @@ +//! Data structures for the type inference system. +//! +//! These are pure data types with no behavior logic. + +use naviscope_api::models::TypeRef; + +/// Helper trait for TypeRef operations +pub trait TypeRefExt { + fn as_fqn(&self) -> Option; +} + +impl TypeRefExt for TypeRef { + fn as_fqn(&self) -> Option { + match self { + TypeRef::Id(fqn) => Some(fqn.clone()), + TypeRef::Generic { base, .. } => base.as_fqn(), + _ => None, + } + } +} + +/// Information about a type (class, interface, enum, etc.) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TypeInfo { + /// Fully qualified name, e.g., "java.util.List" + pub fqn: String, + /// Kind of type + pub kind: TypeKind, + /// Modifiers like public, abstract, final + pub modifiers: Vec, + /// Generic type parameters, e.g., `>` + pub type_parameters: Vec, +} + +/// Kind of type +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TypeKind { + Class, + Interface, + Enum, + Annotation, + Primitive, +} + +/// A generic type parameter declaration +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TypeParameter { + /// Parameter name, e.g., "T" + pub name: String, + /// Upper bounds, e.g., ["Comparable", "Serializable"] for `T extends Comparable & Serializable` + pub bounds: Vec, +} + +/// Information about a member (field, method, constructor) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MemberInfo { + /// Simple name, e.g., "get" or "size" + pub name: String, + /// Fully qualified name, e.g., "java.util.List#get" + pub fqn: String, + /// Kind of member + pub kind: MemberKind, + /// The type that declares this member (may differ from lookup type due to inheritance) + pub declaring_type: String, + /// Field type or method return type + pub type_ref: TypeRef, + /// Method parameters (None for fields) + pub parameters: Option>, + /// Modifiers like public, static, final + pub modifiers: Vec, + /// Raw generic signature from bytecode, if available + pub generic_signature: Option, +} + +/// Kind of member +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemberKind { + Field, + Method, + Constructor, +} + +/// Information about a method parameter +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParameterInfo { + /// Parameter name (may be synthetic like "arg0") + pub name: String, + /// Parameter type + pub type_ref: TypeRef, + /// True when this parameter is declared with `...` varargs syntax. + pub is_varargs: bool, +} + +/// Context for type name resolution +#[derive(Debug, Clone, Default)] +pub struct TypeResolutionContext { + /// Current package, e.g., "com.example" + pub package: Option, + /// Import statements in the file + pub imports: Vec, + /// Type parameters in scope (for generic methods/classes) + pub type_parameters: Vec, + /// Types defined in the current file (FQN list) + pub known_fqns: Vec, +} diff --git a/crates/lang-java/src/inference/core/unification.rs b/crates/lang-java/src/inference/core/unification.rs new file mode 100644 index 0000000..5b2a90f --- /dev/null +++ b/crates/lang-java/src/inference/core/unification.rs @@ -0,0 +1,119 @@ +//! Type unification and substitution. +//! +//! Handles generic type parameter resolution and constraints solving. + +use naviscope_api::models::TypeRef; +use std::collections::HashMap; + +/// A map from type variables to concrete types. +#[derive(Debug, Default, Clone)] +pub struct Substitution { + map: HashMap, +} + +impl Substitution { + /// Create a new empty substitution. + pub fn new() -> Self { + Self::default() + } + + /// Add a mapping. + pub fn insert(&mut self, var: String, ty: TypeRef) { + self.map.insert(var, ty); + } + + /// Apply this substitution to a type. + pub fn apply(&self, ty: &TypeRef) -> TypeRef { + match ty { + TypeRef::Id(name) => { + if let Some(sub) = self.map.get(name) { + sub.clone() + } else { + ty.clone() + } + } + TypeRef::Array { + element, + dimensions, + } => TypeRef::Array { + element: Box::new(self.apply(element)), + dimensions: *dimensions, + }, + TypeRef::Generic { base, args } => TypeRef::Generic { + base: Box::new(self.apply(base)), + args: args.iter().map(|arg| self.apply(arg)).collect(), + }, + TypeRef::Wildcard { + bound, + is_upper_bound, + } => TypeRef::Wildcard { + bound: bound.as_ref().map(|b| Box::new(self.apply(b))), + is_upper_bound: *is_upper_bound, + }, + // Primitives and others remain unchanged + _ => ty.clone(), + } + } +} + +/// Attempt to unify two types, producing a substitution that makes them equal. +/// +/// Note: Very basic implementation. Does not handle deep recursive constraints well yet. +pub fn unify(t1: &TypeRef, t2: &TypeRef) -> Option { + let mut subst = Substitution::new(); + if unify_internal(t1, t2, &mut subst) { + Some(subst) + } else { + None + } +} + +fn unify_internal(t1: &TypeRef, t2: &TypeRef, subst: &mut Substitution) -> bool { + // 1. Resolve current substitutions + let t1_resolved = subst.apply(t1); + let t2_resolved = subst.apply(t2); + + if t1_resolved == t2_resolved { + return true; + } + + match (t1_resolved, t2_resolved) { + // Variable binding (very simplified: treating any single ID as potential var if not FQN) + // In reality, we need to know which IDs are "type variables" vs "concrete types". + // For now, assume single-letter IDs might be variables (heuristic). + (TypeRef::Id(v), other) if is_type_variable(&v) => { + // Occurs check omitted for simplicity + subst.insert(v, other); + true + } + (other, TypeRef::Id(v)) if is_type_variable(&v) => { + subst.insert(v, other); + true + } + + // Structural unification + (TypeRef::Array { element: e1, .. }, TypeRef::Array { element: e2, .. }) => { + unify_internal(&e1, &e2, subst) + } + (TypeRef::Generic { base: b1, args: a1 }, TypeRef::Generic { base: b2, args: a2 }) => { + if !unify_internal(&b1, &b2, subst) { + return false; + } + if a1.len() != a2.len() { + return false; + } + for (arg1, arg2) in a1.iter().zip(a2.iter()) { + if !unify_internal(arg1, arg2, subst) { + return false; + } + } + true + } + _ => false, + } +} + +fn is_type_variable(s: &str) -> bool { + // Heuristic: Single uppercase letter is likely a type var (T, E, K, V) + s.len() == 1 && s.chars().next().unwrap().is_uppercase() +} diff --git a/crates/lang-java/src/inference/mod.rs b/crates/lang-java/src/inference/mod.rs new file mode 100644 index 0000000..2dcd4b1 --- /dev/null +++ b/crates/lang-java/src/inference/mod.rs @@ -0,0 +1,86 @@ +//! Java Type Inference System +//! +//! This module provides a functional-style type inference engine for Java. +//! +//! # Architecture +//! +//! ```text +//! InferStrategy (trait) → combines via or_else(), map() +//! │ +//! ▼ +//! InferContext (immutable) → passed through inference chain +//! │ +//! ▼ +//! JavaTypeSystem (trait) → provides type/member lookup +//! ``` +//! +//! # Key Traits +//! +//! - [`TypeProvider`] - Resolves FQN to type information +//! - [`InheritanceProvider`] - Walks supertype hierarchy +//! - [`MemberProvider`] - Finds members in types +//! - [`JavaTypeSystem`] - Combines all three +//! +//! # Usage +//! +//! ```ignore +//! use naviscope_java::inference::{InferContext, infer_expression}; +//! +//! let ctx = InferContext::new(source, &type_system); +//! let result = infer_expression(&node, &ctx); +//! ``` + +pub mod adapters; +mod chain; +pub mod context; +pub mod core; +pub mod scope; +pub mod strategy; + +// Re-export public API +pub use core::type_system::{InheritanceProvider, JavaTypeSystem, MemberProvider, TypeProvider}; + +pub use core::types::{ + MemberInfo, MemberKind, ParameterInfo, TypeInfo, TypeKind, TypeRefExt, TypeResolutionContext, +}; + +pub use context::InferContext; + +pub use chain::{ChainResolution, resolve_chain}; +pub use strategy::{InferStrategy, infer_expression}; + +use crate::inference::scope::{ScopeBuilder, ScopeManager}; + +/// Creates a new `InferContext` that is fully populated with scope information. +/// +/// This helper: +/// 1. Creates a `ScopeManager`. +/// 2. Iterates over the entire source file (AST root) to build scopes. +/// 3. Returns an `InferContext` ready for type inference. +pub fn create_inference_context<'a>( + root: &tree_sitter::Node, + source: &'a str, + ts: &'a dyn JavaTypeSystem, + scope_manager: &'a mut ScopeManager, + package: Option, + imports: Vec, +) -> InferContext<'a> { + let mut ctx = InferContext::new(source, ts) + .with_package(package) + .with_imports(imports); + + // 1. Pre-scan scopes + { + let mut builder = ScopeBuilder::new(&ctx, scope_manager); + builder.build(root); + } // builder dropped, releasing mutable borrow + + // 2. Extract known FQNs + let known_fqns = scope_manager.get_all_class_fqns(); + ctx.known_fqns = known_fqns; + + // 3. Attach populated scope manager to context + ctx.scope_manager = Some(scope_manager); // Downgrade to shared reference + + ctx +} diff --git a/crates/lang-java/src/inference/scope/builder.rs b/crates/lang-java/src/inference/scope/builder.rs new file mode 100644 index 0000000..18c8d14 --- /dev/null +++ b/crates/lang-java/src/inference/scope/builder.rs @@ -0,0 +1,280 @@ +//! Scope Builder implementation. +//! +//! Walks the AST to populate the ScopeManager with variable declarations. + +use super::ScopeManager; +use super::manager::ScopeKind; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use naviscope_plugin::utils::range_from_ts; +use tree_sitter::{Node, TreeCursor}; + +/// Builds the scope tree and symbol table for a method or block. +pub struct ScopeBuilder<'a, 'b> { + ctx: &'a InferContext<'b>, + manager: &'a mut ScopeManager, +} + +impl<'a, 'b> ScopeBuilder<'a, 'b> { + pub fn new(ctx: &'a InferContext<'b>, manager: &'a mut ScopeManager) -> Self { + Self { ctx, manager } + } + + /// Build scopes for the given root node (e.g., method_declaration) + pub fn build(&mut self, root: &Node) { + let mut cursor = root.walk(); + self.visit_node(root, &mut cursor, None, None); + } + + fn visit_node( + &mut self, + node: &Node, + cursor: &mut TreeCursor, + parent_scope: Option, + fqn_prefix: Option, + ) { + let mut current_scope = parent_scope; + let mut next_fqn_prefix = fqn_prefix.clone(); + + // Check if this node creates a new scope + if is_scope_creator(node.kind()) { + let kind = self.determine_scope_kind(node, &fqn_prefix); + + // If it is a class scope, update the prefix for children + if let ScopeKind::Class(ref fqn) = kind { + next_fqn_prefix = Some(fqn.clone()); + } + + // Register new scope + // Use node.id() as the scope ID/key + let scope_id = self.manager.register_scope(node.id(), parent_scope, kind); + current_scope = Some(scope_id); + + // Register parameters if this is a method or lambda or catch + self.register_parameters(node, scope_id); + } + + // Check if this node declares a variable in the current scope + if let Some(scope_id) = current_scope { + self.register_variable_declarations(node, scope_id); + } + + // Recurse children + if node.child_count() > 0 { + if cursor.goto_first_child() { + loop { + let child = cursor.node(); + self.visit_node(&child, cursor, current_scope, next_fqn_prefix.clone()); + if !cursor.goto_next_sibling() { + break; + } + } + cursor.goto_parent(); + } + } + } + + fn determine_scope_kind(&self, node: &Node, prefix: &Option) -> ScopeKind { + match node.kind() { + "class_declaration" + | "interface_declaration" + | "enum_declaration" + | "annotation_type_declaration" => { + let name = node + .child_by_field_name("name") + .and_then(|n| n.utf8_text(self.ctx.source.as_bytes()).ok()) + .unwrap_or("Anonymous"); + + let fqn = if let Some(p) = prefix { + format!("{}.{}", p, name) + } else if let Some(pkg) = &self.ctx.package { + format!("{}.{}", pkg, name) + } else { + name.to_string() + }; + + ScopeKind::Class(fqn) + } + "method_declaration" | "constructor_declaration" => ScopeKind::Method, + _ => ScopeKind::Local, + } + } + + fn register_parameters(&mut self, node: &Node, scope_id: usize) { + match node.kind() { + "method_declaration" | "constructor_declaration" => { + if let Some(params) = node.child_by_field_name("parameters") { + self.process_parameter_list(¶ms, scope_id); + } + } + "lambda_expression" => { + if let Some(params) = node.child_by_field_name("parameters") { + // Start simple: explicit typed params + // Inferred params need type inference (circular dependency if we use InferStrategy here?) + // For now, we only register fully typed params or basic names + self.process_parameter_list(¶ms, scope_id); + } + } + "catch_clause" => { + if let Some(param) = node.child_by_field_name("parameter") { + self.process_single_parameter(¶m, scope_id); + } + } + "enhanced_for_statement" => { + // for (Type name : iterable) + if let (Some(ty_node), Some(name_node)) = ( + node.child_by_field_name("type"), + node.child_by_field_name("name"), + ) { + if let Some(ty) = self.parse_type(&ty_node) { + if let Ok(name) = name_node.utf8_text(self.ctx.source.as_bytes()) { + let range = range_from_ts(name_node.range()); + self.manager + .add_symbol(scope_id, name.to_string(), ty, range); + } + } + } + } + _ => {} + } + } + + fn process_parameter_list(&mut self, params_node: &Node, scope_id: usize) { + // Handle single parameter lambda: 'x -> ...' where params_node is the identifier + if params_node.kind() == "identifier" { + if let Ok(name) = params_node.utf8_text(self.ctx.source.as_bytes()) { + let range = range_from_ts(params_node.range()); + self.manager + .add_symbol(scope_id, name.to_string(), TypeRef::Unknown, range); + } + return; + } + + let mut cursor = params_node.walk(); + for child in params_node.children(&mut cursor) { + match child.kind() { + "formal_parameter" | "spread_parameter" => { + self.process_single_parameter(&child, scope_id); + } + "inferred_parameters" => { + // Recurse to handle (x, y) -> ... + self.process_parameter_list(&child, scope_id); + } + "identifier" => { + // This case is for children of inferred_parameters if we are inside one, + // or if structure allows. + if let Ok(name) = child.utf8_text(self.ctx.source.as_bytes()) { + let range = range_from_ts(child.range()); + self.manager.add_symbol( + scope_id, + name.to_string(), + TypeRef::Unknown, + range, + ); + } + } + _ => {} + } + } + } + + fn process_single_parameter(&mut self, param: &Node, scope_id: usize) { + let name_node = param.child_by_field_name("name"); + let type_node = param.child_by_field_name("type"); + + if let (Some(name_node), Some(type_node)) = (name_node, type_node) { + if let Ok(name) = name_node.utf8_text(self.ctx.source.as_bytes()) { + if let Some(ty) = self.parse_type(&type_node) { + let range = range_from_ts(name_node.range()); + self.manager + .add_symbol(scope_id, name.to_string(), ty, range); + } + } + } + } + + fn register_variable_declarations(&mut self, node: &Node, scope_id: usize) { + if node.kind() == "local_variable_declaration" { + // Type + let mut ty: Option = None; + + // First child that is not modifier is usually type + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() != "modifiers" && child.kind() != "variable_declarator" { + ty = self.parse_type(&child); + break; + } + } + + if let Some(valid_type) = ty { + // Declarators + for child in node.children(&mut cursor) { + if child.kind() == "variable_declarator" { + if let Some(name_node) = child.child_by_field_name("name") { + if let Ok(name) = name_node.utf8_text(self.ctx.source.as_bytes()) { + let range = range_from_ts(name_node.range()); + self.manager.add_symbol( + scope_id, + name.to_string(), + valid_type.clone(), + range, + ); + } + } + } + } + } else if let Some(_var_node) = node.children(&mut cursor).find(|c| { + c.kind() == "var" || c.utf8_text(self.ctx.source.as_bytes()).unwrap_or("") == "var" + }) { + // Handle 'var' inference + // This is tricky: we are building scope to help inference, but 'var' needs inference. + // We can try to infer the initializer. + // let mut cursor = node.walk(); // Cannot reuse cursor because it's borrowed by children iterator? + // Actually `node.children(&mut cursor)` borrows cursor. + // We need to finish the previous iteration or use a new cursor. + // Since `node.children` consumes the borrow for the loop, we can't reuse it easily if we broke out? + // But here we are reusing `cursor` from line 141. + + // Let's just create a new cursor for the var inference part to avoid complexity + let mut var_cursor = node.walk(); + for child in node.children(&mut var_cursor) { + if child.kind() == "variable_declarator" { + if let (Some(_name_node), Some(_value_node)) = ( + child.child_by_field_name("name"), + child.child_by_field_name("value"), + ) { + // TODO: Recursively call inference on value? + } + } + } + } + } + } + + fn parse_type(&self, node: &Node) -> Option { + use crate::inference::core::normalization::normalize_type; + use crate::inference::strategy::local::parse_type_node; + + let raw_type = parse_type_node(node, self.ctx)?; + Some(normalize_type(raw_type, self.ctx)) + } +} + +fn is_scope_creator(kind: &str) -> bool { + matches!( + kind, + "class_declaration" + | "interface_declaration" + | "enum_declaration" + | "annotation_type_declaration" + | "method_declaration" + | "constructor_declaration" + | "block" + | "lambda_expression" + | "catch_clause" + | "enhanced_for_statement" + | "for_statement" + | "try_with_resources_statement" + ) +} diff --git a/crates/lang-java/src/inference/scope/manager.rs b/crates/lang-java/src/inference/scope/manager.rs new file mode 100644 index 0000000..92ac2d8 --- /dev/null +++ b/crates/lang-java/src/inference/scope/manager.rs @@ -0,0 +1,144 @@ +//! Scope manager for handling variable scopes. + +use super::table::SymbolTable; +use naviscope_api::models::TypeRef; +use std::collections::HashMap; + +/// Identifier for a scope. +type ScopeId = usize; + +/// Kind of scope +#[derive(Debug, Clone, PartialEq)] +pub enum ScopeKind { + Local, + Method, + Class(String), // FQN +} + +/// A scope containing symbols and a reference to its parent. +#[derive(Debug, Clone)] +pub struct Scope { + pub id: ScopeId, + pub parent_id: Option, + pub symbols: SymbolTable, + pub kind: ScopeKind, +} + +/// Manages scopes and symbol tables for a file. +#[derive(Debug, Default, Clone)] +pub struct ScopeManager { + scopes: HashMap, + /// Map from AST node ID to the Scope ID it corresponds to. + /// Only scope-creating nodes (e.g. Block, MethodDeclaration) are keys here. + node_to_scope: HashMap, +} + +impl ScopeManager { + /// Create a new scope manager. + pub fn new() -> Self { + Self::default() + } + + /// Register a new scope for a given node. + pub fn register_scope( + &mut self, + node_id: usize, + parent_node_id: Option, + kind: ScopeKind, + ) -> ScopeId { + let parent_scope_id = parent_node_id.and_then(|pid| self.node_to_scope.get(&pid).copied()); + + // Use node_id as scope_id for simplicity, assuming they are unique from tree-sitter + let scope_id = node_id; + + let scope = Scope { + id: scope_id, + parent_id: parent_scope_id, + symbols: SymbolTable::new(), + kind, + }; + + self.scopes.insert(scope_id, scope); + self.node_to_scope.insert(node_id, scope_id); + + scope_id + } + + /// Add a symbol to a scope. + pub fn add_symbol( + &mut self, + scope_node_id: usize, + name: String, + ty: TypeRef, + range: naviscope_api::models::symbol::Range, + ) { + if let Some(scope) = self.scopes.get_mut(&scope_node_id) { + scope.symbols.insert(name, ty, range); + } + } + + /// Look up a symbol starting from a specific scope and walking up. + /// Returns the normalized TypeRef. + pub fn lookup(&self, start_scope_id: usize, name: &str) -> Option { + self.lookup_symbol(start_scope_id, name) + .map(|si| si.type_ref) + } + + /// Look up a symbol and its declaration range. + pub fn lookup_symbol( + &self, + start_scope_id: usize, + name: &str, + ) -> Option { + let mut current_scope_id = Some(start_scope_id); + + while let Some(scope_id) = current_scope_id { + if let Some(scope) = self.scopes.get(&scope_id) { + if let Some(info) = scope.symbols.get(name) { + return Some(info.clone()); + } + current_scope_id = scope.parent_id; + } else { + break; + } + } + + None + } + + /// Find the enclosing class FQN for a given scope. + pub fn find_enclosing_class(&self, start_scope_id: usize) -> Option { + let mut current_scope_id = Some(start_scope_id); + + while let Some(scope_id) = current_scope_id { + if let Some(scope) = self.scopes.get(&scope_id) { + if let ScopeKind::Class(fqn) = &scope.kind { + return Some(fqn.clone()); + } + current_scope_id = scope.parent_id; + } else { + break; + } + } + None + } + + /// Get scope ID for a node if it exists + pub fn get_scope_id(&self, node_id: usize) -> Option { + self.node_to_scope.get(&node_id).copied() + } + + /// Get all class FQNs found in this file + pub fn get_all_class_fqns(&self) -> Vec { + self.scopes + .values() + .filter_map(|s| { + if let ScopeKind::Class(fqn) = &s.kind { + Some(fqn.clone()) + } else { + None + } + }) + .collect() + } +} diff --git a/crates/lang-java/src/inference/scope/mod.rs b/crates/lang-java/src/inference/scope/mod.rs new file mode 100644 index 0000000..6c7d32d --- /dev/null +++ b/crates/lang-java/src/inference/scope/mod.rs @@ -0,0 +1,7 @@ +pub mod builder; +pub mod manager; +pub mod table; + +pub use builder::ScopeBuilder; +pub use manager::ScopeManager; +pub use table::SymbolTable; diff --git a/crates/lang-java/src/inference/scope/table.rs b/crates/lang-java/src/inference/scope/table.rs new file mode 100644 index 0000000..113ea44 --- /dev/null +++ b/crates/lang-java/src/inference/scope/table.rs @@ -0,0 +1,40 @@ +//! Symbol table for a single scope. + +use naviscope_api::models::TypeRef; +use naviscope_api::models::symbol::Range; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct SymbolInfo { + pub type_ref: TypeRef, + pub range: Range, +} + +/// A symbol table mapping variable names to their types and ranges. +#[derive(Debug, Default, Clone)] +pub struct SymbolTable { + symbols: HashMap, +} + +impl SymbolTable { + /// Create a new empty symbol table. + pub fn new() -> Self { + Self::default() + } + + /// Insert a variable into the symbol table. + pub fn insert(&mut self, name: String, ty: TypeRef, range: Range) { + self.symbols.insert( + name, + SymbolInfo { + type_ref: ty, + range, + }, + ); + } + + /// Look up a variable in this scope. + pub fn get(&self, name: &str) -> Option<&SymbolInfo> { + self.symbols.get(name) + } +} diff --git a/crates/lang-java/src/inference/strategy/combinator.rs b/crates/lang-java/src/inference/strategy/combinator.rs new file mode 100644 index 0000000..e97ecf8 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/combinator.rs @@ -0,0 +1,88 @@ +//! Combinator implementations for InferStrategy. + +use super::InferStrategy; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Or-else combinator: try first, then second if first returns None. +pub struct OrElse { + first: A, + second: B, +} + +impl OrElse { + pub fn new(first: A, second: B) -> Self { + Self { first, second } + } +} + +impl InferStrategy for OrElse { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + self.first + .infer(node, ctx) + .or_else(|| self.second.infer(node, ctx)) + } +} + +// Implement Send + Sync if components are +unsafe impl Send for OrElse {} +unsafe impl Sync for OrElse {} + +/// Map combinator: transform the result. +pub struct Map { + strategy: S, + f: F, +} + +impl Map { + pub fn new(strategy: S, f: F) -> Self { + Self { strategy, f } + } +} + +impl TypeRef + Send + Sync> InferStrategy for Map { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + self.strategy.infer(node, ctx).map(&self.f) + } +} + +/// Cached combinator: memoize results. +/// +/// Note: This is a placeholder. Full caching would need thread-safe storage. +pub struct Cached { + strategy: S, + // TODO: Add actual cache with HashMap> +} + +impl Cached { + pub fn new(strategy: S) -> Self { + Self { strategy } + } +} + +impl InferStrategy for Cached { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + // TODO: Check cache first, then delegate + self.strategy.infer(node, ctx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct ReturnInt; + impl InferStrategy for ReturnInt { + fn infer(&self, _: &Node, _: &InferContext) -> Option { + Some(TypeRef::Raw("int".into())) + } + } + + #[test] + fn test_map_transforms_result() { + let mapped = Map::new(ReturnInt, |_: TypeRef| TypeRef::Raw("long".into())); + // Structure test - actual inference needs a Node + let _ = mapped; + } +} diff --git a/crates/lang-java/src/inference/strategy/field.rs b/crates/lang-java/src/inference/strategy/field.rs new file mode 100644 index 0000000..1eeff70 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/field.rs @@ -0,0 +1,99 @@ +//! Field access inference. + +use super::{InferStrategy, infer_expression}; +use crate::inference::core::unification::Substitution; +use crate::inference::InferContext; +use crate::inference::TypeRefExt; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Infer type from field access expression (obj.field). +pub struct FieldAccessInfer; + +impl InferStrategy for FieldAccessInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + let member = self.resolve_member(node, ctx)?; + Some(member.type_ref.clone()) + } +} + +impl FieldAccessInfer { + /// Resolve the field access to its member definition. + pub fn infer_member(&self, node: &Node, ctx: &InferContext) -> Option { + let member = self.resolve_member(node, ctx)?; + Some(member.fqn.clone()) + } + + fn resolve_member( + &self, + node: &Node, + ctx: &InferContext, + ) -> Option { + let field_node = if node.kind() == "field_access" { + *node + } else if let Some(parent) = node.parent() { + if parent.kind() == "field_access" && parent.child_by_field_name("field") == Some(*node) + { + parent + } else { + return None; + } + } else { + return None; + }; + + // Get the receiver (object before the dot) + let receiver = field_node.child_by_field_name("object")?; + + // Get the field name + let name_node = field_node.child_by_field_name("field")?; + let field_name = name_node.utf8_text(ctx.source.as_bytes()).ok()?; + + // Infer the receiver type (recursive) + let receiver_type = infer_expression(&receiver, ctx)?; + + // Get the FQN from the receiver type + let type_fqn = receiver_type.as_fqn()?; + + // Look up the field in the type hierarchy + let members = ctx.ts.find_member_in_hierarchy(&type_fqn, field_name); + let mut member = members.first().cloned()?; + + self.apply_receiver_substitution(&mut member, &receiver_type, ctx); + Some(member) + } + + fn apply_receiver_substitution( + &self, + member: &mut crate::inference::MemberInfo, + receiver_type: &TypeRef, + ctx: &InferContext, + ) { + let (base_fqn, receiver_args) = match receiver_type { + TypeRef::Generic { base, args } => { + let Some(base_fqn) = base.as_fqn() else { + return; + }; + (base_fqn, args) + } + _ => return, + }; + + let Some(type_info) = ctx.ts.get_type_info(&base_fqn) else { + return; + }; + + if type_info.type_parameters.is_empty() + || type_info.type_parameters.len() != receiver_args.len() + { + return; + } + + let mut subst = Substitution::new(); + for (param, arg) in type_info.type_parameters.iter().zip(receiver_args.iter()) { + subst.insert(param.name.clone(), arg.clone()); + } + + member.type_ref = subst.apply(&member.type_ref); + } +} diff --git a/crates/lang-java/src/inference/strategy/lambda.rs b/crates/lang-java/src/inference/strategy/lambda.rs new file mode 100644 index 0000000..ae0e61e --- /dev/null +++ b/crates/lang-java/src/inference/strategy/lambda.rs @@ -0,0 +1,40 @@ +//! Lambda expression type inference. +//! +//! Requires checking mode (bidirectional inference) as lambdas don't have intrinsic types. + +use crate::inference::{InferContext, InferStrategy, TypeKind, TypeRefExt}; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Strategy to infer types of lambda expressions. +pub struct LambdaInfer; + +impl InferStrategy for LambdaInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + // Lambdas do not possess a standalone type; they require a target type. + // If we are in synthesis mode (infer), we check if the context provides an expected type. + if let Some(expected) = &ctx.expected_type { + return self.check(node, expected, ctx); + } + None + } + + fn check(&self, node: &Node, expected: &TypeRef, ctx: &InferContext) -> Option { + if node.kind() != "lambda_expression" { + return None; + } + + let expected_fqn = expected.as_fqn()?; + + let type_info = ctx.ts.get_type_info(&expected_fqn)?; + + // Basic check: is it an interface? + // This is a heuristic. A proper check would verify functional interface status. + if type_info.kind == TypeKind::Interface { + // Return the expected type as the inferred type of the lambda + Some(expected.clone()) + } else { + None + } + } +} diff --git a/crates/lang-java/src/inference/strategy/literal.rs b/crates/lang-java/src/inference/strategy/literal.rs new file mode 100644 index 0000000..1db09e4 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/literal.rs @@ -0,0 +1,39 @@ +use super::InferStrategy; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +pub struct LiteralInfer; + +impl InferStrategy for LiteralInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + let kind = node.kind(); + + match kind { + "decimal_integer_literal" + | "hex_integer_literal" + | "octal_integer_literal" + | "binary_integer_literal" => { + if let Ok(text) = node.utf8_text(ctx.source.as_bytes()) { + if text.ends_with('L') || text.ends_with('l') { + return Some(TypeRef::Raw("long".to_string())); + } + } + Some(TypeRef::Raw("int".to_string())) + } + "decimal_floating_point_literal" | "hex_floating_point_literal" => { + if let Ok(text) = node.utf8_text(ctx.source.as_bytes()) { + if text.ends_with('f') || text.ends_with('F') { + return Some(TypeRef::Raw("float".to_string())); + } + } + Some(TypeRef::Raw("double".to_string())) + } + "true" | "false" => Some(TypeRef::Raw("boolean".to_string())), + "character_literal" => Some(TypeRef::Raw("char".to_string())), + "string_literal" => Some(TypeRef::Id("java.lang.String".to_string())), + "null_literal" => Some(TypeRef::Unknown), // Null is compatible with any ref type, handled by assignment check usually + _ => None, + } + } +} diff --git a/crates/lang-java/src/inference/strategy/local.rs b/crates/lang-java/src/inference/strategy/local.rs new file mode 100644 index 0000000..abefca2 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/local.rs @@ -0,0 +1,356 @@ +//! Local variable type inference. + +use super::InferStrategy; +use crate::inference::core::unification::Substitution; +use crate::inference::InferContext; +use crate::inference::TypeRefExt; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Infer type from local variable declaration. +pub struct LocalVarInfer; + +impl InferStrategy for LocalVarInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + // Only works for identifier nodes + if node.kind() != "identifier" { + return None; + } + + let name = node.utf8_text(ctx.source.as_bytes()).ok()?; + + // Optimize: If ScopeManager is available, rely on it exclusively. + // We do NOT fallback to AST walking if lookup fails, because if a ScopeManager + // is provided, it is expected to be complete. Fallback would only hide bugs. + // + // NOTE: If scope_manager is None, we now return None. + // This forces integrators to use ScopeManager for local variable inference. + if let Some(sm) = ctx.scope_manager { + // Find the nearest scope-owning ancestor + let mut current = *node; + while let Some(parent) = current.parent() { + // Check if this parent node owns a scope + if sm.get_scope_id(parent.id()).is_some() { + // Delegate to ScopeManager to lookup variable starting from this scope + // The lookup method will automatically traverse up the scope chain + if let Some(ty) = sm.lookup(parent.id(), name) { + if ty != TypeRef::Unknown { + return Some(ty); + } + } + return self.infer_lambda_parameter_type(node, name, ctx); + } + current = parent; + } + return None; + } + + None + } +} + +impl LocalVarInfer { + fn infer_lambda_parameter_type( + &self, + node: &Node, + name: &str, + ctx: &InferContext, + ) -> Option { + let (lambda, lambda_param_index) = self.find_enclosing_lambda_param(node, name, ctx)?; + let (call_node, arg_index) = self.find_lambda_call_site(&lambda)?; + let expected_arg_type = + self.resolve_expected_argument_type(&call_node, &lambda, arg_index, ctx)?; + self.extract_lambda_parameter_type(&expected_arg_type, lambda_param_index, ctx) + } + + fn find_enclosing_lambda_param<'a>( + &self, + node: &Node<'a>, + name: &str, + ctx: &InferContext, + ) -> Option<(Node<'a>, usize)> { + let mut current = *node; + while let Some(parent) = current.parent() { + if parent.kind() == "lambda_expression" { + let params = parent.child_by_field_name("parameters")?; + let names = collect_lambda_parameter_names(¶ms, ctx.source); + if let Some(index) = names.iter().position(|n| n == name) { + return Some((parent, index)); + } + } + current = parent; + } + None + } + + fn find_lambda_call_site<'a>(&self, lambda: &Node<'a>) -> Option<(Node<'a>, usize)> { + let args_node = lambda.parent()?; + if args_node.kind() != "argument_list" { + return None; + } + let call_node = args_node.parent()?; + if call_node.kind() != "method_invocation" { + return None; + } + + let mut arg_index = None; + let mut idx = 0usize; + let mut cursor = args_node.walk(); + for child in args_node.children(&mut cursor) { + if !child.is_named() { + continue; + } + if child == *lambda { + arg_index = Some(idx); + break; + } + idx += 1; + } + + Some((call_node, arg_index?)) + } + + fn resolve_expected_argument_type( + &self, + call_node: &Node<'_>, + lambda_node: &Node<'_>, + arg_index: usize, + ctx: &InferContext, + ) -> Option { + let method_name_node = call_node.child_by_field_name("name")?; + let method_name = method_name_node.utf8_text(ctx.source.as_bytes()).ok()?; + + let receiver_type = if let Some(receiver) = call_node.child_by_field_name("object") { + super::infer_expression(&receiver, ctx)? + } else { + TypeRef::Id(ctx.enclosing_class.clone()?) + }; + let receiver_fqn = receiver_type.as_fqn()?; + + let candidates = ctx.ts.find_member_in_hierarchy(&receiver_fqn, method_name); + if candidates.is_empty() { + // Heuristic fallback when external SDK methods are not indexed. + // Example: List.forEach(it -> it.hello()) => infer lambda param as A. + if method_name == "forEach" { + if let TypeRef::Generic { args, .. } = &receiver_type { + if let Some(first_arg) = args.first() { + return Some(TypeRef::Generic { + base: Box::new(TypeRef::Id( + "java.util.function.Consumer".to_string(), + )), + args: vec![first_arg.clone()], + }); + } + } + } + return None; + } + let candidates = self.apply_receiver_substitution(candidates, &receiver_type, ctx); + + let args_node = call_node.child_by_field_name("arguments")?; + let mut arg_types = Vec::new(); + let mut cursor = args_node.walk(); + for child in args_node.children(&mut cursor) { + if !child.is_named() { + continue; + } + if child == *lambda_node { + arg_types.push(TypeRef::Unknown); + } else if let Some(t) = super::infer_expression(&child, ctx) { + arg_types.push(t); + } else { + arg_types.push(TypeRef::Unknown); + } + } + + let resolved = ctx.ts.resolve_method(&candidates, &arg_types)?; + let params = resolved.parameters?; + params.get(arg_index).map(|p| p.type_ref.clone()) + } + + fn apply_receiver_substitution( + &self, + candidates: Vec, + receiver_type: &TypeRef, + ctx: &InferContext, + ) -> Vec { + let (base_fqn, receiver_args) = match receiver_type { + TypeRef::Generic { base, args } => { + let Some(base_fqn) = base.as_fqn() else { + return candidates; + }; + (base_fqn, args) + } + _ => return candidates, + }; + + let Some(type_info) = ctx.ts.get_type_info(&base_fqn) else { + return candidates; + }; + if type_info.type_parameters.len() != receiver_args.len() { + return candidates; + } + + let mut subst = Substitution::new(); + for (param, arg) in type_info.type_parameters.iter().zip(receiver_args.iter()) { + subst.insert(param.name.clone(), arg.clone()); + } + + candidates + .into_iter() + .map(|mut member| { + member.type_ref = subst.apply(&member.type_ref); + if let Some(params) = &mut member.parameters { + for p in params { + p.type_ref = subst.apply(&p.type_ref); + } + } + member + }) + .collect() + } + + fn extract_lambda_parameter_type( + &self, + expected_arg_type: &TypeRef, + lambda_param_index: usize, + ctx: &InferContext, + ) -> Option { + if let TypeRef::Generic { base, args } = expected_arg_type { + if lambda_param_index < args.len() { + return Some(unwrap_wildcard(args[lambda_param_index].clone())); + } + + if let Some(base_fqn) = base.as_fqn() { + for method_name in ["accept", "apply", "test", "run", "call", "get"] { + let members = ctx.ts.find_member_in_hierarchy(&base_fqn, method_name); + for member in members { + if let Some(params) = member.parameters { + if let Some(param) = params.get(lambda_param_index) { + return Some(unwrap_wildcard(param.type_ref.clone())); + } + } + } + } + } + } + + if let TypeRef::Wildcard { bound: Some(b), .. } = expected_arg_type { + return Some((**b).clone()); + } + + None + } +} + +fn unwrap_wildcard(ty: TypeRef) -> TypeRef { + match ty { + TypeRef::Wildcard { bound: Some(b), .. } => *b, + other => other, + } +} + +fn collect_lambda_parameter_names(params_node: &Node, source: &str) -> Vec { + if params_node.kind() == "identifier" { + return params_node + .utf8_text(source.as_bytes()) + .ok() + .map(|s| vec![s.to_string()]) + .unwrap_or_default(); + } + + let mut names = Vec::new(); + let mut cursor = params_node.walk(); + for child in params_node.children(&mut cursor) { + match child.kind() { + "formal_parameter" | "spread_parameter" => { + if let Some(name_node) = child.child_by_field_name("name") { + if let Ok(name) = name_node.utf8_text(source.as_bytes()) { + names.push(name.to_string()); + } + } + } + "inferred_parameters" => { + names.extend(collect_lambda_parameter_names(&child, source)); + } + "identifier" => { + if let Ok(name) = child.utf8_text(source.as_bytes()) { + names.push(name.to_string()); + } + } + _ => {} + } + } + names +} + +/// Parse a type node into TypeRef. +pub fn parse_type_node(node: &Node, ctx: &InferContext) -> Option { + let kind = node.kind(); + + match kind { + // Primitive types + "integral_type" | "floating_point_type" | "boolean_type" | "void_type" => { + let text = node.utf8_text(ctx.source.as_bytes()).ok()?; + Some(TypeRef::Raw(text.to_string())) + } + // Simple type identifier + "type_identifier" => { + let name = node.utf8_text(ctx.source.as_bytes()).ok()?; + // Try to resolve to FQN + let fqn = ctx + .ts + .resolve_type_name(name, &ctx.to_resolution_context()) + .unwrap_or_else(|| name.to_string()); + Some(TypeRef::Id(fqn)) + } + // Scoped type like java.util.List + "scoped_type_identifier" => { + let text = node.utf8_text(ctx.source.as_bytes()).ok()?; + Some(TypeRef::Id(text.replace(" ", ""))) + } + // Generic type like List + "generic_type" => { + let base_node = node.child_by_field_name("type").or_else(|| node.child(0))?; + let base = parse_type_node(&base_node, ctx)?; + + let mut args = Vec::new(); + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() != "type_arguments" { + continue; + } + + let mut args_cursor = child.walk(); + for arg in child.children(&mut args_cursor) { + if !arg.is_named() { + continue; + } + if let Some(parsed) = parse_type_node(&arg, ctx) { + args.push(parsed); + } + } + } + + Some(TypeRef::Generic { + base: Box::new(base), + args, + }) + } + // Array type + "array_type" => { + let element = node.child_by_field_name("element")?; + let element_type = parse_type_node(&element, ctx)?; + Some(TypeRef::Array { + element: Box::new(element_type), + dimensions: 1, // TODO: count dimensions properly + }) + } + _ => { + // Unknown type node, try raw text + node.utf8_text(ctx.source.as_bytes()) + .ok() + .map(|s| TypeRef::Raw(s.to_string())) + } + } +} diff --git a/crates/lang-java/src/inference/strategy/method.rs b/crates/lang-java/src/inference/strategy/method.rs new file mode 100644 index 0000000..dbacdf3 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/method.rs @@ -0,0 +1,142 @@ +//! Method invocation inference. + +use super::{InferStrategy, infer_expression}; +use crate::inference::InferContext; +use crate::inference::TypeRefExt; +use crate::inference::core::unification::Substitution; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Infer type from method invocation (obj.method() or method()). +pub struct MethodCallInfer; + +impl InferStrategy for MethodCallInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + let member = self.resolve_member(node, ctx)?; + Some(member.type_ref) + } +} + +impl MethodCallInfer { + /// Resolve the method call to its member definition. + pub fn infer_member(&self, node: &Node, ctx: &InferContext) -> Option { + let member = self.resolve_member(node, ctx)?; + Some(member.fqn) + } + + fn resolve_member( + &self, + node: &Node, + ctx: &InferContext, + ) -> Option { + let call_node = if node.kind() == "method_invocation" { + *node + } else if let Some(parent) = node.parent() { + if parent.kind() == "method_invocation" + && parent.child_by_field_name("name") == Some(*node) + { + parent + } else { + return None; + } + } else { + return None; + }; + + // Get method name + let name_node = call_node.child_by_field_name("name")?; + let method_name = name_node.utf8_text(ctx.source.as_bytes()).ok()?; + + // Get receiver, if any + let receiver_type = if let Some(receiver) = call_node.child_by_field_name("object") { + // obj.method() + infer_expression(&receiver, ctx)? + } else { + // method() - implicit this or static import + if let Some(ref enclosing) = ctx.enclosing_class { + TypeRef::Id(enclosing.clone()) + } else { + return None; + } + }; + + // Get the FQN from the receiver type + let raw_fqn = receiver_type.as_fqn()?; + let resolution_ctx = ctx.to_resolution_context(); + let type_fqn = ctx + .ts + .resolve_type_name(&raw_fqn, &resolution_ctx) + .unwrap_or(raw_fqn); + + // Get argument types + let mut arg_types = Vec::new(); + if let Some(args_node) = call_node.child_by_field_name("arguments") { + let mut cursor = args_node.walk(); + for child in args_node.children(&mut cursor) { + if child.is_named() { + if let Some(t) = infer_expression(&child, ctx) { + arg_types.push(t); + } else { + arg_types.push(TypeRef::Unknown); + } + } + } + } + + // Look up the method candidates in the type hierarchy + let candidates = ctx.ts.find_member_in_hierarchy(&type_fqn, method_name); + if candidates.is_empty() { + return None; + } + + let candidates = self.apply_receiver_substitution(candidates, &receiver_type, ctx); + + // Resolve the best match among candidates + ctx.ts.resolve_method(&candidates, &arg_types) + } + + fn apply_receiver_substitution( + &self, + candidates: Vec, + receiver_type: &TypeRef, + ctx: &InferContext, + ) -> Vec { + let (base_fqn, receiver_args) = match receiver_type { + TypeRef::Generic { base, args } => { + let Some(base_fqn) = base.as_fqn() else { + return candidates; + }; + (base_fqn, args) + } + _ => return candidates, + }; + + let Some(type_info) = ctx.ts.get_type_info(&base_fqn) else { + return candidates; + }; + + if type_info.type_parameters.is_empty() + || type_info.type_parameters.len() != receiver_args.len() + { + return candidates; + } + + let mut subst = Substitution::new(); + for (param, arg) in type_info.type_parameters.iter().zip(receiver_args.iter()) { + subst.insert(param.name.clone(), arg.clone()); + } + + candidates + .into_iter() + .map(|mut member| { + member.type_ref = subst.apply(&member.type_ref); + if let Some(params) = &mut member.parameters { + for p in params { + p.type_ref = subst.apply(&p.type_ref); + } + } + member + }) + .collect() + } +} diff --git a/crates/lang-java/src/inference/strategy/mod.rs b/crates/lang-java/src/inference/strategy/mod.rs new file mode 100644 index 0000000..1f41699 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/mod.rs @@ -0,0 +1,136 @@ +//! Inference strategies using combinator pattern. +//! +//! Each strategy implements [`InferStrategy`] and can be combined using +//! `or_else()`, `map()`, etc. + +mod combinator; +mod field; +mod lambda; + +mod literal; +mod method; +mod new_expr; +mod this; + +mod type_id; + +pub use combinator::{Cached, Map, OrElse}; +pub use field::FieldAccessInfer; +pub use lambda::LambdaInfer; +pub use literal::LiteralInfer; +pub use type_id::TypeIdentifierInfer; +pub mod local; +pub use local::LocalVarInfer; +pub use method::MethodCallInfer; +pub use new_expr::NewExprInfer; +pub use this::ThisInfer; + +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// A type inference strategy. +/// +/// Strategies are composable using combinator methods. +/// Each strategy attempts to infer the type of an AST node. +pub trait InferStrategy: Sync + Send { + /// Attempt to infer the type of the given node. + /// + /// Returns `None` if this strategy doesn't apply or can't determine the type. + fn infer(&self, node: &Node, ctx: &InferContext) -> Option; + + /// Check if the node type matches the expected type (Bidirectional Inference). + /// + /// Default implementation purely relies on synthesis (infer) and subtyping check. + fn check(&self, node: &Node, expected: &TypeRef, ctx: &InferContext) -> Option { + let inferred = self.infer(node, ctx)?; + if ctx.ts.is_subtype(&inferred, expected) { + Some(expected.clone()) // Return expected (more precise usually) + } else { + None + } + } + + /// Combine with another strategy using "or" logic. + /// + /// If `self` returns `None`, try `other`. + fn or_else(self, other: S) -> OrElse + where + Self: Sized, + { + OrElse::new(self, other) + } + + /// Transform the result using a function. + fn map(self, f: F) -> Map + where + Self: Sized, + F: Fn(TypeRef) -> TypeRef + Send + Sync, + { + Map::new(self, f) + } + + /// Wrap with caching (memoization). + fn cached(self) -> Cached + where + Self: Sized, + { + Cached::new(self) + } +} + +/// Build the default expression inferrer. +/// +/// This combines all strategies in priority order. +pub fn build_expression_inferrer() -> impl InferStrategy { + ThisInfer + .or_else(LiteralInfer) + .or_else(LocalVarInfer) + .or_else(FieldAccessInfer) + .or_else(MethodCallInfer) + .or_else(NewExprInfer) + .or_else(LambdaInfer) + .or_else(TypeIdentifierInfer) +} + +/// Infer the type of an expression node. +/// +/// This is the main entry point for expression type inference. +pub fn infer_expression(node: &Node, ctx: &InferContext) -> Option { + // TODO: Use lazy_static for the inferrer once all strategies are complete + let inferrer = build_expression_inferrer(); + inferrer.infer(node, ctx) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Dummy strategy for testing combinators + struct AlwaysNone; + impl InferStrategy for AlwaysNone { + fn infer(&self, _: &Node, _: &InferContext) -> Option { + None + } + } + + struct AlwaysSome(TypeRef); + impl InferStrategy for AlwaysSome { + fn infer(&self, _: &Node, _: &InferContext) -> Option { + Some(self.0.clone()) + } + } + + #[test] + fn test_or_else_first_succeeds() { + let s = AlwaysSome(TypeRef::Raw("int".into())).or_else(AlwaysNone); + // Can't easily test without a Node, but the structure is correct + let _ = s; + } + + #[test] + fn test_or_else_fallback() { + let s = AlwaysNone.or_else(AlwaysSome(TypeRef::Raw("String".into()))); + let _ = s; + } +} diff --git a/crates/lang-java/src/inference/strategy/new_expr.rs b/crates/lang-java/src/inference/strategy/new_expr.rs new file mode 100644 index 0000000..eaafa64 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/new_expr.rs @@ -0,0 +1,36 @@ +//! `new` expression inference. + +use super::InferStrategy; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Infer type of `new Type()` expression. +pub struct NewExprInfer; + +impl InferStrategy for NewExprInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + if node.kind() != "object_creation_expression" { + return None; + } + + // Get the type being constructed + let type_node = node.child_by_field_name("type")?; + let type_name = type_node.utf8_text(ctx.source.as_bytes()).ok()?; + + // Handle generic types: get just the base type + let base_type = if let Some(idx) = type_name.find('<') { + &type_name[..idx] + } else { + type_name + }; + + // Resolve to FQN + let fqn = ctx + .ts + .resolve_type_name(base_type, &ctx.to_resolution_context()) + .unwrap_or_else(|| base_type.to_string()); + + Some(TypeRef::Id(fqn)) + } +} diff --git a/crates/lang-java/src/inference/strategy/this.rs b/crates/lang-java/src/inference/strategy/this.rs new file mode 100644 index 0000000..74a18fe --- /dev/null +++ b/crates/lang-java/src/inference/strategy/this.rs @@ -0,0 +1,20 @@ +//! `this` expression inference. + +use super::InferStrategy; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Infer type of `this` expression. +pub struct ThisInfer; + +impl InferStrategy for ThisInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + if node.kind() != "this" { + return None; + } + + // Return the enclosing class type + ctx.enclosing_class.as_ref().map(|c| TypeRef::Id(c.clone())) + } +} diff --git a/crates/lang-java/src/inference/strategy/type_id.rs b/crates/lang-java/src/inference/strategy/type_id.rs new file mode 100644 index 0000000..85387de --- /dev/null +++ b/crates/lang-java/src/inference/strategy/type_id.rs @@ -0,0 +1,59 @@ +use crate::inference::InferContext; +use crate::inference::strategy::InferStrategy; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Infers the type of a plain identifier by looking it up in the type system. +/// +/// This is used for type names in various contexts (declarations, casts, etc.) +/// and as a final fallback for expressions that might be static type references. +pub struct TypeIdentifierInfer; + +impl InferStrategy for TypeIdentifierInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + if !matches!(node.kind(), "identifier" | "type_identifier") { + return None; + } + + let name = node.utf8_text(ctx.source.as_bytes()).ok()?; + let res_ctx = ctx.to_resolution_context(); + + // 0. Check if this is part of a scoped type (e.g., Outer.Inner) + if let Some(parent) = node.parent() { + if parent.kind() == "scoped_type_identifier" { + // Get the full scoped name + let full_name = parent.utf8_text(ctx.source.as_bytes()).ok()?; + // Replace '.' with '$' for inner class naming convention if needed + let normalized = full_name.replace(" ", ""); + + // Try to resolve the full path + if let Some(fqn) = ctx.ts.resolve_type_name(&normalized, &res_ctx) { + return Some(TypeRef::Id(fqn)); + } + + // Try with inner class convention (Outer$Inner or Outer.Inner) + if let Some(fqn) = ctx + .ts + .resolve_type_name(&normalized.replace(".", "$"), &res_ctx) + { + return Some(TypeRef::Id(fqn)); + } + } + } + + // 1. Try to resolve as a type name + if let Some(fqn) = ctx.ts.resolve_type_name(name, &res_ctx) { + return Some(TypeRef::Id(fqn)); + } + + // 2. Try to resolve as an implicit this.field + if let Some(enclosing) = &ctx.enclosing_class { + let members = ctx.ts.find_member_in_hierarchy(enclosing, name); + if let Some(member) = members.first() { + return Some(member.type_ref.clone()); + } + } + + None + } +} diff --git a/crates/lang-java/src/jdk.rs b/crates/lang-java/src/jdk.rs new file mode 100644 index 0000000..367da83 --- /dev/null +++ b/crates/lang-java/src/jdk.rs @@ -0,0 +1,69 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Locates the JDK core asset (e.g., `lib/modules` or `rt.jar`). +pub fn find_jdk_asset() -> Option { + // 1. Try JAVA_HOME environment variable + if let Ok(home) = std::env::var("JAVA_HOME") { + if let Some(asset) = check_jdk_home(Path::new(&home)) { + return Some(asset); + } + } + + // 2. Try macOS specific java_home utility + #[cfg(target_os = "macos")] + { + if let Ok(output) = Command::new("/usr/libexec/java_home").output() { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path_str.is_empty() { + if let Some(asset) = check_jdk_home(Path::new(&path_str)) { + return Some(asset); + } + } + } + } + } + + // 3. Try generic java command + if let Ok(output) = Command::new("java") + .arg("-XshowSettings:properties") + .arg("-version") + .output() + { + // Output is on stderr usually + let stderr = String::from_utf8_lossy(&output.stderr); + for line in stderr.lines() { + if line.trim().starts_with("java.home = ") { + let path_str = line.trim().trim_start_matches("java.home = ").trim(); + if let Some(asset) = check_jdk_home(Path::new(path_str)) { + return Some(asset); + } + } + } + } + + None +} + +fn check_jdk_home(home: &Path) -> Option { + // Check for JImage (Java 9+) + let modules = home.join("lib").join("modules"); + if modules.exists() { + return Some(modules); + } + + // Check for rt.jar (Java 8) + let rt = home.join("lib").join("rt.jar"); + if rt.exists() { + return Some(rt); + } + + // Some JRE layouts + let jre_rt = home.join("jre").join("lib").join("rt.jar"); + if jre_rt.exists() { + return Some(jre_rt); + } + + None +} diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs index 1f42176..f791dd3 100644 --- a/crates/lang-java/src/lib.rs +++ b/crates/lang-java/src/lib.rs @@ -1,308 +1,37 @@ +pub mod cap; +pub mod discoverer; +pub mod inference; +pub mod jdk; +pub mod lsp; pub mod model; pub mod naming; pub mod parser; pub mod queries; -pub mod resolver; +pub mod resolve; + +pub use cap::java_caps; +pub use discoverer::JdkDiscoverer; -use lasso::Key; -use naviscope_api::models::graph::{EmptyMetadata, GraphNode, NodeKind}; -use naviscope_api::models::symbol::{FqnReader, Symbol, SymbolResolution}; -use naviscope_api::models::{DisplayGraphNode, Language}; -use naviscope_plugin::{ - GlobalParseResult, LangResolver, LanguagePlugin, LspParser, NamingConvention, NodeAdapter, - PluginInstance, SemanticResolver, StorageContext, -}; -use std::path::Path; use std::sync::Arc; #[derive(Clone)] pub struct JavaPlugin { - parser: Arc, - resolver: Arc, -} - -impl NodeAdapter for JavaPlugin { - fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode { - let mut display = DisplayGraphNode { - id: crate::naming::JavaNamingConvention.render_fqn(node.id, fqns), - name: fqns.resolve_atom(node.name).to_string(), - kind: node.kind.clone(), - lang: "java".to_string(), - location: node.location.as_ref().map(|l| l.to_display(fqns)), - 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| { - fqns.resolve_atom(Symbol( - lasso::Spur::try_from_usize(s as usize).unwrap(), - )) - .to_string() - }) - .collect(); - let prefix = match node.kind { - NodeKind::Interface => "interface", - 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| { - fqns.resolve_atom(Symbol( - lasso::Spur::try_from_usize(s as usize).unwrap(), - )) - .to_string() - }) - .collect(); - let params_str = parameters - .iter() - .map(|p| { - format!( - "{}: {}", - fqns.resolve_atom(Symbol( - lasso::Spur::try_from_usize(p.name_sid as usize).unwrap(), - )), - crate::model::fmt_type(&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(return_type) - )); - } - } - crate::model::JavaNodeMetadata::Field { - modifiers_sids, - type_ref, - } => { - display.modifiers = modifiers_sids - .iter() - .map(|&s| { - fqns.resolve_atom(Symbol( - lasso::Spur::try_from_usize(s as usize).unwrap(), - )) - .to_string() - }) - .collect(); - display.signature = Some(format!( - "{}: {}", - display.name, - crate::model::fmt_type(type_ref) - )); - } - _ => {} - } - } 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 { - NodeKind::Interface => "interface", - 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 encode_metadata( - &self, - metadata: &dyn naviscope_api::models::graph::NodeMetadata, - _ctx: &mut dyn 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::() - { - rmp_serde::to_vec(&java_idx_meta).unwrap_or_default() - } else { - Vec::new() - } - } - - fn decode_metadata( - &self, - bytes: &[u8], - _ctx: &dyn StorageContext, - ) -> Arc { - if let Ok(element) = rmp_serde::from_slice::(bytes) { - Arc::new(element) - } else { - Arc::new(EmptyMetadata) - } - } + pub(crate) parser: Arc, + pub(crate) type_system: Arc, } impl JavaPlugin { pub fn new() -> std::result::Result> { - let parser = Arc::new(parser::JavaParser::new()?); - let resolver = Arc::new(resolver::JavaResolver { - parser: (*parser).clone(), - }); - Ok(Self { parser, resolver }) - } -} - -impl PluginInstance for JavaPlugin { - fn get_naming_convention(&self) -> Option> { - Some(Arc::new(crate::naming::JavaNamingConvention)) - } + naviscope_plugin::register_metadata_deserializer( + "java", + crate::model::JavaIndexMetadata::deserialize_for_cache, + ); - fn get_node_adapter(&self) -> Option> { - Some(Arc::new(self.clone())) - } -} - -impl LanguagePlugin for JavaPlugin { - fn name(&self) -> Language { - Language::JAVA - } - - fn supported_extensions(&self) -> &[&str] { - &["java"] - } - - fn parse_file( - &self, - source: &str, - path: &Path, - ) -> std::result::Result> { - 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 { - 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 { - self.parser.extract_symbols(tree, source) - } - - fn symbol_kind(&self, kind: &naviscope_api::models::graph::NodeKind) -> lsp_types::SymbolKind { - self.parser.symbol_kind(kind) - } - - fn find_occurrences( - &self, - source: &str, - tree: &tree_sitter::Tree, - target: &SymbolResolution, - ) -> Vec { - self.parser.find_occurrences(source, tree, target) + let parser = Arc::new(parser::JavaParser::new()?); + let type_system = Arc::new(lsp::type_system::JavaTypeSystem::new()); + Ok(Self { + parser, + type_system, + }) } } diff --git a/crates/lang-java/src/lsp/mod.rs b/crates/lang-java/src/lsp/mod.rs new file mode 100644 index 0000000..052a970 --- /dev/null +++ b/crates/lang-java/src/lsp/mod.rs @@ -0,0 +1,41 @@ +mod references; +mod symbols; +pub mod type_system; + +use crate::parser::JavaParser; +use naviscope_api::models::SymbolResolution; +use naviscope_api::models::graph::DisplayGraphNode; +use naviscope_plugin::CodeGraph; +use naviscope_plugin::LspSyntaxService; +use std::sync::Arc; +use tree_sitter::Tree; + +pub struct JavaLspService { + parser: Arc, +} + +impl JavaLspService { + pub fn new(parser: Arc) -> Self { + Self { parser } + } +} + +impl LspSyntaxService for JavaLspService { + 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 { + symbols::extract_symbols(&self.parser, tree, source) + } + + fn find_occurrences( + &self, + source: &str, + tree: &Tree, + target: &SymbolResolution, + index: Option<&dyn CodeGraph>, + ) -> Vec { + references::find_occurrences(&self.parser, source, tree, target, index) + } +} diff --git a/crates/lang-java/src/lsp/references.rs b/crates/lang-java/src/lsp/references.rs new file mode 100644 index 0000000..cdb0634 --- /dev/null +++ b/crates/lang-java/src/lsp/references.rs @@ -0,0 +1,352 @@ +use crate::inference::InferContext; +use crate::inference::adapters::{CodeGraphTypeSystem, NoOpTypeSystem}; +use crate::inference::create_inference_context; +use crate::inference::scope::ScopeManager; +use crate::parser::JavaParser; +use naviscope_api::models::symbol::Range; +use naviscope_api::models::{SymbolIntent, SymbolResolution, TypeRef}; +use naviscope_plugin::CodeGraph; +use naviscope_plugin::utils::{line_col_at_to_offset, range_from_ts}; +use tree_sitter::{Node, Tree}; + +pub fn find_occurrences( + parser: &JavaParser, + source: &str, + tree: &Tree, + target: &SymbolResolution, + index: Option<&dyn CodeGraph>, +) -> Vec { + let mut ranges = Vec::new(); + + // 1. Extract package and imports + let (package, imports) = parser.extract_package_and_imports(tree, source); + + // 2. Build inference context with available type system + if let Some(index) = index { + let ts = CodeGraphTypeSystem::new(index); + let mut scope_manager = ScopeManager::new(); + let ctx = create_inference_context( + &tree.root_node(), + source, + &ts, + &mut scope_manager, + package, + imports, + ); + collect_occurrences_with_ctx(tree, source, target, &ctx, parser, &mut ranges); + } else { + let ts = NoOpTypeSystem; + let mut scope_manager = ScopeManager::new(); + let ctx = create_inference_context( + &tree.root_node(), + source, + &ts, + &mut scope_manager, + package, + imports, + ); + collect_occurrences_with_ctx(tree, source, target, &ctx, parser, &mut ranges); + } + + ranges +} + +fn collect_occurrences_with_ctx( + tree: &Tree, + source: &str, + target: &SymbolResolution, + infer_ctx: &InferContext, + parser: &JavaParser, + ranges: &mut Vec, +) { + let Some(scope_manager) = infer_ctx.scope_manager else { + return; + }; + + match target { + SymbolResolution::Local(decl_range, _decl_name) => { + if let Some(name) = extract_name_from_range(source, decl_range) { + find_matching_identifiers( + tree, + source, + &name, + |node| { + if let Some(scope_id) = find_start_scope_id(node, scope_manager) { + if let Some(info) = scope_manager.lookup_symbol(scope_id, &name) { + return info.range == *decl_range; + } + } + false + }, + ranges, + ); + } + } + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => { + let member_target = fqn.contains('#'); + let type_target = matches!(target, SymbolResolution::Precise(_, SymbolIntent::Type)); + let name = if member_target { + // For signed member FQNs like `Owner#target(java.lang.String)`, + // never split on '.'; extract member part first. + let member = crate::naming::extract_member_name(fqn).unwrap_or(fqn); + crate::naming::extract_simple_name(member) + } else { + fqn.split(['.', '$']).last().unwrap_or(fqn) + }; + + if name.is_empty() { + return; + } + + find_matching_identifiers( + tree, + source, + name, + |node| { + if let Some(scope_id) = find_start_scope_id(node, scope_manager) { + if scope_manager.lookup_symbol(scope_id, name).is_some() { + return false; + } + } + + if member_target { + return resolve_member_reference(node, infer_ctx, parser) + .map(|(resolved, static_site)| { + member_fqn_matches_target(&resolved, fqn, static_site, infer_ctx) + }) + .unwrap_or(false); + } + + if type_target { + if let Some(TypeRef::Id(resolved_type)) = + crate::inference::strategy::infer_expression(node, infer_ctx) + { + return resolved_type == *fqn; + } + } + + // Strict mode: No name-only fallback. + false + }, + ranges, + ); + } + } +} + +fn extract_name_from_range(source: &str, range: &Range) -> Option { + let start = line_col_at_to_offset(source, range.start_line, range.start_col)?; + let end = line_col_at_to_offset(source, range.end_line, range.end_col)?; + if start < end && end <= source.len() { + Some(source[start..end].to_string()) + } else { + None + } +} + +fn find_matching_identifiers( + tree: &Tree, + source: &str, + target_name: &str, + predicate: F, + ranges: &mut Vec, +) where + F: Fn(&Node) -> bool, +{ + visit_tree_recursive(&tree.root_node(), source, target_name, &predicate, ranges); +} + +fn visit_tree_recursive( + node: &Node, + source: &str, + target_name: &str, + predicate: &F, + ranges: &mut Vec, +) where + F: Fn(&Node) -> bool, +{ + if node.kind() == "identifier" || node.kind() == "type_identifier" { + if let Ok(text) = node.utf8_text(source.as_bytes()) { + if text == target_name { + if predicate(node) { + ranges.push(range_from_ts(node.range())); + } + } + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + visit_tree_recursive(&child, source, target_name, predicate, ranges); + } +} + +fn find_start_scope_id(node: &Node, sm: &ScopeManager) -> Option { + let mut current = *node; + while let Some(parent) = current.parent() { + if let Some(sid) = sm.get_scope_id(parent.id()) { + return Some(sid); + } + current = parent; + } + None +} + +fn resolve_member_reference( + node: &Node, + infer_ctx: &InferContext, + parser: &JavaParser, +) -> Option<(String, bool)> { + if node.kind() != "identifier" { + return None; + } + + // Determine the precise enclosing class scope for this node to handle implicit `this` calls + // and correctly resolve members in current class hierarchy. + let enclosing = find_enclosing_class_fqn(node, infer_ctx); + let mut local_ctx = infer_ctx.clone(); + if let Some(fqn) = enclosing { + local_ctx.enclosing_class = Some(fqn); + } + let ctx_ref = &local_ctx; + + if let Some(parent) = node.parent() { + if parent.kind() == "method_invocation" && parent.child_by_field_name("name") == Some(*node) + { + if let Some(resolved) = + crate::inference::strategy::MethodCallInfer.infer_member(&parent, ctx_ref) + { + let static_site = is_static_member_access_site(&parent, ctx_ref); + return Some((resolved, static_site)); + } + + // If implicit inference failed, we return None. + return None; + } + + if parent.kind() == "field_access" && parent.child_by_field_name("field") == Some(*node) { + return crate::inference::strategy::FieldAccessInfer + .infer_member(&parent, ctx_ref) + .map(|fqn| (fqn, is_static_member_access_site(&parent, ctx_ref))); + } + } + + resolve_member_declaration_fqn(node, ctx_ref, parser).map(|fqn| (fqn, false)) +} + +fn resolve_member_declaration_fqn( + node: &Node, + infer_ctx: &InferContext, + parser: &JavaParser, +) -> Option { + let parent = node.parent()?; + if parent.child_by_field_name("name") != Some(*node) { + return None; + } + + let member_name = node.utf8_text(infer_ctx.source.as_bytes()).ok()?; + let class_fqn = find_enclosing_class_fqn(node, infer_ctx)?; + + match parent.kind() { + "method_declaration" | "constructor_declaration" => { + let param_types: Vec = parser + .extract_method_parameters(parent, infer_ctx.source) + .into_iter() + .map(|p| p.type_ref) + .collect(); + let signed_name = crate::naming::build_java_method_name(member_name, ¶m_types); + Some(crate::naming::build_member_fqn(&class_fqn, &signed_name)) + } + "variable_declarator" => { + if parent.parent().map(|p| p.kind()) == Some("field_declaration") { + return Some(crate::naming::build_member_fqn(&class_fqn, member_name)); + } + None + } + _ => None, + } +} + +fn find_enclosing_class_fqn(node: &Node, infer_ctx: &InferContext) -> Option { + let sm = infer_ctx.scope_manager?; + let start_scope = find_start_scope_id(node, sm)?; + sm.find_enclosing_class(start_scope) +} + +fn member_fqn_matches_target( + resolved: &str, + target: &str, + static_site: bool, + infer_ctx: &InferContext, +) -> bool { + if resolved == target { + return true; + } + + let (Some((resolved_owner, resolved_name)), Some((target_owner, target_name))) = + (split_member_fqn(resolved), split_member_fqn(target)) + else { + return false; + }; + + // Compare member names: if both have signatures, compare exactly; + // otherwise fall back to simple-name comparison for graceful degradation. + let resolved_has_sig = resolved_name.contains('(') && resolved_name.ends_with(')'); + let target_has_sig = target_name.contains('(') && target_name.ends_with(')'); + + let names_match = if resolved_has_sig && target_has_sig { + // Both signed → require exact match (overload-safe) + resolved_name == target_name + } else { + // At least one unsigned → compare by simple name + crate::naming::extract_simple_name(resolved_name) + == crate::naming::extract_simple_name(target_name) + }; + + if !names_match { + return false; + } + + // Static member hiding is name-based and not polymorphic: require exact owner match. + if static_site { + return resolved_owner == target_owner; + } + + let resolved_ty = TypeRef::Id(resolved_owner.to_string()); + let target_ty = TypeRef::Id(target_owner.to_string()); + + infer_ctx.ts.is_subtype(&resolved_ty, &target_ty) + || infer_ctx.ts.is_subtype(&target_ty, &resolved_ty) +} + +fn split_member_fqn(fqn: &str) -> Option<(&str, &str)> { + let (owner, member) = fqn.split_once('#')?; + Some((owner, member)) +} + +fn is_static_member_access_site(access_parent: &Node, infer_ctx: &InferContext) -> bool { + let Some(object) = access_parent.child_by_field_name("object") else { + return false; + }; + + match object.kind() { + "type_identifier" | "scoped_type_identifier" | "generic_type" => true, + "this" | "super" => false, + "identifier" => { + let Ok(name) = object.utf8_text(infer_ctx.source.as_bytes()) else { + return false; + }; + + let Some(sm) = infer_ctx.scope_manager else { + return false; + }; + let Some(scope_id) = find_start_scope_id(&object, sm) else { + return true; + }; + + // If identifier is a local variable, this is instance access; otherwise treat as type. + sm.lookup_symbol(scope_id, name).is_none() + } + _ => false, + } +} diff --git a/crates/lang-java/src/lsp/symbols.rs b/crates/lang-java/src/lsp/symbols.rs new file mode 100644 index 0000000..66683e9 --- /dev/null +++ b/crates/lang-java/src/lsp/symbols.rs @@ -0,0 +1,62 @@ +use crate::parser::JavaParser; +use naviscope_api::models::graph::{DisplayGraphNode, NodeKind}; +use naviscope_plugin::utils::{RawSymbol, build_symbol_hierarchy, range_from_ts}; +use std::collections::HashMap; +use tree_sitter::Tree; + +pub fn extract_symbols(parser: &JavaParser, 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 = parser.collect_matches(tree, source); + + // Pass None for package to keep FQNs local/relative during symbol extraction + parser.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: range_from_ts(e.node.range()), + selection_range: e + .node + .child_by_field_name("name") + .map(|n| range_from_ts(n.range())) + .unwrap_or_else(|| range_from_ts(e.node.range())), + node: e.node, + } + }) + .collect(); + + build_symbol_hierarchy(raw_symbols) +} diff --git a/crates/lang-java/src/lsp/type_system.rs b/crates/lang-java/src/lsp/type_system.rs new file mode 100644 index 0000000..39717e3 --- /dev/null +++ b/crates/lang-java/src/lsp/type_system.rs @@ -0,0 +1,69 @@ +use naviscope_api::models::SymbolResolution; +use naviscope_plugin::ReferenceCheckService; +use naviscope_plugin::graph::CodeGraph; + +pub struct JavaTypeSystem; + +impl JavaTypeSystem { + pub fn new() -> Self { + Self + } +} + +impl ReferenceCheckService for JavaTypeSystem { + fn is_reference_to( + &self, + graph: &dyn CodeGraph, + candidate: &SymbolResolution, + target: &SymbolResolution, + ) -> bool { + // Core Java identity and inheritance logic + if candidate == target { + return true; + } + + // Handle method overrides/implementations + let c_fqn = candidate.fqn(); + let t_fqn = target.fqn(); + + if let (Some(c_fqn), Some(t_fqn)) = (c_fqn, t_fqn) { + self.check_inheritance_match(graph, c_fqn, t_fqn) + } else { + false + } + } +} + +impl JavaTypeSystem { + fn check_inheritance_match( + &self, + graph: &dyn CodeGraph, + candidate_fqn: &str, + target_fqn: &str, + ) -> bool { + // Logic: if both are members (contain '#'), compare names and check if classes match inheritance + use naviscope_plugin::naming::parse_member_fqn; + + if let (Some((c_type, c_member)), Some((t_type, t_member))) = ( + parse_member_fqn(candidate_fqn), + parse_member_fqn(target_fqn), + ) { + // Compare: if both are signed, require exact match; otherwise simple-name match + let c_has_sig = naviscope_plugin::naming::has_method_signature(c_member); + let t_has_sig = naviscope_plugin::naming::has_method_signature(t_member); + let names_match = if c_has_sig && t_has_sig { + c_member == t_member + } else { + naviscope_plugin::naming::extract_simple_name(c_member) + == naviscope_plugin::naming::extract_simple_name(t_member) + }; + if names_match { + // Member names match, check if classes are related + return self.is_subtype(graph, c_type, t_type) + || self.is_subtype(graph, t_type, c_type); + } + } + + false + } +} diff --git a/crates/lang-java/src/model.rs b/crates/lang-java/src/model.rs index 8df94f7..7c2184e 100644 --- a/crates/lang-java/src/model.rs +++ b/crates/lang-java/src/model.rs @@ -9,9 +9,11 @@ use std::sync::Arc; pub enum JavaIndexMetadata { Class { modifiers: Vec, + type_parameters: Vec, }, Interface { modifiers: Vec, + type_parameters: Vec, }, Enum { modifiers: Vec, @@ -41,6 +43,10 @@ impl IndexMetadata for JavaIndexMetadata { fn intern(&self, interner: &mut dyn SymbolInterner) -> Arc { Arc::new(self.to_storage(interner)) } + + fn to_cached_metadata(&self) -> naviscope_plugin::CachedMetadata { + self.to_cached_metadata() + } } /// Interned metadata stored in the graph @@ -48,9 +54,11 @@ impl IndexMetadata for JavaIndexMetadata { pub enum JavaNodeMetadata { Class { modifiers_sids: Vec, + type_parameters_sids: Vec, }, Interface { modifiers_sids: Vec, + type_parameters_sids: Vec, }, Enum { modifiers_sids: Vec, @@ -73,13 +81,43 @@ pub enum JavaNodeMetadata { } impl JavaIndexMetadata { + pub fn deserialize_for_cache(_version: u32, bytes: &[u8]) -> Arc { + // In the future, we can switch on version here to handle migrations + match rmp_serde::from_slice::(bytes) { + Ok(meta) => Arc::new(meta), + Err(_) => Arc::new(naviscope_api::models::graph::EmptyMetadata), + } + } + + pub fn to_cached_metadata(&self) -> naviscope_plugin::CachedMetadata { + naviscope_plugin::CachedMetadata { + type_tag: "java".to_string(), + version: 1, // Current version + data: rmp_serde::to_vec(self).unwrap_or_default(), + } + } + pub fn to_storage(&self, ctx: &mut dyn SymbolInterner) -> JavaNodeMetadata { match self { - JavaIndexMetadata::Class { modifiers } => JavaNodeMetadata::Class { + JavaIndexMetadata::Class { + modifiers, + type_parameters, + } => JavaNodeMetadata::Class { modifiers_sids: modifiers.iter().map(|s| ctx.intern_str(s)).collect(), + type_parameters_sids: type_parameters + .iter() + .map(|s| ctx.intern_str(s)) + .collect(), }, - JavaIndexMetadata::Interface { modifiers } => JavaNodeMetadata::Interface { + JavaIndexMetadata::Interface { + modifiers, + type_parameters, + } => JavaNodeMetadata::Interface { modifiers_sids: modifiers.iter().map(|s| ctx.intern_str(s)).collect(), + type_parameters_sids: type_parameters + .iter() + .map(|s| ctx.intern_str(s)) + .collect(), }, JavaIndexMetadata::Enum { modifiers, @@ -104,6 +142,7 @@ impl JavaIndexMetadata { .map(|p| JavaParameterStorage { name_sid: ctx.intern_str(&p.name), type_ref: p.type_ref.clone(), + is_varargs: p.is_varargs, }) .collect(), is_constructor: *is_constructor, @@ -130,12 +169,16 @@ impl NodeMetadata for JavaNodeMetadata { pub struct JavaParameter { pub name: String, pub type_ref: TypeRef, + #[serde(default)] + pub is_varargs: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct JavaParameterStorage { pub name_sid: u32, pub type_ref: TypeRef, + #[serde(default)] + pub is_varargs: bool, } pub fn fmt_type(t: &TypeRef) -> String { diff --git a/crates/lang-java/src/naming.rs b/crates/lang-java/src/naming.rs index 794285d..fe13cbe 100644 --- a/crates/lang-java/src/naming.rs +++ b/crates/lang-java/src/naming.rs @@ -1,68 +1,281 @@ -use naviscope_api::models::NodeKind; -use naviscope_plugin::NamingConvention; +// Re-export cross-language naming utilities from plugin +pub use naviscope_plugin::naming::{ + MEMBER_SEPARATOR, MethodSignature, TYPE_SEPARATOR, build_member_fqn, extract_member_name, + extract_simple_name, extract_type_fqn, format_method_name, has_method_signature, is_member_fqn, + parse_member_fqn, parse_method_signature, +}; -#[derive(Debug, Clone, Copy, Default)] -pub struct JavaNamingConvention; +/// Java uses standard dot-separated paths for types and packages, +/// and standard hash-separated paths for members in our graph. +/// Thus we can alias directly to StandardNamingConvention. +pub use naviscope_plugin::StandardNamingConvention as JavaNamingConvention; -impl NamingConvention for JavaNamingConvention { - fn separator(&self) -> &str { - "." - } +// --------------------------------------------------------------------------- +// Java-specific type normalization for method signatures +// +// Java uses type erasure for generics at the bytecode level, so the canonical +// method signature erases generic type arguments. These rules are Java/JVM +// specific and do NOT belong in the cross-language plugin crate. +// --------------------------------------------------------------------------- + +use naviscope_api::models::TypeRef; - fn get_separator(&self, parent: NodeKind, child: NodeKind) -> &str { - match (parent, child) { - ( - NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation, - NodeKind::Method | NodeKind::Field | NodeKind::Constructor, - ) => "#", - _ => ".", +/// Normalize a `TypeRef` into its canonical signature string using Java rules. +/// +/// Java-specific rules: +/// - Primitives / raw unresolved types: as-is (`int`, `boolean`, `void`) +/// - Resolved reference types: full FQN (`java.lang.String`) +/// - Arrays: element type + `[]` per dimension (`java.lang.String[]`) +/// - Generics: **erased** to base type (`java.util.List` → `java.util.List`) +/// - Varargs: caller should convert to array **before** calling this function +/// (use [`varargs_to_array_type`]) +/// - Wildcards / Unknown: `?` +pub fn normalize_type_for_signature(type_ref: &TypeRef) -> String { + match type_ref { + TypeRef::Raw(s) => s.clone(), + TypeRef::Id(fqn) => fqn.clone(), + TypeRef::Array { + element, + dimensions, + } => { + let base = normalize_type_for_signature(element); + let brackets: String = "[]".repeat(*dimensions); + format!("{}{}", base, brackets) } + TypeRef::Generic { base, .. } => { + // Java type erasure: discard generic type arguments + normalize_type_for_signature(base) + } + TypeRef::Wildcard { .. } => "?".to_string(), + TypeRef::Unknown => "?".to_string(), + } +} + +/// Convert a varargs parameter type to its array equivalent for signature purposes. +/// +/// Java varargs `String...` should be represented as `String[]` in the method signature. +/// Call this before passing the type to signature construction. +/// +/// If the type is already an array, an additional dimension is **not** added — +/// the parser is assumed to have already handled the varargs→array conversion. +pub fn varargs_to_array_type(type_ref: &TypeRef) -> TypeRef { + match type_ref { + TypeRef::Array { .. } => type_ref.clone(), + other => TypeRef::Array { + element: Box::new(other.clone()), + dimensions: 1, + }, + } +} + +/// Build a Java method member name with its parameter signature. +/// +/// This combines Java-specific type normalization with the cross-language +/// `format_method_name`. The result can be passed to `build_member_fqn`. +/// +/// # Examples +/// +/// ```ignore +/// let signed = build_java_method_name("target", &[TypeRef::raw("int")]); +/// // signed == "target(int)" +/// let fqn = build_member_fqn("com.example.A", &signed); +/// // fqn == "com.example.A#target(int)" +/// ``` +pub fn build_java_method_name(name: &str, param_types: &[TypeRef]) -> String { + let normalized: Vec = param_types + .iter() + .map(normalize_type_for_signature) + .collect(); + let refs: Vec<&str> = normalized.iter().map(|s| s.as_str()).collect(); + format_method_name(name, &refs) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- normalize_type_for_signature -- + + #[test] + fn normalize_primitive() { + assert_eq!(normalize_type_for_signature(&TypeRef::raw("int")), "int"); + assert_eq!( + normalize_type_for_signature(&TypeRef::raw("boolean")), + "boolean" + ); + assert_eq!(normalize_type_for_signature(&TypeRef::raw("void")), "void"); + } + + #[test] + fn normalize_resolved_reference() { + assert_eq!( + normalize_type_for_signature(&TypeRef::id("java.lang.String")), + "java.lang.String" + ); } - fn parse_fqn( - &self, - fqn: &str, - heuristic_leaf_kind: Option, - ) -> Vec<(NodeKind, String)> { - // Java Logic: - // 1. Split by '.' (Package/Class separator) - // 2. But wait! Inner classes might use '$' in bytecode but '.' in source FQN. - // And methods use '#' in our graph convention (from JavaParser::get_node_id_for_definition?) - // The string coming in here is likely a "Source FQN" (dot separated). - - let mut result = Vec::new(); - - // Handle '#' for methods/fields if present (e.g. from existing graph ID) - let (path_part, member_part) = if let Some(hash_pos) = fqn.find('#') { - (&fqn[..hash_pos], Some(&fqn[hash_pos + 1..])) - } else { - (fqn, None) + #[test] + fn normalize_array_single_dimension() { + let arr = TypeRef::Array { + element: Box::new(TypeRef::id("java.lang.String")), + dimensions: 1, }; + assert_eq!(normalize_type_for_signature(&arr), "java.lang.String[]"); + } + + #[test] + fn normalize_array_multi_dimension() { + let arr = TypeRef::Array { + element: Box::new(TypeRef::raw("int")), + dimensions: 2, + }; + assert_eq!(normalize_type_for_signature(&arr), "int[][]"); + } + + #[test] + fn normalize_generic_erases_type_args() { + let generic = TypeRef::Generic { + base: Box::new(TypeRef::id("java.util.List")), + args: vec![TypeRef::id("java.lang.String")], + }; + assert_eq!(normalize_type_for_signature(&generic), "java.util.List"); + } + + #[test] + fn normalize_nested_generic_erases_all() { + let generic = TypeRef::Generic { + base: Box::new(TypeRef::id("java.util.Map")), + args: vec![ + TypeRef::id("java.lang.String"), + TypeRef::Generic { + base: Box::new(TypeRef::id("java.util.List")), + args: vec![TypeRef::id("java.lang.Integer")], + }, + ], + }; + assert_eq!(normalize_type_for_signature(&generic), "java.util.Map"); + } - // Split the path part (Packages/Classes) - let parts: Vec<&str> = path_part.split(|c| c == '.' || c == '$').collect(); - for part in parts.iter() { - if part.is_empty() { - continue; + #[test] + fn normalize_generic_array() { + let arr = TypeRef::Array { + element: Box::new(TypeRef::Generic { + base: Box::new(TypeRef::id("java.util.List")), + args: vec![TypeRef::id("java.lang.String")], + }), + dimensions: 1, + }; + assert_eq!(normalize_type_for_signature(&arr), "java.util.List[]"); + } + + #[test] + fn normalize_wildcard_and_unknown() { + assert_eq!( + normalize_type_for_signature(&TypeRef::Wildcard { + bound: None, + is_upper_bound: true + }), + "?" + ); + assert_eq!(normalize_type_for_signature(&TypeRef::Unknown), "?"); + } + + // -- varargs_to_array_type -- + + #[test] + fn varargs_converts_non_array_to_array() { + let input = TypeRef::id("java.lang.String"); + let result = varargs_to_array_type(&input); + assert_eq!( + result, + TypeRef::Array { + element: Box::new(TypeRef::id("java.lang.String")), + dimensions: 1 } + ); + } - // Heuristic: Uppercase = Class, Lowercase = Package - // This is not perfect but standard Java convention. - let is_uppercase = part.chars().next().map_or(false, |c| c.is_uppercase()); - let kind = if is_uppercase { - NodeKind::Class - } else { - NodeKind::Package - }; - result.push((kind, part.to_string())); - } + #[test] + fn varargs_preserves_existing_array() { + let input = TypeRef::Array { + element: Box::new(TypeRef::raw("int")), + dimensions: 1, + }; + let result = varargs_to_array_type(&input); + assert_eq!(result, input); + } - // Handle member part - if let Some(member) = member_part { - let kind = heuristic_leaf_kind.unwrap_or(NodeKind::Method); - result.push((kind, member.to_string())); - } + // -- build_java_method_name -- + + #[test] + fn build_no_params() { + let signed = build_java_method_name("target", &[]); + assert_eq!(signed, "target()"); + } + + #[test] + fn build_single_primitive_param() { + let signed = build_java_method_name("target", &[TypeRef::raw("int")]); + assert_eq!(signed, "target(int)"); + } + + #[test] + fn build_multiple_params() { + let signed = build_java_method_name("target", &[TypeRef::raw("int"), TypeRef::raw("int")]); + assert_eq!(signed, "target(int,int)"); + } + + #[test] + fn build_generic_param_erased() { + let signed = build_java_method_name( + "add", + &[TypeRef::Generic { + base: Box::new(TypeRef::id("java.util.List")), + args: vec![TypeRef::id("java.lang.String")], + }], + ); + assert_eq!(signed, "add(java.util.List)"); + } + + #[test] + fn build_constructor() { + let signed = build_java_method_name("", &[TypeRef::raw("int")]); + assert_eq!(signed, "(int)"); + } + + // -- End-to-end: build_java_method_name → build_member_fqn → parse_method_signature -- + + #[test] + fn roundtrip_java_method_fqn() { + let signed = build_java_method_name( + "target", + &[TypeRef::raw("int"), TypeRef::id("java.lang.String")], + ); + let fqn = build_member_fqn("com.example.A", &signed); + assert_eq!(fqn, "com.example.A#target(int,java.lang.String)"); + + let sig = parse_method_signature(&fqn).unwrap(); + assert_eq!(sig.owner, "com.example.A"); + assert_eq!(sig.name, "target"); + assert_eq!(sig.params, "int,java.lang.String"); + } - result + #[test] + fn two_java_overloads_produce_different_fqns() { + let fqn1 = build_member_fqn( + "com.example.A", + &build_java_method_name("target", &[TypeRef::raw("int")]), + ); + let fqn2 = build_member_fqn( + "com.example.A", + &build_java_method_name("target", &[TypeRef::raw("int"), TypeRef::raw("int")]), + ); + assert_ne!(fqn1, fqn2); + assert_eq!(fqn1, "com.example.A#target(int)"); + assert_eq!(fqn2, "com.example.A#target(int,int)"); } } diff --git a/crates/lang-java/src/parser/ast/entities.rs b/crates/lang-java/src/parser/ast/entities.rs index 63670c7..4e4ebea 100644 --- a/crates/lang-java/src/parser/ast/entities.rs +++ b/crates/lang-java/src/parser/ast/entities.rs @@ -145,14 +145,31 @@ impl JavaParser { relations: &mut Vec, ) -> JavaIndexMetadata { match kind { - KIND_LABEL_CLASS => JavaIndexMetadata::Class { modifiers: vec![] }, - KIND_LABEL_INTERFACE => JavaIndexMetadata::Interface { modifiers: vec![] }, + KIND_LABEL_CLASS => JavaIndexMetadata::Class { + modifiers: vec![], + type_parameters: self.extract_type_parameters(captures, source), + }, + KIND_LABEL_INTERFACE => JavaIndexMetadata::Interface { + modifiers: vec![], + type_parameters: self.extract_type_parameters(captures, source), + }, KIND_LABEL_ENUM => JavaIndexMetadata::Enum { modifiers: vec![], constants: vec![], }, KIND_LABEL_ANNOTATION => JavaIndexMetadata::Annotation { modifiers: vec![] }, KIND_LABEL_METHOD | KIND_LABEL_CONSTRUCTOR => { + let def_idx = if kind == KIND_LABEL_METHOD { + self.indices.method_def + } else { + self.indices.constr_def + }; + let anchor_node = captures + .iter() + .find(|c| c.index == def_idx) + .map(|c| c.node) + .expect("Method definition node must exist"); + let mut return_type = TypeRef::raw("void"); if let Some(ret_node) = captures .iter() @@ -164,7 +181,7 @@ impl JavaParser { } JavaIndexMetadata::Method { return_type, - parameters: vec![], + parameters: self.extract_method_parameters(anchor_node, source), modifiers: vec![], is_constructor: kind == KIND_LABEL_CONSTRUCTOR, } @@ -201,4 +218,62 @@ impl JavaParser { _ => unreachable!(), } } + + fn extract_type_parameters<'a>( + &self, + captures: &[QueryCapture<'a>], + source: &'a str, + ) -> Vec { + let declaration_node = captures.iter().find_map(|c| { + if c.index == self.indices.class_def || c.index == self.indices.inter_def { + Some(c.node) + } else { + None + } + }); + + let Some(declaration_node) = declaration_node else { + return Vec::new(); + }; + + let type_params_node = declaration_node + .child_by_field_name("type_parameters") + .or_else(|| { + let mut cursor = declaration_node.walk(); + declaration_node + .children(&mut cursor) + .find(|n| n.kind() == "type_parameters") + }); + + let Some(type_params_node) = type_params_node else { + return Vec::new(); + }; + + let mut result = Vec::new(); + let mut cursor = type_params_node.walk(); + for child in type_params_node.children(&mut cursor) { + if child.kind() != "type_parameter" { + continue; + } + + if let Some(name_node) = child.child_by_field_name("name") { + if let Ok(name) = name_node.utf8_text(source.as_bytes()) { + result.push(name.to_string()); + } + continue; + } + + let mut type_param_cursor = child.walk(); + for gc in child.children(&mut type_param_cursor) { + if gc.kind() == "type_identifier" { + if let Ok(name) = gc.utf8_text(source.as_bytes()) { + result.push(name.to_string()); + } + break; + } + } + } + + result + } } diff --git a/crates/lang-java/src/parser/ast/metadata.rs b/crates/lang-java/src/parser/ast/metadata.rs index 5cd0727..6294a86 100644 --- a/crates/lang-java/src/parser/ast/metadata.rs +++ b/crates/lang-java/src/parser/ast/metadata.rs @@ -96,7 +96,7 @@ impl JavaParser { } match element { - JavaIndexMetadata::Class { modifiers: _ } => { + JavaIndexMetadata::Class { .. } => { if let Some(s) = captures .iter() .find(|c| c.index == self.indices.class_super) @@ -123,7 +123,7 @@ impl JavaParser { source_id: fqn_id.clone(), target_id: naviscope_api::models::symbol::NodeId::Flat(s_name), rel_type: EdgeType::InheritsFrom, - range: None, + range: Some(range_from_ts(s.node.range())), }); } for cc in captures @@ -139,11 +139,11 @@ impl JavaParser { source_id: fqn_id.clone(), target_id: naviscope_api::models::symbol::NodeId::Flat(i), rel_type: EdgeType::Implements, - range: None, + range: Some(range_from_ts(cc.node.range())), }); } } - JavaIndexMetadata::Interface { modifiers: _ } => { + JavaIndexMetadata::Interface { .. } => { for cc in captures .iter() .filter(|c| c.index == self.indices.inter_ext) @@ -157,7 +157,7 @@ impl JavaParser { source_id: fqn_id.clone(), target_id: naviscope_api::models::symbol::NodeId::Flat(e), rel_type: EdgeType::InheritsFrom, - range: None, + range: Some(range_from_ts(cc.node.range())), }); } } @@ -178,7 +178,7 @@ impl JavaParser { source_id: fqn_id.clone(), target_id: naviscope_api::models::symbol::NodeId::Flat(i), rel_type: EdgeType::Implements, - range: None, + range: Some(range_from_ts(cc.node.range())), }); } } @@ -207,13 +207,19 @@ impl JavaParser { .utf8_text(source.as_bytes()) .unwrap_or_default() .to_string(); + let is_varargs = captures + .iter() + .find(|c| c.index == self.indices.param_match) + .map(|c| c.node.kind() == "spread_parameter") + .unwrap_or(false); if !parameters .iter() - .any(|p| p.name == n && p.type_ref == t_ref) + .any(|p| p.name == n && p.type_ref == t_ref && p.is_varargs == is_varargs) { parameters.push(JavaParameter { type_ref: t_ref, name: n, + is_varargs, }); } self.generate_typed_as_edges(t_node, source, &fqn_id, relations); @@ -234,12 +240,12 @@ impl JavaParser { fn add_modifier(&self, element: &mut JavaIndexMetadata, m_str: String) { match element { - JavaIndexMetadata::Class { modifiers } => { + JavaIndexMetadata::Class { modifiers, .. } => { if !modifiers.contains(&m_str) { modifiers.push(m_str); } } - JavaIndexMetadata::Interface { modifiers } => { + JavaIndexMetadata::Interface { modifiers, .. } => { if !modifiers.contains(&m_str) { modifiers.push(m_str); } diff --git a/crates/lang-java/src/parser/index.rs b/crates/lang-java/src/parser/index.rs index 4a94851..26f21c4 100644 --- a/crates/lang-java/src/parser/index.rs +++ b/crates/lang-java/src/parser/index.rs @@ -1,5 +1,5 @@ use super::JavaParser; -use naviscope_api::models::graph::{DisplaySymbolLocation, NodeKind}; +use naviscope_api::models::graph::{DisplaySymbolLocation, NodeKind, ResolutionStatus}; use naviscope_plugin::utils::range_from_ts; use naviscope_plugin::{GlobalParseResult, IndexNode, IndexRelation, ParseOutput}; use std::sync::Arc; @@ -59,6 +59,8 @@ impl JavaParser { name: e.name.clone(), kind, lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::Project, + status: ResolutionStatus::Resolved, location, metadata: Arc::new(e.element), } diff --git a/crates/lang-java/src/parser/lsp.rs b/crates/lang-java/src/parser/lsp.rs deleted file mode 100644 index e559224..0000000 --- a/crates/lang-java/src/parser/lsp.rs +++ /dev/null @@ -1,208 +0,0 @@ -use super::JavaParser; -use naviscope_api::models::graph::NodeKind; -use naviscope_plugin::LspParser; -use naviscope_plugin::utils::{RawSymbol, build_symbol_hierarchy, line_col_at_to_offset}; -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_api::models::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_plugin::utils::range_from_ts(e.node.range()), - selection_range: e - .node - .child_by_field_name("name") - .map(|n| naviscope_plugin::utils::range_from_ts(n.range())) - .unwrap_or_else(|| naviscope_plugin::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_api::models::SymbolResolution, - ) -> Vec { - let mut ranges = Vec::new(); - - // 1. Extract the identifier name and intent - let (name, intent) = match target { - naviscope_api::models::SymbolResolution::Local(range, _) => { - // For local symbols, we extract the name directly from the source at the declaration range - let start = line_col_at_to_offset(source, range.start_line, range.start_col); - let end = 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_api::models::SymbolResolution::Precise(fqn, intent) => ( - fqn.split(|c| c == '.' || c == '#' || c == '$') - .last() - .unwrap_or(fqn) - .to_string(), - *intent, - ), - naviscope_api::models::SymbolResolution::Global(fqn) => ( - fqn.split(|c| c == '.' || c == '#' || c == '$') - .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_api::models::symbol::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 index eceec15..e3b6534 100644 --- a/crates/lang-java/src/parser/mod.rs +++ b/crates/lang-java/src/parser/mod.rs @@ -5,7 +5,6 @@ use tree_sitter::{Query, StreamingIterator, Tree}; mod ast; mod constants; mod index; -mod lsp; mod naming; mod scope; mod types; @@ -58,6 +57,12 @@ 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_package_and_imports( &self, tree: &Tree, diff --git a/crates/lang-java/src/parser/naming.rs b/crates/lang-java/src/parser/naming.rs index 31d65dd..2489d76 100644 --- a/crates/lang-java/src/parser/naming.rs +++ b/crates/lang-java/src/parser/naming.rs @@ -70,7 +70,15 @@ impl JavaParser { 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()) { - hierarchy.push((pk, n_text.to_string())); + let id_pk = match pk { + naviscope_api::models::graph::NodeKind::Interface + | naviscope_api::models::graph::NodeKind::Enum + | naviscope_api::models::graph::NodeKind::Annotation => { + naviscope_api::models::graph::NodeKind::Class + } + _ => pk, + }; + hierarchy.push((id_pk, n_text.to_string())); } } } @@ -82,7 +90,40 @@ impl JavaParser { parts.extend(hierarchy); // Add self at the end - parts.push((kind, self_name)); + // STABILITY NOTE: For Java, we use NodeKind::Class for all Type-like entities + // in the ID to ensure cross-file references (which often don't know the exact kind) + // can resolve correctly. The actual node.kind will still be accurate. + let id_kind = match kind { + naviscope_api::models::graph::NodeKind::Interface + | naviscope_api::models::graph::NodeKind::Enum + | naviscope_api::models::graph::NodeKind::Annotation => { + naviscope_api::models::graph::NodeKind::Class + } + _ => kind, + }; + + // For methods and constructors, produce a signature-based name like + // `target(int,java.lang.String)` so that overloaded methods get + // distinct FQN IDs in the graph. + let id_name = match id_kind { + naviscope_api::models::graph::NodeKind::Method + | naviscope_api::models::graph::NodeKind::Constructor => { + if let Some(decl_node) = name_node.parent() { + let param_types = self + .extract_method_parameters(decl_node, source) + .into_iter() + .map(|p| p.type_ref) + .collect::>(); + crate::naming::build_java_method_name(&self_name, ¶m_types) + } else { + // Fallback: no parent available, use bare name with empty params + crate::naming::format_method_name(&self_name, &[]) + } + } + _ => self_name, + }; + + parts.push((id_kind, id_name)); naviscope_api::models::symbol::NodeId::Structured(parts) } diff --git a/crates/lang-java/src/parser/scope.rs b/crates/lang-java/src/parser/scope.rs index a3f4b89..e726a30 100644 --- a/crates/lang-java/src/parser/scope.rs +++ b/crates/lang-java/src/parser/scope.rs @@ -1,54 +1,8 @@ use super::JavaParser; use naviscope_api::models::SymbolIntent; -use naviscope_api::models::symbol::Range; -use naviscope_plugin::utils::range_from_ts; 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, @@ -61,7 +15,7 @@ impl JavaParser { return SymbolIntent::Method; } } - SymbolIntent::Type // Likely the receiver/object + SymbolIntent::Unknown // Could be variable, field, or type - resolver will determine } "method_reference" => SymbolIntent::Type, "object_creation_expression" => { @@ -82,7 +36,7 @@ impl JavaParser { return SymbolIntent::Field; } } - SymbolIntent::Type // Likely the receiver/object + SymbolIntent::Unknown // Could be variable, field, or type - resolver will determine } "class_declaration" | "interface_declaration" @@ -105,69 +59,4 @@ impl JavaParser { } } } - - 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 index ca3c29b..574de3c 100644 --- a/crates/lang-java/src/parser/types.rs +++ b/crates/lang-java/src/parser/types.rs @@ -83,17 +83,131 @@ impl JavaParser { is_upper_bound: is_upper, } } + "integral_type" + | "floating_point_type" + | "boolean_type" + | "void_type" + | "_unannotated_type" => { + let text = node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + if text.is_empty() { + TypeRef::Unknown + } else { + TypeRef::Raw(text) + } + } + "type_identifier" | "scoped_type_identifier" => { + let text = node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + if text.is_empty() { + TypeRef::Unknown + } else { + // At parser stage these identifiers are syntactic type names and may not + // be fully resolved; keep them as Raw and normalize later in semantic phase. + TypeRef::Raw(text) + } + } _ => { let text = node .utf8_text(source.as_bytes()) .unwrap_or_default() .to_string(); + // Default fallback if text.is_empty() { TypeRef::Unknown } else { + // Start uppercase -> Id, lowercase -> Raw? Or just Raw (safer for primitives missed) + // If it ends with 'type', it's likely primitive node we missed? + // Safe logic: If primitive keyword -> Raw. Else Id. + // But we can just use Raw for now as fallback. TypeRef::Raw(text) } } } } + + /// Extract parameter types from a method/constructor declaration node. + /// + /// This reads the `formal_parameters` child directly from the AST so that + /// we can build signature-based FQNs at ID-generation time, **before** the + /// enrichment stage populates `JavaIndexMetadata.parameters`. + /// + /// For varargs (`spread_parameter`), the type is wrapped in `TypeRef::Array` + /// to match Java's bytecode representation. + pub fn extract_param_types_from_declaration( + &self, + declaration_node: Node, + source: &str, + ) -> Vec { + self.extract_method_parameters(declaration_node, source) + .into_iter() + .map(|p| p.type_ref) + .collect() + } + + /// Extracts full parameter metadata for parsing. + pub fn extract_method_parameters( + &self, + declaration_node: tree_sitter::Node, + source: &str, + ) -> Vec { + let Some(params_node) = declaration_node.child_by_field_name("parameters") else { + return vec![]; + }; + + let mut result = Vec::new(); + let mut cursor = params_node.walk(); + for child in params_node.children(&mut cursor) { + match child.kind() { + "formal_parameter" => { + if let Some(type_node) = child.child_by_field_name("type") { + let type_ref = self.parse_type_node(type_node, source); + let name_node = child.child_by_field_name("name"); + let name = name_node + .and_then(|n| n.utf8_text(source.as_bytes()).ok()) + .unwrap_or("arg") + .to_string(); + + result.push(crate::model::JavaParameter { + name, + type_ref, + is_varargs: false, + }); + } + } + "spread_parameter" => { + let mut type_ref = naviscope_api::models::TypeRef::Unknown; + let mut name = "arg".to_string(); + + // Find type (first named node that is not variable_declarator) + // Find name (in variable_declarator) + let mut inner_cursor = child.walk(); + for gc in child.children(&mut inner_cursor) { + if gc.kind() == "variable_declarator" { + if let Some(n) = gc.child_by_field_name("name") { + if let Ok(text) = n.utf8_text(source.as_bytes()) { + name = text.to_string(); + } + } + } else if gc.kind() != "..." && gc.is_named() { + let base = self.parse_type_node(gc, source); + type_ref = crate::naming::varargs_to_array_type(&base); + } + } + + result.push(crate::model::JavaParameter { + name, + type_ref, + is_varargs: true, + }); + } + _ => {} + } + } + result + } } diff --git a/crates/lang-java/src/queries/java_definitions.scm b/crates/lang-java/src/queries/java_definitions.scm index 57f7633..9f91428 100644 --- a/crates/lang-java/src/queries/java_definitions.scm +++ b/crates/lang-java/src/queries/java_definitions.scm @@ -65,6 +65,11 @@ type: (_) @param_type name: (identifier) @param_name) @param_match +(spread_parameter + (_unannotated_type) @param_type + (variable_declarator + name: (identifier) @param_name)) @param_match + ;; Call and Instantiation (method_invocation name: (identifier) @call_name) @method_call diff --git a/crates/lang-java/src/resolver/context.rs b/crates/lang-java/src/resolve/context.rs similarity index 100% rename from crates/lang-java/src/resolver/context.rs rename to crates/lang-java/src/resolve/context.rs diff --git a/crates/lang-java/src/resolve/external/converter.rs b/crates/lang-java/src/resolve/external/converter.rs new file mode 100644 index 0000000..2bfe681 --- /dev/null +++ b/crates/lang-java/src/resolve/external/converter.rs @@ -0,0 +1,137 @@ +use naviscope_api::models::TypeRef; +use ristretto_classfile::{BaseType, ClassAccessFlags, FieldAccessFlags, FieldType, MethodAccessFlags}; + +pub struct JavaTypeConverter; + +impl JavaTypeConverter { + pub fn convert_field(ty: &FieldType) -> TypeRef { + Self::convert_type(ty) + } + + pub fn convert_method( + descriptor: &str, + is_varargs: bool, + ) -> Result<(TypeRef, Vec), ristretto_classfile::Error> { + let (params, ret) = FieldType::parse_method_descriptor(descriptor)?; + let return_type = match ret { + None => TypeRef::Raw("void".to_string()), + Some(field_type) => Self::convert_field(&field_type), + }; + + let parameters = params + .iter() + .enumerate() + .map(|(i, field_type)| crate::model::JavaParameter { + name: format!("arg{}", i), + type_ref: Self::convert_field(field_type), + is_varargs: is_varargs && i == params.len().saturating_sub(1), + }) + .collect(); + + Ok((return_type, parameters)) + } + + pub fn convert_type(ty: &FieldType) -> TypeRef { + match ty { + FieldType::Base(BaseType::Byte) => TypeRef::Raw("byte".to_string()), + FieldType::Base(BaseType::Char) => TypeRef::Raw("char".to_string()), + FieldType::Base(BaseType::Double) => TypeRef::Raw("double".to_string()), + FieldType::Base(BaseType::Float) => TypeRef::Raw("float".to_string()), + FieldType::Base(BaseType::Int) => TypeRef::Raw("int".to_string()), + FieldType::Base(BaseType::Long) => TypeRef::Raw("long".to_string()), + FieldType::Base(BaseType::Short) => TypeRef::Raw("short".to_string()), + FieldType::Base(BaseType::Boolean) => TypeRef::Raw("boolean".to_string()), + FieldType::Object(name) => TypeRef::Id(name.replace('/', ".")), + FieldType::Array(component) => { + let mut dimensions = 1usize; + let mut current = component.as_ref(); + while let FieldType::Array(inner) = current { + dimensions += 1; + current = inner.as_ref(); + } + + TypeRef::Array { + element: Box::new(Self::convert_type(current)), + dimensions, + } + } + } + } +} + +pub struct JavaModifierConverter; + +impl JavaModifierConverter { + pub fn parse_class(flags: ClassAccessFlags) -> Vec { + let mut mods = Vec::new(); + if flags.contains(ClassAccessFlags::PUBLIC) { + mods.push("public".into()); + } + if flags.contains(ClassAccessFlags::FINAL) { + mods.push("final".into()); + } + if flags.contains(ClassAccessFlags::ABSTRACT) && !flags.contains(ClassAccessFlags::INTERFACE) + { + mods.push("abstract".into()); + } + mods + } + + pub fn parse_field(flags: FieldAccessFlags) -> Vec { + let mut mods = Vec::new(); + if flags.contains(FieldAccessFlags::PUBLIC) { + mods.push("public".into()); + } + if flags.contains(FieldAccessFlags::PRIVATE) { + mods.push("private".into()); + } + if flags.contains(FieldAccessFlags::PROTECTED) { + mods.push("protected".into()); + } + if flags.contains(FieldAccessFlags::STATIC) { + mods.push("static".into()); + } + if flags.contains(FieldAccessFlags::FINAL) { + mods.push("final".into()); + } + if flags.contains(FieldAccessFlags::VOLATILE) { + mods.push("volatile".into()); + } + if flags.contains(FieldAccessFlags::TRANSIENT) { + mods.push("transient".into()); + } + mods + } + + pub fn parse_method(flags: MethodAccessFlags) -> Vec { + let mut mods = Vec::new(); + if flags.contains(MethodAccessFlags::PUBLIC) { + mods.push("public".into()); + } + if flags.contains(MethodAccessFlags::PRIVATE) { + mods.push("private".into()); + } + if flags.contains(MethodAccessFlags::PROTECTED) { + mods.push("protected".into()); + } + if flags.contains(MethodAccessFlags::STATIC) { + mods.push("static".into()); + } + if flags.contains(MethodAccessFlags::FINAL) { + mods.push("final".into()); + } + if flags.contains(MethodAccessFlags::SYNCHRONIZED) { + mods.push("synchronized".into()); + } + if flags.contains(MethodAccessFlags::NATIVE) { + mods.push("native".into()); + } + if flags.contains(MethodAccessFlags::ABSTRACT) { + mods.push("abstract".into()); + } + if flags.contains(MethodAccessFlags::STRICT) { + mods.push("strictfp".into()); + } + mods + } +} diff --git a/crates/lang-java/src/resolve/external/mod.rs b/crates/lang-java/src/resolve/external/mod.rs new file mode 100644 index 0000000..3c52b76 --- /dev/null +++ b/crates/lang-java/src/resolve/external/mod.rs @@ -0,0 +1,582 @@ +use naviscope_plugin::{ + AssetEntry, AssetIndexer, AssetSource, AssetSourceLocator, GlobalParseResult, IndexNode, + StubGenerator, +}; +use ristretto_classfile::{ClassAccessFlags, ClassFile, MethodAccessFlags}; +use ristretto_jimage::Image; +use std::collections::HashSet; +use std::fs::File; +use std::io::{Cursor, Read}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use zip::ZipArchive; + +mod converter; +use converter::{JavaModifierConverter, JavaTypeConverter}; + +pub struct JavaExternalResolver; + +impl JavaExternalResolver { + fn extract_packages_from_zip( + archive: &mut ZipArchive, + ) -> std::result::Result, Box> { + let mut packages = HashSet::new(); + for i in 0..archive.len() { + let entry = archive.by_index(i)?; + let name = entry.name(); + + if name.ends_with(".class") && !name.contains('$') { + if let Some(slash_idx) = name.rfind('/') { + let package = name[..slash_idx].replace('/', "."); + if !package.starts_with("META-INF") { + packages.insert(package); + } + } + } + } + Ok(packages) + } + + fn extract_packages_from_jimage(image: &Image) -> HashSet { + let mut packages = HashSet::new(); + for resource_result in image.iter() { + if let Ok(resource) = resource_result { + if resource.extension() == "class" && !resource.base().contains('$') { + let parent = resource.parent(); + let path_without_module = if parent.starts_with('/') { + let s = &parent[1..]; + if let Some(idx) = s.find('/') { + &s[idx + 1..] + } else { + s + } + } else { + &parent + }; + + let package = path_without_module.replace('/', "."); + if !package.is_empty() { + packages.insert(package); + } + } + } + } + packages + } +} + +impl JavaExternalResolver { + fn load_class_bytes_for_fqn( + &self, + class_fqn: &str, + asset: &Path, + ) -> std::result::Result, Box> { + let file = File::open(asset)?; + + if let Ok(mut archive) = ZipArchive::new(file) { + let class_path = class_fqn.replace('.', "/") + ".class"; + if let Ok(mut entry) = archive.by_name(&class_path) { + let mut b = Vec::new(); + entry.read_to_end(&mut b)?; + return Ok(b); + } + + let inner_path = class_fqn.replace('.', "/"); + if let Some(idx) = inner_path.rfind('/') { + let mut try_inner = inner_path.clone(); + try_inner.replace_range(idx..idx + 1, "$"); + if let Ok(mut entry) = archive.by_name(&(try_inner + ".class")) { + let mut b = Vec::new(); + entry.read_to_end(&mut b)?; + return Ok(b); + } + } + } else { + let image = Image::from_file(asset)?; + let class_path = class_fqn.replace('.', "/") + ".class"; + for resource_result in image.iter() { + if let Ok(resource) = resource_result { + let name = resource.name(); + if name == class_path || name.ends_with(&format!("/{}", class_path)) { + return Ok(resource.data().to_vec()); + } + } + } + + let inner_path = class_fqn.replace('.', "/"); + if let Some(idx) = inner_path.rfind('/') { + let mut try_inner = inner_path.clone(); + try_inner.replace_range(idx..idx + 1, "$"); + let try_inner_path = try_inner + ".class"; + + for resource_result in image.iter() { + if let Ok(resource) = resource_result { + let name = resource.name(); + if name == try_inner_path || name.ends_with(&format!("/{}", try_inner_path)) + { + return Ok(resource.data().to_vec()); + } + } + } + } + } + + Err(format!("Class {} not found in {}", class_fqn, asset.display()).into()) + } + + fn generate_related_for_class( + &self, + class_fqn: &str, + asset: &Path, + ) -> std::result::Result, Box> { + let bytes = self.load_class_bytes_for_fqn(class_fqn, asset)?; + let class = ClassFile::from_bytes(&mut Cursor::new(bytes)) + .map_err(|e| format!("Failed to parse class: {e:?}"))?; + + let mut out = Vec::new(); + let class_simple_name = class_fqn.split('.').next_back().unwrap_or(class_fqn); + + for field in &class.fields { + let field_name = class + .constant_pool + .try_get_utf8(field.name_index) + .map_err(|e| format!("Failed to parse field name: {e:?}"))?; + let node_fqn = crate::naming::build_member_fqn(class_fqn, field_name); + let type_ref = JavaTypeConverter::convert_field(&field.field_type); + let modifiers = JavaModifierConverter::parse_field(field.access_flags); + let metadata = crate::model::JavaIndexMetadata::Field { + modifiers, + type_ref, + }; + out.push(IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat(node_fqn), + name: field_name.to_string(), + kind: naviscope_api::models::graph::NodeKind::Field, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(metadata), + }); + } + + for method in &class.methods { + let method_name = class + .constant_pool + .try_get_utf8(method.name_index) + .map_err(|e| format!("Failed to parse method name: {e:?}"))?; + + if method_name == "" { + continue; + } + + let method_descriptor = class + .constant_pool + .try_get_utf8(method.descriptor_index) + .map_err(|e| format!("Failed to parse method descriptor: {e:?}"))?; + let is_varargs = method.access_flags.contains(MethodAccessFlags::VARARGS); + let (return_type, parameters) = + JavaTypeConverter::convert_method(method_descriptor, is_varargs) + .map_err(|e| format!("Failed to parse method signature: {e:?}"))?; + + let display_name = if method_name == "" { + class_simple_name.to_string() + } else { + method_name.to_string() + }; + let param_types: Vec = + parameters.iter().map(|p| p.type_ref.clone()).collect(); + let signed_name = crate::naming::build_java_method_name(&display_name, ¶m_types); + let node_fqn = crate::naming::build_member_fqn(class_fqn, &signed_name); + + let modifiers = JavaModifierConverter::parse_method(method.access_flags); + let metadata = crate::model::JavaIndexMetadata::Method { + modifiers, + return_type, + parameters, + is_constructor: method_name == "", + }; + out.push(IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat(node_fqn), + name: display_name, + kind: if method_name == "" { + naviscope_api::models::graph::NodeKind::Constructor + } else { + naviscope_api::models::graph::NodeKind::Method + }, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(metadata), + }); + } + + Ok(out) + } + + pub fn index_asset( + &self, + asset: &Path, + ) -> std::result::Result, Box> { + // Detect format via magic bytes + let mut file = File::open(asset)?; + let mut magic = [0u8; 4]; + if std::io::Read::read(&mut file, &mut magic).is_err() { + return Ok(vec![]); + } + + let packages: HashSet = match &magic { + // ZIP magic: PK\x03\x04 or PK\x05\x06 (empty) or PK\x07\x08 (spanned) + [0x50, 0x4B, _, _] => { + // Reset file position and parse as ZIP + std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(0))?; + let mut archive = ZipArchive::new(file)?; + Self::extract_packages_from_zip(&mut archive)? + } + // JImage magic: CAFEDADA (big-endian) or DADAFECA (little-endian) + [0xCA, 0xFE, 0xDA, 0xDA] | [0xDA, 0xDA, 0xFE, 0xCA] => { + drop(file); // Close file handle before reopening via ristretto_jimage + let image = Image::from_file(asset)?; + Self::extract_packages_from_jimage(&image) + } + _ => { + // Unknown format, skip silently + return Ok(vec![]); + } + }; + + let mut result: Vec = packages.into_iter().collect(); + result.sort(); + Ok(result) + } + + pub fn generate_stub( + &self, + fqn: &str, + asset: &Path, + ) -> std::result::Result> { + let file = File::open(asset)?; + let mut current_fqn = fqn.to_string(); + let mut member_parts = Vec::new(); + + let bytes = match ZipArchive::new(file) { + Ok(mut archive) => { + let mut entry = loop { + let class_path = current_fqn.replace('.', "/") + ".class"; + if let Ok(e) = archive.by_name(&class_path) { + break e; + } + + let inner_path = current_fqn.replace('.', "/"); + if let Some(idx) = inner_path.rfind('/') { + let mut try_inner = inner_path.clone(); + try_inner.replace_range(idx..idx + 1, "$"); + if let Ok(e) = archive.by_name(&(try_inner + ".class")) { + break e; + } + } + + if let Some(idx) = current_fqn.rfind('.') { + member_parts.push(current_fqn[idx + 1..].to_string()); + current_fqn = current_fqn[..idx].to_string(); + } else { + return Err(format!( + "Could not find class for {} in {}", + fqn, + asset.display() + ) + .into()); + } + }; + + let mut b = Vec::new(); + entry.read_to_end(&mut b)?; + b + } + Err(_) => { + // Try JImage + let image = Image::from_file(asset)?; + let mut bytes: Option> = None; + + loop { + let class_path = current_fqn.replace('.', "/") + ".class"; + // Since we don't know the module, we search all modules + for resource_result in image.iter() { + if let Ok(resource) = resource_result { + let name = resource.name(); + if name == class_path || name.ends_with(&format!("/{}", class_path)) { + bytes = Some(resource.data().to_vec()); + break; + } + } + } + + if bytes.is_some() { + break; + } + + // Try inner class + let inner_path = current_fqn.replace('.', "/"); + if let Some(idx) = inner_path.rfind('/') { + let mut try_inner = inner_path.clone(); + try_inner.replace_range(idx..idx + 1, "$"); + let try_inner_path = try_inner + ".class"; + + for resource_result in image.iter() { + if let Ok(resource) = resource_result { + let name = resource.name(); + if name == try_inner_path + || name.ends_with(&format!("/{}", try_inner_path)) + { + bytes = Some(resource.data().to_vec()); + break; + } + } + } + } + + if bytes.is_some() { + break; + } + + if let Some(idx) = current_fqn.rfind('.') { + member_parts.push(current_fqn[idx + 1..].to_string()); + current_fqn = current_fqn[..idx].to_string(); + } else { + return Err(format!( + "Could not find class for {} in jimage {}", + fqn, + asset.display() + ) + .into()); + } + } + + bytes.ok_or_else(|| format!("Class {} not found in jimage", fqn))? + } + }; + + let class = ClassFile::from_bytes(&mut Cursor::new(bytes)) + .map_err(|e| format!("Failed to parse class: {e:?}"))?; + + if member_parts.is_empty() { + let name = fqn.split('.').last().unwrap_or(fqn).to_string(); + let kind = if class + .access_flags + .contains(ClassAccessFlags::INTERFACE) + { + naviscope_api::models::graph::NodeKind::Interface + } else if class + .access_flags + .contains(ClassAccessFlags::ANNOTATION) + { + naviscope_api::models::graph::NodeKind::Annotation + } else if class + .access_flags + .contains(ClassAccessFlags::ENUM) + { + naviscope_api::models::graph::NodeKind::Enum + } else { + naviscope_api::models::graph::NodeKind::Class + }; + + let modifiers = JavaModifierConverter::parse_class(class.access_flags); + let metadata = crate::model::JavaIndexMetadata::Class { + modifiers, + type_parameters: vec![], + }; + + return Ok(IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat(fqn.to_string()), + name, + kind, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(metadata), + }); + } + + member_parts.reverse(); + let member_name = member_parts.join("."); + + for field in &class.fields { + let field_name = class + .constant_pool + .try_get_utf8(field.name_index) + .map_err(|e| format!("Failed to parse field name: {e:?}"))?; + + if field_name == member_name { + let type_ref = JavaTypeConverter::convert_field(&field.field_type); + let modifiers = JavaModifierConverter::parse_field(field.access_flags); + let metadata = crate::model::JavaIndexMetadata::Field { + modifiers, + type_ref, + }; + return Ok(IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat(fqn.to_string()), + name: member_name.clone(), + kind: naviscope_api::models::graph::NodeKind::Field, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(metadata), + }); + } + } + + for method in &class.methods { + let method_name = class + .constant_pool + .try_get_utf8(method.name_index) + .map_err(|e| format!("Failed to parse method name: {e:?}"))?; + + if method_name == member_name { + let method_descriptor = class + .constant_pool + .try_get_utf8(method.descriptor_index) + .map_err(|e| format!("Failed to parse method descriptor: {e:?}"))?; + let is_varargs = method.access_flags.contains(MethodAccessFlags::VARARGS); + let (return_type, parameters) = + JavaTypeConverter::convert_method(method_descriptor, is_varargs) + .map_err(|e| format!("Failed to parse method signature: {e:?}"))?; + let modifiers = JavaModifierConverter::parse_method(method.access_flags); + let metadata = crate::model::JavaIndexMetadata::Method { + modifiers, + return_type, + parameters, + is_constructor: member_name == "", + }; + return Ok(IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat(fqn.to_string()), + name: if member_name == "" { + fqn.split('.').nth_back(1).unwrap_or(fqn).to_string() + } else { + member_name.clone() + }, + kind: if member_name == "" { + naviscope_api::models::graph::NodeKind::Constructor + } else { + naviscope_api::models::graph::NodeKind::Method + }, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(metadata), + }); + } + } + + Err(format!("Member {} not found in class {}", member_name, current_fqn).into()) + } + + pub fn resolve_source( + &self, + _fqn: &str, + _source_asset: &Path, + ) -> std::result::Result> { + Err("Source resolution not yet implemented".into()) + } +} + +impl AssetIndexer for JavaExternalResolver { + fn can_index(&self, asset: &Path) -> bool { + let ext = asset + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + let file_name = asset.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + ext == "jar" || ext == "jmod" || file_name == "modules" + } + + fn index( + &self, + asset: &Path, + ) -> std::result::Result, Box> { + self.index_asset(asset) + } +} + +impl StubGenerator for JavaExternalResolver { + fn can_generate(&self, asset: &Path) -> bool { + self.can_index(asset) + } + + fn generate_stubs( + &self, + fqn: &str, + entry: &AssetEntry, + ) -> std::result::Result, Box> { + if fqn.contains('#') { + return Ok(vec![self.generate_stub(fqn, &entry.path)?]); + } + let primary = self.generate_stub(fqn, &entry.path)?; + let mut nodes = vec![primary]; + if let Ok(related) = self.generate_related_for_class(fqn, &entry.path) { + nodes.extend(related); + } + Ok(nodes) + } +} + +impl AssetSourceLocator for JavaExternalResolver { + fn locate_source(&self, entry: &AssetEntry) -> Option { + if !matches!( + entry.source, + AssetSource::Gradle { .. } | AssetSource::Maven { .. } | AssetSource::Local { .. } + ) { + return None; + } + let file_name = entry.path.file_name()?.to_string_lossy(); + if !file_name.ends_with(".jar") || file_name.ends_with("-sources.jar") { + return None; + } + let mut source_name = file_name.to_string(); + source_name.truncate(source_name.len() - 4); + source_name.push_str("-sources.jar"); + let source_path = entry.path.with_file_name(source_name); + if source_path.exists() { + Some(source_path) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::tempdir; + + fn create_test_jar(path: &Path) { + let file = File::create(path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default(); + + zip.start_file("com/example/Test.class", options).unwrap(); + // CAFEBABE header + zip.write_all(&[0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x34]) + .unwrap(); + + zip.finish().unwrap(); + } + + #[test] + fn test_index_asset() { + let dir = tempdir().unwrap(); + let jar_path = dir.path().join("test.jar"); + create_test_jar(&jar_path); + + let resolver = JavaExternalResolver; + let packages = resolver.index_asset(&jar_path).unwrap(); + + assert_eq!(packages, vec!["com.example".to_string()]); + } +} diff --git a/crates/lang-java/src/resolve/lang.rs b/crates/lang-java/src/resolve/lang.rs new file mode 100644 index 0000000..01ef1f2 --- /dev/null +++ b/crates/lang-java/src/resolve/lang.rs @@ -0,0 +1,480 @@ +use crate::JavaPlugin; +use crate::inference::adapters::HeuristicAdapter; +use crate::inference::{TypeProvider, TypeResolutionContext}; +use crate::model::JavaIndexMetadata; +use crate::resolve::context::ResolutionContext; +use naviscope_api::models::graph::{EdgeType, GraphEdge, NodeKind}; +use naviscope_api::models::symbol::{NodeId, SymbolResolution}; +use naviscope_plugin::{ + GlobalParseResult, GraphOp, IndexNode, IndexRelation, ParsedContent, ParsedFile, ProjectContext, + ResolvedUnit, SourceAnalyzeArtifact, SourceCollectArtifact, SourceIndexCap, +}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +struct CollectOutput { + unit: ResolvedUnit, + container_id: NodeId, +} + +struct AnalyzeOutput { + unit: ResolvedUnit, + res_ctx: TypeResolutionContext, + bound_relations: Vec, + deferred_relations: Vec, +} + +struct BoundRelation { + source_id: NodeId, + target_id: NodeId, + edge: GraphEdge, +} + +struct DeferredRelation { + raw_target: String, +} + +struct JavaCollectArtifact { + parse_result: GlobalParseResult, + collected: CollectOutput, + type_symbols: Vec, + method_symbols: Vec, + provided_dependency_symbols: Vec, + required_dependency_symbols: Vec, +} + +struct JavaAnalyzeArtifact { + parse_result: GlobalParseResult, + analyzed: AnalyzeOutput, +} + +impl SourceCollectArtifact for JavaCollectArtifact { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn into_any(self: Box) -> Box { + self + } + + fn collected_type_symbols(&self) -> &[String] { + &self.type_symbols + } + + fn collected_method_symbols(&self) -> &[String] { + &self.method_symbols + } + + fn provided_dependency_symbols(&self) -> &[String] { + &self.provided_dependency_symbols + } + + fn required_dependency_symbols(&self) -> &[String] { + &self.required_dependency_symbols + } +} + +impl SourceAnalyzeArtifact for JavaAnalyzeArtifact { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} + +impl SourceIndexCap for JavaPlugin { + fn collect_source( + &self, + file: &ParsedFile, + context: &ProjectContext, + ) -> std::result::Result, Box> + { + let parse_result_owned; + let parse_result = match &file.content { + ParsedContent::Language(res) => res, + ParsedContent::Unparsed(src) => { + if file.path().extension().is_some_and(|e| e == "java") { + parse_result_owned = self.parser.parse_file(src, Some(&file.file.path))?; + &parse_result_owned + } else { + return Err("Unsupported non-java file in Java collect_source".into()); + } + } + ParsedContent::Lazy => { + if file.path().extension().is_some_and(|e| e == "java") { + let src = std::fs::read_to_string(file.path()).map_err(|e| { + format!("Failed to read file {}: {}", file.path().display(), e) + })?; + parse_result_owned = self.parser.parse_file(&src, Some(&file.file.path))?; + &parse_result_owned + } else { + return Err("Unsupported non-java file in Java collect_source".into()); + } + } + _ => return Err("Unsupported parsed content in Java collect_source".into()), + }; + + let collected = self.collect_pass(file, context, parse_result); + let type_symbols: Vec = parse_result + .output + .nodes + .iter() + .filter(|node| { + matches!( + node.kind, + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation + ) + }) + .map(|node| node.id.to_string()) + .collect(); + let method_symbols: Vec = parse_result + .output + .nodes + .iter() + .filter(|node| matches!(node.kind, NodeKind::Method | NodeKind::Constructor)) + .map(|node| node.id.to_string()) + .collect(); + let mut provided_dependency_symbols = type_symbols.clone(); + if let Some(pkg) = &parse_result.package_name { + provided_dependency_symbols.push(format!("package:{pkg}")); + } + let mut required_dependency_symbols = Vec::new(); + if let Some(pkg) = &parse_result.package_name { + required_dependency_symbols.push(format!("package:{pkg}")); + } + for import in &parse_result.imports { + if let Some(pkg) = import.strip_suffix(".*") { + required_dependency_symbols.push(format!("package:{pkg}")); + } else { + required_dependency_symbols.push(import.clone()); + } + } + + Ok(Box::new(JavaCollectArtifact { + parse_result: parse_result.clone(), + collected, + type_symbols, + method_symbols, + provided_dependency_symbols, + required_dependency_symbols, + })) + } + + fn analyze_source( + &self, + collected: Box, + context: &ProjectContext, + ) -> std::result::Result, Box> + { + let collected = collected + .into_any() + .downcast::() + .map_err(|_| "Java analyze_source received incompatible collect artifact")?; + let mut analyzed = self.analyze_pass(collected.collected, &collected.parse_result, context); + self.bind_all_relations(&mut analyzed, &collected.parse_result); + + Ok(Box::new(JavaAnalyzeArtifact { + parse_result: collected.parse_result, + analyzed, + })) + } + + fn lower_source( + &self, + analyzed: Box, + _context: &ProjectContext, + ) -> std::result::Result> { + let analyzed = analyzed + .into_any() + .downcast::() + .map_err(|_| "Java lower_source received incompatible analyze artifact")?; + self.lower_pass(analyzed.analyzed, &analyzed.parse_result) + } +} + +impl JavaPlugin { + fn collect_pass( + &self, + file: &ParsedFile, + context: &ProjectContext, + parse_result: &GlobalParseResult, + ) -> CollectOutput { + let mut unit = ResolvedUnit::new(); + 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_parts: Vec<_> = pkg_name + .split('.') + .map(|s| (NodeKind::Package, s.to_string())) + .collect(); + let package_id = NodeId::Structured(package_parts); + + let package_node = IndexNode { + id: package_id.clone(), + name: pkg_name.to_string(), + kind: NodeKind::Package, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, + location: None, + metadata: Arc::new(JavaIndexMetadata::Package), + }; + + unit.add_node(package_node); + unit.add_edge( + module_id.clone().into(), + package_id.clone(), + GraphEdge::new(EdgeType::Contains), + ); + package_id + } else { + module_id.into() + }; + + CollectOutput { + unit, + container_id, + } + } + + fn analyze_pass( + &self, + collected: CollectOutput, + parse_result: &GlobalParseResult, + context: &ProjectContext, + ) -> AnalyzeOutput { + let mut known_types = HashSet::::new(); + known_types.extend(context.symbol_table.type_symbols.iter().cloned()); + for node in &parse_result.output.nodes { + if matches!( + node.kind, + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation + ) { + known_types.insert(node.id.to_string()); + } + } + let res_ctx = TypeResolutionContext { + package: parse_result.package_name.clone(), + imports: parse_result.imports.clone(), + type_parameters: Vec::new(), + known_fqns: known_types.into_iter().collect(), + }; + + let type_provider = HeuristicAdapter; + let mut unit = collected.unit; + + 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, &type_provider, &res_ctx); + for param in parameters { + param.type_ref = self.resolve_type_ref( + ¶m.type_ref, + &type_provider, + &res_ctx, + ); + } + } + JavaIndexMetadata::Field { type_ref, .. } => { + *type_ref = self.resolve_type_ref(type_ref, &type_provider, &res_ctx); + } + _ => {} + } + 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( + collected.container_id.clone().into(), + node.id.clone(), + GraphEdge::new(EdgeType::Contains), + ); + } + } + + AnalyzeOutput { + unit, + res_ctx, + bound_relations: Vec::new(), + deferred_relations: Vec::new(), + } + } + + fn bind_all_relations( + &self, + analyzed: &mut AnalyzeOutput, + parse_result: &GlobalParseResult, + ) { + for rel in &parse_result.output.relations { + self.bind_relation(rel, parse_result, analyzed); + } + } + + fn lower_pass( + &self, + mut analyzed: AnalyzeOutput, + _parse_result: &GlobalParseResult, + ) -> std::result::Result> { + for bound in analyzed.bound_relations.drain(..) { + analyzed + .unit + .add_edge(bound.source_id, bound.target_id, bound.edge); + } + + for deferred in analyzed.deferred_relations.drain(..) { + analyzed.unit.deferred_symbols.push(naviscope_plugin::DeferredSymbol { + target: deferred.raw_target, + }); + } + + Ok(analyzed.unit) + } + + fn bind_relation( + &self, + rel: &IndexRelation, + parse_result: &GlobalParseResult, + analyzed: &mut AnalyzeOutput, + ) { + let dummy_index = naviscope_plugin::EmptyCodeGraph; + let type_provider = HeuristicAdapter; + + let original_target = rel.target_id.to_string(); + let mut resolved_target = original_target.clone(); + let mut precise_bound = false; + + if let (Some(tree), Some(source), Some(r)) = (&parse_result.tree, &parse_result.source, &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, + original_target.clone(), + &dummy_index, + Some(&analyzed.unit), + source, + tree, + &self.parser, + ); + + if let Some(SymbolResolution::Precise(fqn, _)) = self.resolve_symbol_internal(&context) { + resolved_target = fqn; + precise_bound = true; + } else if !resolved_target.contains('.') { + if let Some(res) = + type_provider.resolve_type_name(&resolved_target, &analyzed.res_ctx) + { + resolved_target = res; + } + } + } + } + + if !precise_bound && !matches!(rel.target_id, NodeId::Structured(_)) { + analyzed.deferred_relations.push(DeferredRelation { + raw_target: original_target.clone(), + }); + } + + let target_id = if resolved_target == original_target && matches!(rel.target_id, NodeId::Structured(_)) + { + rel.target_id.clone() + } else { + Self::build_target_node_id( + &resolved_target, + &rel.edge_type, + &analyzed.unit.nodes, + ) + }; + + analyzed.bound_relations.push(BoundRelation { + source_id: rel.source_id.clone(), + target_id, + edge: GraphEdge::new(rel.edge_type.clone()), + }); + } + + fn build_target_node_id( + target: &str, + edge_type: &EdgeType, + known_nodes: &HashMap, + ) -> NodeId { + let segments: Vec<&str> = target.split(['.', '#']).collect(); + let mut structured_parts: Vec<(NodeKind, String)> = Vec::new(); + + for (i, part) in segments.iter().enumerate() { + let mut found_kind = NodeKind::Package; + let is_last = i == segments.len() - 1; + let candidates = [ + NodeKind::Class, + NodeKind::Interface, + NodeKind::Enum, + NodeKind::Annotation, + NodeKind::Method, + NodeKind::Field, + NodeKind::Constructor, + ]; + + let mut matched = false; + for k in &candidates { + let mut probe_parts = structured_parts.clone(); + probe_parts.push((k.clone(), part.to_string())); + let id = NodeId::Structured(probe_parts); + if known_nodes.contains_key(&id) { + found_kind = k.clone(); + matched = true; + break; + } + } + + if !matched { + if is_last { + if *edge_type == EdgeType::Implements + || *edge_type == EdgeType::InheritsFrom + || *edge_type == EdgeType::TypedAs + || *edge_type == EdgeType::DecoratedBy + { + found_kind = NodeKind::Class; + } else if part.chars().next().is_some_and(|c| c.is_uppercase()) { + found_kind = NodeKind::Class; + } + } else if part.chars().next().is_some_and(|c| c.is_uppercase()) { + found_kind = NodeKind::Class; + } else { + found_kind = NodeKind::Package; + } + } + + structured_parts.push((found_kind, part.to_string())); + } + + NodeId::Structured(structured_parts) + } +} diff --git a/crates/lang-java/src/resolve/mod.rs b/crates/lang-java/src/resolve/mod.rs new file mode 100644 index 0000000..b3bbe6b --- /dev/null +++ b/crates/lang-java/src/resolve/mod.rs @@ -0,0 +1,203 @@ +//! Java resolver using the new inference-based type system. + +pub mod context; +pub mod external; +pub mod lang; +pub mod semantic; +pub mod types; + +use crate::JavaPlugin; +use crate::inference::adapters::CodeGraphTypeSystem; +use context::ResolutionContext; +use naviscope_api::models::{SymbolResolution, TypeRef}; + +impl JavaPlugin { + /// Helper to find enclosing class using ScopeManager + fn find_enclosing_class_via_scope( + &self, + node: tree_sitter::Node, + scope_manager: &crate::inference::scope::ScopeManager, + ) -> Option { + let mut current = node; + while let Some(parent) = current.parent() { + if let Some(sid) = scope_manager.get_scope_id(parent.id()) { + return scope_manager.find_enclosing_class(sid); + } + current = parent; + } + None + } + + /// Resolve a symbol using the new inference-based approach. + pub fn resolve_symbol_internal(&self, context: &ResolutionContext) -> Option { + let ts = CodeGraphTypeSystem::new(context.index); + + // Extract package from tree + let (package, imports) = self + .parser + .extract_package_and_imports(context.tree, context.source); + + // Build inference context + // Initialize ScopeManager for efficient local variable lookup + let mut scope_manager = crate::inference::scope::ScopeManager::new(); + + // Create context with populated scopes + // We scan the entire tree to build the scope table + let mut infer_ctx = crate::inference::create_inference_context( + &context.tree.root_node(), + context.source, + &ts, + &mut scope_manager, + package.clone(), + imports.clone(), + ); + + // Add enclosing class if available from context, OR infer from AST via ScopeManager + let enclosing_fqn = if let Some(class) = context.enclosing_classes.first() { + Some(class.clone()) + } else { + infer_ctx + .scope_manager + .and_then(|sm| self.find_enclosing_class_via_scope(context.node, sm)) + }; + + if let Some(fqn) = enclosing_fqn { + infer_ctx = infer_ctx.with_enclosing_class(fqn); + } + + // 1. If this identifier is a declaration's name, resolve to that declaration's FQN + if let Some(parent) = context.node.parent() { + // Check if this node is the 'name' of a method_declaration or class_declaration + if parent.child_by_field_name("name") == Some(context.node) { + match parent.kind() { + "method_declaration" | "constructor_declaration" => { + // Build signature-based method FQN + if let Some(ref enclosing) = infer_ctx.enclosing_class { + let param_types: Vec = self + .parser + .extract_method_parameters(parent, context.source) + .into_iter() + .map(|p| p.type_ref) + .collect(); + let signed_name = + crate::naming::build_java_method_name(&context.name, ¶m_types); + let method_fqn = + crate::naming::build_member_fqn(enclosing, &signed_name); + return Some(SymbolResolution::Precise( + method_fqn, + naviscope_api::models::SymbolIntent::Method, + )); + } + } + "class_declaration" | "interface_declaration" | "enum_declaration" => { + // Build class FQN + let res_ctx = infer_ctx.to_resolution_context(); + if let Some(fqn) = infer_ctx.ts.resolve_type_name(&context.name, &res_ctx) { + return Some(SymbolResolution::Precise( + fqn, + naviscope_api::models::SymbolIntent::Type, + )); + } + } + "variable_declarator" => { + // Check if this is a class field (parent's parent is field_declaration) + // or a local variable (parent's parent is local_variable_declaration) + if let Some(grandparent) = parent.parent() { + if grandparent.kind() == "field_declaration" { + // Build field FQN using canonical member separator + if let Some(ref enclosing) = infer_ctx.enclosing_class { + let field_fqn = + crate::naming::build_member_fqn(enclosing, &context.name); + return Some(SymbolResolution::Precise( + field_fqn, + naviscope_api::models::SymbolIntent::Field, + )); + } + } + // For local_variable_declaration, fall through to local variable handling + } + } + _ => {} + } + } + } + + // 2. Handle 'this' specifically + if context.node.kind() == "this" { + if let Some(enclosing) = &infer_ctx.enclosing_class { + return Some(SymbolResolution::Precise( + enclosing.clone(), + naviscope_api::models::SymbolIntent::Type, + )); + } + } + + // 2.5. Check for local variable references (returns Local resolution) + if context.node.kind() == "identifier" { + if let Some(sm) = infer_ctx.scope_manager { + // Walk up to find the nearest scope + let mut current = context.node; + let mut start_scope_id = None; + while let Some(parent) = current.parent() { + if let Some(sid) = sm.get_scope_id(parent.id()) { + start_scope_id = Some(sid); + break; + } + current = parent; + } + + if let Some(sid) = start_scope_id { + if let Some(info) = sm.lookup_symbol(sid, &context.name) { + // Ensure declaration is before usage + let usage_point = context.node.start_position(); + let decl_line = info.range.start_line; + let decl_col = info.range.start_col; + + // Compare position (row/line are 0-indexed in TS, assuming Range follows TS or is consistent) + if decl_line < usage_point.row + || (decl_line == usage_point.row && decl_col < usage_point.column) + { + // Render the full local type for hover/highlight consumers. + let type_name = Some(crate::model::fmt_type(&info.type_ref)); + return Some(SymbolResolution::Local(info.range.clone(), type_name)); + } + } + } + } + } + + // 3. Resolve context-sensitive references (Methods, Fields) + // If it's a method name identifier, resolve to the method FQN + if let Some(parent) = context.node.parent() { + if parent.kind() == "method_invocation" + && parent.child_by_field_name("name") == Some(context.node) + { + if let Some(type_ref) = + crate::inference::strategy::MethodCallInfer.infer_member(&parent, &infer_ctx) + { + return Some(SymbolResolution::Precise(type_ref, context.intent)); + } + } + if parent.kind() == "field_access" + && parent.child_by_field_name("field") == Some(context.node) + { + if let Some(type_ref) = + crate::inference::strategy::FieldAccessInfer.infer_member(&parent, &infer_ctx) + { + return Some(SymbolResolution::Precise(type_ref, context.intent)); + } + } + } + + // 4. Main inference path for everything else + if let Some(type_ref) = + crate::inference::strategy::infer_expression(&context.node, &infer_ctx) + { + if let TypeRef::Id(fqn) = &type_ref { + return Some(SymbolResolution::Precise(fqn.clone(), context.intent)); + } + } + + None + } +} diff --git a/crates/lang-java/src/resolve/semantic.rs b/crates/lang-java/src/resolve/semantic.rs new file mode 100644 index 0000000..1edef2f --- /dev/null +++ b/crates/lang-java/src/resolve/semantic.rs @@ -0,0 +1,162 @@ +use crate::JavaPlugin; +use crate::inference::adapters::CodeGraphTypeSystem; +use crate::inference::{InheritanceProvider, MemberProvider, TypeProvider, TypeResolutionContext}; +use crate::resolve::context::ResolutionContext; +use naviscope_api::models::graph::EdgeType; +use naviscope_api::models::symbol::{FqnId, matches_intent}; +use naviscope_api::models::{SymbolIntent, SymbolResolution, TypeRef}; +use naviscope_plugin::{CodeGraph, NamingConvention, SymbolQueryService, SymbolResolveService}; +use tree_sitter::Tree; + +impl SymbolResolveService for JavaPlugin { + fn resolve_at( + &self, + tree: &Tree, + source: &str, + line: usize, + byte_col: usize, + index: &dyn 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); + + self.resolve_symbol_internal(&context) + } +} + +impl SymbolQueryService for JavaPlugin { + fn find_matches(&self, index: &dyn CodeGraph, resolution: &SymbolResolution) -> Vec { + match resolution { + SymbolResolution::Local(_, _) => vec![], + SymbolResolution::Precise(fqn, _intent) => index.resolve_fqn(fqn), + SymbolResolution::Global(fqn) => index.resolve_fqn(fqn), + } + } + + fn resolve_type_of( + &self, + index: &dyn CodeGraph, + resolution: &SymbolResolution, + ) -> Vec { + let mut type_resolutions = Vec::new(); + let ts = CodeGraphTypeSystem::new(index); + + match resolution { + SymbolResolution::Local(_, type_name) => { + if let Some(tn) = type_name { + // Use TypeProvider from type system + let ctx = TypeResolutionContext::default(); // Minimal ctx for LSP verify + if let Some(fqn) = ts.resolve_type_name(tn, &ctx) { + type_resolutions.push(SymbolResolution::Precise(fqn, SymbolIntent::Type)); + } + } + } + SymbolResolution::Precise(fqn, intent) => { + // If it's a member (Field/Method), find its type via MemberProvider + // Use unified member FQN parsing + if let Some((type_fqn, member_name)) = crate::naming::parse_member_fqn(fqn) { + if let Some(member) = ts.get_members(type_fqn, member_name).first() { + match &member.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)), + _ => {} + } + } + } else if *intent == SymbolIntent::Type { + type_resolutions.push(resolution.clone()); + } else { + // Fallback for cases without member separator or other global symbols + let fids = index.resolve_fqn(fqn); + for fid in fids { + if let Some(node) = index.get_node(fid) { + if matches_intent(&node.kind, SymbolIntent::Type) { + type_resolutions.push(resolution.clone()); + } + } + } + } + } + SymbolResolution::Global(fqn) => { + let fids = index.resolve_fqn(fqn); + for fid in fids { + if let Some(node) = index.get_node(fid) { + if matches_intent(&node.kind, SymbolIntent::Type) { + type_resolutions.push(resolution.clone()); + } + } + } + } + } + type_resolutions + } + + fn find_implementations( + &self, + index: &dyn CodeGraph, + resolution: &SymbolResolution, + ) -> Vec { + let target_nodes = self.find_matches(index, resolution); + let mut results = Vec::new(); + let ts = CodeGraphTypeSystem::new(index); + + for &node_id in &target_nodes { + let node = match index.get_node(node_id) { + Some(n) => n, + None => continue, + }; + + // Check if it's a method + let is_method = matches!( + node.kind, + naviscope_api::models::graph::NodeKind::Method + | naviscope_api::models::graph::NodeKind::Constructor + ); + + if is_method { + // 1. Find the enclosing class/interface + let parents = index.get_neighbors( + node_id, + naviscope_plugin::Direction::Incoming, + Some(EdgeType::Contains), + ); + for parent_id in parents { + // 2. Find all implementations of this parent + use naviscope_plugin::NamingConvention; + let parent_fqn = crate::naming::JavaNamingConvention::default() + .render_fqn(parent_id, index.fqns()); + + // 3. Walk all descendants of the parent class + for desc_fqn in ts.walk_descendants(&parent_fqn) { + // 4. In each descendant class, find a member with same name + let method_name = index.fqns().resolve_atom(node.name); + if let Some(member) = ts.get_members(&desc_fqn, method_name).first() { + results.extend(index.resolve_fqn(&member.fqn)); + } + } + } + continue; + } + + // For classes/interfaces, get all descendants + let fqn = + crate::naming::JavaNamingConvention::default().render_fqn(node_id, index.fqns()); + for desc_fqn in ts.walk_descendants(&fqn) { + results.extend(index.resolve_fqn(&desc_fqn)); + } + } + results + } +} diff --git a/crates/lang-java/src/resolve/types.rs b/crates/lang-java/src/resolve/types.rs new file mode 100644 index 0000000..a0a0fdb --- /dev/null +++ b/crates/lang-java/src/resolve/types.rs @@ -0,0 +1,56 @@ +use crate::JavaPlugin; +use crate::inference::{TypeProvider, TypeResolutionContext}; +use naviscope_api::models::TypeRef; + +impl JavaPlugin { + pub(crate) fn resolve_type_ref( + &self, + type_ref: &TypeRef, + provider: &dyn TypeProvider, + ctx: &TypeResolutionContext, + ) -> 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) = ctx + .known_fqns + .iter() + .find(|k| k.ends_with(&format!(".{}", name)) || *k == name) + { + return TypeRef::Id(fqn.clone()); + } + + // 2. Delegate to TypeProvider + if let Some(fqn) = provider.resolve_type_name(name, ctx) { + TypeRef::Id(fqn) + } else { + TypeRef::Raw(name.clone()) + } + } + TypeRef::Generic { base, args } => TypeRef::Generic { + base: Box::new(self.resolve_type_ref(base, provider, ctx)), + args: args + .iter() + .map(|a| self.resolve_type_ref(a, provider, ctx)) + .collect(), + }, + TypeRef::Array { + element, + dimensions, + } => TypeRef::Array { + element: Box::new(self.resolve_type_ref(element, provider, ctx)), + dimensions: *dimensions, + }, + TypeRef::Wildcard { + bound, + is_upper_bound, + } => TypeRef::Wildcard { + bound: bound + .as_ref() + .map(|b| Box::new(self.resolve_type_ref(b, provider, ctx))), + is_upper_bound: *is_upper_bound, + }, + _ => type_ref.clone(), + } + } +} diff --git a/crates/lang-java/src/resolver/mod.rs b/crates/lang-java/src/resolver/mod.rs deleted file mode 100644 index b0da50f..0000000 --- a/crates/lang-java/src/resolver/mod.rs +++ /dev/null @@ -1,598 +0,0 @@ -use crate::model::{JavaIndexMetadata, JavaNodeMetadata}; -use crate::parser::JavaParser; -use naviscope_api::models::graph::{EdgeType, GraphEdge, NodeKind}; -use naviscope_api::models::symbol::{FqnId, matches_intent}; -use naviscope_api::models::{SymbolIntent, SymbolResolution, TypeRef}; -use naviscope_plugin::{ - CodeGraph, GraphOp, IndexNode, LangResolver, ParsedContent, ParsedFile, ProjectContext, - ResolvedUnit, SemanticResolver, -}; -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 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); - - self.resolve_symbol_internal(&context) - } - - fn find_matches(&self, index: &dyn CodeGraph, resolution: &SymbolResolution) -> Vec { - match resolution { - SymbolResolution::Local(_, _) => vec![], - SymbolResolution::Precise(fqn, _intent) => index.resolve_fqn(fqn), - SymbolResolution::Global(fqn) => index.resolve_fqn(fqn), - } - } - - fn resolve_type_of( - &self, - index: &dyn 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) => { - let fids = index.resolve_fqn(fqn); - for fid in fids { - if let Some(node) = index.get_node(fid) { - 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()); - } - } - } - } - } - } - if type_resolutions.is_empty() && *intent == SymbolIntent::Type { - type_resolutions.push(resolution.clone()); - } - } - SymbolResolution::Global(fqn) => { - let fids = index.resolve_fqn(fqn); - for fid in fids { - if let Some(node) = index.get_node(fid) { - if matches_intent(&node.kind, SymbolIntent::Type) { - type_resolutions.push(resolution.clone()); - } - } - } - } - } - type_resolutions - } - - fn find_implementations( - &self, - index: &dyn CodeGraph, - resolution: &SymbolResolution, - ) -> Vec { - let target_nodes = self.find_matches(index, resolution); - let mut results = Vec::new(); - - for &node_id in &target_nodes { - let node = match index.get_node(node_id) { - Some(n) => n, - None => continue, - }; - - // 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 parents = index.get_neighbors( - node_id, - naviscope_plugin::Direction::Incoming, - Some(EdgeType::Contains), - ); - for parent_id in parents { - // 2. Find all implementations of this parent - use naviscope_plugin::NamingConvention; - let parent_fqn = - crate::naming::JavaNamingConvention.render_fqn(parent_id, index.fqns()); - 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_id in impl_classes { - let children = index.get_neighbors( - impl_class_id, - naviscope_plugin::Direction::Outgoing, - Some(EdgeType::Contains), - ); - for child_id in children { - if let Some(child_node) = index.get_node(child_id) { - 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_id); - } - } - } - } - } - continue; - } - - results.extend(index.get_neighbors( - node_id, - naviscope_plugin::Direction::Incoming, - Some(EdgeType::Implements), - )); - results.extend(index.get_neighbors( - node_id, - naviscope_plugin::Direction::Incoming, - Some(EdgeType::InheritsFrom), - )); - } - results - } -} - -impl LangResolver for JavaResolver { - fn resolve( - &self, - file: &ParsedFile, - context: &ProjectContext, - ) -> std::result::Result> { - let mut unit = ResolvedUnit::new(); - let dummy_index = naviscope_plugin::EmptyCodeGraph; - - 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 IndexParser from JavaParser - parse_result_owned = self.parser.parse_file(src, Some(&file.file.path))?; - &parse_result_owned - } else { - return Ok(unit); - } - } - ParsedContent::Lazy => { - if file.path().extension().map_or(false, |e| e == "java") { - let src = std::fs::read_to_string(file.path()).map_err(|e| { - format!("Failed to read file {}: {}", file.path().display(), e) - })?; - // use IndexParser from JavaParser - 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 = pkg_name.to_string(); - - let package_node = IndexNode { - id: package_id.clone().into(), - 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( - module_id.clone().into(), - package_id.clone().into(), - GraphEdge::new(EdgeType::Contains), - ); - - package_id - } else { - // For default package, we might want to use a semantic "default package" node - // or just attach to module. - // For now, attaching to module seems safer to avoid colliding all default packages. - // But this means default package classes might be harder to find via clean FQN if module_id is weird. - 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.to_string()); - } - } - - 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( - container_id.clone().into(), - node.id.clone(), - GraphEdge::new(EdgeType::Contains), - ); - } - } - - for rel in &parse_result.output.relations { - let mut resolved_target_str = rel.target_id.to_string(); - - 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.to_string(), - &dummy_index, - Some(&unit), - source, - tree, - &self.parser, - ); - - if let Some(SymbolResolution::Precise(fqn, _)) = - self.resolve_symbol_internal(&context) - { - resolved_target_str = fqn; - } else { - // ... Fallbacks ... - if !resolved_target_str.contains('.') { - if let Some(res) = self.parser.resolve_type_name_to_fqn_data( - &resolved_target_str, - parse_result.package_name.as_deref(), - &parse_result.imports, - ) { - resolved_target_str = res; - } - } - } - } - } - } - - let edge = GraphEdge::new(rel.edge_type.clone()); - - // Optimization: If the resolved string matches the original target ID string, - // trust the original ID IF it is Structured (which preserves metadata from parser). - if resolved_target_str == rel.target_id.to_string() - && matches!( - rel.target_id, - naviscope_api::models::symbol::NodeId::Structured(_) - ) - { - unit.add_edge(rel.source_id.clone(), rel.target_id.clone(), edge); - continue; - } - - // Try to reconstruct a Structured ID to match the graph nodes - let segments: Vec<&str> = resolved_target_str - .split(|c| c == '.' || c == '#') - .collect(); - let mut structured_parts: Vec<(naviscope_api::models::graph::NodeKind, String)> = - Vec::new(); - - for (i, part) in segments.iter().enumerate() { - let mut found_kind = naviscope_api::models::graph::NodeKind::Package; - let is_last = i == segments.len() - 1; - - // Probe kinds in unit.nodes - let candidates = [ - naviscope_api::models::graph::NodeKind::Class, - naviscope_api::models::graph::NodeKind::Interface, - naviscope_api::models::graph::NodeKind::Enum, - naviscope_api::models::graph::NodeKind::Annotation, - naviscope_api::models::graph::NodeKind::Method, - naviscope_api::models::graph::NodeKind::Field, - naviscope_api::models::graph::NodeKind::Constructor, - ]; - - let mut matched = false; - for k in &candidates { - let mut probe_parts = structured_parts.clone(); - probe_parts.push((k.clone(), part.to_string())); - let id = naviscope_api::models::symbol::NodeId::Structured(probe_parts); - if unit.nodes.contains_key(&id) { - found_kind = k.clone(); - matched = true; - break; - } - } - - if !matched { - if is_last { - // Heuristics for last part if not found - if rel.edge_type == EdgeType::Implements { - found_kind = naviscope_api::models::graph::NodeKind::Interface; - } else if rel.edge_type == EdgeType::DecoratedBy { - found_kind = naviscope_api::models::graph::NodeKind::Annotation; - } else if rel.edge_type == EdgeType::InheritsFrom { - if let Some(src_node) = unit.nodes.get(&rel.source_id) { - match src_node.kind { - naviscope_api::models::graph::NodeKind::Interface => { - found_kind = - naviscope_api::models::graph::NodeKind::Interface - } - _ => { - found_kind = - naviscope_api::models::graph::NodeKind::Class - } - } - } else { - found_kind = naviscope_api::models::graph::NodeKind::Class; - } - } else if rel.edge_type == EdgeType::TypedAs { - found_kind = naviscope_api::models::graph::NodeKind::Class; - } else if part.chars().next().map_or(false, |c| c.is_uppercase()) { - found_kind = naviscope_api::models::graph::NodeKind::Class; - } - } else { - found_kind = naviscope_api::models::graph::NodeKind::Package; - } - } - - structured_parts.push((found_kind, part.to_string())); - } - - let final_target_id = - naviscope_api::models::symbol::NodeId::Structured(structured_parts); - unit.add_edge(rel.source_id.clone(), final_target_id, edge); - } - } - - Ok(unit) - } -} diff --git a/crates/lang-java/src/resolver/scope/builtin.rs b/crates/lang-java/src/resolver/scope/builtin.rs deleted file mode 100644 index 00e32de..0000000 --- a/crates/lang-java/src/resolver/scope/builtin.rs +++ /dev/null @@ -1,88 +0,0 @@ -use crate::parser::JavaParser; -use crate::resolver::context::ResolutionContext; -use crate::resolver::scope::SemanticScope; -use naviscope_api::models::SymbolIntent; - -use naviscope_api::models::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.resolve_fqn(&fqn).is_empty(); - - 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 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 = naviscope_plugin::EmptyCodeGraph; - - 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 deleted file mode 100644 index 6a36973..0000000 --- a/crates/lang-java/src/resolver/scope/import_scope.rs +++ /dev/null @@ -1,92 +0,0 @@ -use crate::parser::JavaParser; -use crate::resolver::context::ResolutionContext; -use crate::resolver::scope::SemanticScope; - -use naviscope_api::models::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| { - if !context.index.resolve_fqn(&candidate).is_empty() { - Some(candidate) - } else { - None - } - }) - .map(|fqn| Ok(SymbolResolution::Precise(fqn, context.intent))) - }) - } - fn name(&self) -> &'static str { - "Import" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - 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 = naviscope_plugin::EmptyCodeGraph; - - 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/crates/lang-java/src/resolver/scope/local.rs b/crates/lang-java/src/resolver/scope/local.rs deleted file mode 100644 index 901373a..0000000 --- a/crates/lang-java/src/resolver/scope/local.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::parser::JavaParser; -use crate::resolver::context::ResolutionContext; -use crate::resolver::scope::SemanticScope; -use naviscope_api::models::SymbolResolution; - -pub struct LocalScope<'a> { - pub parser: &'a JavaParser, -} - -impl SemanticScope> for LocalScope<'_> { - 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) - .map(|(range, type_name)| Ok(SymbolResolution::Local(range, type_name))) - } - fn name(&self) -> &'static str { - "Local" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - 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::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(); - - assert_eq!(x_node.utf8_text(source.as_bytes()).unwrap(), "x"); - - let java_parser = JavaParser::new().unwrap(); - let index = naviscope_plugin::EmptyCodeGraph; - let context = - ResolutionContext::new(x_node, "x".to_string(), &index, source, &tree, &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)) => { - assert_eq!(range.start_line, 0); - assert_eq!(type_name, Some("int".to_string())); - } - _ => panic!("Expected Local resolution"), - } - } -} diff --git a/crates/lang-java/src/resolver/scope/member.rs b/crates/lang-java/src/resolver/scope/member.rs deleted file mode 100644 index 5e392eb..0000000 --- a/crates/lang-java/src/resolver/scope/member.rs +++ /dev/null @@ -1,491 +0,0 @@ -use crate::model::{JavaIndexMetadata, JavaNodeMetadata}; -use crate::parser::JavaParser; -use crate::resolver::context::ResolutionContext; -use crate::resolver::scope::SemanticScope; -use naviscope_api::models::{SymbolResolution, TypeRef}; - -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.resolve_fqn(n).is_empty() }; - - // 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.into())) - { - 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().into())) - { - 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 { - context - .index - .resolve_fqn(fqn) - .into_iter() - .next() - .and_then(|id| context.index.get_node(id)) - }; - - let kind = node.kind(); - // println!("RESOLVE_EXPR: kind={}, text={:?}", kind, node.utf8_text(context.source.as_bytes()).unwrap_or("")); - - 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 JavaNodeMetadata::Field { type_ref, .. } = - node.metadata.as_any().downcast_ref::()? - { - 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().into()) { - 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.resolve_fqn(&fqn).is_empty(); - - if in_index - || context - .unit - .map_or(false, |u| u.nodes.contains_key(&fqn.as_str().into())) - { - 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().into()) { - 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().into()) { - 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))); - } - - context - .receiver_node - .as_ref() - .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 - .resolve_fqn(&candidate) - .into_iter() - .next() - .map(|id| { - use naviscope_plugin::NamingConvention; - crate::naming::JavaNamingConvention - .render_fqn(id, context.index.fqns()) - }) - }) - .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_map(|candidate| { - context - .index - .resolve_fqn(&candidate) - .into_iter() - .next() - .map(|id| { - use naviscope_plugin::NamingConvention; - let fqn = crate::naming::JavaNamingConvention - .render_fqn(id, context.index.fqns()); - Ok(SymbolResolution::Precise(fqn, context.intent)) - }) - }) - }) - } - fn name(&self) -> &'static str { - "Member" - } -} - -#[cfg(test)] -mod tests { - use super::*; - use naviscope_api::models::graph::{EdgeType, GraphNode}; - use naviscope_api::models::symbol::{FqnId, FqnNode, FqnReader, Symbol}; - use naviscope_plugin::{CodeGraph, Direction}; - use std::path::Path; - - struct MockCodeGraph { - node: GraphNode, - } - - impl CodeGraph for MockCodeGraph { - fn resolve_fqn(&self, _fqn: &str) -> Vec { - vec![FqnId(0)] - } - fn get_node_at(&self, _path: &Path, _line: usize, _col: usize) -> Option { - None - } - fn resolve_atom(&self, _atom: Symbol) -> &str { - "" - } - fn fqns(&self) -> &dyn FqnReader { - self - } - fn get_node(&self, _id: FqnId) -> Option { - Some(self.node.clone()) - } - fn get_neighbors( - &self, - _id: FqnId, - _direction: Direction, - _edge_type: Option, - ) -> Vec { - vec![] - } - } - - impl FqnReader for MockCodeGraph { - fn resolve_node(&self, _id: FqnId) -> Option { - None - } - fn resolve_atom(&self, _atom: Symbol) -> &str { - "" - } - } - - 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(); - - let index = MockCodeGraph { - node: naviscope_api::models::graph::GraphNode { - id: FqnId(0), - name: Symbol(lasso::Spur::default()), - kind: naviscope_api::models::graph::NodeKind::Field, - lang: Symbol(lasso::Spur::default()), - location: None, - metadata: std::sync::Arc::new(JavaNodeMetadata::Field { - type_ref: naviscope_api::models::TypeRef::Raw("int".to_string()), - modifiers_sids: vec![], - }), - }, - }; - - 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()); - } -} diff --git a/crates/lang-java/src/resolver/scope/mod.rs b/crates/lang-java/src/resolver/scope/mod.rs deleted file mode 100644 index 39857be..0000000 --- a/crates/lang-java/src/resolver/scope/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -use super::context::ResolutionContext; -use naviscope_plugin::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 use builtin::BuiltinScope; -pub use import_scope::ImportScope; -pub use local::LocalScope; -pub use member::MemberScope; diff --git a/crates/lang-java/tests/behavior_common.rs b/crates/lang-java/tests/behavior_common.rs new file mode 100644 index 0000000..7f63f3f --- /dev/null +++ b/crates/lang-java/tests/behavior_common.rs @@ -0,0 +1,21 @@ +use naviscope_api::models::SymbolResolution; +use naviscope_core::model::CodeGraph; +use naviscope_java::JavaPlugin; +use naviscope_plugin::LspSyntaxService; +use std::path::PathBuf; +use tree_sitter::Tree; + +pub fn collect_occurrence_counts_by_file( + resolver: &JavaPlugin, + index: &CodeGraph, + trees: &[(PathBuf, String, Tree)], + target: &SymbolResolution, +) -> Vec<(String, usize)> { + trees + .iter() + .map(|(path, source, tree)| { + let ranges = resolver.find_occurrences(source, tree, target, Some(index)); + (path.to_string_lossy().to_string(), ranges.len()) + }) + .collect() +} diff --git a/crates/lang-java/tests/call_hierarchy_behavior.rs b/crates/lang-java/tests/call_hierarchy_behavior.rs new file mode 100644 index 0000000..dcb5e93 --- /dev/null +++ b/crates/lang-java/tests/call_hierarchy_behavior.rs @@ -0,0 +1,200 @@ +mod common; + +use common::{offset_to_point, setup_java_test_graph}; +use naviscope_api::models::SymbolResolution; +use naviscope_core::features::CodeGraphLike; +use naviscope_core::features::discovery::DiscoveryEngine; +use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; + +#[test] +fn given_leaf_method_when_find_incoming_callers_then_returns_direct_callers_only() { + let files = vec![( + "Test.java", + "public class Test { void leaf() {} void c1() { leaf(); } void c2() { leaf(); } }", + )]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let leaf_pos = content.find("void leaf").expect("find leaf") + 5; + let (line, col) = offset_to_point(content, leaf_pos); + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve leaf symbol"); + + let target = resolver.find_matches(&index, &resolution); + let target_idx = *index.fqn_map().get(&target[0]).expect("target node exists"); + + let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); + let abs_path = std::env::current_dir().expect("cwd").join("Test.java"); + let uri: lsp_types::Uri = format!("file://{}", abs_path.display()) + .parse() + .expect("valid uri"); + + let semantic = JavaPlugin::new().expect("failed to create java plugin"); + let locations = discovery.scan_file(&semantic, content, &resolution, &uri); + + let mut callers = Vec::new(); + for loc in locations { + if let Some(name_range) = index.topology()[target_idx].name_range() + && name_range.start_line == loc.range.start.line as usize + && name_range.start_col == loc.range.start.character as usize + { + continue; + } + + if let Some(container_idx) = index.find_container_node_at( + &std::path::PathBuf::from("Test.java"), + loc.range.start.line as usize, + loc.range.start.character as usize, + ) { + let node = &index.topology()[container_idx]; + let fqn = index + .render_fqn( + node, + Some(&naviscope_java::naming::JavaNamingConvention::default()), + ) + .to_string(); + if !callers.contains(&fqn) { + callers.push(fqn); + } + } + } + + callers.sort(); + assert_eq!(callers, vec!["Test#c1()", "Test#c2()"]); +} + +#[test] +fn given_root_method_when_find_outgoing_calls_then_returns_direct_callees() { + 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 = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let root_pos = content.find("void root").expect("find root") + 5; + let (line, col) = offset_to_point(content, root_pos); + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve root"); + + let target_fqn = resolver.find_matches(&index, &resolution)[0]; + let target_idx = *index + .fqn_map() + .get(&target_fqn) + .expect("target node exists"); + let container_range = index.topology()[target_idx] + .range() + .expect("container range"); + + let mut callees = Vec::new(); + let mut stack = vec![tree.root_node()]; + while let Some(node) = stack.pop() { + let range = node.range(); + if range.start_point.row > container_range.end_line + || range.end_point.row < container_range.start_line + { + continue; + } + + if node.kind() == "identifier" + && let Some(out_res) = resolver.resolve_at( + tree, + content, + range.start_point.row, + range.start_point.column, + &index, + ) + { + let maybe_fqn = match out_res { + SymbolResolution::Global(fqn) => Some(fqn), + SymbolResolution::Precise(fqn, _) => Some(fqn), + _ => None, + }; + + if let Some(fqn) = maybe_fqn + && fqn.contains('#') + && fqn != "Test#root()" + && !callees.contains(&fqn) + { + callees.push(fqn); + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + stack.push(child); + } + } + + callees.sort(); + assert_eq!(callees, vec!["Test#step1()", "Test#step2()"]); +} + +#[test] +fn given_recursive_method_when_find_incoming_callers_then_includes_self_call() { + let files = vec![("Test.java", "public class Test { void rec() { rec(); } }")]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let rec_pos = content.find("void rec").expect("find rec") + 5; + let (line, col) = offset_to_point(content, rec_pos); + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve rec"); + + let target_fqn = resolver.find_matches(&index, &resolution)[0]; + let target_idx = *index + .fqn_map() + .get(&target_fqn) + .expect("target node exists"); + + let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); + let abs_path = std::env::current_dir().expect("cwd").join("Test.java"); + let uri: lsp_types::Uri = format!("file://{}", abs_path.display()) + .parse() + .expect("valid uri"); + + let semantic = JavaPlugin::new().expect("failed to create java plugin"); + let locations = discovery.scan_file(&semantic, content, &resolution, &uri); + + let mut callers = Vec::new(); + for loc in locations { + if let Some(name_range) = index.topology()[target_idx].name_range() + && name_range.start_line == loc.range.start.line as usize + && name_range.start_col == loc.range.start.character as usize + { + continue; + } + + if let Some(container_idx) = index.find_container_node_at( + &std::path::PathBuf::from("Test.java"), + loc.range.start.line as usize, + loc.range.start.character as usize, + ) { + let node = &index.topology()[container_idx]; + let fqn = index + .render_fqn( + node, + Some(&naviscope_java::naming::JavaNamingConvention::default()), + ) + .to_string(); + if !callers.contains(&fqn) { + callers.push(fqn); + } + } + } + + assert!(callers.contains(&"Test#rec()".to_string())); +} diff --git a/crates/lang-java/tests/capability_boundary.rs b/crates/lang-java/tests/capability_boundary.rs index f4be51d..85781cf 100644 --- a/crates/lang-java/tests/capability_boundary.rs +++ b/crates/lang-java/tests/capability_boundary.rs @@ -14,21 +14,22 @@ fn cap_structural_nesting() { )]; let (index, _) = setup_java_test_graph(files); - // Assert FQNs exist - // Note: JavaResolver prepends "module::root." to packages when no specific module is found // Assert FQNs exist // Note: JavaResolver uses clear package names now for FQN compatibility println!("Graph nodes:"); for idx in index.topology().node_indices() { let node = &index.topology()[idx]; use naviscope_plugin::NamingConvention; - println!(" - {:?}", naviscope_plugin::DotPathConvention.render_fqn(node.id, index.fqns())); + println!( + " - {:?}", + naviscope_plugin::StandardNamingConvention.render_fqn(node.id, index.fqns()) + ); } assert!(index.find_node("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!(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(); @@ -36,8 +37,8 @@ fn cap_structural_nesting() { 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(); + 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)); } @@ -70,7 +71,7 @@ fn cap_inheritance_tracking() { println!( " -> {:?} connection {:?} (Target ID: {:?})", edge.edge_type, - naviscope_plugin::DotPathConvention.render_fqn(target.id, index.fqns()), + naviscope_plugin::StandardNamingConvention.render_fqn(target.id, index.fqns()), target.id ); } @@ -102,7 +103,7 @@ fn cap_cross_file_typing() { ]; let (index, _) = setup_java_test_graph(files); - let field_idx = index.find_node("com.app.Main.field").unwrap(); + 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 @@ -147,7 +148,7 @@ fn cap_method_call_tracking() { ]; let (index, _) = setup_java_test_graph(files); - let a_target_idx = index.find_node("A.target").unwrap(); + let a_target_idx = index.find_node("A#target()").unwrap(); // Check DiscoveryEngine "Scouting" (uses Reference Index) let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); @@ -219,7 +220,7 @@ fn cap_static_field_access() { ]; let (index, _) = setup_java_test_graph(files); - let config_key_idx = index.find_node("Config.KEY").unwrap(); + 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, std::collections::HashMap::new()); @@ -243,7 +244,7 @@ fn cap_generic_type_link() { ]; let (index, _) = setup_java_test_graph(files); - let list_idx = index.find_node("Main.list").unwrap(); + let list_idx = index.find_node("Main#list").unwrap(); let type_a_idx = index.find_node("TypeA").unwrap(); let has_link = index diff --git a/crates/lang-java/tests/common/mod.rs b/crates/lang-java/tests/common/mod.rs index 69d92e0..88082f7 100644 --- a/crates/lang-java/tests/common/mod.rs +++ b/crates/lang-java/tests/common/mod.rs @@ -1,14 +1,16 @@ use naviscope_api::models::Language; -use naviscope_core::ingest::builder::CodeGraphBuilder; +use naviscope_core::model::builder::CodeGraphBuilder; +use naviscope_java::JavaPlugin; use naviscope_java::parser::JavaParser; -use naviscope_java::resolver::JavaResolver; use naviscope_plugin::{ - GraphOp, LangResolver, ParsedContent, ParsedFile, ProjectContext, SourceFile, + GraphOp, ParsedContent, ParsedFile, ProjectContext, SourceFile, SourceIndexCap, }; use std::path::PathBuf; use std::sync::Arc; +use std::sync::Once; use tree_sitter::Parser; +#[allow(dead_code)] pub fn setup_java_test_graph( files: Vec<(&str, &str)>, ) -> ( @@ -18,7 +20,7 @@ pub fn setup_java_test_graph( let mut builder = CodeGraphBuilder::new(); builder.naming_conventions.insert( Language::JAVA, - Arc::new(naviscope_java::naming::JavaNamingConvention), + Arc::new(naviscope_java::naming::JavaNamingConvention::default()), ); let mut parsed_files = Vec::new(); let java_parser = JavaParser::new().unwrap(); @@ -40,8 +42,8 @@ pub fn setup_java_test_graph( all_parsed_files.push((parsed_file, content.to_string())); } - // Phase 2: Resolve (using JavaResolver's LangResolver implementation) - let resolver = JavaResolver::new(); + // Phase 2: Resolve (using JavaResolver source-index implementation) + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let context = ProjectContext::new(); // Uses default V2 context let mut all_ops = Vec::new(); @@ -49,8 +51,10 @@ pub fn setup_java_test_graph( for (pf, content) in all_parsed_files { let tree = ts_parser.parse(&content, None).unwrap(); - // Use JavaResolver to get resolved unit - let unit = resolver.resolve(&pf, &context).unwrap(); + // Run staged source indexing: collect -> analyze -> lower + let collected = resolver.collect_source(&pf, &context).unwrap(); + let analyzed = resolver.analyze_source(collected, &context).unwrap(); + let unit = resolver.lower_source(analyzed, &context).unwrap(); all_ops.extend(unit.ops); @@ -74,3 +78,51 @@ pub fn setup_java_test_graph( (builder.build(), parsed_files) } + +#[allow(dead_code)] +pub 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) +} + +#[allow(dead_code)] +pub async fn setup_java_engine( + temp_dir: &std::path::Path, + files: Vec<(&str, &str)>, +) -> naviscope_core::facade::EngineHandle { + ensure_test_index_dir(); + use naviscope_core::runtime::NaviscopeEngine as CoreEngine; + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = CoreEngine::builder(temp_dir.to_path_buf()) + .with_language_caps(java_caps) + .build(); + + // Create files + for (path_str, content) in &files { + let path = temp_dir.join(path_str); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, content).unwrap(); + } + + // Index files + let paths: Vec<_> = files.iter().map(|(p, _)| temp_dir.join(p)).collect(); + engine.update_files(paths).await.unwrap(); + + naviscope_core::facade::EngineHandle::from_engine(Arc::new(engine)) +} + +fn ensure_test_index_dir() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let dir = std::env::temp_dir().join("naviscope_test_index_dir"); + std::fs::create_dir_all(&dir).unwrap(); + unsafe { + std::env::set_var("NAVISCOPE_INDEX_DIR", dir); + } + }); +} diff --git a/crates/lang-java/tests/core_inference_regression.rs b/crates/lang-java/tests/core_inference_regression.rs new file mode 100644 index 0000000..bb783d9 --- /dev/null +++ b/crates/lang-java/tests/core_inference_regression.rs @@ -0,0 +1,201 @@ +use naviscope_api::models::TypeRef; +use naviscope_java::inference::core::subtyping::is_subtype; +use naviscope_java::inference::core::unification::{Substitution, unify}; +use naviscope_java::inference::{ + InheritanceProvider, JavaTypeSystem, MemberInfo, MemberKind, MemberProvider, ParameterInfo, + TypeInfo, TypeProvider, TypeResolutionContext, +}; +use std::collections::{HashMap, HashSet, VecDeque}; + +#[derive(Default)] +struct MiniTypeSystem { + direct_parents: HashMap>, +} + +impl MiniTypeSystem { + fn with_parent(mut self, ty: &str, parent: &str) -> Self { + self.direct_parents + .entry(ty.to_string()) + .or_default() + .push(parent.to_string()); + self + } +} + +impl TypeProvider for MiniTypeSystem { + fn get_type_info(&self, _fqn: &str) -> Option { + None + } + + fn resolve_type_name( + &self, + _simple_name: &str, + _context: &TypeResolutionContext, + ) -> Option { + None + } +} + +impl InheritanceProvider for MiniTypeSystem { + fn get_superclass(&self, fqn: &str) -> Option { + self.direct_parents + .get(fqn) + .and_then(|p| p.first()) + .cloned() + } + + fn get_interfaces(&self, fqn: &str) -> Vec { + self.direct_parents + .get(fqn) + .map(|p| p.iter().skip(1).cloned().collect()) + .unwrap_or_default() + } + + fn walk_ancestors(&self, fqn: &str) -> Box + '_> { + let mut queue: VecDeque = VecDeque::new(); + let mut visited: HashSet = HashSet::new(); + let mut out = Vec::new(); + + if let Some(parents) = self.direct_parents.get(fqn) { + for p in parents { + queue.push_back(p.clone()); + } + } + + while let Some(curr) = queue.pop_front() { + if !visited.insert(curr.clone()) { + continue; + } + out.push(curr.clone()); + if let Some(parents) = self.direct_parents.get(&curr) { + for p in parents { + queue.push_back(p.clone()); + } + } + } + + Box::new(out.into_iter()) + } + + fn get_direct_subtypes(&self, fqn: &str) -> Vec { + self.direct_parents + .iter() + .filter(|(_, parents)| parents.iter().any(|p| p == fqn)) + .map(|(ty, _)| ty.clone()) + .collect() + } + + fn walk_descendants(&self, fqn: &str) -> Box + '_> { + Box::new(self.get_direct_subtypes(fqn).into_iter()) + } +} + +impl MemberProvider for MiniTypeSystem { + fn get_members(&self, _type_fqn: &str, _member_name: &str) -> Vec { + vec![] + } + + fn get_all_members(&self, _type_fqn: &str) -> Vec { + vec![] + } +} + +fn method(fqn: &str, param: TypeRef) -> MemberInfo { + MemberInfo { + name: "set".to_string(), + fqn: fqn.to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".to_string()), + parameters: Some(vec![ParameterInfo { + name: "value".to_string(), + type_ref: param, + is_varargs: false, + }]), + modifiers: vec![], + generic_signature: None, + } +} + +#[test] +fn resolve_method_prefers_exact_match_over_wider_match() { + let ts = MiniTypeSystem::default() + .with_parent("java.lang.Integer", "java.lang.Number") + .with_parent("java.lang.Number", "java.lang.Object"); + + let candidates = vec![ + method("Demo#setNumber", TypeRef::Id("java.lang.Number".to_string())), + method("Demo#setInteger", TypeRef::Id("java.lang.Integer".to_string())), + ]; + + let resolved = ts + .resolve_method(&candidates, &[TypeRef::Id("java.lang.Integer".to_string())]) + .expect("resolved"); + assert_eq!(resolved.fqn, "Demo#setInteger"); +} + +#[test] +fn subtyping_supports_boxing_and_reference_widening() { + let ts = MiniTypeSystem::default() + .with_parent("java.lang.Integer", "java.lang.Number") + .with_parent("java.lang.Number", "java.lang.Object"); + + assert!(is_subtype( + &TypeRef::Raw("int".to_string()), + &TypeRef::Id("java.lang.Number".to_string()), + &ts + )); +} + +#[test] +fn unify_binds_generic_parameter_in_nested_type() { + let lhs = TypeRef::Generic { + base: Box::new(TypeRef::Id("java.util.List".to_string())), + args: vec![TypeRef::Id("T".to_string())], + }; + let rhs = TypeRef::Generic { + base: Box::new(TypeRef::Id("java.util.List".to_string())), + args: vec![TypeRef::Id("java.lang.String".to_string())], + }; + + let subst = unify(&lhs, &rhs).expect("unify"); + let applied = subst.apply(&TypeRef::Id("T".to_string())); + assert_eq!(applied, TypeRef::Id("java.lang.String".to_string())); +} + +#[test] +fn unify_rejects_conflicting_type_variable_bindings() { + let lhs = TypeRef::Generic { + base: Box::new(TypeRef::Id("Pair".to_string())), + args: vec![TypeRef::Id("T".to_string()), TypeRef::Id("T".to_string())], + }; + let rhs = TypeRef::Generic { + base: Box::new(TypeRef::Id("Pair".to_string())), + args: vec![ + TypeRef::Id("java.lang.String".to_string()), + TypeRef::Id("java.lang.Integer".to_string()), + ], + }; + + assert!(unify(&lhs, &rhs).is_none()); +} + +#[test] +fn substitution_applies_inside_wildcard_bounds() { + let mut subst = Substitution::new(); + subst.insert("E".to_string(), TypeRef::Id("com.A".to_string())); + + let ty = TypeRef::Wildcard { + bound: Some(Box::new(TypeRef::Id("E".to_string()))), + is_upper_bound: false, + }; + + let applied = subst.apply(&ty); + assert_eq!( + applied, + TypeRef::Wildcard { + bound: Some(Box::new(TypeRef::Id("com.A".to_string()))), + is_upper_bound: false, + } + ); +} diff --git a/crates/lang-java/tests/edge_verification.rs b/crates/lang-java/tests/edge_verification.rs index 3182768..139a03f 100644 --- a/crates/lang-java/tests/edge_verification.rs +++ b/crates/lang-java/tests/edge_verification.rs @@ -13,7 +13,10 @@ fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: E println!("Available nodes:"); for (id, _) in graph.fqn_map() { use naviscope_plugin::NamingConvention; - println!(" - {}", naviscope_plugin::DotPathConvention.render_fqn(*id, graph.fqns())); + println!( + " - {}", + naviscope_plugin::StandardNamingConvention.render_fqn(*id, graph.fqns()) + ); } panic!("Source node not found: {}", from_fqn); } @@ -21,7 +24,10 @@ fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: E println!("Available nodes:"); for (id, _) in graph.fqn_map() { use naviscope_plugin::NamingConvention; - println!(" - {}", naviscope_plugin::DotPathConvention.render_fqn(*id, graph.fqns())); + println!( + " - {}", + naviscope_plugin::StandardNamingConvention.render_fqn(*id, graph.fqns()) + ); } panic!("Target node not found: {}", to_fqn); } @@ -34,7 +40,10 @@ fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: E println!("Graph nodes:"); for (id, _) in graph.fqn_map() { use naviscope_plugin::NamingConvention; - println!(" - {}", naviscope_plugin::DotPathConvention.render_fqn(*id, graph.fqns())); + println!( + " - {}", + naviscope_plugin::StandardNamingConvention.render_fqn(*id, graph.fqns()) + ); } println!("Edges from {}:", from_fqn); let mut edges = graph @@ -63,7 +72,10 @@ fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: E 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, std::collections::HashMap::new()); + let discovery = naviscope_core::features::discovery::DiscoveryEngine::new( + graph, + std::collections::HashMap::new(), + ); let candidate_files = discovery.scout_references(&[target_idx]); assert!( candidate_files.contains(&std::path::PathBuf::from(expected_file)), @@ -91,14 +103,14 @@ fn test_edge_contains() { assert_edge( &index, "com.test.Container", - "com.test.Container.field", + "com.test.Container#field", EdgeType::Contains, ); // Class -> Method assert_edge( &index, "com.test.Container", - "com.test.Container.method", + "com.test.Container#method()", EdgeType::Contains, ); } @@ -143,7 +155,7 @@ fn test_edge_calls() { 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"); + assert_reference_scouted(&index, "com.test.Service#helper()", "src/Service.java"); } #[test] @@ -180,7 +192,7 @@ fn test_edge_typed_as() { ]; let (index, _) = setup_java_test_graph(files); - assert_edge(&index, "User.address", "Address", EdgeType::TypedAs); + assert_edge(&index, "User#address", "Address", EdgeType::TypedAs); } #[test] diff --git a/crates/lang-java/tests/goto_definition_behavior.rs b/crates/lang-java/tests/goto_definition_behavior.rs new file mode 100644 index 0000000..8c23fad --- /dev/null +++ b/crates/lang-java/tests/goto_definition_behavior.rs @@ -0,0 +1,280 @@ +mod common; + +use common::{offset_to_point, setup_java_test_graph}; +use naviscope_api::models::SymbolResolution; +use naviscope_core::features::CodeGraphLike; +use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; + +#[test] +fn given_local_usage_when_goto_definition_then_returns_local_binding() { + 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 = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let usage_pos = content.rfind("x + 1").expect("find usage"); + let (line, col) = offset_to_point(content, usage_pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve local symbol"); + + if let SymbolResolution::Local(range, _) = resolution { + let def_pos = content.find("int x").expect("find declaration") + 4; + assert_eq!(range.start_col, def_pos); + } else { + panic!("expected local resolution"); + } +} + +#[test] +fn given_cross_file_call_when_goto_definition_then_resolves_precise_method_owner() { + let files = vec![ + ( + "A.java", + "package com; public class A { public void hello() {} }", + ), + ( + "B.java", + "package com; public class B { void run(A a) { a.hello(); } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[1].1; + let tree = &trees[1].2; + let pos = content.find("hello()").expect("find method call"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve hello call"); + let matches = resolver.find_matches(&index, &resolution); + + assert_eq!(matches.len(), 1, "goto definition should be unique"); + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention::default()) + ), + "com.A#hello()" + ); +} + +#[test] +fn given_shadowed_local_when_goto_definition_then_binds_to_local_declaration() { + let files = vec![( + "Test.java", + "public class Test { int x = 0; void run() { int x = 1; x = 2; } }", + )]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let usage_pos = content.find("x = 2").expect("find local assignment"); + let (line, col) = offset_to_point(content, usage_pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve local usage"); + + if let SymbolResolution::Local(range, _) = resolution { + let def_pos = content.find("int x = 1").expect("find local declaration") + 4; + assert_eq!(range.start_col, def_pos); + } else { + panic!("expected local resolution for shadowed variable"); + } +} + +#[test] +fn given_constructor_call_when_goto_definition_then_resolves_class_or_ctor_symbol() { + 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 = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let b_content = &trees[1].1; + let b_tree = &trees[1].2; + let usage_pos = b_content.find("new A()").expect("find constructor call") + 4; + let (line, col) = offset_to_point(b_content, usage_pos); + + let resolution = resolver + .resolve_at(b_tree, b_content, line, col, &index) + .expect("resolve constructor symbol"); + let matches = resolver.find_matches(&index, &resolution); + + assert!(!matches.is_empty()); + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert!( + index + .render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention::default()) + ) + .contains('A') + ); +} + +#[test] +fn given_static_field_access_when_goto_definition_then_resolves_declaring_field() { + 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 = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let b_content = &trees[1].1; + let b_tree = &trees[1].2; + let usage_pos = b_content.find("VAL").expect("find static field usage"); + let (line, col) = offset_to_point(b_content, usage_pos); + + let resolution = resolver + .resolve_at(b_tree, b_content, line, col, &index) + .expect("resolve static field"); + let matches = resolver.find_matches(&index, &resolution); + + assert!(!matches.is_empty()); + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention::default()) + ), + "A#VAL" + ); +} + +#[test] +fn given_overloaded_method_call_chain_when_goto_definition_then_uses_most_specific_overload() { + let files = vec![ + ( + "BaseResult.java", + "public class BaseResult { void base() {} }", + ), + ( + "SpecialResult.java", + "public class SpecialResult extends BaseResult { void special() {} }", + ), + ("SpecialArg.java", "public class SpecialArg {}"), + ( + "A.java", + "public class A { BaseResult pick(Object o) { return new BaseResult(); } SpecialResult pick(SpecialArg a) { return new SpecialResult(); } }", + ), + ( + "Use.java", + "public class Use { void run(A a) { a.pick(new SpecialArg()).special(); } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[4].1; + let tree = &trees[4].2; + let pos = content + .find("special()") + .expect("find chained method invocation"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve chained method symbol"); + let matches = resolver.find_matches(&index, &resolution); + + assert_eq!(matches.len(), 1); + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention::default()) + ), + "SpecialResult#special()" + ); +} + +#[test] +fn given_overloaded_constructor_call_when_goto_definition_then_resolves_constructor_type_symbol() { + let files = vec![ + ("SpecialArg.java", "public class SpecialArg {}"), + ("A.java", "public class A { A() {} A(SpecialArg arg) {} }"), + ( + "Use.java", + "public class Use { A build() { return new A(new SpecialArg()); } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[2].1; + let tree = &trees[2].2; + let pos = content + .find("new A(new SpecialArg())") + .expect("find overloaded constructor call") + + 4; + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve constructor symbol"); + let matches = resolver.find_matches(&index, &resolution); + + assert!(!matches.is_empty()); + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention::default()) + ), + "A" + ); +} + +#[test] +fn given_same_class_different_arity_overloads_when_goto_definition_then_resolves_member_symbol() { + let files = vec![( + "A.java", + "public class A { void target() { target(1); target(1, 2); } void target(int a) {} void target(int a, int b) {} }", + )]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let pos = content + .find("target(1, 2)") + .expect("find same-class overload call"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve overloaded call"); + let matches = resolver.find_matches(&index, &resolution); + + assert!(!matches.is_empty()); + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention::default()) + ), + "A#target(int,int)" + ); +} diff --git a/crates/lang-java/tests/graph_adapter_regression.rs b/crates/lang-java/tests/graph_adapter_regression.rs new file mode 100644 index 0000000..bfc71dd --- /dev/null +++ b/crates/lang-java/tests/graph_adapter_regression.rs @@ -0,0 +1,342 @@ +mod common; + +use common::setup_java_test_graph; +use lasso::Key; +use naviscope_api::models::fqn::FqnNode; +use naviscope_api::models::graph::{EdgeType, GraphNode, NodeKind}; +use naviscope_api::models::symbol::{FqnId, FqnReader, Symbol}; +use naviscope_java::inference::adapters::CodeGraphTypeSystem; +use naviscope_java::inference::{ + InheritanceProvider, MemberProvider, TypeProvider, TypeResolutionContext, +}; +use naviscope_plugin::{CodeGraph, Direction}; +use std::collections::HashMap; +use std::path::Path; + +#[test] +fn resolve_type_name_prefers_explicit_import() { + let files = vec![ + ("src/a/Foo.java", "package a; public class Foo {}"), + ("src/b/Foo.java", "package b; public class Foo {}"), + ( + "src/t/Use.java", + "package t; import a.Foo; import b.*; class Use { Foo foo; }", + ), + ]; + let (graph, _) = setup_java_test_graph(files); + let ts = CodeGraphTypeSystem::new(&graph); + let ctx = TypeResolutionContext { + package: Some("t".to_string()), + imports: vec!["a.Foo".to_string(), "b.*".to_string()], + ..TypeResolutionContext::default() + }; + + assert_eq!(ts.resolve_type_name("Foo", &ctx), Some("a.Foo".to_string())); +} + +#[test] +fn resolve_type_name_prefers_same_package_before_wildcard_import() { + let files = vec![ + ("src/t/Foo.java", "package t; public class Foo {}"), + ("src/b/Foo.java", "package b; public class Foo {}"), + ( + "src/t/Use.java", + "package t; import b.*; class Use { Foo foo; }", + ), + ]; + let (graph, _) = setup_java_test_graph(files); + let ts = CodeGraphTypeSystem::new(&graph); + let ctx = TypeResolutionContext { + package: Some("t".to_string()), + imports: vec!["b.*".to_string()], + ..TypeResolutionContext::default() + }; + + assert_eq!(ts.resolve_type_name("Foo", &ctx), Some("t.Foo".to_string())); +} + +#[test] +fn walk_ancestors_and_descendants_respect_max_depth() { + let files = vec![( + "src/p/Chain.java", + r#" +package p; +class C0 {} +class C1 extends C0 {} +class C2 extends C1 {} +class C3 extends C2 {} +class C4 extends C3 {} +class C5 extends C4 {} +class C6 extends C5 {} +class C7 extends C6 {} +class C8 extends C7 {} +class C9 extends C8 {} +class C10 extends C9 {} +class C11 extends C10 {} +"#, + )]; + + let (graph, _) = setup_java_test_graph(files); + let ts = CodeGraphTypeSystem::new(&graph); + + let ancestors: Vec<_> = ts.walk_ancestors("p.C11").collect(); + assert_eq!(ancestors.len(), 10); + assert!(ancestors.contains(&"p.C1".to_string())); + assert!(!ancestors.contains(&"p.C0".to_string())); + + let descendants: Vec<_> = ts.walk_descendants("p.C0").collect(); + assert_eq!(descendants.len(), 10); + assert!(descendants.contains(&"p.C10".to_string())); + assert!(!descendants.contains(&"p.C11".to_string())); +} + +#[test] +fn type_info_and_members_preserve_modifiers() { + let files = vec![( + "src/p/User.java", + "package p; public class User { public static final String NAME = \"x\"; public void ping() {} }", + )]; + + let (graph, _) = setup_java_test_graph(files); + let ts = CodeGraphTypeSystem::new(&graph); + + let info = ts.get_type_info("p.User").expect("type info"); + assert!(info.modifiers.iter().any(|m| m == "public")); + + let methods = ts.get_members("p.User", "ping"); + assert!(!methods.is_empty()); + assert!( + methods + .iter() + .any(|m| m.modifiers.iter().any(|modi| modi == "public")) + ); +} + +#[test] +fn method_parameters_keep_original_names_from_graph_metadata() { + let files = vec![( + "src/p/User.java", + "package p; public class User { public void greet(String name, int age) {} }", + )]; + + let (graph, _) = setup_java_test_graph(files); + let ts = CodeGraphTypeSystem::new(&graph); + + let method = ts + .get_members("p.User", "greet") + .into_iter() + .next() + .expect("greet member"); + let params = method.parameters.expect("method params"); + assert_eq!(params.len(), 2); + assert_eq!(params[0].name, "name"); + assert_eq!(params[1].name, "age"); +} + +struct FakeGraph { + fqns: HashMap>, + nodes: HashMap, + neighbors: HashMap<(FqnId, u8, Option), Vec>, + atoms: HashMap, +} + +impl FakeGraph { + fn new() -> Self { + Self { + fqns: HashMap::new(), + nodes: HashMap::new(), + neighbors: HashMap::new(), + atoms: HashMap::new(), + } + } +} + +impl FqnReader for FakeGraph { + fn resolve_node(&self, id: FqnId) -> Option { + self.nodes.get(&id).cloned() + } + + fn resolve_atom(&self, atom: Symbol) -> &str { + self.atoms + .get(&(atom.0.into_usize() as u32)) + .map(String::as_str) + .unwrap_or("") + } +} + +impl CodeGraph for FakeGraph { + fn resolve_fqn(&self, fqn: &str) -> Vec { + self.fqns.get(fqn).cloned().unwrap_or_default() + } + + fn get_node_at(&self, _path: &Path, _line: usize, _col: usize) -> Option { + None + } + + fn resolve_atom(&self, atom: Symbol) -> &str { + ::resolve_atom(self, atom) + } + + fn fqns(&self) -> &dyn FqnReader { + self + } + + fn get_node(&self, _id: FqnId) -> Option { + None + } + + fn get_neighbors( + &self, + id: FqnId, + direction: Direction, + edge_type: Option, + ) -> Vec { + self.neighbors + .get(&(id, dir_tag(direction), edge_type)) + .cloned() + .unwrap_or_default() + } +} + +fn atom(id: u32) -> Symbol { + Symbol(lasso::Spur::try_from_usize(id as usize).expect("spur")) +} + +fn dir_tag(direction: Direction) -> u8 { + match direction { + Direction::Incoming => 0, + Direction::Outgoing => 1, + } +} + +fn fake_graph_for_dedup() -> FakeGraph { + let mut g = FakeGraph::new(); + + // atoms + g.atoms.insert(1, "p".to_string()); + g.atoms.insert(2, "A".to_string()); + g.atoms.insert(3, "B".to_string()); + g.atoms.insert(4, "I".to_string()); + + // FQN tree: + // p (100) + // ├─ A (101) and duplicate A (102) + // ├─ B (103) + // └─ I (104) and duplicate I (105) + g.nodes.insert( + FqnId(100), + FqnNode { + parent: None, + name: atom(1), + kind: NodeKind::Package, + }, + ); + g.nodes.insert( + FqnId(101), + FqnNode { + parent: Some(FqnId(100)), + name: atom(2), + kind: NodeKind::Class, + }, + ); + g.nodes.insert( + FqnId(102), + FqnNode { + parent: Some(FqnId(100)), + name: atom(2), + kind: NodeKind::Class, + }, + ); + g.nodes.insert( + FqnId(103), + FqnNode { + parent: Some(FqnId(100)), + name: atom(3), + kind: NodeKind::Class, + }, + ); + g.nodes.insert( + FqnId(104), + FqnNode { + parent: Some(FqnId(100)), + name: atom(4), + kind: NodeKind::Interface, + }, + ); + g.nodes.insert( + FqnId(105), + FqnNode { + parent: Some(FqnId(100)), + name: atom(4), + kind: NodeKind::Interface, + }, + ); + + g.fqns.insert("p.A".to_string(), vec![FqnId(101), FqnId(102)]); + g.fqns.insert("p.I".to_string(), vec![FqnId(104), FqnId(105)]); + + // get_interfaces("p.A"): duplicates from duplicate node ids and repeated neighbors + g.neighbors.insert( + (FqnId(101), dir_tag(Direction::Outgoing), Some(EdgeType::Implements)), + vec![FqnId(104), FqnId(104)], + ); + g.neighbors.insert( + (FqnId(102), dir_tag(Direction::Outgoing), Some(EdgeType::Implements)), + vec![FqnId(104), FqnId(105)], + ); + + // get_direct_subtypes("p.I"): duplicates across InheritsFrom/Implements and duplicate I ids + g.neighbors.insert( + ( + FqnId(104), + dir_tag(Direction::Incoming), + Some(EdgeType::InheritsFrom), + ), + vec![FqnId(101)], + ); + g.neighbors.insert( + ( + FqnId(104), + dir_tag(Direction::Incoming), + Some(EdgeType::Implements), + ), + vec![FqnId(101), FqnId(103), FqnId(101)], + ); + g.neighbors.insert( + ( + FqnId(105), + dir_tag(Direction::Incoming), + Some(EdgeType::InheritsFrom), + ), + vec![FqnId(103)], + ); + g.neighbors.insert( + ( + FqnId(105), + dir_tag(Direction::Incoming), + Some(EdgeType::Implements), + ), + vec![FqnId(103)], + ); + + g +} + +#[test] +fn get_interfaces_returns_unique_fqns() { + let graph = fake_graph_for_dedup(); + let ts = CodeGraphTypeSystem::new(&graph); + + assert_eq!(ts.get_interfaces("p.A"), vec!["p.I".to_string()]); +} + +#[test] +fn get_direct_subtypes_returns_unique_fqns() { + let graph = fake_graph_for_dedup(); + let ts = CodeGraphTypeSystem::new(&graph); + + assert_eq!( + ts.get_direct_subtypes("p.I"), + vec!["p.A".to_string(), "p.B".to_string()] + ); +} diff --git a/crates/lang-java/tests/implementation_behavior.rs b/crates/lang-java/tests/implementation_behavior.rs new file mode 100644 index 0000000..2ed81c0 --- /dev/null +++ b/crates/lang-java/tests/implementation_behavior.rs @@ -0,0 +1,85 @@ +mod common; + +use common::{offset_to_point, setup_java_test_graph}; +use naviscope_core::features::CodeGraphLike; +use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; + +#[test] +fn given_interface_when_goto_implementation_then_returns_all_implementors() { + let files = vec![ + ("IService.java", "public interface IService { void run(); }"), + ( + "AService.java", + "public class AService implements IService { public void run() {} }", + ), + ( + "BService.java", + "public class BService implements IService { public void run() {} }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let pos = content.find("IService").expect("find interface"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve interface"); + + let impls = resolver.find_implementations(&index, &resolution); + let mut fqns: Vec = impls + .iter() + .map(|id| { + let idx = *index.fqn_map().get(id).expect("node exists"); + index + .render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention), + ) + .to_string() + }) + .collect(); + fqns.sort(); + + assert_eq!(fqns, vec!["AService", "BService"]); +} + +#[test] +fn given_interface_method_when_goto_implementation_then_returns_method_override() { + 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 = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let pos = content.find("act()").expect("find interface method"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve interface method"); + + let impls = resolver.find_implementations(&index, &resolution); + assert_eq!(impls.len(), 1); + + let idx = *index.fqn_map().get(&impls[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention) + ), + "Impl#act()" + ); +} diff --git a/crates/lang-java/tests/inference_mock_test.rs b/crates/lang-java/tests/inference_mock_test.rs new file mode 100644 index 0000000..c031af4 --- /dev/null +++ b/crates/lang-java/tests/inference_mock_test.rs @@ -0,0 +1,1135 @@ +//! Mock implementation of JavaTypeSystem for testing. +//! Moved from src/inference/adapters/mock.rs to tests/inference_mock_test.rs + +use naviscope_api::models::TypeRef; +use std::collections::HashMap; + +use naviscope_java::inference::JavaTypeSystem; +use naviscope_java::inference::core::types::TypeParameter; +use naviscope_java::inference::scope::ScopeManager; +use naviscope_java::inference::{InheritanceProvider, MemberProvider, TypeProvider}; +use naviscope_java::inference::{ + MemberInfo, MemberKind, TypeInfo, TypeKind, TypeResolutionContext, +}; +use naviscope_java::inference::{create_inference_context, infer_expression}; +use naviscope_java::parser::JavaParser; + +/// A mock type system for testing. +/// +/// Can be built using a fluent API. +#[derive(Default)] +pub struct MockTypeSystem { + types: HashMap, + inheritance: HashMap, Vec)>, // (superclass, interfaces) + members: HashMap>, +} + +impl MockTypeSystem { + /// Create a new empty mock. + pub fn new() -> Self { + Self::default() + } + + /// Add a class to the mock. + pub fn add_class(mut self, fqn: &str, super_class: Option<&str>) -> Self { + self.types.insert( + fqn.to_string(), + TypeInfo { + fqn: fqn.to_string(), + kind: TypeKind::Class, + modifiers: vec![], + type_parameters: vec![], + }, + ); + + self.inheritance.insert( + fqn.to_string(), + (super_class.map(|s| s.to_string()), vec![]), + ); + + self + } + + /// Add a class with generic type parameters. + pub fn add_class_with_type_params( + mut self, + fqn: &str, + super_class: Option<&str>, + type_parameters: Vec<&str>, + ) -> Self { + self.types.insert( + fqn.to_string(), + TypeInfo { + fqn: fqn.to_string(), + kind: TypeKind::Class, + modifiers: vec![], + type_parameters: type_parameters + .into_iter() + .map(|name| TypeParameter { + name: name.to_string(), + bounds: vec![], + }) + .collect(), + }, + ); + + self.inheritance.insert( + fqn.to_string(), + (super_class.map(|s| s.to_string()), vec![]), + ); + + self + } + + /// Add an interface to the mock. + pub fn add_interface(mut self, fqn: &str) -> Self { + self.types.insert( + fqn.to_string(), + TypeInfo { + fqn: fqn.to_string(), + kind: TypeKind::Interface, + modifiers: vec![], + type_parameters: vec![], + }, + ); + + self.inheritance.insert(fqn.to_string(), (None, vec![])); + + self + } + + /// Add an interface with generic type parameters. + pub fn add_interface_with_type_params(mut self, fqn: &str, type_parameters: Vec<&str>) -> Self { + self.types.insert( + fqn.to_string(), + TypeInfo { + fqn: fqn.to_string(), + kind: TypeKind::Interface, + modifiers: vec![], + type_parameters: type_parameters + .into_iter() + .map(|name| TypeParameter { + name: name.to_string(), + bounds: vec![], + }) + .collect(), + }, + ); + + self.inheritance.insert(fqn.to_string(), (None, vec![])); + + self + } + + /// Add interface implementation to a class. + pub fn implements(mut self, class_fqn: &str, interface_fqn: &str) -> Self { + if let Some((_super_class, interfaces)) = self.inheritance.get_mut(class_fqn) { + interfaces.push(interface_fqn.to_string()); + } else { + self.inheritance.insert( + class_fqn.to_string(), + (None, vec![interface_fqn.to_string()]), + ); + } + // Also ensure interface type exists + if !self.types.contains_key(interface_fqn) { + self = self.add_interface(interface_fqn); + } + self + } + + /// Add a method to a class. + pub fn add_method(mut self, class_fqn: &str, name: &str, return_type: TypeRef) -> Self { + let member = MemberInfo { + name: name.to_string(), + fqn: format!("{}#{}", class_fqn, name), + kind: MemberKind::Method, + declaring_type: class_fqn.to_string(), + type_ref: return_type, + parameters: Some(vec![]), + modifiers: vec![], + generic_signature: None, + }; + + self.members + .entry(class_fqn.to_string()) + .or_default() + .push(member); + + self + } + + /// Add a method with parameters to a class. + pub fn add_method_with_params( + mut self, + class_fqn: &str, + name: &str, + return_type: TypeRef, + param_types: Vec, + ) -> Self { + let parameters: Vec<_> = param_types + .into_iter() + .enumerate() + .map(|(i, t)| naviscope_java::inference::ParameterInfo { + name: format!("arg{}", i), + type_ref: t, + is_varargs: false, + }) + .collect(); + + let member = MemberInfo { + name: name.to_string(), + fqn: format!("{}#{}", class_fqn, name), + kind: MemberKind::Method, + declaring_type: class_fqn.to_string(), + type_ref: return_type, + parameters: Some(parameters), + modifiers: vec![], + generic_signature: None, + }; + + self.members + .entry(class_fqn.to_string()) + .or_default() + .push(member); + + self + } + + /// Add a field to a class. + pub fn add_field(mut self, class_fqn: &str, name: &str, field_type: TypeRef) -> Self { + let member = MemberInfo { + name: name.to_string(), + fqn: format!("{}#{}", class_fqn, name), + kind: MemberKind::Field, + declaring_type: class_fqn.to_string(), + type_ref: field_type, + parameters: None, + modifiers: vec![], + generic_signature: None, + }; + + self.members + .entry(class_fqn.to_string()) + .or_default() + .push(member); + + self + } +} + +fn find_first_named<'a>(node: tree_sitter::Node<'a>, kind: &str) -> Option> { + if node.kind() == kind { + return Some(node); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some(found) = find_first_named(child, kind) { + return Some(found); + } + } + None +} + +impl TypeProvider for MockTypeSystem { + fn get_type_info(&self, fqn: &str) -> Option { + self.types.get(fqn).cloned() + } + + fn resolve_type_name(&self, simple_name: &str, _ctx: &TypeResolutionContext) -> Option { + // Simple mock: just check if we have a type ending with simple name + for fqn in self.types.keys() { + if fqn.ends_with(&format!(".{}", simple_name)) || fqn == simple_name { + return Some(fqn.clone()); + } + } + None + } +} + +impl InheritanceProvider for MockTypeSystem { + fn get_superclass(&self, fqn: &str) -> Option { + self.inheritance + .get(fqn) + .and_then(|(super_class, _)| super_class.clone()) + } + + fn get_interfaces(&self, fqn: &str) -> Vec { + self.inheritance + .get(fqn) + .map(|(_, interfaces)| interfaces.clone()) + .unwrap_or_default() + } + + fn get_direct_subtypes(&self, type_fqn: &str) -> Vec { + let mut subtypes = Vec::new(); + + // Check inheritance map for both superclass and interfaces + for (child, (super_class, interfaces)) in &self.inheritance { + // Check if superclass matches + if let Some(sc) = super_class { + if sc == type_fqn { + subtypes.push(child.clone()); + } + } + + // Check if any interface matches + if interfaces.contains(&type_fqn.to_string()) { + subtypes.push(child.clone()); + } + } + + subtypes + } + + fn walk_descendants(&self, type_fqn: &str) -> Box> { + let mut descendants = Vec::new(); + let mut queue = std::collections::VecDeque::new(); + queue.push_back(type_fqn.to_string()); + + let mut visited = std::collections::HashSet::new(); + visited.insert(type_fqn.to_string()); + + while let Some(current) = queue.pop_front() { + let direct = self.get_direct_subtypes(¤t); + for sub in direct { + if visited.insert(sub.clone()) { + descendants.push(sub.clone()); + queue.push_back(sub); + } + } + } + + Box::new(descendants.into_iter()) + } + + fn walk_ancestors(&self, fqn: &str) -> Box + '_> { + let mut result = vec![]; + let mut visited = std::collections::HashSet::new(); + let mut queue = std::collections::VecDeque::new(); + + // Start with direct parents + if let Some((super_class, interfaces)) = self.inheritance.get(fqn) { + if let Some(s) = super_class { + queue.push_back(s.clone()); + } + for i in interfaces { + queue.push_back(i.clone()); + } + } + + while let Some(current) = queue.pop_front() { + if visited.contains(¤t) { + continue; + } + visited.insert(current.clone()); + result.push(current.clone()); + + // Add parents + if let Some((super_class, interfaces)) = self.inheritance.get(¤t) { + if let Some(s) = super_class { + queue.push_back(s.clone()); + } + for i in interfaces { + queue.push_back(i.clone()); + } + } + } + + Box::new(result.into_iter()) + } +} + +impl MemberProvider for MockTypeSystem { + fn get_members(&self, type_fqn: &str, member_name: &str) -> Vec { + self.members + .get(type_fqn) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|m| m.name == member_name) + .collect() + } + + fn get_all_members(&self, type_fqn: &str) -> Vec { + self.members.get(type_fqn).cloned().unwrap_or_default() + } +} + +// ========================================================================= +// Mock Construction Tests +// ========================================================================= + +#[test] +fn test_mock_add_class() { + let ts = MockTypeSystem::new() + .add_class("Parent", None) + .add_class("Child", Some("Parent")); + + assert!(ts.get_type_info("Parent").is_some()); + assert!(ts.get_type_info("Child").is_some()); + assert_eq!(ts.get_superclass("Child"), Some("Parent".to_string())); +} + +#[test] +fn test_mock_add_method() { + let ts = MockTypeSystem::new().add_class("Foo", None).add_method( + "Foo", + "bar", + TypeRef::Id("String".into()), + ); + + let members = ts.get_members("Foo", "bar"); + assert!(!members.is_empty()); + assert_eq!(members[0].name, "bar"); +} + +#[test] +fn test_mock_add_field() { + let ts = MockTypeSystem::new().add_class("Foo", None).add_field( + "Foo", + "name", + TypeRef::Id("String".into()), + ); + + let members = ts.get_members("Foo", "name"); + assert!(!members.is_empty()); + let member = &members[0]; + assert_eq!(member.name, "name"); + assert_eq!(member.kind, MemberKind::Field); + assert_eq!(member.type_ref, TypeRef::Id("String".into())); +} + +// ========================================================================= +// Inheritance Hierarchy Tests +// ========================================================================= + +#[test] +fn test_find_inherited_member() { + let ts = MockTypeSystem::new() + .add_class("Parent", None) + .add_method("Parent", "getContext", TypeRef::Id("Context".into())) + .add_class("Child", Some("Parent")); + + // Child doesn't have getContext directly + assert!(ts.get_members("Child", "getContext").is_empty()); + + // But it should be found via hierarchy + let members = ts.find_member_in_hierarchy("Child", "getContext"); + assert!(!members.is_empty()); + assert_eq!(members[0].declaring_type, "Parent"); +} + +#[test] +fn test_walk_ancestors() { + let ts = MockTypeSystem::new() + .add_class("Object", None) + .add_class("Parent", Some("Object")) + .add_class("Child", Some("Parent")); + + let ancestors: Vec<_> = ts.walk_ancestors("Child").collect(); + assert!(ancestors.contains(&"Parent".to_string())); + assert!(ancestors.contains(&"Object".to_string())); +} + +#[test] +fn test_interfaces() { + let ts = MockTypeSystem::new() + .add_class("MyList", None) + .add_interface("List") + .add_interface("Iterable") + .implements("MyList", "List") + .implements("List", "Iterable"); + + let ifaces = ts.get_interfaces("MyList"); + assert!(ifaces.contains(&"List".to_string())); + + // Should walk to Iterable + let ancestors: Vec<_> = ts.walk_ancestors("MyList").collect(); + assert!(ancestors.contains(&"List".to_string())); + assert!(ancestors.contains(&"Iterable".to_string())); +} + +// ========================================================================= +// JavaTypeSystem Trait Tests (via MockTypeSystem) +// ========================================================================= + +#[test] +fn test_find_member_in_hierarchy_direct() { + // Test that direct members are found first + let ts = MockTypeSystem::new() + .add_class("Base", None) + .add_method("Base", "toString", TypeRef::Id("String".into())) + .add_class("Derived", Some("Base")) + .add_method("Derived", "toString", TypeRef::Id("StringOverride".into())); + + // Finding toString on Derived should return Derived's version + let members = ts.find_member_in_hierarchy("Derived", "toString"); + assert!(!members.is_empty()); + let member = &members[0]; + assert_eq!(member.declaring_type, "Derived"); + assert_eq!(member.type_ref, TypeRef::Id("StringOverride".into())); +} + +#[test] +fn test_find_member_in_hierarchy_inherited() { + let ts = MockTypeSystem::new() + .add_class("GrandParent", None) + .add_method( + "GrandParent", + "legacyMethod", + TypeRef::Id("LegacyType".into()), + ) + .add_class("Parent", Some("GrandParent")) + .add_class("Child", Some("Parent")); + + // Child should find GrandParent's method through hierarchy + let members = ts.find_member_in_hierarchy("Child", "legacyMethod"); + assert!(!members.is_empty()); + let member = &members[0]; + assert_eq!(member.declaring_type, "GrandParent"); +} + +#[test] +fn test_find_member_in_interface_hierarchy() { + let ts = MockTypeSystem::new() + .add_interface("Iterator") + .add_method("Iterator", "next", TypeRef::Id("Object".into())) + .add_interface("ListIterator") + .implements("ListIterator", "Iterator") + .add_class("ConcreteIterator", None) + .implements("ConcreteIterator", "ListIterator"); + + // ConcreteIterator should find Iterator's method + let members = ts.find_member_in_hierarchy("ConcreteIterator", "next"); + assert!(!members.is_empty()); + assert_eq!(members[0].declaring_type, "Iterator"); +} + +#[test] +fn test_find_nonexistent_member() { + let ts = MockTypeSystem::new().add_class("Class", None); + + let members = ts.find_member_in_hierarchy("Class", "nonexistent"); + assert!(members.is_empty()); +} + +// ========================================================================= +// Type Resolution Tests +// ========================================================================= + +#[test] +fn test_resolve_simple_type_name() { + let ts = MockTypeSystem::new() + .add_class("com.example.MyClass", None) + .add_class("String", None); + + let ctx = TypeResolutionContext::default(); + + // Simple name should match exact + assert_eq!( + ts.resolve_type_name("String", &ctx), + Some("String".to_string()) + ); + + // Should match types ending with the simple name + assert_eq!( + ts.resolve_type_name("MyClass", &ctx), + Some("com.example.MyClass".to_string()) + ); +} + +// ========================================================================= +// Field Access Chain Tests +// ========================================================================= + +#[test] +fn test_field_method_chain_types() { + // Simulates: a.b.doB() + // class A { B b; } + // class B { void doB() {} } + let ts = MockTypeSystem::new() + .add_class("A", None) + .add_field("A", "b", TypeRef::Id("B".into())) + .add_class("B", None) + .add_method("B", "doB", TypeRef::Raw("void".into())); + + // Verify field lookup + let field_b = ts.find_member_in_hierarchy("A", "b"); + assert!(!field_b.is_empty()); + let field_b = &field_b[0]; + assert_eq!(field_b.type_ref, TypeRef::Id("B".into())); + + // Verify method lookup on B + let method_dob = ts.find_member_in_hierarchy("B", "doB"); + assert!(!method_dob.is_empty()); + assert_eq!(method_dob[0].fqn, "B#doB"); +} + +#[test] +fn test_chained_method_calls() { + // Simulates: response.getContext().get("key") + // class HttpResponse { SessionContext getContext(); } + // class SessionContext { Object get(String key); } + let ts = MockTypeSystem::new() + .add_class("HttpResponse", None) + .add_method( + "HttpResponse", + "getContext", + TypeRef::Id("SessionContext".into()), + ) + .add_class("SessionContext", None) + .add_method("SessionContext", "get", TypeRef::Id("Object".into())); + + // Simulate chain resolution: + // 1. Find getContext on HttpResponse -> returns SessionContext + let step1 = ts.find_member_in_hierarchy("HttpResponse", "getContext"); + assert!(!step1.is_empty()); + let context_type = step1[0].type_ref.clone(); + assert_eq!(context_type, TypeRef::Id("SessionContext".into())); + + // 2. Find get on SessionContext -> returns Object + let type_fqn = match &context_type { + TypeRef::Id(fqn) => fqn.clone(), + _ => panic!("Expected Id type"), + }; + let step2 = ts.find_member_in_hierarchy(&type_fqn, "get"); + assert!(!step2.is_empty()); + assert_eq!(step2[0].type_ref, TypeRef::Id("Object".into())); +} + +// ========================================================================= +// Complex Inheritance Tests +// ========================================================================= + +#[test] +fn test_diamond_inheritance() { + // Test diamond inheritance pattern: + // Object + // / \ + // Readable Appendable + // \ / + // Stream + let ts = MockTypeSystem::new() + .add_interface("Object") + .add_method("Object", "toString", TypeRef::Id("String".into())) + .add_interface("Readable") + .add_method("Readable", "read", TypeRef::Raw("int".into())) + .implements("Readable", "Object") + .add_interface("Appendable") + .add_method("Appendable", "append", TypeRef::Id("Appendable".into())) + .implements("Appendable", "Object") + .add_class("Stream", None) + .implements("Stream", "Readable") + .implements("Stream", "Appendable"); + + // Stream should find all methods + assert!(!ts.find_member_in_hierarchy("Stream", "read").is_empty()); + assert!(!ts.find_member_in_hierarchy("Stream", "append").is_empty()); + assert!(!ts.find_member_in_hierarchy("Stream", "toString").is_empty()); +} + +#[test] +fn test_spring_boot_pattern() { + // Common Spring Boot pattern: + // class MyController { + // private MyService service; + // void handle() { service.doWork(); } + // } + let ts = MockTypeSystem::new() + .add_class("MyService", None) + .add_method("MyService", "doWork", TypeRef::Raw("void".into())) + .add_class("MyController", None) + .add_field("MyController", "service", TypeRef::Id("MyService".into())); + + // From MyController, find service field + let service_field = ts.find_member_in_hierarchy("MyController", "service"); + assert!(!service_field.is_empty()); + let service_type = service_field[0].type_ref.clone(); + assert_eq!(service_type, TypeRef::Id("MyService".into())); + + // From MyService, find doWork method + let type_fqn = match &service_type { + TypeRef::Id(fqn) => fqn.clone(), + _ => panic!("Expected Id type"), + }; + let do_work = ts.find_member_in_hierarchy(&type_fqn, "doWork"); + assert!(!do_work.is_empty()); +} + +// ========================================================================= +// Generic Type Tests (Basic) +// ========================================================================= + +#[test] +fn test_generic_return_type() { + // List.get(int) -> String (simplified) + // In reality this needs generic substitution, but we test the raw lookup + let ts = MockTypeSystem::new() + .add_interface("java.util.List") + .add_method("java.util.List", "get", TypeRef::Id("E".into())) // E is type parameter + .add_method("java.util.List", "size", TypeRef::Raw("int".into())); + + let get_method = ts.find_member_in_hierarchy("java.util.List", "get"); + assert!(!get_method.is_empty()); + // For now, returns the raw type parameter E + assert_eq!(get_method[0].type_ref, TypeRef::Id("E".into())); +} + +#[test] +fn test_infer_generic_method_call_with_substitution() { + let source = r#" +import java.util.List; +class Demo { + void run() { + List list = null; + list.get(0); + } +} +"#; + + let parser = JavaParser::new().expect("failed to create parser"); + let tree = parser.parse(source, None).expect("failed to parse source"); + let root = tree.root_node(); + let method_invocation = find_first_named(root, "method_invocation") + .expect("expected method_invocation in test snippet"); + + let ts = MockTypeSystem::new() + .add_class("java.lang.String", None) + .add_class("java.lang.Object", None) + .add_interface_with_type_params("java.util.List", vec!["E"]) + .add_method_with_params( + "java.util.List", + "get", + TypeRef::Id("E".into()), + vec![TypeRef::Raw("int".into())], + ); + + let mut scope_manager = ScopeManager::new(); + let ctx = create_inference_context( + &root, + source, + &ts, + &mut scope_manager, + None, + vec!["java.util.List".to_string()], + ); + + let inferred = infer_expression(&method_invocation, &ctx); + assert_eq!(inferred, Some(TypeRef::Id("java.lang.String".into()))); +} + +#[test] +fn test_infer_map_get_returns_integer() { + let source = r#" +import java.util.Map; +class Demo { + void run() { + Map map = null; + map.get("k"); + } +} +"#; + + let parser = JavaParser::new().expect("failed to create parser"); + let tree = parser.parse(source, None).expect("failed to parse source"); + let root = tree.root_node(); + let method_invocation = find_first_named(root, "method_invocation") + .expect("expected method_invocation in test snippet"); + + let ts = MockTypeSystem::new() + .add_class("java.lang.String", None) + .add_class("java.lang.Integer", None) + .add_interface_with_type_params("java.util.Map", vec!["K", "V"]) + .add_method_with_params( + "java.util.Map", + "get", + TypeRef::Id("V".into()), + vec![TypeRef::Id("java.lang.Object".into())], + ); + + let mut scope_manager = ScopeManager::new(); + let ctx = create_inference_context( + &root, + source, + &ts, + &mut scope_manager, + None, + vec!["java.util.Map".to_string()], + ); + + let inferred = infer_expression(&method_invocation, &ctx); + assert_eq!(inferred, Some(TypeRef::Id("java.lang.Integer".into()))); +} + +#[test] +fn test_infer_optional_get_returns_user() { + let source = r#" +import java.util.Optional; +class User {} +class Demo { + void run() { + Optional user = null; + user.get(); + } +} +"#; + + let parser = JavaParser::new().expect("failed to create parser"); + let tree = parser.parse(source, None).expect("failed to parse source"); + let root = tree.root_node(); + let method_invocation = find_first_named(root, "method_invocation") + .expect("expected method_invocation in test snippet"); + + let ts = MockTypeSystem::new() + .add_class("User", None) + .add_interface_with_type_params("java.util.Optional", vec!["T"]) + .add_method("java.util.Optional", "get", TypeRef::Id("T".into())); + + let mut scope_manager = ScopeManager::new(); + let ctx = create_inference_context( + &root, + source, + &ts, + &mut scope_manager, + None, + vec!["java.util.Optional".to_string()], + ); + + let inferred = infer_expression(&method_invocation, &ctx); + assert_eq!(inferred, Some(TypeRef::Id("User".into()))); +} + +#[test] +fn test_infer_nested_generic_map_get_returns_list_of_integer() { + let source = r#" +import java.util.Map; +import java.util.List; +class Demo { + void run() { + Map> map = null; + map.get("k"); + } +} +"#; + + let parser = JavaParser::new().expect("failed to create parser"); + let tree = parser.parse(source, None).expect("failed to parse source"); + let root = tree.root_node(); + let method_invocation = find_first_named(root, "method_invocation") + .expect("expected method_invocation in test snippet"); + + let ts = MockTypeSystem::new() + .add_class("java.lang.String", None) + .add_class("java.lang.Integer", None) + .add_interface_with_type_params("java.util.List", vec!["E"]) + .add_interface_with_type_params("java.util.Map", vec!["K", "V"]) + .add_method_with_params( + "java.util.Map", + "get", + TypeRef::Id("V".into()), + vec![TypeRef::Id("java.lang.Object".into())], + ); + + let mut scope_manager = ScopeManager::new(); + let ctx = create_inference_context( + &root, + source, + &ts, + &mut scope_manager, + None, + vec!["java.util.Map".to_string(), "java.util.List".to_string()], + ); + + let inferred = infer_expression(&method_invocation, &ctx); + assert_eq!( + inferred, + Some(TypeRef::Generic { + base: Box::new(TypeRef::Id("java.util.List".into())), + args: vec![TypeRef::Id("java.lang.Integer".into())], + }) + ); +} + +#[test] +fn test_resolve_method_autoboxing_primitive_to_wrapper() { + let ts = MockTypeSystem::new().add_class("java.lang.Integer", Some("java.lang.Object")); + + let candidates = vec![MemberInfo { + name: "set".to_string(), + fqn: "Demo#set".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![naviscope_java::inference::ParameterInfo { + name: "value".to_string(), + type_ref: TypeRef::Id("java.lang.Integer".into()), + is_varargs: false, + }]), + modifiers: vec![], + generic_signature: None, + }]; + + let resolved = ts.resolve_method(&candidates, &[TypeRef::Raw("int".into())]); + assert!(resolved.is_some()); +} + +#[test] +fn test_resolve_method_unboxing_with_widening() { + let ts = MockTypeSystem::new().add_class("java.lang.Integer", Some("java.lang.Object")); + + let candidates = vec![MemberInfo { + name: "set".to_string(), + fqn: "Demo#set".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![naviscope_java::inference::ParameterInfo { + name: "value".to_string(), + type_ref: TypeRef::Raw("long".into()), + is_varargs: false, + }]), + modifiers: vec![], + generic_signature: None, + }]; + + let resolved = ts.resolve_method(&candidates, &[TypeRef::Id("java.lang.Integer".into())]); + assert!(resolved.is_some()); +} + +#[test] +fn test_resolve_method_varargs_expanded_arguments() { + let ts = MockTypeSystem::new().add_class("java.lang.String", Some("java.lang.Object")); + + let candidates = vec![MemberInfo { + name: "log".to_string(), + fqn: "Demo#log".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![ + naviscope_java::inference::ParameterInfo { + name: "tag".to_string(), + type_ref: TypeRef::Id("java.lang.String".into()), + is_varargs: false, + }, + naviscope_java::inference::ParameterInfo { + name: "args".to_string(), + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".into())), + dimensions: 1, + }, + is_varargs: true, + }, + ]), + modifiers: vec![], + generic_signature: None, + }]; + + let resolved = ts.resolve_method( + &candidates, + &[ + TypeRef::Id("java.lang.String".into()), + TypeRef::Id("java.lang.String".into()), + TypeRef::Id("java.lang.String".into()), + ], + ); + assert!(resolved.is_some()); +} + +#[test] +fn test_resolve_method_varargs_array_passthrough() { + let ts = MockTypeSystem::new().add_class("java.lang.String", Some("java.lang.Object")); + + let candidates = vec![MemberInfo { + name: "log".to_string(), + fqn: "Demo#log".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![ + naviscope_java::inference::ParameterInfo { + name: "tag".to_string(), + type_ref: TypeRef::Id("java.lang.String".into()), + is_varargs: false, + }, + naviscope_java::inference::ParameterInfo { + name: "args".to_string(), + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".into())), + dimensions: 1, + }, + is_varargs: true, + }, + ]), + modifiers: vec![], + generic_signature: None, + }]; + + let resolved = ts.resolve_method( + &candidates, + &[ + TypeRef::Id("java.lang.String".into()), + TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".into())), + dimensions: 1, + }, + ], + ); + assert!(resolved.is_some()); +} + +#[test] +fn test_resolve_method_varargs_uses_explicit_marker() { + let ts = MockTypeSystem::new().add_class("java.lang.String", Some("java.lang.Object")); + + let non_varargs = MemberInfo { + name: "log".to_string(), + fqn: "Demo#logArray".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![ + naviscope_java::inference::ParameterInfo { + name: "tag".to_string(), + type_ref: TypeRef::Id("java.lang.String".into()), + is_varargs: false, + }, + naviscope_java::inference::ParameterInfo { + name: "args".to_string(), + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".into())), + dimensions: 1, + }, + is_varargs: false, + }, + ]), + modifiers: vec![], + generic_signature: None, + }; + + let varargs = MemberInfo { + fqn: "Demo#logVarargs".to_string(), + ..non_varargs.clone() + }; + let varargs = MemberInfo { + parameters: Some(vec![ + naviscope_java::inference::ParameterInfo { + name: "tag".to_string(), + type_ref: TypeRef::Id("java.lang.String".into()), + is_varargs: false, + }, + naviscope_java::inference::ParameterInfo { + name: "args".to_string(), + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".into())), + dimensions: 1, + }, + is_varargs: true, + }, + ]), + ..varargs + }; + + let resolved = ts.resolve_method( + &[non_varargs, varargs.clone()], + &[ + TypeRef::Id("java.lang.String".into()), + TypeRef::Id("java.lang.String".into()), + TypeRef::Id("java.lang.String".into()), + ], + ); + + assert_eq!(resolved.map(|m| m.fqn), Some("Demo#logVarargs".to_string())); +} + +#[test] +fn test_resolve_method_prefers_most_specific_fixed_overload() { + let ts = MockTypeSystem::new() + .add_class("java.lang.Object", None) + .add_class("java.lang.Number", Some("java.lang.Object")) + .add_class("java.lang.Integer", Some("java.lang.Number")); + + let object_overload = MemberInfo { + name: "set".to_string(), + fqn: "Demo#setObject".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![naviscope_java::inference::ParameterInfo { + name: "value".to_string(), + type_ref: TypeRef::Id("java.lang.Object".into()), + is_varargs: false, + }]), + modifiers: vec![], + generic_signature: None, + }; + + let number_overload = MemberInfo { + fqn: "Demo#setNumber".to_string(), + parameters: Some(vec![naviscope_java::inference::ParameterInfo { + name: "value".to_string(), + type_ref: TypeRef::Id("java.lang.Number".into()), + is_varargs: false, + }]), + ..object_overload.clone() + }; + + let resolved = ts.resolve_method( + &[object_overload, number_overload.clone()], + &[TypeRef::Id("java.lang.Integer".into())], + ); + + assert_eq!(resolved.map(|m| m.fqn), Some("Demo#setNumber".to_string())); +} + +#[test] +fn test_resolve_method_prefers_most_specific_varargs_overload() { + let ts = MockTypeSystem::new() + .add_class("java.lang.Object", None) + .add_class("java.lang.String", Some("java.lang.Object")); + + let object_varargs = MemberInfo { + name: "log".to_string(), + fqn: "Demo#logObjectVarargs".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![naviscope_java::inference::ParameterInfo { + name: "args".to_string(), + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.Object".into())), + dimensions: 1, + }, + is_varargs: true, + }]), + modifiers: vec![], + generic_signature: None, + }; + + let string_varargs = MemberInfo { + fqn: "Demo#logStringVarargs".to_string(), + parameters: Some(vec![naviscope_java::inference::ParameterInfo { + name: "args".to_string(), + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".into())), + dimensions: 1, + }, + is_varargs: true, + }]), + ..object_varargs.clone() + }; + + let resolved = ts.resolve_method( + &[object_varargs, string_varargs.clone()], + &[ + TypeRef::Id("java.lang.String".into()), + TypeRef::Id("java.lang.String".into()), + ], + ); + + assert_eq!( + resolved.map(|m| m.fqn), + Some("Demo#logStringVarargs".to_string()) + ); +} diff --git a/crates/lang-java/tests/java_integration.rs b/crates/lang-java/tests/java_integration.rs index 6ce2956..3f8281c 100644 --- a/crates/lang-java/tests/java_integration.rs +++ b/crates/lang-java/tests/java_integration.rs @@ -1,8 +1,8 @@ mod common; -use common::setup_java_test_graph; -use naviscope_core::ingest::resolver::SemanticResolver; -use naviscope_java::resolver::JavaResolver; +use common::{offset_to_point, setup_java_test_graph}; +use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; #[test] fn test_cross_file_resolution() { @@ -18,7 +18,7 @@ fn test_cross_file_resolution() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); // Test resolving 'A' in 'A a = new A();' let b_content = &trees[1].1; @@ -32,7 +32,7 @@ fn test_cross_file_resolution() { 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_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.example.A"); } else { panic!( @@ -49,8 +49,8 @@ fn test_cross_file_resolution() { 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_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { - assert_eq!(fqn, "com.example.A#hello"); + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { + assert_eq!(fqn, "com.example.A#hello()"); } else { panic!( "Expected precise resolution to com.example.A.hello, got {:?}", @@ -70,7 +70,7 @@ fn test_inheritance_and_implementations() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let i_content = &trees[0].1; let i_tree = &trees[0].2; @@ -92,7 +92,7 @@ fn test_inheritance_and_implementations() { assert_eq!( { use naviscope_plugin::NamingConvention; - naviscope_plugin::DotPathConvention.render_fqn(node.id, index.fqns()) + naviscope_plugin::StandardNamingConvention.render_fqn(node.id, index.fqns()) }, "C" ); @@ -112,7 +112,7 @@ fn test_inner_class_resolution() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let client_content = &trees[1].1; let client_tree = &trees[1].2; @@ -124,7 +124,7 @@ fn test_inner_class_resolution() { 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_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.example.Outer.Inner"); } else { panic!( @@ -156,7 +156,7 @@ fn test_chained_calls_resolution() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let main_content = &trees[3].1; let main_tree = &trees[3].2; @@ -167,8 +167,8 @@ fn test_chained_calls_resolution() { .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_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { - assert_eq!(fqn, "com.chain.B#getC"); + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { + assert_eq!(fqn, "com.chain.B#getC()"); } else { panic!( "Expected precise resolution to com.chain.B.getC, got {:?}", @@ -186,8 +186,8 @@ fn test_chained_calls_resolution() { "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"); + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { + assert_eq!(fqn, "com.chain.C#execute()"); } else { panic!( "Expected precise resolution to com.chain.C.execute, got {:?}", @@ -196,6 +196,56 @@ fn test_chained_calls_resolution() { } } +#[test] +fn test_hover_chain_middle_node_resolution() { + let files = vec![ + ( + "src/web/HttpResponse.java", + "package com.web; public class HttpResponse { public SessionContext getContext() { return new SessionContext(); } }", + ), + ( + "src/web/SessionContext.java", + "package com.web; public class SessionContext { public Object get(String key) { return null; } }", + ), + ( + "src/web/Main.java", + r#"package com.web; +public class Main { + void run() { + HttpResponse response = new HttpResponse(); + response.getContext().get("key"); + } +}"#, + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let main_content = &trees[2].1; + let main_tree = &trees[2].2; + + let get_context_pos = main_content + .find("getContext()") + .expect("Could not find 'getContext()'"); + let (line, col) = offset_to_point(main_content, get_context_pos); + + let res = resolver.resolve_at(main_tree, main_content, line, col, &index); + assert!( + res.is_some(), + "Failed to resolve chain middle node 'getContext'" + ); + + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { + assert_eq!(fqn, "com.web.HttpResponse#getContext()"); + } else { + panic!( + "Expected precise resolution to com.web.HttpResponse#getContext, got {:?}", + res + ); + } +} + #[test] fn test_lambda_parameter_resolution() { let files = vec![( @@ -204,7 +254,7 @@ fn test_lambda_parameter_resolution() { )]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[0].1; let tree = &trees[0].2; @@ -218,7 +268,7 @@ fn test_lambda_parameter_resolution() { "Failed to resolve lambda parameter 'it' at {}", it_usage_pos ); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Local(range, _)) = res { + if let Some(naviscope_api::models::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); @@ -244,7 +294,7 @@ fn test_lambda_explicit_type_resolution() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[1].1; let tree = &trees[1].2; @@ -258,8 +308,8 @@ fn test_lambda_explicit_type_resolution() { "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"); + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { + assert_eq!(fqn, "com.A#hello()"); } else { panic!("Expected precise resolution for it.hello(), got {:?}", res); } @@ -279,7 +329,7 @@ fn test_lambda_heuristic_type_inference() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[1].1; let tree = &trees[1].2; @@ -293,8 +343,8 @@ fn test_lambda_heuristic_type_inference() { "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"); + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { + assert_eq!(fqn, "com.A#hello()"); } else { panic!( "Expected precise resolution for it.hello() via heuristic, got {:?}", @@ -323,25 +373,16 @@ public class DefaultApplicationArguments { )]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[0].1; let tree = &trees[0].2; - // Helper to convert byte offset to (line, col) - let offset_to_point = |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) - }; - // Resolve 'this' in 'this.source.getNonOptionArgs()' let this_pos = content .find("this.source") .expect("Could not find 'this.source'"); - let (line, col) = offset_to_point(this_pos); + let (line, col) = offset_to_point(content, this_pos); let res = resolver.resolve_at(tree, content, line, col, &index); @@ -351,7 +392,7 @@ public class DefaultApplicationArguments { line, col ); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "DefaultApplicationArguments"); } else { panic!("Expected precise resolution for 'this', got {:?}", res); @@ -405,7 +446,7 @@ public class DefaultApplicationArguments { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let tree = &trees[0].2; let source_content = &trees[0].1; @@ -416,15 +457,7 @@ public class DefaultApplicationArguments { .expect("Find expression") + "this.source.".len(); - // Helper to get line/col from offset - let offset_to_point = |offset: usize| { - let pre = &source_content[..offset]; - let line = pre.lines().count() - 1; - let col = offset - pre.rfind('\n').map(|p| p + 1).unwrap_or(0); - (line, col) - }; - - let (line, col) = offset_to_point(method_call_pos); + let (line, col) = offset_to_point(source_content, method_call_pos); println!( "Testing Spring scenario at line {}, col {} (offset {})", line, col, method_call_pos @@ -432,10 +465,10 @@ public class DefaultApplicationArguments { let res = resolver.resolve_at(tree, source_content, line, col, &index); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!( fqn, - "org.springframework.boot.DefaultApplicationArguments.Source#getNonOptionArgs" + "org.springframework.boot.DefaultApplicationArguments.Source#getNonOptionArgs()" ); } else { println!("Graph nodes:"); @@ -444,7 +477,7 @@ public class DefaultApplicationArguments { use naviscope_plugin::NamingConvention; println!( " - {} ({:?})", - naviscope_plugin::DotPathConvention.render_fqn(*fqn, index.fqns()), + naviscope_plugin::StandardNamingConvention.render_fqn(*fqn, index.fqns()), node.kind() ); } @@ -463,7 +496,7 @@ fn test_field_method_call_resolution() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let a_content = &trees[0].1; let a_tree = &trees[0].2; @@ -471,16 +504,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]; - 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) - }; - - let (line, col) = offset_to_point(do_b_pos); + let (line, col) = offset_to_point(a_content, do_b_pos); let res = resolver.resolve_at(a_tree, a_content, line, col, &index); @@ -490,8 +514,8 @@ fn test_field_method_call_resolution() { line, col ); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { - assert_eq!(fqn, "B#doB"); + if let Some(naviscope_api::models::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 deleted file mode 100644 index c8eeebd..0000000 --- a/crates/lang-java/tests/logic_goto_def.rs +++ /dev/null @@ -1,189 +0,0 @@ -mod common; - -use common::setup_java_test_graph; -use naviscope_core::features::CodeGraphLike; -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()); - let idx = *index.fqn_map().get(&matches[0]).expect("Node not found"); - assert_eq!( - index.render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) - ), - "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()); - let idx = *index.fqn_map().get(&matches[0]).expect("Node not found"); - assert_eq!( - index.render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) - ), - "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 - let idx = *index.fqn_map().get(&matches[0]).expect("Node not found"); - assert!( - index - .render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) - ) - .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()); - let idx = *index.fqn_map().get(&matches[0]).expect("Node not found"); - assert_eq!( - index.render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) - ), - "A#VAL" - ); -} diff --git a/crates/lang-java/tests/logic_goto_impl.rs b/crates/lang-java/tests/logic_goto_impl.rs deleted file mode 100644 index 05f842f..0000000 --- a/crates/lang-java/tests/logic_goto_impl.rs +++ /dev/null @@ -1,93 +0,0 @@ -mod common; - -use common::setup_java_test_graph; -use naviscope_core::features::CodeGraphLike; -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(|&id| { - let idx = *index.fqn_map().get(&id).expect("Node not found"); - index - .render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention), - ) - .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); - let idx = *index.fqn_map().get(&impls[0]).expect("Node not found"); - assert_eq!( - index.render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) - ), - "Impl#act" - ); -} diff --git a/crates/lang-java/tests/logic_goto_ref.rs b/crates/lang-java/tests/logic_goto_ref.rs deleted file mode 100644 index 37c20ea..0000000 --- a/crates/lang-java/tests/logic_goto_ref.rs +++ /dev/null @@ -1,52 +0,0 @@ -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_fqn = matches[0]; - let target_idx = *index.fqn_map().get(&target_fqn).expect("Node not found"); - - // Check for candidate files via DiscoveryEngine (Meso-scouting) - let discovery = naviscope_core::features::discovery::DiscoveryEngine::new( - &index, - std::collections::HashMap::new(), - ); - 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 deleted file mode 100644 index 895f221..0000000 --- a/crates/lang-java/tests/logic_goto_type.rs +++ /dev/null @@ -1,88 +0,0 @@ -mod common; - -use common::setup_java_test_graph; -use naviscope_core::features::CodeGraphLike; -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()); - let idx = *index.fqn_map().get(&matches[0]).expect("Node not found"); - assert_eq!( - index.render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) - ), - "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()); - let idx = *index.fqn_map().get(&matches[0]).expect("Node not found"); - assert_eq!( - index.render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) - ), - "Model" - ); -} diff --git a/crates/lang-java/tests/logic_hierarchy.rs b/crates/lang-java/tests/logic_hierarchy.rs deleted file mode 100644 index a0a4531..0000000 --- a/crates/lang-java/tests/logic_hierarchy.rs +++ /dev/null @@ -1,209 +0,0 @@ -mod common; - -use common::setup_java_test_graph; -use naviscope_core::features::CodeGraphLike; -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_fqn = resolver.find_matches(&index, &res)[0]; - let target_idx = *index.fqn_map().get(&target_fqn).expect("Node not found"); - - // Check callers using DiscoveryEngine - let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); - 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 = index - .render_fqn(node, Some(&naviscope_java::naming::JavaNamingConvention)) - .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_fqn = resolver.find_matches(&index, &res)[0]; - let target_idx = *index.fqn_map().get(&target_fqn).expect("Node not found"); - - // 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 target_fqn = resolver.find_matches(&index, &res)[0]; - let idx = *index.fqn_map().get(&target_fqn).expect("Node not found"); - - // Incoming should contain itself - let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); - 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 = index - .render_fqn(node, Some(&naviscope_java::naming::JavaNamingConvention)) - .to_string(); - if !callers.contains(&fqn) { - callers.push(fqn); - } - } - } - - assert!(callers.contains(&"Test#rec".to_string())); -} diff --git a/crates/lang-java/tests/references_behavior.rs b/crates/lang-java/tests/references_behavior.rs new file mode 100644 index 0000000..45604fa --- /dev/null +++ b/crates/lang-java/tests/references_behavior.rs @@ -0,0 +1,280 @@ +mod common; + +use common::{offset_to_point, setup_java_test_graph}; +use naviscope_api::models::symbol::Range; +use naviscope_core::features::discovery::DiscoveryEngine; +use naviscope_java::JavaPlugin; +use naviscope_plugin::{LspSyntaxService, SymbolQueryService, SymbolResolveService}; +use std::collections::BTreeSet; + +fn starts_set(ranges: &[Range]) -> BTreeSet<(usize, usize)> { + ranges.iter().map(|r| (r.start_line, r.start_col)).collect() +} + +fn find_ranges_for_path( + resolver: &JavaPlugin, + index: &naviscope_core::model::CodeGraph, + trees: &[(std::path::PathBuf, String, tree_sitter::Tree)], + resolution: &naviscope_api::models::SymbolResolution, + path: &str, +) -> Vec { + let (.., source, tree) = trees + .iter() + .find(|(p, _, _)| p.to_string_lossy() == path) + .expect("path exists in trees"); + resolver.find_occurrences(source, tree, resolution, Some(index)) +} + +#[test] +fn given_same_method_name_different_owner_when_find_references_then_only_target_owner_hits() { + let files = vec![ + ("A.java", "public class A { void target() {} }"), + ("B.java", "public class B { void target() {} }"), + ( + "Use.java", + "public class Use { void run(A a, B b) { a.target(); b.target(); } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let a_content = &trees[0].1; + let a_tree = &trees[0].2; + let target_pos = a_content.find("target()").expect("find A#target"); + let (line, col) = offset_to_point(a_content, target_pos); + + let resolution = resolver + .resolve_at(a_tree, a_content, line, col, &index) + .expect("resolve A#target"); + + let a_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "A.java"); + let b_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "B.java"); + let use_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "Use.java"); + + let a_decl_pos = a_content.find("target()").expect("find A declaration"); + let a_decl_start = offset_to_point(a_content, a_decl_pos); + + let use_content = &trees[2].1; + let use_call_pos = use_content.find("a.target()").expect("find a.target()") + 2; + let use_call_start = offset_to_point(use_content, use_call_pos); + + assert_eq!(starts_set(&a_ranges), BTreeSet::from([a_decl_start])); + assert!( + b_ranges.is_empty(), + "B.java should not be polluted by same-name method" + ); + assert_eq!(starts_set(&use_ranges), BTreeSet::from([use_call_start])); +} + +#[test] +fn given_local_shadowing_when_find_references_then_only_same_binding_hits() { + let files = vec![( + "Test.java", + "public class Test { int x = 0; void run() { int x = 1; x = 2; } }", + )]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let usage_pos = content.find("x = 2").expect("find local usage"); + let (line, col) = offset_to_point(content, usage_pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve local x"); + + let ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "Test.java"); + let starts = starts_set(&ranges); + let decl_pos = content.find("int x = 1").expect("find local declaration") + 4; + let assign_pos = content.find("x = 2").expect("find local assign"); + let expected = BTreeSet::from([ + offset_to_point(content, decl_pos), + offset_to_point(content, assign_pos), + ]); + assert_eq!( + starts, expected, + "local x should only match declaration + assignment" + ); + + let matches = resolver.find_matches(&index, &resolution); + assert!( + matches.is_empty(), + "local symbol should not map to global graph nodes" + ); +} + +#[test] +fn given_method_symbol_when_scouting_references_then_candidate_files_cover_call_sites() { + 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 = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let a_content = &trees[0].1; + let a_tree = &trees[0].2; + let usage_pos = a_content.find("target()").expect("find declaration"); + let (line, col) = offset_to_point(a_content, usage_pos); + + let resolution = resolver + .resolve_at(a_tree, a_content, line, col, &index) + .expect("resolve method"); + let matches = resolver.find_matches(&index, &resolution); + let target_fqn = matches[0]; + let target_idx = *index.fqn_map().get(&target_fqn).expect("node exists"); + + let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); + let candidate_files = discovery.scout_references(&[target_idx]); + + let paths: Vec = candidate_files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + + assert_eq!(paths.len(), 3); + assert!(paths.contains(&"A.java".to_string())); + assert!(paths.contains(&"B.java".to_string())); + assert!(paths.contains(&"C.java".to_string())); +} + +#[test] +fn given_overloaded_methods_same_owner_when_find_references_then_matches_only_exact_overload() { + let files = vec![ + ( + "A.java", + "public class A { void target(int n) {} void target(String s) {} }", + ), + ( + "Use.java", + "public class Use { void run(A a) { a.target(1); a.target(\"x\"); } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let a_content = &trees[0].1; + let a_tree = &trees[0].2; + // Find target(int) declaration + let target_pos = a_content + .find("target(int") + .expect("find overloaded declaration"); + let (line, col) = offset_to_point(a_content, target_pos); + + let resolution = resolver + .resolve_at(a_tree, a_content, line, col, &index) + .expect("resolve overloaded method"); + + let a_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "A.java"); + let use_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "Use.java"); + + let a_decl_int = a_content.find("target(int n)").expect("find target(int)"); + // We expect strict matching, so target(String) should NOT be included. + + let use_content = &trees[1].1; + let use_call_int = use_content.find("a.target(1)").expect("find call int") + 2; + // target("x") refers to target(String), so it should NOT be included. + + assert_eq!( + starts_set(&a_ranges), + BTreeSet::from([offset_to_point(a_content, a_decl_int),]), + "Should only match the exact overload declaration in A.java" + ); + assert_eq!( + starts_set(&use_ranges), + BTreeSet::from([offset_to_point(use_content, use_call_int),]), + "Should only match the exact overload usage in Use.java" + ); +} + +#[test] +fn given_signed_method_with_fqn_param_when_find_references_then_matches_exact_overload() { + let files = vec![ + ( + "A.java", + "public class A { void target(java.lang.String s) {} void target(int n) {} }", + ), + ( + "Use.java", + "public class Use { void run(A a) { a.target(\"x\"); a.target(1); } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let a_content = &trees[0].1; + let a_tree = &trees[0].2; + let target_pos = a_content + .find("target(java.lang.String") + .expect("find target(String) declaration"); + let (line, col) = offset_to_point(a_content, target_pos); + + let resolution = resolver + .resolve_at(a_tree, a_content, line, col, &index) + .expect("resolve target(java.lang.String)"); + + let a_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "A.java"); + let use_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "Use.java"); + + let a_decl_string = a_content + .find("target(java.lang.String s)") + .expect("find target(java.lang.String)"); + let use_content = &trees[1].1; + let use_call_string = use_content.find("a.target(\"x\")").expect("find call string") + 2; + + assert_eq!( + starts_set(&a_ranges), + BTreeSet::from([offset_to_point(a_content, a_decl_string)]), + "Should match only target(java.lang.String) declaration" + ); + assert_eq!( + starts_set(&use_ranges), + BTreeSet::from([offset_to_point(use_content, use_call_string)]), + "Should match only target(\"x\") call site" + ); +} + +#[test] +fn given_same_class_different_arity_overloads_when_find_references_then_matches_exact_arity() { + let files = vec![( + "A.java", + "public class A { void target() { target(1); target(1, 2); } void target(int a) {} void target(int a, int b) {} }", + )]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + // Find target() declaration + let pos = content + .find("target()") + .expect("find zero-arity declaration"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve overloaded member"); + + let ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "A.java"); + let starts = starts_set(&ranges); + + let decl_zero = content.find("target() {").expect("find target()"); + + // target(1) calls target(int), target(1,2) calls target(int,int). + // querying target() should NOT include them. + // And target(int) / target(int,int) declarations are distinct. + + let expected = BTreeSet::from([offset_to_point(content, decl_zero)]); + assert_eq!( + starts, expected, + "Should strictly match only zero-arity target()" + ); +} diff --git a/crates/lang-java/tests/repro_modifiers.rs b/crates/lang-java/tests/repro_modifiers.rs new file mode 100644 index 0000000..1894f1e --- /dev/null +++ b/crates/lang-java/tests/repro_modifiers.rs @@ -0,0 +1,124 @@ +use naviscope_java::jdk; +use naviscope_java::model::{JavaIndexMetadata, JavaNodeMetadata}; +use naviscope_plugin::{IndexMetadata, SymbolInterner}; +use std::collections::HashMap; + +// Mock context for interning +struct MockContext { + map: HashMap, + reverse: HashMap, + counter: u32, +} + +impl MockContext { + fn new() -> Self { + Self { + map: HashMap::new(), + reverse: HashMap::new(), + counter: 1, + } + } + + fn resolve(&self, id: u32) -> Option<&str> { + self.reverse.get(&id).map(|s| s.as_str()) + } +} + +impl SymbolInterner for MockContext { + fn intern_str(&mut self, s: &str) -> u32 { + if let Some(&id) = self.map.get(s) { + id + } else { + let id = self.counter; + self.counter += 1; + self.map.insert(s.to_string(), id); + self.reverse.insert(id, s.to_string()); + id + } + } +} + +#[test] +fn test_jdk_discovery() { + let jdk_path = jdk::find_jdk_asset(); + if let Some(path) = jdk_path { + println!("JDK discovered at: {:?}", path); + assert!(path.exists(), "Discovered JDK path must exist"); + + // Check if it's a valid asset (jimage or jar) + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + assert!( + ext == "jar" || path.ends_with("modules"), + "JDK asset must be a JAR or modules file" + ); + } else { + println!("WARNING: No JDK found in environment. This is expected if JAVA_HOME is not set."); + } +} + +#[test] +fn test_metadata_serialization_roundtrip() { + let modifiers = vec![ + "public".to_string(), + "final".to_string(), + "abstract".to_string(), + ]; + let metadata = JavaIndexMetadata::Class { + modifiers: modifiers.clone(), + type_parameters: vec![], + }; + + // Serialize JavaIndexMetadata (simulating cache storage) + let cached = metadata.to_cached_metadata(); + assert_eq!(cached.version, 1); + assert!(!cached.data.is_empty()); + + // Deserialize back using helper + // Note: In real app, we register this deserializer. Here we call it directly. + let deserialized = JavaIndexMetadata::deserialize_for_cache(cached.version, &cached.data); + + // Convert to Any to downcast/check + let back = deserialized + .as_any() + .downcast_ref::() + .expect("Should be JavaIndexMetadata"); + + match back { + JavaIndexMetadata::Class { modifiers: m, .. } => { + assert_eq!(m, &modifiers); + } + _ => panic!("Wrong variant"), + } +} + +#[test] +fn test_node_metadata_interning() { + let mut ctx = MockContext::new(); + let modifiers = vec!["public".to_string(), "static".to_string()]; + let metadata = JavaIndexMetadata::Class { + modifiers: modifiers.clone(), + type_parameters: vec![], + }; + + let interned = metadata.intern(&mut ctx); + + // Check if it's JavaNodeMetadata + let node_meta = interned + .as_any() + .downcast_ref::() + .expect("Should be JavaNodeMetadata"); + + match node_meta { + JavaNodeMetadata::Class { modifiers_sids, .. } => { + assert_eq!(modifiers_sids.len(), 2); + // Verify strings + let s1 = ctx.resolve(modifiers_sids[0]).unwrap(); + let s2 = ctx.resolve(modifiers_sids[1]).unwrap(); + + // Order is preserved + assert_eq!(s1, "public"); + assert_eq!(s2, "static"); + } + _ => panic!("Wrong variant"), + } +} diff --git a/crates/lang-java/tests/test_engine_facade.rs b/crates/lang-java/tests/test_engine_facade.rs new file mode 100644 index 0000000..d66d2b2 --- /dev/null +++ b/crates/lang-java/tests/test_engine_facade.rs @@ -0,0 +1,449 @@ +mod common; + +use common::{offset_to_point, setup_java_engine}; +use naviscope_api::models::{PositionContext, ReferenceQuery, SymbolQuery, SymbolResolution}; +use naviscope_api::semantic::{ + CallHierarchyAnalyzer, ReferenceAnalyzer, SymbolInfoProvider, SymbolNavigator, +}; +use std::collections::BTreeSet; + +#[tokio::test] +async fn test_full_engine_java_facade() { + let temp_dir = std::env::temp_dir().join("naviscope_java_facade_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let files = vec![ + ( + "com/example/Base.java", + "package com.example; public interface Base { void run(); }", + ), + ( + "com/example/Impl.java", + "package com.example; public class Impl implements Base { public void run() {} }", + ), + ( + "com/example/App.java", + r#" +package com.example; +public class App { + void start() { + Base b = new Impl(); + b.run(); + } +} +"#, + ), + ]; + + let handle = setup_java_engine(&temp_dir, files).await; + let graph = handle.graph().await; + graph.register_naming_convention(Box::new( + naviscope_plugin::StandardNamingConvention::default(), + )); + graph.topology(); + + let app_path = temp_dir.join("com/example/App.java"); + let app_content = std::fs::read_to_string(&app_path).unwrap(); + let run_pos = app_content.find("b.run()").unwrap() + 2; + let (line, col) = offset_to_point(&app_content, run_pos); + + let ctx = PositionContext { + uri: format!("file://{}", app_path.display()), + line: line as u32, + char: col as u32, + content: Some(app_content.clone()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("Should resolve b.run()"); + + match &resolution { + SymbolResolution::Precise(fqn, _) => assert_eq!(fqn, "com.example.Base#run()"), + SymbolResolution::Global(fqn) => assert_eq!(fqn, "com.example.Base#run()"), + _ => panic!( + "Expected precise or global resolution, got {:?}", + resolution + ), + } + + let query = SymbolQuery { + language: naviscope_api::models::Language::JAVA, + resolution: resolution.clone(), + }; + let impls = handle.find_implementations(&query).await.unwrap(); + assert_eq!(impls.len(), 1); + assert!(impls[0].path.to_string_lossy().contains("Impl.java")); + + let calls = handle + .find_incoming_calls("com.example.Impl#run()") + .await + .unwrap(); + assert_eq!( + calls.len(), + 1, + "Lookup of Impl#run should find the call via Base type" + ); + + let incoming_base = handle + .find_incoming_calls("com.example.Base#run()") + .await + .unwrap(); + + let query_refs = ReferenceQuery { + language: naviscope_api::models::Language::JAVA, + resolution: resolution.clone(), + include_declaration: false, + }; + let refs = handle.find_references(&query_refs).await.unwrap(); + assert_eq!( + refs.len(), + 1, + "find_references should have found 1 reference in App.java" + ); + + assert_eq!( + incoming_base.len(), + 1, + "find_incoming_calls should have found 1 caller in App.java" + ); + assert_eq!(incoming_base[0].from.id, "com.example.App#start()"); +} + +#[tokio::test] +async fn test_find_references_filters_same_name_across_types() { + let temp_dir = std::env::temp_dir().join("naviscope_java_ref_same_name_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let files = vec![ + ( + "com/example/A.java", + "package com.example; public class A { public void target() {} }", + ), + ( + "com/example/X.java", + "package com.example; public class X { public void target() {} }", + ), + ( + "com/example/Use.java", + r#" +package com.example; +public class Use { + void useA(A a) { a.target(); } + void useX(X x) { x.target(); } +} +"#, + ), + ]; + + let handle = setup_java_engine(&temp_dir, files).await; + + let a_path = temp_dir.join("com/example/A.java"); + let a_content = std::fs::read_to_string(&a_path).unwrap(); + let pos = a_content.find("target()").unwrap(); + let (line, col) = offset_to_point(&a_content, pos); + + let ctx = PositionContext { + uri: format!("file://{}", a_path.display()), + line: line as u32, + char: col as u32, + content: Some(a_content.clone()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("Should resolve A#target"); + + let refs = handle + .find_references(&ReferenceQuery { + language: naviscope_api::models::Language::JAVA, + resolution, + include_declaration: false, + }) + .await + .unwrap(); + + assert_eq!(refs.len(), 1, "A#target should only match a.target() usage"); + assert!(refs[0].path.to_string_lossy().contains("Use.java")); + + let use_content = std::fs::read_to_string(temp_dir.join("com/example/Use.java")).unwrap(); + let prefix = use_content + .lines() + .take(refs[0].range.start_line + 1) + .collect::>() + .join("\n"); + assert!( + prefix.contains("useA"), + "Reference should belong to useA(A), not useX(X)" + ); +} + +#[tokio::test] +async fn test_find_references_include_declaration_switch() { + let temp_dir = std::env::temp_dir().join("naviscope_java_ref_include_decl_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let files = vec![ + ( + "com/example/A.java", + "package com.example; public class A { public void target() {} }", + ), + ( + "com/example/Use.java", + r#" +package com.example; +public class Use { + void useA(A a) { a.target(); } +} +"#, + ), + ]; + + let handle = setup_java_engine(&temp_dir, files).await; + + let a_path = temp_dir.join("com/example/A.java"); + let a_content = std::fs::read_to_string(&a_path).unwrap(); + let pos = a_content.find("target()").unwrap(); + let (line, col) = offset_to_point(&a_content, pos); + + let ctx = PositionContext { + uri: format!("file://{}", a_path.display()), + line: line as u32, + char: col as u32, + content: Some(a_content.clone()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("Should resolve A#target"); + + let refs_without_decl = handle + .find_references(&ReferenceQuery { + language: naviscope_api::models::Language::JAVA, + resolution: resolution.clone(), + include_declaration: false, + }) + .await + .unwrap(); + assert_eq!(refs_without_decl.len(), 1); + + let refs_with_decl = handle + .find_references(&ReferenceQuery { + language: naviscope_api::models::Language::JAVA, + resolution, + include_declaration: true, + }) + .await + .unwrap(); + assert_eq!(refs_with_decl.len(), 2); +} + +#[tokio::test] +async fn test_find_references_static_member_hiding() { + let temp_dir = std::env::temp_dir().join("naviscope_java_ref_static_hiding_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let files = vec![ + ( + "com/example/Base.java", + "package com.example; public class Base { public static void ping() {} }", + ), + ( + "com/example/Child.java", + "package com.example; public class Child extends Base { public static void ping() {} }", + ), + ( + "com/example/Use.java", + r#" +package com.example; +public class Use { + void use() { + Base.ping(); + Child.ping(); + } +} +"#, + ), + ]; + + let handle = setup_java_engine(&temp_dir, files).await; + + let base_path = temp_dir.join("com/example/Base.java"); + let base_content = std::fs::read_to_string(&base_path).unwrap(); + let pos = base_content.find("ping()").unwrap(); + let (line, col) = offset_to_point(&base_content, pos); + + let ctx = PositionContext { + uri: format!("file://{}", base_path.display()), + line: line as u32, + char: col as u32, + content: Some(base_content.clone()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("Should resolve Base#ping"); + + let refs = handle + .find_references(&ReferenceQuery { + language: naviscope_api::models::Language::JAVA, + resolution, + include_declaration: false, + }) + .await + .unwrap(); + + assert_eq!( + refs.len(), + 1, + "Base#ping should only match Base.ping() usage" + ); + assert!(refs[0].path.to_string_lossy().contains("Use.java")); + + let use_content = std::fs::read_to_string(temp_dir.join("com/example/Use.java")).unwrap(); + let prefix = use_content + .lines() + .take(refs[0].range.start_line + 1) + .collect::>() + .join("\n"); + assert!( + prefix.contains("Base.ping"), + "Reference should belong to Base.ping(), not Child.ping()" + ); +} + +#[tokio::test] +async fn test_find_references_same_class_overloads_different_arity_via_engine_facade() { + let temp_dir = std::env::temp_dir().join("naviscope_java_ref_overload_arity_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let files = vec![( + "com/example/A.java", + "package com.example; public class A { void target() { target(1); target(1, 2); } void target(int a) {} void target(int a, int b) {} }", + )]; + + let handle = setup_java_engine(&temp_dir, files).await; + + let a_path = temp_dir.join("com/example/A.java"); + let content = std::fs::read_to_string(&a_path).unwrap(); + let pos = content.find("target() {").unwrap(); + let (line, col) = offset_to_point(&content, pos); + + let ctx = PositionContext { + uri: format!("file://{}", a_path.display()), + line: line as u32, + char: col as u32, + content: Some(content.clone()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("Should resolve A#target"); + + let refs = handle + .find_references(&ReferenceQuery { + language: naviscope_api::models::Language::JAVA, + resolution, + include_declaration: true, + }) + .await + .unwrap(); + + assert_eq!( + refs.len(), + 1, + "expected strict resolution to match ONLY exact overload decl (target() is never called)" + ); + assert!( + refs.iter() + .all(|r| r.path.to_string_lossy().contains("com/example/A.java")), + "all references should stay in A.java for this scenario" + ); + + let starts: BTreeSet<(usize, usize)> = refs + .iter() + .map(|r| (r.range.start_line, r.range.start_col)) + .collect(); + let expected: BTreeSet<(usize, usize)> = [content.find("target() {").unwrap()] + .into_iter() + .map(|offset| offset_to_point(&content, offset)) + .collect(); + assert_eq!(starts, expected); +} + +#[tokio::test] +async fn test_resolve_method_declaration_name_and_symbol_info_for_overload_hover_input() { + let temp_dir = std::env::temp_dir().join("naviscope_java_hover_decl_overload_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let files = vec![( + "com/example/A.java", + "package com.example; public class A { void target(int a) {} void target(int a, int b) {} }", + )]; + + let handle = setup_java_engine(&temp_dir, files).await; + + let a_path = temp_dir.join("com/example/A.java"); + let content = std::fs::read_to_string(&a_path).unwrap(); + let pos = content.find("target(int a)").unwrap(); + let (line, col) = offset_to_point(&content, pos); + + let ctx = PositionContext { + uri: format!("file://{}", a_path.display()), + line: line as u32, + char: col as u32, + content: Some(content.clone()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("Should resolve method declaration name"); + + let fqn = match resolution { + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => fqn, + SymbolResolution::Local(_, _) => panic!("method declaration should not resolve as local"), + }; + assert_eq!(fqn, "com.example.A#target(int)"); + + let info = handle + .get_symbol_info(&fqn) + .await + .unwrap() + .expect("symbol info should exist for hover input"); + + let sig = info.signature.unwrap_or_default(); + assert!( + sig.contains("target("), + "signature should be suitable for hover at declaration name" + ); +} diff --git a/crates/lang-java/tests/type_definition_behavior.rs b/crates/lang-java/tests/type_definition_behavior.rs new file mode 100644 index 0000000..8521f94 --- /dev/null +++ b/crates/lang-java/tests/type_definition_behavior.rs @@ -0,0 +1,80 @@ +mod common; + +use common::{offset_to_point, setup_java_test_graph}; +use naviscope_core::features::CodeGraphLike; +use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; + +#[test] +fn given_variable_usage_when_goto_type_definition_then_returns_declared_type() { + let files = vec![ + ("Model.java", "public class Model {}"), + ( + "Client.java", + "public class Client { void run() { Model model = null; } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[1].1; + let tree = &trees[1].2; + let pos = content.find("model = null").expect("find variable usage"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve variable"); + let type_resolutions = resolver.resolve_type_of(&index, &resolution); + + assert_eq!(type_resolutions.len(), 1); + let matches = resolver.find_matches(&index, &type_resolutions[0]); + assert_eq!(matches.len(), 1); + + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention) + ), + "Model" + ); +} + +#[test] +fn given_method_symbol_when_goto_type_definition_then_returns_method_return_type() { + 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 = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[1].1; + let tree = &trees[1].2; + let pos = content.find("get()").expect("find method symbol"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve method"); + let type_resolutions = resolver.resolve_type_of(&index, &resolution); + + assert_eq!(type_resolutions.len(), 1); + let matches = resolver.find_matches(&index, &type_resolutions[0]); + assert_eq!(matches.len(), 1); + + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention) + ), + "Model" + ); +} diff --git a/crates/lsp/Cargo.toml b/crates/lsp/Cargo.toml index f81e87e..7a1458e 100644 --- a/crates/lsp/Cargo.toml +++ b/crates/lsp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-lsp" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] diff --git a/crates/lsp/src/goto.rs b/crates/lsp/src/goto.rs index 3dc8668..16b7a7b 100644 --- a/crates/lsp/src/goto.rs +++ b/crates/lsp/src/goto.rs @@ -1,8 +1,25 @@ use crate::LspServer; -use naviscope_api::models::{PositionContext, SymbolQuery, SymbolResolution}; +use naviscope_api::models::{PositionContext, SymbolLocation, SymbolQuery, SymbolResolution}; use tower_lsp::jsonrpc::Result; use tower_lsp::lsp_types::*; +fn to_lsp_location(loc: SymbolLocation) -> Option { + let uri = match Url::from_file_path(&*loc.path) { + Ok(uri) => uri, + Err(()) => { + tracing::warn!("failed to convert definition path to file URL: {:?}", loc.path); + return None; + } + }; + Some(Location { + uri, + 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), + }, + }) +} + pub async fn definition( server: &LspServer, params: GotoDefinitionParams, @@ -32,7 +49,10 @@ pub async fn definition( let resolution = match engine.resolve_symbol_at(&ctx).await { Ok(Some(r)) => r, Ok(None) => return Ok(None), - Err(_) => return Ok(None), // Log error? + Err(e) => { + tracing::warn!("definition resolve_symbol_at failed for {}: {}", uri, e); + return Ok(None); + } }; if let SymbolResolution::Local(range, _) = resolution { @@ -62,19 +82,13 @@ pub async fn definition( let definitions = match engine.find_definitions(&query).await { Ok(defs) => defs, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_definitions failed for {}: {}", uri, e); + 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(); + let locations: Vec = definitions.into_iter().filter_map(to_lsp_location).collect(); if !locations.is_empty() { if locations.len() == 1 { @@ -112,7 +126,10 @@ pub async fn type_definition( let resolution = match engine.resolve_symbol_at(&ctx).await { Ok(Some(r)) => r, Ok(None) => return Ok(None), - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("type_definition resolve_symbol_at failed for {}: {}", uri, e); + return Ok(None); + } }; let language = match server.documents.get(&uri).map(|d| d.language.clone()) { @@ -127,19 +144,13 @@ pub async fn type_definition( let locations = match engine.find_type_definitions(&query).await { Ok(locs) => locs, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_type_definitions failed for {}: {}", uri, e); + 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(); + let lsp_locations: Vec = locations.into_iter().filter_map(to_lsp_location).collect(); if !lsp_locations.is_empty() { return Ok(Some(GotoDefinitionResponse::Array(lsp_locations))); @@ -172,7 +183,10 @@ pub async fn references( let resolution = match engine.resolve_symbol_at(&ctx).await { Ok(Some(r)) => r, Ok(None) => return Ok(None), - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("references resolve_symbol_at failed for {}: {}", uri, e); + return Ok(None); + } }; let language = match server.documents.get(&uri).map(|d| d.language.clone()) { @@ -202,7 +216,10 @@ pub async fn references( let locations = match engine.find_references(&query).await { Ok(locs) => locs, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_references failed for {}: {}", uri, e); + return Ok(None); + } }; // If local references are found by engine, they are returned. @@ -211,16 +228,7 @@ pub async fn 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(); + let lsp_locations: Vec = locations.into_iter().filter_map(to_lsp_location).collect(); if !lsp_locations.is_empty() { return Ok(Some(lsp_locations)); @@ -253,7 +261,10 @@ pub async fn implementation( let resolution = match engine.resolve_symbol_at(&ctx).await { Ok(Some(r)) => r, Ok(None) => return Ok(None), - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("implementation resolve_symbol_at failed for {}: {}", uri, e); + return Ok(None); + } }; let language = match server.documents.get(&uri).map(|d| d.language.clone()) { @@ -268,19 +279,13 @@ pub async fn implementation( let locations = match engine.find_implementations(&query).await { Ok(locs) => locs, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_implementations failed for {}: {}", uri, e); + 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(); + let lsp_locations: Vec = locations.into_iter().filter_map(to_lsp_location).collect(); if !lsp_locations.is_empty() { return Ok(Some(GotoDefinitionResponse::Array(lsp_locations))); @@ -288,3 +293,44 @@ pub async fn implementation( Ok(None) } + +#[cfg(test)] +mod tests { + use super::to_lsp_location; + use naviscope_api::models::{Range as ApiRange, SymbolLocation}; + use std::path::PathBuf; + use std::sync::Arc; + + #[test] + fn to_lsp_location_handles_absolute_path() { + let abs = std::env::temp_dir().join("naviscope_goto_test.java"); + let loc = SymbolLocation { + path: Arc::from(abs.as_path()), + range: ApiRange { + start_line: 1, + start_col: 2, + end_line: 3, + end_col: 4, + }, + selection_range: None, + }; + + let mapped = to_lsp_location(loc).expect("absolute path should map"); + assert_eq!(mapped.range.start.line, 1); + assert_eq!(mapped.range.start.character, 2); + assert_eq!(mapped.range.end.line, 3); + assert_eq!(mapped.range.end.character, 4); + } + + #[test] + fn to_lsp_location_rejects_relative_path() { + let rel = PathBuf::from("relative/path/Foo.java"); + let loc = SymbolLocation { + path: Arc::from(rel.as_path()), + range: ApiRange::default(), + selection_range: None, + }; + + assert!(to_lsp_location(loc).is_none()); + } +} diff --git a/crates/lsp/src/hierarchy.rs b/crates/lsp/src/hierarchy.rs index d7bed58..10579aa 100644 --- a/crates/lsp/src/hierarchy.rs +++ b/crates/lsp/src/hierarchy.rs @@ -1,8 +1,54 @@ use crate::LspServer; -use naviscope_api::models::{PositionContext, SymbolResolution}; +use naviscope_api::models::{DisplayGraphNode, PositionContext, SymbolResolution}; use tower_lsp::jsonrpc::Result; use tower_lsp::lsp_types::*; +fn parse_hierarchy_fqn(data: Option) -> Option { + match serde_json::from_value(data.unwrap_or_default()) { + Ok(fqn) => Some(fqn), + Err(e) => { + tracing::warn!("failed to parse call hierarchy item data: {}", e); + None + } + } +} + +fn build_call_hierarchy_item(info: DisplayGraphNode, fqn: String) -> Option { + let loc = info.location.as_ref()?; + 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 uri = match Url::from_file_path(&loc.path) { + Ok(uri) => uri, + Err(()) => { + tracing::warn!( + "prepare_call_hierarchy failed to convert path to file URL: {:?}", + loc.path + ); + return None; + } + }; + let data = match serde_json::to_value(fqn.clone()) { + Ok(v) => Some(v), + Err(e) => { + tracing::warn!("prepare_call_hierarchy failed to serialize fqn: {}", e); + None + } + }; + + Some(CallHierarchyItem { + name: info.name, + kind: SymbolKind::METHOD, // Default for call hierarchy + tags: None, + detail: Some(fqn), + uri, + range: lsp_range, + selection_range: lsp_range, + data, + }) +} + pub async fn prepare_call_hierarchy( server: &LspServer, params: CallHierarchyPrepareParams, @@ -41,21 +87,9 @@ pub async fn prepare_call_hierarchy( _ => 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()), + let Some(item) = build_call_hierarchy_item(info, fqn.clone()) else { + tracing::warn!("prepare_call_hierarchy missing/invalid location for {}", fqn); + return Ok(None); }; Ok(Some(vec![item])) @@ -65,8 +99,10 @@ 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(); + let fqn = match parse_hierarchy_fqn(params.item.data) { + Some(fqn) => fqn, + None => return Ok(None), + }; if fqn.is_empty() { return Ok(None); } @@ -79,27 +115,41 @@ pub async fn incoming_calls( let calls = match engine.find_incoming_calls(&fqn).await { Ok(c) => c, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_incoming_calls failed for {}: {}", fqn, e); + return Ok(None); + } }; let lsp_calls: Vec = calls .into_iter() - .map(|item| { - let loc = item.from.location.as_ref().expect("Caller must have location"); + .filter_map(|item| { + let loc = item.from.location.as_ref()?; 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 { + let uri = match Url::from_file_path(&loc.path) { + Ok(uri) => uri, + Err(()) => { + tracing::warn!( + "incoming_calls failed to convert path to file URL: {:?}", + loc.path + ); + return None; + } + }; + let data = serde_json::to_value(item.from.id.clone()).ok(); + Some(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(), + uri, range: lsp_range, selection_range: lsp_range, - data: Some(serde_json::to_value(item.from.id).unwrap()), + data, }, from_ranges: item .from_ranges @@ -109,7 +159,7 @@ pub async fn incoming_calls( end: Position::new(r.end_line as u32, r.end_col as u32), }) .collect(), - } + }) }) .collect(); @@ -120,8 +170,10 @@ 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(); + let fqn = match parse_hierarchy_fqn(params.item.data) { + Some(fqn) => fqn, + None => return Ok(None), + }; if fqn.is_empty() { return Ok(None); } @@ -134,27 +186,41 @@ pub async fn outgoing_calls( let calls = match engine.find_outgoing_calls(&fqn).await { Ok(c) => c, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_outgoing_calls failed for {}: {}", fqn, e); + return Ok(None); + } }; let lsp_calls: Vec = calls .into_iter() - .map(|item| { - let loc = item.to.location.as_ref().expect("Callee must have location"); + .filter_map(|item| { + let loc = item.to.location.as_ref()?; 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 { + let uri = match Url::from_file_path(&loc.path) { + Ok(uri) => uri, + Err(()) => { + tracing::warn!( + "outgoing_calls failed to convert path to file URL: {:?}", + loc.path + ); + return None; + } + }; + let data = serde_json::to_value(item.to.id.clone()).ok(); + Some(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(), + uri, range: lsp_range, selection_range: lsp_range, - data: Some(serde_json::to_value(item.to.id).unwrap()), + data, }, from_ranges: item .from_ranges @@ -164,9 +230,75 @@ pub async fn outgoing_calls( end: Position::new(r.end_line as u32, r.end_col as u32), }) .collect(), - } + }) }) .collect(); Ok(Some(lsp_calls)) } + +#[cfg(test)] +mod tests { + use super::{build_call_hierarchy_item, parse_hierarchy_fqn}; + use naviscope_api::models::graph::{DisplaySymbolLocation, NodeKind, NodeSource, ResolutionStatus}; + use naviscope_api::models::DisplayGraphNode; + use naviscope_api::models::Range as ApiRange; + + #[test] + fn parse_hierarchy_fqn_accepts_string_value() { + let data = Some(serde_json::Value::String("com.example.A#m()".to_string())); + let parsed = parse_hierarchy_fqn(data); + assert_eq!(parsed.as_deref(), Some("com.example.A#m()")); + } + + #[test] + fn parse_hierarchy_fqn_rejects_non_string_value() { + let data = Some(serde_json::json!({ "bad": true })); + assert!(parse_hierarchy_fqn(data).is_none()); + } + + #[test] + fn build_call_hierarchy_item_rejects_missing_location() { + let info = DisplayGraphNode { + id: "com.example.A#m()".to_string(), + name: "m".to_string(), + kind: NodeKind::Method, + lang: "java".to_string(), + source: NodeSource::Project, + status: ResolutionStatus::Resolved, + location: None, + detail: None, + signature: None, + modifiers: vec![], + children: None, + }; + assert!(build_call_hierarchy_item(info, "com.example.A#m()".to_string()).is_none()); + } + + #[test] + fn build_call_hierarchy_item_accepts_location() { + let info = DisplayGraphNode { + id: "com.example.A#m()".to_string(), + name: "m".to_string(), + kind: NodeKind::Method, + lang: "java".to_string(), + source: NodeSource::Project, + status: ResolutionStatus::Resolved, + location: Some(DisplaySymbolLocation { + path: "/tmp/naviscope_hierarchy_test.java".to_string(), + range: ApiRange { + start_line: 1, + start_col: 2, + end_line: 3, + end_col: 4, + }, + selection_range: None, + }), + detail: None, + signature: None, + modifiers: vec![], + children: None, + }; + assert!(build_call_hierarchy_item(info, "com.example.A#m()".to_string()).is_some()); + } +} diff --git a/crates/lsp/src/highlight.rs b/crates/lsp/src/highlight.rs index 6271699..9fc6e26 100644 --- a/crates/lsp/src/highlight.rs +++ b/crates/lsp/src/highlight.rs @@ -27,7 +27,10 @@ pub async fn highlight( let highlights = match engine.find_highlights(&ctx).await { Ok(h) => h, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_highlights failed for {}: {}", uri, e); + return Ok(None); + } }; let lsp_highlights: Vec = highlights diff --git a/crates/lsp/src/hover.rs b/crates/lsp/src/hover.rs index 6082593..4d5e5f0 100644 --- a/crates/lsp/src/hover.rs +++ b/crates/lsp/src/hover.rs @@ -1,11 +1,37 @@ use crate::LspServer; -use naviscope_api::models::PositionContext; +use naviscope_api::models::{DisplayGraphNode, PositionContext, SymbolResolution}; use tower_lsp::jsonrpc::Result; use tower_lsp::lsp_types::*; +fn format_fallback_hover( + fqn: &str, + intent: Option, +) -> String { + let mut text = String::new(); + let label = match intent { + Some(naviscope_api::models::SymbolIntent::Type) => "Type", + Some(naviscope_api::models::SymbolIntent::Method) => "Method", + Some(naviscope_api::models::SymbolIntent::Field) => "Field", + Some(naviscope_api::models::SymbolIntent::Variable) => "Variable", + _ => "Symbol", + }; + text.push_str(&format!("**{}**\n\n", label)); + + if let Some((owner, _member)) = fqn.split_once('#') { + text.push_str(&format!("Declared in `{}`\n\n", owner)); + } else if let Some((owner, _name)) = fqn.rsplit_once('.') { + text.push_str(&format!("Defined in `{}`\n\n", owner)); + } + + text.push_str("*Metadata unavailable (symbol may not be indexed yet)*\n\n"); + text.push_str(&format!("*`{}`*", fqn)); + text +} + 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 content = server.documents.get(&uri).map(|d| d.content.clone()); let engine_lock = server.engine.read().await; let engine = match engine_lock.as_ref() { @@ -17,7 +43,7 @@ pub async fn hover(server: &LspServer, params: HoverParams) -> Result Result res, Ok(None) => return Ok(None), Err(e) => { - return Err(tower_lsp::jsonrpc::Error::invalid_params(format!( - "Resolution error: {}", - e - ))); + tracing::warn!("hover resolve_symbol_at failed for {}: {}", uri, e); + return Ok(None); } }; - // 2. Map resolution to hover text - let mut hover_text = String::new(); + let info = match &resolution { + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => { + engine.get_symbol_info(fqn).await.ok().flatten() + } + SymbolResolution::Local(_, _) => None, + }; + let hover_text = build_hover_text(&resolution, info.as_ref()); + + if !hover_text.is_empty() { + return Ok(Some(Hover { + contents: HoverContents::Scalar(MarkedString::String(hover_text)), + range: None, + })); + } + + Ok(None) +} +fn build_hover_text(resolution: &SymbolResolution, info: Option<&DisplayGraphNode>) -> String { match resolution { - naviscope_api::models::SymbolResolution::Local(_, type_name) => { + SymbolResolution::Local(range, type_name) => { + let mut hover_text = String::new(); hover_text.push_str("**Local variable**"); if let Some(t) = type_name { hover_text.push_str(&format!(": `{}`", t)); } + hover_text.push_str("\n\n"); + hover_text.push_str(&format!( + "Declared at `{}:{}`", + range.start_line + 1, + range.start_col + 1 + )); + hover_text.push_str("\n\n"); + hover_text.push_str("*Scope: local*"); + hover_text } - 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)); - } + SymbolResolution::Precise(fqn, intent) => build_symbol_hover(fqn, Some(*intent), info), + SymbolResolution::Global(fqn) => build_symbol_hover(fqn, None, info), + } +} + +fn build_symbol_hover( + fqn: &str, + intent: Option, + info: Option<&DisplayGraphNode>, +) -> String { + let Some(info) = info else { + return format_fallback_hover(fqn, intent); + }; + + let mut hover_text = String::new(); + let container_line = info.detail.clone().or_else(|| { + fqn.split_once('#') + .map(|(owner, _member)| format!("Declared in `{}`", owner)) + }); + + 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(container_line) = container_line { + hover_text.push_str(&container_line); + hover_text.push_str("\n\n"); + } + + match info.source { + naviscope_api::models::NodeSource::External => { + hover_text.push_str("*Source: external*\n\n"); + } + naviscope_api::models::NodeSource::Builtin => { + hover_text.push_str("*Source: builtin*\n\n"); } + naviscope_api::models::NodeSource::Project => {} } - if !hover_text.is_empty() { - return Ok(Some(Hover { - contents: HoverContents::Scalar(MarkedString::String(hover_text)), - range: None, - })); + hover_text.push_str(&format!("*`{}`*", fqn)); + hover_text +} + +#[cfg(test)] +mod tests { + use super::*; + use naviscope_api::models::graph::{NodeKind, NodeSource, ResolutionStatus}; + use naviscope_api::models::symbol::Range; + + #[test] + fn hover_local_contains_type_and_decl() { + let text = build_hover_text( + &SymbolResolution::Local( + Range { + start_line: 3, + start_col: 8, + end_line: 3, + end_col: 12, + }, + Some("List".into()), + ), + None, + ); + assert!(text.contains("Local variable")); + assert!(text.contains("List")); + assert!(text.contains("Declared at `4:9`")); } - Ok(None) + #[test] + fn hover_member_uses_signature_and_owner() { + let info = DisplayGraphNode { + id: "com.example.Service#getContext".into(), + name: "getContext".into(), + kind: NodeKind::Method, + lang: "java".into(), + source: NodeSource::Project, + status: ResolutionStatus::Resolved, + location: None, + detail: None, + signature: Some("SessionContext getContext()".into()), + modifiers: vec![], + children: None, + }; + + let text = build_hover_text( + &SymbolResolution::Precise( + "com.example.Service#getContext".into(), + naviscope_api::models::SymbolIntent::Method, + ), + Some(&info), + ); + assert!(text.contains("SessionContext getContext()")); + assert!(text.contains("Declared in `com.example.Service`")); + } + + #[test] + fn hover_fallback_mentions_metadata_unavailable() { + let text = build_hover_text( + &SymbolResolution::Global("com.example.Missing#call".into()), + None, + ); + assert!(text.contains("Metadata unavailable")); + assert!(text.contains("com.example.Missing")); + } + + #[test] + fn hover_external_marks_source() { + let info = DisplayGraphNode { + id: "java.util.List#size".into(), + name: "size".into(), + kind: NodeKind::Method, + lang: "java".into(), + source: NodeSource::External, + status: ResolutionStatus::Resolved, + location: None, + detail: Some("Declared in `java.util.List`".into()), + signature: Some("int size()".into()), + modifiers: vec![], + children: None, + }; + + let text = build_hover_text( + &SymbolResolution::Global("java.util.List#size".into()), + Some(&info), + ); + assert!(text.contains("Source: external")); + } } diff --git a/crates/lsp/src/indexer.rs b/crates/lsp/src/indexer.rs index 247ddc2..21b1644 100644 --- a/crates/lsp/src/indexer.rs +++ b/crates/lsp/src/indexer.rs @@ -58,7 +58,7 @@ pub fn spawn_indexer( client.log_message(MessageType::INFO, stats_msg).await; // 2. Setup file watcher - if let Err(e) = engine.watch().await { + if let Err(e) = engine.start_watch().await { client .log_message( MessageType::ERROR, diff --git a/crates/lsp/src/symbols.rs b/crates/lsp/src/symbols.rs index 93ffc3a..b5de571 100644 --- a/crates/lsp/src/symbols.rs +++ b/crates/lsp/src/symbols.rs @@ -17,7 +17,10 @@ pub async fn document_symbol( let api_symbols = match engine.get_document_symbols(uri.as_str()).await { Ok(s) => s, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("get_document_symbols failed for {}: {}", uri, e); + return Ok(None); + } }; let lsp_symbols = convert_api_symbols(api_symbols); @@ -25,22 +28,25 @@ pub async fn document_symbol( } fn convert_api_symbols(symbols: Vec) -> Vec { - symbols.into_iter().map(convert_api_symbol).collect() + symbols.into_iter().filter_map(convert_api_symbol).collect() } -fn convert_api_symbol(sym: DisplayGraphNode) -> DocumentSymbol { - let loc = sym.location.as_ref().expect("Symbol must have location"); +fn convert_api_symbol(sym: DisplayGraphNode) -> Option { + let loc = sym.location.as_ref()?; 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); + 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 { + Some(DocumentSymbol { name: sym.name, detail: sym.detail, kind: node_kind_to_symbol_kind(&sym.kind), @@ -49,7 +55,7 @@ fn convert_api_symbol(sym: DisplayGraphNode) -> DocumentSymbol { range, selection_range, children: sym.children.map(convert_api_symbols), - } + }) } fn node_kind_to_symbol_kind(kind: &NodeKind) -> SymbolKind { @@ -91,12 +97,16 @@ pub async fn workspace_symbol( let query = GraphQuery::Find { pattern: params.query, kind: vec![], + sources: vec![], limit: 100, }; let result = match engine.query(&query).await { Ok(r) => r, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("workspace_symbol query failed: {}", e); + return Ok(None); + } }; let symbols: Vec = result @@ -127,3 +137,29 @@ pub async fn workspace_symbol( Ok(Some(symbols)) } + +#[cfg(test)] +mod tests { + use super::convert_api_symbols; + use naviscope_api::models::graph::{DisplayGraphNode, NodeKind, NodeSource, ResolutionStatus}; + + #[test] + fn convert_api_symbols_skips_entries_without_location() { + let symbols = vec![DisplayGraphNode { + id: "com.example.Missing".to_string(), + name: "Missing".to_string(), + kind: NodeKind::Class, + lang: "java".to_string(), + source: NodeSource::Project, + status: ResolutionStatus::Resolved, + location: None, + detail: None, + signature: None, + modifiers: vec![], + children: None, + }]; + + let converted = convert_api_symbols(symbols); + assert!(converted.is_empty()); + } +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index a77a38d..d15eb40 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-mcp" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 3c32121..1e2e03a 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -48,6 +48,8 @@ pub struct FindArgs { pub pattern: String, /// Optional: Filter by element type. pub kind: Option>, + /// Optional: Filter by node source (project, external, builtin). + pub sources: Option>, /// Maximum number of results to return (default: 20) pub limit: Option, } @@ -58,6 +60,8 @@ pub struct LsArgs { pub fqn: Option, /// Optional: Filter results by element type. pub kind: Option>, + /// Optional: Filter by node source (project, external, builtin). + pub sources: Option>, /// Optional: Filter results by modifiers (e.g. ["public", "static"]) pub modifiers: Option>, } @@ -171,6 +175,7 @@ Naviscope is a graph-based code understanding engine. Unlike text search, it und self.execute_query(GraphQuery::Find { pattern: args.pattern, kind: args.kind.unwrap_or_default(), + sources: args.sources.unwrap_or_default(), limit: args.limit.unwrap_or(20), }) .await @@ -184,6 +189,7 @@ Naviscope is a graph-based code understanding engine. Unlike text search, it und self.execute_query(GraphQuery::Ls { fqn: args.fqn, kind: args.kind.unwrap_or_default(), + sources: args.sources.unwrap_or_default(), modifiers: args.modifiers.unwrap_or_default(), }) .await diff --git a/crates/plugin/Cargo.toml b/crates/plugin/Cargo.toml index 9d18493..3c33a1b 100644 --- a/crates/plugin/Cargo.toml +++ b/crates/plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-plugin" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] @@ -11,3 +11,4 @@ async-trait = { workspace = true } thiserror = { workspace = true } tree-sitter = { workspace = true } lsp-types = { workspace = true } +serde_bytes = { workspace = true } diff --git a/crates/plugin/src/asset.rs b/crates/plugin/src/asset.rs new file mode 100644 index 0000000..9617dd6 --- /dev/null +++ b/crates/plugin/src/asset.rs @@ -0,0 +1,179 @@ +//! Asset and Stub trait definitions for the Global Asset Scanner architecture. +//! +//! This module defines the core abstractions for: +//! - Asset discovery (finding JAR files, JDK modules, etc.) +//! - Asset indexing (extracting package prefixes from assets) +//! - Stub generation (creating type stubs from bytecode) +//! - Route registry (mapping FQNs to asset paths) + +use crate::model::IndexNode; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// Error type for asset operations +pub type BoxError = Box; + +// ==================== Asset Source ==================== + +/// Asset source type - where the asset comes from +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AssetSource { + /// JDK standard library (lib/modules, rt.jar, jmods) + Jdk { + version: Option, // e.g. "17.0.1" + path: PathBuf, // JDK root path + }, + + /// Gradle cache + Gradle { + group: String, // e.g. "io.netty" + artifact: String, // e.g. "netty-common" + version: String, // e.g. "4.1.100.Final" + }, + + /// Maven local repository + Maven { + group: String, + artifact: String, + version: String, + }, + + /// Project local dependency (lib/*.jar) + Local { project_path: PathBuf }, + + /// Unknown source + Unknown, +} + +impl AssetSource { + /// Get the source type as a string (for statistics and filtering) + pub fn source_type(&self) -> &'static str { + match self { + AssetSource::Jdk { .. } => "jdk", + AssetSource::Gradle { .. } => "gradle", + AssetSource::Maven { .. } => "maven", + AssetSource::Local { .. } => "local", + AssetSource::Unknown => "unknown", + } + } +} + +/// Asset entry with source metadata +#[derive(Debug, Clone)] +pub struct AssetEntry { + pub path: PathBuf, + pub source: AssetSource, +} + +impl AssetEntry { + pub fn new(path: PathBuf, source: AssetSource) -> Self { + Self { path, source } + } + + pub fn unknown(path: PathBuf) -> Self { + Self { + path, + source: AssetSource::Unknown, + } + } +} + +// ==================== Asset Layer ==================== + +/// Asset discoverer - knows where to find assets +/// Uses Iterator pattern for streaming (constant memory) +pub trait AssetDiscoverer: Send + Sync { + /// Returns an asset iterator (streaming, does not load all at once) + fn discover(&self) -> Box + Send + '_>; + + /// Discoverer name (for logging/debugging) + fn name(&self) -> &str; + + /// Default source type for this discoverer + fn source_type(&self) -> &str; +} + +/// Asset indexer - knows how to read asset internal structure +/// Returns package prefixes found in the asset +pub trait AssetIndexer: Send + Sync { + /// Check if this indexer can handle the asset + fn can_index(&self, asset: &Path) -> bool; + + /// Extract package prefixes from the asset + /// Returns a list of package prefixes (e.g., "java.lang", "io.netty.channel") + fn index(&self, asset: &Path) -> Result, BoxError>; +} + +/// Asset route registry - manages prefix → AssetEntry[] mapping +pub trait AssetRouteRegistry: Send + Sync { + /// Register a route (with source info) + fn register(&self, prefix: String, entry: AssetEntry); + + /// Query asset entries for an FQN (with source info) + fn lookup(&self, fqn: &str) -> Option>; + + /// Query by source type + fn lookup_by_source(&self, fqn: &str, source_type: &str) -> Option>; + + /// Get all routes (for serialization) + fn all_routes(&self) -> HashMap>; + + /// Get statistics + fn stats(&self) -> RegistryStats; +} + +/// Asset source locator - maps a binary asset to its source asset if available +pub trait AssetSourceLocator: Send + Sync { + fn locate_source(&self, entry: &AssetEntry) -> Option; +} + +/// Registry statistics +#[derive(Debug, Default, Clone)] +pub struct RegistryStats { + pub total_prefixes: usize, + pub total_entries: usize, + pub by_source: HashMap, // e.g. {"jdk": 100, "gradle": 5000} +} + +// ==================== Stub Layer ==================== + +/// Stub generator - knows how to generate type info from asset +/// Implemented by language plugins (e.g. JavaExternalResolver) +pub trait StubGenerator: Send + Sync { + /// Check if this generator can handle the asset + fn can_generate(&self, asset: &Path) -> bool; + + /// Generate one or more stubs for the specified FQN from asset. + /// + /// Implementations should include the primary requested node when possible, + /// and may include related nodes (e.g. class members) for richer resolution. + fn generate_stubs(&self, fqn: &str, entry: &AssetEntry) -> Result, BoxError>; +} + +/// Stub request (with source info) +#[derive(Debug, Clone)] +pub struct StubRequest { + pub fqn: String, + pub candidate_entries: Vec, // candidates with source info +} + +impl StubRequest { + pub fn new(fqn: String, candidate_entries: Vec) -> Self { + Self { + fqn, + candidate_entries, + } + } + + /// Create from legacy format (paths only) + pub fn from_paths(fqn: String, paths: Vec) -> Self { + Self { + fqn, + candidate_entries: paths.into_iter().map(AssetEntry::unknown).collect(), + } + } +} + +pub type StubRequestSender = std::sync::mpsc::Sender; +pub type StubRequestReceiver = std::sync::mpsc::Receiver; diff --git a/crates/plugin/src/cap/asset.rs b/crates/plugin/src/cap/asset.rs new file mode 100644 index 0000000..d7b437a --- /dev/null +++ b/crates/plugin/src/cap/asset.rs @@ -0,0 +1,25 @@ +use crate::asset::{AssetDiscoverer, AssetIndexer, AssetSourceLocator, StubGenerator}; +use std::path::Path; +use std::sync::Arc; + +pub trait AssetCap: Send + Sync { + fn global_asset_discoverer(&self) -> Option> { + None + } + + fn project_asset_discoverer(&self, _project_root: &Path) -> Option> { + None + } + + fn asset_indexer(&self) -> Option> { + None + } + + fn asset_source_locator(&self) -> Option> { + None + } + + fn stub_generator(&self) -> Option> { + None + } +} diff --git a/crates/plugin/src/cap/indexing.rs b/crates/plugin/src/cap/indexing.rs new file mode 100644 index 0000000..88ec3d8 --- /dev/null +++ b/crates/plugin/src/cap/indexing.rs @@ -0,0 +1,46 @@ +use crate::ResolvedUnit; +use crate::asset::BoxError; +use crate::indexing::ProjectContext; +use crate::model::ParsedFile; +use std::any::Any; + +pub trait SourceCollectArtifact: Send + Sync { + fn as_any(&self) -> &dyn Any; + fn into_any(self: Box) -> Box; + fn collected_type_symbols(&self) -> &[String]; + fn collected_method_symbols(&self) -> &[String]; + fn provided_dependency_symbols(&self) -> &[String]; + fn required_dependency_symbols(&self) -> &[String]; +} + +pub trait SourceAnalyzeArtifact: Send + Sync { + fn as_any(&self) -> &dyn Any; + fn into_any(self: Box) -> Box; +} + +pub trait SourceIndexCap: Send + Sync { + fn collect_source( + &self, + file: &ParsedFile, + context: &ProjectContext, + ) -> Result, BoxError>; + + fn analyze_source( + &self, + collected: Box, + context: &ProjectContext, + ) -> Result, BoxError>; + + fn lower_source( + &self, + analyzed: Box, + context: &ProjectContext, + ) -> Result; +} + +pub trait BuildIndexCap: Send + Sync { + fn compile_build( + &self, + files: &[&ParsedFile], + ) -> Result<(ResolvedUnit, ProjectContext), BoxError>; +} diff --git a/crates/plugin/src/cap/matcher.rs b/crates/plugin/src/cap/matcher.rs new file mode 100644 index 0000000..c46b9ab --- /dev/null +++ b/crates/plugin/src/cap/matcher.rs @@ -0,0 +1,5 @@ +use std::path::Path; + +pub trait FileMatcherCap: Send + Sync { + fn supports_path(&self, path: &Path) -> bool; +} diff --git a/crates/plugin/src/cap/metadata_codec.rs b/crates/plugin/src/cap/metadata_codec.rs new file mode 100644 index 0000000..a59aac0 --- /dev/null +++ b/crates/plugin/src/cap/metadata_codec.rs @@ -0,0 +1,8 @@ +use crate::core::NodeMetadataCodec; +use std::sync::Arc; + +pub trait MetadataCodecCap: Send + Sync { + fn metadata_codec(&self) -> Option> { + None + } +} diff --git a/crates/plugin/src/cap/mod.rs b/crates/plugin/src/cap/mod.rs new file mode 100644 index 0000000..e69f7c9 --- /dev/null +++ b/crates/plugin/src/cap/mod.rs @@ -0,0 +1,15 @@ +pub mod asset; +pub mod indexing; +pub mod matcher; +pub mod metadata_codec; +pub mod parse; +pub mod presentation; +pub mod runtime; + +pub use asset::*; +pub use indexing::*; +pub use matcher::*; +pub use metadata_codec::*; +pub use parse::*; +pub use presentation::*; +pub use runtime::*; diff --git a/crates/plugin/src/cap/parse.rs b/crates/plugin/src/cap/parse.rs new file mode 100644 index 0000000..bc39f75 --- /dev/null +++ b/crates/plugin/src/cap/parse.rs @@ -0,0 +1,12 @@ +use crate::asset::BoxError; +use crate::model::{BuildParseResult, GlobalParseResult}; +use std::path::Path; + +pub trait LanguageParseCap: Send + Sync { + fn parse_language_file(&self, source: &str, path: &Path) + -> Result; +} + +pub trait BuildParseCap: Send + Sync { + fn parse_build_file(&self, source: &str) -> Result; +} diff --git a/crates/plugin/src/cap/presentation.rs b/crates/plugin/src/cap/presentation.rs new file mode 100644 index 0000000..276205a --- /dev/null +++ b/crates/plugin/src/cap/presentation.rs @@ -0,0 +1,16 @@ +use crate::core::NodePresenter; +use crate::naming::NamingConvention; +use naviscope_api::models::graph::NodeKind; +use std::sync::Arc; + +pub trait PresentationCap: Send + Sync { + fn naming_convention(&self) -> Option> { + None + } + + fn node_presenter(&self) -> Option> { + None + } + + fn symbol_kind(&self, kind: &NodeKind) -> lsp_types::SymbolKind; +} diff --git a/crates/plugin/src/cap/runtime.rs b/crates/plugin/src/cap/runtime.rs new file mode 100644 index 0000000..ccd862e --- /dev/null +++ b/crates/plugin/src/cap/runtime.rs @@ -0,0 +1,114 @@ +use crate::graph::CodeGraph; +use naviscope_api::models::SymbolResolution; +use naviscope_api::models::graph::DisplayGraphNode; +use naviscope_api::models::symbol::{FqnId, Range}; +use tree_sitter::Tree; + +pub trait SymbolResolveService: Send + Sync { + fn resolve_at( + &self, + tree: &Tree, + source: &str, + line: usize, + byte_col: usize, + index: &dyn CodeGraph, + ) -> Option; +} + +pub trait SymbolQueryService: Send + Sync { + fn find_matches(&self, index: &dyn CodeGraph, res: &SymbolResolution) -> Vec; + fn resolve_type_of( + &self, + index: &dyn CodeGraph, + res: &SymbolResolution, + ) -> Vec; + fn find_implementations(&self, index: &dyn CodeGraph, res: &SymbolResolution) -> Vec; +} + +pub trait LspSyntaxService: Send + Sync { + fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option; + fn extract_symbols(&self, tree: &Tree, source: &str) -> Vec; + fn find_occurrences( + &self, + source: &str, + tree: &Tree, + target: &SymbolResolution, + index: Option<&dyn CodeGraph>, + ) -> Vec; +} + +pub trait ReferenceCheckService: Send + Sync { + fn is_reference_to( + &self, + graph: &dyn CodeGraph, + candidate: &SymbolResolution, + target: &SymbolResolution, + ) -> bool; + + fn is_subtype(&self, graph: &dyn CodeGraph, sub: &str, sup: &str) -> bool { + if sub == sup { + return true; + } + + let sub_ids = graph.resolve_fqn(sub); + let sup_ids = graph.resolve_fqn(sup); + if sub_ids.is_empty() || sup_ids.is_empty() { + return false; + } + + use naviscope_api::models::graph::EdgeType; + use std::collections::{HashSet, VecDeque}; + + for &sub_id in &sub_ids { + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(sub_id); + visited.insert(sub_id); + + while let Some(current) = queue.pop_front() { + if sup_ids.contains(¤t) { + return true; + } + + let parents = graph.get_neighbors( + current, + crate::graph::Direction::Outgoing, + Some(EdgeType::InheritsFrom), + ); + for parent in parents { + if visited.insert(parent) { + queue.push_back(parent); + } + } + + let interfaces = graph.get_neighbors( + current, + crate::graph::Direction::Outgoing, + Some(EdgeType::Implements), + ); + for interface in interfaces { + if visited.insert(interface) { + queue.push_back(interface); + } + } + } + } + + false + } +} + +pub trait SemanticCap: + SymbolResolveService + SymbolQueryService + LspSyntaxService + ReferenceCheckService + Send + Sync +{ +} + +impl SemanticCap for T where + T: SymbolResolveService + + SymbolQueryService + + LspSyntaxService + + ReferenceCheckService + + Send + + Sync +{ +} diff --git a/crates/plugin/src/converter.rs b/crates/plugin/src/converter.rs index da0aae5..6f8b104 100644 --- a/crates/plugin/src/converter.rs +++ b/crates/plugin/src/converter.rs @@ -1,4 +1,4 @@ -use crate::interner::FqnInterner; +use crate::core::FqnInterner; use naviscope_api::models::graph::{DisplayGraphNode, DisplaySymbolLocation, GraphNode}; use naviscope_api::models::symbol::InternedLocation; use std::sync::Arc; @@ -29,6 +29,8 @@ impl ModelConverter for DisplayGraphNode { name: interner.intern_atom(&self.name), kind: self.kind.clone(), lang: interner.intern_atom(&self.lang), + source: self.source.clone(), + status: self.status, location: self.location.as_ref().map(|l| l.to_internal(interner)), metadata: Arc::new(naviscope_api::models::graph::EmptyMetadata), } diff --git a/crates/plugin/src/core/codec.rs b/crates/plugin/src/core/codec.rs new file mode 100644 index 0000000..4a66841 --- /dev/null +++ b/crates/plugin/src/core/codec.rs @@ -0,0 +1,6 @@ +use crate::core::FqnInterner; + +pub trait CodecContext: Send + Sync { + fn interner(&mut self) -> &mut dyn FqnInterner; + fn as_any_mut(&mut self) -> &mut dyn std::any::Any; +} diff --git a/crates/plugin/src/interner.rs b/crates/plugin/src/core/interner.rs similarity index 65% rename from crates/plugin/src/interner.rs rename to crates/plugin/src/core/interner.rs index 13dd547..cb404e6 100644 --- a/crates/plugin/src/interner.rs +++ b/crates/plugin/src/core/interner.rs @@ -14,16 +14,6 @@ pub trait FqnInterner: FqnReader { fn intern_node_id(&self, id: &NodeId) -> FqnId; } -/// Context for metadata serialization/deserialization operations. -/// Provides access to shared string interners and other storage facilities. -pub trait StorageContext: Send + Sync { - /// Get the string interner for converting strings to symbols. - fn interner(&mut self) -> &mut dyn FqnInterner; - - /// Downcast to Any for plugin-specific context access. - fn as_any_mut(&mut self) -> &mut dyn std::any::Any; -} - /// Context for interning strings during metadata conversion. pub trait SymbolInterner { fn intern_str(&mut self, s: &str) -> u32; diff --git a/crates/plugin/src/core/metadata_codec.rs b/crates/plugin/src/core/metadata_codec.rs new file mode 100644 index 0000000..b6117b3 --- /dev/null +++ b/crates/plugin/src/core/metadata_codec.rs @@ -0,0 +1,8 @@ +use crate::core::CodecContext; +use naviscope_api::models::graph::NodeMetadata; +use std::sync::Arc; + +pub trait NodeMetadataCodec: Send + Sync { + fn encode_metadata(&self, metadata: &dyn NodeMetadata, ctx: &mut dyn CodecContext) -> Vec; + fn decode_metadata(&self, bytes: &[u8], ctx: &dyn CodecContext) -> Arc; +} diff --git a/crates/plugin/src/core/mod.rs b/crates/plugin/src/core/mod.rs new file mode 100644 index 0000000..8a67f3c --- /dev/null +++ b/crates/plugin/src/core/mod.rs @@ -0,0 +1,9 @@ +mod codec; +mod interner; +mod metadata_codec; +mod presentation; + +pub use codec::*; +pub use interner::*; +pub use metadata_codec::*; +pub use presentation::*; diff --git a/crates/plugin/src/core/presentation.rs b/crates/plugin/src/core/presentation.rs new file mode 100644 index 0000000..df3e5e2 --- /dev/null +++ b/crates/plugin/src/core/presentation.rs @@ -0,0 +1,6 @@ +use naviscope_api::models::graph::{DisplayGraphNode, GraphNode}; +use naviscope_api::models::symbol::FqnReader; + +pub trait NodePresenter: Send + Sync { + fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode; +} diff --git a/crates/plugin/src/graph.rs b/crates/plugin/src/graph.rs index bef7bf1..ad19e28 100644 --- a/crates/plugin/src/graph.rs +++ b/crates/plugin/src/graph.rs @@ -27,6 +27,11 @@ pub enum GraphOp { UpdateFile { metadata: SourceFile }, } +#[derive(Debug, Clone)] +pub struct DeferredSymbol { + pub target: String, +} + /// Result of resolving a single file or unit #[derive(Debug)] pub struct ResolvedUnit { @@ -38,6 +43,8 @@ pub struct ResolvedUnit { pub identifiers: Vec, /// Naming convention for upgrading FQNs pub naming_convention: Option>, + /// Deferred unresolved targets produced during analyze pass. + pub deferred_symbols: Vec, } impl ResolvedUnit { @@ -47,6 +54,7 @@ impl ResolvedUnit { nodes: HashMap::new(), identifiers: Vec::new(), naming_convention: None, + deferred_symbols: Vec::new(), } } diff --git a/crates/plugin/src/indexing/context.rs b/crates/plugin/src/indexing/context.rs new file mode 100644 index 0000000..20394d7 --- /dev/null +++ b/crates/plugin/src/indexing/context.rs @@ -0,0 +1,38 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Default)] +pub struct ProjectSymbolTable { + pub type_symbols: std::collections::HashSet, + pub method_symbols: std::collections::HashSet, +} + +/// Project context generated during build indexing. +#[derive(Debug, Clone, Default)] +pub struct ProjectContext { + /// Mapping from path prefixes to module IDs (e.g., "/project/app" -> "module::app") + pub path_to_module: HashMap, + /// Project-level collected symbol snapshot used by analyze/bind stage. + pub symbol_table: ProjectSymbolTable, +} + +impl ProjectContext { + pub fn new() -> Self { + Self { + path_to_module: HashMap::new(), + symbol_table: ProjectSymbolTable::default(), + } + } + + /// Finds the best matching module ID for a given file path. + pub fn find_module_for_path(&self, path: &Path) -> Option { + let mut current = path.to_path_buf(); + while let Some(parent) = current.parent() { + if let Some(id) = self.path_to_module.get(parent) { + return Some(id.clone()); + } + current = parent.to_path_buf(); + } + None + } +} diff --git a/crates/plugin/src/indexing/mod.rs b/crates/plugin/src/indexing/mod.rs new file mode 100644 index 0000000..1bb1ca5 --- /dev/null +++ b/crates/plugin/src/indexing/mod.rs @@ -0,0 +1,3 @@ +mod context; + +pub use context::*; diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 4c45c5b..f88a723 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -1,16 +1,22 @@ +pub mod asset; +pub mod cap; pub mod converter; +pub mod core; pub mod graph; -pub mod interner; +pub mod indexing; pub mod model; pub mod naming; -pub mod plugin; -pub mod resolver; +pub mod registration; +pub mod typing; pub mod utils; +pub use asset::*; +pub use cap::*; pub use converter::*; +pub use core::*; pub use graph::*; -pub use interner::*; +pub use indexing::*; pub use model::*; -pub use naming::{DotPathConvention, NamingConvention}; -pub use plugin::*; -pub use resolver::*; +pub use naming::{MethodSignature, NamingConvention, StandardNamingConvention}; +pub use registration::*; +pub use typing::*; diff --git a/crates/plugin/src/model.rs b/crates/plugin/src/model.rs index a4f3959..8867153 100644 --- a/crates/plugin/src/model.rs +++ b/crates/plugin/src/model.rs @@ -1,12 +1,77 @@ -use crate::interner::SymbolInterner; +use crate::core::SymbolInterner; use naviscope_api::models::graph::{ - DisplaySymbolLocation, EdgeType, EmptyMetadata, NodeKind, NodeMetadata, + DisplaySymbolLocation, EdgeType, EmptyMetadata, NodeKind, NodeMetadata, NodeSource, + ResolutionStatus, }; use naviscope_api::models::symbol::{NodeId, Range}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::OnceLock; use tree_sitter::Tree; +/// A function type that can deserialize bytes into language-specific IndexMetadata. +pub type MetadataDeserializer = fn(version: u32, bytes: &[u8]) -> Arc; + +/// Global registry for metadata deserializers. +static DESERIALIZER_REGISTRY: OnceLock>> = + OnceLock::new(); + +/// Register a deserializer for a specific metadata type tag. +pub fn register_metadata_deserializer(type_tag: &str, deserializer: MetadataDeserializer) { + let registry = DESERIALIZER_REGISTRY.get_or_init(|| std::sync::RwLock::new(HashMap::new())); + let mut registry = registry + .write() + .expect("Failed to lock deserializer registry"); + registry.insert(type_tag.to_string(), deserializer); +} + +/// Serialized metadata for cache storage. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedMetadata { + /// Type tag identifying the metadata format (e.g., "java") + pub type_tag: String, + + /// Schema version for the metadata format + pub version: u32, + + /// The serialized metadata bytes + #[serde(with = "serde_bytes")] + pub data: Vec, +} + +impl CachedMetadata { + /// Create a new empty metadata + pub fn empty() -> Self { + Self { + type_tag: "empty".to_string(), + version: 0, + data: Vec::new(), + } + } +} + +/// Deserialize metadata from CachedMetadata using the registered deserializers. +pub fn deserialize_metadata(cached: &CachedMetadata) -> Arc { + if cached.data.is_empty() || cached.type_tag == "empty" { + return Arc::new(EmptyMetadata); + } + + if let Some(registry) = DESERIALIZER_REGISTRY.get() { + let registry = registry + .read() + .expect("Failed to read deserializer registry"); + if let Some(deserializer) = registry.get(&cached.type_tag) { + return deserializer(cached.version, &cached.data); + } + } + + // Fallback: If "java" is not registered but type is "java", return empty + // but try to warn if appropriate (though we want to avoid log spam) + Arc::new(EmptyMetadata) +} + /// 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. @@ -16,6 +81,12 @@ pub trait IndexMetadata: Send + Sync + std::fmt::Debug { /// Transform this metadata into its interned/optimized version for graph storage. fn intern(&self, interner: &mut dyn SymbolInterner) -> Arc; + + /// Convert this metadata into a cacheable form. + /// Default implementation returns empty metadata. + fn to_cached_metadata(&self) -> CachedMetadata { + CachedMetadata::empty() + } } impl IndexMetadata for EmptyMetadata { @@ -36,6 +107,8 @@ pub struct IndexNode { pub name: String, pub kind: NodeKind, pub lang: String, + pub source: NodeSource, + pub status: ResolutionStatus, pub location: Option, pub metadata: Arc, } diff --git a/crates/plugin/src/naming.rs b/crates/plugin/src/naming.rs index 333725d..d4c9187 100644 --- a/crates/plugin/src/naming.rs +++ b/crates/plugin/src/naming.rs @@ -2,6 +2,154 @@ use naviscope_api::models::graph::NodeKind; use naviscope_api::models::symbol::{FqnId, FqnReader}; use std::fmt::Debug; +/// Separator used between a type and its members (methods, fields, constructors). +pub const MEMBER_SEPARATOR: char = '#'; + +/// Separator used between packages and between package/class. +pub const TYPE_SEPARATOR: char = '.'; + +// --------------------------------------------------------------------------- +// Name-level member FQN utilities (existing) +// --------------------------------------------------------------------------- + +/// Build a fully qualified name for a member (method, field, or constructor). +pub fn build_member_fqn(type_fqn: &str, member_name: &str) -> String { + format!("{}{}{}", type_fqn, MEMBER_SEPARATOR, member_name) +} + +/// Parse a member FQN into (type_fqn, member_name). +/// Returns `None` if the FQN does not contain a member separator. +pub fn parse_member_fqn(fqn: &str) -> Option<(&str, &str)> { + fqn.rfind(MEMBER_SEPARATOR) + .map(|pos| (&fqn[..pos], &fqn[pos + 1..])) +} + +/// Check if an FQN represents a member (method, field, constructor). +pub fn is_member_fqn(fqn: &str) -> bool { + fqn.contains(MEMBER_SEPARATOR) +} + +/// Extract the type FQN from a member FQN. +pub fn extract_type_fqn(fqn: &str) -> &str { + parse_member_fqn(fqn) + .map(|(type_fqn, _)| type_fqn) + .unwrap_or(fqn) +} + +/// Extract the member name from a member FQN. +pub fn extract_member_name(fqn: &str) -> Option<&str> { + parse_member_fqn(fqn).map(|(_, member)| member) +} + +// --------------------------------------------------------------------------- +// Signature-level method FQN utilities (cross-language) +// +// These operate on the project's FQN format convention (`Owner#name(params)`) +// and are language-agnostic. Language-specific type normalization (e.g. Java +// generic erasure) belongs in the respective `lang-*` crate. +// --------------------------------------------------------------------------- + +/// Format a method member name with its parameter signature. +/// +/// Takes a method name and **already-normalized** parameter type strings. +/// The normalization of types (e.g. generic erasure, varargs→array) is +/// language-specific and should be done by the caller. +/// +/// # Examples +/// +/// - `format_method_name("target", &[])` → `"target()"` +/// - `format_method_name("target", &["int"])` → `"target(int)"` +/// - `format_method_name("target", &["int", "java.lang.String"])` → `"target(int,java.lang.String)"` +/// +/// The result can be passed directly to [`build_member_fqn`]: +/// ``` +/// use naviscope_plugin::naming::{build_member_fqn, format_method_name}; +/// +/// let signed = format_method_name("target", &["int"]); +/// let fqn = build_member_fqn("com.example.A", &signed); +/// assert_eq!(fqn, "com.example.A#target(int)"); +/// ``` +pub fn format_method_name(name: &str, normalized_param_types: &[&str]) -> String { + format!("{}({})", name, normalized_param_types.join(",")) +} + +/// Parsed components of a signature-level method FQN. +/// +/// Produced by [`parse_method_signature`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MethodSignature<'a> { + /// Owner type FQN (e.g., `"com.example.A"`) + pub owner: &'a str, + /// Method simple name (e.g., `"target"`) + pub name: &'a str, + /// Raw parameter string inside parentheses (e.g., `"int,java.lang.String"`). + /// Empty string for no-arg methods. + pub params: &'a str, +} + +/// Parse a signature-level method FQN into its components. +/// +/// Returns `None` if the FQN is not a signed method FQN (no `#` or no parentheses). +/// +/// # Examples +/// +/// ``` +/// use naviscope_plugin::naming::parse_method_signature; +/// +/// let sig = parse_method_signature("com.example.A#target(int)").unwrap(); +/// assert_eq!(sig.owner, "com.example.A"); +/// assert_eq!(sig.name, "target"); +/// assert_eq!(sig.params, "int"); +/// +/// let sig = parse_method_signature("com.example.A#target()").unwrap(); +/// assert_eq!(sig.params, ""); +/// +/// // Not a signed method FQN: +/// assert!(parse_method_signature("com.example.A#field").is_none()); +/// assert!(parse_method_signature("com.example.A").is_none()); +/// ``` +pub fn parse_method_signature(fqn: &str) -> Option> { + let (owner, member) = parse_member_fqn(fqn)?; + let paren_start = member.find('(')?; + let paren_end = member.rfind(')')?; + if paren_end <= paren_start { + return None; + } + let name = &member[..paren_start]; + let params = &member[paren_start + 1..paren_end]; + Some(MethodSignature { + owner, + name, + params, + }) +} + +/// Check if a member FQN includes a method signature (has parentheses after `#`). +/// +/// - `"com.example.A#target(int)"` → `true` +/// - `"com.example.A#target()"` → `true` +/// - `"com.example.A#field"` → `false` +/// - `"com.example.A"` → `false` +pub fn has_method_signature(fqn: &str) -> bool { + parse_method_signature(fqn).is_some() +} + +/// Extract the simple name from a member part that may include a signature. +/// +/// This is useful for display purposes — stripping the parameter list while keeping +/// the method name. +/// +/// - `"target(int)"` → `"target"` +/// - `"target"` → `"target"` +/// - `"(int)"` → `""` +pub fn extract_simple_name(member: &str) -> &str { + member.find('(').map(|i| &member[..i]).unwrap_or(member) +} + +// --------------------------------------------------------------------------- +// NamingConvention trait +// --------------------------------------------------------------------------- + /// Defines language-specific naming rules for Fully Qualified Names (FQNs). /// This trait allows the core system to parse flat strings into structured paths /// based on language semantics (separators, nesting rules, etc.). @@ -56,36 +204,225 @@ pub trait NamingConvention: Send + Sync + Debug { } result } + + /// Get the member separator character (default '#'). + fn member_separator(&self) -> char { + MEMBER_SEPARATOR + } + + /// Build a fully qualified name for a member using this convention. + fn build_member_fqn(&self, type_fqn: &str, member_name: &str) -> String { + build_member_fqn(type_fqn, member_name) + } + + /// Parse a member FQN into (type_fqn, member_name) using this convention. + fn parse_member_fqn<'a>(&self, fqn: &'a str) -> Option<(&'a str, &'a str)> { + parse_member_fqn(fqn) + } + + /// Check if an FQN represents a member using this convention. + fn is_member_fqn(&self, fqn: &str) -> bool { + is_member_fqn(fqn) + } } -/// A default "Dot" convention (e.g. for Java/Python-ish languages). -/// It assumes "Package" -> "Class" -> "Leaf". -#[derive(Debug, Default)] -pub struct DotPathConvention; +/// A standard naming convention suitable for most polyglot scenarios. +/// It uses `.` for hierarchy/types and `#` for members. +/// This implementation unifies behavior across languages unless specific overrides are needed. +#[derive(Debug, Default, Clone, Copy)] +pub struct StandardNamingConvention; -impl NamingConvention for DotPathConvention { +impl NamingConvention for StandardNamingConvention { fn separator(&self) -> &str { "." } + fn get_separator(&self, parent: NodeKind, child: NodeKind) -> &str { + match (parent, child) { + ( + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation, + NodeKind::Method | NodeKind::Field | NodeKind::Constructor, + ) => "#", + _ => ".", + } + } + fn parse_fqn( &self, fqn: &str, heuristic_leaf_kind: Option, ) -> Vec<(NodeKind, String)> { - // Simple default splitting - let parts: Vec<&str> = fqn.split('.').collect(); - let mut result = Vec::with_capacity(parts.len()); + // 1. Check for standard member separator first + let (type_part, member_part) = parse_member_fqn(fqn) + .map(|(t, m)| (t, Some(m))) + .unwrap_or((fqn, None)); + + // 2. Split the type part by '.' + let parts: Vec<&str> = type_part.split(TYPE_SEPARATOR).collect(); + let mut result = + Vec::with_capacity(parts.len() + if member_part.is_some() { 1 } else { 0 }); for (i, part) in parts.iter().enumerate() { - let is_last = i == parts.len() - 1; - let kind = if is_last { - heuristic_leaf_kind.clone().unwrap_or(NodeKind::Class) + if part.is_empty() { + continue; + } + // If we have a member part, then the last part of type_path is likely a Class/Type. + // If not, we use the heuristic or default to Class. + let is_last_type_part = i == parts.len() - 1; + let kind = if is_last_type_part { + if member_part.is_some() { + NodeKind::Class + } else { + heuristic_leaf_kind.clone().unwrap_or(NodeKind::Class) + } } else { NodeKind::Package }; result.push((kind, part.to_string())); } + + // 3. Add member part if present (includes signature for signed method FQNs) + if let Some(member) = member_part { + // If parsed as member, use Method as default, or heuristic if provided + // Note: Heuristic for member is usually Method, but could be Field. + let kind = heuristic_leaf_kind.unwrap_or(NodeKind::Method); + result.push((kind, member.to_string())); + } + result } } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- format_method_name -- + + #[test] + fn format_method_name_basic() { + assert_eq!(format_method_name("foo", &[]), "foo()"); + assert_eq!(format_method_name("foo", &["A"]), "foo(A)"); + assert_eq!(format_method_name("foo", &["A", "B"]), "foo(A,B)"); + } + + #[test] + fn format_method_name_composes_with_build_member_fqn() { + let signed = format_method_name("bar", &["X"]); + let fqn = build_member_fqn("pkg.Owner", &signed); + assert_eq!(fqn, "pkg.Owner#bar(X)"); + } + + // -- parse_method_signature -- + + #[test] + fn parse_no_params() { + let sig = parse_method_signature("pkg.Owner#foo()").unwrap(); + assert_eq!(sig.owner, "pkg.Owner"); + assert_eq!(sig.name, "foo"); + assert_eq!(sig.params, ""); + } + + #[test] + fn parse_single_param() { + let sig = parse_method_signature("pkg.Owner#foo(T1)").unwrap(); + assert_eq!(sig.owner, "pkg.Owner"); + assert_eq!(sig.name, "foo"); + assert_eq!(sig.params, "T1"); + } + + #[test] + fn parse_multiple_params() { + let sig = parse_method_signature("pkg.Owner#foo(T1,T2)").unwrap(); + assert_eq!(sig.owner, "pkg.Owner"); + assert_eq!(sig.name, "foo"); + assert_eq!(sig.params, "T1,T2"); + } + + #[test] + fn parse_not_signed_field() { + assert!(parse_method_signature("pkg.Owner#field").is_none()); + } + + #[test] + fn parse_not_a_member() { + assert!(parse_method_signature("pkg.Owner").is_none()); + } + + // -- has_method_signature -- + + #[test] + fn has_signature_true_cases() { + assert!(has_method_signature("pkg.Owner#foo()")); + assert!(has_method_signature("pkg.Owner#foo(T1)")); + assert!(has_method_signature("pkg.Owner#foo(T1,T2)")); + } + + #[test] + fn has_signature_false_cases() { + assert!(!has_method_signature("pkg.Owner#field")); + assert!(!has_method_signature("pkg.Owner")); + assert!(!has_method_signature("T1")); + } + + // -- extract_simple_name -- + + #[test] + fn extract_simple_name_with_signature() { + assert_eq!(extract_simple_name("foo(T1)"), "foo"); + assert_eq!(extract_simple_name("foo()"), "foo"); + assert_eq!(extract_simple_name("foo(T1,T2)"), "foo"); + } + + #[test] + fn extract_simple_name_without_signature() { + assert_eq!(extract_simple_name("foo"), "foo"); + assert_eq!(extract_simple_name("field"), "field"); + } + + // -- Interaction with existing utilities -- + + #[test] + fn parse_member_fqn_works_with_signed_method() { + let (owner, member) = parse_member_fqn("pkg.Owner#foo(T1)").unwrap(); + assert_eq!(owner, "pkg.Owner"); + assert_eq!(member, "foo(T1)"); + assert_eq!(extract_simple_name(member), "foo"); + } + + #[test] + fn roundtrip_format_then_parse() { + let signed = format_method_name("foo", &["T1", "T2"]); + let fqn = build_member_fqn("pkg.Owner", &signed); + assert_eq!(fqn, "pkg.Owner#foo(T1,T2)"); + + let sig = parse_method_signature(&fqn).unwrap(); + assert_eq!(sig.owner, "pkg.Owner"); + assert_eq!(sig.name, "foo"); + assert_eq!(sig.params, "T1,T2"); + } + + // -- StandardNamingConvention compatibility -- + + #[test] + fn standard_convention_parses_signed_method_fqn() { + let conv = StandardNamingConvention; + let parts = conv.parse_fqn("pkg.Owner#foo(T1)", None); + assert_eq!(parts.len(), 3); + assert_eq!(parts[0], (NodeKind::Package, "pkg".to_string())); + assert_eq!(parts[1], (NodeKind::Class, "Owner".to_string())); + assert_eq!(parts[2], (NodeKind::Method, "foo(T1)".to_string())); + } + + #[test] + fn two_overloads_produce_distinct_fqn_paths() { + let conv = StandardNamingConvention; + let p1 = conv.parse_fqn("pkg.Owner#foo(T1)", None); + let p2 = conv.parse_fqn("pkg.Owner#foo(T1,T2)", None); + assert_ne!(p1.last(), p2.last()); + } +} diff --git a/crates/plugin/src/plugin.rs b/crates/plugin/src/plugin.rs deleted file mode 100644 index a285982..0000000 --- a/crates/plugin/src/plugin.rs +++ /dev/null @@ -1,105 +0,0 @@ -use crate::interner::StorageContext; -use crate::model::{BuildParseResult, GlobalParseResult}; -use crate::naming::NamingConvention; -use crate::resolver::{BuildResolver, LangResolver, LspParser, SemanticResolver}; -use naviscope_api::models::graph::{DisplayGraphNode, GraphNode, NodeMetadata}; -use naviscope_api::models::symbol::FqnReader; -use std::path::Path; -use std::sync::Arc; - -/// Metadata for a plugin (plugin's own information). -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct PluginInfo { - pub id: String, - pub name: String, - pub version: String, - pub description: Option, -} - -/// Unified plugin handle according to V2 architecture. -pub struct PluginHandle { - pub metadata: PluginInfo, - pub instance: Arc, -} - -/// The core trait that all plugins must implement. -/// It uses "capability discovery" instead of "fat interface inheritance". -pub trait PluginInstance: Send + Sync { - /// Get the naming convention for this plugin (if any). - fn get_naming_convention(&self) -> Option> { - None - } - - /// Get the node adapter for this plugin (if any). - /// The node adapter handles both display rendering and metadata serialization. - fn get_node_adapter(&self) -> Option> { - None - } -} - -/// Unified interface for language-specific node processing. -/// Handles both display rendering and metadata serialization. -pub trait NodeAdapter: Send + Sync { - // === Presentation Layer (Display) === - - /// Convert internal GraphNode to DisplayGraphNode with full information. - fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode; - - // === Storage Layer (Serialization) === - - /// Serialize metadata for storage. - fn encode_metadata( - &self, - _metadata: &dyn NodeMetadata, - _ctx: &mut dyn StorageContext, - ) -> Vec { - // Default: no metadata to store - Vec::new() - } - - /// Deserialize metadata from storage. - fn decode_metadata(&self, bytes: &[u8], ctx: &dyn StorageContext) -> Arc; -} - -/// Unified interface for language-specific support. -pub trait LanguagePlugin: PluginInstance + Send + Sync { - /// Plugin name, e.g., Language::JAVA - fn name(&self) -> naviscope_api::models::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, - ) -> Result>; - - /// Get the semantic resolver for this language - fn resolver(&self) -> Arc; - - /// Get the language-level resolver for graph builds - 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: PluginInstance + Send + Sync { - /// Plugin name, e.g., BuildTool::GRADLE - fn name(&self) -> naviscope_api::models::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, - ) -> Result>; - - /// Get the build resolver - fn build_resolver(&self) -> Arc; -} diff --git a/crates/plugin/src/registration.rs b/crates/plugin/src/registration.rs new file mode 100644 index 0000000..6b38d1b --- /dev/null +++ b/crates/plugin/src/registration.rs @@ -0,0 +1,29 @@ +use crate::cap::{ + AssetCap, BuildIndexCap, BuildParseCap, FileMatcherCap, LanguageParseCap, MetadataCodecCap, + PresentationCap, SemanticCap, SourceIndexCap, +}; +use naviscope_api::models::{BuildTool, Language}; +use std::sync::Arc; + +#[derive(Clone)] +pub struct LanguageCaps { + pub language: Language, + pub matcher: Arc, + pub parser: Arc, + pub semantic: Arc, + pub indexing: Arc, + pub asset: Arc, + pub presentation: Arc, + pub metadata_codec: Arc, +} + +#[derive(Clone)] +pub struct BuildCaps { + pub build_tool: BuildTool, + pub matcher: Arc, + pub parser: Arc, + pub indexing: Arc, + pub asset: Arc, + pub presentation: Arc, + pub metadata_codec: Arc, +} diff --git a/crates/plugin/src/resolver.rs b/crates/plugin/src/resolver.rs deleted file mode 100644 index 54e9973..0000000 --- a/crates/plugin/src/resolver.rs +++ /dev/null @@ -1,124 +0,0 @@ -use crate::graph::{CodeGraph, ResolvedUnit}; -use crate::model::ParsedFile; -use naviscope_api::models::graph::DisplayGraphNode; -use naviscope_api::models::graph::NodeKind; -use naviscope_api::models::symbol::{FqnId, Range}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use tree_sitter::Tree; - -/// 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: &dyn CodeGraph, - ) -> Option; - - /// Find nodes in the global graph matching a resolution result (global view) - fn find_matches( - &self, - index: &dyn CodeGraph, - res: &naviscope_api::models::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: &dyn CodeGraph, - res: &naviscope_api::models::SymbolResolution, - ) -> Vec; - - /// Find implementations or overrides of a symbol (global view) - fn find_implementations( - &self, - index: &dyn CodeGraph, - res: &naviscope_api::models::SymbolResolution, - ) -> Vec; -} - -pub trait LspParser: Send + Sync { - fn parse(&self, source: &str, old_tree: Option<&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. - fn find_occurrences( - &self, - source: &str, - tree: &Tree, - target: &naviscope_api::models::SymbolResolution, - ) -> Vec; -} - -/// Project context generated by BuildResolver during the first phase -#[derive(Debug, Clone)] -pub struct ProjectContext { - /// Mapping from path prefixes to module IDs (e.g., "/project/app" -> "module::app") - pub path_to_module: HashMap, -} - -impl ProjectContext { - pub fn new() -> Self { - Self { - path_to_module: HashMap::new(), - } - } - - /// Finds the best matching module ID for a given file path - pub fn find_module_for_path(&self, path: &Path) -> Option { - let mut current = path.to_path_buf(); - while let Some(parent) = current.parent() { - if let Some(id) = self.path_to_module.get(parent) { - return Some(id.clone()); - } - current = parent.to_path_buf(); - } - None - } -} - -/// Responsible for resolution logic at the build tool level -pub trait BuildResolver: Send + Sync { - /// Resolves build files into graph operations and project context - fn resolve( - &self, - files: &[&ParsedFile], - ) -> Result<(ResolvedUnit, ProjectContext), Box>; -} - -/// Responsible for resolution logic at the language level -pub trait LangResolver: Send + Sync { - /// Resolves source files into graph operations using the provided context - fn resolve( - &self, - file: &ParsedFile, - context: &ProjectContext, - ) -> Result>; -} - -/// A generic trait for semantic scopes in any programming language. -/// `C` represents the language-specific resolution context. -pub trait SemanticScope: Send + Sync { - /// Resolve a name within this specific scope. - /// Returns: - /// - `Some(Ok(res))` if the symbol is found. - /// - `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/crates/plugin/src/typing.rs b/crates/plugin/src/typing.rs new file mode 100644 index 0000000..8c01d1e --- /dev/null +++ b/crates/plugin/src/typing.rs @@ -0,0 +1,61 @@ +use crate::cap::ReferenceCheckService; +use crate::graph::{CodeGraph, Direction}; +use naviscope_api::models::SymbolResolution; +use naviscope_api::models::graph::EdgeType; + +pub struct NoOpReferenceCheckService; + +impl ReferenceCheckService for NoOpReferenceCheckService { + fn is_reference_to( + &self, + _graph: &dyn CodeGraph, + candidate: &SymbolResolution, + target: &SymbolResolution, + ) -> bool { + candidate == target + } + + fn is_subtype(&self, graph: &dyn CodeGraph, sub: &str, sup: &str) -> bool { + if sub == sup { + return true; + } + + let sub_ids = graph.resolve_fqn(sub); + let sup_ids = graph.resolve_fqn(sup); + if sub_ids.is_empty() || sup_ids.is_empty() { + return false; + } + + use std::collections::{HashSet, VecDeque}; + for &sub_id in &sub_ids { + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(sub_id); + visited.insert(sub_id); + + while let Some(current) = queue.pop_front() { + if sup_ids.contains(¤t) { + return true; + } + + let parents = + graph.get_neighbors(current, Direction::Outgoing, Some(EdgeType::InheritsFrom)); + for parent in parents { + if visited.insert(parent) { + queue.push_back(parent); + } + } + + let interfaces = + graph.get_neighbors(current, Direction::Outgoing, Some(EdgeType::Implements)); + for interface in interfaces { + if visited.insert(interface) { + queue.push_back(interface); + } + } + } + } + + false + } +} diff --git a/crates/plugin/src/utils.rs b/crates/plugin/src/utils.rs index 858d2bc..690d723 100644 --- a/crates/plugin/src/utils.rs +++ b/crates/plugin/src/utils.rs @@ -1,4 +1,4 @@ -use naviscope_api::models::graph::{DisplayGraphNode, DisplaySymbolLocation, NodeKind}; +use naviscope_api::models::graph::{DisplayGraphNode, DisplaySymbolLocation, NodeKind, NodeSource}; use naviscope_api::models::symbol::Range; use tree_sitter::{Language, Node, Query}; @@ -53,6 +53,8 @@ pub fn build_symbol_hierarchy(raw_symbols: Vec) -> Vec Arc { - let mut engine = naviscope_core::runtime::orchestrator::NaviscopeEngine::new(path); + let mut builder = naviscope_core::runtime::NaviscopeEngine::builder(path); - // Register Build Tool Plugins - engine.register_build_tool(Arc::new(naviscope_gradle::GradlePlugin::new())); + // Register Build Tool Caps + builder = builder.with_build_caps(naviscope_gradle::gradle_caps()); - // 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), - } + // Register Language Caps + builder = match naviscope_java::java_caps() { + Ok(caps) => builder.with_language_caps(caps), + Err(e) => { + tracing::error!("Failed to load Java plugin: {}", e); + builder + } + }; + + let engine = builder.build(); // Wrap in the standard EngineHandle which implements all API traits Arc::new(naviscope_core::facade::EngineHandle::from_engine(Arc::new( @@ -32,10 +37,12 @@ pub fn init_logging(component: &str, to_stderr: bool) -> Option { } /// 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()) - }, - ) +pub fn clear_all_indices() -> ApiResult<()> { + naviscope_core::runtime::NaviscopeEngine::clear_all_indices() + .map_err(|e: naviscope_core::error::NaviscopeError| ApiError::Internal(e.to_string())) +} + +/// Get the global stub cache manager. +pub fn get_cache_manager() -> std::sync::Arc { + std::sync::Arc::new(naviscope_core::cache::GlobalStubCache::at_default_location()) } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..67f00bd --- /dev/null +++ b/docs/README.md @@ -0,0 +1,25 @@ +# Naviscope Docs + +This directory contains structured documentation outlines for the whole project. + +## Suggested Reading Order +1. `docs/vision/product-overview.md` +2. `docs/architecture/system-overview.md` +3. `docs/architecture/crate-map.md` +4. `docs/architecture/code-graph.md` +5. `docs/architecture/indexing-pipeline.md` +6. `docs/architecture/reference-discovery.md` +7. `docs/design/classpath-three-tier.md` +8. `docs/design/asset-scanner.md` +9. `docs/design/stub-cache.md` +10. `docs/design/source-resolution.md` +11. `docs/runtime/orchestrator.md` +12. `docs/runtime/services.md` +13. `docs/plugins/contracts.md` +14. `docs/language/java.md` +15. `docs/build-tools/gradle.md` +16. `docs/interfaces/cli.md` +17. `docs/interfaces/lsp.md` +18. `docs/interfaces/mcp.md` +19. `docs/storage/persistence.md` +20. `docs/plans/README.md` \ No newline at end of file diff --git a/docs/architecture/code-graph.md b/docs/architecture/code-graph.md new file mode 100644 index 0000000..51eb418 --- /dev/null +++ b/docs/architecture/code-graph.md @@ -0,0 +1,37 @@ +# Code Graph + +## What It Represents +The graph is the unified representation of code semantics. Every symbol is a node, and relationships between symbols are edges. It is the single source of truth for queries across CLI, LSP, and MCP. + +## Node Model +Nodes include packages, types, fields, methods, and variables. Each node has: +- A stable `NodeId` +- A `NodeKind` +- Metadata produced by language plugins +- A source classification (project, external, builtin) + +## Edge Types +- Structural containment (package -> class -> method) +- Inheritance and implementation +- Type usage and annotations +- Build system relationships + +## Indexing and Queries +```mermaid +flowchart TD + Parse[Parse Source] --> Build[Build Nodes and Edges] + Build --> Persist[Persist Graph] + Query[Query Request] --> Resolve[Resolve Context] + Resolve --> Graph[Graph Search] + Graph --> Result[Return Results] +``` + +## Consistency and Versioning +- `NodeId` must be stable across source and bytecode. +- Metadata is versioned for forward compatibility. +- External asset changes invalidate cached stubs. + +## Operational Notes +- The graph is language-agnostic. +- Plugins are responsible for semantic accuracy. +- External symbols are first-class nodes. diff --git a/docs/architecture/crate-map.md b/docs/architecture/crate-map.md new file mode 100644 index 0000000..1bc2078 --- /dev/null +++ b/docs/architecture/crate-map.md @@ -0,0 +1,51 @@ +# Crate Map + +## Layered Structure +```mermaid +graph TD + API[naviscope-api] + Plugin[naviscope-plugin] + Core[naviscope-core] + Runtime[naviscope-runtime] + Java[naviscope-java] + Gradle[naviscope-gradle] + CLI[naviscope-cli] + LSP[naviscope-lsp] + MCP[naviscope-mcp] + + CLI --> Runtime + LSP --> Runtime + MCP --> Runtime + + Runtime --> Core + Runtime --> Java + Runtime --> Gradle + Runtime --> Plugin + + Java --> Plugin + Gradle --> Plugin + Core --> Plugin + + Plugin --> API + Core --> API + Runtime --> API + Java --> API + Gradle --> API + CLI --> API + LSP --> API + MCP --> API +``` + +## What Each Layer Provides +- **API**: shared models plus engine service traits (`GraphService`, `NavigationService`, semantic traits, `EngineLifecycle`) and the composite `NaviscopeEngine`. +- **Plugin**: capability traits (`*Cap` + runtime semantic services) for language/build integrations; keeps Core independent. +- **Core**: graph storage, indexing, persistence, and asset services. +- **Runtime**: orchestration, lifecycle, background tasks, and query serving. +- **Language/Build**: concrete strategies (Java parsing, Gradle structure resolution). +- **Interfaces**: CLI/LSP/MCP entry points that expose the same graph. + +## Flow Through Crates +1. Interfaces call Runtime to build an index or run a query. +2. Runtime invokes language/build plugins to parse and resolve structure. +3. Plugins emit nodes/edges into Core Graph. +4. Runtime serves queries from Core Graph and coordinates background stubbing. diff --git a/docs/architecture/indexing-pipeline.md b/docs/architecture/indexing-pipeline.md new file mode 100644 index 0000000..d3ebc90 --- /dev/null +++ b/docs/architecture/indexing-pipeline.md @@ -0,0 +1,34 @@ +# Indexing Pipeline + +## Inputs and Parsing +- Language plugins discover and parse source files. +- Build-tool plugins resolve project/module structure. +- Parsing produces symbol definitions and references. + +## Graph Construction Flow +```mermaid +sequenceDiagram + participant RT as Runtime + participant LG as Language Plugin + participant CG as Core Graph + + RT->>LG: parse files + LG->>CG: emit nodes + LG->>CG: emit edges + RT->>CG: finalize graph +``` + +## Incremental Updates +- File change detection triggers partial re-index. +- Only affected nodes and edges are rebuilt. +- External caches remain stable between runs. + +## Concurrency and Performance +- Parsing and indexing are parallelized. +- Asset scanning runs in the background. +- Queries read from the current graph state without waiting for stubs. + +## Fault Tolerance +- Partial indexing is allowed. +- Errors are isolated per file. +- The system returns best-effort results. diff --git a/docs/architecture/reference-discovery.md b/docs/architecture/reference-discovery.md new file mode 100644 index 0000000..6fb063b --- /dev/null +++ b/docs/architecture/reference-discovery.md @@ -0,0 +1,24 @@ +# Reference Discovery + +## Two-Phase Strategy +```mermaid +flowchart LR + Tokens[Token Index] --> Candidates[Candidate Files] + Candidates --> Parse[Syntax Parse] + Parse --> Matches[Verified References] +``` + +## Meso-Level Filtering +- Build a token-to-file inverted index. +- Cheap coarse filtering reduces search space. +- Keeps reference queries fast on large repos. + +## Micro-Level Validation +- Tree-sitter parsing for precise matches. +- Symbol-aware filtering by scope and type. +- Avoids false positives from raw text search. + +## Practical Flow +1. Token index returns a candidate file set. +2. Each candidate is parsed for real symbol usage. +3. Verified results are returned to the caller. diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md new file mode 100644 index 0000000..f393b64 --- /dev/null +++ b/docs/architecture/system-overview.md @@ -0,0 +1,68 @@ +# System Overview + +## Purpose +Naviscope builds a unified code knowledge graph that serves both AI agents (MCP) and developer tools (LSP/CLI). The system is designed for fast startup, incremental enrichment, and consistent semantics across interfaces. + +## High-Level Architecture +```mermaid +graph TD + subgraph Interfaces + CLI[CLI] --> Runtime + LSP[LSP Server] --> Runtime + MCP[MCP Server] --> Runtime + end + + subgraph Runtime + Runtime[Naviscope Engine / Orchestrator] + end + + subgraph Language + Java[Java Strategy] + Gradle[Gradle Strategy] + end + + subgraph Core + CoreGraph[Core Graph + Indexing] + Asset[Asset/Stub Service] + end + + subgraph Plugins + Contracts[Plugin Contracts] + end + + Runtime --> CoreGraph + Runtime --> Language + Language --> Contracts + CoreGraph --> Contracts + Runtime --> Asset +``` + +## Main Flow (Index + Query) +```mermaid +sequenceDiagram + participant UI as Interfaces + participant RT as Runtime Orchestrator + participant LG as Language Plugins + participant CG as Core Graph + participant AS as Asset/Stub Service + + UI->>RT: index/project open + RT->>LG: parse + resolve modules + LG->>CG: emit nodes/edges + RT->>AS: scan assets (async) + AS-->>CG: enrich external nodes (stubs) + UI->>RT: query (find/ls/cat) + RT->>CG: graph query + CG-->>UI: results +``` + +## How to Read the System +- The **Runtime Orchestrator** is the control plane: it wires plugins, triggers indexing, and serves queries. +- The **Core Graph** is the single source of truth: all interfaces read from it. +- The **Asset/Stub Service** enriches external symbols without blocking startup. +- **Plugins** supply language/build-tool-specific logic; Core stays language-agnostic. + +## Key Design Expectations +- Indexing should be usable even if external dependencies are still scanning. +- External symbols must be represented as first-class nodes, not special cases. +- The same symbol must have a stable `NodeId` across source and bytecode. diff --git a/docs/build-tools/gradle.md b/docs/build-tools/gradle.md new file mode 100644 index 0000000..fa0a73f --- /dev/null +++ b/docs/build-tools/gradle.md @@ -0,0 +1,29 @@ +# Gradle Integration + +## Purpose +Gradle integration provides project structure and dependency metadata for indexing without relying on executing Gradle builds. + +## Scope and Boundaries +- Resolve modules and project structure +- Delegate external asset discovery to global scanners +- Avoid heavy Gradle invocation during indexing + +## Module Resolution +```mermaid +sequenceDiagram + participant GR as Gradle Plugin + participant RT as Runtime + participant CG as Core Graph + + RT->>GR: resolve project + GR->>CG: emit modules + CG-->>RT: project context +``` + +## Dependency Extraction +- Read Gradle metadata when available +- Prefer static configuration to avoid execution + +## Edge Cases +- Composite builds +- Custom configurations diff --git a/docs/design/asset-scanner.md b/docs/design/asset-scanner.md new file mode 100644 index 0000000..0e2d306 --- /dev/null +++ b/docs/design/asset-scanner.md @@ -0,0 +1,31 @@ +# Asset Scanner + +## Purpose +The asset scanner discovers external libraries (JAR/JMOD/class files) and builds package-prefix routes used by resolvers and stub generators. + +## Core Flow +```mermaid +flowchart TD + D[AssetDiscoverer] --> A[AssetEntry] + A --> I[AssetIndexer] + I --> R[Route Registry] +``` + +## Responsibilities +- Find assets from multiple sources (JDK, Gradle, Maven, local) +- Index assets to extract package prefixes +- Register routes for fast FQN lookup + +## Route Registry Behavior +- Prefix to asset mapping +- Multiple candidates per prefix (split packages) +- Source metadata for debugging and conflict resolution + +## Streaming Scan and Memory Model +- Discoverers yield assets as iterators +- Registry is updated incrementally +- No full asset list held in memory + +## Failure Handling +- Skip unreadable assets +- Keep partial routes and continue scanning diff --git a/docs/design/classpath-three-tier.md b/docs/design/classpath-three-tier.md new file mode 100644 index 0000000..ef49850 --- /dev/null +++ b/docs/design/classpath-three-tier.md @@ -0,0 +1,30 @@ +# Classpath Three-Tier Design + +## Goal +Balance instant usability with deep understanding of external dependencies by layering the resolution strategy. + +## Three Tiers +```mermaid +flowchart TB + T1[Shallow Index] --> T2[Background Stub] + T2 --> T3[On-Demand Source] +``` + +## What Each Tier Does +- **Shallow Index**: project source is indexed immediately; external symbols become placeholders. +- **Background Stub**: bytecode is parsed to enrich external placeholders. +- **On-Demand Source**: `*-sources.jar` is parsed when a user requests source. + +## Build vs Query Timeline +- Build: project graph + external placeholders +- Runtime: background stubs fill metadata +- Query: source resolution for precise navigation + +## Contracts +- Placeholder nodes must be upgradable. +- `NodeId` must be identical across source and bytecode. +- Cache invalidates on asset changes. + +## Practical Notes +- Prioritize referenced external symbols. +- Never block initial navigation on external scans. diff --git a/docs/design/source-resolution.md b/docs/design/source-resolution.md new file mode 100644 index 0000000..a7afafe --- /dev/null +++ b/docs/design/source-resolution.md @@ -0,0 +1,21 @@ +# Source Resolution + +## Purpose +Source resolution attaches real source code to external symbols at query time, improving navigation and documentation visibility. + +## Flow +```mermaid +flowchart TD + Query[Go to Definition] --> Lookup[Locate sources.jar] + Lookup --> Parse[Parse Source] + Parse --> Return[Return Rich Source] +``` + +## Relationship to Stubs +- Stubs provide structure and fast navigation +- Source parsing adds comments and full body +- Fallback to stubs when sources are missing + +## UX Notes +- On-demand only to avoid startup cost +- Clear messaging when sources are unavailable diff --git a/docs/design/stub-cache.md b/docs/design/stub-cache.md new file mode 100644 index 0000000..e8729f3 --- /dev/null +++ b/docs/design/stub-cache.md @@ -0,0 +1,23 @@ +# Stub Cache + +## Purpose +The stub cache stores parsed external symbol metadata so that repeated projects can reuse the same external knowledge without re-parsing bytecode. + +## Flow +```mermaid +flowchart LR + Asset[Asset File] --> Hash[Fingerprint] + Hash --> Cache[Stub Cache] + Cache --> Load[Load Stubs] + Asset --> Change{Asset Changed?} + Change -- yes --> Invalidate[Invalidate Cache] +``` + +## Key Behaviors +- Cache key is asset fingerprint +- Cached payload is compressed and versioned +- Stubs are shared across projects + +## Invalidation +- Any asset file change invalidates its cache +- Versioned metadata enables safe migrations diff --git a/docs/interfaces/cli.md b/docs/interfaces/cli.md new file mode 100644 index 0000000..6e48503 --- /dev/null +++ b/docs/interfaces/cli.md @@ -0,0 +1,20 @@ +# CLI Interface + +## Purpose +The CLI provides direct access to indexing and graph exploration for developers and agents in terminal workflows. + +## Commands and Flags +- Index, shell, watch, clear +- Cache operations and diagnostics + +## Common Flow +```mermaid +flowchart LR + Start[Project Path] --> Index[Index] + Index --> Shell[Interactive Shell] + Shell --> Query[Query Graph] +``` + +## Output Formats +- Human-readable tables +- Machine-friendly JSON where applicable diff --git a/docs/interfaces/lsp.md b/docs/interfaces/lsp.md new file mode 100644 index 0000000..bed44b1 --- /dev/null +++ b/docs/interfaces/lsp.md @@ -0,0 +1,27 @@ +# LSP Interface + +## Purpose +LSP exposes navigation and insight features to editors using the same graph as CLI/MCP. + +## Supported Capabilities +- Definition, references, implementation +- Hover, symbols, hierarchy + +## Request/Response Mapping +```mermaid +sequenceDiagram + participant Client + participant LSP + participant Runtime + participant Graph + + Client->>LSP: textDocument/definition + LSP->>Runtime: resolve symbol + Runtime->>Graph: query + Graph-->>LSP: results + LSP-->>Client: locations +``` + +## Performance Considerations +- Avoid full re-index on open +- Serve from graph cache first diff --git a/docs/interfaces/mcp.md b/docs/interfaces/mcp.md new file mode 100644 index 0000000..b54a9a3 --- /dev/null +++ b/docs/interfaces/mcp.md @@ -0,0 +1,25 @@ +# MCP Interface + +## Purpose +MCP exposes graph queries to AI agents via a stable tool surface. It is optimized for structured, precise context delivery. + +## Tooling Surface +- `get_guide` +- `ls`, `find`, `cat`, `deps` + +## Usage Flow +```mermaid +sequenceDiagram + participant Agent + participant MCP + participant Runtime + + Agent->>MCP: find "Symbol" + MCP->>Runtime: query graph + Runtime-->>MCP: results + MCP-->>Agent: structured response +``` + +## Safety and Limits +- Read-only by default +- Resource limits per request diff --git a/docs/language/java.md b/docs/language/java.md new file mode 100644 index 0000000..e9d875c --- /dev/null +++ b/docs/language/java.md @@ -0,0 +1,24 @@ +# Java Language Strategy + +## Parsing +- Tree-sitter based source parsing +- Package and type extraction +- Identifier indexing for reference discovery + +## Bytecode Stubs +```mermaid +flowchart TD + ClassFile[.class] --> Parse[Bytecode Parser] + Parse --> Stub[IndexNode Stub] + Stub --> Graph[Graph Update] +``` + +## Inheritance and Type System +- Class and interface relations +- Field and method signatures +- Access modifiers and annotations + +## Edge Cases +- Inner classes +- Generics and annotations +- Split packages across multiple JARs diff --git a/docs/plugins/contracts.md b/docs/plugins/contracts.md new file mode 100644 index 0000000..f60d6b4 --- /dev/null +++ b/docs/plugins/contracts.md @@ -0,0 +1,53 @@ +# Plugin Contracts + +## Purpose +`naviscope-plugin` defines capability-oriented contracts that let language/build integrations plug into Core without hard dependencies on concrete crates. + +## Capability Groups + +### 1. File Matching and Parsing +- `FileMatcherCap`: decides whether a plugin handles a path. +- `LanguageParseCap`: parses language source files. +- `BuildParseCap`: parses build-tool files. + +### 2. Index Construction +- `SourceIndexCap`: compiles one source file into `ResolvedUnit`. +- `BuildIndexCap`: compiles build files into `(ResolvedUnit, ProjectContext)`. +- `ProjectContext`: shared indexing context (for module/path mappings and cross-file coordination). + +### 3. Runtime Semantics +- `SymbolResolveService`: resolve symbol at position. +- `SymbolQueryService`: match symbols, infer types, find implementations. +- `LspSyntaxService`: incremental parse, symbol extraction, occurrence discovery. +- `ReferenceCheckService`: semantic reference/subtype checks. +- `SemanticCap`: composite runtime semantic contract above. + +### 4. Asset and Stub Integration +- `AssetCap`: optional entry for: + - asset discoverer + - asset indexer + - source locator + - stub generator + +### 5. Presentation and Metadata +- `PresentationCap`: naming convention, node presentation, symbol kind mapping. +- `MetadataCodecCap`: pluggable metadata encode/decode strategy. + +## Registration Shape +Language crates register capabilities via `LanguageCaps`, selectively wiring only the capabilities they support. Missing capabilities should degrade gracefully through trait defaults. + +## Runtime Flow +```mermaid +flowchart LR + Match[FileMatcherCap] --> Parse[LanguageParseCap / BuildParseCap] + Parse --> Index[SourceIndexCap / BuildIndexCap] + Index --> Graph[Core Graph Updates] + Graph --> Runtime[SemanticCap Services] + Asset[AssetCap] --> Graph + Present[PresentationCap + MetadataCodecCap] --> Runtime +``` + +## Compatibility Notes +- Prefer additive trait evolution (new default methods / new optional caps). +- Keep metadata codecs backward-compatible by versioning serialized payloads. +- Keep capability boundaries stable so runtime wiring remains predictable. diff --git a/docs/runtime/orchestrator.md b/docs/runtime/orchestrator.md new file mode 100644 index 0000000..cadc264 --- /dev/null +++ b/docs/runtime/orchestrator.md @@ -0,0 +1,24 @@ +# Runtime Orchestrator + +## Purpose +The orchestrator wires plugins, builds the graph, runs background tasks, and serves queries. It is the control plane for the engine. + +## Assembly Flow +```mermaid +flowchart TD + Runtime[Orchestrator] --> Plugins[Collect Plugins] + Runtime --> Core[Create Core Graph] + Runtime --> Asset[Start Asset Service] + Runtime --> Interfaces[Expose CLI/LSP/MCP] +``` + +## Lifecycle +- Build project graph first +- Start asset scanning in the background +- Serve queries immediately +- Upgrade placeholders as stubs arrive + +## Injection Points +- AssetStubService +- Language plugin registry +- Cache managers diff --git a/docs/runtime/services.md b/docs/runtime/services.md new file mode 100644 index 0000000..0ce4b47 --- /dev/null +++ b/docs/runtime/services.md @@ -0,0 +1,19 @@ +# Runtime Services + +## AssetStubService +- Coordinates discovery, indexing, and stub requests +- Provides route lookup for external symbols +- Emits stub results to graph updates + +## Indexing Service +- Triggers parsing and graph build +- Manages incremental updates +- Handles error isolation per file + +## Cache Service +- Handles global stub cache +- Supports cache statistics and inspection + +## Other Runtime Services +- File watching +- Diagnostics and logging diff --git a/docs/storage/persistence.md b/docs/storage/persistence.md new file mode 100644 index 0000000..94f13bf --- /dev/null +++ b/docs/storage/persistence.md @@ -0,0 +1,22 @@ +# Storage and Persistence + +## Purpose +Persistence enables fast restarts and cross-session reuse of graph data and external stubs. + +## On-Disk Layout +- Project index storage +- Global stub cache + +## Formats and Versioning +```mermaid +flowchart LR + Data[Index Data] --> Encode[Encode] + Encode --> Store[Store] + Store --> Load[Load] + Load --> Decode[Decode] +``` + +## Migration Strategy +- Versioned metadata for safe upgrades +- Fallback read for older formats +- Progressive migrations where possible diff --git a/docs/vision/product-overview.md b/docs/vision/product-overview.md new file mode 100644 index 0000000..0bd8b57 --- /dev/null +++ b/docs/vision/product-overview.md @@ -0,0 +1,24 @@ +# Product Overview + +## Positioning +Naviscope is a unified code knowledge graph engine that serves both AI agents and developer tools with the same structural understanding of a codebase. It sits between language-specific parsers and user-facing interfaces, providing consistent semantics and fast queries. + +## Core Value +- One graph shared by CLI, LSP, and MCP +- Fast startup with incremental enrichment +- Stable identifiers for long-lived references +- External dependency understanding without blocking indexing + +## Target Users +- Developers who need reliable navigation and dependency analysis +- AI agents that require structured, precise context for reasoning + +## Key Scenarios +- Search and inspect symbols across a large codebase +- Understand type hierarchies and relationships quickly +- Navigate external dependencies with stubs and on-demand sources + +## Differentiation +- Graph-first model instead of text-only search +- Background stubbing for external assets +- Shared caches across projects to reduce cold start cost diff --git a/type_system_integration_plan.md b/type_system_integration_plan.md new file mode 100644 index 0000000..a9643b8 --- /dev/null +++ b/type_system_integration_plan.md @@ -0,0 +1,95 @@ +# Type System 架构深度剥离与接入方案 (v2) + +## 1. 核心哲学:语义解耦 + +我们不应将 Type System 视为一个额外的“功能模块”,而应将其视为**插件的语义大脑**。 +* `LspService`: 负责**语法与局部上下文**(提取符号、查找词法引用)。 +* `SemanticResolver`: 负责**图查询与全局映射**(查找定义、实现、解析 FQN)。 +* `TypeSystem` (新): 负责**逻辑判断与关系推理**(子类型、重写、匹配验证)。 + +## 2. 插件层 (naviscope_plugin) 调整 + +在 `naviscope_plugin` 中确立 `TypeSystem` 的地位,并让其他 Service 能够引用它。 + +### 2.1 定义独立 Trait +```rust +// crates/plugin/src/type_system.rs + +pub trait TypeSystem: Send + Sync { + /// 核心判断逻辑:在给定的图上下文中,candidate 是否是 target 的一个有效语义引用? + /// 这封装了继承、匹配、重写等所有复杂逻辑。 + fn is_reference_to( + &self, + graph: &dyn CodeGraph, + candidate: &SymbolResolution, + target: &SymbolResolution, + ) -> bool; + + /// 获取类型层级关系 + fn is_subtype(&self, graph: &dyn CodeGraph, sub: &str, sup: &str) -> bool; +} +``` + +### 2.2 统一生命周期 +语言插件(如 `JavaPlugin`)不再零散地创建服务,而是统一初始化: +1. 初始化 `LanguageTypeSystem`。 +2. 将该 `TypeSystem` 分别注入给 `LspService` 和 `SemanticResolver`。 + +```rust +// 插件接口更新 +pub trait LanguagePlugin: Send + Sync { + fn type_system(&self) -> Arc; + fn lsp_service(&self) -> Arc; // 内部持有 type_system + fn semantic_resolver(&self) -> Arc; // 内部持有 type_system +} +``` + +## 3. Java 插件的实现微调 (lang-java) + +通过“依赖注入”消除重复逻辑: +* **JavaTypeSystem**: 包装 `crates/lang-java/src/inference` 中的核心推理引擎。 +* **JavaLspService**: 在执行 `find_occurrences` 时,利用注入的 `TypeSystem` 进行局部验证。 +* **JavaResolver**: 在执行 `resolve_at` 时,利用注入的 `TypeSystem` 关联图节点。 + +## 4. 核心引擎 (crates/core) 的极简调用 + +`DiscoveryEngine` 的 `scan_file` 逻辑将变得极其纯粹,它只负责流程编排,不持有任何判断逻辑: + +```rust +// crates/core/src/features/discovery.rs + +pub fn scan_file( + &self, + lsp_service: &dyn LspService, + type_system: &dyn TypeSystem, // 新增参数 + resolver: &dyn SemanticResolver, + source: &str, + target_resolution: &SymbolResolution, +) -> Vec { + // 1. 委托 LspService 进行快速语法扫描 + let candidates = lsp_service.find_occurrences(source, &tree, target_resolution); + + // 2. 委托 Resolver 进行实时点位解析 + for range in candidates { + if let Some(resolved) = resolver.resolve_at(...) { + // 3. 委托 TypeSystem 进行最终的语义身份验证 + if type_system.is_reference_to(self.index, &resolved, target_resolution) { + valid_locations.push(...); + } + } + } +} +``` + +## 5. 方案优势 + +1. **实现不重复**:复杂的 Java 类型逻辑(如 Bridge Method, Generics)只在 `JavaTypeSystem` 中写一次。 +2. **职责明确**:`LspService` 不再需要理解什么是“子类方法”,它只管找“在这个作用域内叫这个名字的符号”。 +3. **可测试性**:可以给 `scan_file` 注入一个 `MockTypeSystem` 来测试核心流程。 + +## 6. 改造清单 + +- [ ] **naviscope-plugin**: 新增 `TypeSystem` trait,并将其加入 `LanguagePlugin` 初始化流。 +- [ ] **lang-java**: 将现有的 `inference` 代码包装为 `TypeSystem` 实现。 +- [ ] **lang-java**: 重构 `JavaLspService` 和 `JavaResolver`,使其通过构造函数接收 `TypeSystem`。 +- [ ] **core**: 在获取服务时,同步获取 `TypeSystem` 并传递给 `DiscoveryEngine`。